diff --git a/.github/workflows/build-linux.yml b/.github/workflows/build-linux.yml index 32bca21ca1..a0fa116852 100644 --- a/.github/workflows/build-linux.yml +++ b/.github/workflows/build-linux.yml @@ -131,6 +131,14 @@ jobs: echo "::error::add window actions with add_action_entries on the window, not a group inserted as win" exit 1 fi + - name: Tab ids in the tab index + # TabIndex maps a page to its tab. qdata on a page is unsafe to + # read and outlives the tab it names. + run: | + if grep -rn 'set_qdata' crates/app/src; then + echo "::error::map a tab page to its tab through TabIndex, not set_qdata" + 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 @@ -139,6 +147,7 @@ jobs: ../scripts/check-doc-symbols.sh \ linux/ARCHITECTURE.md \ linux/docs/state-management.md \ + linux/docs/storage.md \ linux/docs/testing.md \ linux/docs/decisions/0012-window-per-session.md - name: Clippy @@ -149,6 +158,16 @@ jobs: run: meson compile -C _build - name: Tests run: xvfb-run -a dbus-run-session -- meson test -C _build --print-errorlogs + # Meson prints only the last 100 lines of a failed suite, which leaves + # out the names of the tests that failed. + - name: Keep the Meson test log + if: failure() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: meson-testlog + path: linux/_build/meson-logs/testlog.txt + if-no-files-found: ignore + retention-days: 7 - name: Secret Service tests run: | dbus-run-session -- bash -c "gnome-keyring-daemon --components=secrets --daemonize --unlock <<< ci \ @@ -156,6 +175,33 @@ jobs: - name: Dist run: meson dist -C _build --no-tests --formats xztar + licenses: + name: Third-party licenses + runs-on: ubuntu-24.04 + defaults: + run: + working-directory: linux + steps: + - uses: actions/checkout@v5 + with: + ref: ${{ github.event_name == 'schedule' && 'linux' || github.ref }} + - uses: dtolnay/rust-toolchain@ce678459e9fc7500d337468f904b95f1b5c10b5e # 1.98 + with: + toolchain: "1.98" + - uses: taiki-e/install-action@3f74d7c16a4242f1c95561e98edc25d36adb4375 # v2.87.12 + with: + tool: cargo-about@0.9.2 + - name: Regenerate the notices from Cargo.lock + run: build-aux/update-third-party-licenses.sh + - name: The committed notices cover every shipped crate + # A crate added to or bumped in Cargo.lock changes the regenerated + # files, and so does one whose license text changed. + run: | + if ! git diff --exit-code -- data/licenses; then + echo "::error::data/licenses does not match Cargo.lock: run linux/build-aux/update-third-party-licenses.sh and commit the result" + exit 1 + fi + i18n: name: Translations runs-on: ubuntu-24.04 diff --git a/linux/.config/nextest.toml b/linux/.config/nextest.toml index 12e8477b96..e63027822b 100644 --- a/linux/.config/nextest.toml +++ b/linux/.config/nextest.toml @@ -55,6 +55,13 @@ test-group = 'session' filter = 'package(tablepro) & kind(test)' test-group = 'app' +# These press real keys on the X display every test shares. A window +# another test presents takes the focus, and the keys with it, so each +# runs with no other test beside it. +[[profile.default.overrides]] +filter = 'package(tablepro) & test(/::real_keys::/)' +threads-required = 'num-test-threads' + [profile.ci] fail-fast = false # A hung test fails with its name after five minutes. Without a bound it diff --git a/linux/AGENTS.md b/linux/AGENTS.md index faf7b32efd..5721a65ac4 100644 --- a/linux/AGENTS.md +++ b/linux/AGENTS.md @@ -58,5 +58,6 @@ From the repository root, through the verification wrapper, which keeps the full - **Fast checks**: format, both clippy runs, build, every Meson suite, the Secret Service tests, `meson dist`. - **Translations**: the POT template, with gettext 0.24. +- **Third-party licenses**: reruns `build-aux/update-third-party-licenses.sh` and fails when `data/licenses` differs from what it writes, or when a crate has no license file cargo-about can read. - **Docker tests** (`integration`): one matrix job per package on the host runner, for the PostgreSQL, MySQL, SQL Server and ClickHouse drivers, `tablepro-ssh` and `tablepro-test-fixtures`. - **Docker tests (tablepro-session)** (`integration-session`) and **Docker tests (tablepro)** (`integration-app`): in the fast job's `ubuntu:26.04` image with the host's Docker socket, because the session crate links GLib 2.88 and the app GTK 4.22, which the host runner lacks. The app job runs only its named test targets, since the `startup` test needs Meson. diff --git a/linux/ARCHITECTURE.md b/linux/ARCHITECTURE.md index 4d0d9d520f..2493b7d960 100644 --- a/linux/ARCHITECTURE.md +++ b/linux/ARCHITECTURE.md @@ -8,8 +8,10 @@ TablePro Linux is a layered Rust workspace with strict, one-directional dependen linux/ ├── meson.build build entry point; owns install and the build-time config ├── Cargo.toml workspace manifest -├── data/ desktop entry, D-Bus service, AppStream metainfo, icons, GResource +├── about.toml cargo-about settings for the third-party license notices +├── data/ desktop entry, D-Bus service, AppStream metainfo, icons, GResource, licenses ├── build-aux/flatpak/ Devel Flatpak manifest and krb5.conf +├── build-aux/about/ templates that turn Cargo.lock into data/licenses ├── po/ translation catalogue └── crates/ ├── app/ binary, the GTK application, its windows and views @@ -88,14 +90,14 @@ Work crosses between the two through `crate::runtime`: `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`. -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. +One second runtime remains. Relm4 runs a component's commands (`sender.command`, `oneshot_command`) on a runtime of its own, built the first time one runs, with one worker since nothing sets `RELM_THREADS`. The saved-queries dialog still loads through commands, so that runtime starts when it opens. ## The application `TableProApplication` (`crates/app/src/application/`) is an `adw::Application` subclass. `run()` builds it with the `AppServices` every window shares: the driver registry, the connector and its deadlines, the `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. +- **Startup**, in the primary process only, adds the app actions (`new-window`, `quit`, `preferences`, `about`) and installs the application accelerators from the accelerator table, 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. @@ -110,24 +112,36 @@ Files come from Open With in a file manager or from the command line (`HANDLES_O ## 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. +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 picker's `WelcomeView` binds the one `SavedConnectionList` that `AppServices` builds, which follows the list the `ConnectionStore` publishes after each write, so a save, a connect's last-opened stamp, an edit or a delete in any window reaches every picker. A row's menu and its `picker` actions name the connection by id and read it from the list again when they run. 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 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 or an export runs. - The window's actions (`disconnect`, `reconnect`, `close-current` and the rest) go on its own action map, which GTK exports as `win`. +- The window's content is the page itself, never a wrapper, and is at least 360 by 294 pixels. Each page has its own header bars and its own `AdwToastOverlay`, and the window's toasts go to the page on screen. The main menu comes from `data/resources/gtk/menus.ui`, which `GtkApplication` loads at startup. -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. +The `Workspace` (`crates/app/src/ui/workspace/`) shows one session: the sidebar of its objects, the tab view and the banners. Its template puts the `AdwTabOverview` at the root, as libadwaita expects, around an `AdwBreakpointBin` with an `AdwOverlaySplitView`: the sidebar and the tabs each have their own header bar. Below 600sp the sidebar collapses into an overlay, and below 450sp an `AdwTabButton` takes the tab bar's place. The `show-sidebar` window action and F9 show and hide the sidebar. 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. Its `ExportLedger` writes exports off the GTK thread: the rows are encoded on the runtime and GIO writes them. An export takes the rows a grid holds, or every row of a table or an editor statement, streamed through `Connection::stream_read` on a read-only connection of its own with no deadline. `ReplaceFileWriter` removes a file the export made when it stops or fails. An existing regular file gets a temporary file beside it, renamed over it at the end, so a stopped export leaves it as it was. GLib truncates a symbolic link's target, a hard-linked file or a file in a folder that refuses the temporary file when the export opens it, and a stopped export then says that file is incomplete. [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. +A few views are still [Relm4](https://relm4.org) components ([ADR 0003](docs/decisions/0003-relm4-architecture.md)): the saved-queries and CSV import dialogs. Their output reaches the 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. +### Keyboard shortcuts + +Every key the app binds or relies on is one `Accelerator` in `ACCELERATORS` (`crates/app/src/application/accelerators.rs`): the action it fires, its triggers, its `AcceleratorScope` and where the shortcuts dialog lists it. + +- **Application** keys go in with `set_accels_for_action` at startup. GtkWindow runs them in the capture phase, before the focused widget, so none of them is a key a text field or a list uses. +- **Local** keys are a `local_shortcut_controller(owner)` on the owner's widget, firing the owner's action group through `NamedAction`: `browse` on a table tab and its column view, `editor` on the SQL editor's source view, `structure`, `filter`, the sidebar's list, which opens the selected table's menu, and the query history's list, which opens the focused row's menu. A disabled action lets its key go on. Bubble phase leaves a text field inside the owner its own Ctrl+Z and Ctrl+C; the editor's keys run in the capture phase, because GtkTextView binds Ctrl+/ itself. +- **Component** keys are bound by a component's own controller (the grid cell keys, the cell editors), which reads them from the table through `component_trigger` or `component_matches`. +- **Stock** keys belong to GTK or libadwaita: GtkListBase's selection and movement, AdwTabView's tab switching, AdwApplication's Ctrl+?. The workspace removes AdwTabView's Ctrl+Home and Ctrl+End pairs so the lists and the editor in its tabs keep them. + +`data/resources/shortcuts-dialog.ui` lists every listed entry under an id made from its action, and `shortcuts_dialog_matches_accelerator_table` holds the two in step both ways. Menus show a Local key through `menu_item`, and `describe_icon_button` puts a button's key on its accessible node instead of in its tooltip. + ## Driver layer How the app reaches a database: the contract in `tablepro-core`, a transport that carries the bytes, TLS each driver builds from the connection's trust settings, and a session that watches the link. @@ -136,6 +150,32 @@ How the app reaches a database: the contract in `tablepro-core`, a transport tha `DatabaseDriver` describes an engine before any connection exists, through `DriverCapabilities`, and opens a `Connection`. The `Connection` serves the workspace from a pool; every call takes `CallOptions` (a deadline and a cancel token) and runs on a task the connection owns (`OwnedCalls`), so a stop reaches the server instead of leaving the statement running. Saves report what they left in the database through a `WriteLedger`. Each editor tab gets an `EditorSession` of its own that streams results into a `ResultSink`. [docs/adding-drivers.md](docs/adding-drivers.md) is the full contract, [ADR 0016](docs/decisions/0016-driver-contract-v2.md) the reasons for it, and the engine pages say how each driver meets it: [PostgreSQL](docs/drivers/postgresql.md), [MySQL and MariaDB](docs/drivers/mysql.md), [SQLite](docs/drivers/sqlite.md), [SQL Server](docs/drivers/sql-server.md), [ClickHouse](docs/drivers/clickhouse.md). +### SQL scripts + +`tablepro_core::sql_syntax::script` reads a script the way the engine's own client does. `ScriptPlan::build(text, grammar, settings)` lexes the text once and returns its tokens, its statements, its batches, and a `ScriptDiagnostic` for a string, quoted name, comment or dollar quote left open. Ranges are in bytes; `text_offsets` maps them to the characters GTK counts. + +A driver names its grammar through `SqlDialect::grammar`. The rules for each `SqlGrammar` are one table in core (`crates/core/src/sql_syntax/script/script_rules.rs`), so the editor's runs, completion and formatting, the drivers' own splitting and read-only guards, and the history's credential check all read a script the same way. `LexicalSettings` holds what a session can change: whether a backslash escapes in a plain string. + +| Grammar | Splits at | Kept whole across `;` | Beyond standard quotes and comments | +|---|---|---|---| +| PostgreSQL | `;` | dollar-quoted bodies, the `BEGIN ... END` body of a `CREATE FUNCTION` or `PROCEDURE`, parentheses | `E'...'` escapes, nested block comments, backslash escapes only with `standard_conforming_strings` off | +| MySQL, MariaDB | `;`, or the delimiter a `DELIMITER` line sets | nothing | backtick names, `"` strings, `#` comments, `--` only before whitespace, a control character or the end of the text, backslash escapes | +| SQLite | `;` | a `CREATE TRIGGER` body, as `sqlite3_complete` reads it | `[name]` and backtick names | +| SQL Server | a `GO` line, with an optional count | the whole batch, whatever `;` it holds | `[name]` with `]]` inside, nested block comments | +| ClickHouse | `;` | heredocs, `$tag$ ... $tag$` | backslash escapes in every quote, `//`, `# ` and `#!` comments, nested block comments | + +A failed statement stops the rest of a script, except on SQL Server, where each `GO` batch runs on its own as in sqlcmd (`BatchErrorPolicy::ContinueNextBatch`). The editor sends one statement, or one `GO` batch, per run; [docs/state-management.md](docs/state-management.md#the-sql-editor) has the run. + +**MySQL stored programs need `DELIMITER`.** The mysql client splits at every `;` outside a quote or a comment, `BEGIN ... END` included, and the plan does the same: `CREATE PROCEDURE p() BEGIN SELECT 1; SELECT 2; END` is three statements, and the server refuses the first. A trigger, procedure, function or event whose body holds a `;` goes between `DELIMITER` lines, as in the mysql client: + +```sql +DELIMITER // +CREATE PROCEDURE p() BEGIN SELECT 1; SELECT 2; END// +DELIMITER ; +``` + +A `DELIMITER` or `\d` line that starts a line outside a statement is a directive and is never sent. The MySQL driver honours the same lines when a whole script reaches it, and sends text without them as one multi-statement batch, which the server parses itself. + ### Connecting `DriverConnector` in `tablepro-session` turns a saved connection into a connected one: @@ -225,7 +265,7 @@ A window holds one `Session`. Every use of its connection goes through the `Oper 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. +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 shows every schema's tables and views in one `GtkListView` that recycles its rows, a section per schema with the default one first and the engine's own last, and one menu whose items carry the table they act on. It follows every schema's list through a watch the window drops before the session closes, and reads that list again when a reconnect commits the default schema's; a refresh that fails keeps the list and says so in a toast. 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 @@ -240,11 +280,11 @@ pub enum WorkspaceTab { } ``` -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. +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). A `TabIndex` maps each `AdwTabPage` to its tab's UUID, so close and right-click actions can recover it. `TabFactory` builds every tab from a `TabSpec`, whether the sidebar, the overview's new-tab button, a restore or a reopen asks for it. Every tab's title comes from its `TabIdentity` through `resolve_tab_title`, and the window's from the tab in front through `resolve_window_title`. A tab that closing would lose something in shows `document-modified-symbolic` as its page icon, as GNOME Text Editor does; its title never changes for it. 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. +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 the table's row estimate wait for the table's columns from the catalog; an exact count runs on its own only for a table the estimate says is small, and otherwise when the user asks. A read that failed is asked again once the session reconnects, and a tab whose reads all landed keeps its rows; a failed count waits for Try Again. A read that fails with rows on screen leaves them there under a banner with Retry; the error page is for a tab with nothing to show. 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 goes with the workspace on disconnect because descriptors reference tables in its session. @@ -257,9 +297,9 @@ Each workspace owns two registries holding the in-flight edit state for its tabs | `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. `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. +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. `WorkspaceHandle::dirty_tabs` reads both registries, with each editor's open transaction, for the close prompts, the teardown guard and the tabs' unsaved icon. 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)). +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)). A save that loses something, or that has several statements on an engine that applies DDL one statement at a time, first shows the user its `ChangeReview` and runs only the statements reviewed, whether the tab's Save or a close prompt asked for it ([state-management.md](docs/state-management.md#rules-for-every-view)). ## Persistence @@ -270,7 +310,7 @@ The Structure tab is **definition + draft**, not a per-change log. The `TableDef | 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` | +| Query history and saved queries | SQLite with an FTS5 trigram index over the case-folded query, 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` | diff --git a/linux/CHANGELOG.md b/linux/CHANGELOG.md index 9c6ad6e5d3..0578ba99b6 100644 --- a/linux/CHANGELOG.md +++ b/linux/CHANGELOG.md @@ -30,9 +30,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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. +- Screen readers name check box cells and say when a grid cell is modified or NULL and a row 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. +- Try Again on a table tab's error page, and Retry on a banner over rows or a structure that could not be read again. +- Table and structure tabs show a spinner in the tab bar while they read. +- Count Rows in a table tab's paginator, with the reason on Try Again when a count fails. +- Exports write in the background, with progress and Stop in the header bar, and a close or quit asks to stop them first. +- Export a table's current page or every row under its filter and sort, and an editor result's shown rows or all of them. +- Banner over an editor result the row limit cut short, saying how many rows it shows, with Export All for every row. +- Sort an editor result by clicking a column header, with text in your language's alphabetical order. +- Copy as puts CSV, JSON, Markdown, SQL and tab-separated rows on the clipboard as their own type beside plain text. +- Shift+F10 opens the menu of a sidebar table or a query history entry, as the Menu key does. +- Screen readers name icon-only buttons, and announce the shortcut of the table, SQL editor and filter buttons that have one. +- An icon beside each table row with a pending change, and bold text in edited cells. +- Troubleshooting in About TablePro, with the app, toolkit and driver versions to copy into a bug report. +- Licenses of the third-party crates TablePro is built with, in About TablePro and installed with the app. +- F9 and the sidebar button show and hide the table sidebar. +- Sidebar lists the tables of every schema, a section per schema with the default one first, and a middle click opens one in a new tab. +- Connect leads a saved connection's menu, which a right click, the Menu key or Shift+F10 opens. +- Find and replace in the SQL editor with Ctrl+F and Ctrl+H, matching case, whole words or regular expressions. ### Changed @@ -47,6 +64,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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. +- JSON exports and copies put one row per line, and a value TablePro couldn't read stops the export instead of becoming NULL. - 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. @@ -54,10 +72,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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. +- Refresh keeps a table's rows, selection and focused cell on screen while it reads the table again. +- A structure tab keeps the table's structure on screen while it reads it again, a save included. +- Opening a large table no longer counts its rows: the paginator shows the server's estimate, and Last waits for a count. +- Saving rows updates a table's total without counting the table again. +- Row counts and page sizes group digits the way the system locale does. +- Query History opens with Ctrl+Shift+H. +- Query History finds any part of a query in any case, and loads more entries as you scroll. +- Query History filters by any connection with history, under its current name, and starts on the window's own. +- Deleting from Query History offers Undo instead of asking first. +- Query History's filters sit under the search bar instead of in a popover, and Today replaces the last 24 hours. +- Show in Files, in Preferences and Query History, selects the history database in its folder. +- Previous and next page are Alt+Page Up and Alt+Page Down; Page Up and Page Down move through the rows. +- Ctrl+S, Ctrl+Z, F5 and Ctrl+F act on the tab that has the focus, so a text field keeps its own undo. +- Keyboard Shortcuts lists every shortcut, grouped by where it works. +- About TablePro links to the website, documentation and issue tracker, and shows a release's notes. +- The sidebar and the tabs each have a header bar, and a narrow window folds the sidebar away and swaps the tab bar for a tab button. +- Sidebar menus offer Edit Structure, Import CSV and Drop Table only on tables, and nothing that writes on a read-only connection. +- New Connection and the main menu sit in the connection list's header bar. +- Each connection group is a section of its own in the connection list, after the connections in no group. +- A tab with unsaved changes or an open transaction shows a dot icon instead of a • in its title. +- The window title names the tab in front and its connection, and the header says when the connection is read-only. +- Tab titles name the schema once the database has two schemas of its own. ### Removed - Connection buttons in a connected window's header bar; New Window opens another connection. +- Ctrl+E for a new SQL editor tab; Ctrl+T opens one. ### Fixed @@ -65,8 +106,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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 failed query history export said nothing. +- Copying many rows, or showing a row with a long value as JSON, froze the window. - A query saved just before quitting could be lost. - The sidebar's table search button had no name for screen readers. +- The sidebar was slow to fill and to search on databases with thousands of tables, and said nothing when the list could not be read again. +- A connection added, changed or deleted in one window did not show in the other windows' connection lists. +- A connection name with & or < in it showed wrong in the connection list. - 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. @@ -118,6 +164,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - A table or structure tab whose read failed while the connection was lost kept its error page after the reconnect. - A refresh that failed replaced the rows in a table tab with an error page. - Switching connections could show the previous connection's tables in the sidebar, or its reconnect banner. +- The sidebar lost the selected table each time the table list was read again. +- A table tab with no columns the account can read showed a page error instead of saying why. - Switching connections during a table drop could close the new connection's tabs on a table of the same name. - Renaming a MySQL table in another database moved it to the connection's database, and renaming a ClickHouse table failed. - Turning a column's primary key on or off in the Structure tab saved nothing. @@ -145,6 +193,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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. +- Ctrl+N added a row to a read-only table or a table without a primary key. ### Security diff --git a/linux/CONTRIBUTING.md b/linux/CONTRIBUTING.md index 959f6fded7..4d5e1327c1 100644 --- a/linux/CONTRIBUTING.md +++ b/linux/CONTRIBUTING.md @@ -53,6 +53,19 @@ RUST_LOG=debug ./_build/crates/app/tablepro CI sets `GTK_A11Y=test` for GTK tests. With `GTK_A11Y=none` GTK records no accessible properties, and the accessibility helpers report that the test backend is missing. +### Dependencies + +`data/licenses` holds the license notices of every crate the app ships, written from `Cargo.lock` by cargo-about. The About dialog embeds `third-party.json`, and both files install next to `LICENSE`. After a change to `Cargo.lock`, regenerate them and commit the result; the Third-party licenses CI job fails until they match: + +```bash +cargo install --locked cargo-about@0.9.2 --features cli +build-aux/update-third-party-licenses.sh +``` + +A license added to `[licenses].allow` in `deny.toml` goes into `accepted` in `about.toml` at the same position. + +The script stops and names each crate it read no license file for, since the notice would carry the SPDX template text with no copyright holder. Give that crate a `[.clarify]` entry in `about.toml` with the file's path and SHA-256: under `files` for a file in the crate, under `git` for one in its repository at the published commit. An entry whose checksum no longer matches names its crate the same way. The script needs the network for the `git` entries. + ## Code style | Tool | Config | Notes | diff --git a/linux/Cargo.lock b/linux/Cargo.lock index 2b44d6cf97..87474f3f95 100644 --- a/linux/Cargo.lock +++ b/linux/Cargo.lock @@ -1257,6 +1257,17 @@ version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d" +[[package]] +name = "fixed_decimal" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79c3c892f121fff406e5dd6b28c1b30096b95111c30701a899d4f2b18da6d1bd" +dependencies = [ + "displaydoc", + "smallvec", + "writeable", +] + [[package]] name = "flagset" version = "0.4.7" @@ -1505,6 +1516,16 @@ dependencies = [ "version_check", ] +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix", + "windows-link", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -2105,6 +2126,30 @@ dependencies = [ "zerovec", ] +[[package]] +name = "icu_decimal" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9eb8f655bba2d0c0459e43a5b0e6cd3cd388109898fb3d85d048165e8b0abd08" +dependencies = [ + "displaydoc", + "fixed_decimal", + "icu_decimal_data", + "icu_locale_core", + "icu_locale_fallback", + "icu_plurals", + "icu_provider", + "serde", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_decimal_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4c887fc7d4c297cf3f9436864af9e3dba7f8be9415a6c2a7f392f3b1636298c" + [[package]] name = "icu_locale_core" version = "2.3.0" @@ -2113,11 +2158,32 @@ checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" dependencies = [ "displaydoc", "litemap", + "serde", "tinystr", "writeable", "zerovec", ] +[[package]] +name = "icu_locale_fallback" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "251af8e57c9400e3eb58242fe5b8b1152b2a64fdf4cf632f923c38ccee6f2fa9" +dependencies = [ + "icu_locale_core", + "icu_locale_fallback_data", + "icu_provider", + "potential_utf", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_locale_fallback_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "decf2a22ec8fa68f1a0c1129a3f8583f8f8bc24e8b9ccbe98ead99f62a4dc3a8" + [[package]] name = "icu_normalizer" version = "2.3.0" @@ -2138,6 +2204,25 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" +[[package]] +name = "icu_plurals" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e475e6766ef87b1d3c1f97be6363995b0bfaeddbc789671615df598a97ff0593" +dependencies = [ + "fixed_decimal", + "icu_locale_fallback", + "icu_plurals_data", + "icu_provider", + "zerovec", +] + +[[package]] +name = "icu_plurals_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1251aa39a95e1333888e499b1263e1c196447a28948acf5bdabd82c046f153bf" + [[package]] name = "icu_properties" version = "2.3.0" @@ -2167,6 +2252,8 @@ checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" dependencies = [ "displaydoc", "icu_locale_core", + "serde", + "stable_deref_trait", "writeable", "yoke", "zerofrom", @@ -3093,6 +3180,8 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" dependencies = [ + "serde_core", + "writeable", "zerovec", ] @@ -3452,6 +3541,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34f2d2cda7223168a14fb86bc7411dbc3379bd7d49749ecc53b91ed98585b6b0" dependencies = [ "chrono", + "tempfile", "zip", ] @@ -3494,9 +3584,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.45" +version = "0.23.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" +checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" dependencies = [ "aws-lc-rs", "log", @@ -3735,7 +3825,6 @@ version = "1.0.151" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" dependencies = [ - "indexmap 2.14.2", "itoa", "memchr", "serde", @@ -4335,6 +4424,7 @@ dependencies = [ name = "tablepro" version = "0.0.0" dependencies = [ + "async-trait", "chrono", "futures", "gettext-rs", @@ -4343,6 +4433,8 @@ dependencies = [ "glib", "glib-build-tools", "gtk4", + "icu_decimal", + "icu_locale_core", "libadwaita", "log", "proptest", @@ -4373,6 +4465,7 @@ dependencies = [ "tracing-log", "tracing-subscriber", "uuid", + "x11rb", ] [[package]] @@ -4395,6 +4488,7 @@ dependencies = [ "tokio", "tokio-util", "uuid", + "zip", ] [[package]] @@ -4647,7 +4741,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.3", + "getrandom 0.3.4", "once_cell", "rustix", "windows-sys 0.61.2", @@ -4806,6 +4900,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" dependencies = [ "displaydoc", + "serde_core", "zerovec", ] @@ -5693,6 +5788,23 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" +[[package]] +name = "x11rb" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a8885a854a8bfdf87a301e53e41b17c5f8f33639903131338b997b1eb614f44" +dependencies = [ + "gethostname", + "rustix", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acf4d1bc32aa46eec18caa634ec3cf4c05bfa151f12b93b510b15190f69a1ca8" + [[package]] name = "x509-cert" version = "0.2.5" @@ -5901,6 +6013,7 @@ dependencies = [ "displaydoc", "yoke", "zerofrom", + "zerovec", ] [[package]] @@ -5909,6 +6022,7 @@ version = "0.11.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" dependencies = [ + "serde", "yoke", "zerofrom", "zerovec-derive", diff --git a/linux/Cargo.toml b/linux/Cargo.toml index ce5676e5fb..19b49ad743 100644 --- a/linux/Cargo.toml +++ b/linux/Cargo.toml @@ -26,7 +26,7 @@ publish = false async-trait = "0.1" thiserror = "2" serde = { version = "1", features = ["derive"] } -serde_json = { version = "1", features = ["preserve_order", "raw_value"] } +serde_json = { version = "1", features = ["raw_value"] } sqlparser = { version = "0.63", default-features = false, features = ["std"] } tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "time", "sync", "process"] } tokio-util = { version = "0.7", default-features = false, features = ["codec", "rt"] } @@ -42,8 +42,10 @@ tiberius = { version = "0.13", default-features = false, features = ["tds73", "r chrono = { version = "0.4", default-features = false, features = ["clock", "serde"] } csv = "1.4" # No zlib feature: it pulls libz-sys, and the pure-Rust deflate keeps -# the build free of another C dependency. -rust_xlsxwriter = { version = "0.99", default-features = false, features = ["chrono"] } +# the build free of another C dependency. Constant memory lets a sheet +# spill its rows to a temporary file instead of holding them. +rust_xlsxwriter = { version = "0.99", default-features = false, features = ["chrono", "constant_memory"] } +zip = { version = "8.3", default-features = false, features = ["deflate"] } futures = "0.3" futures-util = { version = "0.3", default-features = false, features = ["std"] } bytes = "1" diff --git a/linux/README.md b/linux/README.md index c2a20b7c6d..c537de7753 100644 --- a/linux/README.md +++ b/linux/README.md @@ -67,7 +67,7 @@ with `GSettings schema app.tablepro.TablePro is not installed`. Install with `meson install -C _build`, which places the binary, the askpass helper, the desktop entry, the D-Bus service, the AppStream -metainfo and the icons. See +metainfo, the icons and the third-party license notices. See [ADR 0006](docs/decisions/0006-meson-cargo-build.md). Before pushing, run the [fast-job commands](CONTRIBUTING.md#fast-job-commands). diff --git a/linux/about.toml b/linux/about.toml new file mode 100644 index 0000000000..8994b5a4e1 --- /dev/null +++ b/linux/about.toml @@ -0,0 +1,155 @@ +# cargo-about settings for data/licenses, which +# build-aux/update-third-party-licenses.sh writes. +# +# `accepted` is deny.toml's [licenses].allow in the same order, so a crate +# under "MIT OR Apache-2.0" is listed under the first one deny accepts. +accepted = [ + "AGPL-3.0-or-later", + "MIT", + "Apache-2.0", + "Apache-2.0 WITH LLVM-exception", + "BSD-2-Clause", + "BSD-3-Clause", + "ISC", + "Zlib", + "Unicode-3.0", + "CC0-1.0", + "MPL-2.0", + "OpenSSL", + "CDLA-Permissive-2.0", + "LGPL-2.1-or-later", + "0BSD", +] +targets = ["x86_64-unknown-linux-gnu", "aarch64-unknown-linux-gnu"] +# Neither kind of crate ends up in the shipped binaries. +ignore-build-dependencies = true +ignore-dev-dependencies = true +# The workspace's own crates are TablePro, under LICENSE. +private = { ignore = true } + +# Without a license file cargo-about can read, a crate gets the SPDX +# template text, which names no copyright holder. Each entry below names +# the crate's own file: in the crate, or, for a crate that packages none +# or only a symlink, in its repository at the published commit. An entry +# whose checksum no longer matches is dropped, and +# update-third-party-licenses.sh then names the crate. + +# The LICENSE lists the copyright of every bundled component. +[aws-lc-sys.clarify] +license = "ISC AND (Apache-2.0 OR ISC) AND Apache-2.0 AND MIT AND BSD-3-Clause AND (Apache-2.0 OR ISC OR MIT) AND (Apache-2.0 OR ISC OR MIT-0)" +[[aws-lc-sys.clarify.files]] +path = "LICENSE" +checksum = "728536b4160e051f86d7c9c388f704866b3d512cd7df97ac3516c65279523c4e" + +[aws-lc-rs.clarify] +license = "ISC AND (Apache-2.0 OR ISC)" +[[aws-lc-rs.clarify.files]] +path = "LICENSE" +checksum = "b50b376e7d24a0598488b730c3034ffcd0b58dd36c0913a24b9903a3cfd04bf7" + +# LICENSE.txt holds both licenses, each under its own copyright line. +[chrono.clarify] +license = "MIT OR Apache-2.0" +[[chrono.clarify.files]] +path = "LICENSE.txt" +license = "MIT" +start = "The MIT License (MIT)" +end = "THE SOFTWARE." +checksum = "332b974a713ff4e5536be4732fbffd1026694d4a1cbe8d832c969625d991f22c" + +# The copyright of the musl and FreeBSD code follows the license texts. +[libm.clarify] +license = "MIT" +[[libm.clarify.files]] +path = "LICENSE.txt" +checksum = "3823dda7cf046602f4b4e77ec8e227863dc4736037cc85bb33d9f19febe16bb7" + +[ryu.clarify] +license = "Apache-2.0 OR BSL-1.0" +[[ryu.clarify.files]] +path = "LICENSE-APACHE" +license = "Apache-2.0" +checksum = "62c7a1e35f56406896d7aa7ca52d0cc0d272ac022b5d2796e7d6905db8a3636a" + +[rust_xlsxwriter.clarify] +license = "MIT OR Apache-2.0" +[[rust_xlsxwriter.clarify.files]] +path = "LICENSE_MIT" +license = "MIT" +checksum = "6719e95f813f9f93e1ae52f9c13b89b3766655d65620399638b3753fc24f4c56" + +[whoami.clarify] +license = "Apache-2.0 OR BSL-1.0 OR MIT" +[[whoami.clarify.files]] +path = "LICENSE_MIT" +license = "MIT" +checksum = "508a77d2e7b51d98adeed32648ad124b7b30241a8e70b2e72c99f92d8e5874d1" + +[clickhouse-macros.clarify] +license = "MIT OR Apache-2.0" +[[clickhouse-macros.clarify.git]] +path = "LICENSE-MIT" +license = "MIT" +checksum = "23f18e03dc49df91622fe2a76176497404e46ced8a715d9d2b67a7446571cca3" + +[clickhouse-types.clarify] +license = "MIT OR Apache-2.0" +[[clickhouse-types.clarify.git]] +path = "LICENSE-MIT" +license = "MIT" +checksum = "23f18e03dc49df91622fe2a76176497404e46ced8a715d9d2b67a7446571cca3" + +[libgssapi.clarify] +license = "MIT" +[[libgssapi.clarify.git]] +path = "LICENSE" +checksum = "002c2696d92b5c8cf956c11072baa58eaf9f6ade995c031ea635c6a1ee342ad1" + +[libgssapi-sys.clarify] +license = "MIT" +[[libgssapi-sys.clarify.git]] +path = "LICENSE" +checksum = "002c2696d92b5c8cf956c11072baa58eaf9f6ade995c031ea635c6a1ee342ad1" + +[tiberius-macros.clarify] +license = "MIT OR Apache-2.0" +[[tiberius-macros.clarify.git]] +path = "LICENSE-MIT.txt" +license = "MIT" +checksum = "2e9c4e8b1bb2e73696358b5b1d74d3d56403d33da333e70aa504c9b1d7b4bdd0" + +# The sqlx crates package LICENSE-MIT as a symlink to the workspace root's. +[sqlx-core.clarify] +license = "MIT OR Apache-2.0" +[[sqlx-core.clarify.git]] +path = "LICENSE-MIT" +license = "MIT" +checksum = "5abbdd842310c8f1650fcde3a5a51a3e09412b9e5a6eea87519e0db9e32b339d" + +[sqlx-macros.clarify] +license = "MIT OR Apache-2.0" +[[sqlx-macros.clarify.git]] +path = "LICENSE-MIT" +license = "MIT" +checksum = "5abbdd842310c8f1650fcde3a5a51a3e09412b9e5a6eea87519e0db9e32b339d" + +[sqlx-macros-core.clarify] +license = "MIT OR Apache-2.0" +[[sqlx-macros-core.clarify.git]] +path = "LICENSE-MIT" +license = "MIT" +checksum = "5abbdd842310c8f1650fcde3a5a51a3e09412b9e5a6eea87519e0db9e32b339d" + +[sqlx-mysql.clarify] +license = "MIT OR Apache-2.0" +[[sqlx-mysql.clarify.git]] +path = "LICENSE-MIT" +license = "MIT" +checksum = "5abbdd842310c8f1650fcde3a5a51a3e09412b9e5a6eea87519e0db9e32b339d" + +[sqlx-sqlite.clarify] +license = "MIT OR Apache-2.0" +[[sqlx-sqlite.clarify.git]] +path = "LICENSE-MIT" +license = "MIT" +checksum = "5abbdd842310c8f1650fcde3a5a51a3e09412b9e5a6eea87519e0db9e32b339d" diff --git a/linux/build-aux/about/THIRD-PARTY-NOTICE.md.hbs b/linux/build-aux/about/THIRD-PARTY-NOTICE.md.hbs new file mode 100644 index 0000000000..3937641d9c --- /dev/null +++ b/linux/build-aux/about/THIRD-PARTY-NOTICE.md.hbs @@ -0,0 +1,27 @@ +# Third-party licenses + +TablePro is licensed under the GNU Affero General Public License, version 3 or later, in `LICENSE`. It is built with the Rust crates below, each listed under the license it is used under. + +`build-aux/update-third-party-licenses.sh` writes this file from `Cargo.lock`. Do not edit it by hand. + +## Overview + +{{#each overview}} +- {{{name}}} ({{{id}}}) +{{/each}} + +## License texts +{{#each licenses}} + +### {{{name}}} + +Used by: + +{{#each used_by}} +- {{{crate.name}}} {{{crate.version}}} +{{/each}} + +~~~~text +{{{text}}} +~~~~ +{{/each}} diff --git a/linux/build-aux/about/fallbacks.hbs b/linux/build-aux/about/fallbacks.hbs new file mode 100644 index 0000000000..d71faf9940 --- /dev/null +++ b/linux/build-aux/about/fallbacks.hbs @@ -0,0 +1,7 @@ +{{#each licenses}} +{{#unless source_path}} +{{#each used_by}} +{{{crate.name}}} {{{crate.version}}}: {{{../id}}} +{{/each}} +{{/unless}} +{{/each}} diff --git a/linux/build-aux/about/third-party.json.hbs b/linux/build-aux/about/third-party.json.hbs new file mode 100644 index 0000000000..7d87bcab08 --- /dev/null +++ b/linux/build-aux/about/third-party.json.hbs @@ -0,0 +1,16 @@ +{ + "licenses": [ +{{#each licenses}} + { + "id": {{json id}}, + "name": {{json name}}, + "text": {{json text}}, + "used_by": [ +{{#each used_by}} + { "crate": { "name": {{json crate.name}}, "version": {{json crate.version}} } }{{#unless @last}},{{/unless}} +{{/each}} + ] + }{{#unless @last}},{{/unless}} +{{/each}} + ] +} diff --git a/linux/build-aux/update-third-party-licenses.sh b/linux/build-aux/update-third-party-licenses.sh new file mode 100755 index 0000000000..6569c04094 --- /dev/null +++ b/linux/build-aux/update-third-party-licenses.sh @@ -0,0 +1,45 @@ +#!/bin/sh +# Writes data/licenses from Cargo.lock with cargo-about. CI runs it and +# fails when the committed files differ, so run it after every change to +# Cargo.lock and commit what it writes. +set -eu + +expected_version="cargo-about 0.9.2" +root="$(cd "$(dirname "$0")/.." && pwd)" + +# Another version can render the same lock file differently, which CI +# would report as drift. +version="$(cargo about --version)" +if [ "$version" != "$expected_version" ]; then + echo "update-third-party-licenses.sh: needs $expected_version, found $version" >&2 + exit 1 +fi + +cargo fetch --locked --manifest-path "$root/Cargo.toml" + +# The app manifest, not the workspace, so the test-only crates and their +# dependencies stay out: tablepro-ssh, which builds the askpass helper, +# is one of the app's own dependencies. Not --offline: the git entries +# in about.toml fetch a license file from the crate's repository. +generate() { + cargo about generate --locked --fail \ + --config "$root/about.toml" \ + --manifest-path "$root/crates/app/Cargo.toml" \ + --output-file "$2" \ + "$1" +} + +# A crate with no license file cargo-about can read gets the SPDX template +# text, which names no copyright holder, and --fail lets that through. +fallbacks="$(mktemp)" +trap 'rm -f "$fallbacks"' EXIT +generate "$root/build-aux/about/fallbacks.hbs" "$fallbacks" +if grep -q '[^[:space:]]' "$fallbacks"; then + echo "update-third-party-licenses.sh: no license file read for these crates; add or fix their [.clarify] entry in about.toml:" >&2 + cat "$fallbacks" >&2 + exit 1 +fi + +for output in third-party.json THIRD-PARTY-NOTICE.md; do + generate "$root/build-aux/about/$output.hbs" "$root/data/licenses/$output" +done diff --git a/linux/clippy.toml b/linux/clippy.toml index 82a664d8a1..4598cd680a 100644 --- a/linux/clippy.toml +++ b/linux/clippy.toml @@ -3,3 +3,15 @@ allow-expect-in-tests = true allow-panic-in-tests = true allow-dbg-in-tests = true allow-print-in-tests = true + +# A serde_json map sorts its keys, which would reorder a document the +# server sent. JSON a row holds stays as JsonText, and JSON the app +# writes goes out in the order it is written. +disallowed-types = [ + { path = "serde_json::Map", reason = "key order is not preserved; keep JSON as JsonText" }, +] + +disallowed-methods = [ + { path = "serde_json::Value::as_object", reason = "key order is not preserved; keep JSON as JsonText" }, + { path = "serde_json::Value::as_object_mut", reason = "key order is not preserved; keep JSON as JsonText" }, +] diff --git a/linux/crates/app/Cargo.toml b/linux/crates/app/Cargo.toml index aadf337c07..0ea6feecf5 100644 --- a/linux/crates/app/Cargo.toml +++ b/linux/crates/app/Cargo.toml @@ -62,6 +62,7 @@ tablepro-driver-mssql = { path = "../drivers/mssql" } tablepro-driver-mysql = { path = "../drivers/mysql" } tablepro-driver-postgres = { path = "../drivers/postgres" } tablepro-driver-sqlite = { path = "../drivers/sqlite" } +async-trait.workspace = true chrono.workspace = true futures.workspace = true serde.workspace = true @@ -87,6 +88,9 @@ tracing.workspace = true tracing-subscriber.workspace = true uuid.workspace = true gettext-rs = { version = "0.8", features = ["gettext-system"] } +# Counts grouped the way the user's locale groups digits. +icu_decimal = { version = "2.3", features = ["alloc"] } +icu_locale_core = { version = "2.3", features = ["alloc"] } sqlformat = "0.5" [dev-dependencies] @@ -100,6 +104,8 @@ tokio = { workspace = true, features = ["macros", "rt", "test-util"] } # bridge the way it does. log = "0.4" tracing-log = "0.2" +# Real key presses through XTEST, which GDK reads as it reads a keyboard. +x11rb = { version = "0.14", features = ["xtest"] } [build-dependencies] glib-build-tools.workspace = true diff --git a/linux/crates/app/build.rs b/linux/crates/app/build.rs index 46896d77a5..d8b971dc7a 100644 --- a/linux/crates/app/build.rs +++ b/linux/crates/app/build.rs @@ -1,7 +1,84 @@ -fn main() { +use std::env; +use std::fs; +use std::path::PathBuf; +use std::process::ExitCode; + +const METAINFO_TEMPLATE: &str = "../../data/app.tablepro.TablePro.metainfo.xml.in.in"; +/// The fallback `config::APP_ID` takes when Meson sets nothing. +const DEFAULT_APP_ID: &str = "app.tablepro.TablePro.Devel"; + +fn main() -> ExitCode { + let metainfo_dir = match render_metainfo() { + Ok(dir) => dir, + Err(message) => { + println!("cargo::error={message}"); + return ExitCode::FAILURE; + } + }; glib_build_tools::compile_resources( - &["../../data/resources"], + &[PathBuf::from("../../data/resources"), metainfo_dir], "../../data/resources/resources.gresource.xml", "tablepro.gresource", ); + ExitCode::SUCCESS +} + +/// The About dialog reads the metainfo from the GResource. A Meson build +/// passes the copy it installs, translations merged; a plain cargo build +/// renders the template with the same values, untranslated. +fn render_metainfo() -> Result { + println!("cargo::rerun-if-env-changed=TABLEPRO_METAINFO"); + let out_dir = env::var_os("OUT_DIR").ok_or("OUT_DIR is not set")?; + let rendered = match env::var_os("TABLEPRO_METAINFO") { + Some(merged) => { + let merged = PathBuf::from(merged); + println!("cargo::rerun-if-changed={}", merged.display()); + fs::read_to_string(&merged).map_err(|error| format!("could not read {}: {error}", merged.display()))? + } + None => render_template()?, + }; + + let dir = PathBuf::from(out_dir).join("metainfo"); + fs::create_dir_all(&dir).map_err(|error| format!("could not create {}: {error}", dir.display()))?; + let path = dir.join("metainfo.xml"); + // A rewrite, even of the same bytes, makes cargo run this script and + // rebuild the crate on every later build. + if fs::read_to_string(&path).ok().as_deref() != Some(rendered.as_str()) { + fs::write(&path, rendered).map_err(|error| format!("could not write {}: {error}", path.display()))?; + } + Ok(dir) +} + +fn render_template() -> Result { + println!("cargo::rerun-if-changed={METAINFO_TEMPLATE}"); + println!("cargo::rerun-if-env-changed=TABLEPRO_APP_ID"); + let app_id = env::var("TABLEPRO_APP_ID").unwrap_or_else(|_| DEFAULT_APP_ID.to_owned()); + let package = env::var("CARGO_PKG_NAME").map_err(|error| format!("CARGO_PKG_NAME: {error}"))?; + let template = fs::read_to_string(METAINFO_TEMPLATE) + .map_err(|error| format!("could not read {METAINFO_TEMPLATE}: {error}"))?; + let rendered = template + .replace("@app-id@", &app_id) + .replace("@gettext-package@", &package); + if let Some(name) = unfilled_placeholder(&rendered) { + return Err(format!( + "{METAINFO_TEMPLATE} uses @{name}@, which build.rs does not fill" + )); + } + Ok(rendered) +} + +/// The name of a Meson `@name@` placeholder still in `text`. +fn unfilled_placeholder(text: &str) -> Option<&str> { + let mut rest = text; + while let Some(start) = rest.find('@') { + let after = &rest[start + 1..]; + let length = after + .find(|c: char| !(c.is_ascii_alphanumeric() || c == '-' || c == '_')) + .unwrap_or(after.len()); + if length > 0 && after[length..].starts_with('@') { + return Some(&after[..length]); + } + rest = after; + } + None } diff --git a/linux/crates/app/clippy.toml b/linux/crates/app/clippy.toml index be994fc95e..4d4d39d206 100644 --- a/linux/crates/app/clippy.toml +++ b/linux/crates/app/clippy.toml @@ -4,7 +4,17 @@ allow-panic-in-tests = true allow-dbg-in-tests = true allow-print-in-tests = true +# A serde_json map sorts its keys, which would reorder a document the +# server sent. JSON a row holds stays as JsonText, and JSON the app +# writes goes out in the order it is written. +disallowed-types = [ + { path = "serde_json::Map", reason = "key order is not preserved; keep JSON as JsonText" }, +] + disallowed-methods = [ + { path = "serde_json::Value::as_object", reason = "key order is not preserved; keep JSON as JsonText" }, + { path = "serde_json::Value::as_object_mut", reason = "key order is not preserved; keep JSON as JsonText" }, + # Catalogue text goes into DDL verbatim, so only a driver may build # one from a string. User input goes through SqlDialect::parse_type # or parse_expression first. diff --git a/linux/crates/app/meson.build b/linux/crates/app/meson.build index abb21e5eb1..0625c4775b 100644 --- a/linux/crates/app/meson.build +++ b/linux/crates/app/meson.build @@ -16,6 +16,11 @@ endif cargo_built = cargo_target_dir / cargo_profile_dir +# The About dialog embeds the metainfo Meson installs, with its +# translations, rather than build.rs rendering the template untranslated. +app_env = cargo_env +app_env.set('TABLEPRO_METAINFO', metainfo_file.full_path()) + cargo_build_command = ' '.join( [cargo.full_path(), 'build', '-p', 'tablepro', '-p', 'tablepro-ssh'] + cargo_options, ) @@ -26,7 +31,8 @@ custom_target( build_by_default: true, console: true, output: ['tablepro', 'tablepro-askpass'], - env: cargo_env, + env: app_env, + depends: metainfo_file, command: [ 'sh', '-c', @@ -50,9 +56,12 @@ test( timeout: 3600, ) -gtk_env = cargo_env +gtk_env = app_env gtk_env.set('GSK_RENDERER', 'cairo') gtk_env.set('GTK_A11Y', 'test') +# The key tests press real keys through XTEST, which only an X11 display +# hands to GDK. +gtk_env.set('GDK_BACKEND', 'x11') # gnome.compile_schemas exports GSETTINGS_SCHEMA_DIR to devenv only, so # the suite points at the build tree itself. gtk_env.set('GSETTINGS_SCHEMA_DIR', meson.project_build_root() / 'data') @@ -62,7 +71,7 @@ test( cargo, args: ['test'] + cargo_options + ['-p', 'tablepro'], env: gtk_env, - depends: compiled_schemas, + depends: [compiled_schemas, metainfo_file], suite: 'gtk', is_parallel: false, timeout: 3600, diff --git a/linux/crates/app/src/application/about_dialog.rs b/linux/crates/app/src/application/about_dialog.rs new file mode 100644 index 0000000000..7314dd98f4 --- /dev/null +++ b/linux/crates/app/src/application/about_dialog.rs @@ -0,0 +1,112 @@ +use libadwaita as adw; +use tablepro_core::DriverRegistry; + +use super::debug_info::debug_info; +use super::runtime_versions::RuntimeVersions; +use super::third_party_licenses; +use crate::config::{self, Profile}; +use crate::i18n::gettext; + +const DEVELOPERS: &[&str] = &["TablePro Authors https://github.com/TableProApp/TablePro/graphs/contributors"]; +const DEBUG_INFO_FILENAME: &str = "tablepro-debug-info.txt"; + +/// The name, icon, developer, links and license come from the metainfo +/// the GResource embeds, so the dialog and the software centre agree. +pub fn build_about_dialog(drivers: &DriverRegistry) -> adw::AboutDialog { + let dialog = adw::AboutDialog::from_appdata(&metainfo_resource(), release_notes_version(config::profile())); + // The metainfo gives the latest release, which a development build is + // not: its version carries the commit. + dialog.set_version(config::VERSION); + dialog.set_developers(DEVELOPERS); + // Translators: your names, one per line, as "Name " or "Name https://site". + dialog.set_translator_credits(&gettext("translator-credits")); + dialog.set_debug_info(&debug_info(&RuntimeVersions::current(), drivers)); + dialog.set_debug_info_filename(DEBUG_INFO_FILENAME); + match third_party_licenses::embedded_legal_sections() { + Ok(sections) => third_party_licenses::add_legal_sections(&dialog, §ions), + Err(error) => tracing::warn!(%error, "the About dialog lists no third-party licenses"), + } + dialog +} + +fn metainfo_resource() -> String { + format!("{}/metainfo.xml", config::RESOURCE_BASE_PATH) +} + +/// A development version has no release of its own in the metainfo, and +/// libadwaita reports a version it cannot find as a critical. +fn release_notes_version(profile: Profile) -> Option<&'static str> { + match profile { + Profile::Default => Some(config::VERSION), + Profile::Development => None, + } +} + +#[cfg(test)] +mod tests { + use gtk4 as gtk; + + use super::*; + + fn embedded_metainfo() -> String { + crate::register_test_resources(); + let bytes = gio::resources_lookup_data(&metainfo_resource(), gio::ResourceLookupFlags::NONE) + .expect("the metainfo is embedded at the resource base path"); + String::from_utf8(bytes.to_vec()).expect("the metainfo is UTF-8") + } + + /// The text between the element `open` starts and the next `close`. + fn metainfo_text<'a>(xml: &'a str, open: &str, close: &str) -> &'a str { + let start = xml.find(open).unwrap_or_else(|| panic!("{open} is in the metainfo")); + let content = &xml[start..]; + let content = &content[content.find('>').expect("the element's start tag closes") + 1..]; + let end = content.find(close).unwrap_or_else(|| panic!("{close} follows {open}")); + content[..end].trim() + } + + #[gtk4::test] + fn about_dialog_is_built_from_metainfo() { + adw::init().expect("libadwaita initialises under the test backend"); + let xml = embedded_metainfo(); + let homepage = metainfo_text(&xml, ""); + let bugtracker = metainfo_text(&xml, ""); + let help = metainfo_text(&xml, ""); + let developer = metainfo_text(metainfo_text(&xml, ""), ""); + assert!(!homepage.is_empty() && !bugtracker.is_empty() && !help.is_empty() && !developer.is_empty()); + + let dialog = build_about_dialog(&DriverRegistry::new()); + + assert_eq!(dialog.website(), homepage); + assert_eq!(dialog.issue_url(), bugtracker); + assert_eq!(dialog.support_url(), help); + assert_eq!(dialog.developer_name(), developer); + assert_eq!(dialog.application_icon(), config::APP_ID); + assert_eq!(dialog.version(), config::VERSION); + assert_eq!(dialog.license_type(), gtk::License::Agpl30); + assert_eq!(dialog.application_name(), "TablePro"); + assert_eq!( + dialog.developers().iter().map(|name| name.as_str()).collect::>(), + DEVELOPERS + ); + assert_eq!(dialog.debug_info_filename(), DEBUG_INFO_FILENAME); + assert!(dialog.debug_info().contains(config::APP_ID), "{}", dialog.debug_info()); + } + + #[test] + fn embedded_metainfo_names_the_build_app_id() { + let xml = embedded_metainfo(); + + assert_eq!(metainfo_text(&xml, ""), config::APP_ID); + assert_eq!( + metainfo_text(&xml, ""), + format!("{}.desktop", config::APP_ID) + ); + assert!(!xml.contains("@app-id@") && !xml.contains("@gettext-package@"), "{xml}"); + } + + #[test] + fn release_notes_version_absent_in_development_profile() { + assert_eq!(release_notes_version(Profile::Development), None); + assert_eq!(release_notes_version(Profile::Default), Some(config::VERSION)); + } +} diff --git a/linux/crates/app/src/application/accelerator.rs b/linux/crates/app/src/application/accelerator.rs new file mode 100644 index 0000000000..c4d8025a83 --- /dev/null +++ b/linux/crates/app/src/application/accelerator.rs @@ -0,0 +1,9 @@ +use super::{AcceleratorScope, ShortcutListing}; + +#[derive(Debug, Clone, Copy)] +pub struct Accelerator { + pub action: &'static str, + pub triggers: &'static [&'static str], + pub scope: AcceleratorScope, + pub listing: ShortcutListing, +} diff --git a/linux/crates/app/src/application/accelerator_scope.rs b/linux/crates/app/src/application/accelerator_scope.rs new file mode 100644 index 0000000000..b3cfd71b07 --- /dev/null +++ b/linux/crates/app/src/application/accelerator_scope.rs @@ -0,0 +1,22 @@ +use gtk4 as gtk; + +use super::ShortcutOwner; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AcceleratorScope { + /// Installed with `set_accels_for_action`. GtkWindow runs these in a + /// capture-phase, global controller, so they reach the window before + /// the focused widget does. + Application, + /// Installed by `local_shortcut_controller` on the owner's widget. + Local { + owner: ShortcutOwner, + phase: gtk::PropagationPhase, + }, + /// Bound by the owner's own controller, which reads its keys from the + /// table. + Component(ShortcutOwner), + /// Bound by a GTK or libadwaita class, or by AdwApplication. Listed + /// and checked for collisions, never installed. + Stock, +} diff --git a/linux/crates/app/src/application/accelerators.rs b/linux/crates/app/src/application/accelerators.rs new file mode 100644 index 0000000000..006eeac725 --- /dev/null +++ b/linux/crates/app/src/application/accelerators.rs @@ -0,0 +1,705 @@ +use gtk4 as gtk; +use gtk4::gdk; +use gtk4::prelude::*; + +use super::{Accelerator, AcceleratorScope, ShortcutListing, ShortcutOwner, ShortcutSection}; + +use AcceleratorScope::{Application, Component, Local, Stock}; +use ShortcutListing::{Listed, Unlisted}; +use ShortcutOwner::{ + BrowseGrid, BrowseTab, CellEditor, FilterStrip, GridCell, HistoryRow, PickerRow, SidebarList, SqlEditor, + SqlEditorTab, StructureTab, +}; +use ShortcutSection::{BrowseTable, DataGrid, General, TableStructure, Tabs}; + +const BUBBLE: gtk::PropagationPhase = gtk::PropagationPhase::Bubble; +/// GtkTextView binds Ctrl+/ to select-all, and a bubble controller on +/// the view runs before that binding only while it was added last. +/// Capture runs before every controller the view has. +const CAPTURE: gtk::PropagationPhase = gtk::PropagationPhase::Capture; + +const fn application(action: &'static str, triggers: &'static [&'static str], section: ShortcutSection) -> Accelerator { + Accelerator { + action, + triggers, + scope: Application, + listing: Listed(section), + } +} + +const fn stock(action: &'static str, triggers: &'static [&'static str], listing: ShortcutListing) -> Accelerator { + Accelerator { + action, + triggers, + scope: Stock, + listing, + } +} + +const fn local( + owner: ShortcutOwner, + phase: gtk::PropagationPhase, + action: &'static str, + triggers: &'static [&'static str], + listing: ShortcutListing, +) -> Accelerator { + Accelerator { + action, + triggers, + scope: Local { owner, phase }, + listing, + } +} + +const fn component( + owner: ShortcutOwner, + action: &'static str, + triggers: &'static [&'static str], + listing: ShortcutListing, +) -> Accelerator { + Accelerator { + action, + triggers, + scope: Component(owner), + listing, + } +} + +/// Every key the app binds or relies on. History is on Ctrl+Shift+H, not +/// Ctrl+H: an application accelerator runs before the focused widget, and +/// Ctrl+H is find and replace in a text editor. +pub const ACCELERATORS: &[Accelerator] = &[ + application("app.new-window", &["n"], General), + application("app.preferences", &["comma"], General), + application("win.show-history", &["h"], General), + // As in GNOME Files. + application("win.show-sidebar", &["F9"], General), + application("app.quit", &["q"], General), + // AdwApplication adds the action and its accelerator once it finds + // shortcuts-dialog.ui under the resource base path. + stock("app.shortcuts", &["question"], Listed(General)), + stock("dialog.close", &["Escape"], Listed(General)), + application("win.new-editor-tab", &["t"], Tabs), + application("win.close-current", &["w"], Tabs), + application("win.reopen-closed-tab", &["t"], Tabs), + // AdwTabView's own controller, which runs in the capture phase. The + // workspace removes its Ctrl+Home and Ctrl+End pairs, so the lists + // and the editor inside the tabs keep those keys. + stock( + "tab-view.select-next", + &["Tab", "Page_Down"], + Listed(Tabs), + ), + stock( + "tab-view.select-previous", + &["Tab", "Page_Up"], + Listed(Tabs), + ), + stock("tab-view.move-next", &["Page_Down"], Listed(Tabs)), + stock("tab-view.move-previous", &["Page_Up"], Listed(Tabs)), + stock( + "tab-view.select-number", + &[ + "1", "2", "3", "4", "5", "6", "7", "8", "9", + ], + Unlisted, + ), + stock("tab-view.select-tenth", &["0"], Unlisted), + local(BrowseTab, BUBBLE, "browse.filter", &["f"], Listed(BrowseTable)), + local(BrowseTab, BUBBLE, "browse.refresh", &["F5"], Listed(BrowseTable)), + local(BrowseTab, BUBBLE, "browse.save", &["s"], Listed(BrowseTable)), + local(BrowseTab, BUBBLE, "browse.undo", &["z"], Listed(BrowseTable)), + local( + BrowseTab, + BUBBLE, + "browse.redo", + &["z", "y"], + Listed(BrowseTable), + ), + // GtkListBase binds Page Up and Page Down with no modifier, Ctrl, + // Shift and Ctrl+Shift to move within the rows on screen, so paging + // takes Alt, which no widget in the tab binds. + local( + BrowseTab, + BUBBLE, + "browse.previous-page", + &["Page_Up"], + Listed(BrowseTable), + ), + local( + BrowseTab, + BUBBLE, + "browse.next-page", + &["Page_Down"], + Listed(BrowseTable), + ), + stock("list.select-all", &["a"], Listed(BrowseTable)), + stock("list.unselect-all", &["a"], Listed(BrowseTable)), + stock("list.first-row", &["Home"], Listed(BrowseTable)), + stock("list.last-row", &["End"], Listed(BrowseTable)), + stock( + "list.extend-selection", + &["Pointer_Button1"], + Listed(BrowseTable), + ), + stock( + "list.toggle-selection", + &["Pointer_Button1"], + Listed(BrowseTable), + ), + stock("list.move", &["Up", "Down", "Left", "Right"], Listed(DataGrid)), + component(GridCell, "cell.edit", &["F2", "Return"], Listed(DataGrid)), + component(GridCell, "cell.toggle", &["space"], Listed(DataGrid)), + component(GridCell, "cell.menu", &["Menu", "F10"], Listed(DataGrid)), + component(GridCell, "cell.set-null", &["BackSpace"], Listed(DataGrid)), + stock("text.activate", &["Return"], Listed(DataGrid)), + component(CellEditor, "cell-editor.cancel", &["Escape"], Listed(DataGrid)), + component(CellEditor, "cell-editor.next", &["Tab"], Listed(DataGrid)), + component(CellEditor, "cell-editor.previous", &["Tab"], Listed(DataGrid)), + component( + CellEditor, + "cell-editor.save-text", + &["Return"], + Listed(DataGrid), + ), + component(CellEditor, "cell-editor.pick-date", &["space"], Unlisted), + local( + BrowseGrid, + BUBBLE, + "browse.insert-row", + &["n"], + Listed(DataGrid), + ), + local(BrowseGrid, BUBBLE, "browse.delete-rows", &["Delete"], Listed(DataGrid)), + local( + BrowseGrid, + BUBBLE, + "browse.copy-rows", + &["c"], + Listed(DataGrid), + ), + local(BrowseGrid, BUBBLE, "browse.paste-rows", &["v"], Unlisted), + local( + BrowseGrid, + BUBBLE, + "browse.clear-selection", + &["Escape"], + Listed(DataGrid), + ), + local( + SqlEditor, + CAPTURE, + "editor.run", + &["Return"], + Listed(ShortcutSection::SqlEditor), + ), + local( + SqlEditor, + CAPTURE, + "editor.run-statement", + &["Return"], + Listed(ShortcutSection::SqlEditor), + ), + // Enabled only while a query runs, so otherwise Escape goes on to the + // completion popup. + local( + SqlEditor, + CAPTURE, + "editor.cancel", + &["Escape"], + Listed(ShortcutSection::SqlEditor), + ), + local( + SqlEditor, + CAPTURE, + "editor.format", + &["f"], + Listed(ShortcutSection::SqlEditor), + ), + local( + SqlEditor, + CAPTURE, + "editor.toggle-comment", + &["slash"], + Listed(ShortcutSection::SqlEditor), + ), + // GtkSearchEntry binds Ctrl+G and Ctrl+Shift+G itself, and the find + // bar follows its next-match and previous-match signals. + local( + SqlEditorTab, + BUBBLE, + "editor.find", + &["f"], + Listed(ShortcutSection::SqlEditor), + ), + local( + SqlEditorTab, + BUBBLE, + "editor.find-replace", + &["h"], + Listed(ShortcutSection::SqlEditor), + ), + local( + SqlEditorTab, + BUBBLE, + "editor.find-next", + &["g"], + Listed(ShortcutSection::SqlEditor), + ), + local( + SqlEditorTab, + BUBBLE, + "editor.find-previous", + &["g"], + Listed(ShortcutSection::SqlEditor), + ), + application("win.save-query", &["s"], ShortcutSection::SqlEditor), + local( + StructureTab, + BUBBLE, + "structure.save", + &["s"], + Listed(TableStructure), + ), + local(FilterStrip, BUBBLE, "filter.close", &["Escape"], Unlisted), + local( + SidebarList, + BUBBLE, + "sidebar.context-menu", + &["Menu", "F10"], + Unlisted, + ), + local( + HistoryRow, + BUBBLE, + "history-row.menu", + &["Menu", "F10"], + Unlisted, + ), + local(PickerRow, BUBBLE, "picker-row.menu", &["Menu", "F10"], Unlisted), +]; + +pub fn install_application_accelerators(app: &impl IsA) { + for entry in ACCELERATORS.iter().filter(|entry| entry.scope == Application) { + app.set_accels_for_action(entry.action, entry.triggers); + } +} + +/// The owner's Local entries, each through the action it names, so a +/// disabled action lets its key go on. +pub fn local_shortcut_controller(owner: ShortcutOwner) -> gtk::ShortcutController { + let controller = gtk::ShortcutController::new(); + controller.set_scope(gtk::ShortcutScope::Local); + controller.set_name(Some(controller_name(owner))); + for entry in ACCELERATORS { + let Local { owner: of, phase } = entry.scope else { + continue; + }; + if of != owner { + continue; + } + controller.set_propagation_phase(phase); + for trigger in entry.triggers { + if let Some(trigger) = shortcut_trigger(&[trigger]) { + controller.add_shortcut(gtk::Shortcut::new( + Some(trigger), + Some(gtk::NamedAction::new(entry.action)), + )); + } + } + } + controller +} + +/// The name `local_shortcut_controller` gives the owner's controller. +pub const fn controller_name(owner: ShortcutOwner) -> &'static str { + match owner { + BrowseTab => "tablepro-browse-tab-shortcuts", + BrowseGrid => "tablepro-browse-grid-shortcuts", + GridCell => "tablepro-grid-cell-shortcuts", + CellEditor => "tablepro-cell-editor-shortcuts", + SqlEditor => "tablepro-sql-editor-shortcuts", + SqlEditorTab => "tablepro-sql-editor-tab-shortcuts", + StructureTab => "tablepro-structure-tab-shortcuts", + FilterStrip => "tablepro-filter-strip-shortcuts", + SidebarList => "tablepro-sidebar-list-shortcuts", + HistoryRow => "tablepro-history-row-shortcuts", + PickerRow => "tablepro-picker-row-shortcuts", + } +} + +pub fn triggers_for(action: &str) -> &'static [&'static str] { + ACCELERATORS + .iter() + .find(|entry| entry.action == action) + .map_or(&[], |entry| entry.triggers) +} + +/// The keys for a component's own controller, so what it binds is what +/// the table lists. +pub fn component_trigger(action: &str) -> Option { + shortcut_trigger(triggers_for(action)) +} + +/// For a component that reads its keys from an event controller. +pub fn component_matches(action: &str, keyval: gdk::Key, state: gdk::ModifierType) -> bool { + let modifiers = state & gtk::accelerator_get_default_mod_mask(); + let keyval = keyval.to_lower(); + triggers_for(action) + .iter() + .filter_map(|accel| parse(accel)) + .any(|(key, mods)| mods == modifiers && (key == keyval || keypad_twins(key).contains(&keyval))) +} + +/// GtkPopoverMenu shows an application accelerator by itself; a Local or +/// Component one has to be put on the item. +pub fn menu_item(label: &str, detailed_action: &str) -> gio::MenuItem { + let item = gio::MenuItem::new(Some(label), Some(detailed_action)); + let shown = ACCELERATORS + .iter() + .find(|entry| entry.action == detailed_action && matches!(entry.scope, Local { .. } | Component(_))) + .and_then(|entry| entry.triggers.first()); + if let Some(accel) = shown { + item.set_attribute_value("accel", Some(&accel.to_variant())); + } + item +} + +/// One trigger for all of `accels`, with the keypad twin of each key, as +/// GTK's own classes bind them. +fn shortcut_trigger(accels: &[&str]) -> Option { + accels + .iter() + .filter_map(|accel| parse(accel)) + .flat_map(|(key, modifiers)| { + std::iter::once(key) + .chain(keypad_twins(key).iter().copied()) + .map(move |key| gtk::KeyvalTrigger::new(key, modifiers).upcast::()) + }) + .reduce(|first, second| gtk::AlternativeTrigger::new(first, second).upcast()) +} + +fn parse(accel: &str) -> Option<(gdk::Key, gdk::ModifierType)> { + let parsed = gtk::accelerator_parse(accel); + if parsed.is_none() { + tracing::error!(accel, "an accelerator in the table does not parse; it is not bound"); + } + parsed +} + +pub(crate) fn keypad_twins(key: gdk::Key) -> &'static [gdk::Key] { + match key { + gdk::Key::Return => &[gdk::Key::ISO_Enter, gdk::Key::KP_Enter], + gdk::Key::space => &[gdk::Key::KP_Space], + gdk::Key::Delete => &[gdk::Key::KP_Delete], + _ => &[], + } +} + +#[cfg(test)] +mod tests { + use std::collections::{HashMap, HashSet}; + + use libadwaita as adw; + + use super::*; + use crate::test_support::shortcut_keys::{Keystroke, keystroke, keystrokes_with_twins, shortcuts, trigger_keys}; + + /// Keys a window runs before the focused widget sees them: the + /// application's, and the stock ones AdwApplication and AdwTabView + /// add in the same capture phase. + fn reaches_window_first(entry: &Accelerator) -> bool { + entry.scope == Application || entry.action == "app.shortcuts" || entry.action.starts_with("tab-view.") + } + + fn owner_of(entry: &Accelerator) -> Option { + match entry.scope { + Local { owner, .. } | Component(owner) => Some(owner), + Application | Stock => None, + } + } + + /// The dialog shows these through `action-name`, so it shows the + /// accelerator GTK has installed. + fn shown_by_action_name(entry: &Accelerator) -> bool { + entry.scope == Application || entry.action == "app.shortcuts" + } + + #[gtk4::test] + fn every_trigger_parses() { + for entry in ACCELERATORS { + assert!(!entry.triggers.is_empty(), "{} has no keys", entry.action); + for accel in entry.triggers { + assert!(gtk::accelerator_parse(*accel).is_some(), "{accel} of {}", entry.action); + assert!( + gtk::ShortcutTrigger::parse_string(accel).is_some(), + "{accel} of {}", + entry.action + ); + } + } + } + + #[gtk4::test] + fn no_local_or_component_trigger_shadows_an_application_trigger() { + let first: HashSet = ACCELERATORS + .iter() + .filter(|entry| reaches_window_first(entry)) + .flat_map(|entry| entry.triggers.iter().map(|accel| keystroke(accel))) + .collect(); + assert!(first.contains(&keystroke("w"))); + + for entry in ACCELERATORS { + let Some(owner) = owner_of(entry) else { + continue; + }; + for accel in entry.triggers { + for key in keystrokes_with_twins(accel) { + assert!( + !first.contains(&key), + "{accel} of {} is taken before it reaches {owner:?}", + entry.action + ); + } + } + } + } + + #[gtk4::test] + fn application_scope_avoids_text_editing_and_editor_keys() { + let reserved: HashSet = ["z", "y", "a", "c", "v", "x", "f", "g", "h", "slash"] + .iter() + .map(|key| keystroke(&format!("{key}"))) + .chain( + ["z", "g"] + .iter() + .map(|key| keystroke(&format!("{key}"))), + ) + .collect(); + let with_any_modifier = [ + gdk::Key::F5, + gdk::Key::Escape, + gdk::Key::Return, + gdk::Key::Delete, + gdk::Key::F2, + gdk::Key::BackSpace, + gdk::Key::Home, + gdk::Key::End, + gdk::Key::Tab, + gdk::Key::Page_Up, + gdk::Key::Page_Down, + gdk::Key::Up, + gdk::Key::Down, + gdk::Key::Left, + gdk::Key::Right, + ]; + + for entry in ACCELERATORS.iter().filter(|entry| entry.scope == Application) { + for accel in entry.triggers { + let key = keystroke(accel); + assert!(!reserved.contains(&key), "{} takes {accel}", entry.action); + assert!(!with_any_modifier.contains(&key.0), "{} takes {accel}", entry.action); + } + } + } + + #[gtk4::test] + fn no_duplicate_trigger_within_an_owner() { + let mut seen: HashMap<(Option, Keystroke), &str> = HashMap::new(); + for entry in ACCELERATORS { + let group = match owner_of(entry) { + Some(owner) => Some(owner), + None if reaches_window_first(entry) => None, + None => continue, + }; + for accel in entry.triggers { + for key in keystrokes_with_twins(accel) { + if let Some(other) = seen.insert((group, key), entry.action) { + panic!("{accel} is both {other} and {}", entry.action); + } + } + } + } + } + + #[test] + fn each_action_has_one_entry() { + let mut seen = HashSet::new(); + for entry in ACCELERATORS { + assert!(seen.insert(entry.action), "{} has two entries", entry.action); + } + } + + #[test] + fn each_local_owner_runs_in_one_phase() { + let mut phases: HashMap = HashMap::new(); + for entry in ACCELERATORS { + if let Local { owner, phase } = entry.scope { + let first = *phases.entry(owner).or_insert(phase); + assert_eq!(first, phase, "{owner:?} mixes phases at {}", entry.action); + } + } + } + + #[gtk4::test] + fn local_shortcut_controller_names_each_trigger() { + let mut owners: Vec = Vec::new(); + for entry in ACCELERATORS { + if let Local { owner, .. } = entry.scope + && !owners.contains(&owner) + { + owners.push(owner); + } + } + for owner in owners { + let controller = local_shortcut_controller(owner); + let mut expected = Vec::new(); + for entry in ACCELERATORS { + if let Local { owner: of, phase } = entry.scope + && of == owner + { + assert_eq!(controller.propagation_phase(), phase, "{owner:?}"); + for accel in entry.triggers { + expected.push((entry.action.to_owned(), keystrokes_with_twins(accel))); + } + } + } + + let found: Vec<(String, Vec)> = shortcuts(&controller) + .iter() + .map(|shortcut| { + let action = shortcut + .action() + .and_downcast::() + .map(|action| action.action_name().to_string()) + .unwrap_or_default(); + let keys = shortcut + .trigger() + .map(|trigger| trigger_keys(&trigger)) + .unwrap_or_default(); + (action, keys) + }) + .collect(); + + assert_eq!(found, expected, "{owner:?}"); + assert_eq!(controller.scope(), gtk::ShortcutScope::Local); + assert_eq!(controller.name().as_deref(), Some(controller_name(owner))); + } + } + + #[gtk4::test] + fn menu_item_shows_local_and_component_keys() { + let accel = |item: &gio::MenuItem| { + item.attribute_value("accel", None) + .and_then(|value| value.get::()) + }; + + assert_eq!( + accel(&menu_item("Set to NULL", "cell.set-null")).as_deref(), + Some("BackSpace") + ); + assert_eq!(accel(&menu_item("Edit cell", "cell.edit")).as_deref(), Some("F2")); + assert_eq!( + accel(&menu_item("New window", "app.new-window")), + None, + "GTK shows its own" + ); + assert_eq!(accel(&menu_item("Copy", "cell.copy")), None); + } + + #[gtk4::test] + fn component_matches_reads_the_table_and_keypad_twins() { + let none = gdk::ModifierType::empty(); + + assert!(component_matches("cell-editor.pick-date", gdk::Key::space, none)); + assert!(component_matches("cell-editor.pick-date", gdk::Key::KP_Space, none)); + assert!(component_matches( + "cell-editor.pick-date", + gdk::Key::space, + gdk::ModifierType::LOCK_MASK + )); + assert!(!component_matches( + "cell-editor.pick-date", + gdk::Key::space, + gdk::ModifierType::CONTROL_MASK + )); + assert!(!component_matches("cell-editor.pick-date", gdk::Key::Return, none)); + } + + #[gtk4::test] + fn shortcuts_dialog_matches_accelerator_table() { + adw::init().expect("libadwaita initialises under the test backend"); + crate::register_test_resources(); + let builder = + gtk::Builder::from_resource(&format!("{}/shortcuts-dialog.ui", crate::config::RESOURCE_BASE_PATH)); + let sections = [ + (ShortcutSection::General, "section-general"), + (ShortcutSection::Tabs, "section-tabs"), + (ShortcutSection::BrowseTable, "section-browse-table"), + (ShortcutSection::DataGrid, "section-data-grid"), + (ShortcutSection::SqlEditor, "section-sql-editor"), + (ShortcutSection::TableStructure, "section-table-structure"), + ]; + let mut placed: Vec<(adw::ShortcutsItem, ShortcutSection)> = Vec::new(); + for (section, id) in sections { + let list = builder + .object::(id) + .unwrap_or_else(|| panic!("the dialog has no {id}")); + for position in 0..list.n_items() { + let item = list + .item(position) + .and_downcast::() + .expect("a shortcut item"); + placed.push((item, section)); + } + } + let objects = builder.objects(); + assert_eq!( + objects + .iter() + .filter(|object| object.is::()) + .count(), + sections.len(), + "a section the table does not know" + ); + assert_eq!( + objects + .iter() + .filter(|object| object.is::()) + .count(), + placed.len(), + "an item outside the sections" + ); + + let mut listed = Vec::new(); + for entry in ACCELERATORS { + let Listed(section) = entry.listing else { + continue; + }; + let id = entry.action.replace('.', "-"); + let item = builder + .object::(&id) + .unwrap_or_else(|| panic!("the dialog does not list {}", entry.action)); + let found = placed + .iter() + .find(|(placed, _)| placed == &item) + .map(|(_, section)| *section); + assert_eq!(found, Some(section), "{} is in another section", entry.action); + if shown_by_action_name(entry) { + assert_eq!(item.action_name(), entry.action); + assert_eq!(item.accelerator(), "", "{}", entry.action); + } else { + assert_eq!(item.accelerator(), entry.triggers.join(" "), "{}", entry.action); + assert_eq!(item.action_name(), "", "{}", entry.action); + } + listed.push(item); + } + for (item, _) in &placed { + assert!( + listed.contains(item), + "the dialog lists {:?}, which the table does not", + item.title() + ); + } + } +} diff --git a/linux/crates/app/src/application/app_actions.rs b/linux/crates/app/src/application/app_actions.rs index eeb3bfc9a9..90df868e73 100644 --- a/linux/crates/app/src/application/app_actions.rs +++ b/linux/crates/app/src/application/app_actions.rs @@ -1,21 +1,10 @@ -use gtk4 as gtk; use gtk4::prelude::*; -use libadwaita as adw; use libadwaita::prelude::*; +use super::about_dialog::build_about_dialog; 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, _, _| { @@ -29,12 +18,9 @@ pub fn install_app_actions(app: &TableProApplication) { .activate(|app: &TableProApplication, _, _| present_preferences(app)) .build(); let about = gio::ActionEntry::builder("about") - .activate(|app: &TableProApplication, _, _| about_dialog().present(app.active_window().as_ref())) + .activate(|app: &TableProApplication, _, _| present_about(app)) .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) { @@ -46,126 +32,14 @@ fn present_preferences(app: &TableProApplication) { &services.storage, services.history().store(), &services.tasks, + services.file_revealer.clone(), ) .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"); - } - } +fn present_about(app: &TableProApplication) { + let Some(services) = app.services() else { + return; + }; + build_about_dialog(&services.drivers).present(app.active_window().as_ref()); } diff --git a/linux/crates/app/src/application/app_services.rs b/linux/crates/app/src/application/app_services.rs index d57252d11d..37a0a0c78b 100644 --- a/linux/crates/app/src/application/app_services.rs +++ b/linux/crates/app/src/application/app_services.rs @@ -18,9 +18,11 @@ 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::saved_connection_list::SavedConnectionList; use crate::services::session_context::{CredentialSourceFactory, KeyringSources, SessionContext}; use crate::storage::{AppStorage, SharedStorage}; use crate::ui::credential_prompt::{GtkCredentialPrompter, PromptQueue}; +use crate::ui::file_reveal::{FileLauncherRevealer, FileRevealer}; use crate::workspace::OpenSessions; /// What every window of the application shares. GTK-thread only. @@ -33,6 +35,10 @@ pub struct AppServices { pub settings: Rc, pub storage: SharedStorage, pub tasks: Tasks, + /// The saved connections every picker shows, following the store. + pub saved_connections: Rc, + /// Shows a file selected in the file manager. + pub(crate) file_revealer: Rc, /// Where each session's secrets come from. credentials: Rc, /// Bound to the application once it exists, which is after this. @@ -70,6 +76,7 @@ impl AppServices { } = parts; let application_hold = Rc::new(GioProcessHold::default()); let open_sessions = OpenSessions::new(storage.workspaces().clone(), application_hold.clone()); + let saved_connections = SavedConnectionList::follow(storage.connections()); Self { drivers: Arc::new(drivers), connector, @@ -80,6 +87,8 @@ impl AppServices { credentials: Rc::new(KeyringSources::new(storage.secrets().clone())), storage: Rc::new(storage), tasks, + saved_connections, + file_revealer: Rc::new(FileLauncherRevealer), hold: application_hold.clone(), application_hold, open_sessions, diff --git a/linux/crates/app/src/application/debug_info.rs b/linux/crates/app/src/application/debug_info.rs new file mode 100644 index 0000000000..a54a5c9b70 --- /dev/null +++ b/linux/crates/app/src/application/debug_info.rs @@ -0,0 +1,103 @@ +use tablepro_core::DriverRegistry; + +use super::runtime_versions::RuntimeVersions; +use crate::config::{self, Profile}; + +/// The Troubleshooting text of the About dialog. A user pastes it into a +/// public bug report, so it takes nothing that could hold a connection, +/// a host, a user name or a path. +pub fn debug_info(versions: &RuntimeVersions, drivers: &DriverRegistry) -> String { + let mut drivers: Vec<_> = drivers + .iter() + .map(|driver| (driver.id(), driver.display_name())) + .collect(); + // The registry is a hash map; a report reads the same each time. + drivers.sort_unstable(); + + let mut lines = vec![ + format!("TablePro {}", config::VERSION), + format!("Application ID: {}", config::APP_ID), + format!("Profile: {}", profile_name(config::profile())), + String::new(), + format!("GTK: {}", dotted(versions.gtk)), + format!("libadwaita: {}", dotted(versions.adwaita)), + format!("GtkSourceView: {}", dotted(versions.sourceview)), + format!("Sandbox: {}", if versions.flatpak { "Flatpak" } else { "none" }), + String::new(), + "Drivers:".to_owned(), + ]; + lines.extend(drivers.into_iter().map(|(id, name)| format!("- {id}: {name}"))); + lines.push(String::new()); + lines.join("\n") +} + +fn profile_name(profile: Profile) -> &'static str { + match profile { + Profile::Default => "default", + Profile::Development => "development", + } +} + +fn dotted((major, minor, micro): (u32, u32, u32)) -> String { + format!("{major}.{minor}.{micro}") +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use super::*; + + #[test] + fn debug_info_lists_versions_and_drivers_only() { + let versions = RuntimeVersions { + gtk: (4, 22, 1), + adwaita: (1, 9, 2), + sourceview: (5, 18, 0), + flatpak: true, + }; + let mut drivers = DriverRegistry::new(); + drivers.register(Arc::new(drivers_sqlite::SqliteDriver)); + drivers.register(Arc::new(drivers_postgres::PgDriver)); + + let expected = format!( + "TablePro {version}\n\ + Application ID: {app_id}\n\ + Profile: {profile}\n\ + \n\ + GTK: 4.22.1\n\ + libadwaita: 1.9.2\n\ + GtkSourceView: 5.18.0\n\ + Sandbox: Flatpak\n\ + \n\ + Drivers:\n\ + - postgres: PostgreSQL\n\ + - sqlite: SQLite\n", + version = config::VERSION, + app_id = config::APP_ID, + profile = profile_name(config::profile()), + ); + assert_eq!(debug_info(&versions, &drivers), expected); + } + + #[test] + fn debug_info_says_when_there_is_no_sandbox() { + let versions = RuntimeVersions { + gtk: (4, 22, 0), + adwaita: (1, 9, 0), + sourceview: (5, 18, 0), + flatpak: false, + }; + + let text = debug_info(&versions, &DriverRegistry::new()); + + assert!(text.contains("\nSandbox: none\n"), "{text}"); + assert!(text.ends_with("Drivers:\n"), "{text}"); + } + + #[test] + fn profile_names_match_the_meson_option() { + assert_eq!(profile_name(Profile::Default), "default"); + assert_eq!(profile_name(Profile::Development), "development"); + } +} diff --git a/linux/crates/app/src/application/imp.rs b/linux/crates/app/src/application/imp.rs index f7c311add8..f854373e31 100644 --- a/linux/crates/app/src/application/imp.rs +++ b/linux/crates/app/src/application/imp.rs @@ -10,7 +10,7 @@ use libadwaita as adw; use libadwaita::subclass::prelude::*; use super::session_signals::SessionSignals; -use super::{AppServices, WindowLaunch, WindowRegistry, install_app_actions}; +use super::{AppServices, WindowLaunch, WindowRegistry, install_app_actions, install_application_accelerators}; use crate::workspace::CloseDecision; pub(super) const NEW_WINDOW_OPTION: &str = "new-window"; @@ -75,6 +75,7 @@ impl ApplicationImpl for TableProApplication { self.parent_startup(); let app = self.obj(); install_app_actions(&app); + install_application_accelerators(&*app); if let Some(services) = self.services.get() { services.start(); self.session_signals diff --git a/linux/crates/app/src/application/legal_section.rs b/linux/crates/app/src/application/legal_section.rs new file mode 100644 index 0000000000..0b7a3414ef --- /dev/null +++ b/linux/crates/app/src/application/legal_section.rs @@ -0,0 +1,49 @@ +use gtk4 as gtk; + +/// One block on the Legal page of the About dialog. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LegalSection { + pub title: String, + pub license_type: gtk::License, + /// Set only for `gtk::License::Custom`: for a known type the dialog + /// names the license and links to it. + pub license_text: Option, +} + +impl LegalSection { + /// The dialog sets a custom license as Pango markup, and a copyright + /// line such as `Name ` would blank the section. + pub fn license_markup(&self) -> Option { + self.license_text.as_deref().map(glib::markup_escape_text) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn license_markup_escapes_the_text() { + let section = LegalSection { + title: "a".to_owned(), + license_type: gtk::License::Custom, + license_text: Some("Copyright 2020 A & B".to_owned()), + }; + + assert_eq!( + section.license_markup().as_deref(), + Some("Copyright 2020 A <a@example.org> & B") + ); + } + + #[test] + fn license_markup_is_absent_without_a_text() { + let section = LegalSection { + title: "a".to_owned(), + license_type: gtk::License::MitX11, + license_text: None, + }; + + assert_eq!(section.license_markup(), None); + } +} diff --git a/linux/crates/app/src/application/mod.rs b/linux/crates/app/src/application/mod.rs index 033c60dabf..fefb7486f0 100644 --- a/linux/crates/app/src/application/mod.rs +++ b/linux/crates/app/src/application/mod.rs @@ -1,13 +1,24 @@ //! The GApplication: one process per profile, the actions every window //! shares, and the windows themselves. +mod about_dialog; +mod accelerator; +mod accelerator_scope; +mod accelerators; mod app_actions; mod app_services; mod app_window_host; +mod debug_info; mod imp; +mod legal_section; mod open_files; mod quit_sequence; +mod runtime_versions; mod session_signals; +mod shortcut_listing; +mod shortcut_owner; +mod shortcut_section; +mod third_party_licenses; mod window_host; mod window_launch; mod window_registry; @@ -21,10 +32,21 @@ use gtk4::subclass::prelude::*; use libadwaita as adw; use tablepro_session::SessionKey; -pub use app_actions::{APP_ACCELS, install_app_actions}; +pub use accelerator::Accelerator; +pub use accelerator_scope::AcceleratorScope; +#[cfg(test)] +pub(crate) use accelerators::keypad_twins; +pub use accelerators::{ + ACCELERATORS, component_matches, component_trigger, controller_name, install_application_accelerators, + local_shortcut_controller, menu_item, triggers_for, +}; +pub use app_actions::install_app_actions; pub use app_services::{AppServices, AppServicesParts}; pub use app_window_host::AppWindowHost; pub use quit_sequence::QuitSequence; +pub use shortcut_listing::ShortcutListing; +pub use shortcut_owner::ShortcutOwner; +pub use shortcut_section::ShortcutSection; pub use window_host::WindowHost; pub use window_launch::WindowLaunch; pub(crate) use window_registry::WindowRegistry; diff --git a/linux/crates/app/src/application/runtime_versions.rs b/linux/crates/app/src/application/runtime_versions.rs new file mode 100644 index 0000000000..45f9421d1d --- /dev/null +++ b/linux/crates/app/src/application/runtime_versions.rs @@ -0,0 +1,47 @@ +use std::path::Path; + +use gtk4 as gtk; +use libadwaita as adw; + +/// The toolkit libraries the process loaded, which can be newer than the +/// ones it was built against. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct RuntimeVersions { + pub gtk: (u32, u32, u32), + pub adwaita: (u32, u32, u32), + pub sourceview: (u32, u32, u32), + pub flatpak: bool, +} + +impl RuntimeVersions { + /// GTK thread only, once GTK is initialised: the bindings assert both. + pub fn current() -> Self { + Self { + gtk: (gtk::major_version(), gtk::minor_version(), gtk::micro_version()), + adwaita: (adw::major_version(), adw::minor_version(), adw::micro_version()), + sourceview: ( + sourceview5::major_version(), + sourceview5::minor_version(), + sourceview5::micro_version(), + ), + // Flatpak writes this file at the root of every sandbox. + flatpak: Path::new("/.flatpak-info").exists(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[gtk4::test] + fn current_versions_meet_the_platform_floor() { + adw::init().expect("libadwaita initialises under the test backend"); + + let versions = RuntimeVersions::current(); + + assert!(versions.gtk >= (4, 22, 0), "{:?}", versions.gtk); + assert!(versions.adwaita >= (1, 9, 0), "{:?}", versions.adwaita); + assert!(versions.sourceview >= (5, 18, 0), "{:?}", versions.sourceview); + } +} diff --git a/linux/crates/app/src/application/shortcut_listing.rs b/linux/crates/app/src/application/shortcut_listing.rs new file mode 100644 index 0000000000..5f3a794d82 --- /dev/null +++ b/linux/crates/app/src/application/shortcut_listing.rs @@ -0,0 +1,7 @@ +use super::ShortcutSection; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ShortcutListing { + Listed(ShortcutSection), + Unlisted, +} diff --git a/linux/crates/app/src/application/shortcut_owner.rs b/linux/crates/app/src/application/shortcut_owner.rs new file mode 100644 index 0000000000..4cf3d2c92c --- /dev/null +++ b/linux/crates/app/src/application/shortcut_owner.rs @@ -0,0 +1,22 @@ +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ShortcutOwner { + BrowseTab, + /// The browse tab's column view. + BrowseGrid, + /// A focused grid cell, through the grid's capture-phase controller. + GridCell, + /// A cell's open inline, multi-line or date editor. + CellEditor, + /// The SQL editor's source view. + SqlEditor, + /// The whole SQL editor tab, its find bar included. + SqlEditorTab, + StructureTab, + FilterStrip, + /// A row of the sidebar's table list. + SidebarList, + /// A row of the query history dialog. + HistoryRow, + /// A saved connection's row in the picker. + PickerRow, +} diff --git a/linux/crates/app/src/application/shortcut_section.rs b/linux/crates/app/src/application/shortcut_section.rs new file mode 100644 index 0000000000..219b422a26 --- /dev/null +++ b/linux/crates/app/src/application/shortcut_section.rs @@ -0,0 +1,10 @@ +/// A section of the shortcuts dialog. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ShortcutSection { + General, + Tabs, + BrowseTable, + DataGrid, + SqlEditor, + TableStructure, +} diff --git a/linux/crates/app/src/application/tests.rs b/linux/crates/app/src/application/tests.rs index 9d98d38b95..4da14d2118 100644 --- a/linux/crates/app/src/application/tests.rs +++ b/linux/crates/app/src/application/tests.rs @@ -253,9 +253,35 @@ fn startup_installs_the_app_actions_and_their_accels() { 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 installed_application_accels_match_table() { + use crate::test_support::shortcut_keys::keystroke; + let test = TestApp::started("Accels"); + let installed = |action: &str| -> Vec<_> { + test.app + .accels_for_action(action) + .iter() + .map(|accel| keystroke(accel)) + .collect() + }; + + let application: Vec<_> = ACCELERATORS + .iter() + .filter(|entry| entry.scope == AcceleratorScope::Application) + .collect(); + assert!(application.iter().any(|entry| entry.action == "win.close-current")); + for entry in application { + let table: Vec<_> = entry.triggers.iter().map(|accel| keystroke(accel)).collect(); + assert_eq!(installed(entry.action), table, "{}", entry.action); } + let shortcuts: Vec<_> = triggers_for("app.shortcuts") + .iter() + .map(|accel| keystroke(accel)) + .collect(); + assert_eq!(shortcuts, [keystroke("question")]); + assert_eq!(installed("app.shortcuts"), shortcuts); } #[gtk4::test] diff --git a/linux/crates/app/src/application/third_party_licenses.rs b/linux/crates/app/src/application/third_party_licenses.rs new file mode 100644 index 0000000000..0dfbc40726 --- /dev/null +++ b/linux/crates/app/src/application/third_party_licenses.rs @@ -0,0 +1,294 @@ +use std::collections::{BTreeMap, BTreeSet}; + +use gtk4 as gtk; +use libadwaita as adw; +use serde::Deserialize; +use thiserror::Error; + +use super::legal_section::LegalSection; +use crate::config; + +#[derive(Debug, Error)] +pub enum ThirdPartyLicensesError { + #[error("could not read the embedded third-party licenses: {0}")] + Lookup(#[source] glib::Error), + #[error("could not parse the embedded third-party licenses: {0}")] + Parse(#[source] serde_json::Error), +} + +/// The shape `build-aux/about/third-party.json.hbs` writes. +#[derive(Deserialize)] +struct Notices { + licenses: Vec, +} + +#[derive(Deserialize)] +struct LicenseRecord { + id: String, + text: String, + used_by: Vec, +} + +#[derive(Deserialize)] +struct UsedBy { + #[serde(rename = "crate")] + krate: CrateRecord, +} + +#[derive(Deserialize)] +struct CrateRecord { + name: String, +} + +#[derive(Default)] +struct LicenseGroup { + crates: BTreeSet, + texts: Vec, +} + +pub fn resource_path() -> String { + format!("{}/licenses/third-party.json", config::RESOURCE_BASE_PATH) +} + +pub fn embedded_legal_sections() -> Result, ThirdPartyLicensesError> { + let json = gio::resources_lookup_data(&resource_path(), gio::ResourceLookupFlags::NONE) + .map_err(ThirdPartyLicensesError::Lookup)?; + legal_sections(&json) +} + +/// One section per license, titled with the crates under it. +pub fn legal_sections(json: &[u8]) -> Result, ThirdPartyLicensesError> { + let notices: Notices = serde_json::from_slice(json).map_err(ThirdPartyLicensesError::Parse)?; + // cargo-about gives each distinct text of a license its own entry, + // since the copyright lines differ from crate to crate. + let mut groups: BTreeMap = BTreeMap::new(); + for record in notices.licenses { + let group = groups.entry(record.id).or_default(); + group + .crates + .extend(record.used_by.into_iter().map(|used| used.krate.name)); + if !group.texts.contains(&record.text) { + group.texts.push(record.text); + } + } + Ok(groups + .into_iter() + .map(|(id, group)| { + let license_type = gtk_license(&id); + LegalSection { + title: group.crates.into_iter().collect::>().join(", "), + license_type, + license_text: (license_type == gtk::License::Custom).then(|| group.texts.join("\n\n")), + } + }) + .collect()) +} + +pub fn add_legal_sections(dialog: &adw::AboutDialog, sections: &[LegalSection]) { + for section in sections { + dialog.add_legal_section( + §ion.title, + None, + section.license_type, + section.license_markup().as_deref(), + ); + } +} + +/// The licenses GTK can name itself. Any other is shown as its text. +fn gtk_license(spdx_id: &str) -> gtk::License { + match spdx_id { + "MIT" => gtk::License::MitX11, + "Apache-2.0" => gtk::License::Apache20, + "BSD-2-Clause" => gtk::License::Bsd, + "BSD-3-Clause" => gtk::License::Bsd3, + "MPL-2.0" => gtk::License::Mpl20, + "LGPL-2.1-or-later" => gtk::License::Lgpl21, + "0BSD" => gtk::License::_0bsd, + _ => gtk::License::Custom, + } +} + +#[cfg(test)] +mod tests { + use gtk4::prelude::*; + use libadwaita::prelude::AdwDialogExt; + use tablepro_core::DriverRegistry; + + use super::*; + use crate::application::about_dialog::build_about_dialog; + use crate::test_support::{descendants, drain_main_context}; + + const FIXTURE: &str = r#"{ + "licenses": [ + { + "id": "ISC", + "name": "ISC License", + "text": "ISC text one", + "used_by": [{ "crate": { "name": "untrusted", "version": "0.9.0" } }] + }, + { + "id": "ISC", + "name": "ISC License", + "text": "ISC text two", + "used_by": [{ "crate": { "name": "aws-lc-sys", "version": "0.32.0" } }] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "MIT text one", + "used_by": [ + { "crate": { "name": "serde", "version": "1.0.0" } }, + { "crate": { "name": "anyhow", "version": "1.0.0" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "MIT text two", + "used_by": [ + { "crate": { "name": "windows-sys", "version": "0.52.0" } }, + { "crate": { "name": "windows-sys", "version": "0.59.0" } }, + { "crate": { "name": "serde", "version": "1.0.0" } } + ] + }, + { + "id": "Apache-2.0", + "name": "Apache License 2.0", + "text": "Apache text", + "used_by": [{ "crate": { "name": "fragile", "version": "2.1.0" } }] + } + ] + }"#; + + #[test] + fn spdx_maps_to_gtk_license() { + let expected = [ + ("MIT", gtk::License::MitX11), + ("Apache-2.0", gtk::License::Apache20), + ("BSD-2-Clause", gtk::License::Bsd), + ("BSD-3-Clause", gtk::License::Bsd3), + ("MPL-2.0", gtk::License::Mpl20), + ("LGPL-2.1-or-later", gtk::License::Lgpl21), + ("0BSD", gtk::License::_0bsd), + ("Unicode-3.0", gtk::License::Custom), + ("ISC", gtk::License::Custom), + ]; + for (spdx_id, license) in expected { + assert_eq!(gtk_license(spdx_id), license, "{spdx_id}"); + } + } + + #[test] + fn legal_sections_from_fixture() { + let sections = legal_sections(FIXTURE.as_bytes()).expect("the fixture parses"); + + assert_eq!( + sections, + vec![ + LegalSection { + title: "fragile".to_owned(), + license_type: gtk::License::Apache20, + license_text: None, + }, + LegalSection { + title: "aws-lc-sys, untrusted".to_owned(), + license_type: gtk::License::Custom, + license_text: Some("ISC text one\n\nISC text two".to_owned()), + }, + LegalSection { + title: "anyhow, serde, windows-sys".to_owned(), + license_type: gtk::License::MitX11, + license_text: None, + }, + ] + ); + } + + #[test] + fn legal_sections_rejects_malformed_json() { + for json in [&b"{"[..], br#"{ "licenses": [{ "id": "MIT" }] }"#, b"[]"] { + assert!( + matches!(legal_sections(json), Err(ThirdPartyLicensesError::Parse(_))), + "{}", + String::from_utf8_lossy(json) + ); + } + } + + /// Every SPDX id in the embedded file, read without the parser under test. + fn embedded_license_ids() -> BTreeSet { + crate::register_test_resources(); + let json = gio::resources_lookup_data(&resource_path(), gio::ResourceLookupFlags::NONE) + .expect("the third-party licenses are embedded"); + let value: serde_json::Value = serde_json::from_slice(&json).expect("the embedded file is JSON"); + value["licenses"] + .as_array() + .expect("a licenses array") + .iter() + .map(|license| license["id"].as_str().expect("an id").to_owned()) + .collect() + } + + /// What every label of `dialog` reads once it is shown, which is when + /// it builds its pages. + fn presented_labels(dialog: &adw::AboutDialog) -> Vec { + let window = adw::Window::new(); + window.present(); + dialog.present(Some(&window)); + drain_main_context(); + let labels = descendants(dialog) + .into_iter() + .filter_map(|widget| widget.downcast::().ok()) + .map(|label| label.text().to_string()) + .collect(); + dialog.force_close(); + window.destroy(); + labels + } + + #[gtk4::test] + fn custom_license_text_shows_as_written() { + adw::init().expect("libadwaita initialises under the test backend"); + let text = "Copyright 2020 A & B"; + let dialog = adw::AboutDialog::new(); + let section = LegalSection { + title: "example".to_owned(), + license_type: gtk::License::Custom, + license_text: Some(text.to_owned()), + }; + + add_legal_sections(&dialog, &[section]); + + let labels = presented_labels(&dialog); + assert!(labels.iter().any(|label| label == "example"), "{labels:?}"); + assert!(labels.iter().any(|label| label == text), "{labels:?}"); + } + + #[gtk4::test] + fn about_dialog_shows_every_embedded_section() { + adw::init().expect("libadwaita initialises under the test backend"); + crate::register_test_resources(); + let sections = embedded_legal_sections().expect("the embedded licenses parse"); + + let labels = presented_labels(&build_about_dialog(&DriverRegistry::new())); + + for section in §ions { + assert!(labels.contains(§ion.title), "no heading {}", section.title); + if let Some(text) = §ion.license_text { + assert!(labels.contains(text), "no text under {}", section.title); + } + } + } + + #[test] + fn embedded_licenses_give_one_section_per_license() { + let ids = embedded_license_ids(); + assert!(ids.contains("MIT"), "{ids:?}"); + + let sections = embedded_legal_sections().expect("the embedded licenses parse"); + + assert_eq!(sections.len(), ids.len()); + assert!(sections.iter().all(|section| !section.title.is_empty())); + } +} diff --git a/linux/crates/app/src/runtime/failed_requests.rs b/linux/crates/app/src/runtime/failed_requests.rs index 141c7ecd6e..b12a1ca329 100644 --- a/linux/crates/app/src/runtime/failed_requests.rs +++ b/linux/crates/app/src/runtime/failed_requests.rs @@ -19,6 +19,12 @@ impl FailedRequests { }; } + /// A request that was never asked, because the one it waited on + /// failed first. + pub fn insert(&mut self, kind: K) { + self.kinds.insert(kind); + } + pub fn contains(&self, kind: K) -> bool { self.kinds.contains(&kind) } diff --git a/linux/crates/app/src/runtime/request_sequencer.rs b/linux/crates/app/src/runtime/request_sequencer.rs index ba2dc3b1e5..942e60252e 100644 --- a/linux/crates/app/src/runtime/request_sequencer.rs +++ b/linux/crates/app/src/runtime/request_sequencer.rs @@ -56,6 +56,14 @@ impl RequestSequencer { self.current.contains_key(&kind) } + /// Stop the request of this kind still running; its reply is then + /// refused. + pub fn cancel(&mut self, kind: K) { + if let Some((_, cancel)) = self.current.remove(&kind) { + cancel.cancel(); + } + } + pub fn cancel_all(&mut self) { for (_, (_, cancel)) in self.current.drain() { cancel.cancel(); @@ -135,6 +143,20 @@ mod tests { assert!(sequencer.is_pending(Kind::Page)); } + #[test] + fn cancel_stops_only_its_kind() { + let mut sequencer = RequestSequencer::new(); + let page = sequencer.issue(Kind::Page); + let count = sequencer.issue(Kind::Count); + + sequencer.cancel(Kind::Count); + + assert!(count.cancel.is_cancelled()); + assert!(!sequencer.accept(Kind::Count, count.stamp)); + assert!(!page.cancel.is_cancelled()); + assert!(sequencer.accept(Kind::Page, page.stamp)); + } + #[test] fn cancel_all_stops_every_kind() { let mut sequencer = RequestSequencer::new(); diff --git a/linux/crates/app/src/services/change_origins.rs b/linux/crates/app/src/services/change_origins.rs new file mode 100644 index 0000000000..8ef43b7680 --- /dev/null +++ b/linux/crates/app/src/services/change_origins.rs @@ -0,0 +1,49 @@ +use tablepro_core::save::{ChangeSet, RowChange}; + +/// How many rows a save adds and removes, which is what it does to the +/// table's row count. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct ChangeOrigins { + pub inserts: u64, + pub deletes: u64, +} + +impl ChangeOrigins { + pub fn of(changes: &ChangeSet) -> Self { + changes.changes.iter().fold(Self::default(), |mut origins, change| { + match change { + RowChange::Insert { .. } => origins.inserts += 1, + RowChange::Delete { .. } => origins.deletes += 1, + RowChange::Update(_) => {} + } + origins + }) + } +} + +#[cfg(test)] +mod tests { + use tablepro_core::Value; + use tablepro_core::save::RowUpdate; + + use super::*; + + #[test] + fn a_save_counts_its_inserts_and_deletes_but_not_its_updates() { + let changes = ChangeSet { + changes: vec![ + RowChange::Insert { cells: Vec::new() }, + RowChange::Update(RowUpdate { + key: vec![Value::Int(1)], + assignments: Vec::new(), + }), + RowChange::Delete { + key: vec![Value::Int(2)], + }, + RowChange::Insert { cells: Vec::new() }, + ], + }; + + assert_eq!(ChangeOrigins::of(&changes), ChangeOrigins { inserts: 2, deletes: 1 }); + } +} diff --git a/linux/crates/app/src/services/change_tracker.rs b/linux/crates/app/src/services/change_tracker.rs index a44bb5caa4..59bcdf10b4 100644 --- a/linux/crates/app/src/services/change_tracker.rs +++ b/linux/crates/app/src/services/change_tracker.rs @@ -105,6 +105,17 @@ pub enum StatementSource { Delete { row_key: RowKey }, } +/// A flash `flash_error_row` started. +#[derive(Debug, Clone, PartialEq)] +#[must_use = "the row a flash replaced keeps its mark until it is redrawn"] +pub struct ErrorFlash { + /// Goes back to `clear_error_row_if` when the flash ends. + pub generation: u64, + /// Another row's flash this one cut short. That flash's own timer + /// no longer clears it, so its row needs a redraw now. + pub replaced: Option, +} + /// The save that is running: where its edits end, and the value it /// writes to each cell. #[derive(Debug)] @@ -456,12 +467,10 @@ impl TabChangeTracker { Some(delta) } - #[cfg(test)] pub fn can_undo(&self) -> bool { !self.undo.is_empty() } - #[cfg(test)] pub fn can_redo(&self) -> bool { !self.redo.is_empty() } @@ -497,7 +506,7 @@ impl TabChangeTracker { CellOverlay { pending: self.pending_input(row_key, col), state: self.cell_state(row_key, col), - error_flash: self.error_row.as_ref() == Some(row_key), + error_flash: self.is_error_row(row_key), } } @@ -556,12 +565,19 @@ impl TabChangeTracker { &self.drafts } - /// Mark the row a failed save points at. The returned generation - /// goes back to `clear_error_row_if` when the flash ends. - pub fn flash_error_row(&mut self, key: RowKey) -> u64 { + /// Mark the row a failed save points at. + pub fn flash_error_row(&mut self, key: RowKey) -> ErrorFlash { self.error_row_gen = self.error_row_gen.wrapping_add(1); + let replaced = self.error_row.take().filter(|previous| *previous != key); self.error_row = Some(key); - self.error_row_gen + ErrorFlash { + generation: self.error_row_gen, + replaced, + } + } + + pub fn is_error_row(&self, key: &RowKey) -> bool { + self.error_row.as_ref() == Some(key) } /// Clear the flash unless a newer one has started. Returns whether @@ -2117,8 +2133,8 @@ mod tests { let mut t = TabChangeTracker::default(); let first = rk(&[Value::Int(1)]); let second = RowKey::Draft(3); - let old = t.flash_error_row(first.clone()); - let new = t.flash_error_row(second.clone()); + let old = t.flash_error_row(first.clone()).generation; + let new = t.flash_error_row(second.clone()).generation; assert!(!t.clear_error_row_if(old)); assert!(t.cell_overlay(&second, 0).error_flash); @@ -2126,4 +2142,22 @@ mod tests { assert!(t.clear_error_row_if(new)); assert!(!t.cell_overlay(&second, 0).error_flash); } + + #[test] + fn a_flash_names_the_row_whose_flash_it_cut_short() { + let mut t = TabChangeTracker::default(); + let first = rk(&[Value::Int(1)]); + let second = RowKey::Draft(3); + + assert_eq!(t.flash_error_row(first.clone()).replaced, None); + assert_eq!(t.flash_error_row(first.clone()).replaced, None, "a row replaced itself"); + assert_eq!(t.flash_error_row(second.clone()).replaced, Some(first)); + let ended = t.flash_error_row(second).generation; + assert!(t.clear_error_row_if(ended)); + assert_eq!( + t.flash_error_row(RowKey::Draft(4)).replaced, + None, + "an ended flash was replaced" + ); + } } diff --git a/linux/crates/app/src/services/change_tracker_registry.rs b/linux/crates/app/src/services/change_tracker_registry.rs index 5784f2d96d..60c59ea93d 100644 --- a/linux/crates/app/src/services/change_tracker_registry.rs +++ b/linux/crates/app/src/services/change_tracker_registry.rs @@ -1,14 +1,8 @@ -use super::tab_registry::{TabRegistry, TrackerHandle}; +use super::tab_registry::TabRegistry; use super::tab_tracker_handle::TabTrackerHandle; pub type ChangeTrackerRegistry = TabRegistry; -impl TrackerHandle for TabTrackerHandle { - fn has_pending(&self) -> bool { - self.pending_count() > 0 - } -} - #[cfg(test)] mod tests { use tablepro_core::Value; @@ -36,23 +30,20 @@ mod tests { edit(&first.register(tab)); let other = second.register(tab); - assert_eq!(first.dirty(), vec![tab]); - assert!(second.dirty().is_empty()); + assert_eq!(first.get(tab).map(|tracker| tracker.pending_count()), Some(1)); assert_eq!(other.pending_count(), 0); } #[test] - fn dirty_lists_only_pending_tabs() { + fn a_discard_empties_the_tabs_tracker() { let registry = ChangeTrackerRegistry::default(); let edited = Uuid::new_v4(); - let clean = Uuid::new_v4(); edit(®istry.register(edited)); - registry.register(clean); - - assert_eq!(registry.dirty(), vec![edited]); + registry.register(Uuid::new_v4()); let _ = registry.get(edited).expect("registered").discard(); - assert!(registry.dirty().is_empty()); + + assert_eq!(registry.register(edited).pending_count(), 0); } #[test] @@ -65,7 +56,6 @@ mod tests { registry.unregister(tab); assert!(registry.get(tab).is_none()); - assert!(registry.dirty().is_empty()); assert_eq!(registry.register(tab).pending_count(), 0); } } diff --git a/linux/crates/app/src/services/connection_deletion.rs b/linux/crates/app/src/services/connection_deletion.rs index 2a5c3cacda..2171d0c8b3 100644 --- a/linux/crates/app/src/services/connection_deletion.rs +++ b/linux/crates/app/src/services/connection_deletion.rs @@ -113,7 +113,7 @@ mod tests { 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::query_history::{HistoryPageRequest, HistoryQuery, NewEntry, Outcome}; use tablepro_storage::saved_queries::NewSavedQuery; use tablepro_storage::{ConnectionStore, DraftId, DraftStore, SavedConnection, StorageError, StoragePaths}; use tablepro_test_support::{Call, FakeSecretVault}; @@ -457,13 +457,10 @@ mod tests { assert_eq!(fixture.history_count(fixture.other).await, 1); let left = fixture .history - .search(SearchFilter { - needle: Some("users".to_owned()), - limit: 10, - ..SearchFilter::default() - }) + .search_page(&HistoryQuery::from_input("users"), HistoryPageRequest::first(10)) .await - .expect("search"); + .expect("search") + .entries; assert!(left.iter().all(|entry| entry.connection_id == fixture.other)); } diff --git a/linux/crates/app/src/services/history_service.rs b/linux/crates/app/src/services/history_service.rs index ed7fd0ca27..087d02345e 100644 --- a/linux/crates/app/src/services/history_service.rs +++ b/linux/crates/app/src/services/history_service.rs @@ -247,12 +247,13 @@ mod tests { async fn queries(history: &QueryHistory) -> Vec { history - .search(tablepro_storage::query_history::SearchFilter { - limit: 100, - ..Default::default() - }) + .search_page( + &tablepro_storage::query_history::HistoryQuery::default(), + tablepro_storage::query_history::HistoryPageRequest::first(100), + ) .await .expect("search") + .entries .into_iter() .map(|entry| entry.query) .collect() diff --git a/linux/crates/app/src/services/mod.rs b/linux/crates/app/src/services/mod.rs index 9abb70cc2a..e4e4169b40 100644 --- a/linux/crates/app/src/services/mod.rs +++ b/linux/crates/app/src/services/mod.rs @@ -1,5 +1,6 @@ pub mod active_session; pub mod cell_overlay; +pub mod change_origins; pub mod change_tracker; pub mod change_tracker_registry; pub mod column_relayout; @@ -14,6 +15,10 @@ pub mod history_preferences; pub mod history_service; pub mod pending_structure; pub mod save_batch; +pub mod saved_connection_list; +pub mod saved_connection_object; +mod saved_connection_object_imp; +pub mod saved_connection_order; pub mod secret_labels; pub mod secret_save_report; pub mod session_context; diff --git a/linux/crates/app/src/services/saved_connection_list.rs b/linux/crates/app/src/services/saved_connection_list.rs new file mode 100644 index 0000000000..799b744289 --- /dev/null +++ b/linux/crates/app/src/services/saved_connection_list.rs @@ -0,0 +1,323 @@ +//! The saved connections every picker shows, as one list model that +//! follows the connection store. + +use std::cell::{Cell, RefCell}; +use std::rc::{Rc, Weak}; +use std::sync::Arc; + +use gtk4::prelude::*; +use gtk4::{gio, glib}; +use tablepro_storage::{ConnectionListState, ConnectionStore, SavedConnection}; +use uuid::Uuid; + +use super::saved_connection_object::SavedConnectionObject; +use super::saved_connection_order; + +type StateObserver = Rc; + +/// One model for every window. Each store write publishes a new list, +/// so a save in the connect dialog, a connect's last-opened stamp, a +/// delete or an edit reaches every picker without anyone asking. +pub struct SavedConnectionList { + store: gio::ListStore, + /// The last list the store had ready, in display order. A list that + /// cannot be read keeps it, as the rows do. + connections: RefCell>, + state: RefCell, + watch: RefCell>>, + observers: RefCell>, + next_observer: Cell, +} + +/// What `connect_state_changed` returns, to disconnect with. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct StateObserverId(u64); + +impl SavedConnectionList { + /// Follow `connections` from its list now. The watch runs on the GTK + /// thread and polls nothing but the store's channel, and it ends + /// with the store or with this list. + pub fn follow(connections: &ConnectionStore) -> Rc { + let mut receiver = connections.subscribe(); + let current = receiver.borrow_and_update().state.clone(); + let list = Rc::new(Self { + store: gio::ListStore::new::(), + connections: RefCell::new(Arc::from([])), + state: RefCell::new(ConnectionListState::Loading), + watch: RefCell::new(None), + observers: RefCell::new(Vec::new()), + next_observer: Cell::new(0), + }); + list.apply(current); + let following: Weak = Rc::downgrade(&list); + let watch = glib::spawn_future_local(async move { + while receiver.changed().await.is_ok() { + let state = receiver.borrow_and_update().state.clone(); + let Some(list) = following.upgrade() else { + return; + }; + list.apply(state); + } + }); + list.watch.replace(Some(watch)); + list + } + + pub fn model(&self) -> gio::ListModel { + self.store.clone().upcast() + } + + pub fn state(&self) -> ConnectionListState { + self.state.borrow().clone() + } + + /// The saved connection `id` as the list holds it now. + pub fn get(&self, id: Uuid) -> Option { + self.connections.borrow().iter().find(|saved| saved.id == id).cloned() + } + + /// Every saved connection, in display order. + pub fn connections(&self) -> Arc<[SavedConnection]> { + self.connections.borrow().clone() + } + + /// The groups the list files connections under, in display order. + pub fn groups(&self) -> Vec { + saved_connection_order::groups(&self.connections.borrow()) + } + + /// Run `observer` on every state the store publishes, as the banner + /// that says the list cannot be read needs. + pub fn connect_state_changed(&self, observer: impl Fn(&ConnectionListState) + 'static) -> StateObserverId { + let id = self.next_observer.get(); + self.next_observer.set(id + 1); + self.observers.borrow_mut().push((id, Rc::new(observer))); + StateObserverId(id) + } + + pub fn disconnect_state_changed(&self, id: StateObserverId) { + self.observers.borrow_mut().retain(|(observer, _)| *observer != id.0); + } + + /// A ready list replaces the objects of the connections that changed + /// and keeps the rest, so a write from another window leaves a row's + /// open menu and the keyboard focus alone. A new set of groups + /// replaces every object, because every row's menu offers them. A + /// list that is loading or cannot be read keeps the rows it had, and + /// only its state changes, for the banner. + pub(crate) fn apply(&self, state: ConnectionListState) { + // The state first: the rows the splice builds read it. + self.state.replace(state.clone()); + if let ConnectionListState::Ready(list) = &state { + let mut sorted = list.to_vec(); + saved_connection_order::sort(&mut sorted); + let regrouped = saved_connection_order::groups(&sorted) != self.groups(); + let sorted: Arc<[SavedConnection]> = Arc::from(sorted); + self.connections.replace(sorted.clone()); + if regrouped || self.store.n_items() == 0 { + let objects: Vec = + sorted.iter().cloned().map(SavedConnectionObject::new).collect(); + self.store.splice(0, self.store.n_items(), &objects); + } else { + patch(&self.store, &sorted); + } + } + let observers: Vec = self + .observers + .borrow() + .iter() + .map(|(_, observer)| observer.clone()) + .collect(); + for observer in observers { + observer(&state); + } + } +} + +/// Bring `store` to `wanted`, keeping each object whose connection is +/// unchanged where it can. +fn patch(store: &gio::ListStore, wanted: &[SavedConnection]) { + let mut position = 0; + for saved in wanted { + let kept = (position..store.n_items()).find(|&at| { + store + .item(at) + .and_downcast::() + .is_some_and(|object| object.saved() == Some(saved)) + }); + match kept { + Some(at) if at > position => store.splice(position, at - position, &[] as &[SavedConnectionObject]), + Some(_) => {} + None => store.insert(position, &SavedConnectionObject::new(saved.clone())), + } + position += 1; + } + let extra = store.n_items().saturating_sub(position); + if extra > 0 { + store.splice(position, extra, &[] as &[SavedConnectionObject]); + } +} + +impl Drop for SavedConnectionList { + fn drop(&mut self) { + if let Some(watch) = self.watch.take() { + watch.abort(); + } + } +} + +impl std::fmt::Debug for SavedConnectionList { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("SavedConnectionList") + .field("rows", &self.store.n_items()) + .finish_non_exhaustive() + } +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use tablepro_storage::{DocumentProblem, DocumentProblemKind, StoragePaths}; + + use super::*; + use crate::test_support::wait_until; + + const WAIT: Duration = Duration::from_secs(10); + + fn connection(name: &str) -> SavedConnection { + SavedConnection { + id: Uuid::new_v4(), + name: name.to_owned(), + driver_id: "postgres".to_owned(), + host: "db".to_owned(), + port: 5432, + database: "app".to_owned(), + username: "postgres".to_owned(), + tls: tablepro_core::TlsConfig::verify_full(), + read_only: false, + auth_mode: tablepro_core::AuthMode::Password, + ssh: None, + last_opened_at: None, + color: None, + group: None, + } + } + + fn objects(list: &SavedConnectionList) -> Vec { + let model = list.model(); + (0..model.n_items()) + .filter_map(|position| model.item(position).and_downcast::()) + .collect() + } + + fn names(list: &SavedConnectionList) -> Vec { + objects(list).iter().map(|object| object.name()).collect() + } + + fn ready(connections: &[SavedConnection]) -> ConnectionListState { + ConnectionListState::Ready(Arc::from(connections.to_vec())) + } + + #[gtk4::test] + fn a_write_to_the_store_reaches_the_list() { + let dir = tempfile::tempdir().expect("a temporary directory"); + let store = ConnectionStore::new(&StoragePaths::under(dir.path(), "tablepro", crate::config::APP_ID)); + let list = SavedConnectionList::follow(&store); + assert!(matches!(list.state(), ConnectionListState::Loading)); + let corp = connection("Corp"); + + store.upsert_blocking(corp.clone()).expect("the write"); + + wait_until(WAIT, || names(&list) == ["Corp"]).expect("the new row"); + assert_eq!(list.get(corp.id).map(|saved| saved.name), Some("Corp".to_owned())); + store.remove_blocking(corp.id).expect("the delete"); + wait_until(WAIT, || names(&list).is_empty()).expect("the row to go"); + assert_eq!(list.get(corp.id), None); + } + + #[gtk4::test] + fn unreadable_list_keeps_rows_and_reports_its_state() { + let dir = tempfile::tempdir().expect("a temporary directory"); + let store = ConnectionStore::new(&StoragePaths::under(dir.path(), "tablepro", crate::config::APP_ID)); + let list = SavedConnectionList::follow(&store); + let seen = Rc::new(RefCell::new(Vec::new())); + let sink = seen.clone(); + let observer = list.connect_state_changed(move |state| { + sink.borrow_mut() + .push(matches!(state, ConnectionListState::Unavailable(_))); + }); + let corp = connection("Corp"); + list.apply(ConnectionListState::Ready(Arc::from([corp.clone()]))); + + list.apply(ConnectionListState::Unavailable(Arc::new(DocumentProblem::new( + "/tmp/connections.json", + DocumentProblemKind::Unreadable { + detail: "denied".to_owned(), + }, + )))); + + assert_eq!(names(&list), ["Corp"], "an unreadable list emptied the picker"); + assert_eq!(list.get(corp.id).map(|saved| saved.id), Some(corp.id)); + assert!(matches!(list.state(), ConnectionListState::Unavailable(_))); + assert_eq!(*seen.borrow(), [false, true]); + list.disconnect_state_changed(observer); + list.apply(ConnectionListState::Loading); + assert_eq!(seen.borrow().len(), 2, "a disconnected observer still ran"); + } + + #[gtk4::test] + fn a_write_keeps_the_objects_of_unchanged_connections() { + let dir = tempfile::tempdir().expect("a temporary directory"); + let store = ConnectionStore::new(&StoragePaths::under(dir.path(), "tablepro", crate::config::APP_ID)); + let list = SavedConnectionList::follow(&store); + let (alpha, beta, gamma) = (connection("Alpha"), connection("Beta"), connection("Gamma")); + list.apply(ready(&[alpha.clone(), beta.clone(), gamma.clone()])); + let before = objects(&list); + + let mut stamped = gamma.clone(); + stamped.last_opened_at = Some(chrono::Utc::now()); + list.apply(ready(&[alpha.clone(), beta.clone(), stamped])); + let after = objects(&list); + + assert_eq!(names(&list), ["Gamma", "Alpha", "Beta"]); + assert_eq!(after[1], before[0], "the stamp rebuilt Alpha's row"); + assert_eq!(after[2], before[1], "the stamp rebuilt Beta's row"); + assert_ne!(after[0], before[2], "the stamped row shows the old connection"); + + list.apply(ready(&[alpha, beta])); + assert_eq!(objects(&list), [after[1].clone(), after[2].clone()]); + } + + #[gtk4::test] + fn a_new_group_replaces_every_object() { + let dir = tempfile::tempdir().expect("a temporary directory"); + let store = ConnectionStore::new(&StoragePaths::under(dir.path(), "tablepro", crate::config::APP_ID)); + let list = SavedConnectionList::follow(&store); + let (alpha, beta) = (connection("Alpha"), connection("Beta")); + list.apply(ready(&[alpha.clone(), beta.clone()])); + let before = objects(&list); + + let mut filed = beta; + filed.group = Some("Work".to_owned()); + list.apply(ready(&[alpha, filed])); + + let after = objects(&list); + assert_eq!(list.groups(), ["Work"]); + assert_ne!(after[0], before[0], "Alpha's menu does not offer the new group"); + } + + #[gtk4::test] + fn a_dropped_list_stops_following() { + let dir = tempfile::tempdir().expect("a temporary directory"); + let store = ConnectionStore::new(&StoragePaths::under(dir.path(), "tablepro", crate::config::APP_ID)); + let list = SavedConnectionList::follow(&store); + let model = list.model(); + + drop(list); + store.upsert_blocking(connection("Corp")).expect("the write"); + crate::test_support::drain_main_context(); + + assert_eq!(model.n_items(), 0, "a dropped list still followed the store"); + } +} diff --git a/linux/crates/app/src/services/saved_connection_object.rs b/linux/crates/app/src/services/saved_connection_object.rs new file mode 100644 index 0000000000..fbaf59be73 --- /dev/null +++ b/linux/crates/app/src/services/saved_connection_object.rs @@ -0,0 +1,115 @@ +use std::path::Path; + +use gtk4::glib; +use gtk4::subclass::prelude::*; +use tablepro_core::AuthMode; +use tablepro_storage::SavedConnection; + +use super::file_location; +use super::saved_connection_object_imp as imp; + +glib::wrapper! { + /// One saved connection as the pickers' shared model holds it. It + /// never changes: a changed connection gets a new object. + pub struct SavedConnectionObject(ObjectSubclass); +} + +impl SavedConnectionObject { + pub(crate) fn new(saved: SavedConnection) -> Self { + let object: Self = glib::Object::new(); + let _ = object.imp().saved.set(saved); + object + } + + /// Always set: `new` is the only way to build one. + pub(crate) fn saved(&self) -> Option<&SavedConnection> { + self.imp().saved.get() + } +} + +pub(crate) fn is_sqlite(saved: &SavedConnection) -> bool { + saved.driver_id == "sqlite" +} + +/// The driver and where it connects. `host_path` names the file a +/// portal path stands for, once it is read. +pub(crate) 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), + AuthMode::Password => format!("{} · {}@{}:{}", saved.driver_id, saved.username, saved.host, saved.port), + } +} + +#[cfg(test)] +mod tests { + use chrono::{TimeZone, Utc}; + use uuid::Uuid; + + use super::*; + + fn saved(username: &str, auth_mode: AuthMode) -> SavedConnection { + SavedConnection { + id: Uuid::new_v4(), + name: "Corp".into(), + driver_id: "mssql".into(), + host: "sql.corp.example".into(), + port: 1433, + database: "sales".into(), + username: username.into(), + tls: tablepro_core::TlsConfig::verify_full(), + read_only: false, + auth_mode, + ssh: None, + last_opened_at: None, + color: None, + group: None, + } + } + + #[test] + fn a_kerberos_row_has_no_username_separator_to_dangle() { + assert_eq!( + subtitle_for(&saved("", AuthMode::Kerberos), None), + "mssql · sql.corp.example:1433" + ); + assert_eq!( + 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"); + } + + #[gtk4::test] + fn the_object_carries_the_connection_as_properties() { + let mut connection = saved("sa", AuthMode::Password); + connection.group = Some(" Work ".to_owned()); + connection.last_opened_at = Utc.timestamp_opt(1_700_000_000, 0).single(); + + let object = SavedConnectionObject::new(connection.clone()); + + assert_eq!(object.id(), connection.id.to_string()); + assert_eq!(object.name(), "Corp"); + assert_eq!(object.subtitle(), "mssql · sa@sql.corp.example:1433"); + assert_eq!(object.group().as_deref(), Some("Work")); + assert_eq!(object.last_opened(), 1_700_000_000_000); + assert_eq!(object.saved().map(|saved| saved.id), Some(connection.id)); + } +} diff --git a/linux/crates/app/src/services/saved_connection_object_imp.rs b/linux/crates/app/src/services/saved_connection_object_imp.rs new file mode 100644 index 0000000000..482340bad0 --- /dev/null +++ b/linux/crates/app/src/services/saved_connection_object_imp.rs @@ -0,0 +1,64 @@ +use std::cell::OnceCell; +use std::marker::PhantomData; + +use gtk4::glib; +use gtk4::prelude::*; +use gtk4::subclass::prelude::*; +use tablepro_storage::SavedConnection; + +#[derive(Default, glib::Properties)] +#[properties(wrapper_type = super::saved_connection_object::SavedConnectionObject)] +pub struct SavedConnectionObject { + pub(super) saved: OnceCell, + #[property(get = Self::id)] + id: PhantomData, + #[property(get = Self::name)] + name: PhantomData, + /// The driver and where it connects, as the row shows it. + #[property(get = Self::subtitle)] + subtitle: PhantomData, + #[property(get = Self::group)] + group: PhantomData>, + /// Milliseconds since the epoch, or 0 for never. + #[property(get = Self::last_opened)] + last_opened: PhantomData, +} + +#[glib::object_subclass] +impl ObjectSubclass for SavedConnectionObject { + const NAME: &'static str = "TableProSavedConnectionObject"; + type Type = super::saved_connection_object::SavedConnectionObject; +} + +#[glib::derived_properties] +impl ObjectImpl for SavedConnectionObject {} + +impl SavedConnectionObject { + fn id(&self) -> String { + self.saved.get().map(|saved| saved.id.to_string()).unwrap_or_default() + } + + fn name(&self) -> String { + self.saved.get().map(|saved| saved.name.clone()).unwrap_or_default() + } + + fn subtitle(&self) -> String { + self.saved + .get() + .map(|saved| super::saved_connection_object::subtitle_for(saved, None)) + .unwrap_or_default() + } + + fn group(&self) -> Option { + self.saved + .get() + .and_then(|saved| super::saved_connection_order::group_of(saved).map(str::to_owned)) + } + + fn last_opened(&self) -> i64 { + self.saved + .get() + .and_then(|saved| saved.last_opened_at) + .map_or(0, |at| at.timestamp_millis()) + } +} diff --git a/linux/crates/app/src/ui/connection_list.rs b/linux/crates/app/src/services/saved_connection_order.rs similarity index 58% rename from linux/crates/app/src/ui/connection_list.rs rename to linux/crates/app/src/services/saved_connection_order.rs index 453ddd3dba..cdf35d7152 100644 --- a/linux/crates/app/src/ui/connection_list.rs +++ b/linux/crates/app/src/services/saved_connection_order.rs @@ -1,15 +1,8 @@ -//! The order both connection lists render in, and the group headers -//! that go with it. -//! -//! The welcome page and the header popover show the same connections, -//! so they sort them the same way and read the same headers. - -use relm4::gtk; -use relm4::gtk::prelude::*; +//! The order every connection picker shows the saved connections in. use tablepro_storage::SavedConnection; -/// Sort the list the way both connection lists show it. +/// Sort the list the way every picker shows it. /// /// Ungrouped connections lead, because that is where every connection /// starts and a user with no groups sees the list they always saw. @@ -17,7 +10,7 @@ use tablepro_storage::SavedConnection; /// recency-first with an alphabetical tiebreaker, so the connection /// opened last is the one nearest the top. Never-opened connections /// fall to the end of their block and sort by name among themselves. -pub(super) fn sort(connections: &mut [SavedConnection]) { +pub(crate) fn sort(connections: &mut [SavedConnection]) { connections.sort_by(|a, b| { group_key(a) .cmp(&group_key(b)) @@ -47,7 +40,7 @@ fn recency_key(connection: &SavedConnection) -> (bool, i64) { /// The group as it is shown, which is `None` for a name that is empty /// or only spaces. A group nobody can see is not a group. -pub(super) fn group_of(connection: &SavedConnection) -> Option<&str> { +pub(crate) fn group_of(connection: &SavedConnection) -> Option<&str> { connection .group .as_deref() @@ -55,13 +48,10 @@ pub(super) fn group_of(connection: &SavedConnection) -> Option<&str> { .filter(|group| !group.is_empty()) } -/// Every group in the list, in the order the headers appear, without -/// duplicates. -pub(super) fn groups(connections: &[SavedConnection]) -> Vec { - let mut sorted = connections.to_vec(); - sort(&mut sorted); +/// Every group in `sorted`, in the order the list shows them, once each. +pub(crate) fn groups(sorted: &[SavedConnection]) -> Vec { let mut found: Vec = Vec::new(); - for connection in &sorted { + for connection in sorted { let Some(group) = group_of(connection) else { continue; }; @@ -72,55 +62,6 @@ pub(super) fn groups(connections: &[SavedConnection]) -> Vec { found } -/// The header a row needs, given the row before it: the group's name -/// on the first row of each group, and nothing anywhere else. -/// -/// `None` for the ungrouped block, which leads the list and needs no -/// heading to say what it is. -pub(super) fn header_for(connections: &[SavedConnection], index: usize, previous: Option) -> Option { - let group = group_of(connections.get(index)?)?; - let before = previous.and_then(|index| connections.get(index)).and_then(group_of); - match before == Some(group) { - true => None, - false => Some(group.to_owned()), - } -} - -/// Install the group headers on a list box whose rows are `connections` -/// in order. -/// -/// The header function reads a row's position rather than anything -/// stored on the widget, so the factory that builds the rows does not -/// have to know about groups at all. -pub(super) fn install_group_headers(listbox: >k::ListBox, connections: std::rc::Rc>) { - listbox.set_header_func(move |row, before| { - let index = usize::try_from(row.index()).unwrap_or(0); - let previous = before.map(|row| usize::try_from(row.index()).unwrap_or(0)); - match header_for(&connections, index, previous) { - Some(group) => row.set_header(Some(&group_header(&group))), - None => row.set_header(gtk::Widget::NONE), - } - }); -} - -/// A group heading in the shape GNOME uses for a list section: a bold -/// caption above the first row, indented to line up with the row's -/// title. -fn group_header(group: &str) -> gtk::Widget { - let label = gtk::Label::builder() - .label(group) - .xalign(0.0) - .margin_top(12) - .margin_bottom(6) - .margin_start(12) - .margin_end(12) - .ellipsize(gtk::pango::EllipsizeMode::End) - .build(); - label.add_css_class("heading"); - label.add_css_class("dim-label"); - label.upcast() -} - #[cfg(test)] mod tests { use super::*; @@ -215,36 +156,14 @@ mod tests { } #[test] - fn a_header_sits_on_the_first_row_of_each_group_only() { + fn the_group_list_holds_each_name_once_in_list_order() { let mut list = vec![ - connection("loose", None, Some(1)), - connection("a", Some("Work"), Some(1)), - connection("b", Some("Work"), Some(2)), - connection("c", Some("Zoo"), Some(1)), - ]; - sort(&mut list); - - assert_eq!(header_for(&list, 0, None), None, "the ungrouped block needs no heading"); - assert_eq!(header_for(&list, 1, Some(0)), Some("Work".to_owned())); - assert_eq!(header_for(&list, 2, Some(1)), None); - assert_eq!(header_for(&list, 3, Some(2)), Some("Zoo".to_owned())); - } - - #[test] - fn a_header_lookup_past_the_end_asks_for_nothing() { - let list = vec![connection("loose", None, Some(1))]; - - assert_eq!(header_for(&list, 9, Some(8)), None); - } - - #[test] - fn the_group_list_holds_each_name_once_in_header_order() { - let list = vec![ connection("a", Some("Work"), Some(1)), connection("b", Some("Archive"), Some(1)), connection("c", Some("Work"), Some(2)), connection("d", None, Some(1)), ]; + sort(&mut list); assert_eq!(groups(&list), vec!["Archive".to_owned(), "Work".to_owned()]); } diff --git a/linux/crates/app/src/services/structure_tracker_registry.rs b/linux/crates/app/src/services/structure_tracker_registry.rs index 77202a6e1c..dffec7c9ad 100644 --- a/linux/crates/app/src/services/structure_tracker_registry.rs +++ b/linux/crates/app/src/services/structure_tracker_registry.rs @@ -1,14 +1,8 @@ use super::structure_tracker_handle::StructureTrackerHandle; -use super::tab_registry::{TabRegistry, TrackerHandle}; +use super::tab_registry::TabRegistry; pub type StructureTrackerRegistry = TabRegistry; -impl TrackerHandle for StructureTrackerHandle { - fn has_pending(&self) -> bool { - StructureTrackerHandle::has_pending(self) - } -} - #[cfg(test)] mod tests { use tablepro_core::ddl::{DraftColumn, DraftType, TableChanges, TableDraft}; @@ -40,20 +34,20 @@ mod tests { first.register(tab).set_pending(new_table()); second.register(tab); - assert_eq!(first.dirty(), vec![tab]); - assert!(second.dirty().is_empty()); + assert!(first.get(tab).is_some_and(|tracker| tracker.has_pending())); + assert!(second.get(tab).is_some_and(|tracker| !tracker.has_pending())); } #[test] - fn dirty_lists_only_pending_tabs() { + fn register_returns_the_tabs_own_tracker() { let registry = StructureTrackerRegistry::default(); let edited = Uuid::new_v4(); registry.register(edited).set_pending(new_table()); registry.register(Uuid::new_v4()); - assert_eq!(registry.dirty(), vec![edited]); + assert!(registry.register(edited).has_pending()); registry.get(edited).expect("registered").clear(); - assert!(registry.dirty().is_empty()); + assert!(!registry.register(edited).has_pending()); } } diff --git a/linux/crates/app/src/services/tab_registry.rs b/linux/crates/app/src/services/tab_registry.rs index 26c2642b75..390614d872 100644 --- a/linux/crates/app/src/services/tab_registry.rs +++ b/linux/crates/app/src/services/tab_registry.rs @@ -3,11 +3,6 @@ use std::collections::HashMap; use uuid::Uuid; -/// A tab's tracker as a workspace registry holds it. -pub trait TrackerHandle: Clone + Default { - fn has_pending(&self) -> bool; -} - /// One workspace's trackers, keyed by tab. Each window owns its own, so /// edits in one window never count as unsaved in another. #[derive(Debug)] @@ -23,7 +18,7 @@ impl Default for TabRegistry { } } -impl TabRegistry { +impl TabRegistry { /// The tab's tracker, created on first use. pub fn register(&self, tab_id: Uuid) -> H { self.trackers.borrow_mut().entry(tab_id).or_default().clone() @@ -36,17 +31,4 @@ impl TabRegistry { pub fn get(&self, tab_id: Uuid) -> Option { self.trackers.borrow().get(&tab_id).cloned() } - - /// Tabs with unsaved changes, in a stable order. - pub fn dirty(&self) -> Vec { - let mut dirty: Vec = self - .trackers - .borrow() - .iter() - .filter(|(_, tracker)| tracker.has_pending()) - .map(|(tab_id, _)| *tab_id) - .collect(); - dirty.sort_unstable(); - dirty - } } diff --git a/linux/crates/app/src/services/tab_tracker_handle.rs b/linux/crates/app/src/services/tab_tracker_handle.rs index 55c73630a2..829267e033 100644 --- a/linux/crates/app/src/services/tab_tracker_handle.rs +++ b/linux/crates/app/src/services/tab_tracker_handle.rs @@ -6,7 +6,7 @@ use tablepro_core::edit::CellInput; use tablepro_core::{ColumnInfo, Value}; use super::cell_overlay::CellOverlay; -use super::change_tracker::{DraftRow, RowKey, RowState, TabChangeTracker, TrackedChanges}; +use super::change_tracker::{DraftRow, ErrorFlash, RowKey, RowState, TabChangeTracker, TrackedChanges}; use super::save_batch::{SaveBatch, SaveTarget}; use super::tracker_delta::TrackerDelta; use super::tracker_edit::TrackerEdit; @@ -55,6 +55,14 @@ impl TabTrackerHandle { self.0.borrow().pending_count() } + pub fn can_undo(&self) -> bool { + self.0.borrow().can_undo() + } + + pub fn can_redo(&self) -> bool { + self.0.borrow().can_redo() + } + pub fn drafts(&self) -> Vec { self.0.borrow().drafts().to_vec() } @@ -84,7 +92,7 @@ impl TabTrackerHandle { self.0.borrow_mut().discard() } - pub fn flash_error_row(&self, row_key: RowKey) -> u64 { + pub fn flash_error_row(&self, row_key: RowKey) -> ErrorFlash { self.0.borrow_mut().flash_error_row(row_key) } @@ -92,6 +100,10 @@ impl TabTrackerHandle { self.0.borrow_mut().clear_error_row_if(generation) } + pub fn is_error_row(&self, row_key: &RowKey) -> bool { + self.0.borrow().is_error_row(row_key) + } + /// The table and columns the tab read, which every save is planned /// against. #[must_use = "the grid shows stale rows until the delta is applied"] diff --git a/linux/crates/app/src/test_support.rs b/linux/crates/app/src/test_support.rs index 0de2ec87de..49e3296485 100644 --- a/linux/crates/app/src/test_support.rs +++ b/linux/crates/app/src/test_support.rs @@ -3,25 +3,34 @@ mod alert_response; mod fake_clocks; mod fake_reachability; mod fake_session; +mod icon_buttons; +mod key_injection_error; +mod key_injector; mod main_loop; mod memory_settings; +pub(crate) mod shortcut_keys; mod signal_log; mod tests; mod unlabelled_widget; mod wait_timed_out; mod widget_lookup; -pub(crate) use accessible_label::assert_labelled; +pub(crate) use accessible_label::{assert_labelled, test_backend_records_properties}; 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 icon_buttons::{ + ButtonFace, button_face, has_accessible_label, tooltips_with_keys, unlabelled_icon_buttons, +}; +pub(crate) use key_injection_error::KeyInjectionError; +pub(crate) use key_injector::KeyInjector; pub(crate) use main_loop::{drain_main_context, finish_on_main, wait_until}; pub(crate) use memory_settings::MemorySettings; pub(crate) use signal_log::SignalLog; pub(crate) use unlabelled_widget::UnlabelledWidget; pub(crate) use wait_timed_out::WaitTimedOut; -pub(crate) use widget_lookup::{descendants, find_by_action_name, first_descendant_of_type}; +pub(crate) use widget_lookup::{descendants, find_by_action_name, first_descendant_of_type, status_page_description}; /// `Tasks` bound to the current test runtime. /// diff --git a/linux/crates/app/src/test_support/accessible_label.rs b/linux/crates/app/src/test_support/accessible_label.rs index 351cbd0c61..a45f98aa48 100644 --- a/linux/crates/app/src/test_support/accessible_label.rs +++ b/linux/crates/app/src/test_support/accessible_label.rs @@ -43,7 +43,7 @@ pub(crate) fn assert_labelled(widget: &impl IsA) -> Result<(), Unl Ok(()) } -fn test_backend_records_properties() -> bool { +pub(crate) fn test_backend_records_properties() -> bool { let probe = gtk4::Button::new(); probe.update_property(&[gtk4::accessible::Property::Label("probe")]); gtk4::test_accessible_has_property(&probe, AccessibleProperty::Label) diff --git a/linux/crates/app/src/test_support/icon_buttons.rs b/linux/crates/app/src/test_support/icon_buttons.rs new file mode 100644 index 0000000000..aa491c08f5 --- /dev/null +++ b/linux/crates/app/src/test_support/icon_buttons.rs @@ -0,0 +1,110 @@ +use gtk4::prelude::*; +use gtk4::{AccessibleProperty, AccessibleRelation, AccessibleRole, glib}; +use libadwaita as adw; + +use crate::test_support::descendants; + +/// What a button shows, which decides whether only an accessible label +/// can name it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ButtonFace { + NotAButton, + /// A label, or content of its own, which is taken to carry text. + Text, + Icon, + /// Neither yet: the code gives it one. + Empty, +} + +pub(crate) fn button_face(widget: >k4::Widget) -> ButtonFace { + let face = |label: Option, child: Option, icon: Option| match ( + label.is_some_and(|label| !label.is_empty()), + child, + icon, + ) { + (true, ..) => ButtonFace::Text, + (false, Some(child), _) if child.is::() => ButtonFace::Icon, + (false, Some(_), _) => ButtonFace::Text, + (false, None, Some(_)) => ButtonFace::Icon, + (false, None, None) => ButtonFace::Empty, + }; + if let Some(button) = widget.downcast_ref::() { + return face(button.label(), button.child(), button.icon_name()); + } + if let Some(button) = widget.downcast_ref::() { + return face(button.label(), button.child(), button.icon_name()); + } + if let Some(button) = widget.downcast_ref::() { + return face(button.label(), button.child(), button.icon_name()); + } + if widget.is::() { + return ButtonFace::Icon; + } + ButtonFace::NotAButton +} + +pub(crate) fn is_icon_only(widget: >k4::Widget) -> bool { + button_face(widget) == ButtonFace::Icon +} + +pub(crate) fn has_accessible_label(widget: >k4::Widget) -> bool { + gtk4::test_accessible_has_property(widget, AccessibleProperty::Label) + || gtk4::test_accessible_has_relation(widget, AccessibleRelation::LabelledBy) +} + +/// The icon-only buttons under `root` a screen reader reaches and cannot +/// name, each as its type and tooltip. The button inside a menu, split +/// or tab button is left out, since the outer one is the control. +pub(crate) fn unlabelled_icon_buttons(root: &impl IsA) -> Vec { + let root = root.as_ref(); + descendants(root) + .into_iter() + .filter(|widget| is_icon_only(widget) && !has_accessible_label(widget) && reachable(widget, root)) + .filter(|widget| { + !matches!( + widget.accessible_role(), + AccessibleRole::None | AccessibleRole::Presentation + ) + }) + .filter(|widget| { + !widget.parent().is_some_and(|parent| { + parent.is::() || parent.is::() || parent.is::() + }) + }) + .map(|widget| format!("{} {:?}", widget.type_().name(), widget.tooltip_text())) + .collect() +} + +/// Whether a screen reader reaches `widget`: it and every widget above +/// it up to `root` is shown, and every revealer on the way reveals. +fn reachable(widget: >k4::Widget, root: >k4::Widget) -> bool { + let mut current = widget.clone(); + while ¤t != root { + if !current.is_visible() || !current.is_child_visible() { + return false; + } + let Some(parent) = current.parent() else { + return true; + }; + if &parent != root + && parent + .downcast_ref::() + .is_some_and(|revealer| !revealer.reveals_child()) + { + return false; + } + current = parent; + } + true +} + +/// The tooltips under `root` that spell out a key, which belongs on the +/// accessible node, where a screen reader says it in its own words. +pub(crate) fn tooltips_with_keys(root: &impl IsA) -> Vec { + descendants(root) + .into_iter() + .filter_map(|widget| widget.tooltip_text()) + .map(|tooltip| tooltip.to_string()) + .filter(|tooltip| tooltip.contains("Ctrl+")) + .collect() +} diff --git a/linux/crates/app/src/test_support/key_injection_error.rs b/linux/crates/app/src/test_support/key_injection_error.rs new file mode 100644 index 0000000000..005f7238a7 --- /dev/null +++ b/linux/crates/app/src/test_support/key_injection_error.rs @@ -0,0 +1,14 @@ +#[derive(Debug, thiserror::Error)] +pub(crate) enum KeyInjectionError { + #[error("could not connect to the X server: {0}")] + Connect(#[from] x11rb::errors::ConnectError), + #[error("the X connection failed: {0}")] + Connection(#[from] x11rb::errors::ConnectionError), + #[error("the X server refused a request: {0}")] + Reply(#[from] x11rb::errors::ReplyError), + #[error("no keycode carries keysym {0:#x}")] + KeysymNotMapped(u32), + /// XTEST reaches GDK only through an X11 display; set GDK_BACKEND=x11. + #[error("the display is {0}, not X11")] + NotX11(String), +} diff --git a/linux/crates/app/src/test_support/key_injector.rs b/linux/crates/app/src/test_support/key_injector.rs new file mode 100644 index 0000000000..b5e74cfb65 --- /dev/null +++ b/linux/crates/app/src/test_support/key_injector.rs @@ -0,0 +1,119 @@ +use gtk4::gdk; +use gtk4::glib::translate::IntoGlib; +use gtk4::prelude::*; +use x11rb::connection::Connection; +use x11rb::protocol::xproto::{ConnectionExt as _, KEY_PRESS_EVENT, KEY_RELEASE_EVENT}; +use x11rb::protocol::xtest::ConnectionExt as _; +use x11rb::rust_connection::RustConnection; + +use super::KeyInjectionError; + +const X11_DISPLAY: &str = "GdkX11Display"; + +/// Presses keys through XTEST, which the X server delivers as it does a +/// keyboard's, so the press meets every capture, class and bubble +/// handler in the order GTK runs them. GDK focuses a presented window +/// itself when no window manager runs, as under Xvfb. +pub(crate) struct KeyInjector { + connection: RustConnection, +} + +impl KeyInjector { + pub(crate) fn connect() -> Result { + let display = gdk::Display::default().map(|display| display.type_().name().to_owned()); + require_x11(display.as_deref().unwrap_or("no display"))?; + let (connection, _screen) = x11rb::connect(None)?; + Ok(Self { connection }) + } + + /// Take the keyboard for `window`. A window an earlier test left open + /// in this process can hold a popup's keyboard grab, which takes + /// every key, so every other window goes first. + pub(crate) fn focus_only(window: &impl IsA) { + let window = window.as_ref(); + for other in gtk4::Window::list_toplevels() { + if let Ok(other) = other.downcast::() + && &other != window + { + other.destroy(); + } + } + window.present(); + } + + /// The modifiers go down first and come up last, as a hand does it. + pub(crate) fn press(&self, keyval: gdk::Key, modifiers: gdk::ModifierType) -> Result<(), KeyInjectionError> { + let mut held = Vec::new(); + for (mask, keysym) in [ + (gdk::ModifierType::CONTROL_MASK, gdk::Key::Control_L), + (gdk::ModifierType::SHIFT_MASK, gdk::Key::Shift_L), + (gdk::ModifierType::ALT_MASK, gdk::Key::Alt_L), + ] { + if modifiers.contains(mask) { + held.push(self.keycode(keysym)?); + } + } + let key = self.keycode(keyval)?; + for code in &held { + self.fake(KEY_PRESS_EVENT, *code)?; + } + self.fake(KEY_PRESS_EVENT, key)?; + self.fake(KEY_RELEASE_EVENT, key)?; + for code in held.iter().rev() { + self.fake(KEY_RELEASE_EVENT, *code)?; + } + self.connection.flush()?; + // A round trip, so the server has taken every event before the + // test waits for what they do. + self.connection.get_input_focus()?.reply()?; + Ok(()) + } + + /// The keycode whose unshifted keysym is `keyval`, or failing that + /// one that carries it at another level. + fn keycode(&self, keyval: gdk::Key) -> Result { + let keysym = keyval.into_glib(); + let setup = self.connection.setup(); + let (first, last) = (setup.min_keycode, setup.max_keycode); + let mapping = self + .connection + .get_keyboard_mapping(first, last.saturating_sub(first).saturating_add(1))? + .reply()?; + let per_keycode = usize::from(mapping.keysyms_per_keycode).max(1); + let keycodes: Vec<&[u32]> = mapping.keysyms.chunks(per_keycode).collect(); + keycodes + .iter() + .position(|syms| syms.first() == Some(&keysym)) + .or_else(|| keycodes.iter().position(|syms| syms.contains(&keysym))) + .and_then(|index| u8::try_from(usize::from(first) + index).ok()) + .ok_or(KeyInjectionError::KeysymNotMapped(keysym)) + } + + fn fake(&self, kind: u8, keycode: u8) -> Result<(), KeyInjectionError> { + self.connection + .xtest_fake_input(kind, keycode, x11rb::CURRENT_TIME, x11rb::NONE, 0, 0, 0)?; + Ok(()) + } +} + +fn require_x11(display_type: &str) -> Result<(), KeyInjectionError> { + if display_type == X11_DISPLAY { + Ok(()) + } else { + Err(KeyInjectionError::NotX11(display_type.to_owned())) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn key_injector_reports_not_x11() { + assert!(matches!( + require_x11("GdkWaylandDisplay"), + Err(KeyInjectionError::NotX11(name)) if name == "GdkWaylandDisplay" + )); + assert!(require_x11(X11_DISPLAY).is_ok()); + } +} diff --git a/linux/crates/app/src/test_support/shortcut_keys.rs b/linux/crates/app/src/test_support/shortcut_keys.rs new file mode 100644 index 0000000000..a37570e2d1 --- /dev/null +++ b/linux/crates/app/src/test_support/shortcut_keys.rs @@ -0,0 +1,124 @@ +use std::collections::HashSet; + +use gtk4 as gtk; +use gtk4::gdk; +use gtk4::prelude::*; + +use super::descendants; +use crate::application::{ACCELERATORS, AcceleratorScope, ShortcutOwner, keypad_twins}; + +/// A key with its modifiers, keyval lowered as GtkKeyvalTrigger stores it. +pub(crate) type Keystroke = (gdk::Key, gdk::ModifierType); + +/// The name GTK gives the controller that runs a widget class's bindings. +const CLASS_SHORTCUTS: &str = "gtk-widget-class-shortcuts"; + +pub(crate) fn keystroke(accel: &str) -> Keystroke { + let (key, modifiers) = gtk::accelerator_parse(accel).unwrap_or_else(|| panic!("{accel} does not parse")); + (key.to_lower(), modifiers) +} + +/// `accel` and the keypad twins the table binds beside it. +pub(crate) fn keystrokes_with_twins(accel: &str) -> Vec { + let (key, modifiers) = keystroke(accel); + std::iter::once(key) + .chain(keypad_twins(key).iter().copied()) + .map(|key| (key.to_lower(), modifiers)) + .collect() +} + +pub(crate) fn trigger_keys(trigger: >k::ShortcutTrigger) -> Vec { + let mut keys = Vec::new(); + collect_trigger_keys(trigger, &mut keys); + keys +} + +fn collect_trigger_keys(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::() { + collect_trigger_keys(&pair.first(), into); + collect_trigger_keys(&pair.second(), into); + } +} + +pub(crate) fn shortcuts(controller: >k::ShortcutController) -> Vec { + (0..controller.n_items()) + .filter_map(|position| controller.item(position).and_downcast::()) + .collect() +} + +pub(crate) fn shortcut_controllers(widget: &impl IsA) -> Vec { + let controllers = widget.as_ref().observe_controllers(); + (0..controllers.n_items()) + .filter_map(|position| controllers.item(position).and_downcast::()) + .collect() +} + +pub(crate) fn controller_keys(controller: >k::ShortcutController) -> Vec { + shortcuts(controller) + .iter() + .filter_map(gtk::Shortcut::trigger) + .flat_map(|trigger| trigger_keys(&trigger)) + .collect() +} + +/// The controller `local_shortcut_controller` put on `widget` for +/// `owner`, if any. +pub(crate) fn owner_controller( + widget: &impl IsA, + owner: ShortcutOwner, +) -> Option { + let name = crate::application::controller_name(owner); + shortcut_controllers(widget) + .into_iter() + .find(|controller| controller.name().is_some_and(|found| found == name)) +} + +/// The keys the classes of `widget` bind. +pub(crate) fn class_binding_keys(widget: &impl IsA) -> HashSet { + shortcut_controllers(widget) + .iter() + .filter(|controller| controller.name().is_some_and(|name| name == CLASS_SHORTCUTS)) + .flat_map(controller_keys) + .collect() +} + +/// The class bindings a key meets on its way up to `owner` from any +/// focusable widget inside it, the owner's own included. +pub(crate) fn class_keys_on_focus_paths( + owner: &impl IsA, + skip: impl Fn(>k::Widget) -> bool, +) -> HashSet { + let owner = owner.as_ref(); + let mut on_path: Vec = vec![owner.clone()]; + for focusable in descendants(owner).into_iter().filter(|widget| widget.is_focusable()) { + let mut widget = Some(focusable); + while let Some(current) = widget { + if ¤t == owner || on_path.contains(¤t) { + break; + } + widget = current.parent(); + on_path.push(current); + } + } + on_path + .iter() + .filter(|widget| !skip(widget)) + .flat_map(class_binding_keys) + .collect() +} + +/// Every key the table gives `owner`, with keypad twins for the ones it +/// installs. +pub(crate) fn table_keys(owner: ShortcutOwner) -> HashSet { + ACCELERATORS + .iter() + .filter(|entry| match entry.scope { + AcceleratorScope::Local { owner: of, .. } | AcceleratorScope::Component(of) => of == owner, + AcceleratorScope::Application | AcceleratorScope::Stock => false, + }) + .flat_map(|entry| entry.triggers.iter()) + .flat_map(|accel| keystrokes_with_twins(accel)) + .collect() +} diff --git a/linux/crates/app/src/test_support/widget_lookup.rs b/linux/crates/app/src/test_support/widget_lookup.rs index b8e13c9835..c6ea668a71 100644 --- a/linux/crates/app/src/test_support/widget_lookup.rs +++ b/linux/crates/app/src/test_support/widget_lookup.rs @@ -24,6 +24,17 @@ pub(crate) fn find_by_action_name(root: &impl IsA, name: &str) -> }) } +/// The description a status page shows, as text rather than the markup +/// it was given: markup that does not parse shows nothing. +pub(crate) fn status_page_description(page: &libadwaita::StatusPage) -> Option { + let markup = page.description()?; + descendants(page) + .into_iter() + .filter_map(|widget| widget.downcast::().ok()) + .find(|label| label.label() == markup) + .map(|label| label.text().to_string()) +} + pub(crate) fn first_descendant_of_type>(root: &impl IsA) -> Option { descendants(root) .into_iter() diff --git a/linux/crates/app/src/ui/a11y/icon_button.rs b/linux/crates/app/src/ui/a11y/icon_button.rs new file mode 100644 index 0000000000..0e58e15040 --- /dev/null +++ b/linux/crates/app/src/ui/a11y/icon_button.rs @@ -0,0 +1,58 @@ +use gtk4 as gtk; +use gtk4::prelude::*; + +use super::aria_key_shortcuts; +use crate::application::triggers_for; + +/// The tooltip names the button and the accessible label says the same, +/// since an icon has no text of its own. The keys go on the accessible +/// node, never in the tooltip, so they are announced in the reader's own +/// words. +pub(crate) fn describe_icon_button(widget: &impl IsA, label: &str, action: Option<&str>) { + let widget = widget.as_ref(); + widget.set_tooltip_text(Some(label)); + widget.update_property(&[gtk::accessible::Property::Label(label)]); + if let Some(action) = action { + announce_key_shortcuts(widget, action); + } +} + +/// The keys of `action`, from the accelerator table, on `widget`. +pub(crate) fn announce_key_shortcuts(widget: &impl IsA, action: &str) { + if let Some(keys) = aria_key_shortcuts(triggers_for(action)) { + widget.update_property(&[gtk::accessible::Property::KeyShortcuts(&keys)]); + } +} + +#[cfg(test)] +mod tests { + use gtk4::AccessibleProperty; + + use super::*; + + #[gtk4::test] + fn icon_button_gets_label_tooltip_and_its_actions_keys() { + let button = gtk::Button::from_icon_name("list-add-symbolic"); + + describe_icon_button(&button, "Insert row", Some("browse.insert-row")); + + assert_eq!(button.tooltip_text().as_deref(), Some("Insert row")); + assert!(gtk::test_accessible_has_property(&button, AccessibleProperty::Label)); + assert!(gtk::test_accessible_has_property( + &button, + AccessibleProperty::KeyShortcuts + )); + } + + #[gtk4::test] + fn an_action_without_keys_announces_none() { + let button = gtk::Button::from_icon_name("go-first-symbolic"); + + describe_icon_button(&button, "First page", Some("browse.first-page")); + + assert!(!gtk::test_accessible_has_property( + &button, + AccessibleProperty::KeyShortcuts + )); + } +} diff --git a/linux/crates/app/src/ui/a11y/key_shortcuts.rs b/linux/crates/app/src/ui/a11y/key_shortcuts.rs new file mode 100644 index 0000000000..46219e9ab4 --- /dev/null +++ b/linux/crates/app/src/ui/a11y/key_shortcuts.rs @@ -0,0 +1,79 @@ +use gtk4 as gtk; +use gtk4::gdk; + +/// The `aria-keyshortcuts` value for `triggers`: each one as modifiers +/// and a key value joined by `+`, alternatives joined by spaces. +pub(crate) fn aria_key_shortcuts(triggers: &[&str]) -> Option { + let shortcuts: Vec = triggers + .iter() + .filter_map(|accel| gtk::accelerator_parse(*accel)) + .filter_map(|(key, modifiers)| { + let key = key_value(key)?; + let mut parts: Vec<&str> = [ + (gdk::ModifierType::CONTROL_MASK, "Control"), + (gdk::ModifierType::SHIFT_MASK, "Shift"), + (gdk::ModifierType::ALT_MASK, "Alt"), + (gdk::ModifierType::SUPER_MASK, "Super"), + ] + .into_iter() + .filter(|(mask, _)| modifiers.contains(*mask)) + .map(|(_, name)| name) + .collect(); + parts.push(&key); + Some(parts.join("+")) + }) + .collect(); + (!shortcuts.is_empty()).then(|| shortcuts.join(" ")) +} + +/// The UI Events `key` value ARIA uses, not the GDK keysym name. +fn key_value(key: gdk::Key) -> Option { + let named = match key { + gdk::Key::Return | gdk::Key::KP_Enter | gdk::Key::ISO_Enter => "Enter", + gdk::Key::Page_Up => "PageUp", + gdk::Key::Page_Down => "PageDown", + gdk::Key::BackSpace => "Backspace", + gdk::Key::Menu => "ContextMenu", + gdk::Key::space => "Space", + gdk::Key::Up => "ArrowUp", + gdk::Key::Down => "ArrowDown", + gdk::Key::Left => "ArrowLeft", + gdk::Key::Right => "ArrowRight", + gdk::Key::ISO_Left_Tab => "Tab", + _ => "", + }; + if !named.is_empty() { + return Some(named.to_owned()); + } + match key.to_unicode() { + Some(character) if !character.is_control() => Some(character.to_uppercase().collect()), + _ => key.name().map(|name| name.to_string()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[gtk4::test] + fn aria_key_shortcuts_formats_modifiers_and_keys() { + assert_eq!( + aria_key_shortcuts(&["t"]).as_deref(), + Some("Control+Shift+T") + ); + assert_eq!( + aria_key_shortcuts(&["z", "y"]).as_deref(), + Some("Control+Shift+Z Control+Y") + ); + assert_eq!(aria_key_shortcuts(&["Page_Up"]).as_deref(), Some("PageUp")); + assert_eq!(aria_key_shortcuts(&["Page_Down"]).as_deref(), Some("Alt+PageDown")); + assert_eq!( + aria_key_shortcuts(&["Return"]).as_deref(), + Some("Control+Enter") + ); + assert_eq!(aria_key_shortcuts(&["Delete", "F5"]).as_deref(), Some("Delete F5")); + assert_eq!(aria_key_shortcuts(&["slash"]).as_deref(), Some("Control+/")); + assert_eq!(aria_key_shortcuts(&[]), None); + assert_eq!(aria_key_shortcuts(&["not a key"]), None); + } +} diff --git a/linux/crates/app/src/ui/a11y/mod.rs b/linux/crates/app/src/ui/a11y/mod.rs new file mode 100644 index 0000000000..7d38bbdc2b --- /dev/null +++ b/linux/crates/app/src/ui/a11y/mod.rs @@ -0,0 +1,9 @@ +//! Accessibility properties the toolkit cannot set by itself. + +mod icon_button; +mod key_shortcuts; +#[cfg(test)] +mod tests; + +pub(crate) use icon_button::{announce_key_shortcuts, describe_icon_button}; +pub(crate) use key_shortcuts::aria_key_shortcuts; diff --git a/linux/crates/app/src/ui/a11y/tests.rs b/linux/crates/app/src/ui/a11y/tests.rs new file mode 100644 index 0000000000..8d87e2b0ed --- /dev/null +++ b/linux/crates/app/src/ui/a11y/tests.rs @@ -0,0 +1,274 @@ +use gtk4 as gtk; +use gtk4::prelude::*; +use gtk4::{gio, glib}; +use libadwaita as adw; + +use crate::test_support::{ + ButtonFace, button_face, descendants, has_accessible_label, test_backend_records_properties, +}; +use crate::ui::{ + PreferencesDialog, SessionWindow, browse_tab, connect_dialog, editor, export, history_dialog, session_window, + ssh_section, structure_tab, welcome_view, workspace, +}; + +/// Every class the app's templates build. A template whose class is +/// missing here fails the walk, so a new one cannot slip past it. The +/// templates nested in another are registered by its class. +fn template_classes() -> Vec { + vec![ + browse_tab::BrowseTab::static_type(), + connect_dialog::ConnectDialog::static_type(), + editor::SqlEditor::static_type(), + export::ExportOperationsButton::static_type(), + history_dialog::HistoryDialog::static_type(), + history_dialog::HistoryRow::static_type(), + session_window::ConnectFailedPage::static_type(), + session_window::ConnectingPage::static_type(), + session_window::PickerPage::static_type(), + PreferencesDialog::static_type(), + SessionWindow::static_type(), + ssh_section::SshSection::static_type(), + structure_tab::StructureTab::static_type(), + welcome_view::WelcomeView::static_type(), + workspace::Workspace::static_type(), + ] +} + +/// The markup of every template the app registers. +fn app_templates() -> Vec<(String, String)> { + let dir = format!("{}/ui", crate::config::RESOURCE_BASE_PATH); + gio::resources_enumerate_children(&dir, gio::ResourceLookupFlags::NONE) + .expect("the templates are registered") + .into_iter() + .map(|name| { + let path = format!("{dir}/{name}"); + let bytes = gio::resources_lookup_data(&path, gio::ResourceLookupFlags::NONE).expect("a template"); + let markup = String::from_utf8(bytes.to_vec()).expect("UTF-8 markup"); + (name.to_string(), markup) + }) + .collect() +} + +/// The template's class, and its markup with the root turned into a +/// plain object of the parent class, which a GtkBuilder builds without +/// the class, so what the template alone says can be read back. A +/// binding names the class's own properties and a signal the class's own +/// handler, which the parent lacks, and neither says anything about a +/// label, so both go. A property of the class itself set on the root +/// still fails the parse, with an error that names it. +fn plain_template(markup: &str) -> Option<(String, String)> { + let start = markup.find("", + "", + 1, + ); + for binding in ["bind-source", "bind-property", "bind-flags"] { + plain = without_attribute(&plain, binding); + } + Some((class.to_owned(), without_element(&plain, "signal"))) +} + +fn without_element(markup: &str, name: &str) -> String { + let open = format!("<{name} "); + let close = format!(""); + let mut kept = String::with_capacity(markup.len()); + let mut rest = markup; + while let Some(start) = rest.find(&open) { + kept.push_str(&rest[..start]); + let element = &rest[start..]; + rest = match element.find('>') { + Some(end) if element[..end].ends_with('/') => &element[end + 1..], + Some(_) => element.find(&close).map_or("", |end| &element[end + close.len()..]), + None => "", + }; + } + kept.push_str(rest); + kept +} + +fn without_attribute(markup: &str, name: &str) -> String { + let key = format!(" {name}=\""); + let mut kept = String::with_capacity(markup.len()); + let mut rest = markup; + while let Some(start) = rest.find(&key) { + kept.push_str(&rest[..start]); + let value = &rest[start + key.len()..]; + rest = value.find('"').map_or("", |end| &value[end + 1..]); + } + kept.push_str(rest); + kept +} + +fn attribute<'a>(tag: &'a str, name: &str) -> Option<&'a str> { + let key = format!(" {name}=\""); + let from = tag.find(&key)? + key.len(); + let len = tag[from..].find('"')?; + Some(&tag[from..from + len]) +} + +/// The id GtkBuilder gave the object, unless it made one up. +fn declared_id(widget: >k::Widget) -> Option { + widget + .buildable_id() + .map(|id| id.to_string()) + .filter(|id| !id.starts_with("___object_")) +} + +/// A button with no text, whose icon the template or the code gives it. +fn needs_label(widget: >k::Widget) -> bool { + matches!(button_face(widget), ButtonFace::Icon | ButtonFace::Empty) +} + +/// What is wrong with the icon-only buttons `markup` declares. A button +/// the template labels passes; one it does not must have an id, and be +/// labelled once `class` is built, which is where the code names it. +fn unlabelled_in_template(name: &str, markup: &str, class: Option) -> Vec { + let builder = gtk::Builder::new(); + if let Err(error) = builder.add_from_string(markup) { + return vec![format!("{name}: {error}")]; + } + let objects = builder.objects(); + let unlabelled: Vec = objects + .iter() + .filter_map(|object| object.downcast_ref::()) + .filter(|widget| needs_label(widget) && !has_accessible_label(widget)) + .cloned() + .collect(); + let instance = class + .filter(|_| !unlabelled.is_empty()) + .and_then(|class| glib::Object::with_type(class).downcast::().ok()); + let built = instance.as_ref().map(descendants).unwrap_or_default(); + + let mut problems = Vec::new(); + for button in unlabelled { + let kind = button.type_().name(); + let Some(id) = declared_id(&button) else { + problems.push(format!( + "{name}: an icon-only {kind} with no id has no accessible label in the template" + )); + continue; + }; + let same: Vec<>k::Widget> = built + .iter() + .filter(|widget| widget.type_() == button.type_() && declared_id(widget).as_deref() == Some(id.as_str())) + .collect(); + if same.is_empty() { + problems.push(format!( + "{name}: {id} has no accessible label and is not in the built widget" + )); + } + if same + .iter() + .any(|widget| needs_label(widget) && !has_accessible_label(widget)) + { + problems.push(format!("{name}: {id} has no accessible label")); + } + } + let windows = objects + .iter() + .filter_map(|object| object.downcast_ref::()) + .chain( + instance + .iter() + .filter_map(|widget| widget.downcast_ref::()), + ); + for window in windows { + window.destroy(); + } + problems.sort(); + problems +} + +#[gtk4::test] +fn every_icon_only_button_in_a_template_is_labelled() { + adw::init().expect("libadwaita initialises under the test backend"); + crate::register_test_resources(); + assert!( + test_backend_records_properties(), + "the GTK test accessibility backend is not active; run the tests with GTK_A11Y=test" + ); + // A class's own init registers the templates nested in it. + let _classes: Vec<_> = template_classes() + .into_iter() + .filter_map(glib::Class::::from_type) + .collect(); + + let templates = app_templates(); + let mut problems = Vec::new(); + for (name, markup) in &templates { + let Some((class, plain)) = plain_template(markup) else { + problems.push(format!("{name}: not a template")); + continue; + }; + let Some(class) = glib::Type::from_name(&class) else { + problems.push(format!("{name}: {class} is not in template_classes")); + continue; + }; + problems.extend(unlabelled_in_template(name, &plain, Some(class))); + } + + assert!(!templates.is_empty(), "the walk found no templates"); + assert!(problems.is_empty(), "{problems:#?}"); +} + +#[gtk4::test] +fn the_walk_reports_an_unlabelled_icon_button() { + adw::init().expect("libadwaita initialises under the test backend"); + let markup = r#" + + + + list-add-symbolic + Add + + + + + view-more-symbolic + + + + + user-trash-symbolic + + Delete + + + + + + Save + + + + "#; + + let problems = unlabelled_in_template("probe.ui", markup, None); + + assert_eq!( + problems, + [ + "probe.ui: an icon-only GtkButton with no id has no accessible label in the template", + "probe.ui: named_in_code has no accessible label and is not in the built widget", + ] + ); +} + +#[test] +fn a_template_is_read_as_an_object_of_its_parent_class() { + let markup = r#""#; + + assert_eq!( + plain_template(markup), + Some(( + "TableProThing".to_owned(), + r#""#.to_owned() + )) + ); + assert_eq!(plain_template(""), None); +} diff --git a/linux/crates/app/src/ui/browse_tab/browse_actions.rs b/linux/crates/app/src/ui/browse_tab/browse_actions.rs new file mode 100644 index 0000000000..4d859ae5e3 --- /dev/null +++ b/linux/crates/app/src/ui/browse_tab/browse_actions.rs @@ -0,0 +1,130 @@ +use gtk4 as gtk; +use gtk4::gio; +use gtk4::prelude::*; +use gtk4::subclass::prelude::*; + +use super::{BrowseModel, BrowseTab, BrowseTabInput}; +use crate::application::{ShortcutOwner, local_shortcut_controller}; +use crate::i18n::gettext; +use crate::services::tab_tracker_handle::TabTrackerHandle; +use crate::ui::a11y::{announce_key_shortcuts, describe_icon_button}; + +/// The `browse` actions whose state follows the tab. A disabled one lets +/// its key go on, and greys out the button that names it. +#[derive(Clone)] +pub(super) struct BrowseActions { + pub(super) save: gio::SimpleAction, + pub(super) undo: gio::SimpleAction, + pub(super) redo: gio::SimpleAction, + pub(super) insert_row: gio::SimpleAction, + pub(super) delete_rows: gio::SimpleAction, + pub(super) copy_rows: gio::SimpleAction, + pub(super) clear_selection: gio::SimpleAction, +} + +impl BrowseActions { + pub(super) fn follow_tracker(&self, tracker: &TabTrackerHandle, saving: bool) { + self.save.set_enabled(!saving && tracker.pending_count() > 0); + self.undo.set_enabled(tracker.can_undo()); + self.redo.set_enabled(tracker.can_redo()); + } + + pub(super) fn follow_selection(&self, selected: u32) { + self.copy_rows.set_enabled(selected > 0); + // Clearing a single focused row strands the focus indicator. + self.clear_selection.set_enabled(selected > 1); + } +} + +/// The tab's `browse` group, the tab's own keys for it and the keys its +/// buttons announce. The grid's keys go on each column view the tab +/// builds, through `install_grid_shortcuts`. +pub(super) fn install_browse_actions(tab: &BrowseTab) -> BrowseActions { + let group = gio::SimpleActionGroup::new(); + let add = |name: &str, input: fn() -> BrowseTabInput| { + let action = gio::SimpleAction::new(name, None); + let weak = tab.downgrade(); + action.connect_activate(move |_, _| { + if let Some(tab) = weak.upgrade() { + tab.send(input()); + } + }); + group.add_action(&action); + action + }; + add("filter", || BrowseTabInput::ToggleFilterStrip); + add("refresh", || BrowseTabInput::RequestRefresh); + add("paste-rows", || BrowseTabInput::PasteNotSupported); + let imp = tab.imp(); + let previous = add("previous-page", || BrowseTabInput::PrevPage); + imp.prev_button + .bind_property("sensitive", &previous, "enabled") + .sync_create() + .build(); + let next = add("next-page", || BrowseTabInput::NextPage); + imp.next_button + .bind_property("sensitive", &next, "enabled") + .sync_create() + .build(); + let delete_rows = gio::SimpleAction::new("delete-rows", None); + let weak = tab.downgrade(); + delete_rows.connect_activate(move |_, _| { + let Some(tab) = weak.upgrade() else { + return; + }; + let rows = tab.read(BrowseModel::selected_rows).unwrap_or_default(); + tab.send(BrowseTabInput::DeleteRows(rows)); + }); + group.add_action(&delete_rows); + let actions = BrowseActions { + save: add("save", || BrowseTabInput::CommitSave), + undo: add("undo", || BrowseTabInput::Undo), + redo: add("redo", || BrowseTabInput::Redo), + insert_row: add("insert-row", || BrowseTabInput::InsertRow), + delete_rows, + copy_rows: add("copy-rows", || BrowseTabInput::CopySelectedRowsAsTsv), + clear_selection: add("clear-selection", || BrowseTabInput::ClearSelection), + }; + for action in [ + &actions.save, + &actions.undo, + &actions.redo, + &actions.insert_row, + &actions.delete_rows, + &actions.copy_rows, + &actions.clear_selection, + ] { + action.set_enabled(false); + } + tab.insert_action_group("browse", Some(&group)); + tab.add_controller(local_shortcut_controller(ShortcutOwner::BrowseTab)); + actions +} + +/// Names the icon buttons and gives every button the keys of its action. +/// Run as the tab is built, so the names never wait on its actions. +pub(super) fn describe_buttons(tab: &BrowseTab) { + let imp = tab.imp(); + for (button, label, action) in [ + (&imp.insert_button, gettext("Insert Row"), Some("browse.insert-row")), + ( + &imp.delete_button, + gettext("Delete Selected Rows"), + Some("browse.delete-rows"), + ), + (&imp.first_button, gettext("First Page"), None), + (&imp.prev_button, gettext("Previous Page"), Some("browse.previous-page")), + (&imp.next_button, gettext("Next Page"), Some("browse.next-page")), + (&imp.last_button, gettext("Last Page"), None), + (&imp.export_button, gettext("Export Rows…"), None), + ] { + describe_icon_button(&button.get(), &label, action); + } + for (button, action) in [(&imp.filter_button, "browse.filter"), (&imp.save_button, "browse.save")] { + announce_key_shortcuts(&button.get(), action); + } +} + +pub(super) fn install_grid_shortcuts(view: >k::ColumnView) { + view.add_controller(local_shortcut_controller(ShortcutOwner::BrowseGrid)); +} diff --git a/linux/crates/app/src/ui/browse_tab/browse_command.rs b/linux/crates/app/src/ui/browse_tab/browse_command.rs index 9a96baa97a..869b266c03 100644 --- a/linux/crates/app/src/ui/browse_tab/browse_command.rs +++ b/linux/crates/app/src/ui/browse_tab/browse_command.rs @@ -1,11 +1,12 @@ use std::sync::Arc; use tablepro_core::ColumnInfo; -use tablepro_core::meta::RowIdentity; +use tablepro_core::meta::{RowIdentity, TableEstimate}; use tablepro_session::OperationError; use super::page_reply::PageReply; use crate::runtime::Stamped; +use crate::ui::tab_load::LoadFailure; /// The table's columns from the session's catalog, and how its rows are /// told apart, read together because a page needs both. @@ -19,7 +20,8 @@ pub(crate) struct ColumnsReply { /// answers so a reply to a replaced request is dropped. #[derive(Debug)] pub(crate) enum BrowseCommand { - Columns(Stamped>>), + Columns(Stamped>), Page(Stamped>), + Estimate(Stamped>), Count(Stamped>), } diff --git a/linux/crates/app/src/ui/browse_tab/browse_pager.rs b/linux/crates/app/src/ui/browse_tab/browse_pager.rs new file mode 100644 index 0000000000..2264e4341d --- /dev/null +++ b/linux/crates/app/src/ui/browse_tab/browse_pager.rs @@ -0,0 +1,150 @@ +use tablepro_core::meta::PagePosition; + +use super::row_total::RowTotal; + +/// Where the tab's page starts, and how many rows a page holds. Each +/// move gives the position to read and the row the page then starts at. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct BrowsePager { + page_size: u64, + start_ordinal: u64, +} + +impl BrowsePager { + pub(crate) fn new(page_size: u64, start_ordinal: u64) -> Self { + Self { + page_size: page_size.max(1), + start_ordinal, + } + } + + pub(crate) fn page_size(&self) -> u64 { + self.page_size + } + + pub(crate) fn start_ordinal(&self) -> u64 { + self.start_ordinal + } + + /// The position a read of the page the pager is on asks for. + pub(crate) fn position(&self) -> PagePosition { + position_at(self.start_ordinal) + } + + pub(crate) fn go_to(&mut self, start_ordinal: u64) { + self.start_ordinal = start_ordinal; + } + + /// A new page size starts again from the first row. + pub(crate) fn resize(&mut self, page_size: u64) { + self.page_size = page_size.max(1); + self.start_ordinal = 0; + } + + pub(crate) fn first(&self) -> Option<(PagePosition, u64)> { + (self.start_ordinal > 0).then_some((PagePosition::First, 0)) + } + + pub(crate) fn prev(&self) -> Option<(PagePosition, u64)> { + (self.start_ordinal > 0).then(|| { + let start = self.start_ordinal.saturating_sub(self.page_size); + (position_at(start), start) + }) + } + + /// A page with fewer rows than a page holds was the last one. + pub(crate) fn next(&self, rows_on_page: u64) -> Option<(PagePosition, u64)> { + (rows_on_page >= self.page_size).then(|| { + let start = self.start_ordinal.saturating_add(self.page_size); + (position_at(start), start) + }) + } + + /// Only an exact total says where the last page starts. + pub(crate) fn last(&self, total: &RowTotal) -> Option<(PagePosition, u64)> { + let start = self.last_start(total.exact()?)?; + (start != self.start_ordinal).then(|| (position_at(start), start)) + } + + /// The last page, for a pager left past the end of a table that + /// shrank since. + pub(crate) fn clamp(&self, total: u64) -> Option<(PagePosition, u64)> { + let start = self.last_start(total)?; + (self.start_ordinal >= total).then(|| (position_at(start), start)) + } + + fn last_start(&self, total: u64) -> Option { + let last_row = total.checked_sub(1)?; + Some(last_row / self.page_size * self.page_size) + } +} + +fn position_at(start: u64) -> PagePosition { + match start { + 0 => PagePosition::First, + start => PagePosition::Offset(start), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn browse_pager_last_requires_exact_total() { + let pager = BrowsePager::new(100, 0); + + assert_eq!(pager.last(&RowTotal::Unknown), None); + assert_eq!(pager.last(&RowTotal::Estimated(1_000)), None); + assert_eq!(pager.last(&RowTotal::Counting { estimate: Some(1_000) }), None); + assert_eq!( + pager.last(&RowTotal::Exact(1_000)), + Some((PagePosition::Offset(900), 900)) + ); + assert_eq!( + pager.last(&RowTotal::Exact(1_001)), + Some((PagePosition::Offset(1_000), 1_000)) + ); + assert_eq!(pager.last(&RowTotal::Exact(0)), None); + assert_eq!(BrowsePager::new(100, 900).last(&RowTotal::Exact(1_000)), None); + } + + #[test] + fn the_pager_moves_by_offset_and_stops_at_the_ends() { + let pager = BrowsePager::new(100, 200); + + assert_eq!(pager.position(), PagePosition::Offset(200)); + assert_eq!(pager.first(), Some((PagePosition::First, 0))); + assert_eq!(pager.prev(), Some((PagePosition::Offset(100), 100))); + assert_eq!(pager.next(100), Some((PagePosition::Offset(300), 300))); + assert_eq!(pager.next(40), None, "a short page went on"); + assert_eq!(BrowsePager::new(100, 0).first(), None); + assert_eq!(BrowsePager::new(100, 0).prev(), None); + assert_eq!(BrowsePager::new(100, 50).prev(), Some((PagePosition::First, 0))); + } + + #[test] + fn a_pager_past_the_end_clamps_to_the_last_page() { + assert_eq!( + BrowsePager::new(100, 500).clamp(250), + Some((PagePosition::Offset(200), 200)) + ); + assert_eq!(BrowsePager::new(100, 200).clamp(250), None); + assert_eq!( + BrowsePager::new(100, 250).clamp(260), + None, + "a page with rows on it moved" + ); + assert_eq!(BrowsePager::new(100, 200).clamp(0), None); + } + + #[test] + fn a_new_page_size_starts_from_the_first_row() { + let mut pager = BrowsePager::new(100, 300); + + pager.resize(500); + + assert_eq!((pager.page_size(), pager.start_ordinal()), (500, 0)); + assert_eq!(BrowsePager::new(0, 0).page_size(), 1); + } +} diff --git a/linux/crates/app/src/ui/browse_tab/browse_request_kind.rs b/linux/crates/app/src/ui/browse_tab/browse_request_kind.rs index 07c221d019..a7aeb39eb5 100644 --- a/linux/crates/app/src/ui/browse_tab/browse_request_kind.rs +++ b/linux/crates/app/src/ui/browse_tab/browse_request_kind.rs @@ -6,5 +6,8 @@ pub(crate) enum BrowseRequestKind { /// a count are built from them, so both wait for this one. Columns, Page, + /// The server's guess at the table's size. + Estimate, + /// The exact total, which can scan the whole table. Count, } diff --git a/linux/crates/app/src/ui/browse_tab/column_recheck.rs b/linux/crates/app/src/ui/browse_tab/column_recheck.rs new file mode 100644 index 0000000000..a931a80de2 --- /dev/null +++ b/linux/crates/app/src/ui/browse_tab/column_recheck.rs @@ -0,0 +1,165 @@ +use tablepro_core::ColumnInfo; +use tablepro_core::browse::PageLayout; +use tablepro_core::column::ResultColumn; + +/// Checks that a page came back with the columns the tab built it from. +/// A table altered between the column read and the page shows up here, +/// and the columns are read again once before the tab gives up on it. +#[derive(Debug, Default)] +pub(crate) struct ColumnRecheck { + attempted: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum RecheckAction { + Accept, + /// Read the columns again, then the page. + Refetch, + /// The columns moved again after they were read again: + /// `LoadFailure::ColumnsChanged`. + Fail, +} + +impl ColumnRecheck { + pub(crate) fn check( + &mut self, + columns: &[ColumnInfo], + result_columns: &[ResultColumn], + layout: &PageLayout, + ) -> RecheckAction { + let width = layout.visible.len() + layout.identity.len(); + let visible_names = result_columns + .get(layout.visible.clone()) + .unwrap_or_default() + .iter() + .map(|column| column.name.as_str()); + let matches = result_columns.len() == width + && layout.visible.len() == columns.len() + && visible_names.eq(columns.iter().map(|column| column.name.as_str())); + if matches { + self.attempted = false; + return RecheckAction::Accept; + } + // A failure spends the refetch, so the next mismatch, after Try + // Again or a page turn, gets one of its own. + match std::mem::replace(&mut self.attempted, true) { + false => RecheckAction::Refetch, + true => { + self.attempted = false; + RecheckAction::Fail + } + } + } + + /// A read of the columns other than the recheck's own, or the end of + /// the recheck's own in a failure. + pub(crate) fn reset(&mut self) { + self.attempted = false; + } +} + +#[cfg(test)] +mod tests { + use tablepro_core::column::{ColumnDefault, ColumnType}; + + use super::*; + + 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 result(names: &[&str]) -> Vec { + names.iter().map(|name| column(name).result_column()).collect() + } + + fn layout(visible: usize, identity: usize) -> PageLayout { + PageLayout { + visible: 0..visible, + identity: visible..visible + identity, + } + } + + #[test] + fn column_recheck_accepts_matching_visible_names_with_identity_tail() { + let mut recheck = ColumnRecheck::default(); + let columns = [column("id"), column("name")]; + + let action = recheck.check( + &columns, + &result(&["id", "name", "__tablepro_row_oid", "__tablepro_row_id"]), + &layout(2, 2), + ); + + assert_eq!(action, RecheckAction::Accept); + } + + #[test] + fn column_recheck_refetches_once_then_fails() { + let mut recheck = ColumnRecheck::default(); + let columns = [column("id"), column("name")]; + let renamed = result(&["id", "full_name"]); + + assert_eq!(recheck.check(&columns, &renamed, &layout(2, 0)), RecheckAction::Refetch); + assert_eq!(recheck.check(&columns, &renamed, &layout(2, 0)), RecheckAction::Fail); + assert_eq!( + recheck.check(&columns, &result(&["id", "name"]), &layout(2, 0)), + RecheckAction::Accept + ); + assert_eq!( + recheck.check(&columns, &renamed, &layout(2, 0)), + RecheckAction::Refetch, + "a page that matched did not reset the guard" + ); + } + + #[test] + fn a_failed_recheck_leaves_the_next_mismatch_its_refetch() { + let mut recheck = ColumnRecheck::default(); + let columns = [column("id"), column("name")]; + let renamed = result(&["id", "full_name"]); + + assert_eq!(recheck.check(&columns, &renamed, &layout(2, 0)), RecheckAction::Refetch); + assert_eq!(recheck.check(&columns, &renamed, &layout(2, 0)), RecheckAction::Fail); + assert_eq!(recheck.check(&columns, &renamed, &layout(2, 0)), RecheckAction::Refetch); + } + + #[test] + fn a_reset_leaves_the_next_mismatch_its_refetch() { + let mut recheck = ColumnRecheck::default(); + let columns = [column("id"), column("name")]; + let renamed = result(&["id", "full_name"]); + + assert_eq!(recheck.check(&columns, &renamed, &layout(2, 0)), RecheckAction::Refetch); + recheck.reset(); + + assert_eq!(recheck.check(&columns, &renamed, &layout(2, 0)), RecheckAction::Refetch); + } + + #[test] + fn column_recheck_rejects_wrong_identity_width() { + let mut recheck = ColumnRecheck::default(); + let columns = [column("id"), column("name")]; + + let action = recheck.check(&columns, &result(&["id", "name", "__tablepro_row_id"]), &layout(2, 2)); + + assert_eq!(action, RecheckAction::Refetch); + } + + #[test] + fn column_recheck_rejects_a_column_the_tab_does_not_have() { + let mut recheck = ColumnRecheck::default(); + + let action = recheck.check(&[column("id")], &result(&["id", "name"]), &layout(2, 0)); + + assert_eq!(action, RecheckAction::Refetch); + } +} diff --git a/linux/crates/app/src/ui/browse_tab/columns_fetch.rs b/linux/crates/app/src/ui/browse_tab/columns_fetch.rs new file mode 100644 index 0000000000..1ea2691fb1 --- /dev/null +++ b/linux/crates/app/src/ui/browse_tab/columns_fetch.rs @@ -0,0 +1,41 @@ +use std::sync::Arc; + +use tablepro_core::ColumnInfo; +use tablepro_core::meta::TableRef; +use tablepro_session::Session; + +use crate::ui::tab_load::LoadFailure; + +/// Whether a read of the table's columns may take the catalog's copy. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ColumnFreshness { + Cached, + /// Ask the server even when the catalog has them. A failure keeps + /// the catalog's copy for the next read. + Refetch, +} + +/// The columns a page is built from. A table with none the account can +/// read is a failure too, since no page can be read from it. +pub(crate) async fn load_columns( + session: Arc, + table: TableRef, + freshness: ColumnFreshness, +) -> Result, LoadFailure> { + let read = match freshness { + ColumnFreshness::Cached => session.columns(table.clone()).await, + ColumnFreshness::Refetch => session.refresh_columns(table.clone()).await, + }; + let failure = match read { + Ok(columns) if !columns.is_empty() => return Ok(columns), + Ok(_) => LoadFailure::NoColumns, + Err(error) => LoadFailure::Columns(error), + }; + tracing::warn!( + schema = ?table.schema, + table = %table.name, + category = ?failure.category(), + "browse columns failed" + ); + Err(failure) +} diff --git a/linux/crates/app/src/ui/browse_tab/count_failure_text.rs b/linux/crates/app/src/ui/browse_tab/count_failure_text.rs new file mode 100644 index 0000000000..11439e3a19 --- /dev/null +++ b/linux/crates/app/src/ui/browse_tab/count_failure_text.rs @@ -0,0 +1,86 @@ +use std::time::Duration; + +use tablepro_core::{DriverError, TimeoutPhase}; +use tablepro_session::OperationError; + +/// Why a count gave no total. A count stopped at its deadline names the +/// deadline it ran into, which the policy sets, not the driver. A lock +/// wait or a connect that ran out of time never reached that deadline. +pub(crate) fn count_failure_text(error: &OperationError, count_deadline: Option) -> String { + match (error, count_deadline) { + ( + OperationError::Driver(DriverError::Timeout { + phase: TimeoutPhase::Statement, + .. + }), + Some(deadline), + ) => { + let seconds = deadline.as_secs(); + crate::i18n::ngettext_f( + "Counting took longer than {seconds} second and was stopped.", + "Counting took longer than {seconds} seconds and was stopped.", + u32::try_from(seconds).unwrap_or(u32::MAX), + &[("seconds", &seconds.to_string())], + ) + } + _ => crate::ui::error_text::operation_error_message(error), + } +} + +#[cfg(test)] +mod tests { + use tablepro_core::LossPhase; + + use super::*; + + fn timed_out() -> OperationError { + OperationError::Driver(DriverError::Timeout { + phase: TimeoutPhase::Statement, + server_cancelled: true, + }) + } + + #[test] + fn count_failure_text_uses_policy_deadline() { + assert_eq!( + count_failure_text(&timed_out(), Some(Duration::from_secs(45))), + "Counting took longer than 45 seconds and was stopped." + ); + assert_eq!( + count_failure_text(&timed_out(), Some(Duration::from_secs(1))), + "Counting took longer than 1 second and was stopped." + ); + } + + #[test] + fn a_timeout_before_the_count_ran_is_not_its_deadline() { + for phase in [TimeoutPhase::LockWait, TimeoutPhase::Connect, TimeoutPhase::Login] { + let error = OperationError::Driver(DriverError::Timeout { + phase, + server_cancelled: false, + }); + + assert_eq!( + count_failure_text(&error, Some(Duration::from_secs(45))), + crate::ui::error_text::operation_error_message(&error), + "{phase}" + ); + } + } + + #[test] + fn other_count_failures_say_what_kind_of_problem_it_was() { + let lost = OperationError::Driver(DriverError::ConnectionLost { + during: LossPhase::Statement, + }); + + assert_eq!( + count_failure_text(&lost, Some(Duration::from_secs(45))), + "The connection was closed. Try reconnecting." + ); + assert_eq!( + count_failure_text(&timed_out(), None), + "The statement ran past its time limit, so the server stopped it." + ); + } +} diff --git a/linux/crates/app/src/ui/browse_tab/count_policy.rs b/linux/crates/app/src/ui/browse_tab/count_policy.rs new file mode 100644 index 0000000000..ba2ef4ef54 --- /dev/null +++ b/linux/crates/app/src/ui/browse_tab/count_policy.rs @@ -0,0 +1,66 @@ +use tablepro_core::meta::RowCountEstimate; + +/// Up to this many rows, a count costs little enough to run on its own. +pub(crate) const AUTO_COUNT_MAX_ROWS: u64 = 100_000; + +/// What the paginator does with the server's estimate of the table. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum CountAction { + UseExact(u64), + /// Show the estimate, and offer the count. + UseEstimate(u64), + /// Count now: the table is small enough that it bounds the count. + CountNow, + /// Offer the count and run nothing until the user asks. + OfferCount, +} + +/// `estimate` is the whole table's, so under a filter it says only how +/// large a count could get: no total is shown from it, and a small table +/// is still counted. +pub(crate) fn decide(estimate: Result, filtered: bool) -> CountAction { + match (estimate, filtered) { + (Ok(RowCountEstimate::Exact(rows)), false) => CountAction::UseExact(rows), + (Ok(RowCountEstimate::Exact(rows) | RowCountEstimate::Approximate(rows)), _) if rows <= AUTO_COUNT_MAX_ROWS => { + CountAction::CountNow + } + (Ok(RowCountEstimate::Approximate(rows)), false) => CountAction::UseEstimate(rows), + (Ok(_), _) | (Err(()), _) => CountAction::OfferCount, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn count_policy_table() { + let small = AUTO_COUNT_MAX_ROWS; + let large = AUTO_COUNT_MAX_ROWS + 1; + let cases = [ + (Ok(RowCountEstimate::Exact(7)), false, CountAction::UseExact(7)), + (Ok(RowCountEstimate::Exact(large)), false, CountAction::UseExact(large)), + (Ok(RowCountEstimate::Approximate(small)), false, CountAction::CountNow), + ( + Ok(RowCountEstimate::Approximate(large)), + false, + CountAction::UseEstimate(large), + ), + (Ok(RowCountEstimate::Unknown), false, CountAction::OfferCount), + (Err(()), false, CountAction::OfferCount), + (Ok(RowCountEstimate::Exact(7)), true, CountAction::CountNow), + (Ok(RowCountEstimate::Exact(large)), true, CountAction::OfferCount), + (Ok(RowCountEstimate::Approximate(small)), true, CountAction::CountNow), + (Ok(RowCountEstimate::Approximate(large)), true, CountAction::OfferCount), + (Ok(RowCountEstimate::Unknown), true, CountAction::OfferCount), + (Err(()), true, CountAction::OfferCount), + ]; + for (estimate, filtered, expected) in cases { + assert_eq!( + decide(estimate, filtered), + expected, + "{estimate:?}, filtered {filtered}" + ); + } + } +} diff --git a/linux/crates/app/src/ui/browse_tab/fetch.rs b/linux/crates/app/src/ui/browse_tab/fetch.rs index 9756fa5b81..50ac249745 100644 --- a/linux/crates/app/src/ui/browse_tab/fetch.rs +++ b/linux/crates/app/src/ui/browse_tab/fetch.rs @@ -1,62 +1,77 @@ use std::future::Future; use std::sync::Arc; -use tablepro_core::browse::{BrowseSqlError, PageLayout, PageRequest, SortKey, build_count, build_page}; -use tablepro_core::meta::{PagePosition, RowIdentity, TableRef}; +use tablepro_core::browse::{BrowseSqlError, PageLayout, PageRequest, SortKey, build_page}; +use tablepro_core::meta::{RowIdentity, TableRef}; use tablepro_core::statement::StatementBuilder; -use tablepro_core::{ColumnInfo, FilterSet, QueryResult, RowLimit}; +use tablepro_core::{BuildFilterError, ColumnInfo, FilterSet, QueryResult, RowLimit}; use tablepro_session::{LinkGeneration, OperationError, OperationKind}; +use super::column_recheck::RecheckAction; +use super::columns_fetch::{ColumnFreshness, load_columns}; use super::{ BrowseCommand, BrowseModel, BrowseRequestKind, BrowseTabInput, BrowseTabOutput, ColumnsReply, Effects, PageReply, }; use crate::runtime::{RequestTicket, Stamped}; +use crate::ui::export::TableExport; +use crate::ui::tab_load::LoadFailure; /// What a page or a count reads, copied off the tab so the work owns /// it. The SQL is built inside the operation from the lease it runs on, /// so it is spelled for that server even when a reconnect lands first. #[derive(Debug, Clone)] -struct ReadPlan { - table: TableRef, - columns: Vec, +pub(super) struct ReadPlan { + pub(super) table: TableRef, + pub(super) columns: Vec, identity: RowIdentity, - filter: FilterSet, + pub(super) filter: FilterSet, sort: Vec, - offset: u64, limit: u64, } impl BrowseModel { - /// Ask for one kind of read. A page or a count waits for the - /// columns it is built from. + /// Ask for one kind of read. A page or an estimate waits for the + /// columns, since the tab reads nothing of a table whose columns it + /// could not read. A count needs no such wait: it runs only once the + /// user or an estimate asks, after the columns. pub(super) fn fetch(&mut self, kind: BrowseRequestKind, fx: &mut Effects) { match kind { - BrowseRequestKind::Columns => self.fetch_columns(false), - BrowseRequestKind::Page | BrowseRequestKind::Count if self.identity.is_none() => { + BrowseRequestKind::Columns => self.fetch_columns(ColumnFreshness::Cached), + BrowseRequestKind::Page | BrowseRequestKind::Estimate if self.identity.is_none() => { self.after_columns.insert(kind); if !self.sequencer.is_pending(BrowseRequestKind::Columns) { - self.fetch_columns(false); + self.fetch_columns(ColumnFreshness::Cached); } } BrowseRequestKind::Page => self.fetch_page(fx), - BrowseRequestKind::Count => self.fetch_count(), + BrowseRequestKind::Estimate => self.fetch_estimate(), + BrowseRequestKind::Count => self.start_count(self.total.estimate(), fx), } } - /// Read the columns, then the page and the count built from them. - /// `refresh` asks the server even when the catalog has the columns. - pub(super) fn reload_from_columns(&mut self, refresh: bool) { + /// Read the columns, then the page built from them and the table's + /// estimate. No page is asked for before the columns land, since a + /// page is built from them, and a failure to read them fails the read + /// at once rather than leave the tab waiting. + pub(super) fn reload_from_columns(&mut self, freshness: ColumnFreshness) { self.after_columns.insert(BrowseRequestKind::Page); - self.after_columns.insert(BrowseRequestKind::Count); - self.fetch_columns(refresh); + self.after_columns.insert(BrowseRequestKind::Estimate); + self.fetch_columns(freshness); } - fn issue(&mut self, kind: BrowseRequestKind) -> RequestTicket { + pub(super) fn issue(&mut self, kind: BrowseRequestKind) -> RequestTicket { self.issued_on.insert(kind, self.session.link_generation()); self.sequencer.issue(kind) } - fn fetch_columns(&mut self, refresh: bool) { + /// A new read of the columns, so the page read under them gets its + /// own recheck. + fn fetch_columns(&mut self, freshness: ColumnFreshness) { + self.recheck.reset(); + self.read_columns(freshness); + } + + fn read_columns(&mut self, freshness: ColumnFreshness) { let ticket = self.issue(BrowseRequestKind::Columns); let session = self.session.clone(); let table = self.table_ref(); @@ -65,13 +80,8 @@ impl BrowseModel { // The catalog's read is shared with other views, so a // replaced request stops waiting on it without stopping it. let columns = tokio::select! { - columns = async { - match refresh { - true => session.refresh_columns(table.clone()).await, - false => session.columns(table.clone()).await, - } - } => columns?, - () = cancel.cancelled() => return Err(Arc::new(OperationError::Cancelled)), + columns = load_columns(session.clone(), table.clone(), freshness) => columns?, + () = cancel.cancelled() => return Err(LoadFailure::Columns(Arc::new(OperationError::Cancelled))), }; let identity = session .run_cancellable(OperationKind::Metadata, &cancel, move |lease, options| { @@ -79,11 +89,16 @@ impl BrowseModel { async move { Ok(lease.row_identity(&table, options).await?) } }) .await - .map_err(Arc::new)?; + .map_err(|error| { + if error != OperationError::Cancelled { + tracing::warn!(category = ?error.category(), "browse row identity failed"); + } + LoadFailure::Columns(Arc::new(error)) + })?; Ok(ColumnsReply { columns, identity }) }; self.deliver(ticket, work, BrowseCommand::Columns, |failure| { - Arc::new(OperationError::from(failure)) + LoadFailure::Columns(Arc::new(OperationError::from(failure))) }); } @@ -91,17 +106,15 @@ impl BrowseModel { let Some(plan) = self.read_plan() else { return; }; - if let Some(message) = self.filter_problem() { - self.on_filter_problem(&message, fx); + if let Some(problem) = self.filter_problem() { + self.on_filter_problem(problem, fx); return; } + self.begin_read(fx); let ticket = self.issue(BrowseRequestKind::Page); let session = self.session.clone(); let cancel = ticket.cancel.clone(); - let position = match plan.offset { - 0 => PagePosition::First, - offset => PagePosition::Offset(offset), - }; + let position = self.pager.position(); let work = async move { let reply_position = position.clone(); session @@ -137,37 +150,10 @@ impl BrowseModel { self.deliver(ticket, work, BrowseCommand::Page, OperationError::from); } - /// The "of N" total, under the same filter as the page so the two - /// agree. The page toasts a filter that cannot be written, so the - /// count stays quiet about it. - fn fetch_count(&mut self) { - let Some(plan) = self.read_plan() else { - return; - }; - if self.filter_problem().is_some() { - return; - } - let ticket = self.issue(BrowseRequestKind::Count); - let session = self.session.clone(); - let cancel = ticket.cancel.clone(); - let work = async move { - session - .run_cancellable(OperationKind::Count, &cancel, move |lease, options| { - let plan = plan.clone(); - async move { - let statement = build_count(&*lease.dialect(), &plan.table, &plan.columns, &plan.filter)?; - Ok(lease.count_rows(&statement, options).await?) - } - }) - .await - }; - self.deliver(ticket, work, BrowseCommand::Count, OperationError::from); - } - /// Run `work` on the app runtime and hand its stamped reply back to /// the tab on the GTK thread. The delivery holds the tab weakly, so a /// tab closed meanwhile is not kept alive by its own reads. - fn deliver( + pub(super) fn deliver( &self, ticket: RequestTicket, work: W, @@ -188,7 +174,7 @@ impl BrowseModel { }); } - fn read_plan(&self) -> Option { + pub(super) fn read_plan(&self) -> Option { Some(ReadPlan { table: self.table_ref(), columns: self.current_columns.clone(), @@ -199,32 +185,46 @@ impl BrowseModel { .map(|(column, ascending)| SortKey { column, ascending }) .into_iter() .collect(), - offset: self.current_offset, - limit: self.page_size, + limit: self.pager.page_size(), + }) + } + + /// Every row the tab's filter and sort select, for an export. `None` + /// until the columns and the row identity are in. + pub(super) fn table_export(&self) -> Option { + let plan = self.read_plan()?; + Some(TableExport { + table: plan.table, + columns: plan.columns.into(), + filter: plan.filter, + sort: plan.sort, + identity: plan.identity, }) } /// Why the tab's filter cannot be written for the current server, /// if it cannot. - fn filter_problem(&self) -> Option { + pub(super) fn filter_problem(&self) -> Option { let dialect = self.dialect.as_ref()?; let mut builder = StatementBuilder::new(); - tablepro_core::build_filter(&mut builder, &**dialect, &self.current_columns, &self.current_filter) - .err() - .map(|error| error.to_string()) + tablepro_core::build_filter(&mut builder, &**dialect, &self.current_columns, &self.current_filter).err() } /// A filter the dialect cannot write is the user's to fix, and no - /// page is read under it. A page already read under these columns - /// 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.page.is_some() { - self.refresh_inner_stack_visibility(); - fx.output(BrowseTabOutput::ShowToast(message.to_owned())); + /// page is read under it. Rows already on screen stay with a toast, + /// and a page still on its way under the old filter is stopped; + /// without rows, the error page says why rather than a spinner that + /// nothing will replace. + fn on_filter_problem(&mut self, problem: BuildFilterError, fx: &mut Effects) { + let failure = filter_failure(problem); + if !self.load.has_content() { + self.on_load_failed(failure, fx); return; } - self.show_error(message); + self.sequencer.cancel(BrowseRequestKind::Page); + fx.output(BrowseTabOutput::ShowToast(failure.message())); + let presentation = self.load.abandon(); + self.present(presentation, fx); } pub(super) fn on_command(&mut self, command: BrowseCommand, fx: &mut Effects) { @@ -242,13 +242,15 @@ impl BrowseModel { self.fetch(kind, fx); } } - Err(error) => { - 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); + Err(failure) => { + self.recheck.reset(); + // What waited on the columns was never asked, so it + // is asked again with them rather than set off by a + // later read of the columns that wanted none of it. + for kind in std::mem::take(&mut self.after_columns) { + self.failed.insert(kind); + } + self.on_load_failed(failure, fx); self.retry_if_link_moved(BrowseRequestKind::Columns, fx); } } @@ -259,36 +261,64 @@ impl BrowseModel { } self.failed.record(BrowseRequestKind::Page, &value); match value { - Ok(reply) => { - let offset = reply.offset().unwrap_or(self.current_offset); - self.on_rows_loaded(offset, reply, fx); - } + Ok(reply) => self.on_page_read(reply, fx), Err(error) => { - self.on_read_failed(&crate::ui::error_text::operation_error_message(&error), fx); + self.on_load_failed(LoadFailure::Page(Arc::new(error)), fx); self.retry_if_link_moved(BrowseRequestKind::Page, fx); } } } + BrowseCommand::Estimate(Stamped { stamp, value }) => { + if !self.sequencer.accept(BrowseRequestKind::Estimate, stamp) { + return; + } + self.failed.record(BrowseRequestKind::Estimate, &value); + let failed = value.is_err(); + self.on_estimate(value, fx); + if failed { + self.retry_if_link_moved(BrowseRequestKind::Estimate, fx); + } + } + // A failed count is not asked again on a reconnect: Try Again + // runs it, so a count that ran out of time does not run for + // another deadline, or under a filter applied since. BrowseCommand::Count(Stamped { stamp, value }) => { if !self.sequencer.accept(BrowseRequestKind::Count, stamp) { return; } - self.failed.record(BrowseRequestKind::Count, &value); - match value { - Ok(count) => self.on_row_count_loaded(count, fx), - Err(error) => { - tracing::debug!( - kind = failure_kind(&error), - category = ?error.category(), - "the row count could not be read" - ); - self.retry_if_link_moved(BrowseRequestKind::Count, fx); - } + if let Err(error) = &value { + tracing::debug!( + kind = failure_kind(error), + category = ?error.category(), + "the row count could not be read" + ); } + self.on_count(value, fx); } } } + /// A page read under columns the table no longer has would put cells + /// under the wrong headers, so the columns are read again once, then + /// the page; a second mismatch fails the read. + fn on_page_read(&mut self, reply: PageReply, fx: &mut Effects) { + match self + .recheck + .check(&self.current_columns, &reply.result.columns, &reply.layout) + { + RecheckAction::Accept => { + let offset = reply.offset().unwrap_or(self.pager.start_ordinal()); + self.on_rows_loaded(offset, reply, fx); + } + RecheckAction::Refetch => { + tracing::debug!("a page came back with other columns; reading the columns again"); + self.after_columns.insert(BrowseRequestKind::Page); + self.read_columns(ColumnFreshness::Refetch); + } + RecheckAction::Fail => self.on_load_failed(LoadFailure::ColumnsChanged, fx), + } + } + /// The session reconnected. Reads that failed are asked again on the /// new connection; a tab whose reads all landed keeps its rows. pub(super) fn on_link_changed(&mut self, generation: LinkGeneration, fx: &mut Effects) { @@ -316,13 +346,13 @@ impl BrowseModel { } let kinds = self.failed.take(); if kinds.contains(&BrowseRequestKind::Page) || kinds.contains(&BrowseRequestKind::Columns) { - self.show_loading(); + self.begin_read(fx); } match kinds.contains(&BrowseRequestKind::Columns) { true => { self.after_columns .extend(kinds.into_iter().filter(|kind| *kind != BrowseRequestKind::Columns)); - self.fetch_columns(false); + self.fetch_columns(ColumnFreshness::Cached); } false => { for kind in kinds { @@ -333,6 +363,11 @@ impl BrowseModel { } } +/// A filter the current server cannot write, as the tab reports it. +pub(super) fn filter_failure(problem: BuildFilterError) -> LoadFailure { + LoadFailure::Page(Arc::new(OperationError::Browse(problem.into()))) +} + /// 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> { diff --git a/linux/crates/app/src/ui/browse_tab/imp.rs b/linux/crates/app/src/ui/browse_tab/imp.rs index fb3ea4861f..d21c3262dd 100644 --- a/linux/crates/app/src/ui/browse_tab/imp.rs +++ b/linux/crates/app/src/ui/browse_tab/imp.rs @@ -4,7 +4,6 @@ use std::sync::OnceLock; use glib::subclass::Signal; use gtk4 as gtk; -use gtk4::gdk; use gtk4::glib; use gtk4::prelude::*; use gtk4::subclass::prelude::*; @@ -20,6 +19,8 @@ pub struct BrowseTab { #[template_child] pub toolbar: TemplateChild, #[template_child] + pub stale_banner: TemplateChild, + #[template_child] pub read_only_banner: TemplateChild, #[template_child] pub no_pk_banner: TemplateChild, @@ -32,6 +33,8 @@ pub struct BrowseTab { #[template_child] pub error_page: TemplateChild, #[template_child] + pub retry_button: TemplateChild, + #[template_child] pub insert_button: TemplateChild, #[template_child] pub delete_button: TemplateChild, @@ -46,6 +49,12 @@ pub struct BrowseTab { #[template_child] pub paginator_label: TemplateChild, #[template_child] + pub count_button: TemplateChild, + #[template_child] + pub count_spinner: TemplateChild, + #[template_child] + pub count_label: TemplateChild, + #[template_child] pub selection_label: TemplateChild, #[template_child] pub export_button: TemplateChild, @@ -83,7 +92,6 @@ impl ObjectSubclass for BrowseTab { fn class_init(klass: &mut Self::Class) { klass.bind_template(); - install_bindings(klass); } fn instance_init(object: &glib::subclass::InitializingObject) { @@ -91,81 +99,17 @@ impl ObjectSubclass for BrowseTab { } } -/// The grid's keyboard model. The bindings act only while the focus is -/// inside this tab, so the editor and the other tabs keep their own. -fn install_bindings(klass: &mut ::Class) { - let none = gdk::ModifierType::empty(); - let ctrl = gdk::ModifierType::CONTROL_MASK; - let send = |input: fn() -> BrowseTabInput, propagation: glib::Propagation| { - move |tab: &super::BrowseTab| { - tab.send(input()); - propagation - } - }; - // Esc clears a multi-row selection and lets the key go on, so a - // search bar or dialog above still closes on it. - klass.add_binding( - gdk::Key::Escape, - none, - send(|| BrowseTabInput::ClearSelection, glib::Propagation::Proceed), - ); - klass.add_binding( - gdk::Key::n, - ctrl, - send(|| BrowseTabInput::InsertRow, glib::Propagation::Stop), - ); - // 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( - gdk::Key::c, - ctrl, - send(|| BrowseTabInput::CopySelectedRowsAsTsv, glib::Propagation::Stop), - ); - klass.add_binding( - gdk::Key::v, - ctrl, - send(|| BrowseTabInput::PasteNotSupported, glib::Propagation::Stop), - ); - klass.add_binding( - gdk::Key::a, - ctrl, - send(|| BrowseTabInput::SelectAllRows, glib::Propagation::Stop), - ); - // Paging is by offset, so scrolling stops at the page edge; Page Up - // and Page Down move between pages instead. - klass.add_binding( - gdk::Key::Page_Up, - none, - send(|| BrowseTabInput::PrevPage, glib::Propagation::Stop), - ); - klass.add_binding( - gdk::Key::Page_Down, - none, - send(|| BrowseTabInput::NextPage, glib::Propagation::Stop), - ); - klass.add_binding( - gdk::Key::Home, - ctrl, - send(|| BrowseTabInput::GoToFirstRow, glib::Propagation::Stop), - ); - klass.add_binding( - gdk::Key::End, - ctrl, - send(|| BrowseTabInput::GoToLastRow, glib::Propagation::Stop), - ); -} - impl ObjectImpl for BrowseTab { + fn constructed(&self) { + self.parent_constructed(); + super::browse_actions::describe_buttons(&self.obj()); + } + fn signals() -> &'static [Signal] { static SIGNALS: OnceLock> = OnceLock::new(); 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([glib::BoxedAnyObject::static_type()]) - .build(), Signal::builder("export-results") .param_types([glib::BoxedAnyObject::static_type()]) .build(), @@ -177,7 +121,11 @@ impl ObjectImpl for BrowseTab { .param_types([bool::static_type()]) .build(), Signal::builder("save-requested").build(), + Signal::builder("loading-changed") + .param_types([bool::static_type()]) + .build(), Signal::builder("stop-save-requested").build(), + Signal::builder("refresh-requested").build(), ] }) } diff --git a/linux/crates/app/src/ui/browse_tab/load_tests.rs b/linux/crates/app/src/ui/browse_tab/load_tests.rs new file mode 100644 index 0000000000..c455a45560 --- /dev/null +++ b/linux/crates/app/src/ui/browse_tab/load_tests.rs @@ -0,0 +1,518 @@ +use std::cell::RefCell; +use std::rc::Rc; +use std::time::Duration; + +use gtk4::prelude::*; +use gtk4::subclass::prelude::ObjectSubclassIsExt; +use libadwaita as adw; +use tablepro_core::{ColumnInfo, DriverError, LossPhase, QueryResult, Value}; +use tablepro_test_support::{CallCounters, FakeReply}; + +use super::BrowseTabInput; +use super::tests::{ + Harness, Shown, WAIT, after_next_frame, column, column_view, delete_action, full_page, header_titles, key, + not_null_name, rows, serving_table, text, toasts, +}; +use crate::test_support::{drain_main_context, wait_until}; +use crate::ui::grid::GridCell; + +fn banner(harness: &Harness) -> adw::Banner { + harness.tab().imp().stale_banner.get() +} + +/// Every value the tab's loading signal carried, in order. +fn loading_log(harness: &Harness) -> Rc>> { + let log = Rc::new(RefCell::new(Vec::new())); + let sink = log.clone(); + harness + .tab() + .connect_loading_changed(move |_, loading| sink.borrow_mut().push(loading)); + log +} + +/// A page whose rows are keyed `ids`, each named after its key. +fn keyed(ids: &[i64]) -> QueryResult { + let names: Vec = ids.iter().map(|id| format!("row {id}")).collect(); + let names: Vec<&str> = names.iter().map(String::as_str).collect(); + let mut page = rows(&names); + for (row, id) in page.rows.iter_mut().zip(ids) { + row[0] = Value::Int(*id); + } + page +} + +#[gtk4::test] +fn refresh_keeps_grid_visible_and_sets_tab_loading() { + let mut script = serving_table(); + script + .query_bound + .once(FakeReply::ok(rows(&["before"]))) + .always(FakeReply::Late { + after: Duration::from_millis(300), + outcome: Ok(rows(&["after"])), + }); + let harness = Harness::open(script, []); + wait_until(WAIT, || harness.shown() == text(&["before"])).expect("the first page"); + let loading = loading_log(&harness); + + harness.tab().send(BrowseTabInput::Refresh); + + assert_eq!(harness.visible_page().as_deref(), Some("grid")); + assert_eq!(harness.shown(), text(&["before"]), "the refresh blanked the rows"); + assert!(harness.tab().is_loading()); + wait_until(WAIT, || harness.shown() == text(&["after"])).expect("the refreshed page"); + assert!(!harness.tab().is_loading()); + assert_eq!(*loading.borrow(), [true, false]); +} + +#[gtk4::test] +fn failed_refresh_keeps_rows_and_reveals_stale_banner() { + let mut script = serving_table(); + script + .query_bound + .once(FakeReply::ok(rows(&["kept"]))) + .always(FakeReply::err(DriverError::ConnectionLost { + during: LossPhase::Statement, + })); + let harness = Harness::open(script, []); + wait_until(WAIT, || harness.shown() == text(&["kept"])).expect("the first page"); + let counters = harness.connection.counters(); + + harness.tab().send(BrowseTabInput::Refresh); + wait_until(WAIT, || banner(&harness).is_revealed()).expect("the failed refresh was not shown"); + + assert_eq!(CallCounters::get(&counters.query_bound), 2); + assert_eq!(harness.shown(), text(&["kept"]), "the failed refresh dropped the rows"); + assert_eq!(harness.visible_page().as_deref(), Some("grid")); + assert!(!harness.tab().is_loading()); + let tooltip = banner(&harness).tooltip_text().map(|text| text.to_string()); + assert_eq!(tooltip.as_deref(), Some("The connection was closed. Try reconnecting.")); +} + +#[gtk4::test] +fn stale_banner_retry_reloads_and_hides_banner() { + let mut script = serving_table(); + script + .query_bound + .once(FakeReply::ok(rows(&["kept"]))) + .once(FakeReply::err(DriverError::server("denied"))) + .always(FakeReply::ok(rows(&["fresh"]))); + let harness = Harness::open(script, []); + wait_until(WAIT, || harness.shown() == text(&["kept"])).expect("the first page"); + harness.tab().send(BrowseTabInput::Refresh); + wait_until(WAIT, || banner(&harness).is_revealed()).expect("the stale banner"); + + banner(&harness).emit_by_name::<()>("button-clicked", &[]); + + wait_until(WAIT, || harness.shown() == text(&["fresh"])).expect("Retry read the page again"); + assert!( + !banner(&harness).is_revealed(), + "the banner outlived the read that landed" + ); +} + +#[gtk4::test] +fn first_load_failure_shows_error_page_with_try_again() { + let mut script = serving_table(); + script + .query_bound + .once(FakeReply::err(DriverError::server("relation is gone"))) + .always(FakeReply::ok(rows(&["back"]))); + let harness = Harness::open(script, []); + + wait_until(WAIT, || harness.visible_page().as_deref() == Some("error")).expect("the error page"); + let imp = harness.tab().imp(); + let description = imp + .error_page + .description() + .map(|text| text.to_string()) + .unwrap_or_default(); + assert!(description.contains("relation <users> is gone"), "{description}"); + assert!(!banner(&harness).is_revealed()); + assert!(imp.retry_button.has_css_class("pill") && imp.retry_button.has_css_class("suggested-action")); + + imp.retry_button.emit_clicked(); + + wait_until(WAIT, || harness.shown() == text(&["back"])).expect("Try Again read the page"); + assert_eq!(harness.visible_page().as_deref(), Some("grid")); +} + +#[gtk4::test] +fn refresh_restores_multi_row_selection_and_focus_by_key() { + let mut script = serving_table(); + script + .query_bound + .once(FakeReply::ok(keyed(&[0, 1, 2, 3, 4]))) + .always(FakeReply::ok(keyed(&[9, 0, 1, 2, 3, 4]))); + let harness = Harness::open(script, []); + wait_until(WAIT, || harness.shown().len() == 5).expect("the first page"); + let shown = Shown::new(harness.tab()); + let view = column_view(harness.tab()); + let name = crate::ui::grid::data_columns(&view) + .into_iter() + .nth(1) + .expect("the name column"); + view.scroll_to(3, 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(3))).expect("a cell takes the focus"); + let selection = harness.selection(); + let picked = gtk4::Bitset::new_empty(); + picked.add(1); + picked.add(3); + selection.set_selection(&picked, >k4::Bitset::new_range(0, selection.n_items())); + + harness.tab().send(BrowseTabInput::Refresh); + wait_until(WAIT, || harness.shown().len() == 6).expect("the refreshed page"); + after_next_frame(&shown.0); + + let selected: Vec<_> = crate::ui::grid::selected_positions(&harness.selection()) + .into_iter() + .map(|position| harness.grid_row(position).key().cloned()) + .collect(); + assert_eq!(selected, [Some(key(1)), Some(key(3))]); + wait_until(WAIT, || { + focused_cell() + .is_some_and(|cell| cell.column() == 1 && cell.row().and_then(|row| row.key().cloned()) == Some(key(3))) + }) + .expect("the focus did not come back to the cell it was in"); +} + +#[gtk4::test] +fn a_selection_is_not_put_back_after_a_failed_refresh() { + let mut script = serving_table(); + script + .query_bound + .once(FakeReply::ok(keyed(&[0, 1, 2]))) + .once(FakeReply::err(DriverError::server("denied"))) + .always(FakeReply::ok(keyed(&[0, 1, 2]))); + let harness = Harness::open(script, []); + wait_until(WAIT, || harness.shown().len() == 3).expect("the first page"); + let selection = harness.selection(); + selection.select_item(0, true); + harness.tab().send(BrowseTabInput::Refresh); + wait_until(WAIT, || banner(&harness).is_revealed()).expect("the stale banner"); + selection.select_item(2, true); + + harness.tab().send(BrowseTabInput::Refresh); + wait_until(WAIT, || !banner(&harness).is_revealed()).expect("the second refresh"); + drain_main_context(); + + let selected: Vec<_> = crate::ui::grid::selected_positions(&harness.selection()) + .into_iter() + .map(|position| harness.grid_row(position).key().cloned()) + .collect(); + assert_eq!( + selected, + [Some(key(2))], + "the selection from before the failed refresh came back" + ); +} + +fn error_text(harness: &Harness) -> String { + harness + .tab() + .imp() + .error_page + .description() + .map(|text| text.to_string()) + .unwrap_or_default() +} + +#[gtk4::test] +fn columns_failure_shows_error_page_not_spinner() { + let mut script = serving_table(); + script.fetch_columns = Default::default(); + script + .fetch_columns + .always(FakeReply::err(DriverError::server("permission denied"))); + let harness = Harness::open(script, []); + + wait_until(WAIT, || harness.visible_page().as_deref() == Some("error")).expect("the tab stayed on its spinner"); + + assert!( + error_text(&harness).contains("permission denied"), + "{}", + error_text(&harness) + ); + assert!(!harness.tab().is_loading()); + assert_eq!(CallCounters::get(&harness.connection.counters().query_bound), 0); +} + +#[gtk4::test] +fn empty_column_list_is_a_load_failure() { + let mut script = serving_table(); + script.fetch_columns = Default::default(); + script.fetch_columns.always(FakeReply::ok(Vec::::new())); + let harness = Harness::open(script, []); + + wait_until(WAIT, || harness.visible_page().as_deref() == Some("error")).expect("the tab stayed on its spinner"); + + assert_eq!( + error_text(&harness), + gtk4::glib::markup_escape_text(&crate::ui::tab_load::LoadFailure::NoColumns.message()).as_str() + ); + assert_eq!(CallCounters::get(&harness.connection.counters().query_bound), 0); +} + +#[gtk4::test] +fn refresh_after_columns_failure_refetches_columns() { + let mut script = serving_table(); + script.fetch_columns = Default::default(); + script + .fetch_columns + .once(FakeReply::err(DriverError::server("busy"))) + .always(FakeReply::ok(vec![column("id", true), column("name", false)])); + script.query_bound.always(FakeReply::ok(rows(&["back"]))); + let harness = Harness::open(script, []); + wait_until(WAIT, || harness.visible_page().as_deref() == Some("error")).expect("the error page"); + + harness.tab().imp().retry_button.emit_clicked(); + + wait_until(WAIT, || harness.shown() == text(&["back"])).expect("the grid after the columns were read again"); + assert_eq!(CallCounters::get(&harness.connection.counters().fetch_columns), 2); + assert_eq!(harness.visible_page().as_deref(), Some("grid")); +} + +#[gtk4::test] +fn refresh_failure_of_columns_keeps_cached_columns() { + let mut script = serving_table(); + script.fetch_columns = Default::default(); + script + .fetch_columns + .once(FakeReply::ok(vec![column("id", true), column("name", false)])) + .always(FakeReply::err(DriverError::server("busy"))); + script.query_bound.always(FakeReply::ok(rows(&["kept"]))); + let harness = Harness::open(script, []); + wait_until(WAIT, || harness.shown() == text(&["kept"])).expect("the first page"); + + harness.tab().send(BrowseTabInput::Refresh); + wait_until(WAIT, || banner(&harness).is_revealed()).expect("the failed refresh was not shown"); + + assert_eq!(header_titles(harness.tab()), ["id", "name"]); + assert_eq!(harness.tab().columns(), [column("id", true), column("name", false)]); + assert_eq!(harness.shown(), text(&["kept"])); + assert_eq!(CallCounters::get(&harness.connection.counters().query_bound), 1); +} + +#[gtk4::test] +fn page_column_mismatch_refetches_columns_once() { + let renamed = [column("id", true), column("full_name", false)]; + let mut script = serving_table(); + script.query_bound.always(FakeReply::ok(QueryResult::new( + renamed.iter().map(ColumnInfo::result_column).collect::>(), + vec![vec![Value::Int(0), Value::Text("Ada".to_owned())]], + ))); + let harness = Harness::open(script, []); + + wait_until(WAIT, || harness.visible_page().as_deref() == Some("error")).expect("the mismatched page was shown"); + + let counters = harness.connection.counters(); + assert_eq!(CallCounters::get(&counters.fetch_columns), 2); + assert_eq!(CallCounters::get(&counters.query_bound), 2); + assert!( + harness.shown().is_empty(), + "rows read under other columns reached the grid" + ); + assert!( + error_text(&harness).contains("columns changed"), + "{}", + error_text(&harness) + ); +} + +/// The page under the new filter could not be read, so the error page +/// shows. The rows read without the filter are still in the store, +/// and nothing may act on them from behind it. +#[gtk4::test] +fn the_rows_behind_the_error_page_take_no_action() { + let mut script = serving_table(); + script + .query_bound + .when_sql_contains("IS NOT NULL", FakeReply::err(DriverError::server("denied"))) + .always(FakeReply::ok(rows(&["unfiltered"]))); + let harness = Harness::open(script, []); + wait_until(WAIT, || harness.shown() == text(&["unfiltered"])).expect("the first page"); + harness.selection().select_item(0, true); + assert!(delete_action(harness.tab()).is_enabled()); + let exported = Rc::new(RefCell::new(0)); + let sink = exported.clone(); + harness + .tab() + .connect_export_results(move |_, _| *sink.borrow_mut() += 1); + let toasts = toasts(harness.tab()); + + harness.tab().send(BrowseTabInput::FilterApplied(not_null_name())); + wait_until(WAIT, || harness.visible_page().as_deref() == Some("error")).expect("the error page"); + harness.tab().send(BrowseTabInput::Export(None)); + + assert_eq!(*exported.borrow(), 0, "Export wrote rows the filter does not match"); + assert!( + !copy_rows_action(harness.tab()).is_enabled(), + "Copy stayed on for rows behind the error page" + ); + assert_eq!(*toasts.borrow(), ["Nothing to export"]); + assert!( + !delete_action(harness.tab()).is_enabled(), + "Delete stayed on for rows behind the error page" + ); + assert_eq!(harness.tab().imp().paginator_label.label(), ""); +} + +/// Next moved the pager before its read failed, so the way back goes +/// from where the pager is, and the key for it with the button. +#[gtk4::test] +fn a_failed_next_page_leaves_the_way_back_open() { + let mut script = serving_table(); + // A count below the second page would clamp the pager back to the + // first before the failed read lands. + script.count_rows.always(FakeReply::ok(1_000)); + script + .query_bound + .once(FakeReply::ok(full_page(0))) + .once(FakeReply::err(DriverError::server("denied"))) + .always(FakeReply::ok(full_page(0))); + let harness = Harness::open(script, []); + wait_until(WAIT, || harness.shown().len() == 100).expect("the first page"); + let imp = harness.tab().imp(); + assert!(!imp.prev_button.is_sensitive()); + + harness.tab().send(BrowseTabInput::NextPage); + wait_until(WAIT, || harness.visible_page().as_deref() == Some("error")).expect("the error page"); + + assert_eq!(harness.tab().current_offset(), 100); + assert!(imp.first_button.is_sensitive(), "First stayed off past the first page"); + assert!( + imp.prev_button.is_sensitive(), + "Previous stayed off past the first page" + ); + assert!( + !imp.next_button.is_sensitive(), + "Next stayed on past a page that was not read" + ); + harness + .tab() + .activate_action("browse.previous-page", None) + .expect("the action"); + wait_until(WAIT, || harness.visible_page().as_deref() == Some("grid")).expect("the first page again"); + assert_eq!(harness.tab().current_offset(), 0); + assert!(!imp.prev_button.is_sensitive()); + assert!(imp.next_button.is_sensitive()); +} + +fn renamed_page() -> QueryResult { + let renamed = [column("id", true), column("full_name", false)]; + QueryResult::new( + renamed.iter().map(ColumnInfo::result_column).collect::>(), + vec![vec![Value::Int(0), Value::Text("Ada".to_owned())]], + ) +} + +/// The columns read again for a page that came back with others failed. +/// Try Again starts over, so a page that comes back with other columns +/// then gets its own read of them. +#[gtk4::test] +fn try_again_after_a_failed_recheck_reads_the_columns_once_more() { + let mut script = serving_table(); + script.fetch_columns = Default::default(); + script + .fetch_columns + .once(FakeReply::ok(vec![column("id", true), column("name", false)])) + .once(FakeReply::err(DriverError::server("busy"))) + .always(FakeReply::ok(vec![column("id", true), column("name", false)])); + script + .query_bound + .once(FakeReply::ok(renamed_page())) + .once(FakeReply::ok(renamed_page())) + .always(FakeReply::ok(rows(&["back"]))); + let harness = Harness::open(script, []); + wait_until(WAIT, || error_text(&harness).contains("busy")).expect("the failed read of the columns"); + + harness.tab().imp().retry_button.emit_clicked(); + + wait_until(WAIT, || harness.shown() == text(&["back"])).expect("the page after the columns were read again"); + assert_eq!(CallCounters::get(&harness.connection.counters().fetch_columns), 4); +} + +/// A refresh whose columns failed asked for no page and no estimate. A +/// later read of the columns, for a page that came back with others, +/// must not start them. +#[gtk4::test] +fn a_failed_column_read_leaves_no_read_for_a_later_one_to_start() { + let mut script = serving_table(); + script.fetch_columns = Default::default(); + script + .fetch_columns + .once(FakeReply::ok(vec![column("id", true), column("name", false)])) + .once(FakeReply::err(DriverError::server("busy"))) + .always(FakeReply::ok(vec![column("id", true), column("name", false)])); + script + .query_bound + .once(FakeReply::ok(rows(&["first"]))) + .once(FakeReply::ok(renamed_page())) + .always(FakeReply::ok(rows(&["back"]))); + let harness = Harness::open(script, []); + let counters = harness.connection.counters(); + wait_until(WAIT, || harness.shown() == text(&["first"])).expect("the first page"); + wait_until(WAIT, || CallCounters::get(&counters.count_rows) == 1).expect("the count"); + harness.tab().send(BrowseTabInput::Refresh); + wait_until(WAIT, || banner(&harness).is_revealed()).expect("the failed refresh"); + + harness.tab().send(BrowseTabInput::SortChanged { + col_idx: 0, + ascending: false, + }); + wait_until(WAIT, || harness.shown() == text(&["back"])).expect("the page after the columns were read again"); + std::thread::sleep(Duration::from_millis(200)); + drain_main_context(); + + assert_eq!(CallCounters::get(&counters.fetch_columns), 3); + assert_eq!( + CallCounters::get(&counters.table_estimate), + 1, + "the failed refresh's estimate ran under a later read of the columns" + ); +} + +fn copy_rows_action(tab: &super::BrowseTab) -> gtk4::gio::SimpleAction { + use gtk4::subclass::prelude::ObjectSubclassIsExt; + tab.imp() + .model + .borrow() + .as_ref() + .map(|model| model.actions.copy_rows.clone()) + .expect("the tab has a model") +} + +/// The status page reads its description as markup, where the `&` +/// would leave it empty. +#[gtk4::test] +fn the_loading_page_names_a_table_with_an_ampersand() { + libadwaita::init().expect("libadwaita initialises under the test backend"); + crate::register_test_resources(); + let runtime = crate::test_support::test_runtime(); + let mut script = serving_table(); + script.query_bound.always(FakeReply::Late { + after: Duration::from_secs(5), + outcome: Ok(rows(&["late"])), + }); + let fake = crate::test_support::FakeSession::start(script, [], runtime.tasks()); + let root = tempfile::tempdir().expect("tempdir"); + let tab = super::tests::table_tab( + &fake.session, + &runtime, + &root, + "R&D", + Default::default(), + Default::default(), + false, + ); + let shown = || crate::test_support::status_page_description(&tab.imp().loading_page); + + assert_eq!(shown().as_deref(), Some("Fetching rows from R&D")); + wait_until(WAIT, || tab.is_loading()).expect("the read to start"); + assert_eq!(shown().as_deref(), Some("Fetching rows from R&D")); + + drop(tab); + runtime.shutdown(); +} diff --git a/linux/crates/app/src/ui/browse_tab/mod.rs b/linux/crates/app/src/ui/browse_tab/mod.rs index 81ef35d040..180b28ad07 100644 --- a/linux/crates/app/src/ui/browse_tab/mod.rs +++ b/linux/crates/app/src/ui/browse_tab/mod.rs @@ -3,20 +3,34 @@ //! The tab reads its own columns, pages and row count through the //! session, one request of each kind at a time: a newer request stops //! the one it replaces, and a reply to a replaced request is dropped. -//! A read that failed is asked again once the session reconnects. +//! A read that failed is asked again once the session reconnects. A +//! count is not: it runs only when the estimate or the user asks. +mod browse_actions; mod browse_command; +mod browse_pager; mod browse_request_kind; +mod column_recheck; +mod columns_fetch; mod confirming_delete; +mod count_failure_text; +mod count_policy; mod fetch; mod imp; +#[cfg(test)] +mod load_tests; mod page_reply; mod page_state; +mod row_count; mod row_deletion; +mod row_total; +mod selection_snapshot; #[cfg(test)] mod tests; +#[cfg(test)] +mod total_tests; -use std::cell::{Cell, RefCell}; +use std::cell::Cell; use std::collections::{BTreeSet, VecDeque}; use std::rc::Rc; use std::sync::Arc; @@ -24,35 +38,47 @@ use std::sync::Arc; use gtk4 as gtk; use gtk4::prelude::*; use gtk4::subclass::prelude::*; -use gtk4::{gdk, gio, glib}; +use gtk4::{gio, glib}; use libadwaita as adw; use libadwaita::prelude::*; use tablepro_core::dialect::SqlDialect; use tablepro_core::edit::{CellInput, EditParseError, parse_edit_text}; -use tablepro_core::export::RenderInsertError; -use tablepro_core::meta::{RowIdentity, TableRef}; +use tablepro_core::meta::{PagePosition, RowIdentity, TableRef}; use tablepro_core::{ColumnInfo, QueryResult, Value}; +use tablepro_session::operation::DeadlinePolicy; use tablepro_session::runtime::{Tasks, WatchForwarder}; use tablepro_session::{LinkGeneration, Session}; +use self::browse_actions::BrowseActions; pub(crate) use self::browse_command::{BrowseCommand, ColumnsReply}; +use self::browse_pager::BrowsePager; pub(crate) use self::browse_request_kind::BrowseRequestKind; +use self::column_recheck::ColumnRecheck; +use self::columns_fetch::ColumnFreshness; 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}; +pub(crate) use self::row_total::RowTotal; +use self::selection_snapshot::SelectionSnapshot; +use super::format::format_thousands; use super::grid::{ 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, + GridSorting, RowJsonRequest, RowKeyBuilder, RowObject, RowSnapshot, TabGridContext, build_column_view, + cell_editability, cell_presets, data_columns, delete_availability, edit_context, edit_row, export_snapshot, + present_row_json, }; +use super::tab_load::{ContentLoad, ContentPage, LoadFailure, LoadPresentation, StaleBanner}; use crate::runtime::{FailedRequests, RequestSequencer}; +use crate::services::change_origins::ChangeOrigins; 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; use crate::services::tracker_edit::TrackerEdit; +use crate::ui::clipboard::{CopyKind, CopyRequest, InClauseRows, InsertCopy, InsertRow}; +use crate::ui::export::{ExportOffer, ExportOrigin, ExportQuery, ExportSource}; const PAGE_SIZE_OPTIONS: &[u64] = &[100, 500, 1_000, 5_000, 10_000]; const DEFAULT_PAGE_SIZE: u64 = 1_000; @@ -85,14 +111,8 @@ pub struct BrowseTabInit { /// closed before it shuts down. pub session: Arc, pub tasks: Tasks, -} - -/// Rows the user asked to write to a file, and what to call it. -#[derive(Debug, Clone)] -pub struct ExportRequest { - pub result: QueryResult, - pub name: String, - pub target: Option, + /// The session's deadlines, which a count that ran out names. + pub deadlines: Arc, } impl BrowseTab { @@ -108,10 +128,8 @@ impl BrowseTab { .map(|id| init.filter_settings.filters(id, init.schema.as_deref(), &init.table)) .unwrap_or_default(); let table_label = qualified(init.schema.as_deref(), &init.table); - imp.loading_page.set_description(Some(&crate::i18n::gettext_f( - "Fetching rows from {table}", - &[("table", &table_label)], - ))); + imp.loading_page + .set_description(Some(&loading_description(&table_label))); imp.inner_stack.set_visible_child_name("loading"); imp.read_only_banner.set_revealed(init.read_only); @@ -146,7 +164,15 @@ 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(); + let stale_banner = StaleBanner::new(imp.stale_banner.get(), { + let tab = tab.downgrade(); + move || { + if let Some(tab) = tab.upgrade() { + tab.send(BrowseTabInput::Refresh); + } + } + }); + let actions = browse_actions::install_browse_actions(&tab); // 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 @@ -169,6 +195,7 @@ impl BrowseTab { sequencer: RequestSequencer::new(), failed: FailedRequests::new(), after_columns: BTreeSet::new(), + recheck: ColumnRecheck::default(), issued_on: std::collections::BTreeMap::new(), link_generation, identity: None, @@ -180,27 +207,34 @@ impl BrowseTab { read_only: init.read_only, column_widths: init.column_widths, filter_settings: init.filter_settings, - current_offset: init.initial_offset, - page_size: init.page_size, + pager: BrowsePager::new(init.page_size, init.initial_offset), + deadlines: init.deadlines, current_sort: init.initial_sort, current_filter: initial_filter, current_columns: Vec::new(), page: None, shown_view: None, current_selection: None, - current_total_rows: None, + total: RowTotal::Unknown, + load: ContentLoad::default(), + tab_loading: false, + stale_banner, inner_stack: imp.inner_stack.get(), grid_holder: imp.grid_holder.get(), loading_page: imp.loading_page.get(), error_page: imp.error_page.get(), current_column_view: None, + view_columns: Vec::new(), row_index: GridRowIndex::default(), rendered_columns: Vec::new(), read_only_banner: imp.read_only_banner.get(), no_pk_banner: imp.no_pk_banner.get(), was_dirty: Cell::new(false), - pending_focus_restore: RefCell::new(None), + pending_restore: None, paginator_label: imp.paginator_label.get(), + count_button: imp.count_button.get(), + count_spinner: imp.count_spinner.get(), + count_label: imp.count_label.get(), selection_label: imp.selection_label.get(), first_button: imp.first_button.get(), prev_button: imp.prev_button.get(), @@ -211,7 +245,8 @@ impl BrowseTab { filter_strip: Some(filter_strip), insert_button: imp.insert_button.get(), delete_button: imp.delete_button.get(), - delete_action, + actions, + saving: Cell::new(false), pending_revealer: imp.pending_revealer.get(), save_button: imp.save_button.get(), discard_button: imp.discard_button.get(), @@ -305,37 +340,32 @@ impl BrowseTab { } pub fn current_offset(&self) -> u64 { - self.read(|model| model.current_offset).unwrap_or_default() + self.read(|model| model.pager.start_ordinal()).unwrap_or_default() } pub fn page_size(&self) -> u64 { - self.read(|model| model.page_size).unwrap_or(DEFAULT_PAGE_SIZE) + self.read(|model| model.pager.page_size()).unwrap_or(DEFAULT_PAGE_SIZE) } pub fn current_sort(&self) -> Option<(usize, bool)> { self.read(|model| model.current_sort).flatten() } - pub fn connect_state_changed(&self, f: F) -> glib::SignalHandlerId { - self.connect_closure("state-changed", false, glib::closure_local!(move |tab: &Self| f(tab))) + /// A read of the tab's rows is running. + pub fn is_loading(&self) -> bool { + self.read(|model| model.tab_loading).unwrap_or(false) } - pub fn connect_copy_to_clipboard(&self, f: F) -> glib::SignalHandlerId { - self.connect_closure( - "copy-to-clipboard", - false, - glib::closure_local!(move |tab: &Self, text: glib::BoxedAnyObject| { - f(tab, text.borrow::().clone()); - }), - ) + pub fn connect_state_changed(&self, f: F) -> glib::SignalHandlerId { + self.connect_closure("state-changed", false, glib::closure_local!(move |tab: &Self| f(tab))) } - pub fn connect_export_results(&self, f: F) -> glib::SignalHandlerId { + pub(crate) fn connect_export_results(&self, f: F) -> glib::SignalHandlerId { self.connect_closure( "export-results", false, glib::closure_local!(move |tab: &Self, payload: glib::BoxedAnyObject| { - f(tab, payload.borrow::().clone()); + f(tab, payload.borrow::().clone()); }), ) } @@ -370,6 +400,15 @@ impl BrowseTab { self.connect_closure("save-requested", false, glib::closure_local!(move |tab: &Self| f(tab))) } + /// F5 asked the window to read the tab again. + pub fn connect_refresh_requested(&self, f: F) -> glib::SignalHandlerId { + self.connect_closure( + "refresh-requested", + false, + glib::closure_local!(move |tab: &Self| f(tab)), + ) + } + /// Stop Saving asked the window to stop the tab's save. pub fn connect_stop_save_requested(&self, f: F) -> glib::SignalHandlerId { self.connect_closure( @@ -379,14 +418,29 @@ impl BrowseTab { ) } + /// A read of the tab's rows started or ended. + pub fn connect_loading_changed(&self, f: F) -> glib::SignalHandlerId { + self.connect_closure( + "loading-changed", + false, + glib::closure_local!(move |tab: &Self, loading: bool| f(tab, loading)), + ) + } + 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", &[&glib::BoxedAnyObject::new(text)]); + BrowseTabOutput::Copy(request) => self.copy(request), + BrowseTabOutput::ShowRowJson(request) => { + if let Some(tasks) = self.read(|model| model.tasks.clone()) { + let tab = self.clone().upcast(); + glib::spawn_future_local(async move { + present_row_json(tab, tasks, request).await; + }); + } } - BrowseTabOutput::ExportResults(request) => { - self.emit_by_name::<()>("export-results", &[&glib::BoxedAnyObject::new(request)]); + BrowseTabOutput::Export(offer) => { + self.emit_by_name::<()>("export-results", &[&glib::BoxedAnyObject::new(*offer)]); } BrowseTabOutput::ShowSelectionAlert { title, body } => { self.emit_by_name::<()>("alert", &[&title, &body]); @@ -395,37 +449,54 @@ impl BrowseTab { BrowseTabOutput::DirtyChanged(dirty) => self.emit_by_name::<()>("dirty-changed", &[&dirty]), BrowseTabOutput::SaveRequested => self.emit_by_name::<()>("save-requested", &[]), BrowseTabOutput::StopSaveRequested => self.emit_by_name::<()>("stop-save-requested", &[]), + BrowseTabOutput::LoadingChanged(loading) => self.emit_by_name::<()>("loading-changed", &[&loading]), + BrowseTabOutput::RefreshRequested => self.emit_by_name::<()>("refresh-requested", &[]), } } + /// Render a copy off the GTK thread, and toast how it went. + fn copy(&self, request: CopyRequest) { + let Some(tasks) = self.read(|model| model.tasks.clone()) else { + return; + }; + let tab = self.downgrade(); + crate::ui::clipboard::copy_rows(self, &tasks, request, move |text| { + if let Some(tab) = tab.upgrade() { + tab.emit_by_name::<()>("toast", &[&text]); + } + }); + } + /// The top-bar slot draws a thin strip even when every banner in it /// is collapsed, so the slot itself is hidden unless one shows. fn collapse_top_bars_with(&self, filter: >k::Revealer) { let imp = self.imp(); // The banners and the strip hold this through their notify - // handlers, and the tab holds them, so it holds all four weakly. + // handlers, and the tab holds them, so it holds them all weakly. let sync: Rc = { let toolbar = imp.toolbar.downgrade(); - let read_only = imp.read_only_banner.downgrade(); - let no_pk = imp.no_pk_banner.downgrade(); + let banners = [ + imp.stale_banner.downgrade(), + imp.read_only_banner.downgrade(), + imp.no_pk_banner.downgrade(), + ]; let filter = filter.downgrade(); Rc::new(move || { - let (Some(toolbar), Some(read_only), Some(no_pk), Some(filter)) = ( - toolbar.upgrade(), - read_only.upgrade(), - no_pk.upgrade(), - filter.upgrade(), - ) else { + let (Some(toolbar), Some(filter)) = (toolbar.upgrade(), filter.upgrade()) else { return; }; - toolbar.set_reveal_top_bars(read_only.is_revealed() || no_pk.is_revealed() || filter.reveals_child()); + let banner_shown = banners + .iter() + .filter_map(glib::WeakRef::upgrade) + .any(|banner| banner.is_revealed()); + toolbar.set_reveal_top_bars(banner_shown || filter.reveals_child()); }) }; sync(); - let read_only_sync = sync.clone(); - imp.read_only_banner.connect_revealed_notify(move |_| read_only_sync()); - let no_pk_sync = sync.clone(); - imp.no_pk_banner.connect_revealed_notify(move |_| no_pk_sync()); + for banner in [&imp.stale_banner, &imp.read_only_banner, &imp.no_pk_banner] { + let sync = sync.clone(); + banner.connect_revealed_notify(move |_| sync()); + } filter.connect_reveal_child_notify(move |_| sync()); } @@ -439,15 +510,16 @@ impl BrowseTab { } }); }; - on_click(&imp.insert_button, || BrowseTabInput::InsertRow); on_click(&imp.first_button, || BrowseTabInput::FirstPage); on_click(&imp.prev_button, || BrowseTabInput::PrevPage); on_click(&imp.next_button, || BrowseTabInput::NextPage); on_click(&imp.last_button, || BrowseTabInput::LastPage); - on_click(&imp.export_button, || BrowseTabInput::ExportCurrentPage); + on_click(&imp.export_button, || BrowseTabInput::Export(None)); on_click(&imp.discard_button, || BrowseTabInput::DiscardAll); on_click(&imp.save_button, || BrowseTabInput::CommitSave); on_click(&imp.stop_button, || BrowseTabInput::StopSaving); + on_click(&imp.retry_button, || BrowseTabInput::Refresh); + on_click(&imp.count_button, || BrowseTabInput::CountRows); let tab = self.downgrade(); imp.page_size_dropdown.connect_selected_notify(move |dropdown| { let index = usize::try_from(dropdown.selected()).unwrap_or(usize::MAX); @@ -456,32 +528,6 @@ impl BrowseTab { } }); } - - /// `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)); - }); - 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 - } } impl From for BrowseTabInput { @@ -489,11 +535,13 @@ impl From for BrowseTabInput { match msg { GridMsg::SortChanged(col_idx, ascending) => Self::SortChanged { col_idx, ascending }, GridMsg::Edit { row, column, edit } => Self::GridEdit { row, column, edit }, - GridMsg::CopyToClipboard(text) => Self::GridCopyToClipboard(text), + GridMsg::Copy(request) => Self::GridCopy(request), + GridMsg::ShowRowJson(request) => Self::GridShowRowJson(request), GridMsg::ShowToast(text) => Self::GridShowToast(text), GridMsg::CopyRowsAsInsert { rows } => Self::GridCopyRowsAsInsert(rows), + GridMsg::CopyInClause(values) => Self::GridCopyInClause(values), GridMsg::SetCellValue { row, column, preset } => Self::GridSetCellValue { row, column, preset }, - GridMsg::ExportResults(result) => Self::GridExportResults(result), + GridMsg::ExportResults(page) => Self::Export(Some(page)), GridMsg::DeleteRows { rows } => Self::DeleteRows(rows), GridMsg::InsertRow => Self::InsertRow, GridMsg::DuplicateRows { rows } => Self::DuplicateRows(rows), @@ -536,9 +584,11 @@ pub(crate) struct BrowseModel { tasks: Tasks, sequencer: RequestSequencer, failed: FailedRequests, - /// Reads that wait for the columns, because a page and a count are - /// built from them. + /// Reads that wait for the columns: a page is built from them, and + /// the tab reads nothing else of a table whose columns it could not + /// read. after_columns: BTreeSet, + recheck: ColumnRecheck, /// The connection each running request was issued on. A request /// that fails after the connection was replaced is asked again at /// once, since the reconnect it would wait for has already landed. @@ -559,8 +609,9 @@ pub(crate) struct BrowseModel { column_widths: crate::persistence::ColumnWidthStore, filter_settings: crate::persistence::FilterSettingsStore, - current_offset: u64, - page_size: u64, + /// Where the page the tab reads starts, and how many rows it holds. + pager: BrowsePager, + deadlines: Arc, current_sort: Option<(usize, bool)>, /// The WHERE every page and count reads under, kept per connection, /// schema and table. @@ -572,13 +623,21 @@ pub(crate) struct BrowseModel { /// that same page again leaves it on screen. shown_view: Option, current_selection: Option, - current_total_rows: Option, + /// The table's row count under the filter, as far as the tab knows. + total: RowTotal, + load: ContentLoad, + /// What the tab bar was last told, so it hears only changes. + tab_loading: bool, + stale_banner: StaleBanner, inner_stack: gtk::Stack, grid_holder: gtk::Box, loading_page: adw::StatusPage, error_page: adw::StatusPage, current_column_view: Option, + /// The view's columns in the order of a row's cells, which a drag in + /// the header leaves alone. + view_columns: Vec, /// The store's rows by tracker key, so a tracked change finds the /// row to redraw without a scan of the page. row_index: GridRowIndex, @@ -593,10 +652,13 @@ pub(crate) struct BrowseModel { /// Last-emitted dirty state, so App hears only real transitions and /// not every count change (2 → 3). was_dirty: Cell, - /// The focused row before a sort, save or refresh, so the reloaded - /// page selects it again. - pending_focus_restore: RefCell>, + /// The selection and the focused cell before a sort, save or + /// refresh, so the page read again puts them back. + pending_restore: Option, paginator_label: gtk::Label, + count_button: gtk::Button, + count_spinner: adw::Spinner, + count_label: gtk::Label, selection_label: gtk::Label, first_button: gtk::Button, prev_button: gtk::Button, @@ -607,8 +669,9 @@ pub(crate) struct BrowseModel { 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, + actions: BrowseActions, + /// Between the save starting and settling. + saving: Cell, pending_revealer: gtk::Revealer, save_button: gtk::Button, discard_button: gtk::Button, @@ -639,6 +702,8 @@ pub(crate) enum BrowseTabInput { /// Esc. A single selected row stays selected: clearing it would /// strand the keyboard focus indicator. ClearSelection, + /// F5: the window asks first when the tab has unsaved changes. + RequestRefresh, ToggleFilterStrip, /// The strip's rules, or an empty set for "Clear all". FilterApplied(tablepro_core::FilterSet), @@ -670,11 +735,14 @@ pub(crate) enum BrowseTabInput { preset: CellPreset, }, GridShowToast(String), - GridExportResults(QueryResult), - ExportCurrentPage, + /// The paginator's Export button, or the grid's Export with the page + /// as the grid holds it. + Export(Option), GridCopyRowsAsInsert(Vec), + GridCopyInClause(InClauseRows), DuplicateRows(Vec), - GridCopyToClipboard(String), + GridCopy(CopyRequest), + GridShowRowJson(RowJsonRequest), Undo, Redo, CommitSave, @@ -691,8 +759,11 @@ pub(crate) enum BrowseTabInput { ticket: u64, confirmed: bool, }, - /// The save landed. Edits made while it ran stay pending. - SaveCommitted, + /// The save landed, adding and removing these rows. Edits made + /// while it ran stay pending. + SaveCommitted(ChangeOrigins), + /// Count Rows, or Try Again after a count failed. + CountRows, /// The pending changes stay, so the user can fix the cause and /// save again. SaveFailed(String), @@ -704,9 +775,6 @@ pub(crate) enum BrowseTabInput { ErrorFlashEnded(RowKey), CopySelectedRowsAsTsv, PasteNotSupported, - SelectAllRows, - GoToFirstRow, - GoToLastRow, } #[derive(Debug)] @@ -714,8 +782,9 @@ pub(crate) enum BrowseTabOutput { /// Something the window keeps for the tab changed: the page, the /// sort, the page size. StateChanged, - CopyToClipboard(String), - ExportResults(ExportRequest), + Copy(CopyRequest), + ShowRowJson(RowJsonRequest), + Export(Box), ShowSelectionAlert { title: String, body: String, @@ -724,6 +793,10 @@ pub(crate) enum BrowseTabOutput { DirtyChanged(bool), SaveRequested, StopSaveRequested, + /// A read of the tab's rows started or ended, for the tab bar. + LoadingChanged(bool), + /// F5 asked the window to read the tab again. + RefreshRequested, } impl BrowseModel { @@ -777,16 +850,45 @@ impl BrowseModel { false } - /// 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 page = self.page.as_ref()?; + /// The page the paginator's Export button writes: the grid's rows, + /// so it matches the context menu's Export row for row. + fn export_payload(&self) -> Option { + let page = self.shown_page()?; 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)) + Some(ExportSource::Rows { + columns: page.result_columns.clone(), + rows, + }) + } + + /// Ask the window to export the page, or every row the filter + /// selects. Rows behind an error page are not what the table holds + /// under the current filter, so they are never offered. + fn offer_export(&self, page: Option, fx: &mut Effects) { + let page = match (self.shown_page(), page) { + (Some(_), Some(page)) => Some(page), + (Some(_), None) => self.export_payload(), + (None, _) => None, + }; + let (Some(page), Some(table)) = (page, self.table_export()) else { + fx.output(BrowseTabOutput::ShowToast(crate::i18n::gettext("Nothing to export"))); + return; + }; + fx.output(BrowseTabOutput::Export(Box::new(ExportOffer { + origin: ExportOrigin::Browse { + page, + query: ExportQuery::Table(table), + total: self.total.clone(), + has_unsaved_changes: self.tracker.pending_count() > 0, + }, + session: Some(self.session.clone()), + name: self.export_name(), + target: Some(self.sql_target()), + }))); } /// Resolve a preset against the column it lands in. The menu offers @@ -821,6 +923,8 @@ impl BrowseModel { /// While a save runs, Save and Discard are off, so a double click /// cannot send two, and Stop Saving takes their place. fn show_saving(&self, saving: bool) { + self.saving.set(saving); + self.actions.follow_tracker(&self.tracker, saving); self.save_button.set_sensitive(!saving); self.discard_button.set_sensitive(!saving); self.saving_spinner.set_visible(saving); @@ -866,21 +970,42 @@ impl BrowseModel { self.current_selection.as_ref().map(selected_rows).unwrap_or_default() } + /// GtkMultiSelection emits no selection-changed when its rows are + /// spliced, so a page that drops the selected rows comes through here. + fn sync_selection(&self) { + let selected = self + .current_selection + .as_ref() + .map_or(0, |sel| u32::try_from(sel.selection().size()).unwrap_or(u32::MAX)); + update_selection_chrome(&self.selection_label, selected); + self.sync_delete_action(); + } + /// The rows on screen can change under the selection without it - /// changing, so this runs on every delta and page as well. + /// changing, so this runs on every delta, page and load state too. + /// Rows behind an error page count as none. fn sync_delete_action(&self) { - show_delete_availability( - &self.delete_action, - &self.delete_button, - &self.selected_rows(), - self.read_only, - ); + let rows = match self.shown_page() { + Some(_) => self.selected_rows(), + None => Vec::new(), + }; + self.actions + .follow_selection(u32::try_from(rows.len()).unwrap_or(u32::MAX)); + show_delete_availability(&self.actions.delete_rows, &self.delete_button, &rows, self.read_only); + } + + /// The page whose rows the grid shows. A failed read of another page + /// leaves the rows it replaced in the store, behind the error page, + /// where nothing may export, copy or delete them. + fn shown_page(&self) -> Option<&PageState> { + self.page.as_ref().filter(|_| self.load.has_content()) } /// The footer and the tab's dirty flag, which App turns into the /// title bullet. App hears only real transitions. fn sync_pending_chrome(&self, count: usize, fx: &mut Effects) { self.refresh_pending_bar(count); + self.actions.follow_tracker(&self.tracker, self.saving.get()); let dirty = count > 0; if dirty != self.was_dirty.replace(dirty) { fx.output(BrowseTabOutput::DirtyChanged(dirty)); @@ -930,11 +1055,48 @@ impl BrowseModel { } /// F5: read the columns again, in case the table changed under the - /// tab, then the page and the count built from them. - fn reload(&mut self) { - self.capture_focus_for_restore(); - self.show_loading(); - self.reload_from_columns(true); + /// tab, then the page and the count built from them. The rows on + /// screen stay until the new page lands. + fn reload(&mut self, fx: &mut Effects) { + self.capture_selection(); + self.begin_read(fx); + self.stop_count(true); + self.reload_from_columns(ColumnFreshness::Refetch); + } + + /// A read of the page the tab asks for starts. Rows read under the + /// same filter, sort and offset stay through a failure; rows of + /// another page do not, since they are not what the tab shows. + fn begin_read(&mut self, fx: &mut Effects) { + let presentation = match self.shown_view.as_ref() == Some(&self.page_view()) { + true => self.load.begin(), + false => self.load.begin_replacing(), + }; + self.present(presentation, fx); + } + + /// Put the read's state on screen: the spinner, the grid or the + /// error page, the banner over rows that could not be read again, + /// and the tab bar's spinner. + fn present(&mut self, presentation: LoadPresentation, fx: &mut Effects) { + match &presentation.page { + ContentPage::Spinner => self.show_loading(), + ContentPage::Content => self.inner_stack.set_visible_child_name("grid"), + ContentPage::Error(failure) => { + // The status page reads its description as markup. + self.error_page + .set_description(Some(&glib::markup_escape_text(&failure.message()))); + self.inner_stack.set_visible_child_name("error"); + } + } + self.stale_banner.show(presentation.stale.as_ref()); + self.sync_pager_buttons(); + self.sync_total_chrome(); + self.sync_delete_action(); + if self.tab_loading != presentation.tab_loading { + self.tab_loading = presentation.tab_loading; + fx.output(BrowseTabOutput::LoadingChanged(presentation.tab_loading)); + } } /// Reveal at most ONE banner at a time. Read-only takes priority @@ -979,8 +1141,12 @@ impl BrowseModel { None, ); } - let generation = self.tracker.flash_error_row(key.clone()); + let flash = self.tracker.flash_error_row(key.clone()); row.bump_revision(); + if let Some(replaced) = flash.replaced.and_then(|replaced| self.row_index.get(&replaced)) { + replaced.bump_revision(); + } + let generation = flash.generation; let tracker = self.tracker.clone(); let tab = self.this.clone(); // Matches the length of the flash animation in the stylesheet. The @@ -994,27 +1160,22 @@ impl BrowseModel { }); } - /// Snapshot the currently focused/first-selected row's identity - /// before an operation that reloads the page (sort flip, save, F5), - /// so the view re-anchors to the same row after the new page. - fn capture_focus_for_restore(&self) { + /// Keep the selection and the focused cell by row key before a sort, + /// save or refresh reads the page again. + fn capture_selection(&mut self) { // A capture still pending (sort and save in quick succession) - // is kept: the first visible focus should anchor to where the - // user was before the chain started. - if self.pending_focus_restore.borrow().is_some() { + // is kept: the user was where the chain started. + if self.pending_restore.is_some() { return; } - let Some(selection) = self.current_selection.as_ref() else { + let (Some(selection), Some(column_view)) = (self.current_selection.as_ref(), self.current_column_view.as_ref()) + else { return; }; - let bitset = selection.selection(); - if bitset.size() == 0 { - return; + let snapshot = SelectionSnapshot::capture(selection, column_view); + if !snapshot.is_empty() { + self.pending_restore = Some(snapshot); } - let Some(row) = selection.item(bitset.nth(0)).and_downcast::() else { - return; - }; - *self.pending_focus_restore.borrow_mut() = row.id().tracker_key(); } /// Edit the first editable cell of the new draft, which is always @@ -1028,32 +1189,35 @@ impl BrowseModel { }; glib::idle_add_local_once(move || { // 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() { + // view, and a failed read hides it behind the error page. The + // row was inserted; only the edit is missed. + if cv.is_mapped() { edit_row(&cv, 0); } }); } - /// Select the row captured before the reload again, if it is still on - /// the page. The capture is used up either way, so it cannot steer a - /// later reload. - fn restore_focused_row(&mut self) { - let Some(key) = self.pending_focus_restore.borrow_mut().take() else { + /// Put back the selection captured before the page was read again, + /// on the rows that are still on it. The capture is used up either + /// way, so it cannot steer a later read. + fn restore_selection(&mut self) { + let Some(snapshot) = self.pending_restore.take() else { return; }; - let Some(store) = self.list_store() else { + let (Some(store), Some(selection), Some(column_view)) = ( + self.list_store(), + self.current_selection.clone(), + self.current_column_view.clone(), + ) else { return; }; - let Some(position) = self.row_index.position_in(&store, &key) else { - return; - }; - if let Some(selection) = self.current_selection.as_ref() { - selection.select_item(position, true); - } - if let Some(cv) = self.current_column_view.as_ref() { - cv.scroll_to(position, None, gtk::ListScrollFlags::FOCUS, None); - } + snapshot.restore( + &mut self.row_index, + &store, + &selection, + &column_view, + &self.view_columns, + ); } /// The columns came back the same, so the page on screen still @@ -1063,7 +1227,6 @@ impl BrowseModel { return; } self.refresh_grid_chrome(); - self.restore_focused_row(); fx.output(BrowseTabOutput::StateChanged); } @@ -1079,9 +1242,11 @@ impl BrowseModel { { store.splice(0, store.n_items(), &rows); self.reindex_rows(); - self.sync_delete_action(); + self.sync_selection(); self.refresh_grid_chrome(); - self.restore_focused_row(); + let presentation = self.load.succeed(); + self.present(presentation, fx); + self.restore_selection(); fx.output(BrowseTabOutput::StateChanged); return; } @@ -1090,18 +1255,19 @@ impl BrowseModel { let content = GridContent { columns: page.result_columns.clone(), rows, - truncated: page.truncated, }; let (column_view, selection) = build_column_view( content, &self.current_columns, self.grid_sink.clone(), - self.current_sort, - true, + GridSorting::Server { + initial: self.current_sort, + }, self.grid_context(), ); self.current_selection = Some(selection); self.current_column_view = Some(column_view.clone()); + self.view_columns = data_columns(&column_view); self.rendered_columns = self.current_columns.clone(); // The selection model is new per rebuild, so the previous @@ -1110,7 +1276,7 @@ impl BrowseModel { 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 actions = self.actions.clone(); let read_only = self.read_only; sel.connect_selection_changed(move |sel, _, _| { let (Some(label), Some(button)) = (label.upgrade(), button.upgrade()) else { @@ -1118,13 +1284,14 @@ impl BrowseModel { }; let n = u32::try_from(sel.selection().size()).unwrap_or(u32::MAX); update_selection_chrome(&label, n); - show_delete_availability(&action, &button, &selected_rows(sel), read_only); + actions.follow_selection(n); + show_delete_availability(&actions.delete_rows, &button, &selected_rows(sel), read_only); }); - update_selection_chrome(&self.selection_label, 0); } + browse_actions::install_grid_shortcuts(&column_view); self.reindex_rows(); - self.sync_delete_action(); + self.sync_selection(); let scrolled = gtk::ScrolledWindow::builder() .child(&column_view) @@ -1133,7 +1300,9 @@ impl BrowseModel { .build(); self.grid_holder.append(&scrolled); self.refresh_grid_chrome(); - self.restore_focused_row(); + let presentation = self.load.succeed(); + self.present(presentation, fx); + self.restore_selection(); fx.output(BrowseTabOutput::StateChanged); } @@ -1153,17 +1322,19 @@ impl BrowseModel { /// The grid was built for columns the table no longer has, so it /// goes before a click or a key in it lands on a moved column. - fn drop_grid(&mut self) { + fn drop_grid(&mut self, fx: &mut Effects) { if self.current_column_view.take().is_none() { return; } clear_box(&self.grid_holder); self.current_selection = None; + self.view_columns.clear(); self.rendered_columns.clear(); self.row_index = GridRowIndex::default(); - update_selection_chrome(&self.selection_label, 0); - self.sync_delete_action(); - self.show_loading(); + self.pending_restore = None; + self.sync_selection(); + let presentation = self.load.drop_content(); + self.present(presentation, fx); } fn reindex_rows(&mut self) { @@ -1177,33 +1348,23 @@ impl BrowseModel { /// 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 = 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. - let last_target = self - .current_total_rows - .filter(|t| *t > 0) - .map(|t| (t - 1) / self.page_size * self.page_size); - self.last_button - .set_sensitive(last_target.is_some_and(|target| self.current_offset != target)); - - self.refresh_inner_stack_visibility(); + self.sync_pager_buttons(); + self.sync_total_chrome(); self.suppress_combo_emit.set(false); } - /// Switch the inner stack to the grid once the first page has - /// loaded, and stay there whatever the row count: an empty-list - /// 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.page.is_some() { - self.inner_stack.set_visible_child_name("grid"); - } + /// A page turn moves the pager before its read lands, so a failed + /// read leaves the pager on a page the grid does not show. The way + /// back goes from the pager, and Next waits for a page on screen. + fn sync_pager_buttons(&self) { + self.first_button.set_sensitive(self.pager.first().is_some()); + self.prev_button.set_sensitive(self.pager.prev().is_some()); + self.next_button + .set_sensitive(self.pager.next(self.rows_on_page()).is_some()); + } + + fn rows_on_page(&self) -> u64 { + self.shown_page().map_or(0, |page| page.row_count as u64) } fn has_primary_key(&self) -> bool { @@ -1215,20 +1376,21 @@ impl BrowseModel { let has_pk = self.has_primary_key(); if self.read_only { self.insert_button.set_visible(false); + self.actions.insert_row.set_enabled(false); return; } self.insert_button.set_visible(true); // No-PK tables don't get inline editing because RowKey can't be // formed without a PK. Disable instead of hide so the affordance // stays discoverable; the tooltip explains the gate. - self.insert_button.set_sensitive(has_columns && has_pk); + self.actions.insert_row.set_enabled(has_columns && has_pk); if has_columns && !has_pk { self.insert_button.set_tooltip_text(Some(&crate::i18n::gettext( "This table has no primary key. Inline editing is disabled.", ))); } else { self.insert_button - .set_tooltip_text(Some(&crate::i18n::gettext("Insert row (Ctrl+N)"))); + .set_tooltip_text(Some(&crate::i18n::gettext("Insert Row"))); } self.refresh_banner_visibility(); } @@ -1239,7 +1401,7 @@ impl BrowseModel { self.filter_badge.set_visible(false); self.filter_badge.set_label(""); self.filter_button - .set_tooltip_text(Some(&crate::i18n::gettext("Filter rows (Ctrl+F)"))); + .set_tooltip_text(Some(&crate::i18n::gettext("Filter rows"))); } else { self.filter_badge.set_label(&n.to_string()); self.filter_badge.set_visible(true); @@ -1253,7 +1415,7 @@ impl BrowseModel { } fn update_paginator_label(&self) { - let Some(page) = self.page.as_ref() else { + let Some(page) = self.shown_page() else { self.paginator_label.set_label(""); return; }; @@ -1268,58 +1430,23 @@ impl BrowseModel { } 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 { - Some(total) => crate::i18n::gettext_f( - "Rows {start} – {end} of {total}", - &[ - ("start", &start_s), - ("end", &end_s), - ("total", &format_thousands(total)), - ], - ), - None => crate::i18n::gettext_f("Rows {start} – {end}", &[("start", &start_s), ("end", &end_s)]), - }; - self.paginator_label.set_label(&label); + self.paginator_label.set_label(&self.total.paginator_label(start, end)); } fn show_loading(&self) { - self.loading_page.set_description(Some(&crate::i18n::gettext_f( - "Fetching rows from {table}", - &[("table", &self.table_label())], - ))); + self.loading_page + .set_description(Some(&loading_description(&self.table_label()))); self.inner_stack.set_visible_child_name("loading"); } - /// Show why no rows could be read. The page and the count from - /// 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.page = None; - self.shown_view = None; - self.current_total_rows = None; - self.paginator_label.set_label(""); - self.first_button.set_sensitive(false); - self.prev_button.set_sensitive(false); - self.next_button.set_sensitive(false); - self.last_button.set_sensitive(false); - self.error_page.set_description(Some(message)); - self.inner_stack.set_visible_child_name("error"); - } - /// 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; + self.pager.go_to(offset); let PageReply { result, layout, .. } = reply; - let QueryResult { - columns, - rows, - truncated, - } = result; + let QueryResult { columns, rows, .. } = 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); @@ -1339,7 +1466,6 @@ impl BrowseModel { }; self.page = Some(PageState { result_columns, - truncated, row_count, start_ordinal: offset, }); @@ -1351,24 +1477,18 @@ impl BrowseModel { PageView { filter: self.current_filter.clone(), sort: self.current_sort, - offset: self.current_offset, - limit: self.page_size, + offset: self.pager.start_ordinal(), + limit: self.pager.page_size(), } } - /// A read failed. When it asked again for the page on screen, as F5 - /// 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.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}", - &[("error", message)], - ))); - return; - } - self.show_error(message); + /// A read failed. Rows read again for the same page stay, under the + /// banner; with no rows, or rows of another page, the error page says + /// why. The paginator stays, so the user can go back. + fn on_load_failed(&mut self, failure: LoadFailure, fx: &mut Effects) { + self.pending_restore = None; + let presentation = self.load.fail(failure); + self.present(presentation, fx); } fn on_columns_loaded(&mut self, columns: Vec, fx: &mut Effects) { @@ -1377,7 +1497,7 @@ impl BrowseModel { if self.current_columns != columns { self.page = None; self.shown_view = None; - self.drop_grid(); + self.drop_grid(fx); } self.current_columns = columns.clone(); self.rebase_confirming_delete(fx); @@ -1395,21 +1515,6 @@ impl BrowseModel { self.render_grid_if_ready(fx); } - fn on_row_count_loaded(&mut self, count: u64, fx: &mut Effects) { - self.current_total_rows = Some(count); - // A saved offset past the end goes back to the last full page, - // for rows another session deleted since. - if self.current_offset > 0 && count > 0 && self.current_offset >= count { - let last_page_offset = count.saturating_sub(1) / self.page_size * self.page_size; - if last_page_offset != self.current_offset { - self.current_offset = last_page_offset; - self.fetch(BrowseRequestKind::Page, fx); - fx.output(BrowseTabOutput::StateChanged); - } - } - self.update_paginator_label(); - } - fn update(&mut self, msg: BrowseTabInput, fx: &mut Effects) { match msg { BrowseTabInput::Start => { @@ -1420,19 +1525,22 @@ impl BrowseModel { self.refresh_filter_chrome(); // DDL from the SQL editor or another client never reaches // the catalog, so a tab that opens asks the server. - self.reload_from_columns(true); + self.begin_read(fx); + self.reload_from_columns(ColumnFreshness::Refetch); } BrowseTabInput::Command(command) => self.on_command(command, fx), BrowseTabInput::LinkChanged(generation) => self.on_link_changed(generation, fx), BrowseTabInput::SchemaChanged => { - self.capture_focus_for_restore(); - self.reload_from_columns(false); + self.capture_selection(); + self.begin_read(fx); + self.stop_count(true); + self.reload_from_columns(ColumnFreshness::Cached); } - BrowseTabInput::Refresh => self.reload(), + BrowseTabInput::Refresh => self.reload(fx), BrowseTabInput::DiscardAndRefresh => { let delta = self.tracker.discard(); self.apply_tracker_delta(&delta, fx); - self.reload(); + self.reload(fx); } BrowseTabInput::ClearSelection => { let Some(sel) = self.current_selection.as_ref() else { @@ -1445,6 +1553,7 @@ impl BrowseModel { } sel.unselect_all(); } + BrowseTabInput::RequestRefresh => fx.output(BrowseTabOutput::RefreshRequested), BrowseTabInput::ToggleFilterStrip => { if let Some(strip) = self.filter_strip.as_ref() { strip.toggle(); @@ -1461,49 +1570,20 @@ impl BrowseModel { .set_filters(conn_id, self.schema.as_deref(), &self.table, set.clone()); } // Filtered counts shift, so back to page 1. - self.current_offset = 0; + self.pager.go_to(0); self.refresh_filter_chrome(); if let Some(strip) = self.filter_strip.as_ref() { strip.update_filter(set); } + self.stop_count(false); self.fetch(BrowseRequestKind::Page, fx); - self.fetch(BrowseRequestKind::Count, fx); + self.fetch(BrowseRequestKind::Estimate, fx); fx.output(BrowseTabOutput::StateChanged); } - BrowseTabInput::FirstPage => { - if self.current_offset > 0 { - self.current_offset = 0; - self.fetch(BrowseRequestKind::Page, fx); - fx.output(BrowseTabOutput::StateChanged); - } - } - BrowseTabInput::PrevPage => { - if self.current_offset >= self.page_size { - self.current_offset -= self.page_size; - self.fetch(BrowseRequestKind::Page, fx); - fx.output(BrowseTabOutput::StateChanged); - } - } - BrowseTabInput::NextPage => { - self.current_offset += self.page_size; - self.fetch(BrowseRequestKind::Page, fx); - fx.output(BrowseTabOutput::StateChanged); - } - BrowseTabInput::LastPage => { - // The button stays disabled until the count is known. - let Some(total) = self.current_total_rows else { - return; - }; - if total == 0 { - return; - } - let last_page_offset = (total - 1) / self.page_size * self.page_size; - if self.current_offset != last_page_offset { - self.current_offset = last_page_offset; - self.fetch(BrowseRequestKind::Page, fx); - fx.output(BrowseTabOutput::StateChanged); - } - } + BrowseTabInput::FirstPage => self.turn_page(self.pager.first(), fx), + BrowseTabInput::PrevPage => self.turn_page(self.pager.prev(), fx), + BrowseTabInput::NextPage => self.turn_page(self.pager.next(self.rows_on_page()), fx), + BrowseTabInput::LastPage => self.turn_page(self.pager.last(&self.total), fx), BrowseTabInput::SortChanged { col_idx, ascending } => { // The sorter notifies twice for one click on a new column // (column, then order), with the same final pair. @@ -1512,22 +1592,21 @@ impl BrowseModel { return; } self.current_sort = Some(next); - self.current_offset = 0; - self.capture_focus_for_restore(); + self.pager.go_to(0); + self.capture_selection(); self.fetch(BrowseRequestKind::Page, fx); fx.output(BrowseTabOutput::StateChanged); } BrowseTabInput::PageSizeChanged(size) => { - if self.suppress_combo_emit.get() || self.page_size == size { + if self.suppress_combo_emit.get() || self.pager.page_size() == size { return; } - self.page_size = size; - self.current_offset = 0; + self.pager.resize(size); self.fetch(BrowseRequestKind::Page, fx); fx.output(BrowseTabOutput::StateChanged); } BrowseTabInput::DuplicateRows(rows) => { - if self.current_columns.is_empty() || self.grid_is_stale() || rows.is_empty() { + if self.read_only || self.current_columns.is_empty() || self.grid_is_stale() || rows.is_empty() { return; } let ctx = self.grid_context(); @@ -1553,16 +1632,14 @@ impl BrowseModel { }) .collect(); self.apply_edit(TrackerEdit::InsertDrafts { rows: copies }, fx); - self.refresh_inner_stack_visibility(); fx.input(BrowseTabInput::FocusInsertedDraft); } BrowseTabInput::InsertRow => { - if self.current_columns.is_empty() { + if self.read_only || self.current_columns.is_empty() { return; } 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::DeleteRows(rows) => self.delete_rows(&rows, fx), @@ -1591,50 +1668,26 @@ impl BrowseModel { BrowseTabInput::GridShowToast(message) => { fx.output(BrowseTabOutput::ShowToast(message)); } - BrowseTabInput::GridExportResults(mut result) => { - // The grid owns the rows it shows; whether the fetch was - // cut short belongs to the tab. - result.truncated = self.page.as_ref().is_some_and(|page| page.truncated); - fx.output(BrowseTabOutput::ExportResults(ExportRequest { - result, - name: self.export_name(), - target: Some(self.sql_target()), - })); - } - BrowseTabInput::ExportCurrentPage => { - let Some(result) = self.export_payload() else { - fx.output(BrowseTabOutput::ShowToast(crate::i18n::gettext("Nothing to export"))); - return; - }; - fx.output(BrowseTabOutput::ExportResults(ExportRequest { - result, - name: self.export_name(), - target: Some(self.sql_target()), - })); - } + BrowseTabInput::Export(page) => self.offer_export(page, fx), BrowseTabInput::GridCopyRowsAsInsert(rows) => { if !self.grid_is_stale() { self.copy_rows_as_insert(&rows, fx); } } - BrowseTabInput::GridCopyToClipboard(text) => { - fx.output(BrowseTabOutput::CopyToClipboard(text)); - } + BrowseTabInput::GridCopyInClause(values) => match self.session.current_dialect() { + Some(dialect) => fx.output(BrowseTabOutput::Copy(CopyRequest::InClause { dialect, values })), + None => fx.output(BrowseTabOutput::ShowToast(crate::i18n::gettext( + "Connect to a database first.", + ))), + }, + BrowseTabInput::GridCopy(request) => fx.output(BrowseTabOutput::Copy(request)), + BrowseTabInput::GridShowRowJson(request) => fx.output(BrowseTabOutput::ShowRowJson(request)), BrowseTabInput::CopySelectedRowsAsTsv => self.copy_selected_rows(fx), BrowseTabInput::PasteNotSupported => { fx.output(BrowseTabOutput::ShowToast(crate::i18n::gettext( "Pasting rows isn't supported yet", ))); } - BrowseTabInput::SelectAllRows => { - if let Some(selection) = self.current_selection.as_ref() - && selection.n_items() > 0 - { - selection.select_all(); - } - } - BrowseTabInput::GoToFirstRow => self.scroll_to_row(|_| Some(0)), - BrowseTabInput::GoToLastRow => self.scroll_to_row(|n| n.checked_sub(1)), BrowseTabInput::CommitSave => fx.output(BrowseTabOutput::SaveRequested), BrowseTabInput::SaveStarted => self.show_saving(true), BrowseTabInput::StopSaving => fx.output(BrowseTabOutput::StopSaveRequested), @@ -1675,15 +1728,30 @@ impl BrowseModel { selection.unselect_all(); } } - BrowseTabInput::SaveCommitted => { + BrowseTabInput::SaveCommitted(origins) => { self.show_saving(false); // The tracker already let go of what the save wrote. The // reload replaces every row; dropping the saved drafts // from the store first would blank them until it lands. self.sync_pending_chrome(self.tracker.pending_count(), fx); - self.capture_focus_for_restore(); + self.capture_selection(); + // The save says how the total moved, so nothing is + // counted again. A delete that emptied the last page + // leaves an exact total that moves the tab back a page. + self.total + .adjust_after_save(origins.inserts, origins.deletes, !self.current_filter.is_empty()); + self.clamp_to_total(fx); + self.sync_total_chrome(); self.fetch(BrowseRequestKind::Page, fx); - self.fetch(BrowseRequestKind::Count, fx); + } + BrowseTabInput::CountRows => { + if let Some(problem) = self.filter_problem() { + fx.output(BrowseTabOutput::ShowToast(fetch::filter_failure(problem).message())); + return; + } + if self.total.exact().is_none() && !self.sequencer.is_pending(BrowseRequestKind::Count) { + self.start_count(self.total.estimate(), fx); + } } BrowseTabInput::SaveFailed(message) => { self.show_saving(false); @@ -1719,20 +1787,14 @@ impl BrowseModel { } } - fn scroll_to_row(&self, target: impl FnOnce(u32) -> Option) { - let Some(cv) = self.current_column_view.as_ref() else { + /// Read the page a paginator move landed on, if it moved. + fn turn_page(&mut self, target: Option<(PagePosition, u64)>, fx: &mut Effects) { + let Some((_, start)) = target else { return; }; - let n = self.current_selection.as_ref().map(|s| s.n_items()).unwrap_or(0); - let Some(position) = target(n).filter(|position| *position < n) else { - return; - }; - cv.scroll_to( - position, - None, - gtk::ListScrollFlags::FOCUS | gtk::ListScrollFlags::SELECT, - None, - ); + self.pager.go_to(start); + self.fetch(BrowseRequestKind::Page, fx); + fx.output(BrowseTabOutput::StateChanged); } /// Mark saved rows for deletion and drop drafts. Nothing leaves the @@ -1850,72 +1912,62 @@ impl BrowseModel { ))); return; }; + fx.output(BrowseTabOutput::Copy(CopyRequest::Insert(InsertCopy { + dialect, + table: self.table_ref(), + columns: self.current_columns.iter().cloned().collect(), + rows: self.insert_rows(rows), + }))); + } + + /// The rows an INSERT copy renders off the GTK thread. A saved row + /// shares the page's cells; only a draft's are copied here. + fn insert_rows(&self, rows: &[RowObject]) -> Vec { 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"))); - } + rows.iter() + .map(|row| match row.id() { + GridRowId::Draft(draft_id) => InsertRow::Draft( + self.tracker + .draft_cells(*draft_id, &self.current_columns) + .unwrap_or_default(), + ), + _ => InsertRow::Saved(ctx.snapshot(row)), + }) + .collect() } - /// Same renderer as the context menu's Copy as > Rows: one - /// selection cannot produce two different clipboard payloads. + /// Same request as the context menu's Copy as > Rows: one selection + /// cannot produce two different clipboard payloads. fn copy_selected_rows(&self, fx: &mut Effects) { - let Some(selection) = self.current_selection.as_ref() else { + let (Some(selection), Some(_)) = (self.current_selection.as_ref(), self.shown_page()) else { return; }; - let positions = super::grid::selected_positions(selection); - if positions.is_empty() { - return; - } let ctx = self.grid_context(); - let rows: Vec> = positions + let rows: Vec = super::grid::selected_positions(selection) .iter() .filter_map(|position| selection.item(*position).and_downcast::()) - .map(|row| ctx.snapshot(&row).to_values()) + .map(|row| ctx.snapshot(&row)) .collect(); if rows.is_empty() { return; } - match tablepro_core::export::render_tsv(&result_columns(&self.current_columns), &rows, false) { - Ok(tsv) => fx.output(BrowseTabOutput::CopyToClipboard(tsv)), - Err(error) => fx.output(BrowseTabOutput::ShowToast(crate::i18n::gettext_f( - "Couldn't copy the selection: {error}", - &[("error", &error.to_string())], - ))), - } + fx.output(BrowseTabOutput::Copy(CopyRequest::Rows { + columns: result_columns(&self.current_columns), + rows, + kind: CopyKind::Rows, + })); } } +/// A status page reads its description as markup, and a table's name +/// can hold an `&` or a `<`. +fn loading_description(table: &str) -> glib::GString { + glib::markup_escape_text(&crate::i18n::gettext_f( + "Fetching rows from {table}", + &[("table", table)], + )) +} + fn qualified(schema: Option<&str>, table: &str) -> String { match schema { Some(s) => format!("{s}.{table}"), @@ -1955,7 +2007,7 @@ fn show_delete_availability(action: &gio::SimpleAction, button: >k::Button, ro DeleteAvailability::Unavailable(block) => block.explanation(), DeleteAvailability::Available => None, }; - let tooltip = explanation.unwrap_or_else(|| crate::i18n::gettext("Delete selected rows (Delete)")); + let tooltip = explanation.unwrap_or_else(|| crate::i18n::gettext("Delete Selected Rows")); button.set_tooltip_text(Some(&tooltip)); } @@ -1982,23 +2034,6 @@ fn update_selection_chrome(label: >k::Label, n: u32) { label.set_visible(true); } -/// 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. -fn format_thousands(n: u64) -> String { - let s = n.to_string(); - let bytes = s.as_bytes(); - let len = bytes.len(); - let mut out = String::with_capacity(len + len / 3); - for (i, b) in bytes.iter().enumerate() { - if i > 0 && (len - i).is_multiple_of(3) { - out.push(','); - } - out.push(*b as char); - } - out -} - /// The result-column view of a catalogue column list, for a result the /// grid renders from the schema it already has. fn result_columns(columns: &[tablepro_core::ColumnInfo]) -> std::sync::Arc<[tablepro_core::column::ResultColumn]> { diff --git a/linux/crates/app/src/ui/browse_tab/page_state.rs b/linux/crates/app/src/ui/browse_tab/page_state.rs index c7569925fd..c8c08f034f 100644 --- a/linux/crates/app/src/ui/browse_tab/page_state.rs +++ b/linux/crates/app/src/ui/browse_tab/page_state.rs @@ -8,8 +8,6 @@ use tablepro_core::column::ResultColumn; 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_count.rs b/linux/crates/app/src/ui/browse_tab/row_count.rs new file mode 100644 index 0000000000..e6c3169e3c --- /dev/null +++ b/linux/crates/app/src/ui/browse_tab/row_count.rs @@ -0,0 +1,171 @@ +use gtk4::prelude::*; +use tablepro_core::browse::build_count; +use tablepro_core::meta::{RowCountEstimate, TableEstimate}; +use tablepro_session::{OperationError, OperationKind}; + +use super::count_failure_text::count_failure_text; +use super::count_policy::{CountAction, decide}; +use super::row_total::RowTotal; +use super::{BrowseCommand, BrowseModel, BrowseRequestKind, BrowseTabOutput, Effects}; + +impl BrowseModel { + /// The server's guess at the table's size, which is all the "of N" + /// total needs, and which decides whether a count runs on its own. + pub(super) fn fetch_estimate(&mut self) { + let ticket = self.issue(BrowseRequestKind::Estimate); + let session = self.session.clone(); + let table = self.table_ref(); + let cancel = ticket.cancel.clone(); + let work = async move { + session + .run_cancellable(OperationKind::Metadata, &cancel, move |lease, options| { + let table = table.clone(); + async move { + lease + .table_estimate(&table, options) + .await + .map_err(OperationError::from) + } + }) + .await + }; + self.deliver(ticket, work, BrowseCommand::Estimate, OperationError::from); + } + + pub(super) fn on_estimate(&mut self, value: Result, fx: &mut Effects) { + let estimate = value.map(|estimate| estimate.rows).map_err(|error| { + tracing::warn!(category = ?error.category(), "row estimate unavailable"); + }); + let filtered = !self.current_filter.is_empty(); + // The count gives the total, so an estimate that lands while it + // runs only fills in what shows meanwhile. + if self.sequencer.is_pending(BrowseRequestKind::Count) { + let shown = estimate + .ok() + .and_then(RowCountEstimate::value) + .filter(|_| !filtered) + .or(self.total.estimate()); + self.set_total(RowTotal::Counting { estimate: shown }, fx); + return; + } + match decide(estimate, filtered) { + CountAction::UseExact(rows) => self.set_total(RowTotal::Exact(rows), fx), + CountAction::UseEstimate(rows) => self.set_total(RowTotal::Estimated(rows), fx), + CountAction::CountNow => { + // The whole table's estimate is no total for a filtered view. + let estimate = estimate + .ok() + .and_then(|estimate| estimate.value()) + .filter(|_| !filtered); + self.start_count(estimate, fx); + } + CountAction::OfferCount => self.set_total(RowTotal::Unknown, fx), + } + } + + /// Count the rows under the tab's filter. `estimate` shows while it + /// runs. + pub(super) fn start_count(&mut self, estimate: Option, fx: &mut Effects) { + let Some(plan) = self.read_plan() else { + return; + }; + // The page says why such a filter cannot be read. + if self.filter_problem().is_some() { + return; + } + let ticket = self.issue(BrowseRequestKind::Count); + let session = self.session.clone(); + let cancel = ticket.cancel.clone(); + let work = async move { + session + .run_cancellable(OperationKind::Count, &cancel, move |lease, options| { + let plan = plan.clone(); + async move { + let statement = build_count(&*lease.dialect(), &plan.table, &plan.columns, &plan.filter)?; + Ok(lease.count_rows(&statement, options).await?) + } + }) + .await + }; + self.deliver(ticket, work, BrowseCommand::Count, OperationError::from); + self.set_total(RowTotal::Counting { estimate }, fx); + } + + pub(super) fn on_count(&mut self, value: Result, fx: &mut Effects) { + match value { + Ok(rows) => self.set_total(RowTotal::Exact(rows), fx), + Err(error) => { + let reason = count_failure_text(&error, self.deadlines.deadline(OperationKind::Count)); + let estimate = self.total.estimate(); + self.set_total(RowTotal::Unavailable { estimate, reason }, fx); + } + } + } + + /// A refresh or a new filter reads other rows than the running count + /// counts, so it stops. A refresh keeps what the count started from; + /// a filter has no total until its own estimate lands. + pub(super) fn stop_count(&mut self, keep_total: bool) { + self.sequencer.cancel(BrowseRequestKind::Count); + self.total = match (keep_total, &self.total) { + (false, _) => RowTotal::Unknown, + ( + true, + RowTotal::Counting { + estimate: Some(estimate), + }, + ) => RowTotal::Estimated(*estimate), + (true, RowTotal::Counting { estimate: None }) => RowTotal::Unknown, + (true, total) => total.clone(), + }; + self.sync_total_chrome(); + } + + pub(super) fn set_total(&mut self, total: RowTotal, fx: &mut Effects) { + self.total = total; + if self.clamp_to_total(fx) { + self.fetch(BrowseRequestKind::Page, fx); + } + self.sync_total_chrome(); + } + + /// Only an exact total says where the table ends, so only an exact + /// one moves a page left past the end back to the last page. Says + /// whether it moved, for the caller to read that page. + pub(super) fn clamp_to_total(&mut self, fx: &mut Effects) -> bool { + let Some((_, start)) = self.total.exact().and_then(|rows| self.pager.clamp(rows)) else { + return false; + }; + self.pager.go_to(start); + fx.output(BrowseTabOutput::StateChanged); + true + } + + /// The paginator's label, the Count Rows button and Last, which only + /// an exact total enables. + pub(super) fn sync_total_chrome(&self) { + self.update_paginator_label(); + let last = self.pager.last(&self.total).is_some(); + self.last_button.set_sensitive(last); + let last_tooltip = match self.total.exact() { + Some(_) => crate::i18n::gettext("Last Page"), + None => crate::i18n::gettext("Count rows to go to the last page"), + }; + self.last_button.set_tooltip_text(Some(&last_tooltip)); + + let counting = matches!(self.total, RowTotal::Counting { .. }); + self.count_button + .set_visible(self.shown_page().is_some() && self.total.exact().is_none()); + self.count_button.set_sensitive(!counting); + self.count_spinner.set_visible(counting); + let (label, tooltip) = match &self.total { + RowTotal::Counting { .. } => (crate::i18n::gettext("Counting…"), None), + RowTotal::Unavailable { reason, .. } => (crate::i18n::gettext("Try Again"), Some(reason.as_str())), + RowTotal::Unknown | RowTotal::Estimated(_) | RowTotal::Exact(_) => { + (crate::i18n::gettext("Count Rows"), None) + } + }; + self.count_label.set_label(&label); + self.count_button.set_tooltip_text(tooltip); + } +} diff --git a/linux/crates/app/src/ui/browse_tab/row_total.rs b/linux/crates/app/src/ui/browse_tab/row_total.rs new file mode 100644 index 0000000000..9ea8f9757e --- /dev/null +++ b/linux/crates/app/src/ui/browse_tab/row_total.rs @@ -0,0 +1,153 @@ +use crate::ui::format::format_thousands; + +/// What the tab knows of how many rows the table has under its filter. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub(crate) enum RowTotal { + #[default] + Unknown, + /// The server's estimate, which the label says is about that many. + Estimated(u64), + /// A count is running. `estimate` shows meanwhile. + Counting { + estimate: Option, + }, + Exact(u64), + /// The count failed, and `reason` says why. + Unavailable { + estimate: Option, + reason: String, + }, +} + +impl RowTotal { + /// "Rows 1 – 1,000 of 5,000", "of about 5,000,000", or no total. + pub(crate) fn paginator_label(&self, start: u64, end: u64) -> String { + self.label_with(start, end, format_thousands) + } + + fn label_with(&self, start: u64, end: u64, format: impl Fn(u64) -> String) -> String { + let (start, end) = (format(start), format(end)); + match self { + Self::Exact(total) => crate::i18n::gettext_f( + "Rows {start} – {end} of {total}", + &[("start", &start), ("end", &end), ("total", &format(*total))], + ), + Self::Estimated(estimate) + | Self::Counting { + estimate: Some(estimate), + } + | Self::Unavailable { + estimate: Some(estimate), + .. + } => crate::i18n::gettext_f( + "Rows {start} – {end} of about {total}", + &[("start", &start), ("end", &end), ("total", &format(*estimate))], + ), + Self::Unknown | Self::Counting { estimate: None } | Self::Unavailable { estimate: None, .. } => { + crate::i18n::gettext_f("Rows {start} – {end}", &[("start", &start), ("end", &end)]) + } + } + } + + pub(crate) fn exact(&self) -> Option { + match self { + Self::Exact(total) => Some(*total), + _ => None, + } + } + + /// The estimate a count started from, or the total it replaces. + pub(crate) fn estimate(&self) -> Option { + match self { + Self::Exact(total) | Self::Estimated(total) => Some(*total), + Self::Counting { estimate } | Self::Unavailable { estimate, .. } => *estimate, + Self::Unknown => None, + } + } + + /// A committed save moved the total by what it inserted and deleted. + /// Under a filter, an inserted or updated row may not match it, so + /// an exact total becomes an estimate there. + pub(crate) fn adjust_after_save(&mut self, inserted: u64, deleted: u64, filtered: bool) { + let moved = |total: u64| total.saturating_add(inserted).saturating_sub(deleted); + *self = match std::mem::take(self) { + Self::Exact(total) if !filtered => Self::Exact(moved(total)), + Self::Exact(total) | Self::Estimated(total) => Self::Estimated(moved(total)), + Self::Counting { estimate } => Self::Counting { + estimate: estimate.map(moved), + }, + Self::Unavailable { estimate, reason } => Self::Unavailable { + estimate: estimate.map(moved), + reason, + }, + Self::Unknown => Self::Unknown, + }; + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ui::format::Thousands; + + fn label(total: &RowTotal, start: u64, end: u64) -> String { + let english = Thousands::for_posix_locale("en_US.UTF-8"); + total.label_with(start, end, |n| english.format(n)) + } + + #[test] + fn row_total_labels_use_thousands_and_about() { + assert_eq!(label(&RowTotal::Exact(5_000), 1, 1_000), "Rows 1 – 1,000 of 5,000"); + assert_eq!( + label(&RowTotal::Estimated(5_000_000), 1, 1_000), + "Rows 1 – 1,000 of about 5,000,000" + ); + assert_eq!(label(&RowTotal::Unknown, 1, 1_000), "Rows 1 – 1,000"); + assert_eq!( + label(&RowTotal::Counting { estimate: Some(40) }, 1, 40), + "Rows 1 – 40 of about 40" + ); + assert_eq!(label(&RowTotal::Counting { estimate: None }, 1, 40), "Rows 1 – 40"); + assert_eq!( + label( + &RowTotal::Unavailable { + estimate: None, + reason: "Stopped.".to_owned() + }, + 1_001, + 2_000 + ), + "Rows 1,001 – 2,000" + ); + } + + #[test] + fn adjust_after_save_keeps_exact_only_when_unfiltered() { + let mut unfiltered = RowTotal::Exact(100); + unfiltered.adjust_after_save(3, 1, false); + assert_eq!(unfiltered, RowTotal::Exact(102)); + + let mut filtered = RowTotal::Exact(100); + filtered.adjust_after_save(3, 1, true); + assert_eq!(filtered, RowTotal::Estimated(102)); + + let mut estimated = RowTotal::Estimated(100); + estimated.adjust_after_save(0, 10, false); + assert_eq!(estimated, RowTotal::Estimated(90)); + + let mut unknown = RowTotal::Unknown; + unknown.adjust_after_save(5, 0, false); + assert_eq!(unknown, RowTotal::Unknown); + } + + #[test] + fn row_total_adjust_is_saturating() { + let mut total = RowTotal::Exact(2); + total.adjust_after_save(0, 5, false); + assert_eq!(total, RowTotal::Exact(0)); + + let mut total = RowTotal::Exact(u64::MAX); + total.adjust_after_save(1, 0, false); + assert_eq!(total, RowTotal::Exact(u64::MAX)); + } +} diff --git a/linux/crates/app/src/ui/browse_tab/selection_snapshot.rs b/linux/crates/app/src/ui/browse_tab/selection_snapshot.rs new file mode 100644 index 0000000000..aea5e1ccf8 --- /dev/null +++ b/linux/crates/app/src/ui/browse_tab/selection_snapshot.rs @@ -0,0 +1,71 @@ +use gtk4 as gtk; +use gtk4::gio; +use gtk4::prelude::*; + +use crate::services::change_tracker::RowKey; +use crate::ui::grid::{GridCell, GridRowIndex, RowObject, selected_positions}; + +/// The selected rows and the focused cell, by row key, so a page read +/// again can put them back wherever the rows land in it. +#[derive(Debug, Clone, Default, PartialEq)] +pub(crate) struct SelectionSnapshot { + keys: Vec, + focus: Option<(RowKey, usize)>, +} + +impl SelectionSnapshot { + pub(crate) fn capture(selection: >k::MultiSelection, column_view: >k::ColumnView) -> Self { + let keys = selected_positions(selection) + .into_iter() + .filter_map(|position| selection.item(position).and_downcast::()) + .filter_map(|row| row.id().tracker_key()) + .collect(); + let focus = column_view + .root() + .and_downcast::() + .and_then(|window| gtk::prelude::GtkWindowExt::focus(&window)) + .filter(|focus| focus.is_ancestor(column_view)) + .and_then(|focus| GridCell::from_focus(&focus)) + .and_then(|cell| Some((cell.row()?.id().tracker_key()?, cell.column()))); + Self { keys, focus } + } + + pub(crate) fn is_empty(&self) -> bool { + self.keys.is_empty() && self.focus.is_none() + } + + /// Rows no longer on the page stay unselected. `columns` are the + /// view's columns in the order of the row's cells, which a drag in + /// the header does not change. + pub(crate) fn restore( + &self, + index: &mut GridRowIndex, + store: &gio::ListStore, + selection: >k::MultiSelection, + column_view: >k::ColumnView, + columns: &[gtk::ColumnViewColumn], + ) { + let selected = gtk::Bitset::new_empty(); + for key in &self.keys { + if let Some(position) = index.position_in(store, key) { + selected.add(position); + } + } + let mask = gtk::Bitset::new_range(0, selection.n_items()); + selection.set_selection(&selected, &mask); + let focused = self + .focus + .as_ref() + .and_then(|(key, column)| Some((index.position_in(store, key)?, *column))); + match focused { + Some((position, column)) => { + column_view.scroll_to(position, columns.get(column), gtk::ListScrollFlags::FOCUS, None); + } + // A sort can move the selection anywhere on the page. + None if !selected.is_empty() => { + column_view.scroll_to(selected.minimum(), None, gtk::ListScrollFlags::NONE, None); + } + None => {} + } + } +} diff --git a/linux/crates/app/src/ui/browse_tab/tests.rs b/linux/crates/app/src/ui/browse_tab/tests.rs index 9c5410f096..4587321513 100644 --- a/linux/crates/app/src/ui/browse_tab/tests.rs +++ b/linux/crates/app/src/ui/browse_tab/tests.rs @@ -6,22 +6,23 @@ use gtk4::prelude::*; use tablepro_core::column::{ColumnDefault, ColumnType}; use tablepro_core::edit::CellInput; use tablepro_core::filter::{FilterOp, FilterRule}; -use tablepro_core::meta::RowIdentity; +use tablepro_core::meta::{RowCountEstimate, RowIdentity, TableEstimate}; use tablepro_core::{ColumnInfo, DriverError, FilterSet, LossPhase, QueryResult, Value}; use tablepro_session::Session; use tablepro_session::fakes::FakeEstablish; use tablepro_test_support::{CallCounters, ConnectionScript, FakeConnection, FakeReply}; use super::row_deletion::RowDeletion; -use super::{BrowseTab, BrowseTabInit, BrowseTabInput, Effects, apply_draft_changes, format_thousands}; +use super::{BrowseTab, BrowseTabInit, BrowseTabInput, Effects, RowTotal, apply_draft_changes}; use crate::services::change_tracker::{RowKey, StatementSource}; use crate::services::tab_tracker_handle::TabTrackerHandle; 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::clipboard::InsertRow; use crate::ui::grid::{GridCell, GridRowIndex, RowObject}; -const WAIT: Duration = Duration::from_secs(10); +pub(super) const WAIT: Duration = Duration::from_secs(10); /// A column carrying the server's spelling and the kind it means. #[expect( @@ -63,23 +64,6 @@ fn col_with_default(data_type: &str, default: &str) -> ColumnInfo { c } -#[test] -fn format_thousands_handles_common_page_sizes() { - assert_eq!(format_thousands(100), "100"); - assert_eq!(format_thousands(500), "500"); - assert_eq!(format_thousands(1_000), "1,000"); - assert_eq!(format_thousands(5_000), "5,000"); - assert_eq!(format_thousands(10_000), "10,000"); - assert_eq!(format_thousands(1_000_000), "1,000,000"); -} - -#[test] -fn format_thousands_handles_edges() { - assert_eq!(format_thousands(0), "0"); - assert_eq!(format_thousands(1), "1"); - assert_eq!(format_thousands(999), "999"); -} - fn row_at(store: >k4::gio::ListStore, position: u32) -> RowObject { store.item(position).and_downcast::().expect("a row") } @@ -108,7 +92,7 @@ fn draft_changes_add_and_remove_draft_rows() { assert_eq!(index.get(&RowKey::Draft(4)), None); } -fn column(name: &str, primary_key: bool) -> ColumnInfo { +pub(super) fn column(name: &str, primary_key: bool) -> ColumnInfo { ColumnInfo { name: name.to_owned(), column_type: ColumnType::untyped_text("text"), @@ -121,7 +105,7 @@ fn column(name: &str, primary_key: bool) -> ColumnInfo { } } -fn rows(names: &[&str]) -> QueryResult { +pub(super) fn rows(names: &[&str]) -> QueryResult { let columns = [column("id", true), column("name", false)]; QueryResult::new( columns.iter().map(ColumnInfo::result_column).collect::>(), @@ -135,7 +119,7 @@ fn rows(names: &[&str]) -> QueryResult { /// A connection that serves the table's catalog and count, with the /// page left to the test. -fn serving_table() -> ConnectionScript { +pub(super) fn serving_table() -> ConnectionScript { let mut script = ConnectionScript::default(); script .fetch_columns @@ -144,31 +128,54 @@ fn serving_table() -> ConnectionScript { columns: vec!["id".to_owned()], })); script.count_rows.always(FakeReply::ok(2)); + script + .table_estimate + .always(FakeReply::ok(estimate(RowCountEstimate::Approximate(2)))); script.probe.always(FakeReply::ok(())); script } -struct Harness { - tab: Option, - tracker: TabTrackerHandle, +pub(super) fn estimate(rows: RowCountEstimate) -> TableEstimate { + TableEstimate { + rows, + heap_blocks: None, + } +} + +pub(super) struct Harness { + pub(super) tab: Option, + pub(super) tracker: TabTrackerHandle, others: Vec, - session: Arc, - connection: Arc, - fake: FakeSession, + pub(super) session: Arc, + pub(super) connection: Arc, + pub(super) fake: FakeSession, root: tempfile::TempDir, runtime: Option, } impl Harness { - fn open(script: ConnectionScript, reconnects: impl IntoIterator) -> Self { + pub(super) fn open(script: ConnectionScript, reconnects: impl IntoIterator) -> Self { Self::open_filtered(script, reconnects, FilterSet::default()) } /// The tab opens under `saved`, the filter the table was left with. - fn open_filtered( + pub(super) fn open_filtered( + script: ConnectionScript, + reconnects: impl IntoIterator, + saved: FilterSet, + ) -> Self { + Self::open_with(script, reconnects, saved, false) + } + + fn open_read_only(script: ConnectionScript) -> Self { + Self::open_with(script, [], FilterSet::default(), true) + } + + fn open_with( script: ConnectionScript, reconnects: impl IntoIterator, saved: FilterSet, + read_only: bool, ) -> Self { libadwaita::init().expect("libadwaita initialises under the test backend"); crate::register_test_resources(); @@ -176,7 +183,7 @@ impl Harness { let fake = FakeSession::start(script, reconnects, runtime.tasks()); let root = tempfile::tempdir().expect("tempdir"); let tracker = TabTrackerHandle::default(); - let tab = filtered_users_tab(&fake.session, &runtime, &root, saved, tracker.clone()); + let tab = filtered_users_tab(&fake.session, &runtime, &root, saved, tracker.clone(), read_only); Self { tab: Some(tab), tracker, @@ -189,12 +196,12 @@ impl Harness { } } - fn tab(&self) -> &BrowseTab { + pub(super) fn tab(&self) -> &BrowseTab { self.tab.as_ref().expect("the tab is open") } /// Another tab on the same table and session. - fn open_another(&mut self) -> BrowseTab { + pub(super) fn open_another(&mut self) -> BrowseTab { let runtime = self.runtime.as_ref().expect("the runtime is running"); let tab = users_tab(&self.session, runtime, &self.root); self.others.push(tab.clone()); @@ -203,7 +210,7 @@ impl Harness { /// The second cell of each saved row on screen, empty while the /// tab shows no page. - fn shown(&self) -> Vec { + pub(super) fn shown(&self) -> Vec { self.saved_rows() .iter() .filter_map(|row| row.cell(1).cloned()) @@ -211,7 +218,7 @@ impl Harness { } /// The saved rows the grid holds for the page on screen. - fn saved_rows(&self) -> Vec { + pub(super) fn saved_rows(&self) -> Vec { use gtk4::subclass::prelude::ObjectSubclassIsExt; let model = self.tab().imp().model.borrow(); let Some(selection) = model @@ -227,19 +234,19 @@ impl Harness { .collect() } - fn visible_page(&self) -> Option { + pub(super) fn visible_page(&self) -> Option { self.tab().imp_stack().visible_child_name().map(|name| name.to_string()) } /// The row the grid shows at `position`, drafts included. - fn grid_row(&self, position: u32) -> RowObject { + pub(super) fn grid_row(&self, position: u32) -> RowObject { self.selection() .item(position) .and_downcast::() .expect("a row") } - fn selection(&self) -> gtk4::MultiSelection { + pub(super) fn selection(&self) -> gtk4::MultiSelection { use gtk4::subclass::prelude::ObjectSubclassIsExt; self.tab() .imp() @@ -250,7 +257,7 @@ impl Harness { .expect("the grid is built") } - fn store(&self) -> gtk4::gio::ListStore { + pub(super) fn store(&self) -> gtk4::gio::ListStore { self.selection() .model() .and_downcast::() @@ -268,10 +275,10 @@ impl Harness { } /// The tab in a window, so its grid draws cells. -struct Shown(gtk4::Window); +pub(super) struct Shown(pub(super) gtk4::Window); impl Shown { - fn new(tab: &BrowseTab) -> Self { + pub(super) fn new(tab: &BrowseTab) -> Self { let window = gtk4::Window::new(); window.set_default_size(600, 400); window.set_child(Some(tab)); @@ -287,33 +294,51 @@ impl Drop for Shown { } } -fn users_tab(session: &Arc, runtime: &crate::runtime::AppRuntime, root: &tempfile::TempDir) -> BrowseTab { +pub(super) fn users_tab( + session: &Arc, + runtime: &crate::runtime::AppRuntime, + root: &tempfile::TempDir, +) -> BrowseTab { filtered_users_tab( session, runtime, root, FilterSet::default(), TabTrackerHandle::default(), + false, ) } -fn filtered_users_tab( +pub(super) fn filtered_users_tab( + session: &Arc, + runtime: &crate::runtime::AppRuntime, + root: &tempfile::TempDir, + saved: FilterSet, + tracker: TabTrackerHandle, + read_only: bool, +) -> BrowseTab { + table_tab(session, runtime, root, "users", saved, tracker, read_only) +} + +pub(super) fn table_tab( session: &Arc, runtime: &crate::runtime::AppRuntime, root: &tempfile::TempDir, + table: &str, saved: FilterSet, tracker: TabTrackerHandle, + read_only: bool, ) -> BrowseTab { let tasks = runtime.tasks(); let connection_id = uuid::Uuid::new_v4(); let filter_settings = crate::persistence::FilterSettingsStore::load(root.path().join("filters.json"), &tasks); - filter_settings.set_filters(connection_id, None, "users", saved); + filter_settings.set_filters(connection_id, None, table, saved); BrowseTab::new(BrowseTabInit { tracker, schema: None, - table: "users".to_owned(), + table: table.to_owned(), connection_id: Some(connection_id), - read_only: false, + read_only, page_size: 100, initial_offset: 0, initial_sort: None, @@ -321,6 +346,9 @@ fn filtered_users_tab( filter_settings, session: session.clone(), tasks, + deadlines: Arc::new(tablepro_session::operation::DefaultDeadlines::new( + tablepro_core::LivenessPolicy::DESKTOP, + )), }) } @@ -342,11 +370,11 @@ impl BrowseTab { } } -fn text(values: &[&str]) -> Vec { +pub(super) fn text(values: &[&str]) -> Vec { values.iter().map(|value| Value::Text((*value).to_owned())).collect() } -fn not_null(column: &str) -> FilterSet { +pub(super) fn not_null(column: &str) -> FilterSet { FilterSet { rules: vec![FilterRule { column: column.to_owned(), @@ -357,7 +385,7 @@ fn not_null(column: &str) -> FilterSet { } } -fn not_null_name() -> FilterSet { +pub(super) fn not_null_name() -> FilterSet { not_null("name") } @@ -464,7 +492,7 @@ fn schema_change_reads_the_columns_again_then_the_page() { assert_eq!(CallCounters::get(&counters.fetch_columns), 2); } -fn header_titles(tab: &BrowseTab) -> Vec { +pub(super) fn header_titles(tab: &BrowseTab) -> Vec { let Some(view) = crate::test_support::first_descendant_of_type::(tab) else { return Vec::new(); }; @@ -538,34 +566,13 @@ fn a_tab_that_opens_reads_the_columns_the_table_has_now() { assert_eq!(CallCounters::get(&harness.connection.counters().fetch_columns), 2); } -fn toasts(tab: &BrowseTab) -> std::rc::Rc>> { +pub(super) fn toasts(tab: &BrowseTab) -> std::rc::Rc>> { let toasts = std::rc::Rc::new(std::cell::RefCell::new(Vec::new())); let sink = toasts.clone(); tab.connect_toast(move |_, text| sink.borrow_mut().push(text)); toasts } -#[gtk4::test] -fn a_failed_refresh_keeps_the_rows_on_screen() { - let mut script = serving_table(); - script - .query_bound - .once(FakeReply::ok(rows(&["kept"]))) - .always(FakeReply::err(DriverError::server("denied"))); - let harness = Harness::open(script, []); - wait_until(WAIT, || harness.shown() == text(&["kept"])).expect("the first page"); - let toasts = toasts(harness.tab()); - let counters = harness.connection.counters(); - - harness.tab().send(BrowseTabInput::Refresh); - wait_until(WAIT, || !toasts.borrow().is_empty()).expect("the failed refresh was not reported"); - - assert_eq!(CallCounters::get(&counters.query_bound), 2); - assert_eq!(harness.shown(), text(&["kept"]), "the failed refresh dropped the rows"); - assert_eq!(harness.visible_page().as_deref(), Some("grid")); - assert!(toasts.borrow()[0].contains("denied"), "{:?}", toasts.borrow()); -} - #[gtk4::test] fn a_failed_page_under_a_new_filter_shows_the_error() { let mut script = serving_table(); @@ -580,7 +587,11 @@ fn a_failed_page_under_a_new_filter_shows_the_error() { wait_until(WAIT, || harness.visible_page().as_deref() == Some("error")) .expect("rows read without the filter stayed under it"); - assert!(harness.shown().is_empty()); + assert_eq!( + harness.shown(), + text(&["unfiltered"]), + "the error page let go of the page the paginator still points back to" + ); } #[gtk4::test] @@ -667,22 +678,83 @@ fn export_and_save_reach_the_window_as_signals() { let exported = std::rc::Rc::new(std::cell::RefCell::new(Vec::new())); let saved = std::rc::Rc::new(std::cell::Cell::new(0)); let export_sink = exported.clone(); - harness.tab().connect_export_results(move |_, request| { - export_sink.borrow_mut().push((request.name, request.result.rows.len())); + harness.tab().connect_export_results(move |_, offer| { + export_sink + .borrow_mut() + .push((offer.name, offer.origin.shown().row_count())); }); let save_sink = saved.clone(); harness .tab() .connect_save_requested(move |_| save_sink.set(save_sink.get() + 1)); - harness.tab().send(BrowseTabInput::ExportCurrentPage); + harness.tab().send(BrowseTabInput::Export(None)); harness.tab().send(BrowseTabInput::InsertRow); harness.tab().send(BrowseTabInput::CommitSave); - assert_eq!(*exported.borrow(), [("users".to_owned(), 2)]); + assert_eq!(*exported.borrow(), [("users".to_owned(), Some(2))]); assert_eq!(saved.get(), 1); } +#[gtk4::test] +fn current_page_export_includes_pending_edits() { + 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"); + // The total is read after the page, on its own. + wait_until(WAIT, || { + use gtk4::subclass::prelude::ObjectSubclassIsExt; + harness + .tab() + .imp() + .model + .borrow() + .as_ref() + .is_some_and(|model| model.total != RowTotal::Unknown) + }) + .expect("the table's total"); + harness.apply_delta(&harness.tracker.apply(rename(0, "Ada", "Ada Lovelace"))); + let offers = std::rc::Rc::new(std::cell::RefCell::new(Vec::new())); + let sink = offers.clone(); + harness + .tab() + .connect_export_results(move |_, offer| sink.borrow_mut().push(offer)); + + harness.tab().send(BrowseTabInput::Export(None)); + + let offers = offers.borrow(); + let [offer] = offers.as_slice() else { + panic!("one export, not {}", offers.len()); + }; + let crate::ui::export::ExportOrigin::Browse { + page: crate::ui::export::ExportSource::Rows { rows, .. }, + query: crate::ui::export::ExportQuery::Table(table), + total, + has_unsaved_changes, + } = &offer.origin + else { + panic!("not a browse page: {:?}", offer.origin); + }; + let names: Vec = rows.iter().map(|row| row.to_values()[1].clone()).collect(); + assert_eq!( + names, + [Value::Text("Ada Lovelace".to_owned()), Value::Text("Grace".to_owned())] + ); + assert!(*has_unsaved_changes, "the pending rename went unmentioned"); + assert_eq!(table.table.name, "users"); + assert_eq!( + table + .columns + .iter() + .map(|column| column.name.as_str()) + .collect::>(), + ["id", "name"] + ); + assert_ne!(*total, RowTotal::Unknown, "the table's total was not handed over"); + assert!(offer.session.is_some(), "a read of every row has no session"); +} + #[gtk4::test] fn stop_saving_button_visible_only_while_saving() { use gtk4::subclass::prelude::ObjectSubclassIsExt; @@ -837,49 +909,74 @@ fn copy_row_as_insert_uses_row_below_draft() { 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"); - 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::InsertRow); let below_draft = harness.grid_row(1); - harness - .tab() - .send(BrowseTabInput::GridCopyRowsAsInsert(vec![below_draft])); + let copied = copied_by(harness.tab(), || { + harness + .tab() + .send(BrowseTabInput::GridCopyRowsAsInsert(vec![below_draft])); + }); - assert_eq!( - *copied.borrow(), - ["INSERT INTO \"users\" (\"id\", \"name\") VALUES (0, 'Ada');".to_owned()] - ); + assert_eq!(copied, "INSERT INTO \"users\" (\"id\", \"name\") VALUES (0, 'Ada');"); } +/// MySQL reads a backslash in a string as an escape, so the list is +/// written in the server's own literals. #[gtk4::test] -fn a_copy_with_a_nul_reaches_the_window_whole() { +fn copy_as_in_clause_writes_the_servers_literals() { let mut script = serving_table(); - script.query_bound.always(FakeReply::ok(rows(&["Ada"]))); + script.dialect = Arc::new(drivers_mysql::MySqlDialect::new( + drivers_mysql::MySqlFlavour::MySql, + &tablepro_core::ServerInfo::new("MySQL", "8.4.3", 8, 4, 3), + )); + let injected = "\\') OR 1=1 -- "; + script.query_bound.always(FakeReply::ok(rows(&[injected]))); 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)); + let row = harness.grid_row(0); + let values = crate::ui::clipboard::InClauseRows { + columns: [column("id", true), column("name", false)] + .iter() + .map(ColumnInfo::result_column) + .collect(), + rows: vec![crate::ui::grid::RowSnapshot::new( + row.cells().expect("the row's cells"), + Vec::new(), + )], + column: 1, + }; - harness - .tab() - .send(BrowseTabInput::GridCopyToClipboard("a\0b".to_owned())); + let copied = copied_by(harness.tab(), || { + harness.tab().send(BrowseTabInput::GridCopyInClause(values)); + }); + + assert_eq!( + copied, + format!( + "(_utf8mb4 X'{}')", + tablepro_core::hex::encode_lower(injected.as_bytes()) + ) + ); +} - assert_eq!(*copied.borrow(), ["a\0b".to_owned()]); +/// What the clipboard holds once the copy `copy` starts lands there. +fn copied_by(tab: &BrowseTab, copy: impl FnOnce()) -> String { + const BEFORE: &str = "nothing copied yet"; + let clipboard = tab.clipboard(); + clipboard.set_text(BEFORE); + copy(); + let text = || -> Option { clipboard.content()?.value(String::static_type()).ok()?.get().ok() }; + wait_until(WAIT, || text().is_some_and(|text| text != BEFORE)).expect("the copy to land"); + text().unwrap_or_default() } -fn key(id: i64) -> RowKey { +pub(super) 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 { +pub(super) 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); @@ -963,20 +1060,16 @@ fn page_rows_have_single_owner() { 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())); + let copied = copied_by(harness.tab(), || { + harness.tab().send(BrowseTabInput::GridCopyRowsAsInsert(rows.clone())); + }); harness.tab().send(BrowseTabInput::DuplicateRows(rows)); - let copied = copied.borrow(); - assert_eq!(copied.len(), 1); + assert_eq!(copied.lines().count(), 2, "{copied}"); for hidden in ["16401", "(0,1)", "812", "__tablepro"] { - assert!(!copied[0].contains(hidden), "{hidden} reached the copy: {}", copied[0]); + assert!(!copied.contains(hidden), "{hidden} reached the copy: {copied}"); } let drafts = harness.tracker.drafts(); assert_eq!(drafts.len(), 2); @@ -990,6 +1083,30 @@ fn copy_as_insert_and_duplicate_contain_no_hidden_columns() { } } +#[gtk4::test] +fn copy_as_insert_shares_saved_rows_and_copies_only_drafts() { + let mut script = serving_table(); + script.query_bound.always(FakeReply::ok(rows(&["Ada", "Grace"]))); + let harness = Harness::open(script, []); + wait_until(WAIT, || harness.saved_rows().len() == 2).expect("the first page"); + harness.tab().send(BrowseTabInput::InsertRow); + let saved = harness.saved_rows(); + let mut rows = vec![harness.grid_row(0)]; + rows.extend(saved.iter().cloned()); + + use gtk4::subclass::prelude::ObjectSubclassIsExt; + let model = harness.tab().imp().model.borrow(); + let insert_rows = model.as_ref().expect("the model").insert_rows(&rows); + + let [InsertRow::Draft(_), InsertRow::Saved(first), InsertRow::Saved(second)] = insert_rows.as_slice() else { + panic!("{insert_rows:?}"); + }; + for (snapshot, row) in [first, second].into_iter().zip(&saved) { + let cells = row.shared_cells().expect("a saved row's cells"); + assert!(snapshot.shares_cells_with(cells), "a saved row's cells were copied"); + } +} + #[gtk4::test] fn page_export_snapshot_contains_no_hidden_columns() { let harness = Harness::open(partitioned_table(), []); @@ -998,24 +1115,24 @@ fn page_export_snapshot_contains_no_hidden_columns() { let sink = exported.clone(); harness .tab() - .connect_export_results(move |_, request| sink.borrow_mut().push(request.result)); + .connect_export_results(move |_, offer| sink.borrow_mut().push(offer.origin.shown().clone())); - harness.tab().send(BrowseTabInput::ExportCurrentPage); + harness.tab().send(BrowseTabInput::Export(None)); let exported = exported.borrow(); - let [result] = exported.as_slice() else { + let [crate::ui::export::ExportSource::Rows { columns, rows }] = exported.as_slice() else { panic!("one export, not {}", exported.len()); }; - let names: Vec<&str> = result.columns.iter().map(|column| column.name.as_str()).collect(); + let names: Vec<&str> = 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); + assert_eq!(rows.len(), 2); + for row in rows { + visible_only(&row.to_values()); } } /// A browse page served first, then `next` for every page after it. -fn paging(first: QueryResult, next: QueryResult) -> ConnectionScript { +pub(super) fn paging(first: QueryResult, next: QueryResult) -> ConnectionScript { let mut script = serving_table(); script.count_rows.always(FakeReply::ok(1_000)); script @@ -1026,7 +1143,7 @@ fn paging(first: QueryResult, next: QueryResult) -> ConnectionScript { } /// Rows enough for a full page, so Next has somewhere to go. -fn full_page(first: i64) -> QueryResult { +pub(super) 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) @@ -1074,10 +1191,9 @@ fn a_page_that_replaces_the_row_being_edited_keeps_the_edit_and_the_focus() { 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::() + let name = crate::ui::grid::data_columns(&view) + .into_iter() + .nth(1) .expect("the name column"); view.scroll_to(1, Some(&name), gtk4::ListScrollFlags::FOCUS, None); view.grab_focus(); @@ -1117,7 +1233,7 @@ fn a_page_that_replaces_the_row_being_edited_keeps_the_edit_and_the_focus() { ); } -fn after_next_frame(window: >k4::Window) { +pub(super) 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(); @@ -1128,7 +1244,7 @@ fn after_next_frame(window: >k4::Window) { } /// The cell showing `column` of the saved row keyed `id`, once drawn. -fn grid_cell_of(tab: &BrowseTab, id: i64, column: usize) -> GridCell { +pub(super) fn grid_cell_of(tab: &BrowseTab, id: i64, column: usize) -> GridCell { let key = key(id); let mut found = None; wait_until(WAIT, || { @@ -1357,6 +1473,46 @@ fn error_flash_sets_and_clears_on_the_failed_row() { wait_until(WAIT, || !has_class(&cell, "tp-row-leftmost-error-flash")).expect("the flash never ended"); } +/// The first flash's timer cannot clear a flash that is no longer its +/// own, so the second one has to. +#[gtk4::test] +fn a_second_failed_row_takes_the_mark_off_the_first() { + 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"); + let _shown = Shown::new(harness.tab()); + let first = grid_cell_of(harness.tab(), 1, 0); + let row = first.parent().and_then(|cell| cell.parent()).expect("the row widget"); + let icon = crate::test_support::first_descendant_of_type::(&row).expect("the row's state icon"); + let fail = |id| { + harness + .tab() + .send(BrowseTabInput::FlashErrorRow(StatementSource::Update { + row_key: key(id), + })); + }; + + fail(1); + assert_eq!(icon.icon_name().as_deref(), Some("dialog-error-symbolic")); + fail(0); + + assert!( + first.row().is_some_and(|row| row.key() == Some(&key(1))), + "the cell moved to another row" + ); + assert!(has_class( + &grid_cell_of(harness.tab(), 0, 0), + "tp-row-leftmost-error-flash" + )); + assert!(!has_class(&first, "tp-row-leftmost-error-flash")); + assert_eq!(icon.icon_name(), None, "the first row still reads as not saved"); + assert!(!gtk4::test_accessible_has_property( + &row, + gtk4::AccessibleProperty::Description + )); +} + #[gtk4::test] fn copy_row_as_insert_on_a_draft_leaves_what_the_server_fills_out() { let mut id = col("integer", false); @@ -1381,11 +1537,6 @@ fn copy_row_as_insert_on_a_draft_leaves_what_the_server_fills_out() { ))); 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)); let toasts = toasts(harness.tab()); harness.tab().send(BrowseTabInput::InsertRow); @@ -1400,11 +1551,13 @@ fn copy_row_as_insert_on_a_draft_leaves_what_the_server_fills_out() { input: CellInput::Value(Value::Text("x".into())), }, }); - harness.tab().send(BrowseTabInput::GridCopyRowsAsInsert(vec![draft])); + let copied = copied_by(harness.tab(), || { + harness.tab().send(BrowseTabInput::GridCopyRowsAsInsert(vec![draft])); + }); assert_eq!( - *copied.borrow(), - ["INSERT INTO \"users\" (\"name\") VALUES ('x');".to_owned()], + copied, + "INSERT INTO \"users\" (\"name\") VALUES ('x');", "toasts: {:?}", toasts.borrow() ); @@ -1554,18 +1707,18 @@ 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 { +pub(super) 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()) + .map(|model| model.actions.delete_rows.clone()) .expect("the tab has a model") } fn delete_rows(tab: &BrowseTab) { - tab.activate_action("tab.delete-rows", None) + tab.activate_action("browse.delete-rows", None) .expect("the tab has a delete action"); } @@ -1606,28 +1759,305 @@ fn deleting_a_draft_removes_its_row() { ); } -#[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) - }) +fn named_actions(controller: >k4::ShortcutController) -> Vec { + crate::test_support::shortcut_keys::shortcuts(controller) + .iter() .filter_map(|shortcut| shortcut.action().and_downcast::()) .map(|action| action.action_name().to_string()) + .collect() +} + +fn table_actions(owner: crate::application::ShortcutOwner) -> Vec { + use crate::application::{ACCELERATORS, AcceleratorScope}; + ACCELERATORS + .iter() + .filter(|entry| matches!(entry.scope, AcceleratorScope::Local { owner: of, .. } if of == owner)) + .flat_map(|entry| entry.triggers.iter().map(|_| entry.action.to_owned())) + .collect() +} + +/// The grid's keys sit on the column view, so a text field elsewhere in +/// the tab keeps Ctrl+C, Delete and Escape. +#[gtk4::test] +fn browse_grid_shortcuts_live_on_the_column_view() { + use crate::application::ShortcutOwner; + use crate::test_support::shortcut_keys::{owner_controller, shortcut_controllers}; + 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"); + let view = column_view(harness.tab()); + + let grid = owner_controller(&view, ShortcutOwner::BrowseGrid).expect("the grid's shortcuts"); + let tab = owner_controller(harness.tab(), ShortcutOwner::BrowseTab).expect("the tab's shortcuts"); + + assert_eq!(named_actions(&grid), table_actions(ShortcutOwner::BrowseGrid)); + assert_eq!(named_actions(&tab), table_actions(ShortcutOwner::BrowseTab)); + let on_the_tab: Vec = shortcut_controllers(harness.tab()) + .iter() + .flat_map(named_actions) .collect(); + assert_eq!(on_the_tab, table_actions(ShortcutOwner::BrowseTab), "{on_the_tab:?}"); +} + +/// A bubble key on the tab reaches it only when no class on the way up +/// from the focus binds it first. Text fields keep their own keys, as +/// Ctrl+Z, by design. +#[gtk4::test] +fn browse_tab_keys_are_not_class_bindings_inside_the_tab() { + use crate::application::{ACCELERATORS, AcceleratorScope, ShortcutOwner}; + use crate::test_support::shortcut_keys::{class_keys_on_focus_paths, keystroke}; + 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"); + let _shown = Shown::new(harness.tab()); + + let class_keys = class_keys_on_focus_paths(harness.tab(), |widget| { + widget.is::() || widget.is::() + }); + + assert!( + class_keys.contains(&keystroke("Page_Up")), + "the grid's own bindings were not found, so the check reads nothing" + ); + for entry in ACCELERATORS { + let AcceleratorScope::Local { + owner: ShortcutOwner::BrowseTab, + phase, + } = entry.scope + else { + continue; + }; + assert_eq!(phase, gtk4::PropagationPhase::Bubble, "{}", entry.action); + for accel in entry.triggers { + assert!( + !class_keys.contains(&keystroke(accel)), + "{accel} of {} never reaches the tab", + entry.action + ); + } + } +} + +#[gtk4::test] +fn browse_actions_follow_the_tab() { + 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"); + let activate = |name: &str| { + harness + .tab() + .activate_action(&format!("browse.{name}"), None) + .expect("the browse group"); + }; + + assert!( + action_enabled(harness.tab(), "insert-row"), + "a keyed table refused Ctrl+N" + ); + assert!(!action_enabled(harness.tab(), "save"), "Save with nothing to save"); + assert!(!action_enabled(harness.tab(), "undo")); + assert!( + !action_enabled(harness.tab(), "copy-rows"), + "Ctrl+C with no rows selected" + ); + + harness.selection().select_all(); + assert!(action_enabled(harness.tab(), "copy-rows")); + assert!(action_enabled(harness.tab(), "clear-selection")); + activate("clear-selection"); + assert!( + !action_enabled(harness.tab(), "clear-selection"), + "the selection was not cleared" + ); + + harness.tab().send(BrowseTabInput::InsertRow); + assert!(action_enabled(harness.tab(), "save")); + assert!(action_enabled(harness.tab(), "undo")); + assert!(!action_enabled(harness.tab(), "redo")); + activate("undo"); + assert_eq!(harness.tracker.pending_count(), 0, "browse.undo did not undo"); + assert!(action_enabled(harness.tab(), "redo")); + assert!(!action_enabled(harness.tab(), "save")); +} + +/// These press real keys on the X display every test shares; nextest runs +/// them with no other test beside them. +mod real_keys { + use super::*; + + /// A real key press: the field's own undo runs before the tab's, so + /// Ctrl+Z in a filter field takes back typing, not a row edit. + #[gtk4::test] + fn ctrl_z_in_filter_entry_undoes_text_not_rows() { + use gtk4::gdk::{Key, ModifierType}; + // A key reaches a top bar only once its reveal has ended. + if let Some(settings) = gtk4::Settings::default() { + settings.set_gtk_enable_animations(false); + } + 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"); + let shown = Shown::new(harness.tab()); + crate::test_support::KeyInjector::focus_only(&shown.0); + wait_until(Duration::from_secs(2), || shown.0.is_active()).expect("the window to take the focus"); + harness + .tab() + .send(BrowseTabInput::DeleteRows(vec![harness.grid_row(0)])); + assert_eq!(harness.tracker.pending_count(), 1); + let strip = { + use gtk4::subclass::prelude::ObjectSubclassIsExt; + let model = harness.tab().imp().model.borrow(); + let strip = model.as_ref().and_then(|model| model.filter_strip.as_ref()); + strip.expect("the filter strip").widget.clone() + }; + + harness.tab().send(BrowseTabInput::ToggleFilterStrip); + let mut field = None; + wait_until(Duration::from_secs(2), || { + field = gtk4::prelude::GtkWindowExt::focus(&shown.0) + .filter(|focus| focus.is_ancestor(&strip)) + .and_then(|focus| focus.downcast::().ok()); + field.is_some() + }) + .expect("the strip's field takes the focus"); + let field = field.expect("the focused field"); + let keys = crate::test_support::KeyInjector::connect().expect("an X11 display"); + + keys.press(Key::x, ModifierType::empty()).expect("the key press"); + wait_until(Duration::from_secs(2), || field.text() == "x").expect("the typing to land"); + keys.press(Key::z, ModifierType::CONTROL_MASK).expect("the key press"); + + wait_until(Duration::from_secs(2), || field.text().is_empty()).expect("Ctrl+Z to take back the typing"); + assert_eq!(harness.tracker.pending_count(), 1, "Ctrl+Z undid the row edit"); + } +} + +#[gtk4::test] +fn refresh_asks_the_window_and_filter_opens_the_strip() { + 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"); + let refreshes = crate::test_support::SignalLog::connect(harness.tab(), "refresh-requested", |_| ()); + let strip_open = || { + use gtk4::subclass::prelude::ObjectSubclassIsExt; + let model = harness.tab().imp().model.borrow(); + model + .as_ref() + .and_then(|model| model.filter_strip.as_ref()) + .is_some_and(crate::ui::filter_strip::FilterStrip::is_revealed) + }; + + harness + .tab() + .activate_action("browse.refresh", None) + .expect("the browse group"); + harness + .tab() + .activate_action("browse.filter", None) + .expect("the browse group"); - assert_eq!(named, ["tab.delete-rows"]); + assert_eq!(refreshes.count(), 1); + assert!(strip_open()); +} + +#[gtk4::test] +fn a_read_only_tab_refuses_insert_row() { + let mut script = serving_table(); + script.query_bound.always(FakeReply::ok(rows(&["Ada"]))); + let harness = Harness::open_read_only(script); + wait_until(WAIT, || harness.shown().len() == 1).expect("the first page"); + + assert!(!action_enabled(harness.tab(), "insert-row")); + harness.tab().activate_action("browse.insert-row", None).ok(); + assert_eq!( + harness.tracker.pending_count(), + 0, + "Ctrl+N added a row to a read-only table" + ); + + harness.tab().send(BrowseTabInput::InsertRow); + harness + .tab() + .send(BrowseTabInput::DuplicateRows(vec![harness.grid_row(0)])); + assert_eq!(harness.tracker.pending_count(), 0, "a read-only table took a new row"); +} + +#[gtk4::test] +fn browse_buttons_announce_their_keys() { + use gtk4::subclass::prelude::ObjectSubclassIsExt; + 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 imp = harness.tab().imp(); + + for (name, button) in [ + ("insert row", imp.insert_button.get()), + ("delete rows", imp.delete_button.get()), + ("filter", imp.filter_button.get()), + ("previous page", imp.prev_button.get()), + ("next page", imp.next_button.get()), + ("save", imp.save_button.get()), + ] { + assert!( + gtk4::test_accessible_has_property(&button, gtk4::AccessibleProperty::KeyShortcuts), + "the {name} button announces no keys" + ); + } +} + +/// A page splice leaves the selection model without a selection-changed, +/// so the tab reads the selection again. +#[gtk4::test] +fn a_new_page_turns_off_the_selection_keys() { + use gtk4::subclass::prelude::ObjectSubclassIsExt; + 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.selection().select_range(0, 2, true); + assert!(action_enabled(harness.tab(), "copy-rows")); + assert!(action_enabled(harness.tab(), "clear-selection")); + + harness.tab().send(BrowseTabInput::NextPage); + wait_until(WAIT, || harness.shown() == text(&["Linus", "Ken"])).expect("the next page"); + + assert!( + harness.selection().selection().is_empty(), + "the new page kept a selection" + ); + assert!( + !action_enabled(harness.tab(), "copy-rows"), + "Ctrl+C stayed on with nothing selected" + ); + assert!( + !action_enabled(harness.tab(), "clear-selection"), + "Escape stayed on with nothing selected" + ); + assert!( + !harness.tab().imp().selection_label.is_visible(), + "the footer still counts the rows that left" + ); +} + +fn action_enabled(tab: &BrowseTab, name: &str) -> bool { + use gtk4::subclass::prelude::ObjectSubclassIsExt; + let model = tab.imp().model.borrow(); + let actions = &model.as_ref().expect("the tab has a model").actions; + let action = match name { + "save" => &actions.save, + "undo" => &actions.undo, + "redo" => &actions.redo, + "insert-row" => &actions.insert_row, + "delete-rows" => &actions.delete_rows, + "copy-rows" => &actions.copy_rows, + "clear-selection" => &actions.clear_selection, + _ => panic!("browse.{name} does not follow the tab"), + }; + action.is_enabled() } #[gtk4::test] @@ -1667,7 +2097,7 @@ fn menu_gesture(cell: &GridCell) -> gtk4::GestureClick { .expect("a cell answers a right-click") } -fn column_view(tab: &BrowseTab) -> gtk4::ColumnView { +pub(super) fn column_view(tab: &BrowseTab) -> gtk4::ColumnView { crate::test_support::first_descendant_of_type::(tab).expect("the grid") } @@ -2238,7 +2668,7 @@ fn confirming(tab: &BrowseTab) -> Option<(u64, libadwaita::AlertDialog)> { /// 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); + harness.selection().select_all(); delete_rows(harness.tab()); let (_, dialog) = confirming(harness.tab()).expect("a bulk delete asks first"); assert_eq!(harness.tracker.pending_count(), 0); @@ -2332,10 +2762,10 @@ fn a_bulk_delete_waiting_while_a_column_is_added_marks_its_rows_under_the_new_co /// What this thread logs, as text. #[derive(Clone, Default)] -struct LogLines(Arc>>); +pub(super) struct LogLines(Arc>>); impl LogLines { - fn text(&self) -> String { + pub(super) fn text(&self) -> String { let bytes = self.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner); String::from_utf8_lossy(&bytes).into_owned() } @@ -2432,3 +2862,27 @@ fn insert_row_opens_the_new_rows_first_editable_cell() { let focus = gtk4::prelude::GtkWindowExt::focus(&shown.0).expect("a focused widget"); assert_eq!(GridCell::from_focus(&focus).as_ref(), Some(&cell)); } + +#[gtk4::test] +fn icon_buttons_are_labelled_without_accelerator_hints() { + use gtk4::subclass::prelude::ObjectSubclassIsExt; + let harness = Harness::open(paging(full_page(0), rows_from(100, &["Linus"])), []); + wait_until(WAIT, || harness.shown().len() == 100).expect("the first page"); + let imp = harness.tab().imp(); + + assert_eq!( + crate::test_support::unlabelled_icon_buttons(harness.tab()), + Vec::::new() + ); + assert_eq!( + crate::test_support::tooltips_with_keys(harness.tab()), + Vec::::new() + ); + for button in [imp.insert_button.get(), imp.next_button.get(), imp.save_button.get()] { + assert!( + gtk4::test_accessible_has_property(&button, gtk4::AccessibleProperty::KeyShortcuts), + "{:?} does not announce its keys", + button.tooltip_text() + ); + } +} diff --git a/linux/crates/app/src/ui/browse_tab/total_tests.rs b/linux/crates/app/src/ui/browse_tab/total_tests.rs new file mode 100644 index 0000000000..e807dfd869 --- /dev/null +++ b/linux/crates/app/src/ui/browse_tab/total_tests.rs @@ -0,0 +1,398 @@ +use std::time::Duration; + +use gtk4::prelude::*; +use gtk4::subclass::prelude::ObjectSubclassIsExt; +use tablepro_core::meta::RowCountEstimate; +use tablepro_core::{DriverError, TimeoutPhase}; +use tablepro_session::fakes::FakeEstablish; +use tablepro_test_support::{CallCounters, ConnectionScript, FakeReply}; + +use super::BrowseTabInput; +use super::tests::{ + Harness, LogLines, WAIT, estimate, full_page, not_null, not_null_name, paging, rows, rows_from, serving_table, + text, toasts, +}; +use crate::services::change_origins::ChangeOrigins; +use crate::test_support::{drain_main_context, wait_until}; + +fn paginator(harness: &Harness) -> String { + harness.tab().imp().paginator_label.label().to_string() +} + +fn count_button(harness: &Harness) -> gtk4::Button { + harness.tab().imp().count_button.get() +} + +fn count_label(harness: &Harness) -> String { + harness.tab().imp().count_label.label().to_string() +} + +fn count_calls(harness: &Harness) -> usize { + CallCounters::get(&harness.connection.counters().count_rows) +} + +/// A one-row table whose estimate is `estimate`. +fn estimated(rows_estimate: RowCountEstimate) -> ConnectionScript { + let mut script = serving_table(); + script.query_bound.always(FakeReply::ok(rows(&["Ada", "Grace"]))); + script.table_estimate = Default::default(); + script.table_estimate.always(FakeReply::ok(estimate(rows_estimate))); + script +} + +/// Long enough for a count the tab ran on its own to have been asked. +fn settle() { + std::thread::sleep(Duration::from_millis(200)); + drain_main_context(); +} + +#[gtk4::test] +fn large_estimate_offers_count_without_counting() { + let harness = Harness::open(estimated(RowCountEstimate::Approximate(5_000_000)), []); + wait_until(WAIT, || paginator(&harness).contains("of about")).expect("the estimate in the paginator"); + settle(); + + assert_eq!(count_calls(&harness), 0, "a large table was counted on its own"); + assert!(count_button(&harness).is_visible() && count_button(&harness).is_sensitive()); + assert_eq!(count_label(&harness), "Count Rows"); + assert!(!harness.tab().imp().last_button.is_sensitive()); +} + +#[gtk4::test] +fn unknown_estimate_offers_count_without_counting() { + let harness = Harness::open(estimated(RowCountEstimate::Unknown), []); + wait_until(WAIT, || harness.shown().len() == 2).expect("the first page"); + wait_until(WAIT, || count_button(&harness).is_visible()).expect("Count Rows offered"); + settle(); + + assert_eq!( + count_calls(&harness), + 0, + "a table with no estimate was counted on its own" + ); + assert_eq!(paginator(&harness), "Rows 1 – 2"); +} + +#[gtk4::test] +fn estimate_error_offers_count_and_logs() { + let lines = LogLines::default(); + let subscriber = tracing_subscriber::fmt() + .with_ansi(false) + .with_writer({ + let lines = lines.clone(); + move || lines.clone() + }) + .finish(); + let _logging = tracing::subscriber::set_default(subscriber); + let mut script = estimated(RowCountEstimate::Unknown); + script.table_estimate = Default::default(); + script + .table_estimate + .always(FakeReply::err(DriverError::server("pg_class is hidden from hunter2"))); + let harness = Harness::open(script, []); + + wait_until(WAIT, || lines.text().contains("row estimate unavailable")).expect("the failure is logged"); + wait_until(WAIT, || count_button(&harness).is_visible()).expect("Count Rows offered"); + settle(); + + let text = lines.text(); + assert!(text.contains("category=Some(Server)"), "{text}"); + assert!(!text.contains("hunter2"), "{text}"); + assert_eq!(count_calls(&harness), 0); +} + +#[gtk4::test] +fn small_estimate_counts_automatically() { + let harness = Harness::open(estimated(RowCountEstimate::Approximate(40)), []); + + wait_until(WAIT, || paginator(&harness) == "Rows 1 – 2 of 2").expect("the exact total"); + + assert_eq!(count_calls(&harness), 1); + assert!( + !count_button(&harness).is_visible(), + "Count Rows stayed for an exact total" + ); +} + +#[gtk4::test] +fn count_rows_button_counts_and_shows_progress() { + let mut script = estimated(RowCountEstimate::Unknown); + script.count_rows = Default::default(); + script.count_rows.always(FakeReply::Late { + after: Duration::from_millis(300), + outcome: Ok(2), + }); + let harness = Harness::open(script, []); + wait_until(WAIT, || count_button(&harness).is_visible()).expect("Count Rows offered"); + + count_button(&harness).emit_clicked(); + + assert_eq!(count_label(&harness), "Counting…"); + assert!(!count_button(&harness).is_sensitive()); + assert!(harness.tab().imp().count_spinner.is_visible()); + wait_until(WAIT, || paginator(&harness) == "Rows 1 – 2 of 2").expect("the counted total"); + assert!(!count_button(&harness).is_visible()); +} + +/// The table is too large to count on its own, so under the filter +/// nothing replaces the count: only the filter itself can stop it. +#[gtk4::test] +fn count_is_cancelled_when_filter_changes() { + let mut script = estimated(RowCountEstimate::Approximate(5_000_000)); + script.count_rows = Default::default(); + script.count_rows.always(FakeReply::UntilStopped); + let harness = Harness::open(script, []); + wait_until(WAIT, || paginator(&harness).contains("of about")).expect("the estimate in the paginator"); + count_button(&harness).emit_clicked(); + wait_until(WAIT, || count_calls(&harness) == 1).expect("the count was asked for"); + let counters = harness.connection.counters(); + + harness.tab().send(BrowseTabInput::FilterApplied(not_null_name())); + + wait_until(WAIT, || CallCounters::get(&counters.stopped) == 1).expect("the filter left the count running"); + wait_until(WAIT, || count_label(&harness) == "Count Rows").expect("the stopped count still shows"); + settle(); + assert_eq!(count_calls(&harness), 1, "the filter counted on its own"); +} + +#[gtk4::test] +fn stale_count_reply_does_not_overwrite_newer_total() { + let mut script = estimated(RowCountEstimate::Approximate(40)); + script.count_rows = Default::default(); + script + .count_rows + .once(FakeReply::Late { + after: Duration::from_millis(300), + outcome: Ok(999), + }) + .always(FakeReply::ok(5)); + let harness = Harness::open(script, []); + wait_until(WAIT, || count_calls(&harness) == 1).expect("the first count was asked for"); + + harness.tab().send(BrowseTabInput::FilterApplied(not_null_name())); + wait_until(WAIT, || paginator(&harness) == "Rows 1 – 2 of 5").expect("the filtered total"); + std::thread::sleep(Duration::from_millis(500)); + drain_main_context(); + + assert_eq!( + paginator(&harness), + "Rows 1 – 2 of 5", + "the replaced count's reply was shown" + ); +} + +#[gtk4::test] +fn count_failure_shows_unavailable_with_reason() { + let mut script = estimated(RowCountEstimate::Approximate(40)); + script.count_rows = Default::default(); + script + .count_rows + .once(FakeReply::err(DriverError::server("statement denied"))) + .always(FakeReply::ok(2)); + let harness = Harness::open(script, []); + + // The count and the page are separate reads, and either can land first. + wait_until(WAIT, || { + count_label(&harness) == "Try Again" && paginator(&harness) == "Rows 1 – 2 of about 40" + }) + .expect("the failed count under the first page"); + let tooltip = count_button(&harness) + .tooltip_text() + .map(|text| text.to_string()) + .unwrap_or_default(); + assert!(tooltip.contains("statement denied"), "{tooltip}"); + assert!(count_button(&harness).is_sensitive()); + + count_button(&harness).emit_clicked(); + + wait_until(WAIT, || paginator(&harness) == "Rows 1 – 2 of 2").expect("Try Again counted"); +} + +#[gtk4::test] +fn save_adjusts_unfiltered_total_without_recount() { + let harness = Harness::open(estimated(RowCountEstimate::Approximate(40)), []); + wait_until(WAIT, || paginator(&harness) == "Rows 1 – 2 of 2").expect("the exact total"); + let queries = CallCounters::get(&harness.connection.counters().query_bound); + + harness + .tab() + .send(BrowseTabInput::SaveCommitted(ChangeOrigins { inserts: 2, deletes: 1 })); + wait_until(WAIT, || { + CallCounters::get(&harness.connection.counters().query_bound) == queries + 1 + }) + .expect("the page read after the save"); + settle(); + + assert_eq!(count_calls(&harness), 1, "the save counted the table again"); + assert_eq!(paginator(&harness), "Rows 1 – 2 of 3"); +} + +#[gtk4::test] +fn save_on_filtered_view_demotes_total_to_about() { + let harness = Harness::open_filtered(estimated(RowCountEstimate::Approximate(40)), [], not_null_name()); + wait_until(WAIT, || paginator(&harness) == "Rows 1 – 2 of 2").expect("the exact filtered total"); + + harness + .tab() + .send(BrowseTabInput::SaveCommitted(ChangeOrigins { inserts: 1, deletes: 0 })); + + assert_eq!(paginator(&harness), "Rows 1 – 2 of about 3"); + assert_eq!(count_label(&harness), "Count Rows"); + assert!(count_button(&harness).is_visible()); +} + +#[gtk4::test] +fn closing_tab_cancels_running_count() { + let mut script = estimated(RowCountEstimate::Approximate(40)); + script.count_rows = Default::default(); + script.count_rows.always(FakeReply::UntilStopped); + let mut harness = Harness::open(script, []); + wait_until(WAIT, || count_calls(&harness) == 1).expect("the count was asked for"); + let counters = harness.connection.counters(); + + harness.tab.take(); + drain_main_context(); + + wait_until(WAIT, || CallCounters::get(&counters.stopped) == 1).expect("closing the tab left the count running"); +} + +#[gtk4::test] +fn last_page_waits_for_an_exact_total() { + let mut script = paging(full_page(0), rows_from(900, &["Linus"])); + script.table_estimate = Default::default(); + script + .table_estimate + .always(FakeReply::ok(estimate(RowCountEstimate::Approximate(5_000_000)))); + let harness = Harness::open(script, []); + wait_until(WAIT, || harness.shown().len() == 100).expect("the first page"); + wait_until(WAIT, || count_button(&harness).is_visible()).expect("Count Rows offered"); + let last = harness.tab().imp().last_button.get(); + assert!(!last.is_sensitive()); + assert_eq!( + last.tooltip_text().as_deref(), + Some("Count rows to go to the last page") + ); + harness.tab().send(BrowseTabInput::LastPage); + drain_main_context(); + assert_eq!(CallCounters::get(&harness.connection.counters().query_bound), 1); + + count_button(&harness).emit_clicked(); + wait_until(WAIT, || last.is_sensitive()).expect("Last after the count"); + last.emit_clicked(); + + wait_until(WAIT, || harness.shown() == text(&["Linus"])).expect("the last page"); + assert_eq!(harness.tab().current_offset(), 900); +} + +fn timed_out() -> DriverError { + DriverError::Timeout { + phase: TimeoutPhase::Statement, + server_cancelled: true, + } +} + +/// A count the user asked for ran out of time. Nothing counts again +/// until they ask, a reconnect included, and least of all under the +/// filter they applied since. +#[gtk4::test] +fn a_failed_count_does_not_run_again_after_a_reconnect() { + let mut first = estimated(RowCountEstimate::Approximate(5_000_000)); + first.count_rows = Default::default(); + first.count_rows.always(FakeReply::err(timed_out())); + let harness = Harness::open( + first, + [FakeEstablish::Connect(estimated(RowCountEstimate::Approximate( + 5_000_000, + )))], + ); + wait_until(WAIT, || paginator(&harness).contains("of about")).expect("the estimate in the paginator"); + count_button(&harness).emit_clicked(); + wait_until(WAIT, || count_label(&harness) == "Try Again").expect("the count that ran out of time"); + harness.tab().send(BrowseTabInput::FilterApplied(not_null_name())); + wait_until(WAIT, || count_label(&harness) == "Count Rows").expect("the filtered total"); + + harness.connection.mark_lost(); + wait_until(WAIT, || harness.session.link_generation().get() == 1).expect("the reconnect"); + settle(); + settle(); + + assert_eq!( + CallCounters::get(&harness.fake.replacement().counters().count_rows), + 0, + "the reconnect counted the table without being asked" + ); + assert_eq!(count_calls(&harness), 1); +} + +/// Deleting the only row on the last page leaves the table one page +/// shorter, which the exact total says, so the tab moves to the page +/// that is now the last. +#[gtk4::test] +fn saving_away_the_last_page_moves_to_the_new_last_page() { + let mut script = paging(full_page(0), rows_from(100, &["Linus"])); + script.count_rows = Default::default(); + script.count_rows.always(FakeReply::ok(101)); + let harness = Harness::open(script, []); + wait_until(WAIT, || paginator(&harness) == "Rows 1 – 100 of 101").expect("the exact total"); + harness.tab().send(BrowseTabInput::NextPage); + wait_until(WAIT, || harness.shown() == text(&["Linus"])).expect("the last page"); + let queries = CallCounters::get(&harness.connection.counters().query_bound); + + harness + .tab() + .send(BrowseTabInput::SaveCommitted(ChangeOrigins { inserts: 0, deletes: 1 })); + wait_until(WAIT, || { + CallCounters::get(&harness.connection.counters().query_bound) == queries + 1 + }) + .expect("the page read after the save"); + settle(); + + assert_eq!(harness.tab().current_offset(), 0, "the tab stayed past the end"); + assert_eq!( + CallCounters::get(&harness.connection.counters().query_bound), + queries + 1, + "the save read a page twice" + ); + assert_eq!(count_calls(&harness), 1); +} + +/// The estimate can land after the user asked for a count. The count +/// gives the total, so it keeps running and keeps its spinner. +#[gtk4::test] +fn an_estimate_that_lands_during_a_count_keeps_it_counting() { + let mut script = estimated(RowCountEstimate::Unknown); + script.table_estimate = Default::default(); + script.table_estimate.always(FakeReply::Late { + after: Duration::from_millis(300), + outcome: Ok(estimate(RowCountEstimate::Approximate(5_000_000))), + }); + script.count_rows = Default::default(); + script.count_rows.always(FakeReply::UntilStopped); + let harness = Harness::open(script, []); + wait_until(WAIT, || count_button(&harness).is_visible()).expect("Count Rows offered"); + + count_button(&harness).emit_clicked(); + wait_until(WAIT, || paginator(&harness).contains("of about")).expect("the estimate"); + + assert_eq!(count_label(&harness), "Counting…"); + assert!(!count_button(&harness).is_sensitive()); + assert!(harness.tab().imp().count_spinner.is_visible()); + assert_eq!(count_calls(&harness), 1); + assert_eq!(CallCounters::get(&harness.connection.counters().stopped), 0); +} + +#[gtk4::test] +fn count_rows_under_a_filter_that_no_longer_fits_says_why() { + let harness = Harness::open(estimated(RowCountEstimate::Approximate(5_000_000)), []); + wait_until(WAIT, || paginator(&harness).contains("of about")).expect("the estimate in the paginator"); + let toasts = toasts(harness.tab()); + harness.tab().send(BrowseTabInput::FilterApplied(not_null("nickname"))); + wait_until(WAIT, || toasts.borrow().len() == 1).expect("the filter that does not fit"); + + count_button(&harness).emit_clicked(); + + let shown = toasts.borrow().clone(); + assert_eq!(shown.len(), 2, "Count Rows did nothing and said nothing"); + assert_eq!(shown[1], shown[0]); + assert_eq!(count_calls(&harness), 0); +} diff --git a/linux/crates/app/src/ui/clipboard/clipboard_copy.rs b/linux/crates/app/src/ui/clipboard/clipboard_copy.rs new file mode 100644 index 0000000000..e439fa013f --- /dev/null +++ b/linux/crates/app/src/ui/clipboard/clipboard_copy.rs @@ -0,0 +1,181 @@ +use std::cell::Cell; +use std::rc::Rc; +use std::sync::atomic::{AtomicU64, Ordering}; + +use gtk4 as gtk; +use gtk4::prelude::*; +use gtk4::{gdk, glib}; +use tablepro_core::export::{EncodeError, RenderInsertError}; +use tablepro_session::runtime::Tasks; + +use super::clipboard_writer::{holds_nul, write}; +use super::{ClipboardError, ClipboardPayload, CopyError, CopyKind, CopyRequest, render_copy}; +use crate::i18n::{gettext, gettext_f, ngettext_f}; +use crate::ui::format::{format_thousands, plural_count}; +use crate::ui::handler_guard::HandlerGuard; + +/// The copy asked for last, in the whole process: every window copies +/// to the same display clipboard, and a large copy renders long enough +/// for a later, smaller one to land first. +static LATEST_COPY: AtomicU64 = AtomicU64::new(0); + +#[derive(Debug, Clone, Copy)] +struct CopyTicket(u64); + +impl CopyTicket { + fn take() -> Self { + Self(LATEST_COPY.fetch_add(1, Ordering::Relaxed) + 1) + } + + fn is_latest(self) -> bool { + LATEST_COPY.load(Ordering::Relaxed) == self.0 + } +} + +/// Whether this process put something else on the clipboard since the +/// watch began: a copy that writes at once, or a text field's own Copy, +/// which no ticket sees. Another application's copy is not counted, +/// since Wayland announces the clipboard again on every focus change. +struct LocalWrite { + seen: Rc>, + _handler: HandlerGuard, +} + +impl LocalWrite { + fn watch(clipboard: &gdk::Clipboard) -> Self { + let seen = Rc::new(Cell::new(false)); + let flag = seen.clone(); + let handler = clipboard.connect_changed(move |clipboard| { + if clipboard.is_local() { + flag.set(true); + } + }); + Self { + seen, + _handler: HandlerGuard::new(clipboard, handler), + } + } + + fn seen(&self) -> bool { + self.seen.get() + } +} + +/// What the toast after a copy counts. +#[derive(Debug, Clone, Copy)] +enum Copied { + Value, + Rows, + InList, +} + +impl Copied { + fn of(request: &CopyRequest) -> Self { + match request { + CopyRequest::Rows { + kind: CopyKind::Value(_), + .. + } => Self::Value, + CopyRequest::InClause { .. } => Self::InList, + CopyRequest::Rows { .. } | CopyRequest::Insert(_) => Self::Rows, + } + } + + fn text(self, rows: usize) -> String { + let n = rows as u64; + match self { + Self::Value => gettext("Copied to clipboard"), + Self::Rows => ngettext_f( + "Copied {n} row", + "Copied {n} rows", + plural_count(n), + &[("n", &format_thousands(n))], + ), + Self::InList => ngettext_f( + "Copied {n} value", + "Copied {n} values", + plural_count(n), + &[("n", &format_thousands(n))], + ), + } + } +} + +/// Render `request` off the GTK thread and put it on `widget`'s +/// clipboard, then tell `report` how it went. Anything copied in this +/// process after this one wins, and this one then ends without a word. +/// The handle resolves once the copy has landed or given way. +pub(crate) fn copy_rows( + widget: &impl IsA, + tasks: &Tasks, + request: CopyRequest, + report: impl Fn(&str) + 'static, +) -> glib::JoinHandle<()> { + let ticket = CopyTicket::take(); + let clipboard = widget.as_ref().clipboard(); + let overtaken = LocalWrite::watch(&clipboard); + let copied = Copied::of(&request); + let rendering = render_copy(tasks, request); + glib::spawn_future_local(async move { + let rendered = rendering.await; + if !ticket.is_latest() || overtaken.seen() { + tracing::debug!("a copy finished after a later one and was dropped"); + return; + } + let payload = match rendered { + Ok(Ok(payload)) => payload, + Ok(Err(error)) => return report(©_error_text(&error)), + Err(failure) => { + tracing::warn!(%failure, "a copy could not be rendered"); + return report(&gettext("Couldn't copy the selection")); + } + }; + let (rows, skipped) = (payload.rows, payload.skipped); + if let Err(error) = write(&clipboard, payload) { + return report(&error.message()); + } + report(&copied.text(rows)); + // NULL and binary values have no place in an IN list, so the + // copy says how many it left out instead of handing back a + // shorter list that quietly selects different rows. + if skipped > 0 { + report(&gettext_f( + "{n} NULL or binary values left out of the IN clause", + &[("n", &skipped.to_string())], + )); + } + }) +} + +/// Put `text` on `widget`'s clipboard as it is, and tell `report`. +pub(crate) fn copy_text(widget: &impl IsA, text: String, report: impl Fn(&str)) { + copy_payload(widget, ClipboardPayload::plain(text), report); +} + +/// Put a payload rendered already on `widget`'s clipboard, and tell +/// `report`. +pub(crate) fn copy_payload(widget: &impl IsA, payload: ClipboardPayload, report: impl Fn(&str)) { + if holds_nul(&payload.text) { + return report(&ClipboardError::ContainsNul.message()); + } + match write(&widget.as_ref().clipboard(), payload) { + Ok(()) => report(&gettext("Copied to clipboard")), + Err(error) => report(&error.message()), + } +} + +fn copy_error_text(error: &CopyError) -> String { + match error { + CopyError::Encode(EncodeError::Undecodable { column }) => gettext_f( + "Couldn't copy: the column “{column}” holds a value TablePro couldn't read", + &[("column", column)], + ), + CopyError::Insert(RenderInsertError::Literal(_)) => { + gettext("This row has a value that can't be written as SQL") + } + CopyError::Insert(RenderInsertError::NoValues) => gettext("This row has no value to put in an INSERT"), + CopyError::Literal(_) => gettext("This column has a value that can't be written as SQL"), + CopyError::EmptyInClause => gettext("Nothing to copy: an IN clause can't carry NULL or binary values"), + CopyError::ContainsNul => ClipboardError::ContainsNul.message(), + } +} diff --git a/linux/crates/app/src/ui/clipboard/clipboard_format.rs b/linux/crates/app/src/ui/clipboard/clipboard_format.rs new file mode 100644 index 0000000000..973d4e6b36 --- /dev/null +++ b/linux/crates/app/src/ui/clipboard/clipboard_format.rs @@ -0,0 +1,40 @@ +/// What a copy holds, so an application that reads that type can take +/// it as such: a spreadsheet pastes tab-separated values into cells. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ClipboardFormat { + PlainText, + Tsv, + Csv, + Json, + Markdown, + Sql, +} + +impl ClipboardFormat { + /// The type offered next to plain text, or `None` for text alone. + pub(crate) fn mime_type(self) -> Option<&'static str> { + match self { + Self::PlainText => None, + Self::Tsv => Some("text/tab-separated-values"), + Self::Csv => Some("text/csv"), + Self::Json => Some("application/json"), + Self::Markdown => Some("text/markdown"), + Self::Sql => Some("application/sql"), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn clipboard_format_mime_types() { + assert_eq!(ClipboardFormat::PlainText.mime_type(), None); + assert_eq!(ClipboardFormat::Tsv.mime_type(), Some("text/tab-separated-values")); + assert_eq!(ClipboardFormat::Csv.mime_type(), Some("text/csv")); + assert_eq!(ClipboardFormat::Json.mime_type(), Some("application/json")); + assert_eq!(ClipboardFormat::Markdown.mime_type(), Some("text/markdown")); + assert_eq!(ClipboardFormat::Sql.mime_type(), Some("application/sql")); + } +} diff --git a/linux/crates/app/src/ui/clipboard/clipboard_payload.rs b/linux/crates/app/src/ui/clipboard/clipboard_payload.rs new file mode 100644 index 0000000000..f747bc9401 --- /dev/null +++ b/linux/crates/app/src/ui/clipboard/clipboard_payload.rs @@ -0,0 +1,23 @@ +use super::ClipboardFormat; + +/// A rendered copy, ready for the clipboard. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct ClipboardPayload { + pub text: String, + pub format: ClipboardFormat, + /// The rows it holds, or the values for an IN list. + pub rows: usize, + /// Values an IN list had to leave out. + pub skipped: usize, +} + +impl ClipboardPayload { + pub(crate) fn plain(text: String) -> Self { + Self { + text, + format: ClipboardFormat::PlainText, + rows: 1, + skipped: 0, + } + } +} diff --git a/linux/crates/app/src/ui/clipboard/clipboard_writer.rs b/linux/crates/app/src/ui/clipboard/clipboard_writer.rs new file mode 100644 index 0000000000..237d9a41cc --- /dev/null +++ b/linux/crates/app/src/ui/clipboard/clipboard_writer.rs @@ -0,0 +1,78 @@ +use gtk4::prelude::*; +use gtk4::{gdk, glib}; +use thiserror::Error; + +use super::ClipboardPayload; + +#[derive(Debug, Error)] +pub(crate) enum ClipboardError { + #[error("the text holds a NUL character")] + ContainsNul, + #[error("the clipboard refused the copy: {0}")] + Refused(glib::BoolError), +} + +impl ClipboardError { + pub(crate) fn message(&self) -> String { + match self { + Self::ContainsNul => crate::i18n::gettext("Values with a NUL character can't be copied"), + Self::Refused(_) => crate::i18n::gettext("Couldn't copy to the clipboard"), + } + } +} + +/// The text type is a C string, so a NUL would cut the copy short, and +/// a cut copy pasted back would change the value without a word. +pub(super) fn holds_nul(text: &str) -> bool { + text.contains('\0') +} + +/// Put `payload` on the clipboard as its own type and as plain text, so +/// an application that knows the type takes it, and any other gets the +/// text. The caller has already refused text that `holds_nul`. +pub(super) fn write(clipboard: &gdk::Clipboard, payload: ClipboardPayload) -> Result<(), ClipboardError> { + let Some(mime_type) = payload.format.mime_type() else { + clipboard.set_text(&payload.text); + return Ok(()); + }; + let text = payload.text.to_value(); + let bytes = glib::Bytes::from_owned(payload.text.into_bytes()); + let provider = gdk::ContentProvider::new_union(&[ + gdk::ContentProvider::for_bytes(mime_type, &bytes), + gdk::ContentProvider::for_value(&text), + ]); + clipboard.set_content(Some(&provider)).map_err(|error| { + tracing::warn!(%error, "the clipboard refused a copy"); + ClipboardError::Refused(error) + }) +} + +#[cfg(test)] +mod tests { + use gtk4 as gtk; + + use super::*; + use crate::ui::clipboard::ClipboardFormat; + + fn local_text(clipboard: &gdk::Clipboard) -> Option { + clipboard.content()?.value(String::static_type()).ok()?.get().ok() + } + + #[gtk4::test] + fn a_typed_copy_offers_its_type_and_plain_text() { + let clipboard = gtk::Label::new(None).clipboard(); + let payload = ClipboardPayload { + text: "a,b\n1,2\n".to_owned(), + format: ClipboardFormat::Csv, + rows: 1, + skipped: 0, + }; + + write(&clipboard, payload).expect("written"); + + let formats = clipboard.formats(); + assert!(formats.contain_mime_type("text/csv"), "{}", formats.to_str()); + assert!(formats.contains_type(String::static_type()), "{}", formats.to_str()); + assert_eq!(local_text(&clipboard).as_deref(), Some("a,b\n1,2\n")); + } +} diff --git a/linux/crates/app/src/ui/clipboard/copy_renderer.rs b/linux/crates/app/src/ui/clipboard/copy_renderer.rs new file mode 100644 index 0000000000..077d0f7d04 --- /dev/null +++ b/linux/crates/app/src/ui/clipboard/copy_renderer.rs @@ -0,0 +1,222 @@ +use std::sync::Arc; + +use tablepro_core::column::{ColumnInfo, ColumnType, ResultColumn}; +use tablepro_core::dialect::{LiteralError, SqlDialect}; +use tablepro_core::edit::CellInput; +use tablepro_core::export::{ + self, CsvEncoder, CsvOptions, EncodeError, JsonArrayEncoder, MarkdownEncoder, RenderInsertError, RowEncoder, + TsvEncoder, +}; +use tablepro_core::meta::TableRef; +use tablepro_core::value::Value; +use tablepro_session::runtime::{TaskResult, Tasks}; +use thiserror::Error; + +use super::clipboard_writer::holds_nul; +use super::{ClipboardFormat, ClipboardPayload}; +use crate::ui::grid::RowSnapshot; + +/// What a copy holds, taken on the GTK thread and rendered off it. +#[derive(Debug)] +pub enum CopyRequest { + /// Rows as the grid shows them, under its visible columns. + Rows { + columns: Arc<[ResultColumn]>, + rows: Vec, + kind: CopyKind, + }, + Insert(InsertCopy), + /// One column's values as an IN list, in the spelling of the engine + /// the rows came from. + InClause { + dialect: Arc, + values: InClauseRows, + }, +} + +/// One column of the rows a user picked, for an IN list. +#[derive(Debug)] +pub struct InClauseRows { + pub columns: Arc<[ResultColumn]>, + pub rows: Vec, + pub column: usize, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CopyKind { + /// One cell of the first row. + Value(usize), + Rows, + RowsWithHeaders, + Csv, + CsvWithHeaders, + Json, + Markdown, +} + +/// Rows as INSERT statements for the server the tab reads. +#[derive(Debug)] +pub struct InsertCopy { + pub dialect: Arc, + pub table: TableRef, + pub columns: Arc<[ColumnInfo]>, + pub rows: Vec, +} + +#[derive(Debug)] +pub enum InsertRow { + /// A row the table holds, with its pending edits. + Saved(RowSnapshot), + /// A draft's cells as its save would write them, so the columns the + /// server fills stay out of its statement. + Draft(Vec), +} + +#[derive(Debug, Error)] +pub(crate) enum CopyError { + #[error(transparent)] + Encode(#[from] EncodeError), + #[error(transparent)] + Insert(#[from] RenderInsertError), + #[error(transparent)] + Literal(#[from] LiteralError), + /// Every value was NULL or binary, and `()` is not SQL anywhere. + #[error("no value can go in an IN list")] + EmptyInClause, + #[error("the text holds a NUL character")] + ContainsNul, +} + +/// Render `request` on the blocking pool: a copy of every row can take +/// long enough to stall the window. +pub(crate) fn render_copy(tasks: &Tasks, request: CopyRequest) -> TaskResult> { + tasks.spawn_blocking_task(move || render(request)) +} + +pub(crate) fn render(request: CopyRequest) -> Result { + let payload = match request { + CopyRequest::Rows { columns, rows, kind } => render_rows(columns, &rows, kind), + CopyRequest::Insert(insert) => render_insert(insert), + CopyRequest::InClause { dialect, values } => render_in_clause(&*dialect, &values), + }?; + if holds_nul(&payload.text) { + return Err(CopyError::ContainsNul); + } + Ok(payload) +} + +fn render_rows( + columns: Arc<[ResultColumn]>, + rows: &[RowSnapshot], + kind: CopyKind, +) -> Result { + match kind { + CopyKind::Value(column) => value_text(&columns, rows, column).map(ClipboardPayload::plain), + CopyKind::Rows => encode(TsvEncoder::new(columns, false), rows, ClipboardFormat::Tsv), + CopyKind::RowsWithHeaders => encode(TsvEncoder::new(columns, true), rows, ClipboardFormat::Tsv), + CopyKind::Csv => encode( + CsvEncoder::new( + columns, + CsvOptions { + header_row: false, + ..CsvOptions::default() + }, + ), + rows, + ClipboardFormat::Csv, + ), + CopyKind::CsvWithHeaders => encode( + CsvEncoder::new(columns, CsvOptions::default()), + rows, + ClipboardFormat::Csv, + ), + CopyKind::Json => encode(JsonArrayEncoder::new(columns), rows, ClipboardFormat::Json), + CopyKind::Markdown => encode(MarkdownEncoder::new(columns), rows, ClipboardFormat::Markdown), + } +} + +fn render_in_clause(dialect: &dyn SqlDialect, values: &InClauseRows) -> Result { + let InClauseRows { columns, rows, column } = values; + let column_type = columns + .get(*column) + .map_or_else(|| ColumnType::untyped_text("text"), |column| column.column_type.clone()); + let clause = export::render_in_clause(dialect, &column_type, rows.iter().map(|row| row.value(*column)))?; + if clause.sql.is_empty() { + return Err(CopyError::EmptyInClause); + } + Ok(ClipboardPayload { + text: clause.sql, + format: ClipboardFormat::Sql, + rows: rows.len() - clause.skipped, + skipped: clause.skipped, + }) +} + +/// The cell's full text, never the grid's display form, which stands in +/// `` for a NULL and cuts long values. +fn value_text(columns: &[ResultColumn], rows: &[RowSnapshot], column: usize) -> Result { + let Some(value) = rows.first().map(|row| row.value(column)) else { + return Ok(String::new()); + }; + if let Value::Undecodable(_) = value { + return Err(EncodeError::Undecodable { + column: columns + .get(column) + .map_or_else(|| (column + 1).to_string(), |column| column.name.clone()), + } + .into()); + } + Ok(export::cell_text(columns, column, value).unwrap_or_default()) +} + +fn encode( + mut encoder: impl RowEncoder, + rows: &[RowSnapshot], + format: ClipboardFormat, +) -> Result { + let mut out = Vec::new(); + encoder.begin(&mut out); + for row in rows { + encoder.row(&mut out, &mut row.values())?; + } + encoder.finish(&mut out); + Ok(ClipboardPayload { + // Every encoder writes UTF-8, so the lossy branch is never taken. + text: String::from_utf8(out).unwrap_or_else(|error| String::from_utf8_lossy(error.as_bytes()).into_owned()), + format, + rows: rows.len(), + skipped: 0, + }) +} + +fn render_insert(insert: InsertCopy) -> Result { + let InsertCopy { + dialect, + table, + columns, + rows, + } = insert; + let statements = rows + .iter() + .map(|row| match row { + InsertRow::Saved(snapshot) => { + // A row id read after the columns has no place in the + // statement, so it is not copied. + let cells: Vec = snapshot + .values() + .take(columns.len()) + .cloned() + .map(CellInput::Value) + .collect(); + export::render_insert(&*dialect, &table, &columns, &cells) + } + InsertRow::Draft(cells) => export::render_insert(&*dialect, &table, &columns, cells), + }) + .collect::, _>>()?; + Ok(ClipboardPayload { + text: statements.join("\n"), + format: ClipboardFormat::Sql, + rows: rows.len(), + skipped: 0, + }) +} diff --git a/linux/crates/app/src/ui/clipboard/mod.rs b/linux/crates/app/src/ui/clipboard/mod.rs new file mode 100644 index 0000000000..cc8a6a31dd --- /dev/null +++ b/linux/crates/app/src/ui/clipboard/mod.rs @@ -0,0 +1,19 @@ +//! Copies from the grids: rendered off the GTK thread, and put on the +//! clipboard as their own type beside plain text. + +mod clipboard_copy; +mod clipboard_format; +mod clipboard_payload; +mod clipboard_writer; +mod copy_renderer; + +pub(crate) use clipboard_copy::{copy_payload, copy_rows, copy_text}; +pub(crate) use clipboard_format::ClipboardFormat; +pub(crate) use clipboard_payload::ClipboardPayload; +pub(crate) use clipboard_writer::ClipboardError; +#[cfg(test)] +use copy_renderer::render; +pub(crate) use copy_renderer::{CopyError, CopyKind, CopyRequest, InClauseRows, InsertCopy, InsertRow, render_copy}; + +#[cfg(test)] +mod tests; diff --git a/linux/crates/app/src/ui/clipboard/tests.rs b/linux/crates/app/src/ui/clipboard/tests.rs new file mode 100644 index 0000000000..5ec4a36b97 --- /dev/null +++ b/linux/crates/app/src/ui/clipboard/tests.rs @@ -0,0 +1,355 @@ +use std::cell::{Cell, RefCell}; +use std::rc::Rc; +use std::sync::Arc; +use std::time::Duration; + +use gtk4 as gtk; +use gtk4::prelude::*; +use gtk4::{gdk, glib}; +use tablepro_core::Value; +use tablepro_core::column::{ColumnDefault, ColumnInfo, ColumnType, ResultColumn}; +use tablepro_core::edit::CellInput; +use tablepro_core::export::{CsvOptions, render_csv}; +use tablepro_core::meta::TableRef; +use tablepro_test_support::FakeDialect; + +use super::*; +use crate::test_support::{finish_on_main, test_runtime, wait_until}; +use crate::ui::grid::RowSnapshot; + +const WAIT: Duration = Duration::from_secs(30); + +fn columns(names: &[&str]) -> Arc<[ResultColumn]> { + names + .iter() + .map(|name| ResultColumn::new(*name, ColumnType::untyped_text("text"))) + .collect() +} + +fn snapshots(rows: &[Vec]) -> Vec { + rows.iter() + .map(|row| RowSnapshot::new(row.clone().into(), Vec::new())) + .collect() +} + +fn people() -> Vec> { + vec![ + vec![Value::Int(-5), Value::Text("=cmd".into())], + vec![Value::Int(7), Value::Text("Ada, \"the first\"".into())], + ] +} + +fn rows_request(rows: &[Vec], kind: CopyKind) -> CopyRequest { + CopyRequest::Rows { + columns: columns(&["id", "name"]), + rows: snapshots(rows), + kind, + } +} + +fn local_text(clipboard: &gdk::Clipboard) -> Option { + clipboard.content()?.value(String::static_type()).ok()?.get().ok() +} + +/// Copy through a widget the way a tab does, and wait for the toast +/// that says it landed. +fn copied(request: CopyRequest) -> (gdk::Clipboard, Vec) { + let widget = gtk::Label::new(None); + let clipboard = widget.clipboard(); + clipboard.set_text("before"); + let runtime = test_runtime(); + let toasts = Rc::new(RefCell::new(Vec::new())); + let sink = toasts.clone(); + + copy_rows(&widget, &runtime.tasks(), request, move |text| { + sink.borrow_mut().push(text.to_owned()); + }); + wait_until(WAIT, || !toasts.borrow().is_empty()).expect("the copy to land"); + + runtime.shutdown(); + (clipboard, toasts.take()) +} + +fn column_info(name: &str, primary_key: bool) -> ColumnInfo { + ColumnInfo { + name: name.to_owned(), + column_type: ColumnType::untyped_text("text"), + nullable: !primary_key, + primary_key, + is_auto_increment: false, + is_generated: false, + default: ColumnDefault::None, + comment: None, + } +} + +#[test] +fn copy_renderer_csv_matches_render_csv() { + let rows = people(); + let cases = [ + ( + CopyKind::Csv, + CsvOptions { + header_row: false, + ..CsvOptions::default() + }, + ), + (CopyKind::CsvWithHeaders, CsvOptions::default()), + ]; + for (kind, options) in cases { + let payload = render(rows_request(&rows, kind)).expect("the copy"); + + let expected = render_csv(&columns(&["id", "name"]), &rows, &options).expect("the csv"); + assert_eq!(payload.text, expected, "{kind:?}"); + assert_eq!(payload.format, ClipboardFormat::Csv); + assert_eq!(payload.rows, 2); + } +} + +#[test] +fn copy_insert_uses_visible_columns_only() { + let request = CopyRequest::Insert(InsertCopy { + dialect: Arc::new(FakeDialect), + table: TableRef::new(None, "users"), + columns: vec![column_info("id", true), column_info("name", false)].into(), + rows: vec![ + // A row read with its engine row id after the visible cells. + InsertRow::Saved(RowSnapshot::new( + vec![Value::Int(1), Value::Text("Ada".into()), Value::Text("(0,1)".into())].into(), + Vec::new(), + )), + InsertRow::Draft(vec![CellInput::Default, CellInput::Value(Value::Text("Grace".into()))]), + ], + }); + + let payload = render(request).expect("the copy"); + + assert_eq!( + payload.text, + "INSERT INTO \"users\" (\"id\", \"name\") VALUES (1, 'Ada');\n\ + INSERT INTO \"users\" (\"name\") VALUES ('Grace');" + ); + assert_eq!(payload.format, ClipboardFormat::Sql); + assert_eq!(payload.rows, 2); +} + +/// MySQL reads a backslash in a string as an escape, so a value ending +/// in one would close the string and run the rest as SQL. +#[test] +fn an_in_clause_copy_is_written_in_the_engines_literals() { + let dialect = Arc::new(drivers_mysql::MySqlDialect::new( + drivers_mysql::MySqlFlavour::MySql, + &tablepro_core::ServerInfo::new("MySQL", "8.4.3", 8, 4, 3), + )); + let injected = "\\') OR 1=1 -- "; + let rows = vec![ + vec![Value::Int(1), Value::Text(injected.into())], + vec![Value::Int(2), Value::Null], + ]; + + let payload = render(CopyRequest::InClause { + dialect, + values: InClauseRows { + columns: columns(&["id", "name"]), + rows: snapshots(&rows), + column: 1, + }, + }) + .expect("the copy"); + + assert_eq!( + payload.text, + format!( + "(_utf8mb4 X'{}')", + tablepro_core::hex::encode_lower(injected.as_bytes()) + ) + ); + assert_eq!( + (payload.format, payload.rows, payload.skipped), + (ClipboardFormat::Sql, 1, 1) + ); +} + +#[test] +fn a_copy_holding_a_nul_is_refused_off_the_gtk_thread() { + let rows = vec![vec![Value::Int(1), Value::Text("a\0b".into())]]; + + let refused = render(rows_request(&rows, CopyKind::Rows)).expect_err("a NUL"); + + assert!(matches!(refused, CopyError::ContainsNul), "{refused:?}"); +} + +#[gtk4::test] +fn a_slower_earlier_copy_gives_way_to_a_later_one() { + let widget = gtk::Label::new(None); + let clipboard = widget.clipboard(); + let runtime = test_runtime(); + let toasts = Rc::new(RefCell::new(Vec::new())); + let copy = |request| { + let sink = toasts.clone(); + copy_rows(&widget, &runtime.tasks(), request, move |text| { + sink.borrow_mut().push(text.to_owned()); + }) + }; + let slow = copy(rows_request(&many_rows(), CopyKind::Json)); + let fast = copy(rows_request(&people()[..1], CopyKind::Rows)); + finish_on_main(WAIT, fast) + .expect("the later copy to end") + .expect("the later copy"); + assert_eq!(local_text(&clipboard).as_deref(), Some("-5\t'=cmd\n")); + let landed = clipboard.content(); + finish_on_main(WAIT, slow) + .expect("the earlier copy to end") + .expect("the earlier copy"); + + assert_eq!(toasts.take(), ["Copied 1 row"]); + // Other test processes share the X server and can take the + // clipboard meanwhile, which leaves it with no local content. + let now = clipboard.content(); + assert!( + now.is_none() || now == landed, + "the earlier copy replaced the later one" + ); + runtime.shutdown(); +} + +fn many_rows() -> Vec> { + (0..100_000) + .map(|row| vec![Value::Int(row), Value::Text(format!("name {row}"))]) + .collect() +} + +/// A copy written at once, as Copy Query in the history does, while a +/// large copy still renders. +#[gtk4::test] +fn a_copy_still_rendering_gives_way_to_one_written_at_once() { + let widget = gtk::Label::new(None); + let clipboard = widget.clipboard(); + let runtime = test_runtime(); + let toasts = Rc::new(RefCell::new(Vec::new())); + let sink = toasts.clone(); + + let slow = copy_rows( + &widget, + &runtime.tasks(), + rows_request(&many_rows(), CopyKind::Json), + move |text| sink.borrow_mut().push(text.to_owned()), + ); + copy_text(&widget, "SELECT 1".to_owned(), |text| { + toasts.borrow_mut().push(text.to_owned()) + }); + let landed = clipboard.content(); + finish_on_main(WAIT, slow) + .expect("the earlier copy to end") + .expect("the earlier copy"); + + assert_eq!(toasts.take(), ["Copied to clipboard"]); + let now = clipboard.content(); + assert!( + now.is_none() || now == landed, + "the earlier copy replaced the later one" + ); + runtime.shutdown(); +} + +/// A text field copies through GTK, past every ticket. +#[gtk4::test] +fn a_copy_still_rendering_gives_way_to_a_text_field_copy() { + let widget = gtk::Label::new(None); + let clipboard = widget.clipboard(); + let runtime = test_runtime(); + let toasts = Rc::new(RefCell::new(Vec::new())); + let sink = toasts.clone(); + let field = gtk::Text::new(); + field.set_text("typed"); + field.select_region(0, -1); + + let slow = copy_rows( + &widget, + &runtime.tasks(), + rows_request(&many_rows(), CopyKind::Json), + move |text| sink.borrow_mut().push(text.to_owned()), + ); + field.emit_by_name::<()>("copy-clipboard", &[]); + assert_eq!(local_text(&clipboard).as_deref(), Some("typed")); + let landed = clipboard.content(); + finish_on_main(WAIT, slow) + .expect("the earlier copy to end") + .expect("the earlier copy"); + + assert!(toasts.borrow().is_empty(), "{:?}", toasts.borrow()); + let now = clipboard.content(); + assert!( + now.is_none() || now == landed, + "the earlier copy replaced the text field's" + ); + runtime.shutdown(); +} + +#[gtk4::test] +fn copy_as_csv_offers_text_csv_and_plain_text() { + let (clipboard, toasts) = copied(rows_request(&people(), CopyKind::CsvWithHeaders)); + + let formats = clipboard.formats(); + assert!(formats.contain_mime_type("text/csv"), "{}", formats.to_str()); + assert!(formats.contains_type(String::static_type()), "{}", formats.to_str()); + assert_eq!( + local_text(&clipboard).as_deref(), + Some("\"id\",\"name\"\n\"-5\",\"'=cmd\"\n\"7\",\"Ada, \"\"the first\"\"\"\n") + ); + assert_eq!(toasts, ["Copied 2 rows"]); +} + +#[gtk4::test] +fn copy_rows_offers_tab_separated_values() { + let (clipboard, toasts) = copied(rows_request(&people(), CopyKind::Rows)); + + let formats = clipboard.formats(); + assert!( + formats.contain_mime_type("text/tab-separated-values"), + "{}", + formats.to_str() + ); + assert_eq!( + local_text(&clipboard).as_deref(), + Some("-5\t'=cmd\n7\t\"Ada, \"\"the first\"\"\"\n") + ); + assert_eq!(toasts, ["Copied 2 rows"]); +} + +#[gtk4::test] +fn copy_as_json_offers_application_json() { + let (clipboard, _) = copied(rows_request(&people(), CopyKind::Json)); + + let formats = clipboard.formats(); + assert!(formats.contain_mime_type("application/json"), "{}", formats.to_str()); + let text = local_text(&clipboard).expect("the copy"); + let parsed: serde_json::Value = serde_json::from_str(&text).expect("JSON"); + assert_eq!(parsed[1]["name"], serde_json::json!("Ada, \"the first\"")); +} + +#[gtk4::test] +fn large_copy_renders_on_blocking_pool() { + let rows: Vec> = (0..200_000) + .map(|row| vec![Value::Int(row), Value::Text(format!("name {row}"))]) + .collect(); + let ticks = Rc::new(Cell::new(0_u32)); + let counted = ticks.clone(); + let ticking = glib::timeout_add_local(Duration::from_millis(1), move || { + counted.set(counted.get() + 1); + glib::ControlFlow::Continue + }); + + let (clipboard, toasts) = copied(rows_request(&rows, CopyKind::Json)); + ticking.remove(); + + assert!( + ticks.get() >= 5, + "the main loop stood still while the copy rendered: {} ticks", + ticks.get() + ); + assert_eq!(toasts, ["Copied 200,000 rows"]); + let parsed: Vec = + serde_json::from_str(&local_text(&clipboard).expect("the copy")).expect("JSON"); + assert_eq!(parsed.len(), 200_000); +} diff --git a/linux/crates/app/src/ui/connect_dialog/mod.rs b/linux/crates/app/src/ui/connect_dialog/mod.rs index 5bcc77c084..6f26def22f 100644 --- a/linux/crates/app/src/ui/connect_dialog/mod.rs +++ b/linux/crates/app/src/ui/connect_dialog/mod.rs @@ -621,7 +621,7 @@ impl ConnectDialog { } fn show_toast(&self, message: &str, remedy: Option) { - let toast = adw::Toast::new(message); + let toast = crate::ui::plain_toast(message); if let Some(remedy) = remedy { toast.set_button_label(Some(&remedy.label())); let weak = self.downgrade(); diff --git a/linux/crates/app/src/ui/connection_row.rs b/linux/crates/app/src/ui/connection_row.rs deleted file mode 100644 index d5ae6e1fcf..0000000000 --- a/linux/crates/app/src/ui/connection_row.rs +++ /dev/null @@ -1,497 +0,0 @@ -use std::path::Path; - -use relm4::adw::prelude::*; -use relm4::factory::{DynamicIndex, FactoryComponent, FactorySender}; -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, - 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, - /// 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. - RequestDuplicate, - /// A colour from the row's own menu, or `None` for the "No colour" - /// entry. - SetColor(Option), - /// A group from the row's own menu, or `None` for "No Group". - SetGroup(Option), - /// "New Group…", which asks for a name before filing the - /// connection under it. - NewGroupRequested, -} - -#[derive(Debug)] -pub enum ConnectionRowOutput { - Open(SavedConnection), - Delete(Uuid), - Duplicate(SavedConnection), - SetColor(Uuid, Option), - SetGroup(Uuid, Option), -} - -#[relm4::factory(pub)] -impl FactoryComponent for ConnectionRow { - type Init = ConnectionRowInit; - type Input = ConnectionRowMsg; - type Output = ConnectionRowOutput; - type CommandOutput = (); - type ParentWidget = gtk::ListBox; - - view! { - adw::ActionRow { - set_title: &self.saved.name, - set_subtitle: &subtitle_for(&self.saved, None), - set_activatable: true, - 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 - // no colour, so an untagged list has no decoration in it. - add_prefix = >k::Box { - set_valign: gtk::Align::Center, - add_css_class: "tp-connection-tag", - set_visible: self.saved.color.is_some(), - set_css_classes: &tag_classes(self.saved.color), - set_tooltip_text: color_label(self.saved.color).as_deref(), - }, - - // One menu rather than a button per action: the row gains - // actions as the app does, and a row of icons stops - // reading as a row. - add_suffix = >k::MenuButton { - set_icon_name: crate::ui::icons::VIEW_MORE, - set_valign: gtk::Align::Center, - set_tooltip_text: Some(crate::i18n::gettext("Connection options").as_str()), - add_css_class: "flat", - set_menu_model: Some(&row_menu(&self.groups)), - }, - } - } - - fn init_model(init: Self::Init, _index: &DynamicIndex, _sender: FactorySender) -> Self { - Self { - saved: init.saved, - groups: init.groups, - tasks: init.tasks, - root: None, - } - } - - fn init_widgets( - &mut self, - _index: &DynamicIndex, - root: Self::Root, - _returned_widget: &::ReturnedWidget, - sender: FactorySender, - ) -> Self::Widgets { - 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.deliver(ConnectionRowMsg::RequestDuplicate)) - .build(); - let remove_sender = sender.clone(); - let remove = gio::ActionEntry::builder("remove") - .activate(move |_, _, _| remove_sender.deliver(ConnectionRowMsg::RequestDelete)) - .build(); - actions.add_action_entries([duplicate, remove]); - - // One stateful action rather than one action per colour, so the - // menu renders as a radio group with the current colour ticked. - let color_sender = sender.clone(); - let color = gio::SimpleAction::new_stateful( - "color", - Some(&String::static_variant_type()), - &color_state(self.saved.color).to_variant(), - ); - color.connect_activate(move |action, parameter| { - let Some(id) = parameter.and_then(|value| value.get::()) else { - return; - }; - action.set_state(&id.to_variant()); - color_sender.deliver(ConnectionRowMsg::SetColor(ConnectionColor::from_id(&id))); - }); - actions.add_action(&color); - - // The same radio shape as the colour, over a set of names that - // comes from the list rather than from a fixed vocabulary. - let group_sender = sender.clone(); - let group = gio::SimpleAction::new_stateful( - "group", - Some(&String::static_variant_type()), - &self.saved.group.clone().unwrap_or_default().to_variant(), - ); - group.connect_activate(move |action, parameter| { - let Some(name) = parameter.and_then(|value| value.get::()) else { - return; - }; - action.set_state(&name.to_variant()); - group_sender.deliver(ConnectionRowMsg::SetGroup(match name.trim().is_empty() { - true => None, - false => Some(name), - })); - }); - actions.add_action(&group); - let new_group_sender = sender.clone(); - let new_group = gio::ActionEntry::builder("new-group") - .activate(move |_, _, _| new_group_sender.deliver(ConnectionRowMsg::NewGroupRequested)) - .build(); - actions.add_action_entries([new_group]); - root.insert_action_group("connection", Some(&actions)); - - widgets - } - - fn update(&mut self, msg: Self::Input, sender: FactorySender) { - match msg { - ConnectionRowMsg::Open => { - let _ = sender.output(ConnectionRowOutput::Open(self.saved.clone())); - } - ConnectionRowMsg::RequestDuplicate => { - let _ = sender.output(ConnectionRowOutput::Duplicate(self.saved.clone())); - } - ConnectionRowMsg::SetColor(color) => { - let _ = sender.output(ConnectionRowOutput::SetColor(self.saved.id, color)); - } - ConnectionRowMsg::SetGroup(group) => { - let _ = sender.output(ConnectionRowOutput::SetGroup(self.saved.id, group)); - } - ConnectionRowMsg::NewGroupRequested => { - let dialog = adw::AlertDialog::new(Some(&crate::i18n::gettext("New Group")), None); - let entry = adw::EntryRow::builder().title(crate::i18n::gettext("Name")).build(); - 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("add", &crate::i18n::gettext("Add")); - dialog.set_response_appearance("add", adw::ResponseAppearance::Suggested); - dialog.set_default_response(Some("add")); - dialog.set_close_response("cancel"); - let id = self.saved.id; - let output = sender.output_sender().clone(); - dialog.connect_response(None, move |_, response| { - if response != "add" { - return; - } - let name = entry.text().to_string(); - if name.trim().is_empty() { - return; - } - let _ = output.send(ConnectionRowOutput::SetGroup(id, Some(name))); - }); - 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 => { - let _ = sender.output(ConnectionRowOutput::Delete(self.saved.id)); - } - } - } -} - -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), - AuthMode::Password => format!("{} · {}@{}:{}", saved.driver_id, saved.username, saved.host, saved.port), - } -} - -/// 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(); - menu.append(Some(&crate::i18n::gettext("Duplicate")), Some("connection.duplicate")); - 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(); - // Named as the dialog it opens names it. - danger.append(Some(&crate::i18n::gettext("Delete…")), Some("connection.remove")); - menu.append_section(None, &danger); - menu -} - -/// The colour choices, as a radio group. "No colour" leads because it -/// is the state every connection starts in. -fn color_menu() -> gio::Menu { - let menu = gio::Menu::new(); - let clear = gio::MenuItem::new(Some(&crate::i18n::gettext("No Colour")), None); - clear.set_action_and_target_value(Some("connection.color"), Some(&NO_COLOR.to_variant())); - menu.append_item(&clear); - let colors = gio::Menu::new(); - for color in ConnectionColor::ALL { - let item = gio::MenuItem::new(Some(&color_name(color)), None); - item.set_action_and_target_value(Some("connection.color"), Some(&color.id().to_variant())); - colors.append_item(&item); - } - menu.append_section(None, &colors); - menu -} - -/// The groups already in the list, as a radio group, with a way to -/// start a new one at the end. A connection whose group is the only -/// one of its kind still appears here, because it is in `groups`. -fn group_menu(groups: &[String]) -> gio::Menu { - let menu = gio::Menu::new(); - let none = gio::MenuItem::new(Some(&crate::i18n::gettext("No Group")), None); - none.set_action_and_target_value(Some("connection.group"), Some(&"".to_variant())); - menu.append_item(&none); - if !groups.is_empty() { - let existing = gio::Menu::new(); - for group in groups { - let item = gio::MenuItem::new(Some(group), None); - item.set_action_and_target_value(Some("connection.group"), Some(&group.to_variant())); - existing.append_item(&item); - } - menu.append_section(None, &existing); - } - let new = gio::Menu::new(); - new.append(Some(&crate::i18n::gettext("New Group…")), Some("connection.new-group")); - menu.append_section(None, &new); - menu -} - -/// The action state standing for "no colour". An empty string rather -/// than a missing state, because the action's parameter type is a -/// string and a radio group needs every entry to carry one. -const NO_COLOR: &str = ""; - -fn color_state(color: Option) -> &'static str { - match color { - Some(color) => color.id(), - None => NO_COLOR, - } -} - -fn color_name(color: ConnectionColor) -> String { - match color { - ConnectionColor::Blue => crate::i18n::gettext("Blue"), - ConnectionColor::Teal => crate::i18n::gettext("Teal"), - ConnectionColor::Green => crate::i18n::gettext("Green"), - ConnectionColor::Yellow => crate::i18n::gettext("Yellow"), - ConnectionColor::Orange => crate::i18n::gettext("Orange"), - ConnectionColor::Red => crate::i18n::gettext("Red"), - ConnectionColor::Pink => crate::i18n::gettext("Pink"), - ConnectionColor::Purple => crate::i18n::gettext("Purple"), - ConnectionColor::Slate => crate::i18n::gettext("Slate"), - } -} - -fn color_label(color: Option) -> Option { - color.map(color_name) -} - -/// The dot's classes. Set as a whole list rather than added one by one -/// so a colour change never leaves the previous colour's class behind. -fn tag_classes(color: Option) -> Vec<&'static str> { - let mut classes = vec!["tp-connection-tag"]; - if let Some(color) = color { - classes.push(match color { - ConnectionColor::Blue => "tp-tag-blue", - ConnectionColor::Teal => "tp-tag-teal", - ConnectionColor::Green => "tp-tag-green", - ConnectionColor::Yellow => "tp-tag-yellow", - ConnectionColor::Orange => "tp-tag-orange", - ConnectionColor::Red => "tp-tag-red", - ConnectionColor::Pink => "tp-tag-pink", - ConnectionColor::Purple => "tp-tag-purple", - ConnectionColor::Slate => "tp-tag-slate", - }); - } - classes -} - -#[cfg(test)] -mod tests { - use super::*; - - fn saved(username: &str, auth_mode: AuthMode) -> SavedConnection { - SavedConnection { - id: Uuid::new_v4(), - name: "Corp".into(), - driver_id: "mssql".into(), - host: "sql.corp.example".into(), - port: 1433, - database: "sales".into(), - username: username.into(), - tls: tablepro_core::TlsConfig::verify_full(), - read_only: false, - auth_mode, - ssh: None, - last_opened_at: None, - color: None, - group: None, - } - } - - /// The labels a menu model carries, in order, for every item and - /// every section it holds. - fn menu_labels(menu: &gio::Menu) -> Vec { - use relm4::gtk::prelude::*; - let mut labels = Vec::new(); - for index in 0..menu.n_items() { - if let Some(label) = menu - .item_attribute_value(index, "label", None) - .and_then(|v| v.get::()) - { - labels.push(label); - } - if let Some(section) = menu.item_link(index, "section").and_downcast::() { - labels.extend(menu_labels(§ion)); - } - } - labels - } - - #[test] - fn a_kerberos_row_has_no_username_separator_to_dangle() { - assert_eq!( - subtitle_for(&saved("", AuthMode::Kerberos), None), - "mssql · sql.corp.example:1433" - ); - assert_eq!( - 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"]); - } - - /// A class with no rule behind it paints nothing, and nothing is - /// exactly what a missing tag looks like. - #[test] - fn every_colour_class_has_a_rule_in_the_stylesheet() { - let css = include_str!("../../../../data/resources/style.css"); - - for color in ConnectionColor::ALL { - let class = tag_classes(Some(color))[1]; - assert!( - css.contains(&format!(".tp-connection-tag.{class} {{")), - "{class} has no rule in style.css" - ); - } - } - - #[test] - fn every_colour_has_a_class_of_its_own() { - let classes: std::collections::HashSet<&str> = ConnectionColor::ALL - .into_iter() - .map(|color| tag_classes(Some(color))[1]) - .collect(); - - assert_eq!(classes.len(), ConnectionColor::ALL.len()); - } - - #[test] - fn the_radio_state_tells_no_colour_apart_from_a_colour() { - assert_eq!(color_state(None), ""); - assert_eq!(color_state(Some(ConnectionColor::Red)), "red"); - assert_eq!( - ConnectionColor::from_id(color_state(Some(ConnectionColor::Red))), - Some(ConnectionColor::Red) - ); - } - - #[test] - fn the_group_menu_offers_every_group_the_list_already_has() { - let menu = group_menu(&["Archive".to_owned(), "Work".to_owned()]); - - let labels = menu_labels(&menu); - assert!(labels.contains(&"Archive".to_owned()), "{labels:?}"); - assert!(labels.contains(&"Work".to_owned()), "{labels:?}"); - } - - #[test] - fn the_group_menu_always_offers_no_group_and_a_new_one() { - let labels = menu_labels(&group_menu(&[])); - - assert_eq!(labels.first().map(String::as_str), Some("No Group")); - assert_eq!(labels.last().map(String::as_str), Some("New Group…")); - } -} diff --git a/linux/crates/app/src/ui/editor/editor_actions.rs b/linux/crates/app/src/ui/editor/editor_actions.rs new file mode 100644 index 0000000000..850cd96317 --- /dev/null +++ b/linux/crates/app/src/ui/editor/editor_actions.rs @@ -0,0 +1,58 @@ +use gtk4::gio; +use gtk4::prelude::*; +use gtk4::subclass::prelude::*; + +use super::find_bar::Direction; +use super::{SqlEditor, toggle_line_comment}; +use crate::application::{ShortcutOwner, local_shortcut_controller}; + +impl SqlEditor { + /// The tab's `editor` group, the source view's keys for it, and the + /// buttons' accessible keys. + pub(super) fn install_actions(&self) { + let actions = gio::SimpleActionGroup::new(); + let entry = |name: &'static str, act: fn(&SqlEditor)| { + let weak = self.downgrade(); + gio::ActionEntry::::builder(name) + .activate(move |_, _, _| { + if let Some(editor) = weak.upgrade() { + act(&editor); + } + }) + .build() + }; + actions.add_action_entries([ + entry("open-file", SqlEditor::open_file), + entry("run", SqlEditor::run), + entry("run-statement", SqlEditor::run_at_cursor), + entry("cancel", SqlEditor::stop), + entry("format", SqlEditor::format), + entry("toggle-comment", |editor| toggle_line_comment(&editor.buffer())), + entry("find", |editor| editor.imp().find_bar.open(false)), + entry("find-replace", |editor| editor.imp().find_bar.open(true)), + entry("find-next", |editor| editor.imp().find_bar.find(Direction::Next)), + entry("find-previous", |editor| { + editor.imp().find_bar.find(Direction::Previous) + }), + ]); + set_cancel_enabled(&actions, false); + self.insert_action_group("editor", Some(&actions)); + self.connect_run_state_changed(move |_, running| set_cancel_enabled(&actions, running)); + + let imp = self.imp(); + imp.source_view + .add_controller(local_shortcut_controller(ShortcutOwner::SqlEditor)); + // On the tab, so the find keys work from the find bar's entries too. + self.add_controller(local_shortcut_controller(ShortcutOwner::SqlEditorTab)); + crate::ui::a11y::announce_key_shortcuts(&imp.run_button.get(), "editor.run"); + crate::ui::a11y::announce_key_shortcuts(&imp.stop_button.get(), "editor.cancel"); + } +} + +/// Off while nothing runs, so Escape goes on to the view and its +/// completion popup. +fn set_cancel_enabled(actions: &gio::SimpleActionGroup, enabled: bool) { + if let Some(cancel) = actions.lookup_action("cancel").and_downcast::() { + cancel.set_enabled(enabled); + } +} diff --git a/linux/crates/app/src/ui/editor/find_bar.rs b/linux/crates/app/src/ui/editor/find_bar.rs new file mode 100644 index 0000000000..91db83a412 --- /dev/null +++ b/linux/crates/app/src/ui/editor/find_bar.rs @@ -0,0 +1,467 @@ +use std::cell::{Cell, OnceCell}; +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 sourceview5::prelude::*; + +use super::find_state::FindStatus; + +mod imp { + use libadwaita::subclass::prelude::*; + + use super::*; + + #[derive(Default, gtk::CompositeTemplate)] + #[template(resource = "/app/tablepro/TablePro/ui/find-bar.ui")] + pub struct FindBar { + #[template_child] + pub search_bar: TemplateChild, + #[template_child] + pub search_entry: TemplateChild, + #[template_child] + pub count_label: TemplateChild, + #[template_child] + pub previous_button: TemplateChild, + #[template_child] + pub next_button: TemplateChild, + #[template_child] + pub options_button: TemplateChild, + #[template_child] + pub replace_toggle: TemplateChild, + #[template_child] + pub replace_entry: TemplateChild, + + pub settings: sourceview5::SearchSettings, + pub context: OnceCell, + pub view: glib::WeakRef, + pub actions: OnceCell, + /// A move asked for while the search was still counting, made + /// once it has counted. + pub(super) pending: Cell>, + } + + #[glib::object_subclass] + impl ObjectSubclass for FindBar { + const NAME: &'static str = "TableProFindBar"; + type Type = super::FindBar; + 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 FindBar { + fn signals() -> &'static [Signal] { + static SIGNALS: OnceLock> = OnceLock::new(); + SIGNALS.get_or_init(|| vec![Signal::builder("toast").param_types([String::static_type()]).build()]) + } + + fn constructed(&self) { + self.parent_constructed(); + self.obj().setup(); + } + } + + impl WidgetImpl for FindBar {} + impl BinImpl for FindBar {} +} + +glib::wrapper! { + /// Find and replace over an editor's buffer, through a + /// GtkSourceSearchContext, as GNOME Text Editor lays it out. + pub struct FindBar(ObjectSubclass) + @extends adw::Bin, gtk::Widget, + @implements gtk::Accessible, gtk::Buildable, gtk::ConstraintTarget; +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum Direction { + Next, + Previous, + /// From the start of the selection, so a match being typed stays + /// selected. + FromSelection, +} + +impl FindBar { + fn setup(&self) { + let imp = self.imp(); + imp.settings.set_wrap_around(true); + imp.search_bar.connect_entry(&imp.search_entry.get()); + for (button, icon, label, action) in [ + ( + imp.previous_button.upcast_ref::(), + crate::ui::icons::GO_UP, + crate::i18n::gettext("Previous Match"), + Some("editor.find-previous"), + ), + ( + imp.next_button.upcast_ref(), + crate::ui::icons::GO_DOWN, + crate::i18n::gettext("Next Match"), + Some("editor.find-next"), + ), + ( + imp.options_button.upcast_ref(), + crate::ui::icons::VIEW_MORE, + crate::i18n::gettext("Search Options"), + None, + ), + ( + imp.replace_toggle.upcast_ref(), + crate::ui::icons::EDIT_FIND_REPLACE, + crate::i18n::gettext("Find and Replace"), + Some("editor.find-replace"), + ), + ] { + button.set_property("icon-name", icon); + crate::ui::a11y::describe_icon_button(button, &label, action); + } + self.install_actions(); + + let bar = self.downgrade(); + imp.search_entry.connect_search_changed(move |entry| { + if let Some(bar) = bar.upgrade() { + bar.on_search_changed(&entry.text()); + } + }); + for (signal, direction) in [ + ("activate", Direction::Next), + ("next-match", Direction::Next), + ("previous-match", Direction::Previous), + ] { + let bar = self.downgrade(); + imp.search_entry.connect_local(signal, false, move |_| { + if let Some(bar) = bar.upgrade() { + bar.find(direction); + } + None + }); + } + let bar = self.downgrade(); + imp.replace_entry.connect_activate(move |_| { + if let Some(bar) = bar.upgrade() { + bar.replace(); + } + }); + // Escape in the entry ends the search through the search bar; the + // view takes the focus back, as Text Editor's does. + let bar = self.downgrade(); + imp.search_bar.connect_search_mode_enabled_notify(move |search_bar| { + let Some(bar) = bar.upgrade() else { + return; + }; + if !search_bar.is_search_mode() { + bar.imp().pending.set(None); + if let Some(view) = bar.imp().view.upgrade() { + view.grab_focus(); + } + } + }); + self.refresh_status(); + } + + fn install_actions(&self) { + let imp = self.imp(); + let actions = gio::SimpleActionGroup::new(); + for property in ["case-sensitive", "at-word-boundaries", "regex-enabled"] { + actions.add_action(&gio::PropertyAction::new(property, &imp.settings, property)); + } + let entry = |name: &'static str, act: fn(&FindBar)| { + let bar = self.downgrade(); + gio::ActionEntry::::builder(name) + .activate(move |_, _, _| { + if let Some(bar) = bar.upgrade() { + act(&bar); + } + }) + .build() + }; + actions.add_action_entries([ + entry("next", |bar| bar.find(Direction::Next)), + entry("previous", |bar| bar.find(Direction::Previous)), + entry("replace", FindBar::replace), + entry("replace-all", FindBar::replace_all), + ]); + self.insert_action_group("find", Some(&actions)); + let _ = imp.actions.set(actions); + } + + /// Search `view`'s buffer. Called once, after the buffer is set. + pub(crate) fn attach(&self, view: &sourceview5::View) { + let imp = self.imp(); + let Ok(buffer) = view.buffer().downcast::() else { + tracing::error!("the editor's view has no source buffer to search"); + return; + }; + imp.view.set(Some(view)); + let context = sourceview5::SearchContext::new(&buffer, Some(&imp.settings)); + imp.search_bar + .bind_property("search-mode-enabled", &context, "highlight") + .sync_create() + .build(); + let bar = self.downgrade(); + context.connect_occurrences_count_notify(move |context| { + if let Some(bar) = bar.upgrade() { + bar.refresh_status(); + if context.occurrences_count() >= 0 + && let Some(direction) = bar.imp().pending.take() + { + bar.move_to_match(context, direction); + } + } + }); + let bar = self.downgrade(); + context.connect_regex_error_notify(move |context| { + if let Some(bar) = bar.upgrade() { + bar.show_regex_error(context.regex_error().as_ref()); + } + }); + let bar = self.downgrade(); + buffer.connect_mark_set(move |buffer, _, mark| { + let Some(bar) = bar.upgrade() else { + return; + }; + let moves_selection = *mark == buffer.get_insert() || *mark == buffer.selection_bound(); + if moves_selection && bar.is_open() { + bar.refresh_status(); + } + }); + let _ = imp.context.set(context); + } + + pub(crate) fn is_open(&self) -> bool { + self.imp().search_bar.is_search_mode() + } + + /// Show the bar with the focus in its entry, taking a selection on + /// one line as the text to find. `replace` shows the replace row too. + pub(crate) fn open(&self, replace: bool) { + let imp = self.imp(); + if let Some(text) = self.single_line_selection() { + imp.search_entry.set_text(&text); + } + if replace { + imp.replace_toggle.set_active(true); + } + imp.search_bar.set_search_mode(true); + imp.search_entry.grab_focus(); + imp.search_entry.select_region(0, -1); + self.refresh_status(); + } + + fn single_line_selection(&self) -> Option { + let buffer = self.imp().context.get()?.buffer(); + let (start, end) = buffer.selection_bounds()?; + let text = buffer.text(&start, &end, false); + (!text.contains('\n')).then(|| text.to_string()) + } + + fn on_search_changed(&self, text: &str) { + let imp = self.imp(); + imp.settings.set_search_text((!text.is_empty()).then_some(text)); + if text.is_empty() { + imp.pending.set(None); + self.refresh_status(); + return; + } + self.find(Direction::FromSelection); + } + + /// Select the next or previous match, wrapping at the ends. + pub(crate) fn find(&self, direction: Direction) { + let imp = self.imp(); + let Some(context) = imp.context.get() else { + return; + }; + if !self.has_search_text() { + return; + } + // The sourceview5 binding of forward_async and backward_async + // returns Ok with unset iterators when nothing matches, so the + // move runs through the synchronous search, which says so. Once + // the context has counted, the buffer is scanned and that search + // only walks its match tags. + if context.occurrences_count() < 0 { + imp.pending.set(Some(direction)); + return; + } + self.move_to_match(context, direction); + } + + fn move_to_match(&self, context: &sourceview5::SearchContext, direction: Direction) { + let buffer = context.buffer(); + let (start, end) = selection(&buffer); + let found = match direction { + Direction::Next => context.forward(&end), + Direction::Previous => context.backward(&start), + Direction::FromSelection => context.forward(&start), + }; + if let Some((match_start, match_end, _wrapped)) = found { + buffer.select_range(&match_start, &match_end); + if let Some(view) = self.imp().view.upgrade() { + view.scroll_to_mark(&buffer.get_insert(), 0.1, false, 0.0, 0.0); + } + } + self.refresh_status(); + } + + /// Replace the selected match and move to the next. A selection that + /// is not a match is only moved from. + pub(crate) fn replace(&self) { + let imp = self.imp(); + let Some(context) = imp.context.get() else { + return; + }; + if !self.status().has_matches() { + return; + } + let buffer = context.buffer(); + let (mut start, mut end) = selection(&buffer); + if context.occurrence_position(&start, &end) <= 0 { + self.find(Direction::Next); + return; + } + if let Err(error) = context.replace(&mut start, &mut end, &imp.replace_entry.text()) { + self.emit_by_name::<()>("toast", &[&error.message().to_owned()]); + return; + } + buffer.place_cursor(&end); + self.find(Direction::Next); + } + + /// Replace every match, as one step Ctrl+Z takes back. + pub(crate) fn replace_all(&self) { + let imp = self.imp(); + let Some(context) = imp.context.get() else { + return; + }; + let status = self.status(); + // The binding asserts when a replace finds nothing, so it runs + // only on a count the context has finished. + let Some(count) = status.occurrences.filter(|_| status.has_matches()) else { + return; + }; + let message = match context.replace_all(&imp.replace_entry.text()) { + Ok(()) => crate::i18n::ngettext_f( + "Replaced {count} occurrence", + "Replaced {count} occurrences", + count, + &[("count", &count.to_string())], + ), + Err(error) => error.message().to_owned(), + }; + self.emit_by_name::<()>("toast", &[&message]); + self.refresh_status(); + } + + pub(crate) fn status(&self) -> FindStatus { + let Some(context) = self.imp().context.get() else { + return FindStatus::default(); + }; + if !self.has_search_text() { + return FindStatus::default(); + } + let (start, end) = selection(&context.buffer()); + FindStatus::from_counts( + context.occurrences_count(), + context.occurrence_position(&start, &end), + context.regex_error().map(|error| error.message().to_owned()), + ) + } + + fn has_search_text(&self) -> bool { + self.imp().settings.search_text().is_some_and(|text| !text.is_empty()) + } + + fn refresh_status(&self) { + let imp = self.imp(); + let status = self.status(); + imp.count_label.set_label(&status.label()); + if let Some(actions) = imp.actions.get() { + for name in ["replace", "replace-all"] { + if let Some(action) = actions.lookup_action(name).and_downcast::() { + action.set_enabled(status.has_matches()); + } + } + } + } + + fn show_regex_error(&self, error: Option<&glib::Error>) { + let entry = self.imp().search_entry.get(); + match error { + Some(error) => { + entry.add_css_class("error"); + entry.set_tooltip_text(Some(error.message())); + } + None => { + entry.remove_css_class("error"); + entry.set_tooltip_text(None); + } + } + self.refresh_status(); + } + + pub(crate) fn connect_toast(&self, f: F) -> glib::SignalHandlerId { + self.connect_closure( + "toast", + false, + glib::closure_local!(move |bar: &Self, text: String| f(bar, text)), + ) + } + + /// What the count label says. + #[cfg(test)] + pub(crate) fn count_text(&self) -> String { + self.imp().count_label.label().to_string() + } + + #[cfg(test)] + pub(crate) fn search_entry(&self) -> gtk::SearchEntry { + self.imp().search_entry.get() + } + + #[cfg(test)] + pub(crate) fn replace_entry(&self) -> gtk::Entry { + self.imp().replace_entry.get() + } + + #[cfg(test)] + pub(crate) fn is_replacing(&self) -> bool { + self.imp().replace_toggle.is_active() + } + + #[cfg(test)] + pub(crate) fn settings(&self) -> sourceview5::SearchSettings { + self.imp().settings.clone() + } + + #[cfg(test)] + pub(crate) fn actions(&self) -> Option { + self.imp().actions.get().cloned() + } + + #[cfg(test)] + pub(crate) fn context(&self) -> Option { + self.imp().context.get().cloned() + } +} + +/// The selection, or the cursor as an empty one. +fn selection(buffer: &impl IsA) -> (gtk::TextIter, gtk::TextIter) { + let buffer = buffer.as_ref(); + buffer.selection_bounds().unwrap_or_else(|| { + let cursor = buffer.iter_at_mark(&buffer.get_insert()); + (cursor, cursor) + }) +} diff --git a/linux/crates/app/src/ui/editor/find_state.rs b/linux/crates/app/src/ui/editor/find_state.rs new file mode 100644 index 0000000000..faaab59fd1 --- /dev/null +++ b/linux/crates/app/src/ui/editor/find_state.rs @@ -0,0 +1,77 @@ +/// What the find bar's count label says about the search. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub(crate) struct FindStatus { + /// `None` while the search is still counting, or with nothing to + /// search for. + pub occurrences: Option, + /// The match the selection is on, counted from 1. `None` when the + /// selection is on none, or the search has not reached it yet. + pub position: Option, + pub regex_error: Option, +} + +impl FindStatus { + /// From GtkSourceSearchContext's counts, where -1 means not counted + /// yet and a position of 0 means the selection is on no match. + pub(crate) fn from_counts(occurrences: i32, position: i32, regex_error: Option) -> Self { + Self { + occurrences: u32::try_from(occurrences).ok(), + position: u32::try_from(position).ok().filter(|position| *position > 0), + regex_error, + } + } + + pub(crate) fn label(&self) -> String { + // The entry itself says what is wrong with the pattern. + if self.regex_error.is_some() { + return String::new(); + } + match self.occurrences { + None => String::new(), + Some(0) => crate::i18n::gettext("No results"), + Some(count) => crate::i18n::gettext_f( + "{position} of {count}", + &[ + ("position", &self.position.unwrap_or(0).to_string()), + ("count", &count.to_string()), + ], + ), + } + } + + /// Whether there is a match to replace, with its count known. + pub(crate) fn has_matches(&self) -> bool { + self.regex_error.is_none() && self.occurrences.is_some_and(|count| count > 0) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn find_status_label_counting_empty_and_position() { + assert_eq!(FindStatus::from_counts(-1, -1, None).label(), "", "while counting"); + assert_eq!(FindStatus::default().label(), "", "with nothing to search for"); + assert_eq!(FindStatus::from_counts(0, 0, None).label(), "No results"); + assert_eq!(FindStatus::from_counts(3, 2, None).label(), "2 of 3"); + assert_eq!( + FindStatus::from_counts(3, 0, None).label(), + "0 of 3", + "the selection is on no match" + ); + assert_eq!( + FindStatus::from_counts(0, 0, Some("missing )".to_owned())).label(), + "", + "a bad pattern" + ); + } + + #[test] + fn only_a_counted_match_can_be_replaced() { + assert!(FindStatus::from_counts(2, 1, None).has_matches()); + assert!(!FindStatus::from_counts(-1, -1, None).has_matches()); + assert!(!FindStatus::from_counts(0, 0, None).has_matches()); + assert!(!FindStatus::from_counts(2, 0, Some("bad".to_owned())).has_matches()); + } +} diff --git a/linux/crates/app/src/ui/editor/imp.rs b/linux/crates/app/src/ui/editor/imp.rs index 5347e21c0a..2847301e02 100644 --- a/linux/crates/app/src/ui/editor/imp.rs +++ b/linux/crates/app/src/ui/editor/imp.rs @@ -11,6 +11,7 @@ use tokio_util::sync::CancellationToken; use super::EditorDeps; use super::editor_session_slot::EditorSessionSlot; +use super::find_bar::FindBar; use super::results_pane::ResultsPane; use super::run_log::RunLog; use crate::ui::grid::GridSink; @@ -35,6 +36,8 @@ pub struct SqlEditor { #[template_child] pub run_button: TemplateChild, #[template_child] + pub find_bar: TemplateChild, + #[template_child] pub source_view: TemplateChild, #[template_child] pub results: TemplateChild, @@ -62,6 +65,7 @@ impl ObjectSubclass for SqlEditor { fn class_init(klass: &mut Self::Class) { sourceview5::View::ensure_type(); + FindBar::ensure_type(); ResultsPane::ensure_type(); klass.bind_template(); } @@ -82,11 +86,6 @@ 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([glib::BoxedAnyObject::static_type()]) - .build(), Signal::builder("toast").param_types([String::static_type()]).build(), Signal::builder("undoable-toast") .param_types([String::static_type()]) diff --git a/linux/crates/app/src/ui/editor/mod.rs b/linux/crates/app/src/ui/editor/mod.rs index 2763b3ff78..e6ca1f9e55 100644 --- a/linux/crates/app/src/ui/editor/mod.rs +++ b/linux/crates/app/src/ui/editor/mod.rs @@ -4,11 +4,13 @@ mod buffer_edit; mod completion; +mod editor_actions; mod editor_session_slot; +mod find_bar; +mod find_state; mod format_plan; mod imp; mod result_item; -mod result_rows; mod result_widgets; mod results_pane; mod run_history; @@ -35,16 +37,19 @@ use libadwaita::prelude::*; use sourceview5::prelude::*; use tablepro_core::dialect::SqlDialect; use tablepro_core::sql_syntax::SqlGrammar; -use tablepro_core::{QueryResult, RowLimit}; +use tablepro_core::{RowLimit, UserSql}; use tablepro_session::runtime::{ProcessHold, Tasks}; use tokio_util::sync::CancellationToken; use self::buffer_edit::{CursorAnchor, replace_all_undoable}; use self::imp::RunningScript; -use self::run_log::{RunEvent, RunLog}; +use self::result_widgets::PageActions; +use self::run_log::{EntryKind, RunEvent, RunLog}; use self::run_scope::ScopedText; use self::script_runner::{ScriptRun, plan_batches, run_in_tab}; -use super::grid::{GridMsg, GridSink}; +use super::clipboard::{CopyRequest, InClauseRows}; +use super::export::{ExportOffer, ExportOrigin, ExportQuery, ExportSource}; +use super::grid::{GridMsg, GridSink, present_row_json}; use super::handler_guard::HandlerGuard; use crate::services::active_session::ActiveSession; use crate::services::history_service::HistoryService; @@ -97,11 +102,17 @@ impl SqlEditor { // The text the tab opens with is not an edit to take back. buffer.set_text(&initial_text); imp.source_view.set_buffer(Some(&buffer)); + imp.find_bar.attach(&imp.source_view); + let toasts = editor.downgrade(); + imp.find_bar.connect_toast(move |_, text| { + if let Some(editor) = toasts.upgrade() { + editor.emit_by_name::<()>("toast", &[&text]); + } + }); editor.connect_theme(&init.settings); editor.connect_completion(&buffer); editor.connect_buffer(&buffer); editor.install_actions(); - editor.install_shortcuts(); editor.install_drop_target(); editor.connect_buttons(); editor.connect_grid(); @@ -272,16 +283,6 @@ impl SqlEditor { ) } - pub fn connect_copy_to_clipboard(&self, f: F) -> glib::SignalHandlerId { - self.connect_closure( - "copy-to-clipboard", - false, - glib::closure_local!(move |editor: &Self, text: glib::BoxedAnyObject| { - f(editor, text.borrow::().clone()); - }), - ) - } - pub fn connect_toast(&self, f: F) -> glib::SignalHandlerId { self.connect_closure( "toast", @@ -299,13 +300,12 @@ impl SqlEditor { ) } - pub fn connect_export_results(&self, f: F) -> glib::SignalHandlerId { + pub(crate) fn connect_export_results(&self, f: F) -> glib::SignalHandlerId { self.connect_closure( "export-results", false, glib::closure_local!(move |editor: &Self, payload: glib::BoxedAnyObject| { - let (result, name) = payload.borrow::<(QueryResult, String)>().clone(); - f(editor, result, name); + f(editor, payload.borrow::().clone()); }), ) } @@ -364,7 +364,7 @@ impl SqlEditor { self.say(&crate::i18n::gettext("There is nothing to run.")); return; } - let previews = batches.iter().map(|batch| batch.preview.clone()).collect(); + let statements = batches.iter().map(|batch| UserSql::new(batch.text.as_str())).collect(); let run = ScriptRun { batches, policy, @@ -394,7 +394,7 @@ impl SqlEditor { imp.results.set_summary(&crate::i18n::gettext("Running…")); imp.run_button.set_sensitive(false); imp.stop_button.set_visible(true); - imp.log.replace(Some(RunLog::new(previews, warnings))); + imp.log.replace(Some(RunLog::new(statements, warnings))); self.show_new_entries(0); self.emit_by_name::<()>("run-state-changed", &[&true]); @@ -446,12 +446,20 @@ impl SqlEditor { let grid_sink = imp.grid_sink.get()?; let log = imp.log.borrow(); let entry = log.as_ref()?.entries().get(usize::try_from(index).ok()?)?; - let weak = self.downgrade(); - Some(result_widgets::entry_widget(entry, grid_sink, move |position| { - if let Some(editor) = weak.upgrade() { - editor.go_to_char(position); - } - })) + let (errors, exports) = (self.downgrade(), self.downgrade()); + let actions = PageActions { + go_to_error: move |position| { + if let Some(editor) = errors.upgrade() { + editor.go_to_char(position); + } + }, + export_all: move |shown| { + if let Some(editor) = exports.upgrade() { + editor.offer_export(index, shown); + } + }, + }; + Some(result_widgets::entry_widget(entry, grid_sink, actions)) } fn finish_run( @@ -619,14 +627,21 @@ impl SqlEditor { return; }; match message { - GridMsg::CopyToClipboard(text) => { - editor.emit_by_name::<()>("copy-to-clipboard", &[&glib::BoxedAnyObject::new(text)]); + GridMsg::Copy(request) => editor.copy(request), + GridMsg::CopyInClause(values) => editor.copy_in_clause(values), + GridMsg::ShowRowJson(request) => { + if let Some(tasks) = editor.deps().map(|deps| deps.tasks.clone()) { + let parent = editor.clone().upcast(); + glib::spawn_future_local(async move { + present_row_json(parent, tasks, request).await; + }); + } } GridMsg::ShowToast(text) => editor.emit_by_name::<()>("toast", &[&text]), - GridMsg::ExportResults(result) => { - let name = export_name_for_query(&editor.selected_preview()); - let payload = glib::BoxedAnyObject::new((result, name)); - editor.emit_by_name::<()>("export-results", &[&payload]); + GridMsg::ExportResults(shown) => { + if let Some(index) = editor.imp().results.selected() { + editor.offer_export(index, shown); + } } _ => {} } @@ -634,74 +649,61 @@ impl SqlEditor { let _ = self.imp().grid_sink.set(sink); } - /// The statement behind the result on screen, to name its export. - fn selected_preview(&self) -> String { - let imp = self.imp(); - let index = imp.results.selected().unwrap_or(0) as usize; - imp.log - .borrow() - .as_ref() - .and_then(|log| log.entries().get(index).map(|entry| entry.preview.clone())) - .unwrap_or_default() + /// Render a copy from a result grid off the GTK thread, and toast + /// how it went. + fn copy(&self, request: CopyRequest) { + let Some(tasks) = self.deps().map(|deps| deps.tasks.clone()) else { + return; + }; + let editor = self.downgrade(); + crate::ui::clipboard::copy_rows(self, &tasks, request, move |text| { + if let Some(editor) = editor.upgrade() { + editor.emit_by_name::<()>("toast", &[&text]); + } + }); } - fn install_actions(&self) { - let actions = gio::SimpleActionGroup::new(); - let weak = self.downgrade(); - let open = gio::ActionEntry::builder("open-file") - .activate(move |_: &gio::SimpleActionGroup, _, _| { - if let Some(editor) = weak.upgrade() { - editor.open_file(); - } - }) - .build(); - let weak = self.downgrade(); - let format = gio::ActionEntry::builder("format") - .activate(move |_: &gio::SimpleActionGroup, _, _| { - if let Some(editor) = weak.upgrade() { - editor.format(); - } - }) - .build(); - actions.add_action_entries([open, format]); - self.insert_action_group("editor", Some(&actions)); - } - - fn install_shortcuts(&self) { - let controller = gtk4::ShortcutController::new(); - let shortcut = |key, modifiers, act: fn(&SqlEditor)| { - let weak = self.downgrade(); - gtk4::Shortcut::builder() - .trigger(>k4::KeyvalTrigger::new(key, modifiers)) - .action(>k4::CallbackAction::new(move |_, _| { - if let Some(editor) = weak.upgrade() { - act(&editor); - } - glib::Propagation::Stop - })) - .build() + /// An IN list in the spelling of the server the editor runs on. + fn copy_in_clause(&self, values: InClauseRows) { + let dialect = self + .deps() + .and_then(|deps| deps.session.get()) + .and_then(|session| session.current_dialect()); + match dialect { + Some(dialect) => self.copy(CopyRequest::InClause { dialect, values }), + None => self.emit_by_name::<()>("toast", &[&crate::i18n::gettext("Connect to a database first.")]), + } + } + + /// Ask the window to export the result on line `index`: the rows + /// its grid shows, or every row its statement returns when run + /// again. + fn offer_export(&self, index: u32, shown: ExportSource) { + let Some(offer) = self.export_offer(index, shown) else { + return; }; - let control = gtk4::gdk::ModifierType::CONTROL_MASK; - let control_shift = control | gtk4::gdk::ModifierType::SHIFT_MASK; - controller.add_shortcut(shortcut(gtk4::gdk::Key::Return, control, SqlEditor::run)); - // Esc stops a running query; the editor is not a dialog, so Esc - // is otherwise free, and it is the key a user reaches for when a - // query runs away. - controller.add_shortcut(shortcut( - gtk4::gdk::Key::Escape, - gtk4::gdk::ModifierType::empty(), - SqlEditor::stop, - )); - controller.add_shortcut(shortcut(gtk4::gdk::Key::f, control_shift, SqlEditor::format)); - controller.add_shortcut(shortcut( - gtk4::gdk::Key::Return, - control_shift, - SqlEditor::run_at_cursor, - )); - controller.add_shortcut(shortcut(gtk4::gdk::Key::slash, control, |editor| { - toggle_line_comment(&editor.buffer()); - })); - self.imp().source_view.add_controller(controller); + self.emit_by_name::<()>("export-results", &[&glib::BoxedAnyObject::new(offer)]); + } + + fn export_offer(&self, index: u32, shown: ExportSource) -> Option { + let log = self.imp().log.borrow(); + let entry = log.as_ref()?.entries().get(usize::try_from(index).ok()?)?; + let EntryKind::ResultSet(result) = &entry.kind else { + return None; + }; + Some(ExportOffer { + origin: ExportOrigin::Editor { + shown, + query: ExportQuery::Editor { + sql: result.statement.clone(), + }, + shown_rows: result.row_count() as u64, + truncated: result.summary.is_truncated(), + }, + session: self.deps().and_then(|deps| deps.session.get()), + name: export_name_for_query(&entry.preview), + target: None, + }) } fn install_drop_target(&self) { @@ -785,10 +787,10 @@ fn show_cursor_position(label: >k4::Label, buffer: &sourceview5::Buffer) { fn label_run_button(button: >k4::Button, has_selection: bool) { if has_selection { button.set_label(&crate::i18n::gettext("Run Selection")); - button.set_tooltip_text(Some(&crate::i18n::gettext("Run the selected text (Ctrl+Return)"))); + button.set_tooltip_text(Some(&crate::i18n::gettext("Run the selected text"))); } else { button.set_label(&crate::i18n::gettext("Run")); - button.set_tooltip_text(Some(&crate::i18n::gettext("Run the script (Ctrl+Return)"))); + button.set_tooltip_text(Some(&crate::i18n::gettext("Run the script"))); } } diff --git a/linux/crates/app/src/ui/editor/result_rows.rs b/linux/crates/app/src/ui/editor/result_rows.rs deleted file mode 100644 index 6fc810883c..0000000000 --- a/linux/crates/app/src/ui/editor/result_rows.rs +++ /dev/null @@ -1,29 +0,0 @@ -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 058fa46c65..cfcfe63889 100644 --- a/linux/crates/app/src/ui/editor/result_widgets.rs +++ b/linux/crates/app/src/ui/editor/result_widgets.rs @@ -2,41 +2,33 @@ use gtk4::prelude::*; use libadwaita as adw; use super::run_log::{EntryKind, RunLogEntry}; -use crate::ui::grid::{GridContent, GridSink, RowObject, TabGridContext, build_column_view}; +use crate::ui::export::ExportSource; +use crate::ui::grid::GridSink; use crate::ui::icons; +use crate::ui::result_pane::result_set_page; -/// The widget a results list line shows. `go_to_error` puts the editor -/// cursor where the server says a statement went wrong. -pub fn entry_widget(entry: &RunLogEntry, grid_sink: &GridSink, go_to_error: impl Fn(usize) + 'static) -> gtk4::Widget { +/// What a page's buttons reach in the editor. +pub struct PageActions { + /// Put the editor cursor where the server says a statement went + /// wrong. + pub go_to_error: G, + /// Offer every row of a result the row limit cut short, with the + /// rows its grid shows. + pub export_all: E, +} + +/// The widget a results list line shows. +pub fn entry_widget(entry: &RunLogEntry, grid_sink: &GridSink, actions: PageActions) -> gtk4::Widget +where + G: Fn(usize) + 'static, + E: Fn(ExportSource) + 'static, +{ + let PageActions { + go_to_error, + export_all, + } = actions; match &entry.kind { - EntryKind::ResultSet { result, total_rows } => { - 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) - .vexpand(true) - .build(); - let footer = gtk4::Label::builder() - .label(row_count_text(result.row_count(), result.truncated, *total_rows)) - .xalign(0.0) - .margin_start(12) - .margin_end(12) - .margin_top(4) - .margin_bottom(4) - .build(); - footer.add_css_class("dim-label"); - footer.add_css_class("caption"); - let page = gtk4::Box::builder().orientation(gtk4::Orientation::Vertical).build(); - page.append(&scrolled); - page.append(&footer); - page.upcast() - } + EntryKind::ResultSet(result) => result_set_page(result, grid_sink.clone(), export_all), EntryKind::Command { rows_affected } => { let title = match rows_affected { Some(rows) => crate::i18n::ngettext_f( @@ -115,26 +107,6 @@ fn status_page(title: &str, description: &str, icon: &str) -> adw::StatusPage { .build() } -fn row_count_text(rows: usize, truncated: bool, total: Option) -> String { - let shown = rows.to_string(); - match (truncated, total) { - (true, Some(total)) => crate::i18n::gettext_f( - "Showing the first {shown} of {total} rows", - &[("shown", &shown), ("total", &total.to_string())], - ), - (true, None) => crate::i18n::gettext_f( - "Showing the first {shown} rows. The query returned more.", - &[("shown", &shown)], - ), - (false, _) => crate::i18n::ngettext_f( - "{n} row", - "{n} rows", - u32::try_from(rows).unwrap_or(u32::MAX), - &[("n", &shown)], - ), - } -} - #[cfg(test)] mod tests { use super::*; @@ -143,6 +115,13 @@ mod tests { std::rc::Rc::new(|_| {}) } + fn actions(go_to_error: G) -> PageActions { + PageActions { + go_to_error, + export_all: |_| {}, + } + } + #[gtk4::test] fn markup_in_error_is_rendered_literally() { let entry = RunLogEntry { @@ -154,7 +133,7 @@ mod tests { preview: String::new(), }; - let widget = entry_widget(&entry, &sink(), |_| {}); + let widget = entry_widget(&entry, &sink(), actions(|_| {})); let page = widget.downcast::().expect("a status page"); assert_eq!( @@ -176,18 +155,11 @@ mod tests { let reached = std::rc::Rc::new(std::cell::Cell::new(None)); let seen = reached.clone(); - let widget = entry_widget(&entry, &sink(), move |position| seen.set(Some(position))); + let widget = entry_widget(&entry, &sink(), actions(move |position| seen.set(Some(position)))); let page = widget.downcast::().expect("a status page"); let button = page.child().and_downcast::().expect("Go to Error"); button.emit_clicked(); assert_eq!(reached.get(), Some(42)); } - - #[test] - fn a_truncated_set_says_so() { - assert!(row_count_text(10, true, Some(50)).contains("50")); - assert!(row_count_text(10, true, None).contains("more")); - assert_eq!(row_count_text(1, false, None), "1 row"); - } } diff --git a/linux/crates/app/src/ui/editor/results_pane.rs b/linux/crates/app/src/ui/editor/results_pane.rs index 82cae94399..3815d4f72b 100644 --- a/linux/crates/app/src/ui/editor/results_pane.rs +++ b/linux/crates/app/src/ui/editor/results_pane.rs @@ -261,20 +261,8 @@ fn page_name(index: u32) -> String { /// The line's title and icon. pub fn entry_title(index: usize, entry: &RunLogEntry) -> (String, &'static str) { use crate::ui::icons; - let n = (index + 1).to_string(); match &entry.kind { - EntryKind::ResultSet { result, .. } => { - let rows = result.row_count(); - ( - crate::i18n::ngettext_f( - "Result {n} · {rows} row", - "Result {n} · {rows} rows", - u32::try_from(rows).unwrap_or(u32::MAX), - &[("n", &n), ("rows", &rows.to_string())], - ), - icons::VIEW_GRID, - ) - } + EntryKind::ResultSet(result) => (result.summary.tab_label(index), icons::VIEW_GRID), EntryKind::Command { rows_affected: Some(rows), } => ( diff --git a/linux/crates/app/src/ui/editor/run_history.rs b/linux/crates/app/src/ui/editor/run_history.rs index 10fcc3a62c..9d6a60aa6d 100644 --- a/linux/crates/app/src/ui/editor/run_history.rs +++ b/linux/crates/app/src/ui/editor/run_history.rs @@ -51,7 +51,7 @@ mod tests { } fn log_of(entries: Vec, verdict: Verdict) -> RunLog { - let mut log = RunLog::new(vec!["SELECT 2".to_owned()], Vec::new()); + let mut log = RunLog::new(vec![tablepro_core::UserSql::new("SELECT 2")], Vec::new()); log.apply(RunEvent::Batch(BatchReport { batch: 0, entries, diff --git a/linux/crates/app/src/ui/editor/run_log.rs b/linux/crates/app/src/ui/editor/run_log.rs index d25594c525..02dcb41beb 100644 --- a/linux/crates/app/src/ui/editor/run_log.rs +++ b/linux/crates/app/src/ui/editor/run_log.rs @@ -1,18 +1,16 @@ use tablepro_core::sql_syntax::script::BatchErrorPolicy; use tablepro_core::{ DriverError, EditorSessionState, ErrorCategory, ResultEvent, RunEnd, RunSummary, TransactionState, Truncation, + UserSql, }; -use super::result_rows::ResultRows; +use super::script_runner::sql_preview; +use crate::ui::result_pane::{ResultSetView, ResultSummary}; /// One line of the results list. #[derive(Debug, Clone)] pub enum EntryKind { - ResultSet { - result: ResultRows, - /// What the server says the whole set held, when it says. - total_rows: Option, - }, + ResultSet(ResultSetView), /// A statement that returned no rows, with what it changed when the /// server reports that. Command { @@ -96,22 +94,32 @@ pub enum RunEvent { /// Turns the events of one run into results list lines, on the runtime, /// so only finished lines cross to the GTK thread. -#[derive(Debug, Default)] +#[derive(Debug)] pub struct BatchCollector { + /// The batch's text, which each of its result sets keeps. + statement: UserSql, entries: Vec, /// Where each result set of this run sits in `entries`. sets: Vec, } impl BatchCollector { + pub fn new(statement: UserSql) -> Self { + Self { + statement, + entries: Vec::new(), + sets: Vec::new(), + } + } + pub fn record(&mut self, event: ResultEvent) { match event { ResultEvent::ResultSetStarted { columns } => { self.sets.push(self.entries.len()); - self.entries.push(EntryKind::ResultSet { - result: ResultRows::new(columns), - total_rows: None, - }); + self.entries.push(EntryKind::ResultSet(ResultSetView::new( + columns, + self.statement.clone(), + ))); } ResultEvent::ColumnsResolved { set_index, columns } => { if let Some(result) = self.set(set_index) { @@ -119,26 +127,36 @@ impl BatchCollector { } } ResultEvent::Rows(rows) => match self.last_set() { - Some(result) => result.rows.extend(rows.into_iter().map(Into::into)), + Some(result) => { + result.rows.extend(rows.into_iter().map(Into::into)); + result.summary.retained = result.rows.len() as u64; + } // 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"), }, - ResultEvent::RowLimitReached { .. } => { + // Until the set ends and says how far the read went, all + // that is known is that more rows were left behind. + ResultEvent::RowLimitReached { retained } => { if let Some(result) = self.last_set() { - result.truncated = true; + result.summary = ResultSummary { + retained, + total_rows: None, + truncation: Truncation::ServerStopped, + }; } } ResultEvent::ResultSetFinished { - total_rows, truncation, .. + retained, + total_rows, + truncation, } => { - if let Some(EntryKind::ResultSet { - result, - total_rows: total, - }) = self.sets.last().and_then(|index| self.entries.get_mut(*index)) - { - result.truncated |= truncation == Truncation::ReadPastLimit; - *total = total_rows; + if let Some(result) = self.last_set() { + result.summary = ResultSummary { + retained, + total_rows, + truncation, + }; } } ResultEvent::CommandCompleted { rows_affected } => { @@ -197,15 +215,15 @@ impl BatchCollector { (self.entries, verdict, transaction) } - fn set(&mut self, set_index: usize) -> Option<&mut ResultRows> { + fn set(&mut self, set_index: usize) -> Option<&mut ResultSetView> { let index = *self.sets.get(set_index)?; match self.entries.get_mut(index) { - Some(EntryKind::ResultSet { result, .. }) => Some(result), + Some(EntryKind::ResultSet(result)) => Some(result), _ => None, } } - fn last_set(&mut self) -> Option<&mut ResultRows> { + fn last_set(&mut self) -> Option<&mut ResultSetView> { let last = self.sets.len().checked_sub(1)?; self.set(last) } @@ -245,7 +263,8 @@ pub struct RunLog { } impl RunLog { - pub fn new(previews: Vec, warnings: Vec) -> Self { + /// The log of a run of `statements`, one per batch. + pub fn new(statements: Vec, warnings: Vec) -> Self { let entries = warnings .into_iter() .map(|message| RunLogEntry { @@ -255,7 +274,7 @@ impl RunLog { }) .collect(); Self { - previews, + previews: statements.iter().map(|sql| sql_preview(sql.as_str())).collect(), entries, ..Self::default() } @@ -343,23 +362,29 @@ impl RunLog { } /// What the run changed or returned, for the history entry: the - /// rows statements reported changing, else the rows that came back. + /// rows statements reported changing, else every row the results + /// had. A result the row limit cut short with no total makes that + /// unknown, rather than the rows the grid happens to hold. pub fn rows_for_history(&self) -> Option { - let mut affected: Option = None; - let mut returned: u64 = 0; + let mut affected: Option = None; + let mut returned: Option = Some(0); for entry in &self.entries { match &entry.kind { EntryKind::Command { rows_affected: Some(rows), - } => affected = Some(affected.unwrap_or(0).saturating_add(*rows)), - EntryKind::ResultSet { result, .. } => { - returned = returned.saturating_add(result.rows.len() as u64); + } => { + let rows = i64::try_from(*rows).unwrap_or(i64::MAX); + affected = Some(affected.unwrap_or(0).saturating_add(rows)); + } + EntryKind::ResultSet(result) => { + returned = returned + .zip(result.summary.history_row_count()) + .map(|(sum, rows)| sum.saturating_add(rows)); } _ => {} } } - let rows = affected.unwrap_or(returned); - (affected.is_some() || returned > 0).then(|| i64::try_from(rows).unwrap_or(i64::MAX)) + affected.or(returned.filter(|rows| *rows > 0)) } /// The line above the results: how many there are and how long the @@ -417,7 +442,7 @@ mod tests { } fn collect(events: Vec, end: RunEnd) -> (Vec, Verdict) { - let mut collector = BatchCollector::default(); + let mut collector = BatchCollector::new(UserSql::new("SELECT a FROM t")); for event in events { collector.record(event); } @@ -462,16 +487,75 @@ mod tests { assert_eq!(verdict, Verdict::Completed); assert_eq!(entries.len(), 3, "{entries:?}"); - let EntryKind::ResultSet { result, total_rows } = &entries[0] else { + let EntryKind::ResultSet(result) = &entries[0] else { panic!("expected rows: {entries:?}"); }; assert_eq!(result.row_count(), 2); - assert!(result.truncated); - assert_eq!(*total_rows, Some(5)); + assert_eq!( + result.summary, + ResultSummary { + retained: 2, + total_rows: Some(5), + truncation: Truncation::ServerStopped, + } + ); + assert_eq!(result.statement.as_str(), "SELECT a FROM t"); assert!(matches!(&entries[1], EntryKind::Notice { message } if message.contains("vacuumed"))); assert!(matches!(entries[2], EntryKind::Command { rows_affected: Some(4) })); } + #[test] + fn a_set_cut_by_the_limit_and_then_stopped_stays_truncated() { + let (entries, _) = collect( + vec![ + ResultEvent::ResultSetStarted { + columns: columns(&["a"]), + }, + ResultEvent::Rows(vec![vec![Value::Int(1)]]), + ResultEvent::RowLimitReached { retained: 1 }, + ], + RunEnd::Cancelled, + ); + + let EntryKind::ResultSet(result) = &entries[0] else { + panic!("expected rows: {entries:?}"); + }; + assert!(result.summary.is_truncated()); + assert_eq!(result.summary.total_rows, None); + } + + #[test] + fn history_counts_every_row_a_result_had() { + let set = |summary: ResultSummary| { + let mut view = ResultSetView::new(columns(&["a"]), UserSql::new("SELECT a FROM t")); + view.summary = summary; + EntryKind::ResultSet(view) + }; + let capped = ResultSummary { + retained: 100, + total_rows: None, + truncation: Truncation::ServerStopped, + }; + let counted = ResultSummary { + retained: 100, + total_rows: Some(250), + truncation: Truncation::ReadPastLimit, + }; + let history = |entries: Vec| { + let mut log = RunLog::new(vec![UserSql::new("SELECT a FROM t")], Vec::new()); + log.apply(report(0, entries, Verdict::Completed)); + log.rows_for_history() + }; + + assert_eq!(history(vec![set(counted), set(ResultSummary::complete(3))]), Some(253)); + assert_eq!(history(vec![set(counted), set(capped)]), None); + assert_eq!( + history(vec![set(capped), EntryKind::Command { rows_affected: Some(4) }]), + Some(4), + "rows a statement changed are what history records" + ); + } + #[test] fn a_statement_with_nothing_to_say_still_gets_a_line() { let (entries, _) = collect(Vec::new(), RunEnd::Completed); @@ -488,7 +572,7 @@ mod tests { position: Some(8), ..ServerDiagnostics::new(Some(ServerCode::SqlState("42601".to_owned())), "syntax error") }; - let collector = BatchCollector::default(); + let collector = BatchCollector::new(UserSql::new("SELECT")); let (entries, verdict, _) = collector.finish( summary(RunEnd::Failed(DriverError::reported(diagnostics))), @@ -517,12 +601,10 @@ mod tests { }]; assert!(!continues(Verdict::Failed, BatchErrorPolicy::StopScript)); let mut log = RunLog::new( - vec![ - "SELECT 1".into(), - "SELECT boom".into(), - "SELECT 3".into(), - "SELECT 4".into(), - ], + ["SELECT 1", "SELECT boom", "SELECT 3", "SELECT 4"] + .into_iter() + .map(UserSql::new) + .collect(), Vec::new(), ); @@ -552,7 +634,7 @@ mod tests { #[test] fn a_warning_comes_first_and_counts_as_no_result() { - let mut log = RunLog::new(vec!["SELECT 1".into()], vec!["Unterminated string".into()]); + let mut log = RunLog::new(vec![UserSql::new("SELECT 1")], vec!["Unterminated string".into()]); log.apply(report( 0, diff --git a/linux/crates/app/src/ui/editor/script_runner.rs b/linux/crates/app/src/ui/editor/script_runner.rs index d529fb52a4..dc486e4e02 100644 --- a/linux/crates/app/src/ui/editor/script_runner.rs +++ b/linux/crates/app/src/ui/editor/script_runner.rs @@ -27,7 +27,6 @@ pub struct RunBatch { pub repeat: u32, /// Where the batch starts in the buffer, in characters. pub error_base: usize, - pub preview: String, } /// What one press of Run sends, and how. @@ -41,9 +40,10 @@ pub struct ScriptRun { /// The batches of the scoped text and what the plan warns about. /// -/// Each statement goes on its own, as psql, mysql and sqlite3 send it: -/// no multi-statement text reaches the server, so no implicit -/// transaction can roll back statements that already reported success. +/// Each statement goes on its own, as psql, mysql and sqlite3 send it, +/// so no implicit transaction can roll back statements that already +/// reported success. On SQL Server the unit is the `GO` batch, sent +/// whole as sqlcmd sends it. pub fn plan_batches( scoped: &ScopedText, grammar: SqlGrammar, @@ -59,7 +59,6 @@ pub fn plan_batches( text: text.to_owned(), repeat: batch.repeat.get(), error_base: scoped.base_char + text_offsets::byte_to_char(&scoped.text, batch.range.start), - preview: sql_preview(text), }) }) .collect(); @@ -100,7 +99,8 @@ pub async fn run_batches( if cancel.is_cancelled() { break 'batches; } - let collector = Arc::new(Mutex::new(BatchCollector::default())); + let sql = UserSql::new(batch.text.as_str()); + let collector = Arc::new(Mutex::new(BatchCollector::new(sql.clone()))); let options = RunOptions::new(cancel.child_token()) .with_deadline(run.statement_timeout) .with_row_limit(run.row_limit); @@ -108,9 +108,9 @@ pub async fn run_batches( collector: collector.clone(), }); let started = Instant::now(); - let outcome = editor.run(UserSql::new(batch.text.as_str()), options, sink).await; + let outcome = editor.run(sql.clone(), options, sink).await; let elapsed_ms = started.elapsed().as_millis(); - let collected = std::mem::take(&mut *lock(&collector)); + let collected = std::mem::replace(&mut *lock(&collector), BatchCollector::new(sql)); let state = editor.state(); let (entries, verdict, transaction) = collected.finish(outcome, state, batch.error_base); let report = BatchReport { @@ -217,9 +217,9 @@ mod tests { } } - fn script(text: &str, grammar: SqlGrammar) -> (ScriptRun, Vec, Vec) { + fn script(text: &str, grammar: SqlGrammar) -> (ScriptRun, Vec, Vec) { let (batches, policy, warnings) = plan_batches(&scoped(text), grammar, 0); - let previews = batches.iter().map(|batch| batch.preview.clone()).collect(); + let statements = batches.iter().map(|batch| UserSql::new(batch.text.as_str())).collect(); ( ScriptRun { batches, @@ -227,7 +227,7 @@ mod tests { statement_timeout: None, row_limit: RowLimit::EDITOR_DEFAULT, }, - previews, + statements, warnings, ) } @@ -249,11 +249,11 @@ mod tests { grammar: SqlGrammar, cancel: &CancellationToken, ) -> RunLog { - let (run, previews, warnings) = script(text, grammar); + let (run, statements, warnings) = script(text, grammar); let (events, mut received) = mpsc::channel(8); let session: Arc = editor.clone(); let collect = async move { - let mut log = RunLog::new(previews, warnings); + let mut log = RunLog::new(statements, warnings); while let Some(event) = received.recv().await { let finished = matches!(event, RunEvent::Finished); log.apply(event); @@ -460,7 +460,7 @@ mod tests { #[test] fn a_timed_out_run_is_a_stop() { assert!(!continues(Verdict::Stopped, BatchErrorPolicy::ContinueNextBatch)); - let collector = BatchCollector::default(); + let collector = BatchCollector::new(UserSql::new("SELECT pg_sleep(60)")); let (entries, verdict, _) = collector.finish( Ok(tablepro_core::RunSummary { elapsed: Duration::from_secs(30), diff --git a/linux/crates/app/src/ui/editor/tests.rs b/linux/crates/app/src/ui/editor/tests.rs index 5624de85a1..ba1a7714e4 100644 --- a/linux/crates/app/src/ui/editor/tests.rs +++ b/linux/crates/app/src/ui/editor/tests.rs @@ -1,7 +1,8 @@ use std::sync::Arc; use std::time::Duration; -use tablepro_core::{DriverError, EditorSession, TransactionState}; +use tablepro_core::column::{ColumnType, ResultColumn}; +use tablepro_core::{DriverError, EditorSession, ResultEvent, TransactionState, Truncation, Value}; use tablepro_session::Session; use tablepro_session::fakes::{FakeConnector, FakeCredentialSource}; use tablepro_session::operation::DefaultDeadlines; @@ -76,8 +77,12 @@ fn build_editor(runs: Replies, text: &str, history: impl FnOnce(&Tasks) /// An editor whose runs go into a real history under `root`. fn editor_with_history(root: &tempfile::TempDir, text: &str) -> Harness { + editor_with_history_runs(root, answering(), text) +} + +fn editor_with_history_runs(root: &tempfile::TempDir, runs: Replies, text: &str) -> Harness { let paths = tablepro_storage::StoragePaths::under(root.path(), "tablepro-test", "app.tablepro.TablePro.Devel"); - build_editor(answering(), text, |tasks| { + build_editor(runs, text, |tasks| { let preferences = HistoryPreferences { retention_days: tokio::sync::watch::channel(30).1, save: tokio::sync::watch::channel(true).1, @@ -317,18 +322,46 @@ fn a_dropped_file_goes_in_at_the_cursor_as_one_undo_step() { } #[gtk4::test] -fn a_copy_from_a_result_grid_reaches_the_tab_whole() { +fn a_copy_from_a_result_grid_reaches_the_clipboard() { + use tablepro_core::column::{ColumnType, ResultColumn}; + + use tablepro_core::Value; + + use crate::ui::clipboard::{CopyKind, CopyRequest}; + use crate::ui::grid::RowSnapshot; + let harness = editor_with(answering(), "SELECT 1"); - let copied = Rc::new(std::cell::RefCell::new(Vec::new())); - let sink = copied.clone(); + let toasts = Rc::new(std::cell::RefCell::new(Vec::new())); + let sink = toasts.clone(); harness .editor - .connect_copy_to_clipboard(move |_, text| sink.borrow_mut().push(text)); + .connect_toast(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()]); + let clipboard = harness.editor.clipboard(); + clipboard.set_text("before"); + let columns: std::sync::Arc<[ResultColumn]> = ["a", "b"] + .iter() + .map(|name| ResultColumn::new(*name, ColumnType::untyped_text("text"))) + .collect(); + let rows = vec![ + RowSnapshot::new(vec![Value::Int(1), Value::Text("x".into())].into(), Vec::new()), + RowSnapshot::new(vec![Value::Int(2), Value::Text("y".into())].into(), Vec::new()), + ]; + + grid(GridMsg::Copy(CopyRequest::Rows { + columns, + rows, + kind: CopyKind::Rows, + })); + wait_until(WAIT, || !toasts.borrow().is_empty()).expect("the copy to land"); + + assert_eq!(*toasts.borrow(), ["Copied 2 rows".to_owned()]); + assert!(clipboard.formats().contain_mime_type("text/tab-separated-values")); + let text: Option = clipboard + .content() + .and_then(|content| content.value(String::static_type()).ok()) + .and_then(|value| value.get().ok()); + assert_eq!(text.as_deref(), Some("1\tx\n2\ty\n")); } #[gtk4::test] @@ -353,6 +386,14 @@ fn the_icon_buttons_have_accessible_names() { harness.editor.imp().source_view.upcast_ref::(), gtk4::AccessibleProperty::Label )); + assert_eq!( + crate::test_support::unlabelled_icon_buttons(&harness.editor), + Vec::::new() + ); + assert_eq!( + crate::test_support::tooltips_with_keys(&harness.editor), + Vec::::new() + ); } #[gtk4::test] @@ -412,18 +453,133 @@ fn a_run_is_judged_by_the_engine_it_ran_on() { /// What the harness's history holds, newest first. fn recorded(harness: &Harness) -> Vec { + recorded_entries(harness).into_iter().map(|entry| entry.query).collect() +} + +fn recorded_entries(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() + let everything = tablepro_storage::query_history::HistoryQuery::default(); + let search = history.search_page( + &everything, + tablepro_storage::query_history::HistoryPageRequest::first(100), + ); + block_on(&harness.runtime, search).expect("search").entries +} + +/// A result set of `rows` one-column rows, ended the way `truncation` +/// says. +fn result_set(rows: u64, total_rows: Option, truncation: Truncation) -> FakeRun { + let columns: Arc<[ResultColumn]> = vec![ResultColumn::new("n", ColumnType::untyped_text("text"))].into(); + let mut events = vec![ + ResultEvent::ResultSetStarted { columns }, + ResultEvent::Rows((0..rows).map(|row| vec![Value::Int(row as i64)]).collect()), + ]; + if truncation != Truncation::Complete { + events.push(ResultEvent::RowLimitReached { retained: rows }); + } + events.push(ResultEvent::ResultSetFinished { + retained: rows, + total_rows, + truncation, }); - block_on(&harness.runtime, search) - .expect("search") - .into_iter() - .map(|entry| entry.query) - .collect() + FakeRun::completed(events) +} + +/// The banner over the result page on show. +fn shown_banner(harness: &Harness) -> adw::Banner { + let page = harness + .editor + .imp() + .results + .imp() + .stack + .visible_child() + .expect("a result page"); + crate::test_support::first_descendant_of_type::(&page).expect("the page's banner") +} + +#[gtk4::test] +fn truncated_result_reveals_banner_and_complete_result_hides_it() { + let mut runs = Replies::default(); + runs.when_sql_contains("capped", result_set(3, None, Truncation::ServerStopped)); + runs.always(result_set(2, Some(2), Truncation::Complete)); + let harness = editor_with(runs, "SELECT n FROM capped; SELECT n FROM small"); + + run_and_wait(&harness); + let results = &harness.editor.imp().results; + results.select(0); + let capped = shown_banner(&harness); + results.select(1); + let complete = shown_banner(&harness); + + assert!(capped.is_revealed(), "the capped result has no banner"); + assert_eq!(capped.title(), "Showing the first 3 rows"); + assert_eq!(capped.button_label().as_deref(), Some("Export All…")); + assert!(!complete.is_revealed(), "a complete result shows a banner"); +} + +#[gtk4::test] +fn export_all_uses_originating_statement_of_second_result() { + let mut runs = Replies::default(); + runs.always(result_set(3, Some(40), Truncation::ReadPastLimit)); + let harness = editor_with(runs, "SELECT n FROM first;\nSELECT n FROM second WHERE n > 2;"); + let offers = SignalLog::connect(&harness.editor, "export-results", |values| { + values[1] + .get::() + .expect("an offer") + .borrow::() + .clone() + }); + run_and_wait(&harness); + + harness.editor.imp().results.select(1); + shown_banner(&harness).emit_by_name::<()>("button-clicked", &[]); + + let offers = offers.take(); + let [offer] = offers.as_slice() else { + panic!("one offer, not {}", offers.len()); + }; + let ExportOrigin::Editor { + shown, + query: ExportQuery::Editor { sql }, + shown_rows, + truncated, + } = &offer.origin + else { + panic!("not an editor result: {:?}", offer.origin); + }; + assert_eq!(sql.as_str(), "SELECT n FROM second WHERE n > 2"); + assert!(*truncated); + assert_eq!(*shown_rows, 3); + assert_eq!(shown.row_count(), Some(3)); + assert!(offer.session.is_some(), "Export All has no session to read through"); + assert_eq!(offer.origin.preselected(), crate::ui::export::ScopeSource::Query); +} + +#[gtk4::test] +fn history_records_none_for_capped_result() { + let root = tempfile::tempdir().expect("tempdir"); + let mut runs = Replies::default(); + runs.when_sql_contains("capped", result_set(3, None, Truncation::ServerStopped)); + runs.always(result_set(2, Some(9), Truncation::ReadPastLimit)); + let harness = editor_with_history_runs(&root, runs, "SELECT n FROM capped;"); + run_and_wait(&harness); + harness.editor.buffer().set_text("SELECT n FROM counted;"); + run_and_wait(&harness); + + wait_until(WAIT, || recorded_entries(&harness).len() == 2).expect("both runs recorded"); + let entries = recorded_entries(&harness); + let rows = |query: &str| { + entries + .iter() + .find(|entry| entry.query.contains(query)) + .map(|entry| entry.rows_affected) + .expect("the run's entry") + }; + assert_eq!(rows("capped"), None, "a capped result recorded the rows it kept"); + assert_eq!(rows("counted"), Some(9)); } /// The test drives the runtime from outside it, which is the one place @@ -509,3 +665,472 @@ fn export_name_slugs_the_statement() { fn export_name_falls_back_when_there_is_no_statement() { assert_eq!(export_name_for_query(" \n "), crate::i18n::gettext("query-results")); } + +/// The editor in a window. A widget finds its ancestors' action groups +/// once it is rooted, which a key press always is. +struct InWindow(gtk4::Window); + +impl InWindow { + fn new(editor: &SqlEditor) -> Self { + let window = gtk4::Window::new(); + window.set_child(Some(editor)); + Self(window) + } + + /// Shown, and holding the keyboard focus. + fn presented(editor: &SqlEditor) -> Self { + let shown = Self::new(editor); + shown.0.set_default_size(480, 320); + crate::test_support::KeyInjector::focus_only(&shown.0); + wait_until(Duration::from_secs(2), || shown.0.is_active()).expect("the window to take the focus"); + shown + } +} + +impl Drop for InWindow { + fn drop(&mut self) { + self.0.set_child(None::<>k4::Widget>); + self.0.destroy(); + } +} + +fn editor_keys(harness: &Harness) -> gtk4::ShortcutController { + crate::test_support::shortcut_keys::owner_controller( + &harness.editor.imp().source_view.get(), + crate::application::ShortcutOwner::SqlEditor, + ) + .expect("the editor's shortcuts") +} + +/// What `accel` does on the source view, as a key reaching the editor's +/// controller would. `false` when the key goes on. +fn press(harness: &Harness, accel: &str) -> bool { + use crate::test_support::shortcut_keys::{keystroke, shortcuts, trigger_keys}; + let key = keystroke(accel); + let action = shortcuts(&editor_keys(harness)) + .into_iter() + .find(|shortcut| { + shortcut + .trigger() + .is_some_and(|trigger| trigger_keys(&trigger).contains(&key)) + }) + .and_then(|shortcut| shortcut.action()) + .expect("the editor binds the key"); + action.activate( + gtk4::ShortcutActionFlags::empty(), + &harness.editor.imp().source_view.get(), + None, + ) +} + +/// GtkTextView binds Ctrl+/ to select-all, and a bubble controller on +/// the view runs before that binding only while it was added last, so +/// every editor key that a class binds too runs in the capture phase. +#[gtk4::test] +fn editor_keys_that_the_view_binds_run_in_capture() { + use crate::application::{ACCELERATORS, AcceleratorScope, ShortcutOwner}; + use crate::test_support::shortcut_keys::{class_binding_keys, keystroke}; + let harness = editor_with(answering(), "SELECT 1"); + let view = harness.editor.imp().source_view.get(); + let class_keys = class_binding_keys(&view); + let controller = editor_keys(&harness); + + assert!( + class_keys.contains(&keystroke("slash")), + "the view's own bindings were not found, so the check reads nothing" + ); + assert_eq!(controller.propagation_phase(), gtk4::PropagationPhase::Capture); + for entry in ACCELERATORS { + let AcceleratorScope::Local { + owner: ShortcutOwner::SqlEditor, + phase, + } = entry.scope + else { + continue; + }; + if entry + .triggers + .iter() + .any(|accel| class_keys.contains(&keystroke(accel))) + { + assert_eq!(phase, gtk4::PropagationPhase::Capture, "{}", entry.action); + } + } +} + +#[gtk4::test] +fn ctrl_slash_toggles_the_comment() { + let harness = editor_with(answering(), "select 1"); + let _window = InWindow::new(&harness.editor); + + assert!(press(&harness, "slash")); + + assert_eq!(buffer_text(&harness.editor.buffer()), "-- select 1"); + assert!(!harness.editor.buffer().has_selection()); +} + +#[gtk4::test] +fn escape_cancels_only_while_a_query_runs() { + let mut runs = Replies::default(); + runs.always(FakeRun::until_stopped()); + let harness = editor_with(runs, "SELECT pg_sleep(60)"); + let _window = InWindow::new(&harness.editor); + + assert!(!press(&harness, "Escape"), "Escape was taken with nothing running"); + + assert!(press(&harness, "Return")); + assert!(harness.editor.is_running(), "Ctrl+Return did not run"); + wait_until(WAIT, || !harness.fake.texts().is_empty()).expect("the statement to start"); + assert!(press(&harness, "Escape"), "Escape did not cancel the run"); + wait_until(WAIT, || !harness.editor.is_running()).expect("the run to end"); + assert_eq!(harness.fake.stopped_runs(), 1); + assert!(!press(&harness, "Escape"), "Escape stayed taken after the run"); +} + +/// GtkPopoverMenu shows only application accelerators by itself. +#[gtk4::test] +fn the_editor_menu_shows_the_tables_local_keys() { + use crate::application::{ACCELERATORS, AcceleratorScope}; + let harness = editor_with(answering(), "SELECT 1"); + let menu = harness + .editor + .imp() + .menu_button + .menu_model() + .expect("the editor's menu"); + let mut checked = 0; + for section in 0..menu.n_items() { + let Some(items) = menu.item_link(section, "section") else { + continue; + }; + for item in 0..items.n_items() { + let Some(action) = items + .item_attribute_value(item, "action", None) + .and_then(|value| value.get::()) + else { + continue; + }; + let accel = items + .item_attribute_value(item, "accel", None) + .and_then(|value| value.get::()); + let entry = ACCELERATORS.iter().find(|entry| entry.action == action); + let expected = entry + .filter(|entry| matches!(entry.scope, AcceleratorScope::Local { .. })) + .and_then(|entry| entry.triggers.first()) + .map(|accel| (*accel).to_owned()); + assert_eq!(accel, expected, "{action}"); + checked += usize::from(expected.is_some()); + } + } + assert!(checked > 0, "no menu item has a local key, so the check reads nothing"); +} + +fn find_bar(harness: &Harness) -> find_bar::FindBar { + harness.editor.imp().find_bar.get() +} + +/// What `accel` does on the editor tab, as a key reaching the tab's own +/// controller would. +fn press_on_tab(harness: &Harness, accel: &str) -> bool { + use crate::test_support::shortcut_keys::{keystroke, owner_controller, shortcuts, trigger_keys}; + let key = keystroke(accel); + let controller = owner_controller(&harness.editor, crate::application::ShortcutOwner::SqlEditorTab) + .expect("the tab's shortcuts"); + let action = shortcuts(&controller) + .into_iter() + .find(|shortcut| { + shortcut + .trigger() + .is_some_and(|trigger| trigger_keys(&trigger).contains(&key)) + }) + .and_then(|shortcut| shortcut.action()) + .expect("the tab binds the key"); + action.activate(gtk4::ShortcutActionFlags::empty(), &harness.editor, None) +} + +fn select(buffer: >k4::TextBuffer, start: i32, end: i32) { + buffer.select_range(&buffer.iter_at_offset(start), &buffer.iter_at_offset(end)); +} + +fn selected(buffer: >k4::TextBuffer) -> Option<(i32, i32)> { + buffer + .selection_bounds() + .map(|(start, end)| (start.offset(), end.offset())) +} + +/// Open the bar and search for `text`, waiting until the bar shows +/// what the search counted. The count can be read a moment before the +/// context says it changed. +fn search(harness: &Harness, text: &str) -> find_bar::FindBar { + assert!(press_on_tab(harness, "f")); + let bar = find_bar(harness); + bar.search_entry().set_text(text); + wait_until(WAIT, || !bar.count_text().is_empty()).expect("the search to count"); + bar +} + +#[gtk4::test] +fn ctrl_f_reveals_bar_and_prefills_selection() { + let harness = editor_with(answering(), "SELECT * FROM orders JOIN orders_lines"); + let _window = InWindow::new(&harness.editor); + let buffer = harness.editor.buffer(); + select(&buffer, 14, 20); + + assert!(press_on_tab(&harness, "f")); + + let bar = find_bar(&harness); + assert!(bar.is_open()); + assert!(!bar.is_replacing()); + assert_eq!(bar.search_entry().text(), "orders"); + wait_until(WAIT, || bar.count_text() == "1 of 2").expect("the search to count"); + assert_eq!( + bar.context().map(|context| context.is_highlight()), + Some(true), + "matches are not highlighted" + ); +} + +#[gtk4::test] +fn a_selection_over_lines_is_not_searched_for() { + let harness = editor_with(answering(), "SELECT 1;\nSELECT 2;"); + let _window = InWindow::new(&harness.editor); + select(&harness.editor.buffer(), 0, 15); + + assert!(press_on_tab(&harness, "f")); + + assert_eq!(find_bar(&harness).search_entry().text(), ""); +} + +#[gtk4::test] +fn next_selects_second_occurrence() { + let harness = editor_with(answering(), "orders; orders; orders"); + let _window = InWindow::new(&harness.editor); + let buffer = harness.editor.buffer(); + select(&buffer, 0, 6); + let bar = search(&harness, "orders"); + wait_until(WAIT, || selected(&buffer) == Some((0, 6))).expect("the first match selected"); + + assert!(press_on_tab(&harness, "g")); + assert_eq!(selected(&buffer), Some((8, 14))); + assert_eq!(bar.count_text(), "2 of 3"); + + assert!(press_on_tab(&harness, "g")); + assert_eq!(selected(&buffer), Some((0, 6))); + assert!(press_on_tab(&harness, "g")); + assert_eq!(selected(&buffer), Some((16, 22)), "the search did not wrap around"); +} + +/// GtkSearchEntry binds Ctrl+G itself, so in the entry the key reaches +/// the bar as the entry's next-match signal. +#[gtk4::test] +fn the_search_entrys_next_match_moves_to_the_next() { + let harness = editor_with(answering(), "a; b; a"); + let _window = InWindow::new(&harness.editor); + let buffer = harness.editor.buffer(); + buffer.place_cursor(&buffer.start_iter()); + let bar = search(&harness, "a"); + wait_until(WAIT, || selected(&buffer) == Some((0, 1))).expect("the first match selected"); + + bar.search_entry().emit_by_name::<()>("next-match", &[]); + + assert_eq!(selected(&buffer), Some((6, 7))); +} + +#[gtk4::test] +fn replace_all_is_single_undo_step() { + let harness = editor_with(answering(), "x = 1 AND x = 2"); + let _window = InWindow::new(&harness.editor); + let toasts = SignalLog::connect(&harness.editor, "toast", |values| { + values[1].get::().expect("a message") + }); + assert!(press_on_tab(&harness, "h")); + let bar = find_bar(&harness); + assert!(bar.is_replacing(), "Ctrl+H did not show the replace row"); + bar.replace_entry().set_text("y"); + bar.search_entry().set_text("x"); + wait_until(WAIT, || bar.count_text().ends_with("of 2")).expect("the search to count"); + + WidgetExt::activate_action(&bar, "find.replace-all", None).expect("find.replace-all"); + + let buffer = harness.editor.buffer(); + assert_eq!(toasts.take(), ["Replaced 2 occurrences"]); + assert_eq!(buffer_text(&buffer), "y = 1 AND y = 2"); + buffer.undo(); + assert_eq!(buffer_text(&buffer), "x = 1 AND x = 2"); +} + +#[gtk4::test] +fn replace_changes_the_selected_match_and_moves_on() { + let harness = editor_with(answering(), "x; x; x"); + let _window = InWindow::new(&harness.editor); + let buffer = harness.editor.buffer(); + select(&buffer, 3, 4); + let bar = search(&harness, "x"); + bar.replace_entry().set_text("yy"); + + WidgetExt::activate_action(&bar, "find.replace", None).expect("find.replace"); + + assert_eq!(buffer_text(&buffer), "x; yy; x"); + wait_until(WAIT, || selected(&buffer) == Some((7, 8))).expect("the next match selected"); +} + +#[gtk4::test] +fn nothing_to_replace_leaves_the_buffer_alone() { + let harness = editor_with(answering(), "SELECT 1"); + let _window = InWindow::new(&harness.editor); + let bar = search(&harness, "orders"); + assert_eq!(bar.count_text(), "No results"); + + let actions = bar.actions().expect("the find actions"); + assert!( + !actions.is_action_enabled("replace-all"), + "Replace All is on with nothing to replace" + ); + assert!(!actions.is_action_enabled("replace")); + bar.replace_all(); + + assert_eq!(buffer_text(&harness.editor.buffer()), "SELECT 1"); +} + +#[gtk4::test] +fn invalid_regex_marks_entry_error() { + let harness = editor_with(answering(), "SELECT 1"); + let _window = InWindow::new(&harness.editor); + assert!(press_on_tab(&harness, "f")); + let bar = find_bar(&harness); + WidgetExt::activate_action(&bar, "find.regex-enabled", None).expect("find.regex-enabled"); + let entry = bar.search_entry(); + + entry.set_text("("); + wait_until(WAIT, || entry.has_css_class("error")).expect("the entry to show the error"); + assert!(entry.tooltip_text().is_some_and(|text| !text.is_empty())); + assert_eq!(bar.count_text(), ""); + + entry.set_text("SEL+"); + wait_until(WAIT, || !entry.has_css_class("error")).expect("the error to clear"); + assert_eq!(entry.tooltip_text(), None); +} + +#[gtk4::test] +fn case_sensitive_action_toggles_settings() { + let harness = editor_with(answering(), "SELECT 1"); + let _window = InWindow::new(&harness.editor); + let bar = find_bar(&harness); + assert!(!bar.settings().is_case_sensitive()); + + WidgetExt::activate_action(&bar, "find.case-sensitive", None).expect("find.case-sensitive"); + assert!(bar.settings().is_case_sensitive()); + + WidgetExt::activate_action(&bar, "find.at-word-boundaries", None).expect("find.at-word-boundaries"); + assert!(bar.settings().is_at_word_boundaries()); +} + +#[gtk4::test] +fn escape_hides_bar_and_disables_highlight() { + let harness = editor_with(answering(), "SELECT 1"); + let _window = InWindow::new(&harness.editor); + let bar = search(&harness, "SEL"); + let context = bar.context().expect("the search context"); + assert!(context.is_highlight()); + + bar.search_entry().emit_by_name::<()>("stop-search", &[]); + + assert!(!bar.is_open()); + assert!(!context.is_highlight()); +} + +/// A bubble key on the tab reaches it only when no class on the way up +/// from the focus binds it first. The search entry binds Ctrl+G, which +/// the bar follows through the entry's own signals. +#[gtk4::test] +fn find_keys_are_not_class_bindings_inside_the_tab() { + use crate::application::{ACCELERATORS, AcceleratorScope, ShortcutOwner}; + use crate::test_support::shortcut_keys::{class_keys_on_focus_paths, keystroke}; + let harness = editor_with(answering(), "SELECT 1"); + let _window = InWindow::new(&harness.editor); + + let class_keys = class_keys_on_focus_paths(&harness.editor, |widget| { + widget.is::() || widget.is::() + }); + + assert!( + class_keys.contains(&keystroke("slash")), + "the view's own bindings were not found, so the check reads nothing" + ); + let mut checked = 0; + for entry in ACCELERATORS { + let AcceleratorScope::Local { + owner: ShortcutOwner::SqlEditorTab, + phase, + } = entry.scope + else { + continue; + }; + assert_eq!(phase, gtk4::PropagationPhase::Bubble, "{}", entry.action); + for accel in entry.triggers { + assert!( + !class_keys.contains(&keystroke(accel)), + "{accel} of {} never reaches the tab", + entry.action + ); + checked += 1; + } + } + assert_eq!(checked, 4); +} + +/// These press real keys on the X display every test shares; nextest runs +/// them with no other test beside them. +mod real_keys { + use super::*; + + /// A real key press, so the editor's controller meets GtkTextView's own + /// Ctrl+/ binding in the order GTK runs them. + #[gtk4::test] + fn ctrl_slash_toggles_comment_instead_of_selecting_all() { + let harness = editor_with(answering(), "select 1"); + let _window = InWindow::presented(&harness.editor); + let buffer = harness.editor.buffer(); + buffer.place_cursor(&buffer.start_iter()); + harness.editor.imp().source_view.grab_focus(); + + crate::test_support::KeyInjector::connect() + .expect("an X11 display") + .press(gtk4::gdk::Key::slash, gtk4::gdk::ModifierType::CONTROL_MASK) + .expect("the key press"); + + wait_until(Duration::from_secs(2), || buffer_text(&buffer) == "-- select 1") + .expect("Ctrl+/ to comment the line"); + assert!(!buffer.has_selection(), "Ctrl+/ selected the text"); + } + + /// Real keys, so Ctrl+F meets every handler on its way up from the + /// view, and Escape the search entry's own. + #[gtk4::test] + fn ctrl_f_in_the_view_opens_find_and_escape_returns_to_it() { + // The bar slides in, and its entry takes no key until the first + // frame of the slide shows it. + if let Some(settings) = gtk4::Settings::default() { + settings.set_gtk_enable_animations(false); + } + let harness = editor_with(answering(), "select 1"); + let _window = InWindow::presented(&harness.editor); + let view = harness.editor.imp().source_view.get(); + view.grab_focus(); + let keys = crate::test_support::KeyInjector::connect().expect("an X11 display"); + let bar = find_bar(&harness); + let entry = bar.search_entry(); + + keys.press(gtk4::gdk::Key::f, gtk4::gdk::ModifierType::CONTROL_MASK) + .expect("the key press"); + wait_until(Duration::from_secs(2), || { + bar.is_open() && entry.state_flags().contains(gtk4::StateFlags::FOCUS_WITHIN) + }) + .expect("Ctrl+F to open the find bar with the focus in its entry"); + + keys.press(gtk4::gdk::Key::Escape, gtk4::gdk::ModifierType::empty()) + .expect("the key press"); + wait_until(Duration::from_secs(2), || !bar.is_open() && view.has_focus()) + .expect("Escape to close the bar and return to the view"); + } +} diff --git a/linux/crates/app/src/ui/export/document_sink.rs b/linux/crates/app/src/ui/export/document_sink.rs new file mode 100644 index 0000000000..3cd005f209 --- /dev/null +++ b/linux/crates/app/src/ui/export/document_sink.rs @@ -0,0 +1,233 @@ +use std::sync::Arc; + +use async_trait::async_trait; +use tablepro_core::column::ResultColumn; +use tablepro_core::export::RenderError; +use tablepro_core::{ResultEvent, ResultSink, SinkFlow, Value}; +use tablepro_session::runtime::Tasks; +use tokio::sync::mpsc; + +use super::export_format::DocumentWriter; +use super::{DocumentFormat, ExportMessage, ExportStreamFailure}; + +/// Rows a document takes in between progress reports. +const ROWS_PER_PROGRESS: u64 = 4_096; + +/// Writes a result's rows into its document on the blocking pool as +/// they arrive, and hands the finished document on as one chunk. A +/// workbook is a zip archive, which is written whole, but its sheet +/// spills the rows to a temporary file meanwhile, so the result is +/// never held. +pub(crate) struct DocumentSink { + messages: mpsc::Sender, + format: DocumentFormat, + /// The most rows the document can hold. + limit: u64, + tasks: Tasks, + /// Out on the blocking pool while a batch is written, and `None` + /// before the result set starts. + writer: Option, + rows: u64, + result_sets: u32, +} + +impl DocumentSink { + pub(crate) fn new(messages: mpsc::Sender, format: DocumentFormat, tasks: Tasks) -> Self { + Self { + messages, + limit: format.row_limit() as u64, + format, + tasks, + writer: None, + rows: 0, + result_sets: 0, + } + } + + async fn send(&mut self, message: ExportMessage) -> SinkFlow { + match self.messages.send(message).await { + Ok(()) => SinkFlow::Continue, + Err(_) => SinkFlow::ConsumerGone, + } + } + + async fn fail(&mut self, failure: ExportStreamFailure) -> SinkFlow { + self.send(ExportMessage::Failed(failure)).await; + SinkFlow::ConsumerGone + } + + async fn in_pool( + &mut self, + work: impl FnOnce() -> Result + Send + 'static, + ) -> Result { + match self.tasks.spawn_blocking_task(work).await { + Ok(Ok(done)) => Ok(done), + Ok(Err(error)) => Err(self.fail(error.into()).await), + Err(failure) => Err(self.fail(failure.into()).await), + } + } + + async fn start(&mut self, columns: Arc<[ResultColumn]>) -> SinkFlow { + let format = self.format.clone(); + match self.in_pool(move || format.start(columns)).await { + Ok(writer) => { + self.writer = Some(writer); + SinkFlow::Continue + } + Err(flow) => flow, + } + } + + async fn take(&mut self, rows: Vec>) -> SinkFlow { + let Some(mut writer) = self.writer.take() else { + tracing::warn!("rows arrived before their result set started"); + return SinkFlow::ConsumerGone; + }; + let before = self.rows; + let now = before + rows.len() as u64; + // The sheet refuses a row past its last, so the read stops here + // rather than reading the rest. + if now > self.limit { + let limit = self.limit as usize; + return self.fail(ExportStreamFailure::SheetFull { limit }).await; + } + let written = self.in_pool(move || writer.write_rows(&rows).map(|()| writer)).await; + match written { + Ok(writer) => self.writer = Some(writer), + Err(flow) => return flow, + } + self.rows = now; + if before / ROWS_PER_PROGRESS != now / ROWS_PER_PROGRESS { + return self.send(ExportMessage::Progress { rows: now }).await; + } + SinkFlow::Continue + } + + async fn finish(&mut self) -> SinkFlow { + let Some(writer) = self.writer.take() else { + return SinkFlow::Continue; + }; + let rows = self.rows; + let bytes = match self.in_pool(move || writer.finish()).await { + Ok(bytes) => bytes, + Err(flow) => return flow, + }; + if self.send(ExportMessage::Chunk { bytes, rows }).await == SinkFlow::ConsumerGone { + return SinkFlow::ConsumerGone; + } + self.send(ExportMessage::Finished { rows }).await + } +} + +#[async_trait] +impl ResultSink for DocumentSink { + async fn accept(&mut self, event: ResultEvent) -> SinkFlow { + match event { + ResultEvent::ResultSetStarted { columns } => { + self.result_sets += 1; + if self.result_sets > 1 { + return self.fail(ExportStreamFailure::MultipleResultSets).await; + } + self.start(columns).await + } + ResultEvent::Rows(rows) => self.take(rows).await, + ResultEvent::ResultSetFinished { .. } => self.finish().await, + _ => SinkFlow::Continue, + } + } +} + +#[cfg(test)] +mod tests { + use tablepro_core::Truncation; + use tablepro_core::column::ColumnType; + + use super::*; + use crate::test_support::paused_tasks; + + fn started() -> ResultEvent { + ResultEvent::ResultSetStarted { + columns: vec![ResultColumn::new("a", ColumnType::untyped_text("text"))].into(), + } + } + + fn finished() -> ResultEvent { + ResultEvent::ResultSetFinished { + retained: 0, + total_rows: None, + truncation: Truncation::Complete, + } + } + + fn rows(count: usize) -> Vec> { + (0..count).map(|row| vec![Value::Int(row as i64)]).collect() + } + + fn sheet() -> DocumentFormat { + DocumentFormat::Xlsx { + sheet: "rows".to_owned(), + } + } + + #[tokio::test] + async fn a_document_is_rendered_once_its_last_row_is_in() { + let (messages, mut received) = mpsc::channel(16); + let mut sink = DocumentSink::new(messages, sheet(), paused_tasks()); + + sink.accept(started()).await; + sink.accept(ResultEvent::Rows(rows(5_000))).await; + assert_eq!(sink.accept(finished()).await, SinkFlow::Continue); + drop(sink); + + let mut seen = Vec::new(); + while let Some(message) = received.recv().await { + seen.push(message); + } + let [ + ExportMessage::Progress { rows: 5_000 }, + ExportMessage::Chunk { bytes, rows: 5_000 }, + ExportMessage::Finished { rows: 5_000 }, + ] = seen.as_slice() + else { + panic!("{seen:?}"); + }; + assert!(bytes.starts_with(b"PK"), "the workbook is not a zip archive"); + } + + /// Each batch goes into the sheet as it arrives, so the sink holds + /// no rows between batches. + #[tokio::test] + async fn rows_are_written_into_the_sheet_as_they_arrive() { + let (messages, _received) = mpsc::channel(16); + let mut sink = DocumentSink::new(messages, sheet(), paused_tasks()); + + sink.accept(started()).await; + sink.accept(ResultEvent::Rows(rows(300))).await; + sink.accept(ResultEvent::Rows(rows(200))).await; + + let Some(DocumentWriter::Xlsx(writer)) = sink.writer.as_ref() else { + panic!("no sheet is being written"); + }; + assert_eq!(writer.rows(), 500); + } + + #[tokio::test] + async fn a_sheet_stops_the_read_once_it_is_full() { + let (messages, mut received) = mpsc::channel(16); + let mut sink = DocumentSink::new(messages, sheet(), paused_tasks()); + let limit = 10; + sink.limit = limit as u64; + + sink.accept(started()).await; + let flow = sink.accept(ResultEvent::Rows(rows(limit + 1))).await; + + assert_eq!(flow, SinkFlow::ConsumerGone); + drop(sink); + let mut full = false; + while let Some(message) = received.recv().await { + full |= + matches!(message, ExportMessage::Failed(ExportStreamFailure::SheetFull { limit: got }) if got == limit); + } + assert!(full, "the full sheet was not reported"); + } +} diff --git a/linux/crates/app/src/ui/export/encoding_sink.rs b/linux/crates/app/src/ui/export/encoding_sink.rs new file mode 100644 index 0000000000..24d1ca804b --- /dev/null +++ b/linux/crates/app/src/ui/export/encoding_sink.rs @@ -0,0 +1,261 @@ +use async_trait::async_trait; +use tablepro_core::export::RowEncoder; +use tablepro_core::{ResultEvent, ResultSink, SinkFlow, Value}; +use tokio::sync::mpsc; + +use super::{ExportMessage, ExportStreamFailure, RowFormat}; + +/// How much encoded output a sink holds before it hands it to the +/// writer. +pub(crate) const CHUNK_BYTES: usize = 64 * 1024; + +/// Encodes a result's rows as they arrive, and hands the bytes on in +/// chunks. A send waits while the writer is behind, which stops the +/// read until the disk catches up. +pub(crate) struct EncodingSink { + messages: mpsc::Sender, + format: RowFormat, + /// Built from the columns the stream announces, which are the ones + /// its rows carry. + encoder: Option>, + buffer: Vec, + rows: u64, + result_sets: u32, +} + +impl EncodingSink { + pub(crate) fn new(messages: mpsc::Sender, format: RowFormat) -> Self { + Self { + messages, + format, + encoder: None, + buffer: Vec::with_capacity(CHUNK_BYTES), + rows: 0, + result_sets: 0, + } + } + + // `&mut self` throughout: the encoder is not Sync, so a shared + // borrow held across an await would make the future not Send. + async fn send(&mut self, message: ExportMessage) -> SinkFlow { + match self.messages.send(message).await { + Ok(()) => SinkFlow::Continue, + Err(_) => SinkFlow::ConsumerGone, + } + } + + async fn flush(&mut self) -> SinkFlow { + if self.buffer.is_empty() { + return SinkFlow::Continue; + } + let bytes = std::mem::replace(&mut self.buffer, Vec::with_capacity(CHUNK_BYTES)); + self.send(ExportMessage::Chunk { bytes, rows: self.rows }).await + } + + /// The export cannot go on, so the run is stopped too. + async fn fail(&mut self, failure: ExportStreamFailure) -> SinkFlow { + self.send(ExportMessage::Failed(failure)).await; + SinkFlow::ConsumerGone + } + + async fn rows(&mut self, rows: Vec>) -> SinkFlow { + for row in &rows { + let Some(encoder) = self.encoder.as_mut() else { + tracing::warn!("rows arrived before their result set started"); + return SinkFlow::ConsumerGone; + }; + if let Err(error) = encoder.row(&mut self.buffer, &mut row.iter()) { + return self.fail(error.into()).await; + } + self.rows += 1; + if self.buffer.len() >= CHUNK_BYTES && self.flush().await == SinkFlow::ConsumerGone { + return SinkFlow::ConsumerGone; + } + } + SinkFlow::Continue + } +} + +#[async_trait] +impl ResultSink for EncodingSink { + async fn accept(&mut self, event: ResultEvent) -> SinkFlow { + match event { + ResultEvent::ResultSetStarted { columns } => { + self.result_sets += 1; + // A file holds one result, and a second set would come + // out as a second document glued to the first. + if self.result_sets > 1 { + return self.fail(ExportStreamFailure::MultipleResultSets).await; + } + let mut encoder = self.format.encoder(columns); + encoder.begin(&mut self.buffer); + self.encoder = Some(encoder); + SinkFlow::Continue + } + ResultEvent::Rows(rows) => self.rows(rows).await, + ResultEvent::ResultSetFinished { .. } => { + if let Some(encoder) = self.encoder.as_mut() { + encoder.finish(&mut self.buffer); + } + if self.flush().await == SinkFlow::ConsumerGone { + return SinkFlow::ConsumerGone; + } + self.send(ExportMessage::Finished { rows: self.rows }).await + } + _ => SinkFlow::Continue, + } + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + use std::sync::atomic::{AtomicBool, Ordering}; + use std::time::Duration; + + use tablepro_core::Truncation; + use tablepro_core::column::{ColumnType, ResultColumn}; + use tablepro_core::export::CsvOptions; + + use super::*; + use crate::test_support::paused_tasks; + + fn columns(names: &[&str]) -> Arc<[ResultColumn]> { + names + .iter() + .map(|name| ResultColumn::new(*name, ColumnType::untyped_text("text"))) + .collect() + } + + fn plain_csv() -> RowFormat { + RowFormat::Csv(CsvOptions { + header_row: false, + sanitize_formulas: false, + ..CsvOptions::default() + }) + } + + fn sink(format: RowFormat, capacity: usize) -> (EncodingSink, mpsc::Receiver) { + let (messages, received) = mpsc::channel(capacity); + (EncodingSink::new(messages, format), received) + } + + /// One-cell rows that each encode to exactly 1 KiB of plain CSV. + fn kib_rows(count: usize) -> Vec> { + (0..count) + .map(|row| vec![Value::Text(format!("{row:01023}"))]) + .collect() + } + + fn started(names: &[&str]) -> ResultEvent { + ResultEvent::ResultSetStarted { + columns: columns(names), + } + } + + fn finished() -> ResultEvent { + ResultEvent::ResultSetFinished { + retained: 0, + total_rows: None, + truncation: Truncation::Complete, + } + } + + async fn drain(received: &mut mpsc::Receiver) -> (Vec<(Vec, u64)>, Option) { + let mut chunks = Vec::new(); + let mut finished = None; + while let Some(message) = received.recv().await { + match message { + ExportMessage::Chunk { bytes, rows } => chunks.push((bytes, rows)), + ExportMessage::Finished { rows } => finished = Some(rows), + ExportMessage::Progress { .. } => {} + ExportMessage::Failed(failure) => panic!("the export failed: {failure}"), + } + } + (chunks, finished) + } + + #[tokio::test(start_paused = true)] + async fn encoding_sink_chunks_at_64_kib() { + let (mut sink, mut received) = sink(plain_csv(), 64); + + assert_eq!(sink.accept(started(&["a"])).await, SinkFlow::Continue); + assert_eq!( + sink.accept(ResultEvent::Rows(kib_rows(1_000))).await, + SinkFlow::Continue + ); + assert_eq!(sink.accept(finished()).await, SinkFlow::Continue); + drop(sink); + let (chunks, finished) = drain(&mut received).await; + + assert_eq!(finished, Some(1_000)); + let (last, full) = chunks.split_last().expect("chunks"); + assert_eq!(full.len(), 15); + for (index, (bytes, rows)) in full.iter().enumerate() { + assert_eq!(bytes.len(), CHUNK_BYTES, "chunk {index}"); + assert_eq!(*rows, 64 * (index as u64 + 1), "chunk {index}"); + } + assert_eq!(last.0.len(), 40 * 1024); + assert_eq!(last.1, 1_000); + } + + #[tokio::test(start_paused = true)] + async fn encoding_sink_applies_backpressure() { + let (mut sink, mut received) = sink(plain_csv(), 4); + let done = Arc::new(AtomicBool::new(false)); + let finished = done.clone(); + let running = paused_tasks().spawn_task(async move { + sink.accept(started(&["a"])).await; + sink.accept(ResultEvent::Rows(kib_rows(1_000))).await; + finished.store(true, Ordering::SeqCst); + }); + + tokio::time::sleep(Duration::from_secs(60)).await; + assert_eq!(received.len(), 4, "the sink did not fill the channel"); + assert!(!done.load(Ordering::SeqCst), "the sink ran ahead of the writer"); + + received.recv().await.expect("a chunk"); + tokio::time::sleep(Duration::from_secs(60)).await; + assert_eq!(received.len(), 4, "a freed slot was not filled"); + assert!(!done.load(Ordering::SeqCst)); + + while !done.load(Ordering::SeqCst) { + received.recv().await.expect("a chunk"); + tokio::time::sleep(Duration::from_millis(1)).await; + } + running.await.expect("the sink's task"); + } + + #[tokio::test(start_paused = true)] + async fn encoding_sink_builds_encoder_from_stream_columns() { + let (mut sink, mut received) = sink(RowFormat::Csv(CsvOptions::default()), 4); + + sink.accept(started(&["x", "y"])).await; + sink.accept(ResultEvent::Rows(vec![vec![Value::Int(1), Value::Int(2)]])) + .await; + sink.accept(finished()).await; + drop(sink); + let (chunks, finished) = drain(&mut received).await; + + assert_eq!(finished, Some(1)); + let text: Vec = chunks.into_iter().flat_map(|(bytes, _)| bytes).collect(); + assert_eq!(String::from_utf8(text).expect("utf-8"), "\"x\",\"y\"\n\"1\",\"2\"\n"); + } + + #[tokio::test(start_paused = true)] + async fn encoding_sink_rejects_second_result_set() { + let (mut sink, mut received) = sink(RowFormat::Json, 4); + + sink.accept(started(&["a"])).await; + sink.accept(finished()).await; + let flow = sink.accept(started(&["b"])).await; + + assert_eq!(flow, SinkFlow::ConsumerGone); + drop(sink); + let mut failed = false; + while let Some(message) = received.recv().await { + failed |= matches!(message, ExportMessage::Failed(ExportStreamFailure::MultipleResultSets)); + } + assert!(failed, "no failure was sent"); + } +} diff --git a/linux/crates/app/src/ui/export/export_failure_text.rs b/linux/crates/app/src/ui/export/export_failure_text.rs new file mode 100644 index 0000000000..0f5705b4c7 --- /dev/null +++ b/linux/crates/app/src/ui/export/export_failure_text.rs @@ -0,0 +1,276 @@ +use gtk4::prelude::*; +use gtk4::{self as gtk, gio, glib}; +use libadwaita as adw; +use libadwaita::prelude::*; +use tablepro_core::ErrorCategory; +use tablepro_core::export::{EncodeError, RenderError}; +use tablepro_session::OperationError; +use tablepro_session::runtime::TaskFailure; + +use super::{AbortOutcome, ExpectedRows, ExportFailure, ExportOutcome, ExportStreamFailure}; +use crate::i18n::{gettext, gettext_f, ngettext_f}; +use crate::ui::error_text::operation_error_message; +use crate::ui::format::{format_thousands, plural_count}; + +pub(crate) fn exported_text(rows: u64, name: &str) -> String { + ngettext_f( + "Exported {rows} row to {name}", + "Exported {rows} rows to {name}", + plural_count(rows), + &[("rows", &format_thousands(rows)), ("name", name)], + ) +} + +pub(crate) fn stopped_text(outcome: AbortOutcome, name: &str) -> String { + match outcome { + AbortOutcome::Removed | AbortOutcome::Untouched => gettext("Export stopped"), + AbortOutcome::Incomplete => gettext_f("Export stopped. {name} is incomplete.", &[("name", name)]), + } +} + +/// How far a running export has got, for its row in the operations +/// popover. +pub(crate) fn progress_text(rows: u64, expected: Option) -> String { + let count = format_thousands(rows); + match expected { + Some(ExpectedRows::Exact(total)) => ngettext_f( + "{rows} of {total} row", + "{rows} of {total} rows", + plural_count(total), + &[("rows", &count), ("total", &format_thousands(total))], + ), + Some(ExpectedRows::About(total)) => ngettext_f( + "{rows} of about {total} row", + "{rows} of about {total} rows", + plural_count(total), + &[("rows", &count), ("total", &format_thousands(total))], + ), + None => ngettext_f("{rows} row", "{rows} rows", plural_count(rows), &[("rows", &count)]), + } +} + +/// Why an export failed, as the alert says it, and what it left of the +/// file. +pub(crate) fn failure_text(failure: &ExportFailure, file: Option, name: &str) -> String { + let reason = match failure { + ExportFailure::Stream(ExportStreamFailure::Encode(EncodeError::Undecodable { column })) => gettext_f( + "The column “{column}” holds a value TablePro couldn't read, so it can't be written to the file.", + &[("column", column)], + ), + ExportFailure::Stream(ExportStreamFailure::MultipleResultSets) => { + gettext("This statement returned more than one result, so it can't be exported as one file.") + } + ExportFailure::Stream(ExportStreamFailure::NoResultSet) => { + gettext("This statement returned no rows to export.") + } + ExportFailure::Stream(ExportStreamFailure::Document(error)) => document_text(error), + ExportFailure::Stream(ExportStreamFailure::SheetFull { limit }) => gettext_f( + "An Excel sheet holds {limit} rows, and this result has more. Export it as CSV instead.", + &[("limit", &format_thousands(*limit as u64))], + ), + ExportFailure::Table(error) => operation_error_message(error), + ExportFailure::Statement(error) => rerun_text(error), + ExportFailure::File(error) => gettext_f( + "Writing {name} failed: {error}", + &[("name", name), ("error", error.message())], + ), + ExportFailure::InUse => gettext_f( + "An export to {name} is already running. Wait for it to finish, or export to another file.", + &[("name", name)], + ), + ExportFailure::Task(TaskFailure::Panicked(_) | TaskFailure::Aborted) + | ExportFailure::Stream(ExportStreamFailure::Task(_)) => gettext("The export stopped unexpectedly."), + }; + match file { + Some(AbortOutcome::Incomplete) => { + format!("{reason}\n\n{}", gettext_f("{name} is incomplete.", &[("name", name)])) + } + _ => reason, + } +} + +/// Why Export All's run of the statement failed. It ran on a connection +/// of its own, read-only, which is behind most of the ways it can fail +/// where the editor's own run did not. +fn rerun_text(error: &OperationError) -> String { + match error.category() { + Some(ErrorCategory::ReadOnly) => { + gettext("Export All only runs statements that read data. This statement changes data.") + } + Some(ErrorCategory::Server) => format!( + "{}\n\n{}", + gettext( + "Export All runs the statement again on a separate connection, so temporary tables, session settings and uncommitted changes from this editor aren't available." + ), + operation_error_message(error) + ), + _ => operation_error_message(error), + } +} + +fn document_text(error: &RenderError) -> String { + match error { + RenderError::TooManyRows { got, limit } => gettext_f( + "An Excel sheet holds {limit} rows, and this result has {got}.", + &[ + ("limit", &format_thousands(*limit as u64)), + ("got", &format_thousands(*got as u64)), + ], + ), + RenderError::TooManyColumns { got, limit } => gettext_f( + "An Excel sheet holds {limit} columns, and this result has {got}.", + &[ + ("limit", &format_thousands(*limit as u64)), + ("got", &format_thousands(*got as u64)), + ], + ), + RenderError::Xlsx(_) | RenderError::Csv(_) | RenderError::Flush(_) | RenderError::Utf8(_) => { + gettext_f("The file couldn't be built: {error}", &[("error", &error.to_string())]) + } + } +} + +/// Where an export's outcome is told: a toast on the workspace, or an +/// alert over its window for a failure. +#[derive(Debug, Clone)] +pub(crate) struct ExportReporter { + parent: glib::WeakRef, + toasts: glib::WeakRef, +} + +impl ExportReporter { + pub(crate) fn new(parent: &impl IsA, toasts: &adw::ToastOverlay) -> Self { + Self { + parent: parent.as_ref().downgrade(), + toasts: toasts.downgrade(), + } + } + + pub(crate) fn report(&self, file: &gio::File, name: &str, outcome: &ExportOutcome) { + match outcome { + ExportOutcome::Exported { rows } => { + let toast = crate::ui::plain_toast(&exported_text(*rows, name)); + toast.set_button_label(Some(&gettext("Show in Files"))); + let (file, parent) = (file.clone(), self.parent.clone()); + toast.connect_button_clicked(move |_| show_in_files(&file, &parent)); + self.toast(toast); + } + ExportOutcome::Stopped(left) => self.toast(crate::ui::plain_toast(&stopped_text(*left, name))), + ExportOutcome::Failed { failure, file: left } => { + tracing::warn!(%failure, "an export failed"); + let Some(parent) = self.parent.upgrade() else { + return; + }; + let alert = adw::AlertDialog::new( + Some(&gettext("Couldn't Export")), + Some(&failure_text(failure, *left, name)), + ); + alert.add_response("close", &gettext("_Close")); + alert.set_default_response(Some("close")); + alert.set_close_response("close"); + alert.present(Some(&parent)); + } + } + } + + fn toast(&self, toast: adw::Toast) { + if let Some(toasts) = self.toasts.upgrade() { + toasts.add_toast(toast); + } + } +} + +fn show_in_files(file: &gio::File, parent: &glib::WeakRef) { + let window = parent + .upgrade() + .and_then(|parent| parent.root()) + .and_downcast::(); + let launcher = gtk::FileLauncher::new(Some(file)); + glib::spawn_future_local(async move { + if let Err(error) = launcher.open_containing_folder_future(window.as_ref()).await { + tracing::warn!(%error, "the export's folder could not be shown"); + } + }); +} + +#[cfg(test)] +mod tests { + use tablepro_core::{DriverError, LossPhase, ReadOnlyRefusal}; + + use super::*; + + fn statement(error: DriverError) -> ExportFailure { + ExportFailure::Statement(OperationError::Driver(error)) + } + + #[test] + fn editor_rerun_failure_text_per_category() { + let missing = || DriverError::server("relation \"scratch\" does not exist"); + let read_only = failure_text( + &statement(DriverError::ReadOnly(ReadOnlyRefusal::ClientGuard)), + None, + "rows.csv", + ); + let server = failure_text(&statement(missing()), None, "rows.csv"); + let lost = failure_text( + &statement(DriverError::ConnectionLost { + during: LossPhase::Statement, + }), + None, + "rows.csv", + ); + let table = failure_text( + &ExportFailure::Table(OperationError::Driver(missing())), + None, + "rows.csv", + ); + let multiple = failure_text( + &ExportFailure::Stream(ExportStreamFailure::MultipleResultSets), + None, + "rows.csv", + ); + + assert_eq!( + read_only, + "Export All only runs statements that read data. This statement changes data." + ); + let (why, said) = server + .split_once("\n\n") + .expect("a blank line before the server's words"); + assert_eq!( + why, + "Export All runs the statement again on a separate connection, so temporary tables, session settings and uncommitted changes from this editor aren't available." + ); + assert_eq!(said, "Query failed: relation \"scratch\" does not exist"); + assert_eq!(lost, "The connection was closed. Try reconnecting."); + assert_eq!(table, said, "a table read has no statement to explain"); + assert_eq!( + multiple, + "This statement returned more than one result, so it can't be exported as one file." + ); + } + + #[test] + fn progress_against_an_estimate_says_about() { + assert_eq!( + progress_text(12, Some(ExpectedRows::About(500))), + "12 of about 500 rows" + ); + assert_eq!(progress_text(12, Some(ExpectedRows::Exact(500))), "12 of 500 rows"); + assert_eq!(progress_text(1, None), "1 row"); + } + + #[test] + fn a_full_sheet_says_how_many_rows_it_holds() { + let text = failure_text( + &ExportFailure::Stream(ExportStreamFailure::SheetFull { limit: 100 }), + Some(AbortOutcome::Removed), + "rows.xlsx", + ); + + assert_eq!( + text, + "An Excel sheet holds 100 rows, and this result has more. Export it as CSV instead." + ); + } +} diff --git a/linux/crates/app/src/ui/export/export_format.rs b/linux/crates/app/src/ui/export/export_format.rs new file mode 100644 index 0000000000..57800fc061 --- /dev/null +++ b/linux/crates/app/src/ui/export/export_format.rs @@ -0,0 +1,93 @@ +use std::sync::Arc; + +use tablepro_core::Value; +use tablepro_core::column::ResultColumn; +use tablepro_core::dialect::SqlDialect; +use tablepro_core::export::{ + self, CsvEncoder, CsvOptions, HtmlEncoder, JsonArrayEncoder, MarkdownEncoder, RenderError, RowEncoder, + SqlInsertEncoder, XlsxSheetWriter, XmlEncoder, +}; + +/// What an export writes. +#[derive(Debug, Clone)] +pub(crate) enum ExportFormat { + /// Encoded a row at a time, as the rows arrive. + Rows(RowFormat), + /// Written on the blocking pool as the rows arrive, and handed on + /// whole once the last is in: a workbook is a zip archive. + Document(DocumentFormat), +} + +#[derive(Debug, Clone)] +pub(crate) enum RowFormat { + Csv(CsvOptions), + Json, + Markdown, + Html { title: String }, + Xml, + Sql(InsertTarget), +} + +impl RowFormat { + /// An encoder for rows under `columns`, the ones the stream itself + /// announced. + pub(crate) fn encoder(&self, columns: Arc<[ResultColumn]>) -> Box { + match self { + Self::Csv(options) => Box::new(CsvEncoder::new(columns, options.clone())), + Self::Json => Box::new(JsonArrayEncoder::new(columns)), + Self::Markdown => Box::new(MarkdownEncoder::new(columns)), + Self::Html { title } => Box::new(HtmlEncoder::new(columns, title.clone())), + Self::Xml => Box::new(XmlEncoder::new(columns)), + Self::Sql(target) => Box::new(SqlInsertEncoder::new(target.dialect.clone(), &target.table, columns)), + } + } +} + +#[derive(Debug, Clone)] +pub(crate) enum DocumentFormat { + Xlsx { sheet: String }, +} + +impl DocumentFormat { + /// The most rows the document can hold. + pub(crate) fn row_limit(&self) -> usize { + match self { + Self::Xlsx { .. } => export::XLSX_MAX_DATA_ROWS, + } + } + + /// A document for rows under `columns`, written on the blocking + /// pool. + pub(crate) fn start(&self, columns: Arc<[ResultColumn]>) -> Result { + match self { + Self::Xlsx { sheet } => XlsxSheetWriter::new(columns, sheet).map(DocumentWriter::Xlsx), + } + } +} + +/// A document part way through, which takes rows a batch at a time. +pub(crate) enum DocumentWriter { + Xlsx(XlsxSheetWriter), +} + +impl DocumentWriter { + pub(crate) fn write_rows(&mut self, rows: &[Vec]) -> Result<(), RenderError> { + match self { + Self::Xlsx(writer) => writer.write_rows(rows), + } + } + + pub(crate) fn finish(self) -> Result, RenderError> { + match self { + Self::Xlsx(writer) => writer.finish(), + } + } +} + +/// A table to insert into and the spelling of the server the rows came +/// from. SQL is offered only with both. +#[derive(Debug, Clone)] +pub(crate) struct InsertTarget { + pub table: String, + pub dialect: Arc, +} diff --git a/linux/crates/app/src/ui/export/export_job.rs b/linux/crates/app/src/ui/export/export_job.rs new file mode 100644 index 0000000000..f98cf1f165 --- /dev/null +++ b/linux/crates/app/src/ui/export/export_job.rs @@ -0,0 +1,39 @@ +use gtk4::gio; + +use super::{ExportFormat, ExportSource}; + +/// One export, as the dialog hands it to the ledger. +#[derive(Debug)] +pub(crate) struct ExportJob { + pub destination: gio::File, + pub format: ExportFormat, + pub source: ExportSource, + pub expected_rows: Option, +} + +/// Names a running export in its ledger. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub(crate) struct ExportJobId(u64); + +impl ExportJobId { + pub(crate) fn new(id: u64) -> Self { + Self(id) + } +} + +/// How many rows an export will write, as far as anyone knows before it +/// does. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ExpectedRows { + Exact(u64), + /// The server's estimate, which the export may pass. + About(u64), +} + +impl ExpectedRows { + pub(crate) fn total(self) -> u64 { + match self { + Self::Exact(total) | Self::About(total) => total, + } + } +} diff --git a/linux/crates/app/src/ui/export/export_job_item.rs b/linux/crates/app/src/ui/export/export_job_item.rs new file mode 100644 index 0000000000..702d0e860d --- /dev/null +++ b/linux/crates/app/src/ui/export/export_job_item.rs @@ -0,0 +1,64 @@ +use std::cell::{Cell, OnceCell, RefCell}; + +use gtk4::glib; +use gtk4::prelude::*; +use gtk4::subclass::prelude::*; +use tokio_util::sync::CancellationToken; + +use super::ExpectedRows; + +mod imp { + use super::*; + + #[derive(Default, glib::Properties)] + #[properties(wrapper_type = super::ExportJobItem)] + pub struct ExportJobItem { + /// The file's name as the file manager shows it. + #[property(get, set)] + pub title: RefCell, + /// Rows written to the file so far. + #[property(get, set)] + pub rows: Cell, + pub expected: Cell>, + pub cancel: OnceCell, + } + + #[glib::object_subclass] + impl ObjectSubclass for ExportJobItem { + const NAME: &'static str = "TableProExportJobItem"; + type Type = super::ExportJobItem; + } + + #[glib::derived_properties] + impl ObjectImpl for ExportJobItem {} +} + +glib::wrapper! { + /// A running export as the operations popover lists it. + pub struct ExportJobItem(ObjectSubclass); +} + +impl ExportJobItem { + pub(crate) fn new(title: &str, expected: Option, cancel: CancellationToken) -> Self { + let item: Self = glib::Object::builder().property("title", title).build(); + item.imp().expected.set(expected); + let _ = item.imp().cancel.set(cancel); + item + } + + pub(crate) fn expected(&self) -> Option { + self.imp().expected.get() + } + + /// The share of the rows written, while the total is known. + pub(crate) fn fraction(&self) -> Option { + let total = self.expected()?.total(); + (total > 0).then(|| (self.rows() as f64 / total as f64).min(1.0)) + } + + pub(crate) fn stop(&self) { + if let Some(cancel) = self.imp().cancel.get() { + cancel.cancel(); + } + } +} diff --git a/linux/crates/app/src/ui/export/export_ledger.rs b/linux/crates/app/src/ui/export/export_ledger.rs new file mode 100644 index 0000000000..7e1fa17487 --- /dev/null +++ b/linux/crates/app/src/ui/export/export_ledger.rs @@ -0,0 +1,416 @@ +use std::cell::RefCell; +use std::collections::BTreeMap; +use std::future::Future; +use std::pin::pin; +use std::rc::Rc; + +use futures::channel::oneshot; +use futures::future::{Either, LocalBoxFuture, select}; +use gtk4::prelude::*; +use gtk4::{gio, glib}; +use tablepro_core::StreamSummary; +use tablepro_session::OperationError; +use tablepro_session::runtime::{HoldToken, ProcessHold, TaskResult, Tasks}; +use tokio::sync::mpsc; +use tokio_util::sync::CancellationToken; + +use super::export_producer::{start_query_producer, start_rows_producer}; +use super::{ + ExportFailure, ExportJob, ExportJobId, ExportJobItem, ExportMessage, ExportOutcome, ExportQuery, ExportReporter, + ExportSinkParts, ExportSource, ExportStreamFailure, FinishError, ReplaceFileWriter, +}; +use crate::i18n::{gettext, ngettext_f}; +use crate::ui::format::plural_count; +use crate::workspace::{CloseBlockPrompt, CloseBlocker, InhibitReason, WindowInhibitor}; + +/// Chunks in flight between the encoder and the file. Few, so a slow +/// disk holds the encoder back within a few hundred kilobytes. +const CHANNEL_CHUNKS: usize = 4; + +/// The exports a workspace has running. +/// +/// Each one holds the process, so quitting while one writes still lets +/// it finish or clean up, and while any runs the session is asked not +/// to log out or suspend under it. +pub(crate) struct ExportLedger { + tasks: Tasks, + hold: Rc, + reporter: ExportReporter, + inhibitor: RefCell, + /// The running jobs, as the operations popover lists them. + jobs: gio::ListStore, + state: RefCell, +} + +#[derive(Default)] +struct LedgerState { + next: u64, + running: BTreeMap, + waiters: Vec>, + /// The workspace shut down: its close already asked about the + /// exports, and a new one would run with no window to report to. + shut_down: bool, +} + +struct Running { + cancel: CancellationToken, + _hold: HoldToken, +} + +pub(crate) struct ExportLedgerInit { + pub tasks: Tasks, + pub hold: Rc, + pub reporter: ExportReporter, + pub inhibitor: WindowInhibitor, +} + +/// A job the ledger started. Dropping it leaves the job running, and +/// the ledger reports how it ended either way. +pub(crate) struct ExportJobHandle { + outcome: oneshot::Receiver, +} + +impl ExportJobHandle { + /// How the job ended. `None` when the ledger went before it did. + #[cfg_attr( + not(test), + expect(dead_code, reason = "the app hears the outcome through the ledger's toast or alert") + )] + pub(crate) async fn outcome(self) -> Option { + self.outcome.await.ok() + } +} + +impl ExportLedger { + pub(crate) fn new(init: ExportLedgerInit) -> Rc { + Rc::new(Self { + tasks: init.tasks, + hold: init.hold, + reporter: init.reporter, + inhibitor: RefCell::new(init.inhibitor), + jobs: gio::ListStore::new::(), + state: RefCell::default(), + }) + } + + pub(crate) fn jobs(&self) -> gio::ListStore { + self.jobs.clone() + } + + /// Start `job`, unless the workspace has shut down. A file chooser + /// still open when the window closed can hand one over after that. + pub(crate) fn begin(self: &Rc, job: ExportJob) -> Option { + if self.state.borrow().shut_down { + tracing::debug!("an export began after its workspace shut down, and was not started"); + return None; + } + let cancel = CancellationToken::new(); + let name = file_name(&job.destination); + let item = ExportJobItem::new(&name, job.expected_rows, cancel.clone()); + let id = { + let mut state = self.state.borrow_mut(); + state.next += 1; + let id = ExportJobId::new(state.next); + state.running.insert( + id, + Running { + cancel: cancel.clone(), + _hold: self.hold.acquire(), + }, + ); + id + }; + self.jobs.append(&item); + self.refresh_inhibitor(); + + let (reply, outcome) = oneshot::channel(); + let ledger = self.clone(); + glib::spawn_future_local(async move { + let destination = job.destination.clone(); + let ended = run(&ledger.tasks, job, &cancel, &item).await; + ledger.settle(id, &item); + ledger.reporter.report(&destination, &item.title(), &ended); + let _ = reply.send(ended); + }); + Some(ExportJobHandle { outcome }) + } + + pub(crate) fn in_flight(&self) -> usize { + self.state.borrow().running.len() + } + + /// Resolves once no export is running. + pub(crate) fn when_idle(&self) -> impl Future + use<> { + let receiver = if self.in_flight() == 0 { + None + } else { + let (sender, receiver) = oneshot::channel(); + self.state.borrow_mut().waiters.push(sender); + Some(receiver) + }; + async move { + if let Some(receiver) = receiver { + // A ledger dropped with jobs running wakes nobody, and a + // waiter that outlived it has nothing left to wait on. + let _ = receiver.await; + } + } + } + + /// Stop every running export. Each settles once its file is cleaned + /// up. + pub(crate) fn cancel_all(&self) { + for running in self.state.borrow().running.values() { + running.cancel.cancel(); + } + } + + /// Stop every running export and refuse new ones, for a workspace + /// that is going away. + pub(crate) fn shut_down(&self) { + self.state.borrow_mut().shut_down = true; + self.cancel_all(); + } + + fn settle(&self, id: ExportJobId, item: &ExportJobItem) { + let (finished, woken) = { + let mut state = self.state.borrow_mut(); + let finished = state.running.remove(&id); + let woken = if state.running.is_empty() { + std::mem::take(&mut state.waiters) + } else { + Vec::new() + }; + (finished, woken) + }; + if let Some(position) = self.jobs.find(item) { + self.jobs.remove(position); + } + self.refresh_inhibitor(); + // The hold goes only after the state is consistent, in case + // dropping it ends the main loop. + drop(finished); + for waiter in woken { + let _ = waiter.send(()); + } + } + + fn refresh_inhibitor(&self) { + let reason = (self.in_flight() > 0).then_some(InhibitReason::Exporting); + self.inhibitor.borrow_mut().update(reason); + } + + /// Whether the session is held back for the running exports. + pub(crate) fn is_inhibiting(&self) -> bool { + self.inhibitor.borrow().is_held() + } +} + +impl CloseBlocker for ExportLedger { + fn blocking(&self) -> Option { + let running = self.in_flight(); + (running > 0).then(|| CloseBlockPrompt { + heading: gettext("Stop Exports?"), + body: ngettext_f( + "{n} export is still running. Stopping it leaves the file unfinished.", + "{n} exports are still running. Stopping them leaves the files unfinished.", + plural_count(running as u64), + &[("n", &running.to_string())], + ), + keep_label: gettext("_Keep Exporting"), + stop_label: gettext("_Stop Exports"), + }) + } + + fn stop(&self) -> LocalBoxFuture<'static, ()> { + self.cancel_all(); + Box::pin(self.when_idle()) + } +} + +impl std::fmt::Debug for ExportLedger { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ExportLedger") + .field("in_flight", &self.in_flight()) + .finish_non_exhaustive() + } +} + +/// The file's name as it is shown until GIO answers with the one the +/// file manager uses. +fn file_name(file: &gio::File) -> String { + file.basename() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_else(|| file.parse_name().to_string()) +} + +/// Asked once the file exists: a file not yet made has no display name. +async fn show_display_name(file: &gio::File, item: &ExportJobItem) { + let info = file + .query_info_future( + gio::FILE_ATTRIBUTE_STANDARD_DISPLAY_NAME, + gio::FileQueryInfoFlags::NONE, + glib::Priority::DEFAULT, + ) + .await; + match info { + Ok(info) => item.set_title(info.display_name()), + Err(error) => tracing::debug!(%error, "an export's file has no display name"), + } +} + +/// Write the job's file: open it, start the producer, and take its +/// chunks until it finishes, fails or the job is stopped. +async fn run(tasks: &Tasks, job: ExportJob, cancel: &CancellationToken, item: &ExportJobItem) -> ExportOutcome { + let writer = match ReplaceFileWriter::open(&job.destination).await { + Ok(writer) => writer, + Err(error) => { + return ExportOutcome::Failed { + failure: error.into(), + file: None, + }; + } + }; + show_display_name(&job.destination, item).await; + if cancel.is_cancelled() { + return ExportOutcome::Stopped(writer.abort().await); + } + let (messages, mut received) = mpsc::channel(CHANNEL_CHUNKS); + let parts = ExportSinkParts { + messages, + format: job.format, + tasks: tasks.clone(), + }; + let producer = Producer::start(tasks, job.source, parts, cancel); + loop { + let Some(next) = until_cancelled(cancel, received.recv()).await else { + return ExportOutcome::Stopped(writer.abort().await); + }; + match next { + Some(ExportMessage::Chunk { bytes, rows }) => { + if let Err(error) = writer.write(bytes).await { + cancel.cancel(); + return failed(writer, error.into()).await; + } + item.set_rows(rows); + } + Some(ExportMessage::Progress { rows }) => item.set_rows(rows), + Some(ExportMessage::Failed(failure)) => { + cancel.cancel(); + return failed(writer, failure.into()).await; + } + Some(ExportMessage::Finished { rows }) => { + // A read can still fail once its rows are in, as a + // statement that writes after it reads does, and the + // file is kept only for a read that ended well. + let Some(ended) = until_cancelled(cancel, producer.finish()).await else { + return ExportOutcome::Stopped(writer.abort().await); + }; + // A sink that stopped the read on a second result set + // says so here, and a driver may still end that read + // Ok, as SQL Server's does. + if let Some(failure) = sent_failure(&mut received) { + return failed(writer, failure).await; + } + if let Err(failure) = ended { + return failed(writer, failure).await; + } + return match writer.finish().await { + Ok(()) => ExportOutcome::Exported { rows }, + Err(FinishError { error, file }) => ExportOutcome::Failed { + failure: error.into(), + file: Some(file), + }, + }; + } + // The producer let go of its sender without finishing: + // stopped by the cancel, which the next turn sees, or ended + // by a failure or a panic. + None => { + if cancel.is_cancelled() { + return ExportOutcome::Stopped(writer.abort().await); + } + let failure = match producer.finish().await { + Ok(()) => ExportStreamFailure::NoResultSet.into(), + Err(failure) => failure, + }; + return failed(writer, failure).await; + } + } + } +} + +/// `work`'s output, or `None` once the job is stopped. +async fn until_cancelled(cancel: &CancellationToken, work: impl Future) -> Option { + let stopped = pin!(cancel.cancelled()); + let work = pin!(work); + match select(stopped, work).await { + Either::Left(((), _)) => None, + Either::Right((output, _)) => Some(output), + } +} + +/// The failure a sink sent after its result finished. It says more than +/// the read's own end: a second result set stops the read, which then +/// reports only that it was stopped, or nothing wrong at all. +fn sent_failure(received: &mut mpsc::Receiver) -> Option { + while let Ok(message) = received.try_recv() { + if let ExportMessage::Failed(failure) = message { + return Some(failure.into()); + } + } + None +} + +async fn failed(writer: ReplaceFileWriter, failure: ExportFailure) -> ExportOutcome { + ExportOutcome::Failed { + failure, + file: Some(writer.abort().await), + } +} + +/// What feeds a job's sink: rows a grid already holds, or a read +/// streamed from the server. +enum Producer { + Rows(TaskResult<()>), + Query { + task: TaskResult>, + rerun: bool, + }, +} + +impl Producer { + /// Neither kind is aborted when the job stops. The cancel reaches + /// both: the rows producer checks it between batches, and a read + /// stops its statement on the server, which an abort would leave + /// running behind a dropped future. + fn start(tasks: &Tasks, source: ExportSource, parts: ExportSinkParts, cancel: &CancellationToken) -> Self { + match source { + ExportSource::Rows { columns, rows } => { + Self::Rows(start_rows_producer(tasks, columns, rows, parts, cancel.clone())) + } + ExportSource::Query { session, query } => { + let rerun = matches!(query, ExportQuery::Editor { .. }); + Self::Query { + task: start_query_producer(tasks, session, query, parts, cancel.clone()), + rerun, + } + } + } + } + + async fn finish(self) -> Result<(), ExportFailure> { + match self { + Self::Rows(task) => task.await.map_err(ExportFailure::from), + Self::Query { task, rerun } => match task.await { + Ok(Ok(_)) => Ok(()), + Ok(Err(error)) if rerun => Err(ExportFailure::Statement(error)), + Ok(Err(error)) => Err(ExportFailure::Table(error)), + Err(failure) => Err(failure.into()), + }, + } + } +} + +#[cfg(test)] +mod tests; diff --git a/linux/crates/app/src/ui/export/export_ledger/tests.rs b/linux/crates/app/src/ui/export/export_ledger/tests.rs new file mode 100644 index 0000000000..27b7d6b716 --- /dev/null +++ b/linux/crates/app/src/ui/export/export_ledger/tests.rs @@ -0,0 +1,712 @@ +use std::cell::{Cell, RefCell}; +use std::path::Path; +use std::sync::Arc; +use std::time::Duration; + +use gtk4 as gtk; +use libadwaita as adw; +use libadwaita::prelude::*; +use tablepro_core::column::{ColumnDefault, ColumnType, ResultColumn}; +use tablepro_core::dialect::SqlDialect; +use tablepro_core::export::CsvOptions; +use tablepro_core::meta::{EngineRowId, RowIdentity, TableRef}; +use tablepro_core::{ + ColumnInfo, DriverError, ErrorCategory, FilterSet, ReadOnlyRefusal, ResultEvent, ServerDiagnostics, ServerInfo, + Truncation, UserSql, Value, +}; +use tablepro_session::runtime::CountingHold; +use tablepro_test_support::{ConnectionScript, FakeReply, StreamEnd}; + +use super::*; +use crate::test_support::{FakeSession, descendants, finish_on_main, first_descendant_of_type, wait_until}; +use crate::ui::export::{AbortOutcome, ExpectedRows, ExportFormat, ExportOperationsButton, RowFormat, TableExport}; +use crate::ui::grid::RowSnapshot; +use crate::workspace::{InhibitTarget, answer, visible_alert}; + +const WAIT: Duration = Duration::from_secs(20); + +/// Hands out cookies, and keeps the ones still held. +#[derive(Clone, Default)] +struct Session { + held: Rc>>, + next: Rc>, +} + +impl InhibitTarget for Session { + fn inhibit(&self, flags: gtk::ApplicationInhibitFlags, _reason: &str) -> u32 { + self.next.set(self.next.get() + 1); + self.held.borrow_mut().push((self.next.get(), flags)); + self.next.get() + } + + fn uninhibit(&self, cookie: u32) { + self.held.borrow_mut().retain(|(held, _)| *held != cookie); + } +} + +struct Harness { + window: adw::Window, + ledger: Rc, + session: Session, + hold: Rc, + dir: tempfile::TempDir, + runtime: Option, +} + +impl Harness { + fn new() -> Self { + adw::init().expect("libadwaita initialises under the test backend"); + crate::register_test_resources(); + if let Some(settings) = gtk::Settings::default() { + settings.set_gtk_enable_animations(false); + } + let window = adw::Window::new(); + let toasts = adw::ToastOverlay::new(); + window.set_content(Some(&toasts)); + window.present(); + let runtime = crate::test_support::test_runtime(); + let session = Session::default(); + let hold = Rc::new(CountingHold::new()); + let ledger = ExportLedger::new(ExportLedgerInit { + tasks: runtime.tasks(), + hold: hold.clone(), + reporter: ExportReporter::new(&window, &toasts), + inhibitor: WindowInhibitor::new(session.clone()), + }); + Self { + window, + ledger, + session, + hold, + dir: tempfile::tempdir().expect("tempdir"), + runtime: Some(runtime), + } + } + + fn path(&self, name: &str) -> std::path::PathBuf { + self.dir.path().join(name) + } + + /// A session whose reads stream `events`. + fn session(&self, events: FakeReply>, dialect: Option>) -> FakeSession { + self.scripted(events, |script| { + if let Some(dialect) = dialect { + script.dialect = dialect; + } + }) + } + + fn scripted(&self, events: FakeReply>, adjust: impl FnOnce(&mut ConnectionScript)) -> FakeSession { + let mut script = ConnectionScript::default(); + script.probe.always(FakeReply::ok(())); + script.stream_read.always(events); + adjust(&mut script); + let tasks = self.runtime.as_ref().expect("the runtime").tasks(); + FakeSession::start(script, [], tasks) + } + + fn finish(&self, handle: ExportJobHandle) -> ExportOutcome { + finish_on_main(WAIT, handle.outcome()) + .expect("the export to end") + .expect("an outcome") + } +} + +impl Drop for Harness { + fn drop(&mut self) { + self.window.close(); + if let Some(runtime) = self.runtime.take() { + runtime.shutdown(); + } + } +} + +fn job(path: &Path, count: usize) -> ExportJob { + let columns: Arc<[ResultColumn]> = ["id", "name", "note"] + .iter() + .map(|name| ResultColumn::new(*name, ColumnType::untyped_text("text"))) + .collect(); + let rows = (0..count) + .map(|row| { + RowSnapshot::new( + vec![ + Value::Int(row as i64), + Value::Text(format!("name {row}")), + Value::Text("a note of some length".to_owned()), + ] + .into(), + Vec::new(), + ) + }) + .collect(); + ExportJob { + destination: gio::File::for_path(path), + format: ExportFormat::Rows(RowFormat::Csv(CsvOptions::default())), + source: ExportSource::Rows { columns, rows }, + expected_rows: Some(ExpectedRows::Exact(count as u64)), + } +} + +fn table_column(name: &str, primary_key: bool) -> ColumnInfo { + ColumnInfo { + name: name.to_owned(), + column_type: ColumnType::untyped_text("text"), + nullable: !primary_key, + primary_key, + is_auto_increment: false, + is_generated: false, + default: ColumnDefault::None, + comment: None, + } +} + +fn users(identity: RowIdentity) -> ExportQuery { + ExportQuery::Table(TableExport { + table: TableRef::new(None, "users"), + columns: vec![table_column("id", true), table_column("name", false)].into(), + filter: FilterSet::default(), + sort: Vec::new(), + identity, + }) +} + +fn by_id() -> RowIdentity { + RowIdentity::UniqueKey { + columns: vec!["id".to_owned()], + } +} + +fn started(names: &[&str]) -> ResultEvent { + ResultEvent::ResultSetStarted { + columns: names + .iter() + .map(|name| ResultColumn::new(*name, ColumnType::untyped_text("text"))) + .collect(), + } +} + +fn finished(rows: u64) -> ResultEvent { + ResultEvent::ResultSetFinished { + retained: rows, + total_rows: Some(rows), + truncation: Truncation::Complete, + } +} + +/// `batches` batches of `size` rows each, numbered from zero. +fn streamed(batches: usize, size: usize) -> Vec { + let mut events = vec![started(&["id", "name"])]; + for batch in 0..batches { + let rows = (0..size) + .map(|row| { + let id = (batch * size + row) as i64; + vec![Value::Int(id), Value::Text(format!("user {id}"))] + }) + .collect(); + events.push(ResultEvent::Rows(rows)); + } + events.push(finished((batches * size) as u64)); + events +} + +fn read_job(path: &Path, session: &FakeSession, query: ExportQuery) -> ExportJob { + ExportJob { + destination: gio::File::for_path(path), + format: ExportFormat::Rows(RowFormat::Csv(CsvOptions::default())), + source: ExportSource::Query { + session: session.session.clone(), + query, + }, + expected_rows: None, + } +} + +#[gtk4::test] +fn entire_table_export_streams_every_row() { + let harness = Harness::new(); + let path = harness.path("users.csv"); + let fake = harness.session(FakeReply::ok(streamed(100, 250)), None); + + let outcome = harness.finish( + harness + .ledger + .begin(read_job(&path, &fake, users(by_id()))) + .expect("started"), + ); + + assert!( + matches!(outcome, ExportOutcome::Exported { rows: 25_000 }), + "{outcome:?}" + ); + let text = std::fs::read_to_string(&path).expect("the export"); + let lines: Vec<&str> = text.lines().collect(); + assert_eq!(lines.len(), 25_001); + assert_eq!(lines[0], "\"id\",\"name\""); + assert_eq!(lines[25_000], "\"24999\",\"user 24999\""); + assert_eq!( + fake.connection.observed_deadlines("stream_read"), + [None], + "an export ran under a deadline" + ); +} + +#[gtk4::test] +fn entire_table_export_has_no_identity_columns() { + let harness = Harness::new(); + let path = harness.path("users.csv"); + let dialect: Arc = Arc::new(drivers_postgres::PgDialect::new(&ServerInfo::new( + "PostgreSQL", + "16.4", + 16, + 4, + 0, + ))); + let fake = harness.session(FakeReply::ok(streamed(1, 2)), Some(dialect)); + let identity = RowIdentity::EngineRowId(EngineRowId::PostgresCtid); + + let outcome = harness.finish( + harness + .ledger + .begin(read_job(&path, &fake, users(identity))) + .expect("started"), + ); + + assert!(matches!(outcome, ExportOutcome::Exported { rows: 2 }), "{outcome:?}"); + let sql = fake.connection.observed_sql("stream_read"); + let [Some(sql)] = sql.as_slice() else { + panic!("one read, not {sql:?}"); + }; + let projection = sql + .strip_prefix("SELECT ") + .and_then(|rest| rest.split_once(" FROM ")) + .map(|(projection, _)| projection) + .expect("a projection"); + assert!(!projection.contains("ctid"), "the row id was projected: {sql}"); + let projected: Vec<&str> = projection.split(", ").collect(); + assert_eq!(projected.len(), 2, "{sql}"); + assert!( + projected[0].ends_with(r#""id""#) && projected[1].ends_with(r#""name""#), + "{sql}" + ); + let text = std::fs::read_to_string(&path).expect("the export"); + assert_eq!(text.lines().next(), Some("\"id\",\"name\"")); +} + +#[gtk4::test] +fn stopping_entire_table_export_cancels_stream() { + let harness = Harness::new(); + let path = harness.path("users.csv"); + std::fs::write(&path, "old").expect("the existing file"); + let fake = harness.session(FakeReply::UntilStopped, None); + let handle = harness + .ledger + .begin(read_job(&path, &fake, users(by_id()))) + .expect("started"); + wait_until(WAIT, || fake.connection.observed_cancels("stream_read").len() == 1).expect("the read to start"); + + harness.ledger.cancel_all(); + let outcome = harness.finish(handle); + + assert!( + matches!(outcome, ExportOutcome::Stopped(AbortOutcome::Untouched)), + "{outcome:?}" + ); + let cancel = fake.connection.observed_cancels("stream_read").remove(0); + wait_until(WAIT, || cancel.is_cancelled()).expect("the stop to reach the read"); + assert_eq!(std::fs::read_to_string(&path).expect("the file"), "old"); +} + +#[gtk4::test] +fn editor_all_rows_export_uses_user_sql_read() { + let harness = Harness::new(); + let path = harness.path("rows.csv"); + let fake = harness.session(FakeReply::ok(streamed(1, 3)), None); + let statement = "SELECT id, name FROM users WHERE id < 3"; + let query = ExportQuery::Editor { + sql: UserSql::new(statement), + }; + + let outcome = harness.finish(harness.ledger.begin(read_job(&path, &fake, query)).expect("started")); + + assert!(matches!(outcome, ExportOutcome::Exported { rows: 3 }), "{outcome:?}"); + assert_eq!( + fake.connection.observed_sql("stream_read"), + [Some(statement.to_owned())] + ); + assert_eq!(fake.connection.observed_deadlines("stream_read"), [None]); +} + +#[gtk4::test] +fn editor_all_rows_multiple_result_sets_presents_alert() { + let harness = Harness::new(); + let path = harness.path("rows.csv"); + let mut events = streamed(1, 3); + events.extend(streamed(1, 1)); + let fake = harness.session(FakeReply::ok(events), None); + let query = ExportQuery::Editor { + sql: UserSql::new("SELECT 1; SELECT 2"), + }; + + let outcome = harness.finish(harness.ledger.begin(read_job(&path, &fake, query)).expect("started")); + + assert!( + matches!( + outcome, + ExportOutcome::Failed { + failure: ExportFailure::Stream(ExportStreamFailure::MultipleResultSets), + file: Some(AbortOutcome::Removed), + } + ), + "{outcome:?}" + ); + wait_until(WAIT, || visible_alert(&harness.window).is_some()).expect("the alert"); + let alert = visible_alert(&harness.window).expect("the alert"); + assert_eq!(alert.heading().as_deref(), Some("Couldn't Export")); + assert!(alert.body().contains("more than one result"), "{}", alert.body()); + assert!(!path.exists(), "the failed export left its file"); +} + +#[gtk4::test] +fn a_second_result_set_fails_the_export_when_the_read_ends_ok() { + let harness = Harness::new(); + let path = harness.path("rows.csv"); + let mut events = streamed(1, 3); + events.extend(streamed(1, 1)); + let fake = harness.scripted(FakeReply::ok(events), |script| { + script.stream_end = StreamEnd::SummaryWhenStopped; + }); + let query = ExportQuery::Editor { + sql: UserSql::new("SELECT * FROM a; SELECT * FROM b"), + }; + + let outcome = harness.finish(harness.ledger.begin(read_job(&path, &fake, query)).expect("started")); + + assert!( + matches!( + outcome, + ExportOutcome::Failed { + failure: ExportFailure::Stream(ExportStreamFailure::MultipleResultSets), + file: Some(AbortOutcome::Removed), + } + ), + "{outcome:?}" + ); + assert!(!path.exists(), "the first result's rows were kept as the export"); +} + +#[gtk4::test] +fn a_read_that_fails_after_its_rows_removes_the_file() { + let harness = Harness::new(); + let path = harness.path("users.csv"); + let failure = DriverError::Server(Box::new(ServerDiagnostics::new(None, "division by zero"))); + let fake = harness.scripted(FakeReply::ok(streamed(1, 3)), |script| { + script.stream_end = StreamEnd::Failed(failure); + }); + + let outcome = harness.finish( + harness + .ledger + .begin(read_job(&path, &fake, users(by_id()))) + .expect("started"), + ); + + let ExportOutcome::Failed { + failure: ExportFailure::Table(error), + file: Some(AbortOutcome::Removed), + } = &outcome + else { + panic!("{outcome:?}"); + }; + assert_eq!(error.category(), Some(ErrorCategory::Server)); + assert!(!path.exists(), "the failed read left its file"); +} + +#[gtk4::test] +fn a_statement_with_no_result_set_says_it_returned_no_rows() { + let harness = Harness::new(); + let path = harness.path("rows.csv"); + let fake = harness.session(FakeReply::ok(Vec::new()), None); + let query = ExportQuery::Editor { + sql: UserSql::new("SET search_path TO app"), + }; + + let outcome = harness.finish(harness.ledger.begin(read_job(&path, &fake, query)).expect("started")); + + assert!( + matches!( + outcome, + ExportOutcome::Failed { + failure: ExportFailure::Stream(ExportStreamFailure::NoResultSet), + file: Some(AbortOutcome::Removed), + } + ), + "{outcome:?}" + ); + wait_until(WAIT, || visible_alert(&harness.window).is_some()).expect("the alert"); + let body = visible_alert(&harness.window).expect("the alert").body(); + assert!(body.contains("returned no rows to export"), "{body}"); + assert!(!path.exists(), "the empty export left its file"); +} + +#[gtk4::test] +fn a_rerun_refused_as_a_write_says_export_all_only_reads() { + let harness = Harness::new(); + let path = harness.path("rows.csv"); + let refused = DriverError::ReadOnly(ReadOnlyRefusal::server(ServerDiagnostics::new( + None, + "cannot execute INSERT in a read-only transaction", + ))); + let fake = harness.session(FakeReply::err(refused), None); + let query = ExportQuery::Editor { + sql: UserSql::new("INSERT INTO t VALUES (1) RETURNING *"), + }; + + let outcome = harness.finish(harness.ledger.begin(read_job(&path, &fake, query)).expect("started")); + + let ExportOutcome::Failed { + failure: ExportFailure::Statement(error), + .. + } = &outcome + else { + panic!("{outcome:?}"); + }; + assert_eq!(error.category(), Some(ErrorCategory::ReadOnly)); + wait_until(WAIT, || visible_alert(&harness.window).is_some()).expect("the alert"); + let body = visible_alert(&harness.window).expect("the alert").body(); + assert!( + body.starts_with("Export All only runs statements that read data."), + "{body}" + ); +} + +#[gtk4::test] +fn snapshot_export_of_10k_rows_keeps_main_loop_responsive() { + let harness = Harness::new(); + let path = harness.path("rows.csv"); + let ticks = Rc::new(Cell::new(0_u32)); + let counted = ticks.clone(); + let ticking = glib::timeout_add_local(Duration::from_millis(1), move || { + counted.set(counted.get() + 1); + glib::ControlFlow::Continue + }); + + let handle = harness.ledger.begin(job(&path, 10_000)).expect("started"); + let outcome = harness.finish(handle); + ticking.remove(); + + assert!( + matches!(outcome, ExportOutcome::Exported { rows: 10_000 }), + "{outcome:?}" + ); + assert!(ticks.get() > 0, "the main loop never ran while the export did"); + let text = std::fs::read_to_string(&path).expect("the export"); + assert_eq!(text.lines().count(), 10_001); + assert!( + text.starts_with("\"id\",\"name\",\"note\"\n\"0\",\"name 0\""), + "{}", + &text[..40] + ); + assert_eq!(harness.ledger.in_flight(), 0); + assert_eq!(harness.hold.live(), 0, "the export kept the process held"); +} + +#[gtk4::test] +fn stop_button_cancels_job_and_removes_row() { + let harness = Harness::new(); + let path = harness.path("stopped.csv"); + let button = ExportOperationsButton::default(); + button.bind(&harness.ledger.jobs()); + assert!(!button.is_visible(), "the button showed with nothing running"); + + let handle = harness.ledger.begin(job(&path, 10_000)).expect("started"); + assert!(button.is_visible()); + let row = button.job_list().row_at_index(0).expect("the job's row"); + let stop = first_descendant_of_type::(&row).expect("the stop button"); + stop.emit_clicked(); + let outcome = harness.finish(handle); + + assert!( + matches!(outcome, ExportOutcome::Stopped(AbortOutcome::Removed)), + "{outcome:?}" + ); + assert!(button.job_list().row_at_index(0).is_none(), "the row stayed"); + assert!(!button.is_visible(), "the button stayed with nothing running"); + assert!(!path.exists(), "the stopped export left its file"); +} + +#[gtk4::test] +fn ledger_holds_inhibit_cookie_while_exporting() { + let harness = Harness::new(); + assert!(!harness.ledger.is_inhibiting()); + + let handle = harness + .ledger + .begin(job(&harness.path("held.csv"), 100)) + .expect("started"); + assert!(harness.ledger.is_inhibiting()); + assert_eq!( + harness.session.held.borrow().as_slice(), + [( + 1, + gtk::ApplicationInhibitFlags::LOGOUT | gtk::ApplicationInhibitFlags::SUSPEND + )] + ); + assert_eq!(harness.hold.live(), 1); + harness.finish(handle); + + assert!(!harness.ledger.is_inhibiting()); + assert!(harness.session.held.borrow().is_empty(), "the cookie was kept"); + assert_eq!(harness.hold.live(), 0); +} + +#[gtk4::test] +fn close_blocker_reports_running_exports_and_stop_waits_for_idle() { + let harness = Harness::new(); + assert_eq!(harness.ledger.blocking(), None); + + let handle = harness + .ledger + .begin(job(&harness.path("blocked.csv"), 10_000)) + .expect("started"); + let prompt = harness.ledger.blocking().expect("a running export blocks the close"); + assert_eq!(prompt.heading, "Stop Exports?"); + assert!(prompt.body.starts_with("1 export is still running"), "{}", prompt.body); + let stopping = harness.ledger.stop(); + finish_on_main(WAIT, stopping).expect("the stop to settle"); + + assert_eq!(harness.ledger.in_flight(), 0); + assert_eq!(harness.ledger.blocking(), None); + let outcome = harness.finish(handle); + assert!(matches!(outcome, ExportOutcome::Stopped(_)), "{outcome:?}"); +} + +#[gtk4::test] +fn failed_export_presents_alert() { + let harness = Harness::new(); + let path = harness.path("missing").join("rows.csv"); + + let outcome = harness.finish(harness.ledger.begin(job(&path, 10)).expect("started")); + + assert!( + matches!( + &outcome, + ExportOutcome::Failed { + failure: ExportFailure::File(error), + file: None, + } if error.matches(gio::IOErrorEnum::NotFound) + ), + "{outcome:?}" + ); + wait_until(WAIT, || visible_alert(&harness.window).is_some()).expect("the alert"); + let alert = visible_alert(&harness.window).expect("the alert"); + assert_eq!(alert.heading().as_deref(), Some("Couldn't Export")); + assert!(alert.body().contains("rows.csv"), "{}", alert.body()); +} + +#[gtk4::test] +fn a_window_close_asks_to_stop_running_exports() { + let test = crate::ui::session_window::tests::TestWindow::connected("ExportClose"); + let exports = test.workspace().exports().expect("the workspace's exports"); + let path = test.dir().join("closing.csv"); + let _running = exports.begin(job(&path, 10_000)).expect("started"); + + test.window.close(); + wait_until(WAIT, || { + visible_alert(&test.window).is_some_and(|alert| alert.heading().as_deref() == Some("Stop Exports?")) + }) + .expect("the close to ask about the export"); + assert!(answer(&test.window, "stop")); + + wait_until(WAIT, || !test.window.is_visible()).expect("the window to close once the export stopped"); + assert_eq!(exports.in_flight(), 0); +} + +#[gtk4::test] +fn a_shut_down_ledger_refuses_new_exports() { + let harness = Harness::new(); + let path = harness.path("late.csv"); + + harness.ledger.shut_down(); + let refused = harness.ledger.begin(job(&path, 10)); + crate::test_support::drain_main_context(); + + assert!(refused.is_none(), "an export began after the shutdown"); + assert_eq!(harness.ledger.in_flight(), 0); + assert_eq!(harness.hold.live(), 0); + assert!(!path.exists(), "the refused export made its file"); +} + +#[gtk4::test] +fn a_new_file_shows_under_its_display_name() { + use std::os::unix::ffi::OsStrExt; + + let harness = Harness::new(); + // Not UTF-8, so the name GIO shows differs from the bytes on disk. + let path = harness.dir.path().join(std::ffi::OsStr::from_bytes(b"caf\xe9.csv")); + let handle = harness.ledger.begin(job(&path, 10)).expect("started"); + let item = harness + .ledger + .jobs() + .item(0) + .and_downcast::() + .expect("the job's row"); + + let outcome = harness.finish(handle); + + assert!(matches!(outcome, ExportOutcome::Exported { rows: 10 }), "{outcome:?}"); + assert!(item.title().ends_with("(invalid encoding)"), "{}", item.title()); +} + +/// Both exports would race for the file, and the one stopped would +/// remove what the other finished. +#[gtk4::test] +fn a_second_export_to_a_file_being_written_is_refused() { + let harness = Harness::new(); + let path = harness.path("rows.csv"); + + let first = harness.ledger.begin(job(&path, 10_000)).expect("started"); + let second = harness.ledger.begin(job(&path, 10)).expect("started"); + let refused = harness.finish(second); + let exported = harness.finish(first); + + assert!( + matches!( + refused, + ExportOutcome::Failed { + failure: ExportFailure::InUse, + file: None, + } + ), + "{refused:?}" + ); + assert!( + matches!(exported, ExportOutcome::Exported { rows: 10_000 }), + "{exported:?}" + ); + let text = std::fs::read_to_string(&path).expect("the export"); + assert_eq!(text.lines().count(), 10_001); + let alert = visible_alert(&harness.window).expect("the alert"); + assert_eq!( + alert.body(), + "An export to rows.csv is already running. Wait for it to finish, or export to another file." + ); +} + +/// The toast's title is plain text: as markup, the `&` would leave it +/// empty. +#[gtk4::test] +fn a_file_name_with_an_ampersand_shows_in_its_toast() { + let harness = Harness::new(); + let path = harness.path("R&D.csv"); + + let outcome = harness.finish(harness.ledger.begin(job(&path, 10)).expect("started")); + + assert!(matches!(outcome, ExportOutcome::Exported { rows: 10 }), "{outcome:?}"); + let shown = || { + descendants(&harness.window) + .into_iter() + .filter_map(|widget| widget.downcast::().ok()) + .any(|label| label.text() == "Exported 10 rows to R&D.csv") + }; + wait_until(WAIT, shown).expect("the toast to name the file"); +} diff --git a/linux/crates/app/src/ui/export/export_message.rs b/linux/crates/app/src/ui/export/export_message.rs new file mode 100644 index 0000000000..298b0714d1 --- /dev/null +++ b/linux/crates/app/src/ui/export/export_message.rs @@ -0,0 +1,42 @@ +use tablepro_core::export::{EncodeError, RenderError}; +use tablepro_session::runtime::TaskFailure; +use thiserror::Error; + +/// What the producer hands the writer on the GTK thread. +#[derive(Debug)] +pub(crate) enum ExportMessage { + /// Encoded bytes, and how many rows the file holds once they are + /// written. + Chunk { + bytes: Vec, + rows: u64, + }, + /// Rows read so far for a document, which is written only once the + /// last one is in. + Progress { + rows: u64, + }, + Failed(ExportStreamFailure), + Finished { + rows: u64, + }, +} + +/// Why the rows could not be turned into the file's bytes. +#[derive(Debug, Error)] +pub(crate) enum ExportStreamFailure { + #[error(transparent)] + Encode(#[from] EncodeError), + #[error("the statement returned more than one result set")] + MultipleResultSets, + #[error("the read returned no result set")] + NoResultSet, + #[error(transparent)] + Document(#[from] RenderError), + /// A sheet is full and more rows are still coming, so how many there + /// are in all is never read. + #[error("a sheet holds {limit} rows, and the result has more")] + SheetFull { limit: usize }, + #[error(transparent)] + Task(#[from] TaskFailure), +} diff --git a/linux/crates/app/src/ui/export/export_operations_button.rs b/linux/crates/app/src/ui/export/export_operations_button.rs new file mode 100644 index 0000000000..d4d0ec8b47 --- /dev/null +++ b/linux/crates/app/src/ui/export/export_operations_button.rs @@ -0,0 +1,150 @@ +use gtk4 as gtk; +use gtk4::prelude::*; +use gtk4::subclass::prelude::*; +use gtk4::{gio, glib}; +use libadwaita as adw; +use libadwaita::subclass::prelude::*; + +use super::ExportJobItem; +use super::export_failure_text::progress_text; +use crate::i18n::gettext; + +mod imp { + use super::*; + + #[derive(Default, gtk::CompositeTemplate)] + #[template(resource = "/app/tablepro/TablePro/ui/export-operations-button.ui")] + pub struct ExportOperationsButton { + #[template_child] + pub menu_button: TemplateChild, + #[template_child] + pub job_list: TemplateChild, + } + + #[glib::object_subclass] + impl ObjectSubclass for ExportOperationsButton { + const NAME: &'static str = "TableProExportOperationsButton"; + type Type = super::ExportOperationsButton; + 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 ExportOperationsButton {} + impl WidgetImpl for ExportOperationsButton {} + impl BinImpl for ExportOperationsButton {} +} + +glib::wrapper! { + /// The header bar's button for the exports a workspace has running, + /// with a row for each that shows its progress and stops it. + pub struct ExportOperationsButton(ObjectSubclass) + @extends adw::Bin, gtk::Widget, + @implements gtk::Accessible, gtk::Buildable, gtk::ConstraintTarget; +} + +impl Default for ExportOperationsButton { + fn default() -> Self { + glib::Object::new() + } +} + +impl ExportOperationsButton { + /// List `jobs`, and show the button only while there is one. + pub(crate) fn bind(&self, jobs: &gio::ListStore) { + let imp = self.imp(); + imp.job_list.bind_model(Some(jobs), |item| { + item.downcast_ref::() + .map_or_else(|| gtk::ListBoxRow::new().upcast(), job_row) + }); + self.set_visible(jobs.n_items() > 0); + let button = self.downgrade(); + jobs.connect_items_changed(move |jobs, _, _, _| { + if let Some(button) = button.upgrade() { + let running = jobs.n_items() > 0; + if !running { + button.imp().menu_button.popdown(); + } + button.set_visible(running); + } + }); + } + + #[cfg(test)] + pub(crate) fn job_list(&self) -> gtk::ListBox { + self.imp().job_list.get() + } +} + +fn job_row(item: &ExportJobItem) -> gtk::Widget { + let title = gtk::Label::builder() + .xalign(0.0) + .ellipsize(gtk::pango::EllipsizeMode::Middle) + .build(); + item.bind_property("title", &title, "label").sync_create().build(); + let progress = gtk::ProgressBar::new(); + let subtitle = gtk::Label::builder().xalign(0.0).build(); + subtitle.add_css_class("caption"); + subtitle.add_css_class("dim-label"); + let text = gtk::Box::builder() + .orientation(gtk::Orientation::Vertical) + .spacing(6) + .hexpand(true) + .build(); + text.append(&title); + text.append(&progress); + text.append(&subtitle); + + let stop = gtk::Button::from_icon_name(crate::ui::icons::PROCESS_STOP); + crate::ui::a11y::describe_icon_button(&stop, &gettext("Stop Export"), None); + stop.set_valign(gtk::Align::Center); + stop.add_css_class("flat"); + stop.add_css_class("circular"); + let stopping = item.downgrade(); + stop.connect_clicked(move |_| { + if let Some(item) = stopping.upgrade() { + item.stop(); + } + }); + + let content = gtk::Box::builder() + .spacing(12) + .margin_top(12) + .margin_bottom(12) + .margin_start(12) + .margin_end(12) + .build(); + content.append(&text); + content.append(&stop); + + show_progress(item, &progress, &subtitle); + let (progress_bar, subtitle_label) = (progress.downgrade(), subtitle.downgrade()); + item.connect_rows_notify(move |item| { + if let (Some(progress), Some(subtitle)) = (progress_bar.upgrade(), subtitle_label.upgrade()) { + show_progress(item, &progress, &subtitle); + } + }); + + gtk::ListBoxRow::builder() + .activatable(false) + .child(&content) + .build() + .upcast() +} + +/// A bar that fills when the total is known, and pulses with each +/// chunk written when it is not. +fn show_progress(item: &ExportJobItem, progress: >k::ProgressBar, subtitle: >k::Label) { + match item.fraction() { + Some(fraction) => progress.set_fraction(fraction), + None if item.rows() > 0 => progress.pulse(), + None => {} + } + subtitle.set_label(&progress_text(item.rows(), item.expected())); +} diff --git a/linux/crates/app/src/ui/export/export_outcome.rs b/linux/crates/app/src/ui/export/export_outcome.rs new file mode 100644 index 0000000000..08116a3531 --- /dev/null +++ b/linux/crates/app/src/ui/export/export_outcome.rs @@ -0,0 +1,49 @@ +use gtk4::glib; +use tablepro_session::OperationError; +use tablepro_session::runtime::TaskFailure; +use thiserror::Error; + +use super::{AbortOutcome, ExportStreamFailure, OpenError}; + +/// How an export ended. +#[derive(Debug)] +pub(crate) enum ExportOutcome { + Exported { + rows: u64, + }, + Stopped(AbortOutcome), + /// `file` says what became of the file, when the export had opened + /// it. + Failed { + failure: ExportFailure, + file: Option, + }, +} + +#[derive(Debug, Error)] +pub(crate) enum ExportFailure { + #[error(transparent)] + Stream(#[from] ExportStreamFailure), + #[error(transparent)] + File(#[from] glib::Error), + #[error(transparent)] + Task(#[from] TaskFailure), + /// Reading the table failed. + #[error("reading the table failed: {0}")] + Table(OperationError), + /// Running an editor's statement again failed. It ran on a + /// connection of its own, which the alert has to explain. + #[error("running the statement again failed: {0}")] + Statement(OperationError), + #[error("another export is writing to this file")] + InUse, +} + +impl From for ExportFailure { + fn from(error: OpenError) -> Self { + match error { + OpenError::InUse => Self::InUse, + OpenError::File(error) => Self::File(error), + } + } +} diff --git a/linux/crates/app/src/ui/export/export_producer.rs b/linux/crates/app/src/ui/export/export_producer.rs new file mode 100644 index 0000000000..df5881d999 --- /dev/null +++ b/linux/crates/app/src/ui/export/export_producer.rs @@ -0,0 +1,126 @@ +use std::sync::Arc; + +use tablepro_core::browse::build_export; +use tablepro_core::column::ResultColumn; +use tablepro_core::{ReadStatement, ResultEvent, SinkFlow, StreamSummary, Truncation}; +use tablepro_session::runtime::{TaskResult, Tasks}; +use tablepro_session::{OperationError, OperationKind, Session}; +use tokio_util::sync::CancellationToken; + +use super::{ExportQuery, ExportSinkParts}; +use crate::ui::grid::RowSnapshot; + +/// Rows a producer encodes before it looks at the cancel again. +const ROWS_PER_BATCH: usize = 256; + +/// Encode rows the grid already holds, on the runtime, through the same +/// sink a streamed read feeds, so both kinds of export write the same +/// bytes. +pub(crate) fn start_rows_producer( + tasks: &Tasks, + columns: Arc<[ResultColumn]>, + rows: Vec, + parts: ExportSinkParts, + cancel: CancellationToken, +) -> TaskResult<()> { + tasks.spawn_task(async move { + let mut sink = parts.sink(); + if sink.accept(ResultEvent::ResultSetStarted { columns }).await == SinkFlow::ConsumerGone { + return; + } + for batch in rows.chunks(ROWS_PER_BATCH) { + if cancel.is_cancelled() { + return; + } + let batch = batch.iter().map(RowSnapshot::to_values).collect(); + if sink.accept(ResultEvent::Rows(batch)).await == SinkFlow::ConsumerGone { + return; + } + } + if cancel.is_cancelled() { + return; + } + let count = rows.len() as u64; + sink.accept(ResultEvent::ResultSetFinished { + retained: count, + total_rows: Some(count), + truncation: Truncation::Complete, + }) + .await; + }) +} + +/// Stream every row of `query` through `session` into a sink built +/// from `parts`, on a connection of the read's own. The SQL is built +/// from the lease that streams it, so it is spelled for that server. +pub(crate) fn start_query_producer( + tasks: &Tasks, + session: Arc, + query: ExportQuery, + parts: ExportSinkParts, + cancel: CancellationToken, +) -> TaskResult> { + let query = Arc::new(query); + tasks.spawn_task(async move { + session + .run_cancellable(OperationKind::Export, &cancel, move |lease, options| { + let parts = parts.clone(); + let query = Arc::clone(&query); + async move { + let read = match &*query { + ExportQuery::Table(export) => { + ReadStatement::Generated(build_export(&*lease.dialect(), &export.request())?) + } + ExportQuery::Editor { sql } => ReadStatement::User(sql.clone()), + }; + Ok(lease.stream_read(&read, parts.sink(), options).await?) + } + }) + .await + }) +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use tablepro_core::Value; + use tablepro_core::column::ColumnType; + use tablepro_core::export::CsvOptions; + use tokio::sync::mpsc; + + use super::*; + use crate::test_support::paused_tasks; + use crate::ui::export::{ExportFormat, ExportMessage, RowFormat}; + + #[tokio::test(start_paused = true)] + async fn rows_producer_stops_on_cancel() { + let columns: Arc<[ResultColumn]> = vec![ResultColumn::new("a", ColumnType::untyped_text("text"))].into(); + let rows: Vec = (0..10_000) + .map(|row| RowSnapshot::new(vec![Value::Text(format!("{row:01023}"))].into(), Vec::new())) + .collect(); + let (messages, mut received) = mpsc::channel(1); + let cancel = CancellationToken::new(); + let parts = ExportSinkParts { + messages, + format: ExportFormat::Rows(RowFormat::Csv(CsvOptions::default())), + tasks: paused_tasks(), + }; + let producer = start_rows_producer(&paused_tasks(), columns, rows, parts, cancel.clone()); + + let first = received.recv().await; + assert!(matches!(first, Some(ExportMessage::Chunk { .. })), "{first:?}"); + cancel.cancel(); + let mut last_rows = 0; + while let Some(message) = received.recv().await { + match message { + ExportMessage::Chunk { rows, .. } => last_rows = rows, + other => panic!("the producer went on past the cancel: {other:?}"), + } + tokio::time::sleep(Duration::from_millis(1)).await; + } + + producer.await.expect("the producer"); + assert!(last_rows < 10_000, "every row was encoded"); + } +} diff --git a/linux/crates/app/src/ui/export/export_query.rs b/linux/crates/app/src/ui/export/export_query.rs new file mode 100644 index 0000000000..abfd4922e7 --- /dev/null +++ b/linux/crates/app/src/ui/export/export_query.rs @@ -0,0 +1,37 @@ +use std::sync::Arc; + +use tablepro_core::browse::{ExportRequest, SortKey}; +use tablepro_core::meta::{RowIdentity, TableRef}; +use tablepro_core::{ColumnInfo, FilterSet, UserSql}; + +/// A read an export runs on the server rather than rows it already +/// holds. +#[derive(Debug, Clone)] +pub enum ExportQuery { + /// Every row of a table under the grid's filter and sort. + Table(TableExport), + /// The statement behind an editor result, run again from the top. + Editor { sql: UserSql }, +} + +/// A table as a browse tab filters and sorts it. +#[derive(Debug, Clone)] +pub struct TableExport { + pub table: TableRef, + pub columns: Arc<[ColumnInfo]>, + pub filter: FilterSet, + pub sort: Vec, + pub identity: RowIdentity, +} + +impl TableExport { + pub fn request(&self) -> ExportRequest<'_> { + ExportRequest { + table: &self.table, + columns: &self.columns, + filter: &self.filter, + sort: &self.sort, + identity: &self.identity, + } + } +} diff --git a/linux/crates/app/src/ui/export/export_scope.rs b/linux/crates/app/src/ui/export/export_scope.rs new file mode 100644 index 0000000000..a9e5399f6c --- /dev/null +++ b/linux/crates/app/src/ui/export/export_scope.rs @@ -0,0 +1,313 @@ +use std::sync::Arc; + +use tablepro_session::Session; + +use super::{ExpectedRows, ExportQuery, ExportSource}; +use crate::i18n::{gettext, ngettext_f, pgettext}; +use crate::ui::browse_tab::RowTotal; +use crate::ui::export_dialog::SqlTarget; +use crate::ui::format::{format_thousands, plural_count}; + +/// What a tab asks the window to export. +#[derive(Debug, Clone)] +pub(crate) struct ExportOffer { + pub origin: ExportOrigin, + /// The session the tab reads through, which a read of every row + /// goes through too. + pub session: Option>, + pub name: String, + pub target: Option, +} + +/// What the user was looking at when they asked to export, and what +/// else they could export from there. +#[derive(Debug, Clone)] +pub(crate) enum ExportOrigin { + Browse { + /// The page on screen, pending edits included. + page: ExportSource, + query: ExportQuery, + total: RowTotal, + has_unsaved_changes: bool, + }, + Editor { + /// The rows the result holds, which the run's row limit may + /// have cut short. + shown: ExportSource, + query: ExportQuery, + shown_rows: u64, + truncated: bool, + }, +} + +/// Which rows a scope exports. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ScopeSource { + /// The rows the grid holds. + Shown, + /// Every row, read again from the server. + Query, +} + +/// One entry of the dialog's Rows list. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct ScopeChoice { + pub title: String, + pub subtitle: String, + pub source: ScopeSource, + pub expected: Option, +} + +pub(crate) fn scope_choices(origin: &ExportOrigin) -> Vec { + scope_choices_with(origin, format_thousands) +} + +fn scope_choices_with(origin: &ExportOrigin, format: impl Fn(u64) -> String) -> Vec { + match origin { + ExportOrigin::Browse { + page, + query, + total, + has_unsaved_changes, + } => { + let shown = page.row_count().unwrap_or_default() as u64; + let filtered = matches!(query, ExportQuery::Table(export) if !export.filter.is_empty()); + let mut table = vec![table_total(total, &format)]; + if filtered { + table.push(gettext("filters apply")); + } + if *has_unsaved_changes { + table.push(gettext("unsaved changes aren't included")); + } + vec![ + ScopeChoice { + title: gettext("Current Page"), + subtitle: rows_text(shown, &format), + source: ScopeSource::Shown, + expected: Some(ExpectedRows::Exact(shown)), + }, + ScopeChoice { + title: gettext("Entire Table"), + subtitle: table.join(&pgettext("list separator", ", ")), + source: ScopeSource::Query, + expected: expected_total(total), + }, + ] + } + ExportOrigin::Editor { shown_rows, .. } => vec![ + ScopeChoice { + title: gettext("Shown Rows"), + subtitle: rows_text(*shown_rows, &format), + source: ScopeSource::Shown, + expected: Some(ExpectedRows::Exact(*shown_rows)), + }, + ScopeChoice { + title: gettext("All Rows"), + subtitle: gettext( + "Runs the statement again on a separate read-only connection, without this editor's USE or SET changes", + ), + source: ScopeSource::Query, + expected: None, + }, + ], + } +} + +impl ExportOrigin { + /// The choice the dialog opens on: every row when the editor's + /// result was cut short, since that is why the user is here. + pub(crate) fn preselected(&self) -> ScopeSource { + match self { + Self::Editor { truncated: true, .. } => ScopeSource::Query, + Self::Browse { .. } | Self::Editor { .. } => ScopeSource::Shown, + } + } + + /// The rows the grid held. + #[cfg(test)] + pub(crate) fn shown(&self) -> &ExportSource { + match self { + Self::Browse { page, .. } => page, + Self::Editor { shown, .. } => shown, + } + } + + /// The rows `source` stands for. A read needs `session`, and there + /// is none once the window has disconnected. + pub(crate) fn into_source(self, source: ScopeSource, session: Option>) -> Option { + let (shown, query) = match self { + Self::Browse { page, query, .. } => (page, query), + Self::Editor { shown, query, .. } => (shown, query), + }; + match source { + ScopeSource::Shown => Some(shown), + ScopeSource::Query => session.map(|session| ExportSource::Query { session, query }), + } + } +} + +fn rows_text(rows: u64, format: &impl Fn(u64) -> String) -> String { + ngettext_f("{n} row", "{n} rows", plural_count(rows), &[("n", &format(rows))]) +} + +fn table_total(total: &RowTotal, format: &impl Fn(u64) -> String) -> String { + match (total.exact(), total.estimate()) { + (Some(exact), _) => rows_text(exact, format), + (None, Some(estimate)) => ngettext_f( + "About {n} row", + "About {n} rows", + plural_count(estimate), + &[("n", &format(estimate))], + ), + (None, None) => gettext("Every row"), + } +} + +fn expected_total(total: &RowTotal) -> Option { + match (total.exact(), total.estimate()) { + (Some(exact), _) => Some(ExpectedRows::Exact(exact)), + (None, Some(estimate)) => Some(ExpectedRows::About(estimate)), + (None, None) => None, + } +} + +#[cfg(test)] +mod tests { + use tablepro_core::meta::{RowIdentity, TableRef}; + use tablepro_core::{FilterOp, FilterRule, FilterSet, UserSql}; + + use super::*; + use crate::ui::export::TableExport; + use crate::ui::format::Thousands; + + fn english(origin: &ExportOrigin) -> Vec { + let english = Thousands::for_posix_locale("en_US.UTF-8"); + scope_choices_with(origin, |n| english.format(n)) + } + + fn page(rows: usize) -> ExportSource { + ExportSource::Rows { + columns: Vec::new().into(), + rows: (0..rows) + .map(|_| crate::ui::grid::RowSnapshot::new(Vec::new().into(), Vec::new())) + .collect(), + } + } + + fn table(filter: FilterSet) -> ExportQuery { + ExportQuery::Table(TableExport { + table: TableRef::new(None, "users".to_owned()), + columns: Vec::new().into(), + filter, + sort: Vec::new(), + identity: RowIdentity::Unordered, + }) + } + + fn browse(total: RowTotal, filter: FilterSet, has_unsaved_changes: bool) -> ExportOrigin { + ExportOrigin::Browse { + page: page(3), + query: table(filter), + total, + has_unsaved_changes, + } + } + + fn editor(shown_rows: u64, truncated: bool) -> ExportOrigin { + ExportOrigin::Editor { + shown: page(0), + query: ExportQuery::Editor { + sql: UserSql::new("SELECT 1"), + }, + shown_rows, + truncated, + } + } + + fn filtered() -> FilterSet { + FilterSet { + rules: vec![FilterRule { + column: "name".to_owned(), + op: FilterOp::IsNull, + value: None, + }], + ..FilterSet::default() + } + } + + #[test] + fn scope_choices_browse_entire_table_uses_about_for_estimates() { + let estimated = english(&browse(RowTotal::Estimated(5_000_000), FilterSet::default(), false)); + let exact = english(&browse(RowTotal::Exact(1), FilterSet::default(), false)); + let unknown = english(&browse(RowTotal::Unknown, FilterSet::default(), false)); + let counting = english(&browse( + RowTotal::Counting { estimate: Some(40) }, + FilterSet::default(), + false, + )); + + assert_eq!(estimated[0].title, "Current Page"); + assert_eq!(estimated[0].subtitle, "3 rows"); + assert_eq!(estimated[0].expected, Some(ExpectedRows::Exact(3))); + assert_eq!(estimated[1].title, "Entire Table"); + assert_eq!(estimated[1].subtitle, "About 5,000,000 rows"); + assert_eq!(estimated[1].source, ScopeSource::Query); + assert_eq!(estimated[1].expected, Some(ExpectedRows::About(5_000_000))); + assert_eq!(exact[1].subtitle, "1 row"); + assert_eq!(exact[1].expected, Some(ExpectedRows::Exact(1))); + assert_eq!(counting[1].subtitle, "About 40 rows"); + assert_eq!(unknown[1].subtitle, "Every row"); + assert_eq!(unknown[1].expected, None, "an unknown total has nothing to fill a bar"); + } + + #[test] + fn scope_choices_browse_notes_unsaved_changes() { + let clean = english(&browse(RowTotal::Exact(10), FilterSet::default(), false)); + let dirty = english(&browse(RowTotal::Exact(10), filtered(), true)); + + assert_eq!(clean[1].subtitle, "10 rows"); + assert_eq!( + dirty[1].subtitle, + "10 rows, filters apply, unsaved changes aren't included" + ); + assert_eq!(dirty[0].subtitle, "3 rows", "the page holds the pending edits"); + } + + #[test] + fn scope_choices_editor_preselects_all_rows_when_truncated() { + let truncated = editor(1_000, true); + let complete = editor(10, false); + let choices = english(&truncated); + + assert_eq!( + choices.iter().map(|choice| choice.title.as_str()).collect::>(), + ["Shown Rows", "All Rows"] + ); + assert_eq!(choices[0].subtitle, "1,000 rows"); + assert_eq!(choices[1].expected, None); + assert_eq!(truncated.preselected(), ScopeSource::Query); + assert_eq!(complete.preselected(), ScopeSource::Shown); + assert_eq!( + browse(RowTotal::Unknown, FilterSet::default(), false).preselected(), + ScopeSource::Shown + ); + } + + #[test] + fn scope_subtitles_use_thousands_separators() { + let browse = english(&browse(RowTotal::Exact(1_234_567), FilterSet::default(), false)); + let editor = english(&editor(12_345, true)); + + assert_eq!(browse[1].subtitle, "1,234,567 rows"); + assert_eq!(editor[0].subtitle, "12,345 rows"); + } + + #[test] + fn a_read_needs_a_session() { + let shown = editor(1, true).into_source(ScopeSource::Shown, None); + let read = editor(1, true).into_source(ScopeSource::Query, None); + + assert!(matches!(shown, Some(ExportSource::Rows { .. }))); + assert!(read.is_none()); + } +} diff --git a/linux/crates/app/src/ui/export/export_sink_parts.rs b/linux/crates/app/src/ui/export/export_sink_parts.rs new file mode 100644 index 0000000000..7c24bf07cc --- /dev/null +++ b/linux/crates/app/src/ui/export/export_sink_parts.rs @@ -0,0 +1,75 @@ +use tablepro_core::ResultSink; +use tablepro_session::runtime::Tasks; +use tokio::sync::mpsc; + +use super::{DocumentSink, EncodingSink, ExportFormat, ExportMessage}; + +/// What a sink for one export is made from. The channel is bounded, so +/// a disk slower than the encoder holds the encoder back rather than +/// queueing the whole result in memory. +#[derive(Clone)] +pub(crate) struct ExportSinkParts { + pub messages: mpsc::Sender, + pub format: ExportFormat, + /// Where a document is rendered once its last row is in. + pub tasks: Tasks, +} + +impl ExportSinkParts { + /// A fresh sink, for one run of the read. + pub(crate) fn sink(&self) -> Box { + match &self.format { + ExportFormat::Rows(format) => Box::new(EncodingSink::new(self.messages.clone(), format.clone())), + ExportFormat::Document(format) => Box::new(DocumentSink::new( + self.messages.clone(), + format.clone(), + self.tasks.clone(), + )), + } + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use tablepro_core::column::{ColumnType, ResultColumn}; + use tablepro_core::{ResultEvent, SinkFlow, Value}; + use tablepro_test_support::FakeDialect; + + use super::*; + use crate::test_support::paused_tasks; + use crate::ui::export::{InsertTarget, RowFormat}; + + #[tokio::test(start_paused = true)] + async fn html_xml_and_sql_are_written_before_the_last_row_is_in() { + let formats = [ + RowFormat::Html { title: "t".to_owned() }, + RowFormat::Xml, + RowFormat::Sql(InsertTarget { + table: "t".to_owned(), + dialect: Arc::new(FakeDialect), + }), + ]; + for format in formats { + let (messages, mut received) = mpsc::channel(64); + let parts = ExportSinkParts { + messages, + format: ExportFormat::Rows(format.clone()), + tasks: paused_tasks(), + }; + let mut sink = parts.sink(); + let columns: Arc<[ResultColumn]> = vec![ResultColumn::new("a", ColumnType::untyped_text("text"))].into(); + let rows = (0..200).map(|row| vec![Value::Text(format!("{row:01023}"))]).collect(); + + sink.accept(ResultEvent::ResultSetStarted { columns }).await; + assert_eq!(sink.accept(ResultEvent::Rows(rows)).await, SinkFlow::Continue); + + let first = received.try_recv(); + assert!( + matches!(first, Ok(ExportMessage::Chunk { .. })), + "{format:?} held its rows until the end: {first:?}" + ); + } + } +} diff --git a/linux/crates/app/src/ui/export/export_source.rs b/linux/crates/app/src/ui/export/export_source.rs new file mode 100644 index 0000000000..1819e205f8 --- /dev/null +++ b/linux/crates/app/src/ui/export/export_source.rs @@ -0,0 +1,33 @@ +use std::sync::Arc; + +use tablepro_core::column::ResultColumn; +use tablepro_session::Session; + +use super::ExportQuery; +use crate::ui::grid::RowSnapshot; + +/// Where an export reads its rows. +#[derive(Debug, Clone)] +pub enum ExportSource { + /// The rows a grid showed when the user asked, pending edits + /// included. They share the cells the grid holds rather than copy + /// them. + Rows { + columns: Arc<[ResultColumn]>, + rows: Vec, + }, + /// A read streamed from the server through `session`, every row of + /// it. + Query { session: Arc, query: ExportQuery }, +} + +impl ExportSource { + /// The rows it holds. `None` for a read, whose count only the + /// server knows. + pub fn row_count(&self) -> Option { + match self { + Self::Rows { rows, .. } => Some(rows.len()), + Self::Query { .. } => None, + } + } +} diff --git a/linux/crates/app/src/ui/export/mod.rs b/linux/crates/app/src/ui/export/mod.rs new file mode 100644 index 0000000000..e21c0dd4b5 --- /dev/null +++ b/linux/crates/app/src/ui/export/mod.rs @@ -0,0 +1,39 @@ +//! Exports that write their file off the GTK thread: rows a grid holds, +//! or every row of a read streamed from the server, are encoded on the +//! runtime and handed over in chunks, GIO writes them, and an export +//! that stops or fails leaves the file as close to how it was as GIO +//! allows. The workspace's ledger tracks the running ones for the +//! header bar, for logout and for the close prompts. + +mod document_sink; +mod encoding_sink; +mod export_failure_text; +mod export_format; +mod export_job; +mod export_job_item; +mod export_ledger; +mod export_message; +mod export_operations_button; +mod export_outcome; +mod export_producer; +mod export_query; +mod export_scope; +mod export_sink_parts; +mod export_source; +mod replace_file_writer; + +pub(crate) use document_sink::DocumentSink; +pub(crate) use encoding_sink::EncodingSink; +pub(crate) use export_failure_text::{ExportReporter, failure_text}; +pub(crate) use export_format::{DocumentFormat, ExportFormat, InsertTarget, RowFormat}; +pub(crate) use export_job::{ExpectedRows, ExportJob, ExportJobId}; +pub(crate) use export_job_item::ExportJobItem; +pub(crate) use export_ledger::{ExportLedger, ExportLedgerInit}; +pub(crate) use export_message::{ExportMessage, ExportStreamFailure}; +pub(crate) use export_operations_button::ExportOperationsButton; +pub(crate) use export_outcome::{ExportFailure, ExportOutcome}; +pub(crate) use export_query::{ExportQuery, TableExport}; +pub(crate) use export_scope::{ExportOffer, ExportOrigin, ScopeChoice, ScopeSource, scope_choices}; +pub(crate) use export_sink_parts::ExportSinkParts; +pub(crate) use export_source::ExportSource; +pub(crate) use replace_file_writer::{AbortOutcome, FinishError, OpenError, ReplaceFileWriter}; diff --git a/linux/crates/app/src/ui/export/replace_file_writer.rs b/linux/crates/app/src/ui/export/replace_file_writer.rs new file mode 100644 index 0000000000..edf271bfc7 --- /dev/null +++ b/linux/crates/app/src/ui/export/replace_file_writer.rs @@ -0,0 +1,284 @@ +use std::cell::RefCell; +use std::collections::HashSet; + +use futures::channel::oneshot; +use gtk4::prelude::*; +use gtk4::{gio, glib}; +use thiserror::Error; + +/// How a stopped export left its file. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum AbortOutcome { + /// The export made the file, and it is gone again. + Removed, + /// The file that was there is as it was. + Untouched, + /// The file holds part of the export. + Incomplete, +} + +/// A close that failed, and what it left of the file. +#[derive(Debug)] +pub(crate) struct FinishError { + pub error: glib::Error, + pub file: AbortOutcome, +} + +#[derive(Debug, Error)] +pub(crate) enum OpenError { + /// Another export in this process is writing the file. Both would + /// race for it, and the one that stopped would remove what the + /// other finished. + #[error("another export is writing to this file")] + InUse, + #[error(transparent)] + File(#[from] glib::Error), +} + +/// How the writer came to hold the file. +#[derive(Debug, Clone)] +enum Strategy { + /// `made` names the file the writer made, so a stopped export + /// removes that one and not a file put at its path since. `None` + /// where the backend has no inode, and the file then stays, since + /// it cannot be told from one put there since. + Created { made: Option }, + /// `before` is the file's tag when it was opened, to tell afterwards + /// whether a stopped export changed it. + Replaced { before: Option }, +} + +/// Writes an export to its file without losing the file that was there +/// when the export stops partway. +/// +/// GIO writes on its own worker threads, so the GTK thread only waits. +/// A local replace stream renames its temporary file over the +/// destination when it closes, unless the close sees a cancelled +/// cancellable, and dispose closes without one. GLib takes the +/// temporary-file path only for an existing regular file with a single +/// link whose directory takes the temporary file; otherwise it truncates +/// the destination when it opens it. So an abort leaves the old file +/// untouched only on that path, and comparing tags reports the rest. +pub(crate) struct ReplaceFileWriter { + file: gio::File, + /// Taken by `finish` or `abort`. One still here at drop belongs to an + /// export that never finished. + stream: Option, + strategy: Strategy, + /// Let go once the file is cleaned up, which for a dropped writer is + /// after its abort. + claim: Option, +} + +impl ReplaceFileWriter { + pub(crate) async fn open(file: &gio::File) -> Result { + let claim = DestinationClaim::take(file).ok_or(OpenError::InUse)?; + // An export holds table rows or query history, so a new file is + // the user's alone. A replaced file keeps the mode it had. + let (stream, strategy) = match file + .create_future(gio::FileCreateFlags::PRIVATE, glib::Priority::DEFAULT) + .await + { + Ok(stream) => { + let made = FileIdentity::of_stream(&stream).await; + (stream, Strategy::Created { made }) + } + Err(error) if error.matches(gio::IOErrorEnum::Exists) => { + let before = etag(file).await; + // Writing through a symbolic link rather than replacing + // it, as GtkSourceView's file saver does. + let stream = file + .replace_future(None, false, gio::FileCreateFlags::NONE, glib::Priority::DEFAULT) + .await?; + (stream, Strategy::Replaced { before }) + } + Err(error) => return Err(error.into()), + }; + Ok(Self { + file: file.clone(), + stream: Some(stream), + strategy, + claim: Some(claim), + }) + } + + pub(crate) async fn write(&self, chunk: Vec) -> Result<(), glib::Error> { + let Some(stream) = self.stream.as_ref() else { + return Err(closed()); + }; + match stream.write_all_future(chunk, glib::Priority::DEFAULT).await { + Ok((_, _, None)) => Ok(()), + Ok((_, _, Some(error))) | Err((_, error)) => Err(error), + } + } + + /// Close the file, which for a replaced one puts the new content in + /// its place. A close can still fail, on a network or full disk, and + /// the file is then cleaned up as a stopped export's is. + pub(crate) async fn finish(mut self) -> Result<(), FinishError> { + let Some(stream) = self.stream.take() else { + return Err(FinishError { + error: closed(), + file: AbortOutcome::Incomplete, + }); + }; + match stream.close_future(glib::Priority::DEFAULT).await { + Ok(()) => Ok(()), + Err(error) => Err(FinishError { + error, + file: left_behind(&self.file, &self.strategy).await, + }), + } + } + + /// Stop writing and leave the file as close to how it was as GIO + /// allows. + pub(crate) async fn abort(mut self) -> AbortOutcome { + match self.stream.take() { + Some(stream) => abort(&self.file, &stream, &self.strategy).await, + None => AbortOutcome::Incomplete, + } + } +} + +impl Drop for ReplaceFileWriter { + fn drop(&mut self) { + if let Some(stream) = self.stream.take() { + let (file, strategy, claim) = (self.file.clone(), self.strategy.clone(), self.claim.take()); + glib::spawn_future_local(async move { + let outcome = abort(&file, &stream, &strategy).await; + drop(claim); + tracing::debug!(?outcome, "an unfinished export was abandoned"); + }); + } + } +} + +async fn abort(file: &gio::File, stream: &gio::FileOutputStream, strategy: &Strategy) -> AbortOutcome { + close_cancelled(stream).await; + left_behind(file, strategy).await +} + +/// Remove a file the export made, if it is still the one at the path, +/// and tell whether one it replaced is as it was. A replace stream +/// whose close failed before its rename has dropped its temporary file, +/// so the old file can still be whole. +async fn left_behind(file: &gio::File, strategy: &Strategy) -> AbortOutcome { + match strategy { + Strategy::Created { made: None } => AbortOutcome::Incomplete, + Strategy::Created { made: Some(made) } => { + if FileIdentity::at_path(file).await != Some(*made) { + tracing::debug!("a stopped export's file was replaced at its path, which is left as it is"); + return AbortOutcome::Removed; + } + match file.delete_future(glib::Priority::DEFAULT).await { + Ok(()) => AbortOutcome::Removed, + Err(error) => { + tracing::warn!(%error, "a stopped export's file could not be removed"); + AbortOutcome::Incomplete + } + } + } + Strategy::Replaced { before } => match (before, etag(file).await) { + (Some(before), Some(after)) if *before == after => AbortOutcome::Untouched, + _ => AbortOutcome::Incomplete, + }, + } +} + +/// Close `stream` with a cancellable that is already cancelled, which +/// a replace stream takes as the order to drop its temporary file. +async fn close_cancelled(stream: &gio::FileOutputStream) { + let cancellable = gio::Cancellable::new(); + cancellable.cancel(); + let (sender, receiver) = oneshot::channel(); + stream.close_async(glib::Priority::DEFAULT, Some(&cancellable), move |closed| { + let _ = sender.send(closed); + }); + // The close reports the cancel; what it did to the file is read + // back by the caller. + let _ = receiver.await; +} + +async fn etag(file: &gio::File) -> Option { + file.query_info_future( + gio::FILE_ATTRIBUTE_ETAG_VALUE, + gio::FileQueryInfoFlags::NONE, + glib::Priority::DEFAULT, + ) + .await + .ok() + .and_then(|info| info.etag()) +} + +const IDENTITY_ATTRIBUTES: &str = "unix::device,unix::inode"; + +/// A file as the file system knows it, whatever path leads to it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct FileIdentity { + device: u32, + inode: u64, +} + +impl FileIdentity { + async fn of_stream(stream: &gio::FileOutputStream) -> Option { + let info = stream + .query_info_future(IDENTITY_ATTRIBUTES, glib::Priority::DEFAULT) + .await + .inspect_err(|error| tracing::debug!(%error, "an export's new file has no identity")) + .ok()?; + Self::from_info(&info) + } + + async fn at_path(file: &gio::File) -> Option { + let info = file + .query_info_future( + IDENTITY_ATTRIBUTES, + gio::FileQueryInfoFlags::NOFOLLOW_SYMLINKS, + glib::Priority::DEFAULT, + ) + .await + .ok()?; + Self::from_info(&info) + } + + fn from_info(info: &gio::FileInfo) -> Option { + let known = + info.has_attribute(gio::FILE_ATTRIBUTE_UNIX_DEVICE) && info.has_attribute(gio::FILE_ATTRIBUTE_UNIX_INODE); + known.then(|| Self { + device: info.attribute_uint32(gio::FILE_ATTRIBUTE_UNIX_DEVICE), + inode: info.attribute_uint64(gio::FILE_ATTRIBUTE_UNIX_INODE), + }) + } +} + +thread_local! { + /// The destinations a writer in this process holds. Every window + /// writes to the same file system, so one list serves them all. + static CLAIMED: RefCell> = RefCell::default(); +} + +#[derive(Debug)] +struct DestinationClaim(String); + +impl DestinationClaim { + fn take(file: &gio::File) -> Option { + let uri = file.uri().to_string(); + CLAIMED + .with_borrow_mut(|claimed| claimed.insert(uri.clone())) + .then(|| Self(uri)) + } +} + +impl Drop for DestinationClaim { + fn drop(&mut self) { + CLAIMED.with_borrow_mut(|claimed| claimed.remove(&self.0)); + } +} + +fn closed() -> glib::Error { + glib::Error::new(gio::IOErrorEnum::Closed, "the export's file is already closed") +} + +#[cfg(test)] +mod tests; diff --git a/linux/crates/app/src/ui/export/replace_file_writer/tests.rs b/linux/crates/app/src/ui/export/replace_file_writer/tests.rs new file mode 100644 index 0000000000..87266aeda8 --- /dev/null +++ b/linux/crates/app/src/ui/export/replace_file_writer/tests.rs @@ -0,0 +1,296 @@ +use std::future::Future; +use std::path::Path; +use std::time::{Duration, SystemTime}; + +use super::*; +use crate::test_support::{finish_on_main, wait_until}; + +const WAIT: Duration = Duration::from_secs(10); + +fn run(future: impl Future + 'static) -> T { + finish_on_main(WAIT, future).expect("the file operation to end") +} + +fn open(path: &Path) -> ReplaceFileWriter { + let file = gio::File::for_path(path); + run(async move { ReplaceFileWriter::open(&file).await }).expect("the writer") +} + +fn write(writer: ReplaceFileWriter, chunks: &[&str]) -> ReplaceFileWriter { + let chunks: Vec> = chunks.iter().map(|chunk| chunk.as_bytes().to_vec()).collect(); + run(async move { + for chunk in chunks { + writer.write(chunk).await.expect("a written chunk"); + } + writer + }) +} + +fn entries(dir: &Path) -> Vec { + let mut names: Vec = std::fs::read_dir(dir) + .expect("the directory") + .map(|entry| entry.expect("an entry").file_name().to_string_lossy().into_owned()) + .collect(); + names.sort(); + names +} + +/// A file last changed long ago, so a write now changes its tag. +fn seed(path: &Path, content: &str) { + std::fs::write(path, content).expect("seed"); + std::fs::File::options() + .write(true) + .open(path) + .and_then(|file| file.set_modified(SystemTime::UNIX_EPOCH + Duration::from_secs(1_000_000))) + .expect("an old modification time"); +} + +#[gtk4::test] +fn writer_creates_new_file_and_finish_keeps_it() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("export.csv"); + + let writer = write(open(&path), &["a,b\n", "1,2\n"]); + run(async move { writer.finish().await }).expect("closed"); + + assert_eq!(std::fs::read_to_string(&path).expect("read back"), "a,b\n1,2\n"); + assert_eq!(entries(dir.path()), ["export.csv"]); +} + +#[gtk4::test] +fn abort_after_create_removes_new_file() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("export.csv"); + + let writer = write(open(&path), &["a,b\n"]); + let outcome = run(async move { writer.abort().await }); + + assert_eq!(outcome, AbortOutcome::Removed); + assert!(entries(dir.path()).is_empty(), "{:?}", entries(dir.path())); +} + +#[gtk4::test] +fn abort_on_regular_file_leaves_content_and_reports_untouched() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("export.csv"); + seed(&path, "old"); + + let writer = write(open(&path), &["new and partial"]); + let outcome = run(async move { writer.abort().await }); + + assert_eq!(outcome, AbortOutcome::Untouched); + assert_eq!(std::fs::read_to_string(&path).expect("read back"), "old"); + assert_eq!( + entries(dir.path()), + ["export.csv"], + "the temporary file was left behind" + ); +} + +/// GLib renames its temporary file over the destination as the stream +/// closes. With that file gone the rename fails after every chunk was +/// written, the way a close on a full or network disk does. +#[gtk4::test] +fn failed_finish_on_regular_file_reports_untouched() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("export.csv"); + seed(&path, "old"); + + let writer = write(open(&path), &["new"]); + let temporary = entries(dir.path()) + .into_iter() + .find(|name| name != "export.csv") + .expect("the temporary file"); + std::fs::remove_file(dir.path().join(temporary)).expect("the temporary file removed"); + let failed = run(async move { writer.finish().await }).expect_err("the rename to fail"); + + assert_eq!(failed.file, AbortOutcome::Untouched); + assert_eq!(std::fs::read_to_string(&path).expect("read back"), "old"); + assert_eq!(entries(dir.path()), ["export.csv"]); +} + +#[gtk4::test] +fn file_made_by_export_whose_close_failed_is_removed() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("export.csv"); + std::fs::write(&path, "partial").expect("a partial file"); + let file = gio::File::for_path(&path); + + let outcome = run(async move { + let made = FileIdentity::at_path(&file).await; + assert!(made.is_some(), "a local file has an inode"); + left_behind(&file, &Strategy::Created { made }).await + }); + + assert_eq!(outcome, AbortOutcome::Removed); + assert!(entries(dir.path()).is_empty(), "{:?}", entries(dir.path())); +} + +#[gtk4::test] +fn file_whose_identity_is_unknown_is_kept() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("export.csv"); + std::fs::write(&path, "partial").expect("a partial file"); + let file = gio::File::for_path(&path); + + let outcome = run(async move { left_behind(&file, &Strategy::Created { made: None }).await }); + + assert_eq!(outcome, AbortOutcome::Incomplete); + assert_eq!(entries(dir.path()), ["export.csv"]); +} + +#[gtk4::test] +fn finish_through_symlink_keeps_link_and_writes_target() { + let dir = tempfile::tempdir().expect("tempdir"); + let target = dir.path().join("target.csv"); + let link = dir.path().join("link.csv"); + seed(&target, "old"); + std::os::unix::fs::symlink(&target, &link).expect("a link"); + + let writer = write(open(&link), &["new"]); + run(async move { writer.finish().await }).expect("closed"); + + assert!( + std::fs::symlink_metadata(&link) + .expect("the link") + .file_type() + .is_symlink(), + "the link was replaced by a file" + ); + assert_eq!(std::fs::read_to_string(&target).expect("read back"), "new"); +} + +#[gtk4::test] +fn abort_through_symlink_reports_incomplete() { + let dir = tempfile::tempdir().expect("tempdir"); + let target = dir.path().join("target.csv"); + let link = dir.path().join("link.csv"); + seed(&target, "old"); + std::os::unix::fs::symlink(&target, &link).expect("a link"); + + let writer = write(open(&link), &["partial"]); + let outcome = run(async move { writer.abort().await }); + + assert_eq!(outcome, AbortOutcome::Incomplete); +} + +#[gtk4::test] +fn dropping_unfinished_writer_leaves_existing_file_untouched() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("export.csv"); + seed(&path, "old"); + + let writer = write(open(&path), &["new and partial"]); + assert_eq!(entries(dir.path()).len(), 2, "no temporary file to write into"); + drop(writer); + + wait_until(WAIT, || entries(dir.path()).len() == 1).expect("the temporary file to go"); + crate::test_support::drain_main_context(); + assert_eq!(std::fs::read_to_string(&path).expect("read back"), "old"); +} + +/// A directory's permissions do not stop root, which the CI container +/// runs as, so a folder that is not there stands in for one the user +/// cannot write to. +#[gtk4::test] +fn writer_error_on_missing_directory_reports_glib_error() { + let dir = tempfile::tempdir().expect("tempdir"); + let file = gio::File::for_path(dir.path().join("missing").join("export.csv")); + + let error = run(async move { ReplaceFileWriter::open(&file).await.map(drop) }).expect_err("nowhere to write"); + + assert!( + matches!(&error, OpenError::File(error) if error.matches(gio::IOErrorEnum::NotFound)), + "{error}" + ); +} + +/// GLib's replace renames its temporary file over the path, as another +/// program's save does. The export's own file is gone then, and the +/// one at the path is not its to remove. +#[gtk4::test] +fn abort_after_create_keeps_a_file_put_at_the_path_since() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("export.csv"); + let writer = write(open(&path), &["partial"]); + let theirs = dir.path().join("theirs.tmp"); + std::fs::write(&theirs, "finished elsewhere").expect("their file"); + std::fs::rename(&theirs, &path).expect("their file renamed over the export"); + + let outcome = run(async move { writer.abort().await }); + + assert_eq!(outcome, AbortOutcome::Removed); + assert_eq!( + std::fs::read_to_string(&path).expect("their file"), + "finished elsewhere" + ); +} + +#[gtk4::test] +fn a_second_writer_to_a_file_being_written_is_refused() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("export.csv"); + let first = write(open(&path), &["first"]); + let try_open = || { + let file = gio::File::for_path(&path); + run(async move { ReplaceFileWriter::open(&file).await.map(drop) }) + }; + + let refused = try_open(); + let refused_again = try_open(); + run(async move { first.finish().await }).expect("closed"); + + assert!(matches!(refused, Err(OpenError::InUse)), "{refused:?}"); + assert!( + matches!(refused_again, Err(OpenError::InUse)), + "a refused open let go of the first writer's claim: {refused_again:?}" + ); + assert_eq!(std::fs::read_to_string(&path).expect("read back"), "first"); + let again = write(open(&path), &["again"]); + run(async move { again.finish().await }).expect("the file is free once the first closed"); + assert_eq!(std::fs::read_to_string(&path).expect("read back"), "again"); +} + +#[gtk4::test] +fn a_dropped_writer_frees_its_file_once_cleaned_up() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("export.csv"); + + drop(write(open(&path), &["partial"])); + wait_until(WAIT, || entries(dir.path()).is_empty()).expect("the abandoned file to go"); + crate::test_support::drain_main_context(); + + let writer = write(open(&path), &["next"]); + run(async move { writer.finish().await }).expect("closed"); + assert_eq!(std::fs::read_to_string(&path).expect("read back"), "next"); +} + +#[gtk4::test] +fn a_new_file_is_readable_by_its_owner_only() { + use std::os::unix::fs::PermissionsExt; + + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("export.csv"); + + let writer = write(open(&path), &["a,b\n"]); + run(async move { writer.finish().await }).expect("closed"); + + let mode = std::fs::metadata(&path).expect("the file").permissions().mode(); + assert_eq!(mode & 0o777, 0o600, "{mode:o}"); +} + +#[gtk4::test] +fn a_replaced_file_keeps_its_mode() { + use std::os::unix::fs::PermissionsExt; + + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("export.csv"); + seed(&path, "old"); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o640)).expect("a mode"); + + let writer = write(open(&path), &["new"]); + run(async move { writer.finish().await }).expect("closed"); + + let mode = std::fs::metadata(&path).expect("the file").permissions().mode(); + assert_eq!(mode & 0o777, 0o640, "{mode:o}"); +} diff --git a/linux/crates/app/src/ui/export_dialog.rs b/linux/crates/app/src/ui/export_dialog.rs index ee1ed8dec3..02ad86b11b 100644 --- a/linux/crates/app/src/ui/export_dialog.rs +++ b/linux/crates/app/src/ui/export_dialog.rs @@ -1,3 +1,4 @@ +use std::cell::RefCell; use std::rc::Rc; use std::sync::Arc; @@ -5,9 +6,16 @@ use relm4::adw::prelude::*; use relm4::gtk::{gio, glib}; use relm4::{adw, gtk}; -use tablepro_core::QueryResult; use tablepro_core::dialect::SqlDialect; -use tablepro_core::export::{self, CsvDecimal, CsvDelimiter, CsvLineBreak, CsvOptions, CsvQuote}; +use tablepro_core::export::{CsvDecimal, CsvDelimiter, CsvLineBreak, CsvOptions, CsvQuote, XLSX_MAX_DATA_ROWS}; +use tablepro_session::Session; + +use super::export::{ + DocumentFormat, ExpectedRows, ExportFormat, ExportJob, ExportLedger, ExportOffer, ExportOrigin, ExportSource, + InsertTarget, RowFormat, ScopeChoice, ScopeSource, scope_choices, +}; +use crate::i18n::gettext_f; +use crate::ui::format::format_thousands; /// The table a result can be written back to as `INSERT` statements. /// @@ -18,14 +26,6 @@ pub struct SqlTarget { pub table: String, } -/// A table to insert into and the spelling of the server the rows came -/// from. SQL is offered only with both. -#[derive(Debug, Clone)] -struct InsertTarget { - table: String, - dialect: Arc, -} - #[derive(Clone, Copy, PartialEq, Eq)] enum Format { Csv, @@ -89,6 +89,34 @@ impl Format { } } + /// What a job writes in this format. `None` for SQL without a table + /// to insert into, which the dialog never offers. + fn job_format(self, csv: CsvOptions, name: &str, target: Option<&InsertTarget>) -> Option { + Some(match self { + Format::Csv => ExportFormat::Rows(RowFormat::Csv(csv)), + Format::Json => ExportFormat::Rows(RowFormat::Json), + Format::Markdown => ExportFormat::Rows(RowFormat::Markdown), + Format::Xlsx => ExportFormat::Document(DocumentFormat::Xlsx { sheet: name.to_owned() }), + Format::Html => ExportFormat::Rows(RowFormat::Html { title: name.to_owned() }), + Format::Xml => ExportFormat::Rows(RowFormat::Xml), + Format::Sql => ExportFormat::Rows(RowFormat::Sql(target?.clone())), + }) + } + + /// Why rows numbering `expected` can't be written in this format, + /// told before the export reads them all: a table known to hold + /// more rows than an Excel sheet does. + fn refusal(self, expected: Option) -> Option { + let limit = XLSX_MAX_DATA_ROWS as u64; + match (self, expected) { + (Format::Xlsx, Some(ExpectedRows::Exact(rows))) if rows > limit => Some(gettext_f( + "An Excel sheet holds {limit} rows, and this export has {rows}. Export it as CSV instead.", + &[("limit", &format_thousands(limit)), ("rows", &format_thousands(rows))], + )), + _ => None, + } + } + fn mime_type(self) -> &'static str { match self { Format::Csv => "text/csv", @@ -164,15 +192,66 @@ fn combo_row(title: &str, choices: &[&str]) -> adw::ComboRow { .build() } -pub fn present( - parent: &adw::ApplicationWindow, - toast_overlay: &adw::ToastOverlay, - result: QueryResult, - name: String, - target: Option, - dialect: Option>, - settings: &Rc, -) { +/// What the dialog exports, and where it hands the job. +pub(crate) struct ExportDialogInit<'a> { + pub parent: &'a adw::ApplicationWindow, + pub exports: &'a Rc, + pub offer: ExportOffer, + pub dialect: Option>, + pub settings: &'a Rc, +} + +/// The Rows list: which rows the export writes, one entry per scope +/// the origin offers. +struct ScopeRow { + row: adw::ComboRow, + choices: Vec, +} + +impl ScopeRow { + fn new(origin: &ExportOrigin, can_read: bool) -> Self { + let mut choices = scope_choices(origin); + // A read of every row goes through the tab's session, and a + // window that disconnected has none. + choices.retain(|choice| can_read || choice.source == ScopeSource::Shown); + let titles: Vec<&str> = choices.iter().map(|choice| choice.title.as_str()).collect(); + let row = combo_row(&crate::i18n::gettext("Rows"), &titles); + let preselected = origin.preselected(); + let index = choices + .iter() + .position(|choice| choice.source == preselected) + .unwrap_or_default(); + row.set_selected(u32::try_from(index).unwrap_or_default()); + row.set_sensitive(choices.len() > 1); + let subtitles: Vec = choices.iter().map(|choice| choice.subtitle.clone()).collect(); + let show_subtitle = move |row: &adw::ComboRow| { + let subtitle = subtitles.get(row.selected() as usize).map_or("", String::as_str); + row.set_subtitle(subtitle); + }; + show_subtitle(&row); + row.connect_selected_notify(show_subtitle); + Self { row, choices } + } + + fn selected(&self) -> Option<&ScopeChoice> { + self.choices.get(self.row.selected() as usize) + } +} + +pub(crate) fn present(init: ExportDialogInit<'_>) { + let ExportDialogInit { + parent, + exports, + offer, + dialect, + settings, + } = init; + let ExportOffer { + origin, + session, + name, + target, + } = offer; let target = target.zip(dialect).map(|(target, dialect)| InsertTarget { table: target.table, dialect, @@ -181,15 +260,10 @@ pub fn present( let page = adw::PreferencesPage::new(); let format_group = adw::PreferencesGroup::new(); + let scope = ScopeRow::new(&origin, session.is_some()); + format_group.add(&scope.row); let format_labels: Vec<&str> = formats.iter().map(|f| f.label()).collect(); let format_row = combo_row(&crate::i18n::gettext("Format"), &format_labels); - let rows_label = crate::i18n::ngettext_f( - "{n} row", - "{n} rows", - result.rows.len() as u32, - &[("n", &result.rows.len().to_string())], - ); - format_row.set_subtitle(&rows_label); format_group.add(&format_row); page.add(&format_group); @@ -301,58 +375,114 @@ pub fn present( footer.append(>k::Box::builder().hexpand(true).build()); footer.append(&export_button); + let expected = scope.choices.iter().map(|choice| choice.expected).collect(); + refuse_what_cannot_fit(&format_row, formats, &scope.row, expected, &export_button); + let toolbar = adw::ToolbarView::new(); toolbar.add_top_bar(&adw::HeaderBar::new()); toolbar.set_content(Some(&page)); toolbar.add_bottom_bar(&footer); let dialog = adw::Dialog::builder() - .title(crate::i18n::gettext("Export Results")) + .title(crate::i18n::gettext("Export Rows")) .content_width(480) .child(&toolbar) .build(); dialog.set_default_widget(Some(&export_button)); + // One dialog, one export: the rows go to the job rather than being + // copied for each click. + let unexported = Rc::new(RefCell::new(Some(Unexported { origin, session }))); let window = parent.clone(); - let toast_overlay = toast_overlay.clone(); + let exports = exports.clone(); let dialog_for_export = dialog.clone(); export_button.connect_clicked(move |_| { let format = pick(formats, format_row.selected()); - let options = rows.read(); + let Some(job_format) = format.job_format(rows.read(), &name, target.as_ref()) else { + return; + }; + let Some(choice) = scope.selected().cloned() else { + return; + }; + if format.refusal(choice.expected).is_some() { + return; + } + let Some(Unexported { origin, session }) = unexported.borrow_mut().take() else { + return; + }; + let Some(source) = origin.into_source(choice.source, session) else { + return; + }; dialog_for_export.close(); - save_with_file_dialog( - &window, - &toast_overlay, - Export { - format, - name: name.clone(), - target: target.clone(), - result: result.clone(), - options, - }, - ); + let chosen = Chosen { + format, + job_format, + name: name.clone(), + source, + expected_rows: choice.expected, + }; + glib::spawn_future_local(save_with_file_dialog(window.clone(), exports.clone(), chosen)); }); dialog.present(Some(parent)); } +/// Keeps Export off, with the reason under the format, while the chosen +/// rows can't be written in the chosen format. `expected` is each scope's +/// count, in the Rows list's order. +fn refuse_what_cannot_fit( + format_row: &adw::ComboRow, + formats: &'static [Format], + scope_row: &adw::ComboRow, + expected: Vec>, + export_button: >k::Button, +) { + let refuse = { + let format_row = format_row.downgrade(); + let scope_row = scope_row.downgrade(); + let export_button = export_button.downgrade(); + Rc::new(move || { + let (Some(format_row), Some(scope_row), Some(export_button)) = + (format_row.upgrade(), scope_row.upgrade(), export_button.upgrade()) + else { + return; + }; + let expected = expected.get(scope_row.selected() as usize).copied().flatten(); + let refusal = pick(formats, format_row.selected()).refusal(expected); + format_row.set_subtitle(refusal.as_deref().unwrap_or_default()); + export_button.set_sensitive(refusal.is_none()); + }) + }; + for row in [format_row, scope_row] { + let refuse = refuse.clone(); + row.connect_selected_notify(move |_| refuse()); + } + refuse(); +} + +/// What the dialog holds until its one export takes it. +struct Unexported { + origin: ExportOrigin, + session: Option>, +} + /// One export, as the dialog left it. -struct Export { +struct Chosen { format: Format, + job_format: ExportFormat, name: String, - target: Option, - result: QueryResult, - options: CsvOptions, + source: ExportSource, + expected_rows: Option, } -fn save_with_file_dialog(parent: &adw::ApplicationWindow, toast_overlay: &adw::ToastOverlay, export: Export) { - let Export { +async fn save_with_file_dialog(parent: adw::ApplicationWindow, exports: Rc, chosen: Chosen) { + let Chosen { format, + job_format, name, - target, - result, - options, - } = export; + source, + expected_rows, + } = chosen; let filter = gtk::FileFilter::new(); filter.set_name(Some(&crate::i18n::gettext_f( "{format} files", @@ -363,79 +493,90 @@ fn save_with_file_dialog(parent: &adw::ApplicationWindow, toast_overlay: &adw::T let filters = gio::ListStore::new::(); filters.append(&filter); let file_dialog = gtk::FileDialog::builder() - .title(crate::i18n::gettext("Export Results")) + .title(crate::i18n::gettext("Export Rows")) .modal(true) .initial_name(format!("{name}.{}", format.extension())) .default_filter(&filter) .filters(&filters) .build(); - - let parent_for_alert = parent.clone(); - let toast_overlay = toast_overlay.clone(); - // The HTML document titles itself after the export, and the - // callback outlives this call, so it carries its own copy. - let title = name.clone(); - file_dialog.save(Some(parent), gio::Cancellable::NONE, move |outcome| { - let Ok(file) = outcome 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 { - Format::Csv => export::render_csv(&result.columns, &result.rows, &options) - .map(String::into_bytes) - .map_err(|e| e.to_string()), - Format::Xlsx => { - export::render_xlsx(&result.columns, &result.rows, &title).map_err(|error| error.to_string()) - } - Format::Json => Ok(export::render_json(&result.columns, &result.rows).into_bytes()), - Format::Markdown => Ok(export::render_markdown(&result.columns, &result.rows).into_bytes()), - Format::Html => Ok(export::render_html(&result.columns, &result.rows, &title).into_bytes()), - Format::Xml => Ok(export::render_xml(&result.columns, &result.rows).into_bytes()), - Format::Sql => match target.as_ref() { - Some(target) => { - Ok( - export::render_sql_insert(&*target.dialect, &target.table, &result.columns, &result.rows) - .into_bytes(), - ) - } - // The format is only offered with a target, so this is - // unreachable through the dialog. - None => Err(crate::i18n::gettext("This result has no table to insert into.")), - }, - }; - 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)); - } - } - }); + // Cancelling the chooser is the only way it fails without a file. + let Ok(destination) = file_dialog.save_future(Some(&parent)).await else { + return; + }; + // The ledger reports how the job ends, or logs why it never began. + let _ = exports.begin(ExportJob { + destination, + format: job_format, + source, + expected_rows, }); } #[cfg(test)] mod tests { + use std::time::Duration; + + use tablepro_core::column::{ColumnType, ResultColumn}; + use tablepro_core::{EditorSession, ResultEvent, Truncation, Value}; + use tablepro_test_support::{FakeEditorSession, FakeReply, FakeRun, Replies}; + use super::*; + use crate::test_support::{descendants, first_descendant_of_type, wait_until}; + use crate::ui::session_window::tests::{TestWindow, editors}; + + const WAIT: Duration = Duration::from_secs(20); + + /// A run whose one result the row limit cut short. + fn capped_run() -> Replies { + let columns: Arc<[ResultColumn]> = vec![ResultColumn::new("n", ColumnType::untyped_text("text"))].into(); + let mut runs = Replies::default(); + runs.always(FakeRun::completed(vec![ + ResultEvent::ResultSetStarted { columns }, + ResultEvent::Rows(vec![vec![Value::Int(1)], vec![Value::Int(2)]]), + ResultEvent::RowLimitReached { retained: 2 }, + ResultEvent::ResultSetFinished { + retained: 2, + total_rows: None, + truncation: Truncation::ServerStopped, + }, + ])); + runs + } + + #[gtk4::test] + fn export_all_opens_dialog_with_all_rows_scope_selected() { + let fake = Arc::new(FakeEditorSession::new(capped_run())); + let test = TestWindow::connected_with("ExportAll", |script| { + let editor: Arc = fake.clone(); + script.open_editor_session.always(FakeReply::ok(editor)); + }); + test.run_in_new_editor_tab(); + let editor = editors(&test.window).pop().expect("the editor tab"); + let banner = first_descendant_of_type::(&editor).expect("the result's banner"); + assert!(banner.is_revealed(), "the capped result has no banner"); + + banner.emit_by_name::<()>("button-clicked", &[]); + + wait_until(WAIT, || test.window.visible_dialog().is_some()).expect("the export dialog"); + let dialog = test.window.visible_dialog().expect("the export dialog"); + assert_eq!(dialog.title(), "Export Rows"); + let rows = descendants(&dialog) + .into_iter() + .filter_map(|widget| widget.downcast::().ok()) + .find(|row| row.title() == "Rows") + .expect("the Rows list"); + let selected = rows + .selected_item() + .and_downcast::() + .expect("a selected scope"); + assert_eq!(selected.string(), "All Rows"); + assert_eq!( + rows.subtitle().as_deref(), + Some( + "Runs the statement again on a separate read-only connection, without this editor's USE or SET changes" + ) + ); + } #[test] fn every_format_has_a_label_extension_and_type_of_its_own() { @@ -451,6 +592,49 @@ mod tests { } } + #[test] + fn excel_is_refused_for_more_rows_than_a_sheet_holds() { + let limit = XLSX_MAX_DATA_ROWS as u64; + + assert!(Format::Xlsx.refusal(Some(ExpectedRows::Exact(limit + 1))).is_some()); + assert_eq!(Format::Xlsx.refusal(Some(ExpectedRows::Exact(limit))), None); + assert_eq!( + Format::Xlsx.refusal(Some(ExpectedRows::About(limit * 2))), + None, + "an estimate can be wrong, so the sheet's own check decides" + ); + assert_eq!(Format::Xlsx.refusal(None), None); + assert_eq!(Format::Csv.refusal(Some(ExpectedRows::Exact(limit * 2))), None); + } + + #[gtk4::test] + fn export_is_off_while_the_rows_cannot_fit_an_excel_sheet() { + adw::init().expect("libadwaita initialises under the test backend"); + let formats = Format::offered(None); + let at = |format| u32::try_from(formats.iter().position(|f| *f == format).expect("offered")).expect("an index"); + let labels: Vec<&str> = formats.iter().map(|f| f.label()).collect(); + let format_row = combo_row("Format", &labels); + let scope_row = combo_row("Rows", &["Current Page", "Entire Table"]); + let export = gtk::Button::new(); + let past_a_sheet = XLSX_MAX_DATA_ROWS as u64 + 1; + refuse_what_cannot_fit( + &format_row, + formats, + &scope_row, + vec![Some(ExpectedRows::Exact(3)), Some(ExpectedRows::Exact(past_a_sheet))], + &export, + ); + + format_row.set_selected(at(Format::Xlsx)); + assert!(export.is_sensitive(), "a page fits a sheet"); + scope_row.set_selected(1); + assert!(!export.is_sensitive(), "the whole table was let through"); + assert!(format_row.subtitle().is_some_and(|text| text.contains("Excel"))); + format_row.set_selected(at(Format::Csv)); + assert!(export.is_sensitive()); + assert!(format_row.subtitle().unwrap_or_default().is_empty()); + } + #[test] fn sql_is_the_only_format_that_needs_a_table() { assert!(!Format::offered(None).contains(&Format::Sql)); diff --git a/linux/crates/app/src/ui/file_reveal/file_launcher_revealer.rs b/linux/crates/app/src/ui/file_reveal/file_launcher_revealer.rs new file mode 100644 index 0000000000..d433f53919 --- /dev/null +++ b/linux/crates/app/src/ui/file_reveal/file_launcher_revealer.rs @@ -0,0 +1,15 @@ +use futures::future::LocalBoxFuture; +use gtk4 as gtk; +use gtk4::{gio, glib}; + +use super::FileRevealer; + +/// Opens the folder with the file selected, through the file manager or, +/// inside Flatpak, the OpenURI portal. +pub(crate) struct FileLauncherRevealer; + +impl FileRevealer for FileLauncherRevealer { + fn reveal(&self, file: gio::File, parent: Option) -> LocalBoxFuture<'static, Result<(), glib::Error>> { + gtk::FileLauncher::new(Some(&file)).open_containing_folder_future(parent.as_ref()) + } +} diff --git a/linux/crates/app/src/ui/file_reveal/file_revealer.rs b/linux/crates/app/src/ui/file_reveal/file_revealer.rs new file mode 100644 index 0000000000..18b9dbdbf9 --- /dev/null +++ b/linux/crates/app/src/ui/file_reveal/file_revealer.rs @@ -0,0 +1,9 @@ +use futures::future::LocalBoxFuture; +use gtk4 as gtk; +use gtk4::{gio, glib}; + +/// Shows a file in the file manager. A trait so a test can see what +/// would have been shown without starting one. +pub(crate) trait FileRevealer { + fn reveal(&self, file: gio::File, parent: Option) -> LocalBoxFuture<'static, Result<(), glib::Error>>; +} diff --git a/linux/crates/app/src/ui/file_reveal/mod.rs b/linux/crates/app/src/ui/file_reveal/mod.rs new file mode 100644 index 0000000000..a0500bc881 --- /dev/null +++ b/linux/crates/app/src/ui/file_reveal/mod.rs @@ -0,0 +1,13 @@ +//! Showing a file in the file manager, selected in its folder. + +mod file_launcher_revealer; +mod file_revealer; +#[cfg(test)] +mod recording_revealer; +mod reveal_failure_toast; + +pub(crate) use file_launcher_revealer::FileLauncherRevealer; +pub(crate) use file_revealer::FileRevealer; +#[cfg(test)] +pub(crate) use recording_revealer::RecordingRevealer; +pub(crate) use reveal_failure_toast::reveal_failure_toast; diff --git a/linux/crates/app/src/ui/file_reveal/recording_revealer.rs b/linux/crates/app/src/ui/file_reveal/recording_revealer.rs new file mode 100644 index 0000000000..4425dd7607 --- /dev/null +++ b/linux/crates/app/src/ui/file_reveal/recording_revealer.rs @@ -0,0 +1,49 @@ +use std::cell::RefCell; +use std::path::PathBuf; + +use futures::future::LocalBoxFuture; +use gtk4 as gtk; +use gtk4::prelude::*; +use gtk4::{gio, glib}; + +use super::FileRevealer; + +/// Remembers each file it was asked to show and answers with `outcome`. +pub(crate) struct RecordingRevealer { + revealed: RefCell>, + outcome: Result<(), glib::Error>, +} + +impl RecordingRevealer { + pub(crate) fn succeeding() -> Self { + Self { + revealed: RefCell::default(), + outcome: Ok(()), + } + } + + pub(crate) fn failing() -> Self { + Self { + revealed: RefCell::default(), + outcome: Err(glib::Error::new(gio::IOErrorEnum::NotSupported, "no file manager")), + } + } + + pub(crate) fn revealed(&self) -> Vec { + self.revealed.borrow().clone() + } +} + +impl FileRevealer for RecordingRevealer { + fn reveal( + &self, + file: gio::File, + _parent: Option, + ) -> LocalBoxFuture<'static, Result<(), glib::Error>> { + if let Some(path) = file.path() { + self.revealed.borrow_mut().push(path); + } + let outcome = self.outcome.clone(); + Box::pin(async move { outcome }) + } +} diff --git a/linux/crates/app/src/ui/file_reveal/reveal_failure_toast.rs b/linux/crates/app/src/ui/file_reveal/reveal_failure_toast.rs new file mode 100644 index 0000000000..fdc8e679d5 --- /dev/null +++ b/linux/crates/app/src/ui/file_reveal/reveal_failure_toast.rs @@ -0,0 +1,35 @@ +use gtk4::glib; +use libadwaita as adw; + +use crate::i18n::gettext; + +/// What to tell the user when the file manager could not show the file. +/// The error is logged; its text is the platform's, not ours. +pub(crate) fn reveal_failure_toast(result: &Result<(), glib::Error>) -> Option { + let Err(error) = result else { + return None; + }; + tracing::warn!(%error, "could not show a file in the file manager"); + Some(crate::ui::plain_toast(&gettext("Could not show the file in Files"))) +} + +#[cfg(test)] +mod tests { + use gtk4::gio; + + use super::*; + + #[gtk4::test] + fn failed_reveal_produces_toast() { + let failed = Err(glib::Error::new(gio::IOErrorEnum::NotSupported, "no file manager")); + + let toast = reveal_failure_toast(&failed).expect("a toast"); + + assert_eq!(toast.title().as_deref(), Some("Could not show the file in Files")); + assert!(!toast.uses_markup()); + assert!( + reveal_failure_toast(&Ok(())).is_none(), + "a reveal that worked made a toast" + ); + } +} diff --git a/linux/crates/app/src/ui/filter_strip.rs b/linux/crates/app/src/ui/filter_strip.rs index 04b5735f1a..2432d5a8a4 100644 --- a/linux/crates/app/src/ui/filter_strip.rs +++ b/linux/crates/app/src/ui/filter_strip.rs @@ -38,6 +38,8 @@ use libadwaita::prelude::*; use tablepro_core::{ColumnInfo, Combinator, FilterOp, FilterRule, FilterSet, FilterValue}; +use crate::application::{ShortcutOwner, local_shortcut_controller}; + /// Closure that rebuilds the rule list. Stored in an Rc> so /// every input handler can call it through one slot, avoiding the /// type-complexity hit clippy raises on the raw signature. @@ -269,9 +271,10 @@ pub fn build(columns: Vec, initial: FilterSet, on_apply: Rc, initial: FilterSet, on_apply: Rc, initial: FilterSet, on_apply: Rc gtk::InputPurpose { #[cfg(test)] mod tests { use super::*; - use crate::test_support::{drain_main_context, first_descendant_of_type}; + use crate::test_support::{ + drain_main_context, first_descendant_of_type, tooltips_with_keys, unlabelled_icon_buttons, + }; fn rule(column: &str) -> FilterRule { FilterRule { @@ -790,6 +775,27 @@ mod tests { std::iter::successors(list.first_child(), |row| row.next_sibling()).count() } + #[gtk4::test] + fn escape_closes_the_strip_through_the_table() { + use crate::test_support::shortcut_keys::{keystroke, owner_controller, shortcuts, trigger_keys}; + let strip = strip_with(vec![rule("a")]); + strip.set_revealed(true); + let outer = strip.widget.child().expect("the strip's content"); + let escape = shortcuts(&owner_controller(&outer, ShortcutOwner::FilterStrip).expect("the strip's keys")) + .into_iter() + .find(|shortcut| { + shortcut + .trigger() + .is_some_and(|trigger| trigger_keys(&trigger).contains(&keystroke("Escape"))) + }) + .and_then(|shortcut| shortcut.action()) + .expect("Escape is bound"); + + assert!(escape.activate(gtk::ShortcutActionFlags::empty(), &outer, None)); + + assert!(!strip.is_revealed()); + } + #[gtk4::test] fn strip_freed_after_drop() { let strip = strip_with(vec![rule("a"), rule("b")]); @@ -851,6 +857,27 @@ mod tests { assert_eq!(alive, 0, "the From and To entries outlived the strip"); } + #[gtk4::test] + fn icon_buttons_are_labelled_without_accelerator_hints() { + let strip = strip_with(vec![rule("a")]); + let remove = crate::test_support::descendants(&strip.widget) + .into_iter() + .filter_map(|widget| widget.downcast::().ok()) + .find(|button| { + button + .icon_name() + .is_some_and(|icon| icon.as_str() == crate::ui::icons::USER_TRASH) + }) + .expect("the rule's Remove Rule button"); + + assert!(gtk::test_accessible_has_property( + &remove, + gtk::AccessibleProperty::Label + )); + assert_eq!(unlabelled_icon_buttons(&strip.widget), Vec::::new()); + assert_eq!(tooltips_with_keys(&strip.widget), Vec::::new()); + } + #[gtk4::test] fn an_applied_filter_rebuilds_the_rule_list() { let strip = strip_with(vec![rule("a"), rule("b")]); diff --git a/linux/crates/app/src/ui/format/mod.rs b/linux/crates/app/src/ui/format/mod.rs new file mode 100644 index 0000000000..ade92482ee --- /dev/null +++ b/linux/crates/app/src/ui/format/mod.rs @@ -0,0 +1,13 @@ +//! Numbers as the user reads them. + +mod thousands; + +#[cfg(test)] +pub(crate) use self::thousands::Thousands; +pub(crate) use self::thousands::format_thousands; + +/// The count gettext picks a plural form by. It takes a u32, and a +/// result past that is rare enough for the largest u32 to stand in. +pub(crate) fn plural_count(n: u64) -> u32 { + u32::try_from(n).unwrap_or(u32::MAX) +} diff --git a/linux/crates/app/src/ui/format/thousands.rs b/linux/crates/app/src/ui/format/thousands.rs new file mode 100644 index 0000000000..b3395e50df --- /dev/null +++ b/linux/crates/app/src/ui/format/thousands.rs @@ -0,0 +1,100 @@ +use gtk4::glib; +use icu_decimal::DecimalFormatter; +use icu_decimal::input::Decimal; +use icu_locale_core::Locale; + +/// Groups a count's digits the way a locale does: 1,000 in English, +/// 1.000 in German. The digits stay ASCII whatever digits the locale +/// writes by default, as the grid's numbers do. +pub(crate) struct Thousands { + formatter: Option, +} + +impl Thousands { + /// `name` is a POSIX locale name, as LC_NUMERIC holds it: + /// `de_DE.UTF-8`. The C locale, and a name ICU does not know, group + /// as the root locale does. + pub(crate) fn for_posix_locale(name: &str) -> Self { + let language = name.split(['.', '@']).next().unwrap_or_default().replace('_', "-"); + let locale = + Locale::try_from_str(&format!("{language}-u-nu-latn")).or_else(|_| Locale::try_from_str("und-u-nu-latn")); + let formatter = match locale { + Ok(locale) => DecimalFormatter::try_new((&locale).into(), Default::default()) + .inspect_err(|error| tracing::debug!(%error, "no number format for the locale")) + .ok(), + Err(error) => { + tracing::debug!(%error, "the root locale did not parse"); + None + } + }; + Self { formatter } + } + + pub(crate) fn format(&self, n: u64) -> String { + match &self.formatter { + Some(formatter) => formatter.format_to_string(&Decimal::from(n)), + None => n.to_string(), + } + } +} + +// A cache, not state: every window formats with the process's locale, +// read once, and nothing writes to it after. +thread_local! { + static USER: Thousands = Thousands::for_posix_locale(&numeric_locale()); +} + +/// What LC_NUMERIC names, or LC_ALL or LANG in its place. +fn numeric_locale() -> String { + glib::language_names_with_category("LC_NUMERIC") + .first() + .map(|name| name.to_string()) + .unwrap_or_default() +} + +/// `n` grouped as the user's locale groups digits. +pub(crate) fn format_thousands(n: u64) -> String { + USER.with(|thousands| thousands.format(n)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn format_thousands_edges() { + let english = Thousands::for_posix_locale("en_US.UTF-8"); + + assert_eq!(english.format(0), "0"); + assert_eq!(english.format(1), "1"); + assert_eq!(english.format(999), "999"); + assert_eq!(english.format(1_000), "1,000"); + assert_eq!(english.format(10_000), "10,000"); + assert_eq!(english.format(1_000_000), "1,000,000"); + assert_eq!(english.format(u64::MAX), "18,446,744,073,709,551,615"); + } + + #[test] + fn digits_are_grouped_as_the_locale_groups_them() { + assert_eq!( + Thousands::for_posix_locale("de_DE.UTF-8").format(1_234_567), + "1.234.567" + ); + assert_eq!(Thousands::for_posix_locale("sr_RS@latin").format(1_000), "1.000"); + } + + #[test] + fn a_locale_with_its_own_digits_keeps_ascii_digits() { + assert_eq!( + Thousands::for_posix_locale("bn_BD.UTF-8").format(1_000_007), + "10,00,007" + ); + } + + #[test] + fn the_c_locale_and_an_unknown_name_still_group() { + assert_eq!(Thousands::for_posix_locale("C").format(1_000), "1,000"); + assert_eq!(Thousands::for_posix_locale("").format(1_000), "1,000"); + assert_eq!(Thousands::for_posix_locale("not a locale").format(1_000), "1,000"); + } +} diff --git a/linux/crates/app/src/ui/grid/calendar_editor.rs b/linux/crates/app/src/ui/grid/calendar_editor.rs index 2d1edcaabe..7412602bc5 100644 --- a/linux/crates/app/src/ui/grid/calendar_editor.rs +++ b/linux/crates/app/src/ui/grid/calendar_editor.rs @@ -8,6 +8,7 @@ use tablepro_core::{ColumnInfo, Value}; use super::edit_commit::{commit_edit, send_commit}; use super::inline_editor::{InlineEditor, InlineOptions}; use super::{EditSnapshot, GridCell}; +use crate::application::component_matches; #[derive(Debug, Clone)] enum CalendarStart { @@ -78,8 +79,8 @@ impl CalendarEditor { 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) { + keys.connect_key_released(move |controller, keyval, _, state| { + if !component_matches("cell-editor.pick-date", keyval, state) { return; } let Some(calendar) = controller.widget().and_downcast::() else { diff --git a/linux/crates/app/src/ui/grid/cell_accessibility.rs b/linux/crates/app/src/ui/grid/cell_accessibility.rs index 461ce3b261..b7179409e2 100644 --- a/linux/crates/app/src/ui/grid/cell_accessibility.rs +++ b/linux/crates/app/src/ui/grid/cell_accessibility.rs @@ -1,40 +1,77 @@ -use crate::services::change_tracker::{CellState, RowState}; +use super::row_state_icon::RowPendingState; +use crate::services::change_tracker::CellState; /// 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, - } +/// nothing of their own: the row says it is new. A NULL is named, since +/// it reads as the text "NULL" would. +pub fn cell_description(state: CellState, null: bool) -> Option { + let text = match (state, null) { + (CellState::Modified, true) => crate::i18n::gettext("Modified, NULL value"), + (CellState::Modified, false) => crate::i18n::gettext("Modified"), + (_, true) => crate::i18n::gettext("NULL value"), + (_, false) => return None, + }; + Some(text) } /// 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 { +pub fn row_description(state: RowPendingState) -> 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, + RowPendingState::Modified => None, + other => pending_state_text(other), } } +pub(super) fn pending_state_text(state: RowPendingState) -> Option { + let text = match state { + RowPendingState::Unchanged => return None, + RowPendingState::Modified => crate::i18n::gettext("Modified"), + RowPendingState::Inserted => crate::i18n::gettext("New row"), + RowPendingState::MarkedForDeletion => crate::i18n::gettext("Marked for deletion"), + RowPendingState::SaveFailed => crate::i18n::gettext("Not saved"), + }; + Some(text) +} + #[cfg(test)] mod tests { use super::{cell_description, row_description}; - use crate::services::change_tracker::{CellState, RowState}; + use crate::services::change_tracker::CellState; + use crate::ui::grid::row_state_icon::RowPendingState; #[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(), + cell_description(CellState::Modified, false).as_deref(), + Some("Modified") + ); + assert_eq!(cell_description(CellState::Clean, false), None); + assert_eq!(cell_description(CellState::InsertDraft, false), None); + assert_eq!(row_description(RowPendingState::Inserted).as_deref(), Some("New row")); + assert_eq!( + row_description(RowPendingState::MarkedForDeletion).as_deref(), Some("Marked for deletion") ); - assert_eq!(row_description(RowState::Clean), None); - assert_eq!(row_description(RowState::Modified), None); + assert_eq!( + row_description(RowPendingState::SaveFailed).as_deref(), + Some("Not saved") + ); + assert_eq!(row_description(RowPendingState::Unchanged), None); + assert_eq!(row_description(RowPendingState::Modified), None); + } + + #[test] + fn cell_description_marks_null_and_modified() { + assert_eq!( + cell_description(CellState::Modified, true).as_deref(), + Some("Modified, NULL value") + ); + assert_eq!(cell_description(CellState::Clean, true).as_deref(), Some("NULL value")); + assert_eq!( + cell_description(CellState::InsertDraft, true).as_deref(), + Some("NULL value") + ); + assert_eq!(cell_description(CellState::Clean, false), None); } } diff --git a/linux/crates/app/src/ui/grid/column_drawing.rs b/linux/crates/app/src/ui/grid/column_drawing.rs index 5891dd8733..6ea52ff156 100644 --- a/linux/crates/app/src/ui/grid/column_drawing.rs +++ b/linux/crates/app/src/ui/grid/column_drawing.rs @@ -8,7 +8,7 @@ use tablepro_core::{ColumnInfo, Value}; use super::cell_tooltip::cell_tooltip; use super::display_text::{DisplayText, display_text}; -use super::grid_attributes::GridAttributes; +use super::grid_attributes::{GridAttributes, TextMark}; use super::toggle_cell::{ToggleCell, toggle_state}; use super::{CellLock, GridCell, GridRowId, RowObject, TabGridContext, cell_editability}; use crate::services::change_tracker::CellState; @@ -44,10 +44,14 @@ impl ColumnDrawing { } } - pub fn draw(&self, cell: &GridCell, row: &RowObject) -> CellState { + pub fn draw(&self, cell: &GridCell, row: &RowObject) -> DrawnCell { 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| { + let drawn = DrawnCell { + state, + null: !default && value.is_null(), + }; // 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. @@ -55,7 +59,7 @@ impl ColumnDrawing { cell.cancel_edit(); } let Some(content) = cell.content() else { - return; + return drawn; }; apply_pending_classes(content, &pending_classes); let pending_delete = pending_classes.contains(&"tp-row-pending-delete"); @@ -69,16 +73,21 @@ impl ColumnDrawing { // 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 mark = match (pending_delete, state) { + (true, _) => TextMark::PendingDelete, + (false, CellState::Modified) => TextMark::Modified, + (false, _) => TextMark::Plain, + }; let shown = CellText { value, default, editable: cell.lock().is_none(), - pending_delete, + mark, }; cell.with_scratch(|scratch| self.draw_text(display, &shown, scratch)); } - }); - state + drawn + }) } /// An editable cell shows NULL as ``, so it reads differently @@ -99,17 +108,25 @@ impl ColumnDrawing { "dimmed", !cell.default && cell.value.is_null() && !cell.editable, ); - display.set_attributes(attributes.for_text(cell.pending_delete, shown.placeholder)); + display.set_attributes(attributes.for_text(cell.mark, shown.placeholder)); } } +/// What a draw tells a screen reader about the cell. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct DrawnCell { + pub state: CellState, + /// The cell holds NULL, not a draft's cell left to the server. + pub null: bool, +} + /// 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, + mark: TextMark, } fn set_class(widget: >k::Widget, class: &str, on: bool) { diff --git a/linux/crates/app/src/ui/grid/column_factory.rs b/linux/crates/app/src/ui/grid/column_factory.rs index 0cdd4ff26d..f0078651df 100644 --- a/linux/crates/app/src/ui/grid/column_factory.rs +++ b/linux/crates/app/src/ui/grid/column_factory.rs @@ -1,7 +1,7 @@ use std::rc::Rc; use gtk4::prelude::*; -use gtk4::{self as gtk, glib}; +use gtk4::{self as gtk, gio, glib}; use tablepro_core::ColumnInfo; use tablepro_core::column::{ColumnKind, ResultColumn}; @@ -14,7 +14,9 @@ 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::result_sorters::column_sorter; use super::row_factory::install_row_factory; +use super::row_state_column::row_state_column; use super::toggle_cell::ToggleCell; use super::{GridCell, GridContent, GridMsg, GridSink, RowObject, TabGridContext}; @@ -22,29 +24,40 @@ 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( +/// How a grid's headers sort its rows. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum GridSorting { + /// The server sorts: a header click sends `SortChanged` and the rows + /// stay in the order they arrived, which `initial` describes. + Server { initial: Option<(usize, bool)> }, + /// The grid sorts the rows it holds, each column by its own sorter. + Client, +} + +pub(crate) fn build_column_view( content: GridContent, schema_columns: &[ColumnInfo], sink: GridSink, - sort: Option<(usize, bool)>, - sortable: bool, + sorting: GridSorting, tab_ctx: TabGridContext, ) -> (gtk::ColumnView, gtk::MultiSelection) { let GridContent { columns: result_columns, rows, - truncated, } = content; - let store = gtk4::gio::ListStore::new::(); + let store = gio::ListStore::new::(); 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(); + let selection = match sorting { + GridSorting::Client => sorted_selection(store, column_view.sorter()), + GridSorting::Server { .. } => gtk::MultiSelection::new(Some(store)), + }; + column_view.set_model(Some(&selection)); // 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()); @@ -53,7 +66,6 @@ pub fn build_column_view( column_view: &column_view, sink: sink.clone(), columns: result_columns.clone(), - truncated, tab_ctx: tab_ctx.clone(), }); GridKeyboard::install(&column_view, grid_menus.clone(), sink.clone()); @@ -70,12 +82,18 @@ pub fn build_column_view( let grid_columns = Rc::new(GridColumns::default()); let attributes = Rc::new(GridAttributes::new()); for (i, column) in result_columns.iter().enumerate() { + let sorter = match sorting { + // The server orders the rows, so the column's own sorter + // only marks the header and must leave them where they are. + GridSorting::Server { .. } => gtk::CustomSorter::new(|_, _| gtk::Ordering::Equal).upcast(), + GridSorting::Client => column_sorter(i, column), + }; let col = build_column(ColumnInit { info: column, idx: i, catalog: schema_columns.get(i).cloned(), sink: sink.clone(), - sortable, + sorter, tab_ctx: tab_ctx.clone(), default_min_width, column_view: column_view.downgrade(), @@ -86,10 +104,15 @@ pub fn build_column_view( column_view.append_column(&col); columns.push(col); } + if tab_ctx.tracks_changes() { + column_view.insert_column(0, &row_state_column(tab_ctx.clone())); + } // Applied before the notify handlers are wired, so it does not echo // back as a SortChanged. - if let Some((col_idx, ascending)) = sort + if let GridSorting::Server { + initial: Some((col_idx, ascending)), + } = sorting && let Some(col) = columns.get(col_idx) { let direction = if ascending { @@ -100,7 +123,7 @@ pub fn build_column_view( column_view.sort_by_column(Some(col), direction); } - if sortable + if matches!(sorting, GridSorting::Server { .. }) && let Some(view_sorter) = column_view .sorter() .and_then(|s| s.downcast::().ok()) @@ -132,6 +155,17 @@ pub fn build_column_view( (column_view, selection) } +/// A selection over `store` sorted by `sorter`, sorted at once on each +/// click rather than in steps, so the rows never show half sorted and a +/// copy right after a click takes the order on screen. +/// `GtkMultiSelection` follows its selected rows to where a sort puts +/// them. +fn sorted_selection(store: gio::ListStore, sorter: Option) -> gtk::MultiSelection { + let sorted = gtk::SortListModel::new(Some(store), sorter); + sorted.set_incremental(false); + gtk::MultiSelection::new(Some(sorted)) +} + /// How a column's cells are built. Editors are made when an edit /// opens, so only the display is chosen here. #[derive(Clone, Copy)] @@ -148,7 +182,7 @@ struct ColumnInit<'a> { /// `None` for a column the grid knows only from the result set. catalog: Option, sink: GridSink, - sortable: bool, + sorter: gtk::Sorter, tab_ctx: TabGridContext, default_min_width: Option, column_view: glib::WeakRef, @@ -163,7 +197,7 @@ fn build_column(init: ColumnInit<'_>) -> gtk::ColumnViewColumn { idx, catalog, sink, - sortable, + sorter, tab_ctx, default_min_width, column_view, @@ -196,6 +230,7 @@ fn build_column(init: ColumnInit<'_>) -> gtk::ColumnViewColumn { let setup_drawing = drawing.clone(); let setup_columns = grid_columns.clone(); + let setup_name = info.name.clone(); factory.connect_setup(move |_, item| { let Some(cell) = item.downcast_ref::() else { return; @@ -212,7 +247,7 @@ fn build_column(init: ColumnInit<'_>) -> gtk::ColumnViewColumn { grid_cell.set_columns(setup_columns.clone()); match kind { CellKind::ReadOnly => grid_cell.set_content(&text_display(numeric)), - CellKind::Toggle => ToggleCell::setup(&grid_cell), + CellKind::Toggle => ToggleCell::setup(&grid_cell, &setup_name), CellKind::Text => { grid_cell.set_content(&text_display(numeric)); install_edit_triggers(&grid_cell); @@ -250,10 +285,7 @@ fn build_column(init: ColumnInit<'_>) -> gtk::ColumnViewColumn { .resizable(true) .expand(true) .build(); - if sortable { - let dummy = gtk::CustomSorter::new(|_, _| gtk::Ordering::Equal); - column.set_sorter(Some(&dummy)); - } + column.set_sorter(Some(&sorter)); if let Some(widths) = tab_ctx.column_widths.clone() { if let Some(saved) = widths.width(&info.name) { column.set_fixed_width(saved); @@ -299,7 +331,7 @@ mod tests { use tablepro_core::Value; use tablepro_core::edit::CellInput; - use super::build_column_view; + use super::{GridSorting, build_column_view}; use crate::services::change_tracker::RowKey; use crate::services::tab_tracker_handle::TabTrackerHandle; use crate::services::tracker_edit::TrackerEdit; @@ -750,8 +782,9 @@ mod tests { content(&columns, persisted(&rows)), &columns, sink, - Some((0, true)), - true, + GridSorting::Server { + initial: Some((0, true)), + }, context, ); let column = view @@ -790,8 +823,7 @@ mod tests { content(&columns, persisted(&rows)), &columns, sink, - None, - false, + GridSorting::Server { initial: None }, tracked(&tracker), ); let window = gtk::Window::new(); diff --git a/linux/crates/app/src/ui/grid/grid_attributes.rs b/linux/crates/app/src/ui/grid/grid_attributes.rs index 20e5a8802a..231c0afc85 100644 --- a/linux/crates/app/src/ui/grid/grid_attributes.rs +++ b/linux/crates/app/src/ui/grid/grid_attributes.rs @@ -2,13 +2,25 @@ use gtk4::pango; use super::display_text::{auto_filled_sentinel, editable_null_sentinel}; +/// How a pending change marks a cell's text, beside its tint, so the +/// change reads without colour. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TextMark { + Plain, + Modified, + /// Wins over `Modified`: an edited cell in a row marked for deletion + /// is going away with it. + PendingDelete, +} + /// 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. +/// Italic marks a placeholder. Pango attributes, because CSS +/// `text-decoration` does not reach an Inscription's text reliably. #[derive(Debug)] pub struct GridAttributes { + modified: pango::AttrList, + modified_placeholder: pango::AttrList, strikethrough: pango::AttrList, placeholder: pango::AttrList, strikethrough_placeholder: pango::AttrList, @@ -25,10 +37,12 @@ impl Default for GridAttributes { impl GridAttributes { pub fn new() -> Self { - let list = |strikethrough: bool, italic: bool| { + let list = |mark: TextMark, italic: bool| { let attrs = pango::AttrList::new(); - if strikethrough { - attrs.insert(pango::AttrInt::new_strikethrough(true)); + match mark { + TextMark::Plain => {} + TextMark::Modified => attrs.insert(pango::AttrInt::new_weight(pango::Weight::Bold)), + TextMark::PendingDelete => attrs.insert(pango::AttrInt::new_strikethrough(true)), } if italic { attrs.insert(pango::AttrInt::new_style(pango::Style::Italic)); @@ -36,21 +50,25 @@ impl GridAttributes { attrs }; Self { - strikethrough: list(true, false), - placeholder: list(false, true), - strikethrough_placeholder: list(true, true), + modified: list(TextMark::Modified, false), + modified_placeholder: list(TextMark::Modified, true), + strikethrough: list(TextMark::PendingDelete, false), + placeholder: list(TextMark::Plain, true), + strikethrough_placeholder: list(TextMark::PendingDelete, 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), + pub fn for_text(&self, mark: TextMark, placeholder: bool) -> Option<&pango::AttrList> { + match (mark, placeholder) { + (TextMark::Plain, false) => None, + (TextMark::Plain, true) => Some(&self.placeholder), + (TextMark::Modified, false) => Some(&self.modified), + (TextMark::Modified, true) => Some(&self.modified_placeholder), + (TextMark::PendingDelete, false) => Some(&self.strikethrough), + (TextMark::PendingDelete, 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 de32324240..d08e753691 100644 --- a/linux/crates/app/src/ui/grid/grid_cell.rs +++ b/linux/crates/app/src/ui/grid/grid_cell.rs @@ -241,10 +241,10 @@ impl GridCell { let (Some(row), Some(drawing)) = (row, self.drawing()) else { return; }; - let state = drawing.draw(self, &row); + let drawn = drawing.draw(self, &row); // On the cell, since GtkColumnViewCell passes on no accessible // property of its own. - match cell_description(state) { + match cell_description(drawn.state, drawn.null) { Some(description) => self.update_property(&[gtk::accessible::Property::Description(&description)]), None => self.reset_property(gtk::AccessibleProperty::Description), } @@ -400,11 +400,13 @@ fn holds_focus(widget: &impl IsA) -> bool { mod tests { use gtk4 as gtk; use gtk4::prelude::*; - use tablepro_core::Value; + use tablepro_core::{ColumnInfo, Value}; use super::GridCell; use crate::services::tracker_edit::TrackerEdit; - use crate::ui::grid::test_grid::{key, two_text_rows}; + use crate::test_support::{descendants, wait_until}; + use crate::ui::grid::test_grid::{Grid, WAIT, flag_rows, key, named, recorder, two_text_rows}; + use crate::ui::grid::{RowObject, build_result_grid}; fn described(widget: &impl IsA) -> bool { gtk::test_accessible_has_property(widget, gtk::AccessibleProperty::Description) @@ -429,6 +431,91 @@ mod tests { assert!(!described(&cell), "an undone edit still read as modified"); } + fn has_attribute(cell: &GridCell, kind: gtk::pango::AttrType) -> bool { + cell.display() + .and_then(|display| display.attributes()) + .is_some_and(|list| list.attributes().iter().any(|attr| attr.type_() == kind)) + } + + #[gtk4::test] + fn modified_cell_renders_bold() { + let grid = two_text_rows(); + let cell = grid.cell_of(1, 1); + assert!(!has_attribute(&cell, gtk::pango::AttrType::Weight)); + + grid.redraw(&grid.tracker.apply(TrackerEdit::Cell { + row_key: key(1), + col: 1, + original: Value::Text("A".into()), + value: Value::Text("edited".into()), + })); + assert!( + has_attribute(&cell, gtk::pango::AttrType::Weight), + "an edit showed only by its tint" + ); + assert!(!has_attribute(&grid.cell_of(1, 0), gtk::pango::AttrType::Weight)); + + grid.redraw(&grid.tracker.apply(TrackerEdit::DeleteRows { + persisted: vec![(key(1), vec![Value::Int(1), Value::Text("A".into())].into())], + drafts: Vec::new(), + })); + assert!(has_attribute(&cell, gtk::pango::AttrType::Strikethrough)); + assert!( + !has_attribute(&cell, gtk::pango::AttrType::Weight), + "a cell going away with its row read as an edit" + ); + } + + #[gtk4::test] + fn null_cell_has_description_text_null_does_not() { + let columns = [named("id", "integer", false), named("name", "text", false)]; + let rows = vec![RowObject::result(vec![Value::Null, Value::Text("NULL".into())].into())]; + let (sink, _messages) = recorder(); + let (view, _selection) = build_result_grid(columns.iter().map(ColumnInfo::result_column).collect(), rows, sink); + let window = gtk::Window::new(); + window.set_child(Some(&view)); + window.present(); + let cells = || -> Vec { + descendants(&view) + .into_iter() + .filter_map(|widget| widget.downcast::().ok()) + .filter(|cell| cell.row().is_some()) + .collect() + }; + wait_until(WAIT, || cells().len() == 2).expect("the cells are drawn"); + let cell = |column| { + cells() + .into_iter() + .find(|cell| cell.column() == column) + .expect("the cell") + }; + + assert!(described(&cell(0)), "a NULL read the same as the text NULL"); + assert!(!described(&cell(1)), "the text NULL read as a NULL"); + window.destroy(); + + let editable = Grid::open( + vec![named("id", "integer", true), named("name", "text", false)], + vec![ + vec![Value::Int(1), Value::Null], + vec![Value::Int(2), Value::Text("NULL".into())], + ], + ); + assert!(described(&editable.cell_of(1, 1))); + assert!(!described(&editable.cell_of(2, 1))); + } + + #[gtk4::test] + fn bool_cell_is_labelled() { + let grid = flag_rows(); + let check = grid.cell_of(1, 1).check_button().expect("a check button"); + + assert!(gtk::test_accessible_has_property( + &check, + gtk::AccessibleProperty::Label + )); + } + #[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); diff --git a/linux/crates/app/src/ui/grid/grid_content.rs b/linux/crates/app/src/ui/grid/grid_content.rs index e65ca1968d..4d1f0ccf2a 100644 --- a/linux/crates/app/src/ui/grid/grid_content.rs +++ b/linux/crates/app/src/ui/grid/grid_content.rs @@ -10,7 +10,4 @@ use super::RowObject; 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 index 0086dd9cd0..f62c3c8844 100644 --- a/linux/crates/app/src/ui/grid/grid_keyboard.rs +++ b/linux/crates/app/src/ui/grid/grid_keyboard.rs @@ -7,6 +7,7 @@ use super::cell_preset::cell_presets; use super::context_menu::prepare_menu; use super::grid_menus::GridMenus; use super::{CellPreset, GridCell, GridMsg, GridSink}; +use crate::application::{ShortcutOwner, component_trigger, controller_name}; pub(super) struct GridKeyboard; @@ -24,42 +25,21 @@ impl GridKeyboard { 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))), - )); + controller.set_name(Some(controller_name(ShortcutOwner::GridCell))); + let add = |action: &str, act: gtk::CallbackAction| { + controller.add_shortcut(gtk::Shortcut::new(component_trigger(action), Some(act))); + }; + add("cell.edit", on_focused_cell(|_, cell| edit_or_toggle(cell))); + add("cell.toggle", on_focused_cell(|_, cell| toggle(cell))); + add( + "cell.menu", + on_focused_cell(move |widget, cell| show_menu(widget, cell, &menus)), + ); + add("cell.set-null", 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), @@ -129,50 +109,32 @@ fn set_null(cell: &GridCell, sink: &GridSink) -> glib::Propagation { #[cfg(test)] mod tests { + use std::collections::HashSet; + use gtk4::prelude::*; use gtk4::{self as gtk, gdk}; use tablepro_core::Value; - use crate::application::APP_ACCELS; + use crate::application::{ACCELERATORS, AcceleratorScope, ShortcutOwner}; + use crate::test_support::shortcut_keys::{ + Keystroke, class_binding_keys, class_keys_on_focus_paths, keystroke, owner_controller, shortcuts, table_keys, + trigger_keys, + }; 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() - } + use crate::ui::grid::{ + CellPreset, GridCell, GridMsg, GridSorting, RowObject, TabGridContext, begin_edit, build_column_view, + }; - fn accels(shortcut: >k::Shortcut) -> Vec { - let mut keys = Vec::new(); - if let Some(trigger) = shortcut.trigger() { - keys_of(&trigger, &mut keys); - } - keys + fn accels(shortcut: >k::Shortcut) -> Vec { + shortcut + .trigger() + .map(|trigger| trigger_keys(&trigger)) + .unwrap_or_default() } 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") + owner_controller(view, ShortcutOwner::GridCell).expect("the grid's keyboard controller") } /// Run what `key` does on the focused cell, as a key press reaching @@ -190,26 +152,69 @@ mod tests { 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_keyboard_matches_component_entries() { + let grid = two_text_rows(); + let bound: HashSet = shortcuts(&keyboard(&grid.view)).iter().flat_map(accels).collect(); + + assert_eq!(bound, table_keys(ShortcutOwner::GridCell)); + assert!(bound.contains(&keystroke("BackSpace"))); + assert!( + bound.contains(&(gdk::Key::KP_Enter, none())), + "the keypad Enter was lost" + ); } + /// A bubble-phase key on the column view reaches it only when no + /// class on the way up from the focused cell binds it first. #[gtk4::test] - fn grid_accels_do_not_collide_with_app_accels() { + fn browse_grid_keys_are_not_class_bindings_inside_the_grid() { 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}"); + grid.focus(0, 1); + let class_keys = class_keys_on_focus_paths(&grid.view, |_| false); + assert!( + class_keys.contains(&keystroke("a")), + "the list view's own bindings were not found, so the check reads nothing" + ); + + for entry in ACCELERATORS { + let AcceleratorScope::Local { + owner: ShortcutOwner::BrowseGrid, + phase, + } = entry.scope + else { + continue; + }; + assert_eq!(phase, gtk::PropagationPhase::Bubble, "{}", entry.action); + for accel in entry.triggers { + assert!( + !class_keys.contains(&keystroke(accel)), + "{accel} of {} never reaches the column view", + entry.action + ); + } } - for accel in ["q", "n", "comma", "question"] { - assert!(!bound.contains(&parse(accel)), "the grid binds {accel}"); + } + + /// The table lists these as GtkListBase's own, so the list must bind + /// them. + #[gtk4::test] + fn stock_list_keys_are_list_view_bindings() { + let grid = two_text_rows(); + let list_view = descendants(&grid.view) + .into_iter() + .find(|widget| widget.is::()) + .expect("the column view's list"); + let bound = class_binding_keys(&list_view); + + for entry in ACCELERATORS.iter().filter(|entry| { + entry.scope == AcceleratorScope::Stock + && entry.action.starts_with("list.") + && entry.triggers.iter().all(|accel| !accel.contains("Pointer")) + }) { + for accel in entry.triggers { + assert!(bound.contains(&keystroke(accel)), "the list does not bind {accel}"); + } } } @@ -449,8 +454,7 @@ mod tests { content(&columns, vec![RowObject::result(vec![Value::Int(7)].into())]), &[], sink, - None, - false, + GridSorting::Server { initial: None }, TabGridContext::default(), ); let window = gtk::Window::new(); diff --git a/linux/crates/app/src/ui/grid/grid_menus.rs b/linux/crates/app/src/ui/grid/grid_menus.rs index a0d2ea7a5c..9adbf9d2de 100644 --- a/linux/crates/app/src/ui/grid/grid_menus.rs +++ b/linux/crates/app/src/ui/grid/grid_menus.rs @@ -9,11 +9,13 @@ use super::cell_edit::begin_edit; use super::cell_editor_kind::CellEditorKind; use super::cell_preset::cell_presets; use super::context_menu::{CellContext, MenuTarget}; -use super::row_json_dialog::show_row_json_dialog; -use super::selection::{export_snapshot, snapshot_result}; +use super::row_json_dialog::RowJsonRequest; +use super::selection::export_snapshot; use super::{ - CellPreset, DeleteAvailability, GridCell, GridMsg, GridSink, RowObject, TabGridContext, delete_availability, + CellPreset, DeleteAvailability, GridCell, GridMsg, GridSink, RowObject, RowSnapshot, TabGridContext, + delete_availability, }; +use crate::ui::clipboard::{CopyKind, CopyRequest, InClauseRows}; /// Enabled per right-clicked cell. `None` on a read-only grid, which /// registers none of them. @@ -101,7 +103,7 @@ 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)); + let item = crate::application::menu_item(&label, action); item.set_attribute_value("hidden-when", Some(&"action-disabled".to_variant())); edit_section.append_item(&item); }; @@ -158,7 +160,7 @@ fn build_cell_menu(shape: MenuShape) -> gio::Menu { menu.append_section(None, &json_section); let action_section = gio::Menu::new(); - action_section.append(Some(&crate::i18n::gettext("Export Results…")), Some("cell.export")); + action_section.append(Some(&crate::i18n::gettext("Export…")), Some("cell.export")); if shape.row_ops { action_section.append(Some(&crate::i18n::gettext("Insert row")), Some("cell.insert-row")); action_section.append(Some(&crate::i18n::gettext("Duplicate")), Some("cell.duplicate-row")); @@ -172,9 +174,6 @@ pub(super) struct GridMenuInit<'a> { pub(super) column_view: &'a gtk::ColumnView, pub(super) sink: GridSink, pub(super) columns: std::sync::Arc<[ResultColumn]>, - /// From the fetch that filled the grid, so Export Results reports the - /// same truncation the paginator does. - pub(super) truncated: bool, pub(super) tab_ctx: TabGridContext, } @@ -183,7 +182,6 @@ pub(super) fn install_grid_context_menus(init: GridMenuInit<'_>) -> GridMenus { column_view, sink, columns, - truncated, tab_ctx, } = init; let editable = tab_ctx.editable; @@ -216,25 +214,22 @@ pub(super) fn install_grid_context_menus(init: GridMenuInit<'_>) -> GridMenus { .build() }}; } - fn clipboard_message(encoded: Result) -> GridMsg { - match encoded { - Ok(text) => GridMsg::CopyToClipboard(text), - Err(error) => GridMsg::ShowToast(crate::i18n::gettext_f( - "Couldn't copy the selection: {error}", - &[("error", &error.to_string())], - )), - } - } macro_rules! rows_action { ($name:literal, |$cell:ident, $rows:ident| $body:expr) => {{ cell_action!($name, |$cell, _row, $rows| $body) }}; } + // The rows are taken as the grid shows them, sharing its cells, and + // the owner renders the copy off the GTK thread. macro_rules! copy_action { - ($name:literal, |$rows:ident| $message:expr) => {{ + ($name:literal, |$cell:ident| $kind:expr) => {{ let s = sink.clone(); let tab = tab.clone(); - rows_action!($name, |_cell, rows| { - let $rows = row_values(&tab, &rows); - s($message); + let columns = columns.clone(); + rows_action!($name, |$cell, rows| { + s(GridMsg::Copy(CopyRequest::Rows { + columns: columns.clone(), + rows: snapshots(&tab, &rows), + kind: $kind, + })); }) }}; } @@ -256,105 +251,64 @@ pub(super) fn install_grid_context_menus(init: GridMenuInit<'_>) -> GridMenus { let copy_action = { let s = sink.clone(); let tab = tab.clone(); - let cols = columns.clone(); + let columns = columns.clone(); rows_action!("copy", |cell, rows| { - let column = cell.column(); - let message = match rows.as_slice() { - [row] => GridMsg::CopyToClipboard( - tablepro_core::export::cell_text(&cols, column, tab.snapshot(row).value(column)) - .unwrap_or_default(), - ), - many => clipboard_message(tablepro_core::export::render_tsv(&cols, &row_values(&tab, many), false)), + let kind = match rows.len() { + 1 => CopyKind::Value(cell.column()), + _ => CopyKind::Rows, }; - s(message); + s(GridMsg::Copy(CopyRequest::Rows { + columns: columns.clone(), + rows: snapshots(&tab, &rows), + kind, + })); }) }; - let copy_rows_action = { - let cols = columns.clone(); - copy_action!("copy-rows", |rows| clipboard_message( - tablepro_core::export::render_tsv(&cols, &rows, false) - )) - }; - let copy_rows_headers_action = { - let cols = columns.clone(); - copy_action!("copy-rows-headers", |rows| clipboard_message( - tablepro_core::export::render_tsv(&cols, &rows, true) - )) - }; - let copy_json_action = { - let cols = columns.clone(); - copy_action!("copy-json", |rows| GridMsg::CopyToClipboard( - tablepro_core::export::render_json(&cols, &rows) - )) - }; - let copy_csv_action = { - let cols = columns.clone(); - copy_action!("copy-csv", |rows| clipboard_message(tablepro_core::export::render_csv( - &cols, - &rows, - &tablepro_core::export::CsvOptions { - header_row: false, - ..Default::default() - } - ))) - }; - let copy_csv_headers_action = { - let cols = columns.clone(); - copy_action!("copy-csv-headers", |rows| clipboard_message( - tablepro_core::export::render_csv(&cols, &rows, &tablepro_core::export::CsvOptions::default()) - )) - }; - let copy_markdown_action = { - let cols = columns.clone(); - copy_action!("copy-markdown", |rows| GridMsg::CopyToClipboard( - tablepro_core::export::render_markdown(&cols, &rows) - )) - }; - // NULL and binary values have no place in an IN list, so the - // clause says how many it left out instead of handing back a - // shorter list that quietly selects different rows. + let copy_rows_action = copy_action!("copy-rows", |_cell| CopyKind::Rows); + let copy_rows_headers_action = copy_action!("copy-rows-headers", |_cell| CopyKind::RowsWithHeaders); + let copy_json_action = copy_action!("copy-json", |_cell| CopyKind::Json); + let copy_csv_action = copy_action!("copy-csv", |_cell| CopyKind::Csv); + let copy_csv_headers_action = copy_action!("copy-csv-headers", |_cell| CopyKind::CsvWithHeaders); + let copy_markdown_action = copy_action!("copy-markdown", |_cell| CopyKind::Markdown); let copy_in_clause_action = { let s = sink.clone(); let tab = tab.clone(); + let columns = columns.clone(); 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( - "Nothing to copy: an IN clause can't carry NULL or binary values", - ))); - return; - } - s(GridMsg::CopyToClipboard(clause.sql)); - if clause.skipped > 0 { - s(GridMsg::ShowToast(crate::i18n::gettext_f( - "{n} NULL or binary values left out of the IN clause", - &[("n", &clause.skipped.to_string())], - ))); - } + s(GridMsg::CopyInClause(InClauseRows { + columns: columns.clone(), + rows: snapshots(&tab, &rows), + column: cell.column(), + })); }) }; let copy_column_name_action = { + let s = sink.clone(); let cols = columns.clone(); - send_action!("copy-column-name", |cell, _row| GridMsg::CopyToClipboard( - cols.get(cell.column()) + let view = view.clone(); + cell_action!("copy-column-name", |cell, _row, _rows| { + let Some(cv) = view.upgrade() else { return }; + let name = cols + .get(cell.column()) .map(|column| column.name.clone()) - .unwrap_or_default() - )) + .unwrap_or_default(); + crate::ui::clipboard::copy_text(&cv, name, |message| s(GridMsg::ShowToast(message.to_owned()))); + }) }; 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 s = sink.clone(); let cols = columns.clone(); - let view = view.clone(); let tab = tab.clone(); - cell_action!("show-row-json", |_cell, row, _rows| { - let Some(cv) = view.upgrade() else { return }; - let text = tablepro_core::export::row_to_json(&cols, &tab.snapshot(&row).to_values()); - show_row_json_dialog(&cv, text); - }) + cell_action!("show-row-json", |_cell, row, _rows| s(GridMsg::ShowRowJson( + RowJsonRequest { + columns: cols.clone(), + row: tab.snapshot(&row), + } + ))) }; let set_empty_action = send_action!("set-empty", |cell, row| GridMsg::SetCellValue { row, @@ -379,8 +333,10 @@ pub(super) fn install_grid_context_menus(init: GridMenuInit<'_>) -> GridMenus { gio::ActionEntry::builder("export") .activate(move |_, _, _| { let Some(cv) = view.upgrade() else { return }; - let rows = export_snapshot(&cv, &tab); - s(GridMsg::ExportResults(snapshot_result(cols.clone(), &rows, truncated))); + s(GridMsg::ExportResults(crate::ui::export::ExportSource::Rows { + columns: cols.clone(), + rows: export_snapshot(&cv, &tab), + })); }) .build() }; @@ -501,8 +457,8 @@ 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 snapshots(tab: &TabGridContext, rows: &[RowObject]) -> Vec { + rows.iter().map(|row| tab.snapshot(row)).collect() } fn simple_action(group: &gio::SimpleActionGroup, name: &str) -> Option { @@ -510,3 +466,43 @@ fn simple_action(group: &gio::SimpleActionGroup, name: &str) -> Option().ok()) } + +#[cfg(test)] +mod tests { + use super::*; + + fn accel_of(menu: &gio::MenuModel, action: &str) -> Option> { + for position in 0..menu.n_items() { + let named = menu + .item_attribute_value(position, "action", None) + .and_then(|value| value.get::()); + if named.as_deref() == Some(action) { + return Some( + menu.item_attribute_value(position, "accel", None) + .and_then(|value| value.get::()), + ); + } + for link in ["section", "submenu"] { + if let Some(found) = menu + .item_link(position, link) + .and_then(|inner| accel_of(&inner, action)) + { + return Some(found); + } + } + } + None + } + + #[gtk4::test] + fn the_cell_menu_shows_the_cell_keys() { + let menu = build_cell_menu(MenuShape::FULL).upcast::(); + + assert_eq!( + accel_of(&menu, "cell.set-null"), + Some(Some("BackSpace".to_owned())) + ); + assert_eq!(accel_of(&menu, "cell.edit"), Some(Some("F2".to_owned()))); + assert_eq!(accel_of(&menu, "cell.set-empty"), Some(None)); + } +} diff --git a/linux/crates/app/src/ui/grid/grid_msg.rs b/linux/crates/app/src/ui/grid/grid_msg.rs index 75d08a9b95..01708c78c6 100644 --- a/linux/crates/app/src/ui/grid/grid_msg.rs +++ b/linux/crates/app/src/ui/grid/grid_msg.rs @@ -1,9 +1,9 @@ use std::rc::Rc; -use tablepro_core::QueryResult; - -use super::{CellPreset, RowObject}; +use super::{CellPreset, RowJsonRequest, RowObject}; use crate::services::tracker_edit::TrackerEdit; +use crate::ui::clipboard::{CopyRequest, InClauseRows}; +use crate::ui::export::ExportSource; /// Where a grid sends its events. The owner holds the grid, so the /// closure holds the owner weakly. @@ -20,11 +20,15 @@ pub enum GridMsg { column: usize, edit: TrackerEdit, }, - CopyToClipboard(String), + /// Rows to copy, which the owner renders off the GTK thread. + Copy(CopyRequest), + ShowRowJson(RowJsonRequest), ShowToast(String), CopyRowsAsInsert { rows: Vec, }, + /// The owner knows the engine whose literals the list is written in. + CopyInClause(InClauseRows), /// An intent the tab resolves against the column, so the keyboard /// path and the menu reach the same value. SetCellValue { @@ -32,7 +36,7 @@ pub enum GridMsg { column: usize, preset: CellPreset, }, - ExportResults(QueryResult), + ExportResults(ExportSource), DeleteRows { rows: Vec, }, diff --git a/linux/crates/app/src/ui/grid/inline_editor.rs b/linux/crates/app/src/ui/grid/inline_editor.rs index 6a9ca29ca1..424d8c5f6f 100644 --- a/linux/crates/app/src/ui/grid/inline_editor.rs +++ b/linux/crates/app/src/ui/grid/inline_editor.rs @@ -190,6 +190,7 @@ mod tests { use gtk4::gdk::{Key, ModifierType}; use super::{EditKey, edit_key}; + use crate::test_support::shortcut_keys::keystroke; use crate::ui::grid::edit_navigation::EditDirection; #[test] @@ -208,4 +209,23 @@ mod tests { assert_eq!(edit_key(Key::Tab, ModifierType::ALT_MASK), None); assert_eq!(edit_key(Key::Return, none), None, "Return is the field's activate"); } + + /// The editor reads keys itself, to leave them to an open preedit, so + /// the table's entries are checked against what it does. + #[gtk4::test] + fn edit_keys_match_cell_editor_entries() { + let expected = [ + ("cell-editor.cancel", EditKey::Cancel), + ("cell-editor.next", EditKey::Move(EditDirection::Forward)), + ("cell-editor.previous", EditKey::Move(EditDirection::Backward)), + ]; + for (action, outcome) in expected { + let triggers = crate::application::triggers_for(action); + assert!(!triggers.is_empty(), "{action} is not in the table"); + for accel in triggers { + let (key, modifiers) = keystroke(accel); + assert_eq!(edit_key(key, modifiers), Some(outcome), "{accel}"); + } + } + } } diff --git a/linux/crates/app/src/ui/grid/mod.rs b/linux/crates/app/src/ui/grid/mod.rs index 494cccb51b..35779dd881 100644 --- a/linux/crates/app/src/ui/grid/mod.rs +++ b/linux/crates/app/src/ui/grid/mod.rs @@ -29,16 +29,22 @@ mod grid_row_index; mod inline_editor; mod menu_rows; mod multiline_editor; +mod result_grid; +mod result_sorters; mod row_factory; mod row_json_dialog; mod row_key_builder; mod row_object; mod row_snapshot; +mod row_state_column; +mod row_state_icon; +mod row_watches; mod selection; mod tab_grid_context; #[cfg(test)] mod test_grid; mod toggle_cell; +mod value_order; #[cfg(test)] pub(crate) use self::cell_edit::begin_edit; @@ -46,7 +52,7 @@ pub(crate) use self::cell_editability::cell_editability; pub(crate) use self::cell_focus::edit_row; pub(crate) use self::cell_lock::CellLock; pub(crate) use self::cell_preset::{CellPreset, cell_presets}; -pub(crate) use self::column_factory::build_column_view; +pub(crate) use self::column_factory::{GridSorting, 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; @@ -56,8 +62,11 @@ 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::result_grid::build_result_grid; +pub(crate) use self::row_json_dialog::{RowJsonRequest, present_row_json}; pub(crate) use self::row_key_builder::RowKeyBuilder; pub(crate) use self::row_object::RowObject; pub(crate) use self::row_snapshot::RowSnapshot; -pub(crate) use self::selection::{export_snapshot, selected_positions, snapshot_result}; +pub(crate) use self::row_state_column::data_columns; +pub(crate) use self::selection::{export_snapshot, selected_positions}; 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 index 621c36682e..38f9a3eb62 100644 --- a/linux/crates/app/src/ui/grid/multiline_editor.rs +++ b/linux/crates/app/src/ui/grid/multiline_editor.rs @@ -4,6 +4,7 @@ use sourceview5::prelude::*; use super::edit_commit::{commit_edit, send_commit}; use super::{EditSnapshot, GridCell}; +use crate::application::{ShortcutOwner, component_trigger, controller_name}; /// 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 @@ -65,12 +66,10 @@ impl MultilineEditor { } }); let save_shortcut = gtk::ShortcutController::new(); + save_shortcut.set_name(Some(controller_name(ShortcutOwner::CellEditor))); 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, - )), + component_trigger("cell-editor.save-text"), Some(gtk::CallbackAction::new(move |_, _| { if let Some(save) = weak_save.upgrade() { save.emit_clicked(); diff --git a/linux/crates/app/src/ui/grid/result_grid.rs b/linux/crates/app/src/ui/grid/result_grid.rs new file mode 100644 index 0000000000..4fee174e87 --- /dev/null +++ b/linux/crates/app/src/ui/grid/result_grid.rs @@ -0,0 +1,272 @@ +use std::sync::Arc; + +use gtk4 as gtk; +use tablepro_core::column::ResultColumn; + +use super::{GridContent, GridSink, GridSorting, RowObject, TabGridContext, build_column_view}; + +/// A grid of an editor result, which sorts the rows it holds when a +/// header is clicked. +/// +/// The rows reach the view through a `GtkSortListModel` on the view's +/// own sorter, so every selection, copy, menu and export reads the row +/// at its place on screen. +pub(crate) fn build_result_grid( + columns: Arc<[ResultColumn]>, + rows: Vec, + sink: GridSink, +) -> (gtk::ColumnView, gtk::MultiSelection) { + build_column_view( + GridContent { columns, rows }, + &[], + sink, + GridSorting::Client, + TabGridContext::default(), + ) +} + +#[cfg(test)] +mod tests { + use std::time::{Duration, Instant}; + + use gtk4::glib; + use gtk4::prelude::*; + use tablepro_core::Value; + use tablepro_core::column::ColumnInfo; + + use super::*; + use crate::test_support::{descendants, drain_main_context, wait_until}; + use crate::ui::clipboard::{CopyKind, CopyRequest}; + use crate::ui::grid::test_grid::{named, recorder}; + use crate::ui::grid::{GridCell, GridMsg}; + + const WAIT: Duration = Duration::from_secs(10); + + fn grid(columns: &[ColumnInfo], rows: Vec>, sink: GridSink) -> (gtk::ColumnView, gtk::MultiSelection) { + let rows = rows.into_iter().map(|cells| RowObject::result(cells.into())).collect(); + build_result_grid(columns.iter().map(ColumnInfo::result_column).collect(), rows, sink) + } + + fn sort(view: >k::ColumnView, column: u32, order: gtk::SortType) { + let column = view + .columns() + .item(column) + .and_downcast::() + .expect("the column"); + view.sort_by_column(Some(&column), order); + } + + /// Column `column` of each row, in the order the view shows them. + fn shown(selection: >k::MultiSelection, column: usize) -> Vec { + (0..selection.n_items()) + .filter_map(|position| selection.item(position).and_downcast::()) + .map(|row| row.cell(column).cloned().unwrap_or(Value::Null)) + .collect() + } + + fn text(value: &str) -> Value { + Value::Text(value.to_owned()) + } + + #[gtk4::test] + fn sort_by_column_descending_orders_result_rows() { + let columns = [named("id", "integer", false), named("name", "text", false)]; + let (view, selection) = grid( + &columns, + vec![ + vec![Value::Int(2), text("b")], + vec![Value::Int(10), text("j")], + vec![Value::Null, text("none")], + vec![Value::Int(-1), text("a")], + ], + recorder().0, + ); + + sort(&view, 0, gtk::SortType::Descending); + + assert_eq!( + shown(&selection, 0), + [Value::Int(10), Value::Int(2), Value::Int(-1), Value::Null] + ); + assert_eq!(shown(&selection, 1), [text("j"), text("b"), text("a"), text("none")]); + sort(&view, 0, gtk::SortType::Ascending); + assert_eq!( + shown(&selection, 0), + [Value::Null, Value::Int(-1), Value::Int(2), Value::Int(10)] + ); + } + + #[gtk4::test] + fn a_sort_keeps_the_selected_rows_selected() { + let columns = [named("id", "integer", false)]; + let (view, selection) = grid( + &columns, + vec![vec![Value::Int(3)], vec![Value::Int(1)], vec![Value::Int(2)]], + recorder().0, + ); + selection.select_item(0, true); + selection.select_item(1, false); + + sort(&view, 0, gtk::SortType::Ascending); + + let selected: Vec = (0..selection.n_items()) + .filter(|position| selection.is_selected(*position)) + .filter_map(|position| selection.item(position).and_downcast::()) + .map(|row| row.cell(0).cloned().unwrap_or(Value::Null)) + .collect(); + assert_eq!(selected, [Value::Int(1), Value::Int(3)]); + } + + #[gtk4::test] + fn text_column_sorts_with_unicode_collation() { + let columns = [named("name", "text", false)]; + let names = ["f", "é", "e", "E"]; + let mut rows: Vec> = names.iter().map(|name| vec![text(name)]).collect(); + rows.insert(2, vec![Value::Null]); + let (view, selection) = grid(&columns, rows, recorder().0); + + sort(&view, 0, gtk::SortType::Ascending); + + // The order is the locale's: whatever it is, it is the order of + // the collation keys GLib builds for it, with NULL first. The + // sorter ignores case, as GtkStringSorter does by default, so + // `e` and `E` tie and keep their row order. + let mut collated = names.to_vec(); + collated.sort_by_key(|name| glib::CollationKey::from(glib::casefold(name).as_str())); + let mut expected = vec![Value::Null]; + expected.extend(collated.iter().map(|name| text(name))); + assert_eq!(shown(&selection, 0), expected); + let key = |name: &str| glib::CollationKey::from(glib::casefold(name).as_str()); + if key("e") < key("é") && key("é") < key("f") { + let order: Vec = shown(&selection, 0) + .into_iter() + .filter(|value| *value != text("E")) + .collect(); + assert_eq!(order, [Value::Null, text("e"), text("é"), text("f")]); + } + sort(&view, 0, gtk::SortType::Descending); + assert_eq!( + shown(&selection, 0).last(), + Some(&Value::Null), + "NULL is not last descending" + ); + } + + #[gtk4::test] + fn a_text_column_orders_its_numbers_before_its_text() { + let mut column = named("tag", "nvarchar(20)", false); + column.column_type.dynamic_storage = false; + let (view, selection) = grid( + &[column], + vec![ + vec![Value::Int(10)], + vec![text("b")], + vec![Value::Int(9)], + vec![Value::Null], + vec![text("a")], + ], + recorder().0, + ); + + sort(&view, 0, gtk::SortType::Ascending); + + assert_eq!( + shown(&selection, 0), + [Value::Null, Value::Int(9), Value::Int(10), text("a"), text("b")] + ); + } + + #[gtk4::test] + fn text_column_sorter_is_string_sorter_with_unicode_collation() { + let (view, _selection) = grid(&[named("name", "varchar(20)", false)], Vec::new(), recorder().0); + let column = view + .columns() + .item(0) + .and_downcast::() + .expect("the column"); + + let sorter = column + .sorter() + .and_downcast::() + .expect("a text column sorts in steps"); + let collating = sorter + .item(sorter.n_items() - 1) + .and_downcast::() + .expect("the last step collates the text"); + assert_eq!(collating.collation(), gtk::Collation::Unicode); + assert!( + sorter.item(0).and_downcast::().is_some(), + "NULL is not placed first" + ); + } + + #[gtk4::test] + fn sorting_10k_text_rows_finishes_within_one_second() { + let rows = (0..10_000_u64) + .map(|row| vec![text(&format!("name {:05}", row * 7_919 % 10_000))]) + .collect(); + let (view, selection) = grid(&[named("name", "text", false)], rows, recorder().0); + + let started = Instant::now(); + sort(&view, 0, gtk::SortType::Ascending); + let count = selection.n_items(); + let elapsed = started.elapsed(); + + assert_eq!(count, 10_000); + assert!(elapsed < Duration::from_secs(1), "sorting took {elapsed:?}"); + assert_eq!(shown(&selection, 0)[0], text("name 00000")); + assert_eq!(shown(&selection, 0)[9_999], text("name 09999")); + } + + #[gtk4::test] + fn copy_after_sort_copies_displayed_row() { + let columns = [named("id", "integer", false), named("name", "text", false)]; + let (sink, messages) = recorder(); + let (view, _selection) = grid( + &columns, + vec![ + vec![Value::Int(3), text("c")], + vec![Value::Int(1), text("a")], + vec![Value::Int(2), text("b")], + ], + sink, + ); + let window = gtk::Window::new(); + window.set_default_size(600, 400); + window.set_child(Some(&view)); + window.present(); + sort(&view, 0, gtk::SortType::Ascending); + let first_cell = || { + descendants(&view) + .into_iter() + .filter_map(|widget| widget.downcast::().ok()) + .find(|cell| cell.position() == Some(0) && cell.column() == 1) + }; + wait_until(WAIT, || first_cell().is_some_and(|cell| cell.row().is_some())).expect("the cells are drawn"); + let cell = first_cell().expect("the first row's cell"); + let gesture = { + 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") + }; + + gesture.emit_by_name::<()>("pressed", &[&1i32, &1.0f64, &1.0f64]); + view.activate_action("cell.copy", None).expect("the menu's Copy"); + drain_main_context(); + + let copied: Vec<(Vec, CopyKind)> = messages + .borrow() + .iter() + .filter_map(|msg| match msg { + GridMsg::Copy(CopyRequest::Rows { rows, kind, .. }) => { + Some((rows.iter().flat_map(|row| row.to_values()).collect(), *kind)) + } + _ => None, + }) + .collect(); + assert_eq!(copied, [(vec![Value::Int(1), text("a")], CopyKind::Value(1))]); + window.close(); + } +} diff --git a/linux/crates/app/src/ui/grid/result_sorters.rs b/linux/crates/app/src/ui/grid/result_sorters.rs new file mode 100644 index 0000000000..8553ce35a0 --- /dev/null +++ b/linux/crates/app/src/ui/grid/result_sorters.rs @@ -0,0 +1,63 @@ +use std::cmp::Ordering; + +use gtk4::prelude::*; +use gtk4::{self as gtk, glib}; +use tablepro_core::Value; +use tablepro_core::column::{ColumnKind, ResultColumn}; + +use super::RowObject; +use super::value_order::{compare, sort_text, storage_class_order}; + +/// What a header click on column `index` sorts the rows by. +/// +/// Text is collated in the user's locale by `GtkStringSorter`, which +/// builds a `g_utf8_collate_key` once per row and compares the keys +/// with `strcmp`, so a sort of thousands of rows does not normalise a +/// string for every comparison. The sorter ahead of it places every +/// value that is not text, so the pair serves any column that can hold +/// text: a text kind, or one with `dynamic_storage`, which a column of +/// an ordinary SQLite table has because its rows choose their own +/// storage whatever the declared kind. +pub(crate) fn column_sorter(index: usize, column: &ResultColumn) -> gtk::Sorter { + let column_type = &column.column_type; + if column_type.dynamic_storage || holds_text(&column_type.kind) { + return collating(cells_by(index, storage_class_order), index); + } + cells_by(index, compare).upcast() +} + +fn holds_text(kind: &ColumnKind) -> bool { + kind.is_textual() || matches!(kind, ColumnKind::Json { .. } | ColumnKind::Network(_)) +} + +/// `first`, then the locale's collation of the column's text. +fn collating(first: gtk::CustomSorter, index: usize) -> gtk::Sorter { + let sorter = gtk::MultiSorter::new(); + sorter.append(first); + sorter.append(text_sorter(index)); + sorter.upcast() +} + +fn text_sorter(index: usize) -> gtk::StringSorter { + let text = gtk::ClosureExpression::with_callback(&[] as &[gtk::Expression], move |values: &[glib::Value]| { + let row = values.first()?.get::().ok()?; + row.cell(index).and_then(sort_text).map(str::to_owned) + }); + let sorter = gtk::StringSorter::new(Some(text)); + sorter.set_collation(gtk::Collation::Unicode); + sorter +} + +/// A sorter that orders two rows by their cells in column `index`. +fn cells_by(index: usize, order: fn(&Value, &Value) -> Ordering) -> gtk::CustomSorter { + gtk::CustomSorter::new(move |a, b| { + let (Some(a), Some(b)) = (a.downcast_ref::(), b.downcast_ref::()) else { + return gtk::Ordering::Equal; + }; + order( + a.cell(index).unwrap_or(&Value::Null), + b.cell(index).unwrap_or(&Value::Null), + ) + .into() + }) +} diff --git a/linux/crates/app/src/ui/grid/row_factory.rs b/linux/crates/app/src/ui/grid/row_factory.rs index b403312e1b..61095da789 100644 --- a/linux/crates/app/src/ui/grid/row_factory.rs +++ b/linux/crates/app/src/ui/grid/row_factory.rs @@ -1,19 +1,12 @@ -use std::cell::RefCell; -use std::collections::HashMap; use std::rc::Rc; +use gtk4 as gtk; use gtk4::prelude::*; -use gtk4::{self as gtk, glib}; use super::cell_accessibility::row_description; +use super::row_watches::RowWatches; 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. @@ -25,7 +18,7 @@ pub(super) fn install_row_factory(column_view: >k::ColumnView, tab_ctx: TabGri } }); - let watches: Rc>> = Rc::default(); + let watches: Rc> = Rc::default(); let tab_ctx = Rc::new(tab_ctx); { let watches = watches.clone(); @@ -36,28 +29,15 @@ pub(super) fn install_row_factory(column_view: >k::ColumnView, tab_ctx: TabGri 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); - } + watches.bind(list_row, row, move |list_row, row| describe(list_row, &tab_ctx, row)); }); } 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); - } + watches.unbind(list_row); list_row.set_property("accessible-description", None::); }); column_view.set_row_factory(Some(&factory)); @@ -65,7 +45,10 @@ pub(super) fn install_row_factory(column_view: >k::ColumnView, tab_ctx: TabGri /// 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))); + list_row.set_property( + "accessible-description", + row_description(tab_ctx.row_pending_state(row)), + ); } #[cfg(test)] @@ -73,6 +56,7 @@ mod tests { use gtk4 as gtk; use gtk4::prelude::*; use tablepro_core::Value; + use tablepro_core::edit::CellInput; use crate::services::tracker_edit::TrackerEdit; use crate::test_support::wait_until; @@ -113,6 +97,13 @@ mod tests { .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"); + // A NULL is named whatever the row, so the cell holds a value. + let draft_id = draft.row().and_then(|row| row.draft_id()).expect("a draft"); + grid.redraw(&grid.tracker.apply(TrackerEdit::DraftCell { + draft_id, + col: draft.column(), + input: CellInput::Value(Value::Text("typed".into())), + })); assert!(gtk::test_accessible_has_property( &row, diff --git a/linux/crates/app/src/ui/grid/row_json_dialog.rs b/linux/crates/app/src/ui/grid/row_json_dialog.rs index 6f513bb498..afee27b50e 100644 --- a/linux/crates/app/src/ui/grid/row_json_dialog.rs +++ b/linux/crates/app/src/ui/grid/row_json_dialog.rs @@ -1,10 +1,97 @@ +use std::sync::Arc; + use gtk4 as gtk; use gtk4::prelude::*; use libadwaita as adw; use libadwaita::prelude::*; use sourceview5::prelude::*; +use tablepro_core::Value; +use tablepro_core::column::ResultColumn; +use tablepro_session::runtime::Tasks; + +use super::RowSnapshot; +use crate::ui::clipboard::{ClipboardFormat, ClipboardPayload}; +use crate::ui::format::{format_thousands, plural_count}; + +/// Longer text is cut in the dialog: the view lays out every character +/// it holds, and a value of megabytes would stall it. +const SHOWN_CHARS: usize = 10_000; + +/// One row to show as JSON, under the grid's visible columns. +#[derive(Debug)] +pub struct RowJsonRequest { + pub columns: Arc<[ResultColumn]>, + pub row: RowSnapshot, +} + +/// The row's JSON in full, for a copy, and as the dialog shows it. +struct RowJson { + full: String, + shown: String, + shortened: bool, +} + +fn render(request: RowJsonRequest) -> RowJson { + let values = request.row.to_values(); + let full = tablepro_core::export::row_to_json(&request.columns, &values); + let mut shortened = false; + let shown_values: Vec = values + .into_iter() + .map(|value| match value { + Value::Text(text) => match shorten(&text) { + Some(cut) => { + shortened = true; + Value::Text(cut) + } + None => Value::Text(text), + }, + other => other, + }) + .collect(); + let shown = match shortened { + true => tablepro_core::export::row_to_json(&request.columns, &shown_values), + false => full.clone(), + }; + RowJson { full, shown, shortened } +} + +/// `text` cut at a character boundary after `SHOWN_CHARS` characters, +/// saying how much is left out, or `None` when it fits. +fn shorten(text: &str) -> Option { + let (end, _) = text.char_indices().nth(SHOWN_CHARS)?; + let more = (text.len() - end) as u64; + Some(format!( + "{}{}", + &text[..end], + crate::i18n::ngettext_f( + "… ({count} more byte)", + "… ({count} more bytes)", + plural_count(more), + &[("count", &format_thousands(more))], + ) + )) +} + +/// Render the row off the GTK thread, then show it over `parent`. +pub(crate) async fn present_row_json( + parent: gtk::Widget, + tasks: Tasks, + request: RowJsonRequest, +) -> Option { + let rendered = match tasks.spawn_blocking_task(move || render(request)).await { + Ok(rendered) => rendered, + Err(failure) => { + tracing::warn!(%failure, "a row could not be rendered as JSON"); + return None; + } + }; + let dialog = row_json_dialog(rendered); + dialog.present(Some(&parent)); + Some(dialog) +} -pub(super) fn show_row_json_dialog(parent: &impl IsA, json: String) { +fn row_json_dialog(json: RowJson) -> adw::Dialog { + let RowJson { full, shown, shortened } = json; let buffer = sourceview5::Buffer::new(None); if let Some(lang) = sourceview5::LanguageManager::default().language("json") { buffer.set_language(Some(&lang)); @@ -15,7 +102,7 @@ pub(super) fn show_row_json_dialog(parent: &impl IsA, json: String) "Adwaita" }; buffer.set_style_scheme(sourceview5::StyleSchemeManager::default().scheme(scheme_name).as_ref()); - buffer.set_text(&json); + buffer.set_text(&shown); let view = sourceview5::View::with_buffer(&buffer); view.set_editable(false); view.set_monospace(true); @@ -25,13 +112,34 @@ pub(super) fn show_row_json_dialog(parent: &impl IsA, json: String) let scrolled = gtk::ScrolledWindow::builder().child(&view).vexpand(true).build(); let copy_button = gtk::Button::from_icon_name(crate::ui::icons::EDIT_COPY); - copy_button.set_tooltip_text(Some(&crate::i18n::gettext("Copy"))); - copy_button.connect_clicked(move |b| b.clipboard().set_text(&json)); + crate::ui::a11y::describe_icon_button(©_button, &crate::i18n::gettext("Copy"), None); + let payload = ClipboardPayload { + text: full, + format: ClipboardFormat::Json, + rows: 1, + skipped: 0, + }; + let toasts = adw::ToastOverlay::new(); + toasts.set_child(Some(&scrolled)); + let shown_on = toasts.downgrade(); + copy_button.connect_clicked(move |button| { + let shown_on = shown_on.clone(); + crate::ui::clipboard::copy_payload(button, payload.clone(), move |message| { + if let Some(toasts) = shown_on.upgrade() { + toasts.add_toast(crate::ui::plain_toast(message)); + } + }); + }); let header = adw::HeaderBar::new(); header.pack_end(©_button); + let banner = adw::Banner::new(&crate::i18n::gettext( + "Long values are shortened here. Copy includes them in full.", + )); + banner.set_revealed(shortened); let toolbar = adw::ToolbarView::new(); toolbar.add_top_bar(&header); - toolbar.set_content(Some(&scrolled)); + toolbar.add_top_bar(&banner); + toolbar.set_content(Some(&toasts)); adw::Dialog::builder() .title(crate::i18n::gettext("Row as JSON")) @@ -39,5 +147,77 @@ pub(super) fn show_row_json_dialog(parent: &impl IsA, json: String) .content_height(480) .child(&toolbar) .build() - .present(Some(parent)); +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use tablepro_core::column::ColumnType; + + use super::*; + use crate::test_support::{descendants, finish_on_main, first_descendant_of_type}; + + fn local_text(clipboard: >k::gdk::Clipboard) -> Option { + clipboard.content()?.value(String::static_type()).ok()?.get().ok() + } + + #[gtk4::test] + fn row_json_dialog_caps_display_but_copies_full_value() { + adw::init().expect("libadwaita initialises under the test backend"); + let window = adw::Window::new(); + window.present(); + let runtime = crate::test_support::test_runtime(); + let long = "x".repeat(25_000); + let request = RowJsonRequest { + columns: vec![ + ResultColumn::new("id", ColumnType::untyped_text("text")), + ResultColumn::new("body", ColumnType::untyped_text("text")), + ] + .into(), + row: RowSnapshot::new(vec![Value::Int(1), Value::Text(long.clone())].into(), Vec::new()), + }; + + let dialog = finish_on_main( + Duration::from_secs(10), + present_row_json(window.clone().upcast(), runtime.tasks(), request), + ) + .expect("the render to end") + .expect("the dialog"); + + let view = first_descendant_of_type::(&dialog).expect("the text"); + let buffer = view.buffer(); + let shown = buffer.text(&buffer.start_iter(), &buffer.end_iter(), false).to_string(); + assert!(!shown.contains(&long), "the whole value was laid out"); + assert!( + shown.contains("… (15,000 more bytes)"), + "{}", + &shown[shown.len() - 60..] + ); + let banner = first_descendant_of_type::(&dialog).expect("the banner"); + assert!(banner.is_revealed()); + + let copy = descendants(&dialog) + .into_iter() + .filter_map(|widget| widget.downcast::().ok()) + .find(|button| button.tooltip_text().as_deref() == Some("Copy")) + .expect("the copy button"); + copy.emit_clicked(); + + assert!( + descendants(&dialog) + .iter() + .filter_map(|widget| widget.downcast_ref::()) + .any(|label| label.label() == "Copied to clipboard"), + "no toast said the copy landed" + ); + let clipboard = copy.clipboard(); + assert!(clipboard.formats().contain_mime_type("application/json")); + let copied = local_text(&clipboard).expect("the copied text"); + assert!(copied.contains(&long), "the copy was shortened too"); + let parsed: serde_json::Value = serde_json::from_str(&copied).expect("the copy is JSON"); + assert_eq!(parsed["body"], serde_json::json!(long)); + dialog.close(); + runtime.shutdown(); + } } diff --git a/linux/crates/app/src/ui/grid/row_snapshot.rs b/linux/crates/app/src/ui/grid/row_snapshot.rs index 4a98b76d1f..fbe6092d86 100644 --- a/linux/crates/app/src/ui/grid/row_snapshot.rs +++ b/linux/crates/app/src/ui/grid/row_snapshot.rs @@ -34,9 +34,20 @@ impl RowSnapshot { self.cells.len() } - /// The row as the owned cells an encoder takes. + /// Each cell as the grid shows it, borrowed, for an encoder that + /// takes a row at a time. + pub fn values(&self) -> impl Iterator { + (0..self.len()).map(|column| self.value(column)) + } + + /// The row as owned cells, for a renderer that takes whole rows. pub fn to_values(&self) -> Vec { - (0..self.len()).map(|column| self.value(column).clone()).collect() + self.values().cloned().collect() + } + + #[cfg(test)] + pub(crate) fn shares_cells_with(&self, cells: &Arc<[Value]>) -> bool { + Arc::ptr_eq(&self.cells, cells) } } diff --git a/linux/crates/app/src/ui/grid/row_state_column.rs b/linux/crates/app/src/ui/grid/row_state_column.rs new file mode 100644 index 0000000000..0fa81768a5 --- /dev/null +++ b/linux/crates/app/src/ui/grid/row_state_column.rs @@ -0,0 +1,263 @@ +use std::rc::Rc; + +use gtk4 as gtk; +use gtk4::prelude::*; + +use super::row_state_icon::row_state_icon; +use super::row_watches::RowWatches; +use super::{RowObject, TabGridContext}; + +/// Not a result column, so it has no name in the table, no saved width +/// and no place in a copy or an export. +pub(crate) const ROW_STATE_COLUMN_ID: &str = "tp-row-state"; +/// One symbolic icon and its padding. +const ROW_STATE_COLUMN_WIDTH: i32 = 28; + +/// A narrow first column that shows each row's pending change as an +/// icon, so the change reads without its tint, as a diff gutter marks a +/// line. +pub(super) fn row_state_column(tab_ctx: TabGridContext) -> gtk::ColumnViewColumn { + let factory = gtk::SignalListItemFactory::new(); + factory.connect_setup(|_, item| { + let Some(cell) = item.downcast_ref::() else { + return; + }; + // The row's own description names the state, so the icon would + // say it twice. + let image = gtk::Image::builder() + .accessible_role(gtk::AccessibleRole::Presentation) + .build(); + cell.set_property("focusable", false); + cell.set_child(Some(&image)); + }); + + let watches: Rc> = Rc::default(); + let tab_ctx = Rc::new(tab_ctx); + { + let watches = watches.clone(); + factory.connect_bind(move |_, item| { + let Some(cell) = item.downcast_ref::() else { + return; + }; + let Some(row) = cell.item().and_downcast::() else { + return; + }; + let tab_ctx = tab_ctx.clone(); + watches.bind(cell, row, move |cell, row| { + if let Some(image) = cell.child().and_downcast::() { + show_state(&image, &tab_ctx, row); + } + }); + }); + } + factory.connect_unbind(move |_, item| { + if let Some(cell) = item.downcast_ref::() { + watches.unbind(cell); + } + }); + + gtk::ColumnViewColumn::builder() + .id(ROW_STATE_COLUMN_ID) + .factory(&factory) + .fixed_width(ROW_STATE_COLUMN_WIDTH) + .resizable(false) + .build() +} + +fn show_state(image: >k::Image, tab_ctx: &TabGridContext, row: &RowObject) { + let shown = row_state_icon(tab_ctx.row_pending_state(row)); + image.set_icon_name(shown.as_ref().map(|(icon, _)| *icon)); + image.set_tooltip_text(shown.as_ref().map(|(_, text)| text.as_str())); +} + +/// The view's columns that show the result's, in the view's order. That +/// is the order of a row's cells only until a header drag moves one, so +/// read it as the view is built. +pub(crate) fn data_columns(view: >k::ColumnView) -> Vec { + let columns = view.columns(); + (0..columns.n_items()) + .filter_map(|position| columns.item(position).and_downcast::()) + .filter(|column| column.id().is_none_or(|id| id.as_str() != ROW_STATE_COLUMN_ID)) + .collect() +} + +#[cfg(test)] +mod tests { + use gtk4 as gtk; + use gtk4::prelude::*; + use tablepro_core::Value; + + use super::{ROW_STATE_COLUMN_ID, data_columns}; + use crate::services::tab_tracker_handle::TabTrackerHandle; + use crate::services::tracker_edit::TrackerEdit; + use crate::test_support::{first_descendant_of_type, wait_until}; + use crate::ui::grid::test_grid::{WAIT, content, key, named, persisted, recorder, tracked, two_text_rows}; + use crate::ui::grid::{ColumnWidthBinding, GridCell, GridMsg, GridSorting, RowObject, build_column_view}; + + /// The state icon in the row `cell` is in. + fn state_icon(cell: &GridCell) -> gtk::Image { + let row = cell.parent().and_then(|cell| cell.parent()).expect("the row widget"); + first_descendant_of_type::(&row).expect("the row's state icon") + } + + fn shown(image: >k::Image) -> (Option, Option) { + ( + image.icon_name().map(|name| name.to_string()), + image.tooltip_text().map(|text| text.to_string()), + ) + } + + fn state(icon: &str, text: &str) -> (Option, Option) { + (Some(icon.to_owned()), Some(text.to_owned())) + } + + #[gtk4::test] + fn row_state_column_reflects_each_state() { + let grid = two_text_rows(); + let first = state_icon(&grid.cell_of(1, 1)); + let first_row = grid.cell_of(1, 1).row().expect("a bound row"); + assert_eq!(shown(&first), (None, None)); + assert!(gtk::test_accessible_has_role(&first, gtk::AccessibleRole::Presentation)); + assert!( + !first.parent().is_some_and(|cell| cell.is_focusable()), + "the state cell took the focus a data cell should" + ); + + grid.redraw(&grid.tracker.apply(TrackerEdit::Cell { + row_key: key(1), + col: 1, + original: Value::Text("A".into()), + value: Value::Text("edited".into()), + })); + assert_eq!(shown(&first), state("document-edit-symbolic", "Modified")); + + let generation = grid.tracker.flash_error_row(key(1)).generation; + first_row.bump_revision(); + assert_eq!(shown(&first), state("dialog-error-symbolic", "Not saved")); + assert!(grid.tracker.clear_error_row_if(generation)); + first_row.bump_revision(); + assert_eq!(shown(&first), state("document-edit-symbolic", "Modified")); + + grid.redraw(&grid.tracker.apply(TrackerEdit::DeleteRows { + persisted: vec![(key(2), vec![Value::Int(2), Value::Text("B".into())].into())], + drafts: Vec::new(), + })); + assert_eq!( + shown(&state_icon(&grid.cell_of(2, 1))), + state("edit-delete-symbolic", "Marked for deletion") + ); + + 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"); + assert_eq!(shown(&state_icon(&draft)), state("list-add-symbolic", "New row")); + + grid.redraw(&grid.tracker.discard()); + assert_eq!(shown(&state_icon(&grid.cell_of(1, 1))), (None, None)); + } + + /// The second row, since the first holds the focus, and a focused + /// row taken out of the list trips GTK 4.22. + #[gtk4::test] + fn an_unbound_state_icon_stops_following_its_row() { + let grid = two_text_rows(); + let icon = state_icon(&grid.cell_of(2, 1)); + let row: RowObject = grid.cell_of(2, 1).row().expect("a bound row"); + + grid.store.remove(1); + crate::test_support::drain_main_context(); + let _ = grid.tracker.apply(TrackerEdit::Cell { + row_key: key(2), + col: 1, + original: Value::Text("B".into()), + value: Value::Text("edited".into()), + }); + row.bump_revision(); + + assert_eq!(icon.icon_name(), None, "a recycled icon drew a row it no longer shows"); + } + + #[gtk4::test] + fn row_state_column_is_excluded_from_widths_and_sorting() { + let runtime = crate::test_support::test_runtime(); + let root = tempfile::tempdir().expect("tempdir"); + let widths = ColumnWidthBinding { + store: crate::persistence::ColumnWidthStore::load(root.path().join("column-widths.json"), &runtime.tasks()), + connection_id: uuid::Uuid::new_v4(), + schema: None, + table: "t".to_owned(), + }; + let columns = vec![named("id", "integer", true), named("name", "text", false)]; + let rows = vec![vec![Value::Int(1), Value::Text("A".into())]]; + let (sink, messages) = recorder(); + let tracker = TabTrackerHandle::default(); + let (view, _selection) = build_column_view( + content(&columns, persisted(&rows)), + &columns, + sink, + GridSorting::Server { initial: None }, + crate::ui::grid::TabGridContext { + column_widths: Some(widths.clone()), + ..tracked(&tracker) + }, + ); + let state = view + .columns() + .item(0) + .and_downcast::() + .expect("the state column"); + let data = data_columns(&view); + + assert_eq!( + state.id().map(|id| id.to_string()).as_deref(), + Some(ROW_STATE_COLUMN_ID) + ); + assert!(!state.is_resizable()); + assert!(state.sorter().is_none()); + assert_eq!(state.fixed_width(), 28); + assert_eq!(state.title(), None); + let titles: Vec = data + .iter() + .filter_map(|column| column.title().map(|title| title.to_string())) + .collect(); + assert_eq!(titles, ["id", "name"]); + + state.set_fixed_width(60); + data[1].set_fixed_width(200); + assert_eq!(widths.width(ROW_STATE_COLUMN_ID), None); + assert_eq!(widths.width("name"), Some(200)); + + view.sort_by_column(Some(&data[0]), gtk::SortType::Descending); + assert!( + messages + .borrow() + .iter() + .any(|msg| matches!(msg, GridMsg::SortChanged(0, false))), + "a sort by the first data column named another" + ); + } + + #[gtk4::test] + fn a_grid_without_edits_has_no_state_column() { + let columns = vec![named("id", "integer", true)]; + let (sink, _messages) = recorder(); + let (view, _selection) = build_column_view( + content(&columns, persisted(&[vec![Value::Int(1)]])), + &columns, + sink, + GridSorting::Client, + crate::ui::grid::TabGridContext::default(), + ); + + assert_eq!(view.columns().n_items(), 1); + assert_eq!(data_columns(&view).len(), 1); + } +} diff --git a/linux/crates/app/src/ui/grid/row_state_icon.rs b/linux/crates/app/src/ui/grid/row_state_icon.rs new file mode 100644 index 0000000000..ca991d8a95 --- /dev/null +++ b/linux/crates/app/src/ui/grid/row_state_icon.rs @@ -0,0 +1,90 @@ +use super::cell_accessibility::pending_state_text; +use crate::services::change_tracker::RowState; +use crate::ui::icons; + +/// A row's pending change, as the grid shows it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RowPendingState { + Unchanged, + Modified, + Inserted, + MarkedForDeletion, + /// The row produced the statement a save just failed on. + SaveFailed, +} + +impl RowPendingState { + pub fn new(state: RowState, save_failed: bool) -> Self { + if save_failed { + return Self::SaveFailed; + } + match state { + RowState::Clean => Self::Unchanged, + RowState::Modified => Self::Modified, + RowState::InsertDraft => Self::Inserted, + RowState::PendingDelete => Self::MarkedForDeletion, + } + } +} + +/// The icon and its tooltip, so the state reads without its tint. +pub fn row_state_icon(state: RowPendingState) -> Option<(&'static str, String)> { + let icon = match state { + RowPendingState::Unchanged => return None, + RowPendingState::Modified => icons::DOCUMENT_EDIT, + RowPendingState::Inserted => icons::LIST_ADD, + RowPendingState::MarkedForDeletion => icons::EDIT_DELETE, + RowPendingState::SaveFailed => icons::DIALOG_ERROR, + }; + Some((icon, pending_state_text(state)?)) +} + +#[cfg(test)] +mod tests { + use std::collections::HashSet; + + use super::{RowPendingState, row_state_icon}; + use crate::services::change_tracker::RowState; + + const PENDING: [RowPendingState; 4] = [ + RowPendingState::Modified, + RowPendingState::Inserted, + RowPendingState::MarkedForDeletion, + RowPendingState::SaveFailed, + ]; + + #[test] + fn row_state_icons_are_distinct_and_described() { + let shown: Vec<(&str, String)> = PENDING.into_iter().filter_map(row_state_icon).collect(); + + assert_eq!( + shown, + [ + ("document-edit-symbolic", "Modified".to_owned()), + ("list-add-symbolic", "New row".to_owned()), + ("edit-delete-symbolic", "Marked for deletion".to_owned()), + ("dialog-error-symbolic", "Not saved".to_owned()), + ] + ); + let icons: HashSet<&str> = shown.iter().map(|(icon, _)| *icon).collect(); + assert_eq!(icons.len(), PENDING.len()); + assert_eq!(row_state_icon(RowPendingState::Unchanged), None); + } + + #[test] + fn a_failed_save_outranks_the_rows_change() { + assert_eq!( + RowPendingState::new(RowState::Modified, true), + RowPendingState::SaveFailed + ); + assert_eq!( + RowPendingState::new(RowState::InsertDraft, false), + RowPendingState::Inserted + ); + assert_eq!( + RowPendingState::new(RowState::PendingDelete, false), + RowPendingState::MarkedForDeletion + ); + assert_eq!(RowPendingState::new(RowState::Clean, false), RowPendingState::Unchanged); + } +} diff --git a/linux/crates/app/src/ui/grid/row_watches.rs b/linux/crates/app/src/ui/grid/row_watches.rs new file mode 100644 index 0000000000..4bbf75bceb --- /dev/null +++ b/linux/crates/app/src/ui/grid/row_watches.rs @@ -0,0 +1,90 @@ +use std::cell::RefCell; +use std::collections::HashMap; + +use gtk4::glib; +use gtk4::prelude::*; + +use super::RowObject; + +struct RowWatch { + row: RowObject, + handler: glib::SignalHandlerId, +} + +/// The rows a factory's bound items show. An item is redrawn each time +/// its row's revision moves, until it is unbound. +pub(super) struct RowWatches { + watches: RefCell>, +} + +impl Default for RowWatches { + fn default() -> Self { + Self { + watches: RefCell::default(), + } + } +} + +impl RowWatches +where + W: IsA, +{ + /// Show `row` on `item` now and after each of the row's changes. + pub fn bind(&self, item: &W, row: RowObject, show: impl Fn(&W, &RowObject) + 'static) { + show(item, &row); + // The row outlives the item it is bound to, so the handler holds + // the item weakly. + let weak = item.downgrade(); + let handler = row.connect_revision_notify(move |row| { + if let Some(item) = weak.upgrade() { + show(&item, row); + } + }); + if let Some(stale) = self + .watches + .borrow_mut() + .insert(item.clone(), RowWatch { row, handler }) + { + stale.row.disconnect(stale.handler); + } + } + + pub fn unbind(&self, item: &W) { + // `W` also borrows as a plain object, so the key type is named. + if let Some(watch) = self.watches.borrow_mut().remove::(item) { + watch.row.disconnect(watch.handler); + } + } +} + +#[cfg(test)] +mod tests { + use std::cell::RefCell; + use std::rc::Rc; + + use gtk4 as gtk; + + use super::RowWatches; + use crate::ui::grid::RowObject; + + #[gtk4::test] + fn an_item_follows_only_the_row_it_is_bound_to() { + let watches = RowWatches::default(); + let item = gtk::Label::new(None); + let (first, second) = (RowObject::draft(1), RowObject::draft(2)); + let shown = Rc::new(RefCell::new(Vec::new())); + let record = || { + let shown = shown.clone(); + move |_: >k::Label, row: &RowObject| shown.borrow_mut().push(row.draft_id()) + }; + + watches.bind(&item, first.clone(), record()); + watches.bind(&item, second.clone(), record()); + first.bump_revision(); + second.bump_revision(); + watches.unbind(&item); + second.bump_revision(); + + assert_eq!(*shown.borrow(), [Some(1), Some(2), Some(2)]); + } +} diff --git a/linux/crates/app/src/ui/grid/selection.rs b/linux/crates/app/src/ui/grid/selection.rs index 45d88b171b..d3fb70b014 100644 --- a/linux/crates/app/src/ui/grid/selection.rs +++ b/linux/crates/app/src/ui/grid/selection.rs @@ -1,11 +1,6 @@ -use std::sync::Arc; - use gtk4 as gtk; use gtk4::prelude::*; -use tablepro_core::QueryResult; -use tablepro_core::column::ResultColumn; - use super::{RowObject, RowSnapshot, TabGridContext}; pub(crate) fn selected_positions(selection: >k::MultiSelection) -> Vec { @@ -39,8 +34,8 @@ pub(super) fn select_row_for_menu(column_view: >k::ColumnView, position: u32) selection.select_item(position, true); } -/// The paginator's Export button and the menu's Export Results both -/// read the rows here, so one label cannot mean two files. +/// The paginator's Export button and the menu's Export 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(); @@ -50,9 +45,3 @@ pub(crate) fn export_snapshot(column_view: >k::ColumnView, ctx: &TabGridContex .map(|row| ctx.snapshot(&row)) .collect() } - -/// 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 da45135f44..8913f0bc6e 100644 --- a/linux/crates/app/src/ui/grid/tab_grid_context.rs +++ b/linux/crates/app/src/ui/grid/tab_grid_context.rs @@ -4,6 +4,7 @@ use tablepro_core::Value; use tablepro_core::edit::CellInput; use tablepro_core::meta::RowIdentity; +use super::row_state_icon::RowPendingState; use super::{ColumnWidthBinding, GridRowId, RowObject, RowSnapshot}; use crate::services::cell_overlay::CellOverlay; use crate::services::change_tracker::{CellState, RowKey, RowState}; @@ -40,10 +41,18 @@ impl TabGridContext { Some((self.tracker.as_ref()?, row.id().tracker_key()?)) } - /// `Clean` for a row nothing tracks. - pub(super) fn row_state(&self, row: &RowObject) -> RowState { + /// `Unchanged` for a row nothing tracks. + pub(super) fn row_pending_state(&self, row: &RowObject) -> RowPendingState { self.tracked_key(row) - .map_or(RowState::Clean, |(tracker, key)| tracker.row_state(&key)) + .map_or(RowPendingState::Unchanged, |(tracker, key)| { + RowPendingState::new(tracker.row_state(&key), tracker.is_error_row(&key)) + }) + } + + /// Whether the grid's rows can carry pending changes, which is when + /// it gives them a state column. + pub(super) fn tracks_changes(&self) -> bool { + self.editable && self.tracker.is_some() } /// The row as the grid shows it, for every copy and export path: diff --git a/linux/crates/app/src/ui/grid/test_grid.rs b/linux/crates/app/src/ui/grid/test_grid.rs index 7633eb5bdd..c4d34de0d8 100644 --- a/linux/crates/app/src/ui/grid/test_grid.rs +++ b/linux/crates/app/src/ui/grid/test_grid.rs @@ -9,7 +9,10 @@ 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 super::{ + GridCell, GridContent, GridMsg, GridSink, GridSorting, RowObject, TabGridContext, begin_edit, build_column_view, + data_columns, +}; use crate::services::change_tracker::RowKey; use crate::services::tab_tracker_handle::TabTrackerHandle; use crate::services::tracker_delta::TrackerDelta; @@ -85,7 +88,6 @@ pub(super) fn content(columns: &[ColumnInfo], rows: Vec) -> GridConte GridContent { columns: columns.iter().map(ColumnInfo::result_column).collect(), rows, - truncated: false, } } @@ -113,8 +115,7 @@ impl Grid { content(&columns, persisted(&rows)), &columns, sink, - None, - false, + GridSorting::Server { initial: None }, tracked(&tracker), ); let store = selection @@ -164,12 +165,10 @@ impl Grid { drain_main_context(); } + /// The view's `index`th column of the result, past the state column. pub(super) fn column(&self, index: u32) -> gtk::ColumnViewColumn { - self.view - .columns() - .item(index) - .and_downcast::() - .expect("a column") + let index = usize::try_from(index).expect("a column index"); + data_columns(&self.view).into_iter().nth(index).expect("a column") } pub(super) fn focused_cell(&self) -> Option { diff --git a/linux/crates/app/src/ui/grid/toggle_cell.rs b/linux/crates/app/src/ui/grid/toggle_cell.rs index 912a99c667..6a975ddb3e 100644 --- a/linux/crates/app/src/ui/grid/toggle_cell.rs +++ b/linux/crates/app/src/ui/grid/toggle_cell.rs @@ -22,7 +22,9 @@ pub struct ToggleState { } impl ToggleCell { - pub(super) fn setup(cell: &GridCell) { + /// `column` names the check box, as written in the table, since a + /// box with no label reads as nothing but its state. + pub(super) fn setup(cell: &GridCell, column: &str) { let check = gtk::CheckButton::builder() .halign(gtk::Align::Start) .valign(gtk::Align::Center) @@ -33,6 +35,7 @@ impl ToggleCell { .focusable(false) .focus_on_click(false) .build(); + check.update_property(&[gtk::accessible::Property::Label(column)]); cell.set_content(&check); // The cell owns the check button, so the handler finds the cell // through its parent rather than holding it. diff --git a/linux/crates/app/src/ui/grid/value_order.rs b/linux/crates/app/src/ui/grid/value_order.rs new file mode 100644 index 0000000000..191e2b6dd7 --- /dev/null +++ b/linux/crates/app/src/ui/grid/value_order.rs @@ -0,0 +1,491 @@ +use std::borrow::Cow; +use std::cmp::Ordering; +use std::str::FromStr; + +use tablepro_core::Value; +use tablepro_core::value::{SqlDecimal, SqlInterval, Temporal, TimeWithOffset}; + +/// The order a header click puts a column's values in, for every kind +/// but text, which `GtkStringSorter` collates in the user's locale. +/// +/// NULL comes first. Numbers compare by value across their kinds, so +/// an integer, a float and a decimal in one column interleave as they +/// would on a number line. Each value is ordered by what it holds +/// rather than by its column's declared type, since a SQLite column +/// can hold any kind. +pub(crate) fn compare(a: &Value, b: &Value) -> Ordering { + match (a, b) { + (Value::Null, Value::Null) => Ordering::Equal, + (Value::Null, _) => Ordering::Less, + (_, Value::Null) => Ordering::Greater, + _ => match (rank(a), rank(b)) { + (left, right) if left != right => left.cmp(&right), + _ => same_rank(a, b), + }, + } +} + +/// The order of a column that can hold text: NULL, then numbers by +/// value, then text, then blobs, then the rest as `compare` orders +/// them. Two texts are `Equal` here, so the collating sorter after this +/// one orders them. +pub(crate) fn storage_class_order(a: &Value, b: &Value) -> Ordering { + let class = storage_class(a); + match class.cmp(&storage_class(b)) { + Ordering::Equal => match class { + StorageClass::Null | StorageClass::Text => Ordering::Equal, + StorageClass::Numeric => numeric_order(a, b), + StorageClass::Blob | StorageClass::Other => compare(a, b), + }, + unequal => unequal, + } +} + +/// The text a collating sorter orders `value` by. `None` for a value +/// that is not text, which some other sorter has already placed. +pub(crate) fn sort_text(value: &Value) -> Option<&str> { + match value { + Value::Text(text) => Some(text), + Value::Json(json) => Some(json.as_str()), + Value::Other(other) => Some(&other.text), + _ => None, + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +enum StorageClass { + Null, + Numeric, + Text, + Blob, + Other, +} + +fn storage_class(value: &Value) -> StorageClass { + match value { + Value::Null => StorageClass::Null, + _ if is_number(value) => StorageClass::Numeric, + _ if sort_text(value).is_some() => StorageClass::Text, + Value::Bytes(_) => StorageClass::Blob, + _ => StorageClass::Other, + } +} + +/// Where a kind of value sorts among the others, for a column that +/// mixes them. +fn rank(value: &Value) -> u8 { + match value { + Value::Null => 0, + Value::Bool(_) => 1, + Value::Int(_) + | Value::UInt(_) + | Value::WideInt(_) + | Value::Float32(_) + | Value::Float64(_) + | Value::Decimal(_) => 2, + Value::Date(_) | Value::Timestamp(_) | Value::TimestampTz(_) => 3, + Value::Time(_) | Value::TimeTz(_) => 4, + Value::Interval(_) => 5, + Value::Text(_) | Value::Json(_) | Value::Other(_) => 6, + Value::Uuid(_) => 7, + Value::Bits(_) => 8, + Value::Bytes(_) => 9, + Value::Array(_) => 10, + Value::Undecodable(_) => 11, + } +} + +fn same_rank(a: &Value, b: &Value) -> Ordering { + match (a, b) { + (Value::Bool(a), Value::Bool(b)) => a.cmp(b), + _ if is_number(a) && is_number(b) => numeric_order(a, b), + (Value::Date(_) | Value::Timestamp(_) | Value::TimestampTz(_), _) => instant_order(a, b), + (Value::Time(a), Value::Time(b)) => a.total_nanos().cmp(&b.total_nanos()), + (Value::TimeTz(a), Value::TimeTz(b)) => { + let utc = |time: &TimeWithOffset| { + time.time.total_nanos() - i128::from(time.offset.local_minus_utc()) * 1_000_000_000 + }; + utc(a) + .cmp(&utc(b)) + .then_with(|| a.offset.local_minus_utc().cmp(&b.offset.local_minus_utc())) + } + (Value::Time(_), Value::TimeTz(_)) => Ordering::Less, + (Value::TimeTz(_), Value::Time(_)) => Ordering::Greater, + (Value::Interval(a), Value::Interval(b)) => interval_span(a).cmp(&interval_span(b)), + _ if sort_text(a).is_some() => sort_text(a).cmp(&sort_text(b)), + (Value::Uuid(a), Value::Uuid(b)) => a.as_bytes().cmp(b.as_bytes()), + (Value::Bits(a), Value::Bits(b)) => a + .bit_len() + .cmp(&b.bit_len()) + .then_with(|| a.as_bytes().cmp(b.as_bytes())), + (Value::Bytes(a), Value::Bytes(b)) => a.cmp(b), + (Value::Array(a), Value::Array(b)) => a + .iter() + .zip(b.iter()) + .map(|(a, b)| compare(a, b)) + .find(|order| order.is_ne()) + .unwrap_or_else(|| a.len().cmp(&b.len())), + _ => Ordering::Equal, + } +} + +/// An interval's length the way PostgreSQL compares two: a month is 30 +/// days and a day 24 hours, so `1 mon` equals `30 days` and sorts +/// before `40 days`. +fn interval_span(interval: &SqlInterval) -> i128 { + const MICROS_PER_DAY: i128 = 86_400_000_000; + let days = i128::from(interval.months) * 30 + i128::from(interval.days); + days * MICROS_PER_DAY + i128::from(interval.microseconds) +} + +fn is_number(value: &Value) -> bool { + matches!( + value, + Value::Int(_) | Value::UInt(_) | Value::WideInt(_) | Value::Float32(_) | Value::Float64(_) | Value::Decimal(_) + ) +} + +/// Two numbers by value. NaN sorts after every number, as PostgreSQL +/// sorts it. +fn numeric_order(a: &Value, b: &Value) -> Ordering { + match (float_of(a), float_of(b)) { + (Some(a), Some(b)) => return float_order(a, b), + (Some(a), None) if a.is_nan() => return Ordering::Greater, + (None, Some(b)) if b.is_nan() => return Ordering::Less, + _ => {} + } + if let (Some(a), Some(b)) = (integer_of(a), integer_of(b)) { + return a.cmp(&b); + } + match (decimal_of(a), decimal_of(b)) { + (Some(a), Some(b)) => a.cmp_numeric(b.as_ref()), + _ => Ordering::Equal, + } +} + +fn float_order(a: f64, b: f64) -> Ordering { + match (a.is_nan(), b.is_nan()) { + (true, true) => Ordering::Equal, + (true, false) => Ordering::Greater, + (false, true) => Ordering::Less, + (false, false) => a.total_cmp(&b), + } +} + +fn float_of(value: &Value) -> Option { + match value { + Value::Float32(float) => Some(f64::from(*float)), + Value::Float64(float) => Some(*float), + _ => None, + } +} + +fn integer_of(value: &Value) -> Option { + match value { + Value::Int(int) => Some(i128::from(*int)), + Value::UInt(int) => Some(i128::from(*int)), + Value::WideInt(wide) => wide.to_i128(), + _ => None, + } +} + +/// The value as an exact decimal, for comparing numbers of different +/// kinds. A float is taken at the decimal its shortest form spells. A +/// decimal is borrowed, since a sort compares each one many times. +fn decimal_of(value: &Value) -> Option> { + let owned = match value { + Value::Decimal(decimal) => return Some(Cow::Borrowed(decimal)), + Value::Float32(_) | Value::Float64(_) => { + let float = float_of(value)?; + if float.is_nan() { + SqlDecimal::nan() + } else if float.is_infinite() { + SqlDecimal::infinity(float < 0.0) + } else { + SqlDecimal::from_str(&format_float(value)).ok()? + } + } + _ => match integer_of(value) { + Some(int) => SqlDecimal::from_scaled_i128(int, 0), + None => match value { + Value::WideInt(wide) => SqlDecimal::from_str(&wide.to_string()).ok()?, + _ => return None, + }, + }, + }; + Some(Cow::Owned(owned)) +} + +/// A float's shortest decimal spelling at its own width, so `0.1f32` +/// is `1e-1` rather than the double it widens to. +fn format_float(value: &Value) -> String { + match value { + Value::Float32(float) => format!("{float:e}"), + Value::Float64(float) => format!("{float:e}"), + _ => String::new(), + } +} + +/// Dates and timestamps, with the infinities at either end. A timestamp +/// with an offset orders by its instant, then by its offset, so two +/// spellings of one instant keep a stable order. +fn instant_order(a: &Value, b: &Value) -> Ordering { + let (Some(a_key), Some(b_key)) = (instant(a), instant(b)) else { + return rank(a).cmp(&rank(b)); + }; + a_key.cmp(&b_key) +} + +/// `(end, instant, offset)`: `end` puts -infinity first and infinity +/// last. +fn instant(value: &Value) -> Option<(i8, Option, i32)> { + let midnight = |date: &chrono::NaiveDate| date.and_hms_opt(0, 0, 0); + match value { + Value::Date(date) => Some(temporal_key(date, |date| (midnight(date), 0))), + Value::Timestamp(stamp) => Some(temporal_key(stamp, |stamp| (Some(*stamp), 0))), + Value::TimestampTz(stamp) => Some(temporal_key(stamp, |stamp| { + (Some(stamp.utc()), stamp.offset().local_minus_utc()) + })), + _ => None, + } +} + +fn temporal_key( + temporal: &Temporal, + finite: impl Fn(&T) -> (Option, i32), +) -> (i8, Option, i32) { + match temporal { + Temporal::NegInfinity => (-1, None, 0), + Temporal::Finite(value) => { + let (instant, offset) = finite(value); + (0, instant, offset) + } + Temporal::Infinity => (1, None, 0), + } +} + +#[cfg(test)] +mod tests { + use chrono::{DateTime, NaiveDate}; + use tablepro_core::value::{BitString, OffsetTimestamp, WideInteger}; + + use super::*; + + fn order(a: &Value, b: &Value) -> Ordering { + compare(a, b) + } + + fn sorted(mut values: Vec) -> Vec { + values.sort_by(order); + values + } + + fn decimal(text: &str) -> Value { + Value::Decimal(SqlDecimal::from_str(text).expect("a decimal")) + } + + #[test] + fn nulls_sort_first() { + assert_eq!( + sorted(vec![Value::Int(1), Value::Null, Value::Bool(false), Value::Null]), + [Value::Null, Value::Null, Value::Bool(false), Value::Int(1)] + ); + assert_eq!(order(&Value::Null, &Value::Float64(f64::NAN)), Ordering::Less); + } + + #[test] + fn int_decimal_and_float_compare_numerically() { + let values = vec![ + decimal("2.5"), + Value::Int(-3), + Value::Float64(2.25), + Value::UInt(u64::MAX), + Value::Float32(0.1), + decimal("0.09"), + Value::Int(2), + Value::WideInt(WideInteger::from_i128(i128::from(u64::MAX) + 1)), + Value::WideInt(WideInteger::from_u128(u128::MAX)), + ]; + + assert_eq!( + sorted(values), + [ + Value::Int(-3), + decimal("0.09"), + Value::Float32(0.1), + Value::Int(2), + Value::Float64(2.25), + decimal("2.5"), + Value::UInt(u64::MAX), + Value::WideInt(WideInteger::from_i128(i128::from(u64::MAX) + 1)), + Value::WideInt(WideInteger::from_u128(u128::MAX)), + ] + ); + assert_eq!(order(&Value::Int(2), &decimal("2.000")), Ordering::Equal); + assert_eq!(order(&Value::Float64(-0.0), &Value::Float64(0.0)), Ordering::Less); + } + + #[test] + fn nan_sorts_after_numbers() { + let nan = Value::Float64(f64::NAN); + + assert_eq!(order(&nan, &Value::Float64(f64::INFINITY)), Ordering::Greater); + assert_eq!(order(&nan, &decimal("1e300")), Ordering::Greater); + assert_eq!(order(&Value::Int(i64::MAX), &nan), Ordering::Less); + assert_eq!(order(&nan, &Value::Float32(f32::NAN)), Ordering::Equal); + assert_eq!( + order(&Value::Decimal(SqlDecimal::nan()), &Value::Float64(1.0)), + Ordering::Greater + ); + } + + #[test] + fn temporal_infinities_order() { + let day = |d| Value::Date(Temporal::Finite(NaiveDate::from_ymd_opt(2024, 1, d).expect("a date"))); + + assert_eq!( + sorted(vec![ + Value::Date(Temporal::Infinity), + day(2), + Value::Date(Temporal::NegInfinity), + day(1), + ]), + [ + Value::Date(Temporal::NegInfinity), + day(1), + day(2), + Value::Date(Temporal::Infinity), + ] + ); + } + + #[test] + fn offset_timestamps_compare_by_instant_then_offset() { + let at = |text: &str| { + Value::TimestampTz(Temporal::Finite(OffsetTimestamp::from_datetime( + DateTime::parse_from_rfc3339(text).expect("a timestamp"), + ))) + }; + let noon_utc = at("2024-06-15T12:00:00+00:00"); + let same_in_hanoi = at("2024-06-15T19:00:00+07:00"); + let earlier_in_hanoi = at("2024-06-15T18:00:00+07:00"); + + assert_eq!(order(&earlier_in_hanoi, &noon_utc), Ordering::Less); + assert_eq!( + order(&noon_utc, &same_in_hanoi), + Ordering::Less, + "a tie broke on the wall clock" + ); + assert_eq!(order(&same_in_hanoi, &noon_utc), Ordering::Greater); + assert_eq!( + order(&Value::TimestampTz(Temporal::Infinity), &noon_utc), + Ordering::Greater + ); + } + + #[test] + fn intervals_compare_by_their_length() { + let interval = |months, days, microseconds| { + Value::Interval(SqlInterval { + months, + days, + microseconds, + }) + }; + let hour = 3_600_000_000; + + assert_eq!(order(&interval(1, 0, 0), &interval(0, 40, 0)), Ordering::Less); + assert_eq!(order(&interval(1, 0, 0), &interval(0, 30, 0)), Ordering::Equal); + assert_eq!(order(&interval(0, 1, 0), &interval(0, 0, 25 * hour)), Ordering::Less); + assert_eq!(order(&interval(0, -1, 0), &interval(0, 0, -hour)), Ordering::Less); + } + + #[test] + fn bytes_bits_uuids_and_arrays_order_by_content() { + let bits = |value, len| Value::Bits(BitString::from_u64(value, len)); + + assert_eq!( + order(&Value::Bytes(vec![1, 2]), &Value::Bytes(vec![1, 3])), + Ordering::Less + ); + assert_eq!( + order(&bits(0b1, 1), &bits(0b0, 2)), + Ordering::Less, + "a shorter bit string sorts first" + ); + assert_eq!(order(&bits(0b01, 2), &bits(0b10, 2)), Ordering::Less); + assert_eq!( + order( + &Value::Uuid(uuid::Uuid::from_u128(1)), + &Value::Uuid(uuid::Uuid::from_u128(2)) + ), + Ordering::Less + ); + assert_eq!( + order( + &Value::Array(vec![Value::Int(1), Value::Int(2)]), + &Value::Array(vec![Value::Int(1), Value::Int(2), Value::Int(0)]) + ), + Ordering::Less + ); + assert_eq!( + order( + &Value::Array(vec![Value::Int(10)]), + &Value::Array(vec![Value::Int(9), Value::Int(9)]) + ), + Ordering::Greater + ); + } + + #[test] + fn dynamic_storage_orders_by_storage_class() { + let mut values = vec![ + Value::Bytes(vec![0]), + Value::Text("apple".to_owned()), + Value::Float64(2.5), + Value::Null, + Value::Int(3), + Value::Int(-1), + ]; + values.sort_by(storage_class_order); + + assert_eq!( + values, + [ + Value::Null, + Value::Int(-1), + Value::Float64(2.5), + Value::Int(3), + Value::Text("apple".to_owned()), + Value::Bytes(vec![0]), + ] + ); + assert_eq!( + storage_class_order(&Value::Text("b".to_owned()), &Value::Text("a".to_owned())), + Ordering::Equal, + "text is the collating sorter's to order" + ); + } + + #[test] + fn sort_text_covers_text_kinds_only() { + let json = tablepro_core::value::JsonText::parse("{\"a\":1}".to_owned()).expect("json"); + let other = Value::Other(Box::new(tablepro_core::value::OtherValue { + type_name: "inet".to_owned(), + text: "10.0.0.1".to_owned(), + })); + + assert_eq!(sort_text(&Value::Text("plain".to_owned())), Some("plain")); + assert_eq!(sort_text(&Value::Json(json)), Some("{\"a\":1}")); + assert_eq!(sort_text(&other), Some("10.0.0.1")); + for value in [ + Value::Null, + Value::Int(1), + Value::Bytes(b"text".to_vec()), + Value::Uuid(uuid::Uuid::nil()), + Value::Bool(true), + ] { + assert_eq!(sort_text(&value), None, "{value:?}"); + } + } +} diff --git a/linux/crates/app/src/ui/history_dialog.rs b/linux/crates/app/src/ui/history_dialog.rs deleted file mode 100644 index b3861994bf..0000000000 --- a/linux/crates/app/src/ui/history_dialog.rs +++ /dev/null @@ -1,1115 +0,0 @@ -use std::collections::HashSet; -use std::time::{Duration, SystemTime}; - -use relm4::adw::prelude::*; -use relm4::gtk::{gio, glib}; -use relm4::prelude::*; -use relm4::{adw, gtk}; - -use tablepro_session::runtime::Tasks; -use tablepro_storage::QueryHistory; -use tablepro_storage::query_history::{Entry, SearchFilter}; - -use uuid::Uuid; - -use crate::runtime::DeliverInput; - -pub struct HistoryDialog { - history: Option, - tasks: Tasks, - root: adw::Dialog, - search: gtk::SearchEntry, - pinned_group: adw::PreferencesGroup, - pinned_listbox: gtk::ListBox, - list_group: adw::PreferencesGroup, - listbox: gtk::ListBox, - stack: gtk::Stack, - status_page: adw::StatusPage, - - filter_connection: adw::ComboRow, - /// The connection filter's labels, after "All connections". - connection_labels: gtk::StringList, - filter_status: adw::ComboRow, - filter_window: adw::ComboRow, - /// Pending debounced search timeout. Replaces the previous fire- - /// on-every-keystroke behaviour: typing or flipping a filter - /// schedules a Refresh 150 ms later and cancels any earlier - /// scheduled one, so we send a single SQL search per pause — - /// matches GNOME Files' search debounce. - filter_debounce: std::rc::Rc>>, - - selection_bar: gtk::Revealer, - selection_label: gtk::Label, - - /// Every connection the history holds entries for, in the order the - /// filter lists them, so a connection that is not open can still be - /// picked. - connections: Vec<(Uuid, String)>, - selected_ids: HashSet, - entries: Vec, - row_popovers: Vec, - row_checkboxes: Vec, - select_mode: bool, -} - -pub struct HistoryDialogInit { - pub history: crate::services::history_service::HistoryService, - pub tasks: Tasks, -} - -#[derive(Debug)] -pub enum HistoryDialogInput { - SearchChanged, - FiltersChanged, - Refresh, - Activate(i64), - ReplaceCurrent(i64), - TogglePin(i64), - Delete(i64), - ToggleSelectMode(bool), - ToggleSelected(i64, bool), - DeleteSelected, - /// Confirmed-bulk-delete arm. Bulk deletion is irreversible - /// (pinned entries included), so the inline `Delete` button on - /// the selection bar opens an `AdwAlertDialog` first; only the - /// destructive response routes to this variant. - DeleteSelectedConfirmed, - ExportSelectedSql, - ExportSelectedCsv, - ClearAllRequested, - ClearAllConfirmed, - OpenStorageLocation, - ConnectionsLoaded(Vec<(Uuid, String)>), -} - -#[derive(Debug)] -pub enum HistoryDialogOutput { - OpenInNewTab(String), - ReplaceCurrentTabQuery(String), -} - -#[derive(Debug)] -pub enum HistoryDialogCmd { - Loaded(Vec), - Cleared, - ExportReady(String, String), - ExportFailed(String), -} - -impl Component for HistoryDialog { - type Init = HistoryDialogInit; - type Input = HistoryDialogInput; - type Output = HistoryDialogOutput; - type CommandOutput = HistoryDialogCmd; - type Root = adw::Dialog; - type Widgets = (); - - fn init_root() -> Self::Root { - adw::Dialog::builder() - .title(crate::i18n::gettext("Query History")) - .content_width(640) - .content_height(640) - .build() - } - - fn init(init: Self::Init, root: Self::Root, sender: ComponentSender) -> ComponentParts { - let toolbar = adw::ToolbarView::new(); - let header = adw::HeaderBar::builder().show_end_title_buttons(true).build(); - header.set_title_widget(Some(&adw::WindowTitle::new(&crate::i18n::gettext("Query History"), ""))); - - let filter_popover = gtk::Popover::new(); - let filter_button = gtk::MenuButton::builder() - .label(crate::i18n::gettext("Filter")) - .always_show_arrow(true) - .tooltip_text(crate::i18n::gettext("Filter history")) - .popover(&filter_popover) - .build(); - - // The connections fill in once the history has listed them. - let conn_model = gtk::StringList::new(&[crate::i18n::gettext("All connections").as_str()]); - let filter_connection = adw::ComboRow::builder() - .title(crate::i18n::gettext("Connection")) - .model(&conn_model) - .build(); - - let status_strings = [ - crate::i18n::gettext("Any"), - crate::i18n::gettext("Successful"), - crate::i18n::gettext("Failed"), - crate::i18n::gettext("Cancelled"), - ]; - let status_strings_ref: Vec<&str> = status_strings.iter().map(String::as_str).collect(); - let status_model = gtk::StringList::new(&status_strings_ref); - let filter_status = adw::ComboRow::builder() - .title(crate::i18n::gettext("Status")) - .model(&status_model) - .build(); - - let window_strings = [ - crate::i18n::gettext("Any time"), - crate::i18n::gettext("Last 24 hours"), - crate::i18n::gettext("Last 7 days"), - crate::i18n::gettext("Last 30 days"), - ]; - let window_strings_ref: Vec<&str> = window_strings.iter().map(String::as_str).collect(); - let window_model = gtk::StringList::new(&window_strings_ref); - let filter_window = adw::ComboRow::builder() - .title(crate::i18n::gettext("Time window")) - .model(&window_model) - .build(); - - let reset_button = gtk::Button::builder() - .label(crate::i18n::gettext("Reset")) - .halign(gtk::Align::End) - .margin_top(6) - .build(); - reset_button.add_css_class("flat"); - let reset_conn = filter_connection.clone(); - let reset_status = filter_status.clone(); - let reset_window = filter_window.clone(); - reset_button.connect_clicked(move |_| { - reset_conn.set_selected(0); - reset_status.set_selected(0); - reset_window.set_selected(0); - }); - - // Native popover content: a single AdwPreferencesGroup of - // AdwComboRow filters, with the Reset button below. Replaces - // the gtk::Grid + paired labels (the GTK3-era pattern) — - // ComboRows carry their own titles, so the Grid column of - // labels was redundant. - let filter_group = adw::PreferencesGroup::new(); - filter_group.add(&filter_connection); - filter_group.add(&filter_status); - filter_group.add(&filter_window); - - // Reset lives in the group's header-suffix slot so the - // popover content is just the AdwPreferencesGroup — no - // wrapper Box needed for layout. - filter_group.set_header_suffix(Some(&reset_button)); - filter_popover.set_child(Some(&filter_group)); - - for combo in [&filter_connection, &filter_status, &filter_window] { - let s = sender.clone(); - combo.connect_selected_notify(move |_| s.deliver(HistoryDialogInput::FiltersChanged)); - } - - header.pack_start(&filter_button); - - let select_button = gtk::ToggleButton::builder() - .label(crate::i18n::gettext("Select")) - .tooltip_text(crate::i18n::gettext("Toggle multi-select")) - .build(); - let s_select = sender.clone(); - select_button.connect_toggled(move |btn| { - s_select.deliver(HistoryDialogInput::ToggleSelectMode(btn.is_active())); - }); - header.pack_start(&select_button); - - let menu = gio::Menu::new(); - let storage_section = gio::Menu::new(); - storage_section.append( - Some(&crate::i18n::gettext("Show storage location")), - Some("history.show-storage"), - ); - menu.append_section(None, &storage_section); - let danger_section = gio::Menu::new(); - danger_section.append( - Some(&crate::i18n::gettext("Clear all history…")), - Some("history.clear-all"), - ); - menu.append_section(None, &danger_section); - let menu_button = gtk::MenuButton::builder() - .icon_name(crate::ui::icons::VIEW_MORE) - .menu_model(&menu) - .tooltip_text(crate::i18n::gettext("More actions")) - .build(); - header.pack_end(&menu_button); - - 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.deliver(HistoryDialogInput::ExportSelectedSql)) - .build(); - let export_csv_sender = sender.clone(); - let export_csv = gio::ActionEntry::builder("export-csv") - .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.deliver(HistoryDialogInput::DeleteSelected)) - .build(); - let clear_sender = sender.clone(); - let clear_all = gio::ActionEntry::builder("clear-all") - .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.deliver(HistoryDialogInput::OpenStorageLocation)) - .build(); - action_group.add_action_entries([export_sql, export_csv, delete, clear_all, show_storage]); - - let search = gtk::SearchEntry::builder() - .placeholder_text(crate::i18n::gettext("Search queries…")) - .hexpand(true) - .build(); - let s = sender.clone(); - search.connect_search_changed(move |_| s.deliver(HistoryDialogInput::SearchChanged)); - - let search_bar = gtk::SearchBar::builder() - .child(&search) - .show_close_button(true) - .search_mode_enabled(false) - .build(); - search_bar.connect_entry(&search); - - let search_toggle = gtk::ToggleButton::builder() - .icon_name(crate::ui::icons::SYSTEM_SEARCH) - .tooltip_text(crate::i18n::gettext("Search")) - .build(); - let search_bar_for_toggle = search_bar.clone(); - search_toggle.connect_toggled(move |btn| { - search_bar_for_toggle.set_search_mode(btn.is_active()); - }); - let toggle_for_close = search_toggle.clone(); - search_bar.connect_search_mode_enabled_notify(move |bar| { - toggle_for_close.set_active(bar.is_search_mode()); - }); - header.pack_end(&search_toggle); - - let inner = gtk::Box::builder().orientation(gtk::Orientation::Vertical).build(); - inner.append(&search_bar); - - // Each section is an AdwPreferencesGroup with a boxed-list - // ListBox. The group's `title` provides the section heading - // styling (matches Settings / Builder section dividers); we - // toggle the group's visibility instead of separate Label + - // ListBox visibility. - let pinned_listbox = gtk::ListBox::builder().selection_mode(gtk::SelectionMode::None).build(); - pinned_listbox.add_css_class("boxed-list"); - let pinned_group = adw::PreferencesGroup::builder() - .title(crate::i18n::gettext("Pinned")) - .visible(false) - .build(); - pinned_group.add(&pinned_listbox); - - let listbox = gtk::ListBox::builder().selection_mode(gtk::SelectionMode::None).build(); - listbox.add_css_class("boxed-list"); - let list_group = adw::PreferencesGroup::builder() - .title(crate::i18n::gettext("All queries")) - .visible(false) - .build(); - list_group.add(&listbox); - - let list_box = gtk::Box::builder() - .orientation(gtk::Orientation::Vertical) - .spacing(12) - .margin_top(12) - .margin_bottom(12) - .margin_start(12) - .margin_end(12) - .build(); - list_box.append(&pinned_group); - list_box.append(&list_group); - - let scroll = gtk::ScrolledWindow::builder() - .child(&list_box) - .hscrollbar_policy(gtk::PolicyType::Never) - .vexpand(true) - .build(); - - let status_page = adw::StatusPage::builder() - .icon_name(crate::ui::icons::DOCUMENT_OPEN_RECENT) - .title(crate::i18n::gettext("No queries yet")) - .description(crate::i18n::gettext( - "Run a query in the SQL editor and it will appear here.", - )) - .vexpand(true) - .build(); - - let stack = gtk::Stack::builder() - .vhomogeneous(false) - .vexpand(true) - .margin_top(12) - .build(); - stack.add_named(&scroll, Some("list")); - stack.add_named(&status_page, Some("empty")); - - inner.append(&stack); - search_bar.set_key_capture_widget(Some(&inner)); - - let selection_label = gtk::Label::builder().xalign(0.0).hexpand(true).build(); - selection_label.add_css_class("dim-label"); - - let export_menu = gio::Menu::new(); - export_menu.append(Some(&crate::i18n::gettext("Export as SQL")), Some("history.export-sql")); - export_menu.append(Some(&crate::i18n::gettext("Export as CSV")), Some("history.export-csv")); - let export_button = gtk::MenuButton::builder() - .label(crate::i18n::gettext("Export")) - .always_show_arrow(true) - .menu_model(&export_menu) - .build(); - - 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.deliver(HistoryDialogInput::DeleteSelected)); - - let selection_bar_inner = gtk::Box::builder() - .orientation(gtk::Orientation::Horizontal) - .spacing(8) - .margin_top(6) - .margin_bottom(6) - .margin_start(12) - .margin_end(12) - .build(); - selection_bar_inner.append(&selection_label); - selection_bar_inner.append(&export_button); - selection_bar_inner.append(&delete_button); - let selection_bar = gtk::Revealer::builder() - .child(&selection_bar_inner) - .transition_type(gtk::RevealerTransitionType::SlideUp) - .reveal_child(false) - .build(); - toolbar.add_bottom_bar(&selection_bar); - - toolbar.add_top_bar(&header); - toolbar.set_content(Some(&inner)); - root.set_child(Some(&toolbar)); - root.insert_action_group("history", Some(&action_group)); - - let model = Self { - history: init.history.store(), - tasks: init.tasks.clone(), - root: root.clone(), - search, - pinned_group, - pinned_listbox, - list_group, - listbox, - stack, - status_page, - filter_connection, - connection_labels: conn_model, - filter_status, - filter_window, - filter_debounce: std::rc::Rc::new(std::cell::RefCell::new(None)), - selection_bar, - selection_label, - connections: Vec::new(), - selected_ids: HashSet::new(), - entries: Vec::new(), - row_popovers: Vec::new(), - row_checkboxes: Vec::new(), - select_mode: false, - }; - - model.load_connections(&sender); - sender.deliver(HistoryDialogInput::Refresh); - - ComponentParts { model, widgets: () } - } - - fn update(&mut self, msg: Self::Input, sender: ComponentSender, _root: &Self::Root) { - match msg { - HistoryDialogInput::Refresh => self.run_search(sender), - // SearchChanged + FiltersChanged go through the debounce — - // every keystroke and every dropdown flip would otherwise - // fire its own SQL search. After a 150 ms idle window the - // scheduled timeout re-enters update with Refresh, which - // does the actual work. - HistoryDialogInput::SearchChanged | HistoryDialogInput::FiltersChanged => { - self.schedule_debounced_search(sender); - } - - HistoryDialogInput::Activate(id) => { - if let Some(query) = self.query_for_id(id) { - let _ = sender.output(HistoryDialogOutput::OpenInNewTab(query)); - self.root.close(); - } - } - - HistoryDialogInput::ReplaceCurrent(id) => { - if let Some(query) = self.query_for_id(id) { - let _ = sender.output(HistoryDialogOutput::ReplaceCurrentTabQuery(query)); - self.root.close(); - } - } - - HistoryDialogInput::TogglePin(id) => { - let pinned = !self.is_pinned(id); - if let Some(entry) = self.entries.iter_mut().find(|e| e.id == id) { - entry.pinned = pinned; - } - let Some(history) = self.history.clone() else { - return; - }; - self.tasks.spawn_task(async move { - if let Err(e) = history.set_pinned(id, pinned).await { - tracing::warn!(error = %e, "history set_pinned failed"); - } - }); - sender.deliver(HistoryDialogInput::Refresh); - } - - HistoryDialogInput::Delete(id) => { - self.selected_ids.remove(&id); - let Some(history) = self.history.clone() else { - return; - }; - self.tasks.spawn_task(async move { - if let Err(e) = history.delete(id).await { - tracing::warn!(error = %e, "history delete failed"); - } - }); - sender.deliver(HistoryDialogInput::Refresh); - } - - HistoryDialogInput::ToggleSelected(id, on) => { - if on { - self.selected_ids.insert(id); - } else { - self.selected_ids.remove(&id); - } - self.refresh_selection_bar(); - } - - HistoryDialogInput::ToggleSelectMode(on) => { - self.select_mode = on; - if !on { - self.selected_ids.clear(); - for cb in &self.row_checkboxes { - cb.set_active(false); - } - } - for cb in &self.row_checkboxes { - cb.set_visible(on); - } - self.refresh_selection_bar(); - } - - HistoryDialogInput::DeleteSelected => { - // Bulk deletion is irreversible and can wipe pinned - // entries the user marked as important; gate it - // behind an AdwAlertDialog confirmation before - // touching the database. Mirrors the Clear-All - // pattern below. - let n = self.selected_ids.len(); - if n == 0 { - return; - } - let dialog = adw::AlertDialog::new(None, None); - dialog.set_heading(Some(&if n == 1 { - crate::i18n::gettext("Delete this query?") - } else { - crate::i18n::ngettext_f( - "Delete {n} query?", - "Delete {n} queries?", - n as u32, - &[("n", &n.to_string())], - ) - })); - dialog.set_body(&crate::i18n::gettext( - "Selected entries will be permanently removed from history, including any pinned ones.", - )); - 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 s = sender; - dialog.connect_response(None, move |_, response| { - if response == "delete" { - s.deliver(HistoryDialogInput::DeleteSelectedConfirmed); - } - }); - dialog.present(Some(&self.root)); - } - - HistoryDialogInput::DeleteSelectedConfirmed => { - let ids: Vec = self.selected_ids.drain().collect(); - if ids.is_empty() { - return; - } - let Some(history) = self.history.clone() else { - return; - }; - self.tasks.spawn_task(async move { - if let Err(e) = history.delete_many(&ids).await { - tracing::warn!(error = %e, "history delete_many failed"); - } - }); - sender.deliver(HistoryDialogInput::Refresh); - } - - HistoryDialogInput::ExportSelectedSql => self.export_selected("sql", sender), - HistoryDialogInput::ExportSelectedCsv => self.export_selected("csv", sender), - - HistoryDialogInput::ClearAllRequested => { - let dialog = adw::AlertDialog::new( - Some(&crate::i18n::gettext("Clear all query history?")), - Some(&crate::i18n::gettext( - "This permanently deletes every saved query, including pinned ones.", - )), - ); - dialog.add_response("cancel", &crate::i18n::gettext("Cancel")); - dialog.add_response("clear", &crate::i18n::gettext("Clear")); - dialog.set_response_appearance("clear", adw::ResponseAppearance::Destructive); - dialog.set_default_response(Some("cancel")); - dialog.set_close_response("cancel"); - let s = sender; - dialog.connect_response(None, move |_, response| { - if response == "clear" { - s.deliver(HistoryDialogInput::ClearAllConfirmed); - } - }); - dialog.present(Some(&self.root)); - } - - HistoryDialogInput::ClearAllConfirmed => { - let s = sender.clone(); - let Some(history) = self.history.clone() else { - return; - }; - sender.command(move |out, shutdown| { - shutdown - .register(async move { - let _ = s; - if let Err(e) = history.clear_all().await { - tracing::warn!(error = %e, "history clear_all failed"); - } - out.send(HistoryDialogCmd::Cleared).ok() - }) - .drop_on_shutdown() - }); - } - - HistoryDialogInput::OpenStorageLocation => { - if let Some(path) = self.history.as_ref().map(|history| history.path().to_owned()) { - let parent = path.parent().map(|p| p.to_path_buf()).unwrap_or(path); - let file = gio::File::for_path(&parent); - let launcher = gtk::FileLauncher::new(Some(&file)); - launcher.launch( - Some( - &self - .root - .root() - .and_then(|r| r.downcast::().ok()) - .unwrap_or_default(), - ), - gio::Cancellable::NONE, - |_| {}, - ); - } - } - - HistoryDialogInput::ConnectionsLoaded(connections) => { - for (_, name) in &connections { - self.connection_labels.append(name); - } - self.connections = connections; - } - } - } - - fn update_cmd(&mut self, msg: Self::CommandOutput, sender: ComponentSender, _root: &Self::Root) { - match msg { - HistoryDialogCmd::Loaded(entries) => self.render_entries(entries, &sender), - HistoryDialogCmd::Cleared => { - self.selected_ids.clear(); - sender.deliver(HistoryDialogInput::Refresh); - } - HistoryDialogCmd::ExportReady(content, suggested_name) => self.save_export(content, suggested_name), - HistoryDialogCmd::ExportFailed(msg) => { - tracing::warn!(error = %msg, "history export failed"); - } - } - } -} - -impl HistoryDialog { - pub fn dialog(&self) -> &adw::Dialog { - &self.root - } - - /// Read which connections the history has entries for, on the - /// runtime because the history is a database. - fn load_connections(&self, sender: &ComponentSender) { - let Some(history) = self.history.clone() else { - return; - }; - let sender = sender.clone(); - crate::runtime::spawn_and_deliver( - &self.tasks, - async move { history.known_connections().await }, - move |listed| match listed { - 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"), - }, - ); - } - - /// Run the search immediately. Triggered by Refresh (initial load - /// + after any mutation) and by the debounced timeout firing. - fn run_search(&self, sender: ComponentSender) { - let filter = self.build_filter(); - let Some(history) = self.history.clone() else { - sender.deliver(HistoryDialogInput::Refresh); - return; - }; - sender.command(move |out, shutdown| { - shutdown - .register(async move { - match history.search(filter).await { - Ok(entries) => out.send(HistoryDialogCmd::Loaded(entries)).ok(), - Err(e) => { - tracing::warn!(error = %e, "history search failed"); - out.send(HistoryDialogCmd::Loaded(Vec::new())).ok() - } - } - }) - .drop_on_shutdown() - }); - } - - /// Cancel any pending debounce timeout and schedule a new Refresh - /// 150 ms from now. The 150 ms idle threshold matches GNOME Files' - /// search-typing debounce — short enough to feel responsive, long - /// enough that a fast typer never fires more than one query. - fn schedule_debounced_search(&self, sender: ComponentSender) { - if let Some(prev) = self.filter_debounce.borrow_mut().take() { - prev.remove(); - } - let s = sender.clone(); - 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.deliver(HistoryDialogInput::Refresh); - }); - *self.filter_debounce.borrow_mut() = Some(id); - } - - fn build_filter(&self) -> SearchFilter { - let needle = { - let text = self.search.text().to_string(); - let trimmed = text.trim().to_string(); - if trimmed.is_empty() { - None - } else { - Some(fts5_query(&trimmed)) - } - }; - let connection_id = if self.filter_connection.selected() == 0 { - None - } else { - self.connections - .get((self.filter_connection.selected() - 1) as usize) - .map(|(id, _)| *id) - }; - let (success_only, exclude_cancelled) = match self.filter_status.selected() { - 0 => (None, None), - 1 => (Some(true), Some(true)), - 2 => (Some(false), Some(true)), - 3 => (None, Some(false)), - _ => (None, None), - }; - let min_executed_at = match self.filter_window.selected() { - 1 => Some(SystemTime::now() - Duration::from_secs(24 * 3600)), - 2 => Some(SystemTime::now() - Duration::from_secs(7 * 24 * 3600)), - 3 => Some(SystemTime::now() - Duration::from_secs(30 * 24 * 3600)), - _ => None, - }; - SearchFilter { - needle, - connection_id, - success_only, - exclude_cancelled, - min_executed_at, - limit: 200, - } - } - - fn render_entries(&mut self, entries: Vec, sender: &ComponentSender) { - // Unparent the previous round's row popovers explicitly so they drop - // along with their captured sender clones. Without this, set_parent's - // strong link from popover→row would keep both alive past the - // listbox.remove() call below. - for popover in self.row_popovers.drain(..) { - popover.unparent(); - } - self.row_checkboxes.clear(); - clear_listbox(&self.pinned_listbox); - clear_listbox(&self.listbox); - - let valid_ids: HashSet = entries.iter().map(|e| e.id).collect(); - self.selected_ids.retain(|id| valid_ids.contains(id)); - self.entries = entries.clone(); - - if entries.is_empty() { - self.pinned_group.set_visible(false); - self.list_group.set_visible(false); - let has_search = !self.search.text().to_string().trim().is_empty(); - let has_filter = self.filter_connection.selected() != 0 - || self.filter_status.selected() != 0 - || self.filter_window.selected() != 0; - if has_search || has_filter { - self.status_page.set_title(&crate::i18n::gettext("No matches")); - self.status_page.set_description(Some(&crate::i18n::gettext( - "Try a different search term or change the filters.", - ))); - self.status_page.set_icon_name(Some(crate::ui::icons::SYSTEM_SEARCH)); - } else { - self.status_page.set_title(&crate::i18n::gettext("No queries yet")); - self.status_page.set_description(Some(&crate::i18n::gettext( - "Run a query in the SQL editor and it will appear here.", - ))); - self.status_page - .set_icon_name(Some(crate::ui::icons::DOCUMENT_OPEN_RECENT)); - } - self.stack.set_visible_child_name("empty"); - self.refresh_selection_bar(); - return; - } - self.stack.set_visible_child_name("list"); - - let (pinned, regular): (Vec<_>, Vec<_>) = entries.into_iter().partition(|e| e.pinned); - let has_pinned = !pinned.is_empty(); - let has_regular = !regular.is_empty(); - self.pinned_group.set_visible(has_pinned); - self.list_group.set_visible(has_regular); - - for entry in &pinned { - let (row, popover, checkbox) = self.build_row(entry, sender.clone()); - self.pinned_listbox.append(&row); - self.row_popovers.push(popover); - self.row_checkboxes.push(checkbox); - } - for entry in ®ular { - let (row, popover, checkbox) = self.build_row(entry, sender.clone()); - self.listbox.append(&row); - self.row_popovers.push(popover); - self.row_checkboxes.push(checkbox); - } - - self.refresh_selection_bar(); - } - - fn build_row( - &self, - entry: &Entry, - sender: ComponentSender, - ) -> (adw::ActionRow, gtk::PopoverMenu, gtk::CheckButton) { - let title = preview_title(&entry.query); - let subtitle = format_subtitle(entry); - let row = adw::ActionRow::builder() - .title(&title) - .subtitle(&subtitle) - .activatable(true) - .build(); - row.add_css_class("monospace"); - - let icon_name = if entry.cancelled { - crate::ui::icons::PROCESS_STOP - } else if entry.success { - crate::ui::icons::SUCCESS - } else { - crate::ui::icons::DIALOG_ERROR - }; - let icon = gtk::Image::from_icon_name(icon_name); - icon.add_css_class("dim-label"); - row.add_prefix(&icon); - - let select_check = gtk::CheckButton::builder() - .valign(gtk::Align::Center) - .visible(self.select_mode) - .build(); - select_check.set_active(self.selected_ids.contains(&entry.id)); - let id_for_check = entry.id; - let s_check = sender.clone(); - select_check.connect_toggled(move |btn| { - s_check.deliver(HistoryDialogInput::ToggleSelected(id_for_check, btn.is_active())); - }); - row.add_prefix(&select_check); - - let pin_btn = gtk::Button::builder() - .icon_name(crate::ui::icons::VIEW_PIN) - .valign(gtk::Align::Center) - .tooltip_text(if entry.pinned { - crate::i18n::gettext("Unpin") - } else { - crate::i18n::gettext("Pin") - }) - .build(); - pin_btn.add_css_class("flat"); - if entry.pinned { - pin_btn.add_css_class("accent"); - } - let id = entry.id; - let s = sender.clone(); - pin_btn.connect_clicked(move |_| s.deliver(HistoryDialogInput::TogglePin(id))); - row.add_suffix(&pin_btn); - - let delete_btn = gtk::Button::builder() - .icon_name(crate::ui::icons::USER_TRASH) - .valign(gtk::Align::Center) - .tooltip_text(crate::i18n::gettext("Delete")) - .build(); - 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.deliver(HistoryDialogInput::Delete(id))); - row.add_suffix(&delete_btn); - - let s_act = sender.clone(); - row.connect_activated(move |_| s_act.deliver(HistoryDialogInput::Activate(id))); - - let menu = gio::Menu::new(); - let nav_section = gio::Menu::new(); - nav_section.append(Some(&crate::i18n::gettext("Open in new tab")), Some("history-row.open")); - nav_section.append( - Some(&crate::i18n::gettext("Replace current tab")), - Some("history-row.replace"), - ); - menu.append_section(None, &nav_section); - let util_section = gio::Menu::new(); - util_section.append( - Some(&if entry.pinned { - crate::i18n::gettext("Unpin") - } else { - crate::i18n::gettext("Pin") - }), - Some("history-row.pin"), - ); - util_section.append(Some(&crate::i18n::gettext("Copy SQL")), Some("history-row.copy")); - menu.append_section(None, &util_section); - let danger_section = gio::Menu::new(); - danger_section.append(Some(&crate::i18n::gettext("Delete")), Some("history-row.delete")); - menu.append_section(None, &danger_section); - - let popover_menu = gtk::PopoverMenu::from_model(Some(&menu)); - popover_menu.set_has_arrow(true); - popover_menu.set_parent(&row); - - let row_actions = gio::SimpleActionGroup::new(); - let s_open = sender.clone(); - let act_open = gio::ActionEntry::builder("open") - .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.deliver(HistoryDialogInput::ReplaceCurrent(id))) - .build(); - let s_pin = sender.clone(); - let act_pin = gio::ActionEntry::builder("pin") - .activate(move |_, _, _| s_pin.deliver(HistoryDialogInput::TogglePin(id))) - .build(); - let query_for_copy = entry.query.clone(); - let row_for_copy = row.clone(); - let act_copy = gio::ActionEntry::builder("copy") - .activate(move |_, _, _| { - row_for_copy.clipboard().set_text(&query_for_copy); - }) - .build(); - let s_del2 = sender.clone(); - let act_del = gio::ActionEntry::builder("delete") - .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)); - - let gesture = gtk::GestureClick::new(); - gesture.set_button(gtk::gdk::BUTTON_SECONDARY); - let popover_for_gesture = popover_menu.clone(); - gesture.connect_pressed(move |gesture, _n, x, y| { - popover_for_gesture.set_pointing_to(Some(>k::gdk::Rectangle::new(x as i32, y as i32, 1, 1))); - popover_for_gesture.popup(); - gesture.set_state(gtk::EventSequenceState::Claimed); - }); - row.add_controller(gesture); - - let menu_shortcut = gtk::Shortcut::builder() - .trigger(>k::KeyvalTrigger::new( - gtk::gdk::Key::Menu, - gtk::gdk::ModifierType::empty(), - )) - .action(>k::CallbackAction::new({ - let popover = popover_menu.clone(); - move |_, _| { - popover.popup(); - glib::Propagation::Stop - } - })) - .build(); - let controller = gtk::ShortcutController::new(); - controller.set_scope(gtk::ShortcutScope::Local); - controller.add_shortcut(menu_shortcut); - row.add_controller(controller); - - (row, popover_menu, select_check) - } - - fn refresh_selection_bar(&self) { - let n = self.selected_ids.len(); - if n == 0 { - self.selection_bar.set_reveal_child(false); - return; - } - self.selection_label - .set_label(&crate::i18n::gettext_f("{n} selected", &[("n", &n.to_string())])); - self.selection_bar.set_reveal_child(true); - } - - fn export_selected(&self, kind: &'static str, sender: ComponentSender) { - let ids: Vec = self.selected_ids.iter().copied().collect(); - if ids.is_empty() { - return; - } - let suggested = if kind == "sql" { - "tablepro-history.sql".to_string() - } else { - "tablepro-history.csv".to_string() - }; - let Some(history) = self.history.clone() else { - return; - }; - sender.command(move |out, shutdown| { - shutdown - .register(async move { - let result = match kind { - "sql" => history.export_sql(&ids).await, - _ => history.export_csv(&ids).await, - }; - let msg = match result { - Ok(text) => HistoryDialogCmd::ExportReady(text, suggested), - Err(e) => HistoryDialogCmd::ExportFailed(e.to_string()), - }; - out.send(msg).ok() - }) - .drop_on_shutdown() - }); - } - - fn save_export(&self, content: String, suggested_name: String) { - let filter = gtk::FileFilter::new(); - if suggested_name.ends_with(".sql") { - filter.set_name(Some(&crate::i18n::gettext("SQL files"))); - filter.add_mime_type("text/x-sql"); - filter.add_suffix("sql"); - } else { - filter.set_name(Some(&crate::i18n::gettext("CSV files"))); - filter.add_mime_type("text/csv"); - filter.add_suffix("csv"); - } - let filters = gio::ListStore::new::(); - filters.append(&filter); - let dialog = gtk::FileDialog::builder() - .title(crate::i18n::gettext("Export query history")) - .modal(true) - .initial_name(&suggested_name) - .default_filter(&filter) - .filters(&filters) - .build(); - 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 }; - 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"); - } - }); - }); - } - - fn query_for_id(&self, id: i64) -> Option { - self.entries.iter().find(|e| e.id == id).map(|e| e.query.clone()) - } - - fn is_pinned(&self, id: i64) -> bool { - self.entries.iter().any(|e| e.id == id && e.pinned) - } -} - -fn fts5_query(input: &str) -> String { - // Tokenise on whitespace and quote each token. FTS5 then ANDs them by - // default ("SELECT users" → match rows containing both terms anywhere), - // matching what users expect from a search box. Quoting protects each - // token from FTS5's own operator chars (parens, AND, OR, NEAR, *). - input - .split_whitespace() - .map(|tok| { - let escaped = tok.replace('"', "\"\""); - format!("\"{escaped}\"") - }) - .collect::>() - .join(" ") -} - -fn preview_title(query: &str) -> String { - let first_line = query - .lines() - .find(|l| !l.trim().is_empty() && !l.trim().starts_with("--")) - .unwrap_or("") - .trim(); - let collected: String = first_line.chars().take(80).collect(); - if collected.chars().count() < first_line.chars().count() { - format!("{collected}…") - } else if collected.is_empty() { - crate::i18n::gettext("Empty query") - } else { - collected - } -} - -fn format_subtitle(entry: &Entry) -> String { - let when = format_relative_time(entry.executed_at); - let mut parts: Vec = vec![entry.connection_name.clone(), entry.driver_id.clone(), when]; - if let Some(d) = entry.duration_ms { - parts.push(format!("{d} ms")); - } - if entry.cancelled { - parts.push(crate::i18n::gettext("cancelled")); - } else if let Some(err) = &entry.error { - let trimmed: String = err.chars().take(48).collect(); - parts.push(if trimmed.chars().count() < err.chars().count() { - format!("{trimmed}…") - } else { - trimmed - }); - } else if let Some(rows) = entry.rows_affected { - parts.push(crate::i18n::ngettext_f( - "{n} row", - "{n} rows", - rows as u32, - &[("n", &rows.to_string())], - )); - } - parts.join(" · ") -} - -fn format_relative_time(when: SystemTime) -> String { - let now = SystemTime::now(); - let diff = now.duration_since(when).unwrap_or_default(); - let secs = diff.as_secs(); - if secs < 60 { - crate::i18n::gettext("just now") - } else if secs < 3600 { - let n = secs / 60; - crate::i18n::ngettext_f("{n} minute ago", "{n} minutes ago", n as u32, &[("n", &n.to_string())]) - } else if secs < 86_400 { - let n = secs / 3600; - crate::i18n::ngettext_f("{n} hour ago", "{n} hours ago", n as u32, &[("n", &n.to_string())]) - } else if secs < 30 * 86_400 { - let n = secs / 86_400; - crate::i18n::ngettext_f("{n} day ago", "{n} days ago", n as u32, &[("n", &n.to_string())]) - } else { - let dt = chrono::DateTime::::from(when); - dt.format("%Y-%m-%d").to_string() - } -} - -fn clear_listbox(listbox: >k::ListBox) { - while let Some(child) = listbox.first_child() { - listbox.remove(&child); - } -} diff --git a/linux/crates/app/src/ui/history_dialog/connection_filter.rs b/linux/crates/app/src/ui/history_dialog/connection_filter.rs new file mode 100644 index 0000000000..f0d5de59b2 --- /dev/null +++ b/linux/crates/app/src/ui/history_dialog/connection_filter.rs @@ -0,0 +1,158 @@ +use std::cell::RefCell; +use std::rc::Rc; + +use gtk4 as gtk; +use gtk4::prelude::*; +use tablepro_storage::SavedConnection; +use tablepro_storage::query_history::HistoryConnection; +use uuid::Uuid; + +use crate::i18n::gettext; + +/// The connection filter's choices: All Connections, then each +/// connection the history has entries for. A saved connection shows the +/// name it has now; one deleted or never saved shows the name its latest +/// run had. A clone shares the same choices. +#[derive(Clone)] +pub(crate) struct ConnectionFilterModel { + ids: Rc>>>, + labels: gtk::StringList, +} + +impl ConnectionFilterModel { + pub(crate) fn new() -> Self { + Self { + ids: Rc::new(RefCell::new(vec![None])), + labels: gtk::StringList::new(&[gettext("All Connections").as_str()]), + } + } + + /// One label per choice, in the order of the ids. + pub(crate) fn labels(&self) -> >k::StringList { + &self.labels + } + + pub(crate) fn fill(&self, connections: &[HistoryConnection], saved: &[SavedConnection]) { + let mut listed: Vec<(String, Uuid)> = connections + .iter() + .map(|connection| { + let name = saved + .iter() + .find(|saved| saved.id == connection.id) + .map_or(&connection.name, |saved| &saved.name); + (name.clone(), connection.id) + }) + .collect(); + listed.sort_by_cached_key(|(name, id)| (name.to_lowercase(), *id)); + let ids: Vec> = std::iter::once(None) + .chain(listed.iter().map(|(_, id)| Some(*id))) + .collect(); + let unchanged = *self.ids.borrow() == ids + && listed + .iter() + .zip(1..) + .all(|((name, _), position)| self.labels.string(position).is_some_and(|label| label == name.as_str())); + // A splice drops the drop-down's selection, so the filter would + // move for a list that did not change. + if unchanged { + return; + } + // The ids go first: the splice notifies the drop-down, and what + // it reads then has to match the labels. + self.ids.replace(ids); + let names: Vec<&str> = listed.iter().map(|(name, _)| name.as_str()).collect(); + self.labels.splice(1, self.labels.n_items().saturating_sub(1), &names); + } + + /// `None` for All Connections, and for a position past the end. + pub(crate) fn id_at(&self, position: u32) -> Option { + self.ids.borrow().get(position as usize).copied().flatten() + } + + pub(crate) fn position_of(&self, id: Uuid) -> Option { + self.ids + .borrow() + .iter() + .position(|listed| *listed == Some(id)) + .and_then(|position| u32::try_from(position).ok()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn saved(id: Uuid, name: &str) -> SavedConnection { + SavedConnection { + id, + name: name.to_owned(), + driver_id: "postgres".to_owned(), + host: "db".to_owned(), + port: 5432, + database: "app".to_owned(), + username: "postgres".to_owned(), + tls: tablepro_core::TlsConfig::verify_full(), + read_only: false, + auth_mode: tablepro_core::AuthMode::Password, + ssh: None, + last_opened_at: None, + color: None, + group: None, + } + } + + fn labels(model: &ConnectionFilterModel) -> Vec { + (0..model.labels().n_items()) + .filter_map(|position| model.labels().string(position)) + .map(String::from) + .collect() + } + + #[gtk4::test] + fn saved_connections_show_their_current_name_in_order() { + let (renamed, unsaved) = (Uuid::new_v4(), Uuid::new_v4()); + let model = ConnectionFilterModel::new(); + + model.fill( + &[ + HistoryConnection { + id: renamed, + name: "zeta".to_owned(), + }, + HistoryConnection { + id: unsaved, + name: "Scratch".to_owned(), + }, + ], + &[saved(renamed, "alpha"), saved(Uuid::new_v4(), "No history")], + ); + + assert_eq!(labels(&model), ["All Connections", "alpha", "Scratch"]); + assert_eq!(model.id_at(0), None); + assert_eq!(model.id_at(1), Some(renamed)); + assert_eq!(model.position_of(unsaved), Some(2)); + assert_eq!(model.id_at(3), None); + } + + #[gtk4::test] + fn filling_with_the_same_connections_leaves_the_labels_alone() { + let model = ConnectionFilterModel::new(); + let listed = [HistoryConnection { + id: Uuid::new_v4(), + name: "alpha".to_owned(), + }]; + model.fill(&listed, &[]); + let changes = std::rc::Rc::new(std::cell::Cell::new(0)); + let counted = changes.clone(); + model + .labels() + .connect_items_changed(move |_, _, _, _| counted.set(counted.get() + 1)); + + model.fill(&listed, &[]); + assert_eq!(changes.get(), 0, "an unchanged list was spliced"); + + model.fill(&listed, &[saved(listed[0].id, "renamed")]); + assert_eq!(changes.get(), 1); + assert_eq!(labels(&model), ["All Connections", "renamed"]); + } +} diff --git a/linux/crates/app/src/ui/history_dialog/history_actions.rs b/linux/crates/app/src/ui/history_dialog/history_actions.rs new file mode 100644 index 0000000000..33fafaf388 --- /dev/null +++ b/linux/crates/app/src/ui/history_dialog/history_actions.rs @@ -0,0 +1,46 @@ +use gtk4::glib; +use gtk4::subclass::prelude::*; + +use super::history_export::ExportKind; +use super::{HistoryDialog, QueryDestination, imp}; + +/// The dialog's actions. The ones for an entry take its id, so the row +/// buttons and the one row menu name the entry they act on. +pub(super) fn install(klass: &mut ::Class) { + let id = Some(glib::VariantTy::INT64); + klass.install_action("history.open", id, |dialog, _, target| { + with_id(dialog, target, |dialog, id| { + dialog.send_query(id, QueryDestination::NewTab) + }); + }); + klass.install_action("history.replace", id, |dialog, _, target| { + with_id(dialog, target, |dialog, id| { + dialog.send_query(id, QueryDestination::CurrentTab) + }); + }); + klass.install_action("history.pin", id, |dialog, _, target| { + with_id(dialog, target, HistoryDialog::toggle_pin); + }); + klass.install_action("history.copy", id, |dialog, _, target| { + with_id(dialog, target, HistoryDialog::copy_query); + }); + klass.install_action("history.delete", id, |dialog, _, target| { + with_id(dialog, target, |dialog, id| dialog.delete_entries(vec![id])); + }); + klass.install_action("history.delete-selected", None, |dialog, _, _| dialog.delete_selected()); + klass.install_action("history.export-sql", None, |dialog, _, _| { + dialog.export_selected(ExportKind::Sql); + }); + klass.install_action("history.export-csv", None, |dialog, _, _| { + dialog.export_selected(ExportKind::Csv); + }); + klass.install_action("history.clear-all", None, |dialog, _, _| dialog.confirm_clear_all()); + klass.install_action("history.show-in-files", None, |dialog, _, _| dialog.show_in_files()); + klass.install_action("history-row.menu", None, |dialog, _, _| dialog.popup_focused_row_menu()); +} + +fn with_id(dialog: &HistoryDialog, target: Option<&glib::Variant>, act: impl FnOnce(&HistoryDialog, i64)) { + if let Some(id) = target.and_then(glib::Variant::get::) { + act(dialog, id); + } +} diff --git a/linux/crates/app/src/ui/history_dialog/history_entry_object.rs b/linux/crates/app/src/ui/history_dialog/history_entry_object.rs new file mode 100644 index 0000000000..06450b46ec --- /dev/null +++ b/linux/crates/app/src/ui/history_dialog/history_entry_object.rs @@ -0,0 +1,37 @@ +use gtk4::glib; +use gtk4::subclass::prelude::*; +use tablepro_storage::query_history::Entry; + +use super::history_entry_object_imp as imp; + +glib::wrapper! { + /// One history entry as the dialog's list model holds it. An entry + /// never changes in place: a pin puts a new one where the history + /// lists it now. + pub struct HistoryEntryObject(ObjectSubclass); +} + +impl HistoryEntryObject { + pub(crate) fn new(entry: Entry) -> Self { + let object: Self = glib::Object::new(); + let _ = object.imp().entry.set(entry); + object + } + + /// Always set: `new` is the only way to build one. + pub(crate) fn entry(&self) -> Option<&Entry> { + self.imp().entry.get() + } + + pub(crate) fn id(&self) -> Option { + self.entry().map(|entry| entry.id) + } + + pub(crate) fn is_pinned(&self) -> bool { + self.entry().is_some_and(|entry| entry.pinned) + } + + pub(crate) fn query(&self) -> Option { + self.entry().map(|entry| entry.query.clone()) + } +} diff --git a/linux/crates/app/src/ui/history_dialog/history_entry_object_imp.rs b/linux/crates/app/src/ui/history_dialog/history_entry_object_imp.rs new file mode 100644 index 0000000000..cf6ee724ea --- /dev/null +++ b/linux/crates/app/src/ui/history_dialog/history_entry_object_imp.rs @@ -0,0 +1,18 @@ +use std::cell::OnceCell; + +use gtk4::glib; +use gtk4::subclass::prelude::*; +use tablepro_storage::query_history::Entry; + +#[derive(Default)] +pub struct HistoryEntryObject { + pub(super) entry: OnceCell, +} + +#[glib::object_subclass] +impl ObjectSubclass for HistoryEntryObject { + const NAME: &'static str = "TableProHistoryEntryObject"; + type Type = super::HistoryEntryObject; +} + +impl ObjectImpl for HistoryEntryObject {} diff --git a/linux/crates/app/src/ui/history_dialog/history_export.rs b/linux/crates/app/src/ui/history_dialog/history_export.rs new file mode 100644 index 0000000000..869c38687b --- /dev/null +++ b/linux/crates/app/src/ui/history_dialog/history_export.rs @@ -0,0 +1,161 @@ +use gtk4 as gtk; +use gtk4::prelude::*; +use gtk4::{gio, glib}; +use libadwaita as adw; +use libadwaita::prelude::*; +use tablepro_storage::{QueryHistory, StorageError}; + +use crate::i18n::gettext; +use crate::ui::export::{ExportFailure, FinishError, ReplaceFileWriter, failure_text}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum ExportKind { + Sql, + Csv, +} + +impl ExportKind { + pub(super) async fn render(self, history: QueryHistory, ids: Vec) -> Result { + match self { + Self::Sql => history.export_sql(&ids).await, + Self::Csv => history.export_csv(&ids).await, + } + } + + fn suggested_name(self) -> &'static str { + match self { + Self::Sql => "tablepro-history.sql", + Self::Csv => "tablepro-history.csv", + } + } + + fn filter(self) -> gtk::FileFilter { + let filter = gtk::FileFilter::new(); + match self { + Self::Sql => { + filter.set_name(Some(&gettext("SQL files"))); + filter.add_mime_type("text/x-sql"); + filter.add_suffix("sql"); + } + Self::Csv => { + filter.set_name(Some(&gettext("CSV files"))); + filter.add_mime_type("text/csv"); + filter.add_suffix("csv"); + } + } + filter + } +} + +/// Ask where to keep `content`, then write it there. +pub(super) fn save_export(parent: &adw::Dialog, kind: ExportKind, content: String) { + let filter = kind.filter(); + let filters = gio::ListStore::new::(); + filters.append(&filter); + let chooser = gtk::FileDialog::builder() + .title(gettext("Export Query History")) + .modal(true) + .initial_name(kind.suggested_name()) + .default_filter(&filter) + .filters(&filters) + .build(); + let window = parent.root().and_downcast::(); + let parent = parent.clone().upcast::(); + glib::spawn_future_local(async move { + // Cancelling the chooser is the only way it fails without a file. + let Ok(file) = chooser.save_future(window.as_ref()).await else { + return; + }; + write_history_export(&parent, &file, content).await; + }); +} + +/// Write the exported history to `file`, and say over `parent` when it +/// fails and what it left of the file. +pub(super) async fn write_history_export(parent: >k::Widget, file: &gio::File, content: String) { + let written = async { + let writer = ReplaceFileWriter::open(file) + .await + .map_err(|error| (ExportFailure::from(error), None))?; + if let Err(error) = writer.write(content.into_bytes()).await { + return Err((error.into(), Some(writer.abort().await))); + } + writer + .finish() + .await + .map_err(|FinishError { error, file }| (error.into(), Some(file))) + } + .await; + if let Err((failure, left)) = written { + tracing::warn!(%failure, path = %file.parse_name(), "history export write failed"); + let name = file.basename().map_or_else( + || file.parse_name().to_string(), + |name| name.to_string_lossy().into_owned(), + ); + show_export_failure(parent, &failure_text(&failure, left, &name)); + } +} + +/// Why the entries could not be read for an export. The storage error +/// is logged; its text is not translated. +pub(super) fn history_export_failure_text(error: &StorageError) -> String { + match error { + StorageError::Encode(_) => gettext("The selected entries couldn't be written as CSV."), + _ => gettext("The selected entries couldn't be read from the query history."), + } +} + +pub(super) fn show_export_failure(parent: >k::Widget, body: &str) { + let alert = adw::AlertDialog::new(Some(&gettext("Couldn't Export")), Some(body)); + alert.add_response("close", &gettext("_Close")); + alert.set_default_response(Some("close")); + alert.set_close_response("close"); + alert.present(Some(parent)); +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use super::*; + use crate::test_support::finish_on_main; + use crate::workspace::visible_alert; + + #[gtk4::test] + fn history_export_failure_presents_alert() { + adw::init().expect("libadwaita initialises under the test backend"); + let window = adw::Window::new(); + window.present(); + let dir = tempfile::tempdir().expect("tempdir"); + let file = gio::File::for_path(dir.path().join("missing").join("history.csv")); + let parent = window.clone().upcast::(); + + finish_on_main(Duration::from_secs(10), async move { + write_history_export(&parent, &file, "query\n".to_owned()).await; + }) + .expect("the write to end"); + + let alert = visible_alert(&window).expect("an alert"); + assert_eq!(alert.heading().as_deref(), Some("Couldn't Export")); + assert!( + alert.body().contains("history.csv"), + "the alert does not name the file: {}", + alert.body() + ); + assert!(!alert.body().contains("incomplete"), "{}", alert.body()); + } + + #[test] + fn history_export_failure_text_is_not_the_storage_error() { + let errors = [ + StorageError::NotInitialised, + StorageError::Encode(tablepro_core::export::RenderError::Flush(std::io::Error::other("full"))), + ]; + + for error in errors { + let text = history_export_failure_text(&error); + assert!(!text.contains(&error.to_string()), "{text}"); + assert!(text.starts_with("The selected entries couldn't"), "{text}"); + } + } +} diff --git a/linux/crates/app/src/ui/history_dialog/history_filter_state.rs b/linux/crates/app/src/ui/history_dialog/history_filter_state.rs new file mode 100644 index 0000000000..64b8171d64 --- /dev/null +++ b/linux/crates/app/src/ui/history_dialog/history_filter_state.rs @@ -0,0 +1,71 @@ +use std::time::{Duration, SystemTime}; + +use chrono::{DateTime, Local, NaiveTime, TimeDelta}; +use tablepro_storage::query_history::StatusFilter; +use uuid::Uuid; + +const DAY: Duration = Duration::from_secs(86_400); + +/// What the history filters are set to. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub(crate) struct HistoryFilterState { + pub connection: Option, + pub status: StatusFilter, + pub window: TimeWindow, +} + +/// How far back the history looks. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub(crate) enum TimeWindow { + #[default] + AnyTime, + Today, + Last7Days, + Last30Days, +} + +impl TimeWindow { + /// In the order the drop-down lists them. + pub(crate) const ALL: [Self; 4] = [Self::AnyTime, Self::Today, Self::Last7Days, Self::Last30Days]; + + /// The earliest run the window keeps. Today starts at the local + /// midnight, not 24 hours back. + pub(crate) fn executed_after(self, now: SystemTime) -> Option { + match self { + Self::AnyTime => None, + Self::Today => Some(start_of_local_day(now)), + Self::Last7Days => now.checked_sub(7 * DAY), + Self::Last30Days => now.checked_sub(30 * DAY), + } + } +} + +/// A zone that moves its clocks at midnight skips it on that day, which +/// then starts at the first hour that exists. +fn start_of_local_day(now: SystemTime) -> SystemTime { + let midnight = DateTime::::from(now).date_naive().and_time(NaiveTime::MIN); + (0..=24) + .filter_map(|hours| midnight.checked_add_signed(TimeDelta::hours(hours))) + .find_map(|moment| moment.and_local_timezone(Local).earliest()) + .map_or(now, SystemTime::from) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn time_window_executed_after() { + let now = SystemTime::UNIX_EPOCH + Duration::from_secs(1_800_000_000); + + assert_eq!(TimeWindow::AnyTime.executed_after(now), None); + assert_eq!(TimeWindow::Last7Days.executed_after(now), Some(now - 7 * DAY)); + assert_eq!(TimeWindow::Last30Days.executed_after(now), Some(now - 30 * DAY)); + + let today = TimeWindow::Today.executed_after(now).expect("a start"); + let local = DateTime::::from(today); + assert!(today <= now); + assert_eq!(local.date_naive(), DateTime::::from(now).date_naive()); + assert_eq!(local.time(), NaiveTime::MIN); + } +} diff --git a/linux/crates/app/src/ui/history_dialog/history_filters.rs b/linux/crates/app/src/ui/history_dialog/history_filters.rs new file mode 100644 index 0000000000..0bb3be85d0 --- /dev/null +++ b/linux/crates/app/src/ui/history_dialog/history_filters.rs @@ -0,0 +1,201 @@ +use std::rc::Rc; + +use gtk4 as gtk; +use gtk4::prelude::*; +use libadwaita as adw; +use tablepro_storage::query_history::StatusFilter; +use uuid::Uuid; + +use super::connection_filter::ConnectionFilterModel; +use super::history_filter_state::{HistoryFilterState, TimeWindow}; +use crate::i18n::gettext; + +const STATUSES: [(&str, StatusFilter); 4] = [ + ("any", StatusFilter::Any), + ("succeeded", StatusFilter::Succeeded), + ("failed", StatusFilter::Failed), + ("cancelled", StatusFilter::Cancelled), +]; + +/// The filters under the search bar, in a box that wraps them onto more +/// lines when the dialog is narrow. +pub(crate) struct HistoryFilters { + root: adw::WrapBox, + connection: gtk::DropDown, + status: adw::ToggleGroup, + window: gtk::DropDown, + connections: ConnectionFilterModel, +} + +impl HistoryFilters { + pub(crate) fn new(connections: &ConnectionFilterModel) -> Self { + let connection = gtk::DropDown::new(Some(connections.labels().clone()), None::); + // Search matches the label the list shows for each connection. + connection.set_expression(Some(gtk::PropertyExpression::new( + gtk::StringObject::static_type(), + None::, + "string", + ))); + connection.set_enable_search(true); + label(&connection, &gettext("Connection")); + + let status = adw::ToggleGroup::new(); + for (name, text) in STATUSES.iter().map(|(name, _)| *name).zip([ + gettext("All"), + gettext("Succeeded"), + gettext("Failed"), + gettext("Cancelled"), + ]) { + status.add(adw::Toggle::builder().name(name).label(text).build()); + } + status.set_active_name(Some("any")); + label(&status, &gettext("Status")); + + let window_labels = [ + gettext("Any Time"), + gettext("Today"), + gettext("Last 7 Days"), + gettext("Last 30 Days"), + ]; + let window_labels: Vec<&str> = window_labels.iter().map(String::as_str).collect(); + let window = gtk::DropDown::from_strings(&window_labels); + label(&window, &gettext("Time window")); + + let root = adw::WrapBox::builder() + .child_spacing(6) + .line_spacing(6) + .margin_top(6) + .margin_bottom(6) + .margin_start(12) + .margin_end(12) + .build(); + root.append(&connection); + root.append(&status); + root.append(&window); + Self { + root, + connection, + status, + window, + connections: connections.clone(), + } + } + + pub(crate) fn root(&self) -> &adw::WrapBox { + &self.root + } + + /// The connection drop-down's choices. + pub(crate) fn connections(&self) -> &ConnectionFilterModel { + &self.connections + } + + pub(crate) fn state(&self) -> HistoryFilterState { + read_state(&self.connection, &self.status, &self.window, &self.connections) + } + + /// `f` runs with the new state whenever any filter changes. + pub(crate) fn connect_changed(&self, f: impl Fn(HistoryFilterState) + 'static) { + let f = Rc::new(f); + // Weak, since each handler lives on one of the widgets it reads. + let (connection, status, window) = ( + self.connection.downgrade(), + self.status.downgrade(), + self.window.downgrade(), + ); + let connections = self.connections.clone(); + let changed = Rc::new(move || { + if let (Some(connection), Some(status), Some(window)) = + (connection.upgrade(), status.upgrade(), window.upgrade()) + { + f(read_state(&connection, &status, &window, &connections)); + } + }); + let notify = changed.clone(); + self.connection.connect_selected_notify(move |_| notify()); + let notify = changed.clone(); + self.status.connect_active_name_notify(move |_| notify()); + self.window.connect_selected_notify(move |_| changed()); + } + + /// Put the connection filter on `id`, or on All Connections when the + /// history has nothing for it. + pub(crate) fn select_connection(&self, id: Option) { + let position = id.and_then(|id| self.connections.position_of(id)).unwrap_or(0); + self.connection.set_selected(position); + } + + #[cfg(test)] + pub(crate) fn controls(&self) -> (gtk::DropDown, adw::ToggleGroup, gtk::DropDown) { + (self.connection.clone(), self.status.clone(), self.window.clone()) + } +} + +fn read_state( + connection: >k::DropDown, + status: &adw::ToggleGroup, + window: >k::DropDown, + connections: &ConnectionFilterModel, +) -> HistoryFilterState { + let active = status.active_name(); + HistoryFilterState { + connection: connections.id_at(connection.selected()), + status: STATUSES + .iter() + .find(|(name, _)| active.as_deref() == Some(*name)) + .map_or(StatusFilter::Any, |(_, status)| *status), + window: TimeWindow::ALL + .get(window.selected() as usize) + .copied() + .unwrap_or_default(), + } +} + +/// The controls have no visible label of their own, so a screen reader +/// is told what each one filters by. +fn label(widget: &impl IsA, text: &str) { + let widget = widget.upcast_ref::(); + widget.update_property(&[gtk::accessible::Property::Label(text)]); + widget.set_tooltip_text(Some(text)); +} + +#[cfg(test)] +mod tests { + use std::cell::RefCell; + + use super::*; + + #[gtk4::test] + fn toggle_emits_failed_status() { + let filters = HistoryFilters::new(&ConnectionFilterModel::new()); + let seen: Rc>> = Rc::default(); + let sink = seen.clone(); + filters.connect_changed(move |state| sink.borrow_mut().push(state)); + let (_, status, _) = filters.controls(); + + status.set_active_name(Some("failed")); + + assert_eq!( + seen.borrow().last().map(|state| state.status), + Some(StatusFilter::Failed) + ); + assert_eq!(filters.state().status, StatusFilter::Failed); + } + + #[gtk4::test] + fn the_time_window_drop_down_names_each_window() { + let filters = HistoryFilters::new(&ConnectionFilterModel::new()); + let (_, _, window) = filters.controls(); + + window.set_selected(1); + + assert_eq!(filters.state().window, TimeWindow::Today); + assert_eq!( + filters.state(), + HistoryFilterState { + window: TimeWindow::Today, + ..HistoryFilterState::default() + } + ); + } +} diff --git a/linux/crates/app/src/ui/history_dialog/history_formatting.rs b/linux/crates/app/src/ui/history_dialog/history_formatting.rs new file mode 100644 index 0000000000..c12e043878 --- /dev/null +++ b/linux/crates/app/src/ui/history_dialog/history_formatting.rs @@ -0,0 +1,166 @@ +use std::time::SystemTime; + +use tablepro_storage::query_history::Entry; + +use crate::i18n::{gettext, ngettext_f}; +use crate::ui::format::{format_thousands, plural_count}; + +const TITLE_CHARS: usize = 80; +const ERROR_CHARS: usize = 48; + +/// The first line that says something, cut to fit one row. +pub(super) fn preview_title(query: &str) -> String { + let first_line = query + .lines() + .map(str::trim) + .find(|line| !line.is_empty() && !line.starts_with("--")) + .unwrap_or(""); + if first_line.is_empty() { + return gettext("Empty query"); + } + truncated(first_line, TITLE_CHARS) +} + +pub(super) fn subtitle(entry: &Entry, now: SystemTime) -> String { + let mut parts = vec![ + entry.connection_name.clone(), + entry.driver_id.clone(), + relative_time(entry.executed_at, now), + ]; + if let Some(ms) = entry.duration_ms { + parts.push(format!("{} ms", format_thousands(u64::try_from(ms).unwrap_or(0)))); + } + if entry.cancelled { + parts.push(gettext("cancelled")); + } else if let Some(error) = &entry.error { + parts.push(truncated(error, ERROR_CHARS)); + } else if let Some(rows) = entry.rows_affected { + let rows = u64::try_from(rows).unwrap_or(0); + parts.push(ngettext_f( + "{n} row", + "{n} rows", + plural_count(rows), + &[("n", &format_thousands(rows))], + )); + } + parts.join(" · ") +} + +/// What the status icon stands for, for a screen reader. +pub(super) fn outcome_label(entry: &Entry) -> String { + if entry.cancelled { + gettext("Cancelled") + } else if entry.success { + gettext("Succeeded") + } else { + gettext("Failed") + } +} + +pub(super) fn outcome_icon(entry: &Entry) -> &'static str { + if entry.cancelled { + crate::ui::icons::PROCESS_STOP + } else if entry.success { + crate::ui::icons::SUCCESS + } else { + crate::ui::icons::DIALOG_ERROR + } +} + +fn relative_time(when: SystemTime, now: SystemTime) -> String { + let secs = now.duration_since(when).unwrap_or_default().as_secs(); + let count = |n: u64| (plural_count(n), n.to_string()); + if secs < 60 { + gettext("just now") + } else if secs < 3_600 { + let (n, text) = count(secs / 60); + ngettext_f("{n} minute ago", "{n} minutes ago", n, &[("n", &text)]) + } else if secs < 86_400 { + let (n, text) = count(secs / 3_600); + ngettext_f("{n} hour ago", "{n} hours ago", n, &[("n", &text)]) + } else if secs < 30 * 86_400 { + let (n, text) = count(secs / 86_400); + ngettext_f("{n} day ago", "{n} days ago", n, &[("n", &text)]) + } else { + chrono::DateTime::::from(when) + .format("%Y-%m-%d") + .to_string() + } +} + +fn truncated(text: &str, limit: usize) -> String { + let mut chars = text.chars(); + let kept: String = chars.by_ref().take(limit).collect(); + if chars.next().is_some() { + format!("{kept}…") + } else { + kept + } +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use uuid::Uuid; + + use super::*; + + fn entry(executed_at: SystemTime) -> Entry { + Entry { + id: 1, + query: "SELECT 1".to_owned(), + driver_id: "postgres".to_owned(), + connection_id: Uuid::nil(), + connection_name: "local".to_owned(), + executed_at, + duration_ms: Some(12), + rows_affected: Some(2), + success: true, + cancelled: false, + pinned: false, + error: None, + } + } + + #[test] + fn the_title_skips_blank_and_comment_lines() { + assert_eq!(preview_title("\n-- yesterday\n SELECT 1\nFROM t"), "SELECT 1"); + } + + #[test] + fn a_long_title_is_cut_on_a_character_boundary() { + let title = preview_title(&"é".repeat(100)); + + assert_eq!(title.chars().count(), TITLE_CHARS + 1); + assert!(title.ends_with('…')); + } + + #[test] + fn a_query_of_only_comments_is_called_empty() { + assert_eq!(preview_title("-- nothing\n\n"), "Empty query"); + } + + #[test] + fn the_subtitle_names_where_when_and_what_came_back() { + let now = SystemTime::UNIX_EPOCH + Duration::from_secs(1_800_000_000); + + let text = subtitle(&entry(now - Duration::from_secs(120)), now); + + assert_eq!(text, "local · postgres · 2 minutes ago · 12 ms · 2 rows"); + } + + #[test] + fn a_failed_run_shows_its_error_cut_short() { + let now = SystemTime::UNIX_EPOCH + Duration::from_secs(1_800_000_000); + let mut failed = entry(now); + failed.success = false; + failed.error = Some("x".repeat(60)); + + let text = subtitle(&failed, now); + + assert!(text.ends_with(&format!("{}…", "x".repeat(ERROR_CHARS))), "{text}"); + assert_eq!(outcome_icon(&failed), crate::ui::icons::DIALOG_ERROR); + assert_eq!(outcome_label(&failed), "Failed"); + } +} diff --git a/linux/crates/app/src/ui/history_dialog/history_paging.rs b/linux/crates/app/src/ui/history_dialog/history_paging.rs new file mode 100644 index 0000000000..223974b895 --- /dev/null +++ b/linux/crates/app/src/ui/history_dialog/history_paging.rs @@ -0,0 +1,181 @@ +use tablepro_storage::query_history::HistoryCursor; + +/// Which page reads the dialog is waiting on. A refresh starts a new +/// generation, so a page that was still on its way for the old search +/// is dropped when it lands, and only one next-page read runs at once. +#[derive(Debug, Default)] +pub(super) struct HistoryPaging { + generation: u64, + next: Option, + loading_more: bool, + loaded: bool, +} + +impl HistoryPaging { + pub(super) fn start_refresh(&mut self) -> u64 { + self.generation += 1; + self.loading_more = false; + // The cursor is the old search's: a next page read with it under + // the new search would skip rows or show some twice. + self.next = None; + self.generation + } + + /// Whether the first page belongs to the latest refresh. + pub(super) fn first_page_arrived(&mut self, generation: u64, next: Option) -> bool { + if generation != self.generation { + return false; + } + self.next = next; + self.loaded = true; + true + } + + /// Whether a first page ever arrived, so a failed refresh knows if + /// there are rows to keep. + pub(super) fn has_loaded(&self) -> bool { + self.loaded + } + + pub(super) fn is_current(&self, generation: u64) -> bool { + generation == self.generation + } + + pub(super) fn is_loading_more(&self) -> bool { + self.loading_more + } + + /// Whether an entry that sorts at `cursor` falls in the pages read + /// so far, rather than in one still to come. + pub(super) fn covers(&self, cursor: HistoryCursor) -> bool { + self.next.is_none_or(|next| cursor > next) + } + + /// The generation and cursor for the next page, unless one is on its + /// way or the last page is in. + pub(super) fn start_more(&mut self) -> Option<(u64, HistoryCursor)> { + if self.loading_more { + return None; + } + let next = self.next?; + self.loading_more = true; + Some((self.generation, next)) + } + + pub(super) fn more_arrived(&mut self, generation: u64, next: Option) -> bool { + if generation != self.generation { + return false; + } + self.loading_more = false; + self.next = next; + true + } + + /// The read failed; the next edge reached tries it again. + pub(super) fn more_failed(&mut self, generation: u64) { + if generation == self.generation { + self.loading_more = false; + } + } +} + +#[cfg(test)] +mod tests { + use std::time::SystemTime; + + use tablepro_storage::query_history::Entry; + use uuid::Uuid; + + use super::*; + + fn cursor(id: i64) -> HistoryCursor { + HistoryCursor::after(&Entry { + id, + query: String::new(), + driver_id: String::new(), + connection_id: Uuid::nil(), + connection_name: String::new(), + executed_at: SystemTime::UNIX_EPOCH, + duration_ms: None, + rows_affected: None, + success: true, + cancelled: false, + pinned: false, + error: None, + }) + } + + #[test] + fn one_next_page_read_at_a_time() { + let mut paging = HistoryPaging::default(); + let generation = paging.start_refresh(); + assert!(paging.first_page_arrived(generation, Some(cursor(10)))); + + assert_eq!(paging.start_more(), Some((generation, cursor(10)))); + assert_eq!( + paging.start_more(), + None, + "a second read started while one was on its way" + ); + + assert!(paging.more_arrived(generation, None)); + assert_eq!(paging.start_more(), None, "a read started past the last page"); + } + + #[test] + fn a_page_for_an_older_search_is_dropped() { + let mut paging = HistoryPaging::default(); + let old = paging.start_refresh(); + assert!(paging.first_page_arrived(old, Some(cursor(10)))); + let (more_for, _) = paging.start_more().expect("a next page"); + + let new = paging.start_refresh(); + + assert!(!paging.more_arrived(more_for, None)); + assert!(!paging.first_page_arrived(old, None)); + assert!(paging.first_page_arrived(new, Some(cursor(5)))); + assert_eq!( + paging.start_more(), + Some((new, cursor(5))), + "the old read kept the slot" + ); + } + + #[test] + fn a_refresh_drops_the_old_searchs_cursor() { + let mut paging = HistoryPaging::default(); + let old = paging.start_refresh(); + assert!(paging.first_page_arrived(old, Some(cursor(10)))); + + let new = paging.start_refresh(); + + assert_eq!(paging.start_more(), None, "a next page read with the old cursor"); + assert!(paging.first_page_arrived(new, Some(cursor(5)))); + assert_eq!(paging.start_more(), Some((new, cursor(5)))); + } + + #[test] + fn covers_what_sorts_above_the_next_page() { + let mut paging = HistoryPaging::default(); + let generation = paging.start_refresh(); + assert!(paging.first_page_arrived(generation, Some(cursor(10)))); + + assert!(paging.covers(cursor(11))); + assert!(!paging.covers(cursor(9))); + + assert!(paging.more_arrived(generation, None)); + assert!(paging.covers(cursor(9)), "the last page is in"); + } + + #[test] + fn a_failed_next_page_can_be_tried_again() { + let mut paging = HistoryPaging::default(); + let generation = paging.start_refresh(); + assert!(paging.first_page_arrived(generation, Some(cursor(10)))); + let _ = paging.start_more(); + + paging.more_failed(generation); + + assert_eq!(paging.start_more(), Some((generation, cursor(10)))); + } +} diff --git a/linux/crates/app/src/ui/history_dialog/history_row.rs b/linux/crates/app/src/ui/history_dialog/history_row.rs new file mode 100644 index 0000000000..dcc44b75fa --- /dev/null +++ b/linux/crates/app/src/ui/history_dialog/history_row.rs @@ -0,0 +1,126 @@ +use std::cell::RefCell; +use std::time::SystemTime; + +use gtk4 as gtk; +use gtk4::glib; +use gtk4::prelude::*; +use gtk4::subclass::prelude::*; + +use super::HistoryEntryObject; +use super::history_formatting::{outcome_icon, outcome_label, preview_title, subtitle}; +use crate::i18n::gettext; +use crate::ui::a11y::describe_icon_button; + +mod imp { + use super::*; + + #[derive(Default, gtk::CompositeTemplate)] + #[template(resource = "/app/tablepro/TablePro/ui/history-row.ui")] + pub struct HistoryRow { + #[template_child] + pub check: TemplateChild, + #[template_child] + pub status_icon: TemplateChild, + #[template_child] + pub title_label: TemplateChild, + #[template_child] + pub subtitle_label: TemplateChild, + #[template_child] + pub pin_button: TemplateChild, + #[template_child] + pub delete_button: TemplateChild, + pub entry: RefCell>, + } + + #[glib::object_subclass] + impl ObjectSubclass for HistoryRow { + const NAME: &'static str = "TableProHistoryRow"; + type Type = super::HistoryRow; + type ParentType = gtk::Box; + + fn class_init(klass: &mut Self::Class) { + klass.bind_template(); + } + + fn instance_init(object: &glib::subclass::InitializingObject) { + object.init_template(); + } + } + + impl ObjectImpl for HistoryRow { + fn constructed(&self) { + self.parent_constructed(); + self.obj().show_pinned(false); + } + } + impl WidgetImpl for HistoryRow {} + impl BoxImpl for HistoryRow {} +} + +glib::wrapper! { + /// A row of the history list. The list view builds a few and fills + /// them again as the user scrolls, so nothing here belongs to one + /// entry for longer than a bind. + pub struct HistoryRow(ObjectSubclass) + @extends gtk::Box, gtk::Widget, + @implements gtk::Accessible, gtk::Buildable, gtk::ConstraintTarget, gtk::Orientable; +} + +impl Default for HistoryRow { + fn default() -> Self { + glib::Object::new() + } +} + +impl HistoryRow { + pub(super) fn bind(&self, object: &HistoryEntryObject, now: SystemTime) { + let imp = self.imp(); + imp.entry.replace(Some(object.clone())); + let Some(entry) = object.entry() else { + return; + }; + imp.title_label.set_label(&preview_title(&entry.query)); + imp.subtitle_label.set_label(&subtitle(entry, now)); + imp.status_icon.set_icon_name(Some(outcome_icon(entry))); + imp.status_icon + .update_property(&[gtk::accessible::Property::Label(&outcome_label(entry))]); + + self.show_pinned(entry.pinned); + let id = entry.id.to_variant(); + // The target goes first: an action that takes an id, named on a + // button without one, warns that it cannot be activated. + imp.pin_button.set_action_target_value(Some(&id)); + imp.pin_button.set_action_name(Some("history.pin")); + imp.delete_button.set_action_target_value(Some(&id)); + imp.delete_button.set_action_name(Some("history.delete")); + } + + fn show_pinned(&self, pinned: bool) { + let button = self.imp().pin_button.get(); + let label = if pinned { gettext("Unpin") } else { gettext("Pin") }; + describe_icon_button(&button, &label, None); + if pinned { + button.add_css_class("accent"); + } else { + button.remove_css_class("accent"); + } + } + + pub(super) fn unbind(&self) { + self.imp().entry.replace(None); + } + + /// The entry the row shows now, if it is bound. + pub(super) fn entry(&self) -> Option { + self.imp().entry.borrow().clone() + } + + pub(super) fn check(&self) -> gtk::CheckButton { + self.imp().check.get() + } + + #[cfg(test)] + pub(super) fn title(&self) -> String { + self.imp().title_label.label().to_string() + } +} diff --git a/linux/crates/app/src/ui/history_dialog/imp.rs b/linux/crates/app/src/ui/history_dialog/imp.rs new file mode 100644 index 0000000000..1c57a73937 --- /dev/null +++ b/linux/crates/app/src/ui/history_dialog/imp.rs @@ -0,0 +1,102 @@ +use std::cell::{Cell, OnceCell, RefCell}; +use std::sync::OnceLock; + +use glib::subclass::Signal; +use gtk4 as gtk; +use gtk4::glib; +use gtk4::prelude::*; +use gtk4::subclass::prelude::*; +use libadwaita as adw; +use libadwaita::subclass::prelude::*; +use tablepro_storage::query_history::HistoryQuery; + +use super::DialogParts; +use super::history_paging::HistoryPaging; + +#[derive(Default, gtk::CompositeTemplate, glib::Properties)] +#[template(resource = "/app/tablepro/TablePro/ui/history-dialog.ui")] +#[properties(wrapper_type = super::HistoryDialog)] +pub struct HistoryDialog { + #[template_child] + pub toast_overlay: TemplateChild, + #[template_child] + pub toolbar_view: TemplateChild, + #[template_child] + pub search_bar: TemplateChild, + #[template_child] + pub search_entry: TemplateChild, + #[template_child] + pub stack: TemplateChild, + #[template_child] + pub scroll: TemplateChild, + #[template_child] + pub list_view: TemplateChild, + #[template_child] + pub status_page: TemplateChild, + #[template_child] + pub selection_bar: TemplateChild, + #[template_child] + pub selection_label: TemplateChild, + + /// Select mode: rows show a check and the list takes a multiple + /// selection instead of opening what is clicked. + #[property(get, set = Self::set_selecting)] + pub selecting: Cell, + + pub(super) parts: OnceCell, + /// The search the rows on screen answer. + pub(super) query: RefCell, + pub(super) paging: RefCell, +} + +impl HistoryDialog { + fn set_selecting(&self, selecting: bool) { + if self.selecting.replace(selecting) != selecting { + self.obj().selection_mode_changed(); + } + } +} + +#[glib::object_subclass] +impl ObjectSubclass for HistoryDialog { + const NAME: &'static str = "TableProHistoryDialog"; + type Type = super::HistoryDialog; + type ParentType = adw::Dialog; + + fn class_init(klass: &mut Self::Class) { + klass.bind_template(); + super::history_actions::install(klass); + } + + fn instance_init(object: &glib::subclass::InitializingObject) { + object.init_template(); + } +} + +#[glib::derived_properties] +impl ObjectImpl for HistoryDialog { + fn signals() -> &'static [Signal] { + static SIGNALS: OnceLock> = OnceLock::new(); + SIGNALS.get_or_init(|| { + vec![ + Signal::builder("open-in-new-tab") + .param_types([String::static_type()]) + .build(), + Signal::builder("replace-current-tab") + .param_types([String::static_type()]) + .build(), + ] + }) + } + + /// The row menu is parented to the list view, which would otherwise + /// be finalized with a child it does not know about. + fn dispose(&self) { + if let Some(parts) = self.parts.get() { + parts.menu.unparent(); + } + } +} + +impl WidgetImpl for HistoryDialog {} +impl AdwDialogImpl for HistoryDialog {} diff --git a/linux/crates/app/src/ui/history_dialog/mod.rs b/linux/crates/app/src/ui/history_dialog/mod.rs new file mode 100644 index 0000000000..d6cce01f49 --- /dev/null +++ b/linux/crates/app/src/ui/history_dialog/mod.rs @@ -0,0 +1,1110 @@ +//! The query history: a searchable list of what ran, read a page at a +//! time off the GTK thread. + +mod connection_filter; +mod history_actions; +mod history_entry_object; +mod history_entry_object_imp; +mod history_export; +mod history_filter_state; +mod history_filters; +mod history_formatting; +mod history_paging; +mod history_row; +mod imp; +#[cfg(test)] +mod tests; + +use std::collections::HashSet; +use std::rc::Rc; +use std::sync::Arc; +use std::time::SystemTime; + +use gtk4 as gtk; +use gtk4::prelude::*; +use gtk4::subclass::prelude::*; +use gtk4::{gdk, gio, glib, graphene}; +use libadwaita as adw; +use libadwaita::prelude::*; +use tablepro_session::runtime::{TaskFailure, Tasks}; +use tablepro_storage::query_history::{ + Entry, HistoryConnection, HistoryCursor, HistoryPage, HistoryPageRequest, HistoryQuery, StatusFilter, +}; + +use tablepro_storage::{ConnectionStore, QueryHistory, SavedConnection, StorageError}; +use uuid::Uuid; + +use crate::application::{ShortcutOwner, local_shortcut_controller}; +use crate::i18n::{gettext, ngettext_f}; +use crate::runtime::spawn_and_deliver; +use crate::services::history_service::HistoryService; +use crate::ui::file_reveal::{FileRevealer, reveal_failure_toast}; +use crate::ui::format::plural_count; +use history_export::{ExportKind, history_export_failure_text, save_export, show_export_failure}; + +use connection_filter::ConnectionFilterModel; +pub(crate) use history_entry_object::HistoryEntryObject; +use history_filters::HistoryFilters; +pub(super) use history_row::HistoryRow; + +/// Several screens of rows, so the edge is reached rarely, and few +/// enough that the first page is quick on a large history. +const PAGE_SIZE: usize = 100; + +glib::wrapper! { + pub struct HistoryDialog(ObjectSubclass) + @extends adw::Dialog, gtk::Widget, + @implements gtk::Accessible, gtk::Buildable, gtk::ConstraintTarget; +} + +pub(crate) struct HistoryDialogInit { + pub history: HistoryService, + /// Where the filter reads the connections' current names. + pub connections: ConnectionStore, + /// The presenting window's connection, which the filter starts on. + pub preferred_connection: Option, + pub revealer: Rc, + pub tasks: Tasks, +} + +/// Where a query the user picked goes. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum QueryDestination { + NewTab, + CurrentTab, +} + +impl QueryDestination { + fn signal(self) -> &'static str { + match self { + Self::NewTab => "open-in-new-tab", + Self::CurrentTab => "replace-current-tab", + } + } +} + +/// What the dialog is built with and keeps for its life. +pub(super) struct DialogParts { + history: Option, + connections: ConnectionStore, + preferred_connection: Option, + filters: HistoryFilters, + revealer: Rc, + tasks: Tasks, + entries: gio::ListStore, + plain: gtk::NoSelection, + multiple: gtk::MultiSelection, + menu: gtk::PopoverMenu, +} + +impl HistoryDialog { + pub(crate) fn new(init: HistoryDialogInit) -> Self { + let dialog: Self = glib::Object::new(); + let imp = dialog.imp(); + let entries = gio::ListStore::new::(); + let sorted = gtk::SortListModel::new(Some(entries.clone()), None::); + sorted.set_section_sorter(Some(&pinned_first())); + let menu = gtk::PopoverMenu::from_model(None::<&gio::MenuModel>); + menu.set_has_arrow(true); + // Parented now: a popover menu finds its actions through the + // parent it had when it was set up. + menu.set_parent(&imp.list_view.get()); + let filters = HistoryFilters::new(&ConnectionFilterModel::new()); + // After the search bar, so the filters sit under it. + imp.toolbar_view.add_top_bar(filters.root()); + let _ = imp.parts.set(DialogParts { + history: init.history.store(), + connections: init.connections, + preferred_connection: init.preferred_connection, + filters, + revealer: init.revealer, + tasks: init.tasks, + entries, + plain: gtk::NoSelection::new(Some(sorted.clone())), + multiple: gtk::MultiSelection::new(Some(sorted)), + menu, + }); + dialog.setup_list(); + dialog.load_connections(); + dialog + } + + pub(crate) fn connect_query_chosen(&self, f: F) { + let f = Rc::new(f); + for destination in [QueryDestination::NewTab, QueryDestination::CurrentTab] { + let f = f.clone(); + self.connect_closure( + destination.signal(), + false, + glib::closure_local!(move |dialog: &Self, query: String| f(dialog, destination, query)), + ); + } + } + + fn setup_list(&self) { + let imp = self.imp(); + let Some(parts) = imp.parts.get() else { + return; + }; + let list_view = imp.list_view.get(); + list_view.set_model(Some(&parts.plain)); + list_view.set_factory(Some(&self.row_factory())); + list_view.set_header_factory(Some(§ion_header_factory())); + // The Menu key reaches the list view, which has the focused row. + list_view.add_controller(local_shortcut_controller(ShortcutOwner::HistoryRow)); + let dialog = self.downgrade(); + list_view.connect_activate(move |_, position| { + if let Some(dialog) = dialog.upgrade() { + dialog.open_at(position); + } + }); + let dialog = self.downgrade(); + parts.multiple.connect_selection_changed(move |_, _, _| { + if let Some(dialog) = dialog.upgrade() { + dialog.selection_changed(); + } + }); + let dialog = self.downgrade(); + imp.scroll.connect_edge_reached(move |_, edge| { + if edge == gtk::PositionType::Bottom + && let Some(dialog) = dialog.upgrade() + { + dialog.load_more(); + } + }); + imp.search_bar.set_key_capture_widget(Some(&imp.stack.get())); + } + + fn connect_filters(&self) { + let imp = self.imp(); + let dialog = self.downgrade(); + imp.search_entry.connect_search_changed(move |_| { + if let Some(dialog) = dialog.upgrade() { + dialog.refresh(); + } + }); + let Some(parts) = imp.parts.get() else { + return; + }; + let dialog = self.downgrade(); + parts.filters.connect_changed(move |_| { + if let Some(dialog) = dialog.upgrade() { + dialog.refresh(); + } + }); + } + + fn row_factory(&self) -> gtk::SignalListItemFactory { + let factory = gtk::SignalListItemFactory::new(); + let dialog = self.downgrade(); + factory.connect_setup(move |_, object| { + let (Some(item), Some(dialog)) = (object.downcast_ref::(), dialog.upgrade()) else { + return; + }; + let row = HistoryRow::default(); + dialog + .bind_property("selecting", &row.check(), "visible") + .sync_create() + .build(); + dialog.follow_selection(item, &row); + dialog.toggle_on_click(item, &row); + dialog.attach_row_menu(&row); + item.set_child(Some(&row)); + }); + factory.connect_bind(|_, object| { + let Some(item) = object.downcast_ref::() else { + return; + }; + let (Some(row), Some(entry)) = ( + item.child().and_downcast::(), + item.item().and_downcast::(), + ) else { + return; + }; + row.bind(&entry, SystemTime::now()); + row.check().set_active(item.is_selected()); + }); + factory.connect_unbind(|_, object| { + if let Some(row) = object + .downcast_ref::() + .and_then(|item| item.child().and_downcast::()) + { + row.unbind(); + } + }); + factory + } + + /// The row's check and the list's selection say the same thing, + /// whichever of them the user changes. + fn follow_selection(&self, item: >k::ListItem, row: &HistoryRow) { + let check = row.check(); + let shown = check.downgrade(); + item.connect_selected_notify(move |item| { + if let Some(check) = shown.upgrade() { + check.set_active(item.is_selected()); + } + }); + let (dialog, item) = (self.downgrade(), item.downgrade()); + check.connect_toggled(move |check| { + let (Some(dialog), Some(item)) = (dialog.upgrade(), item.upgrade()) else { + return; + }; + if item.is_selected() == check.is_active() { + return; + } + let Some(parts) = dialog.imp().parts.get() else { + return; + }; + let position = item.position(); + if check.is_active() { + parts.multiple.select_item(position, false); + } else { + parts.multiple.unselect_item(position); + } + }); + } + + /// In select mode a click on a row toggles it, as its check does. + /// The list view's own click would select that row alone, and drop + /// the others the user ticked. + fn toggle_on_click(&self, item: >k::ListItem, row: &HistoryRow) { + let gesture = gtk::GestureClick::new(); + gesture.set_button(gdk::BUTTON_PRIMARY); + let (dialog, item) = (self.downgrade(), item.downgrade()); + gesture.connect_released(move |gesture, _, _, _| { + let (Some(dialog), Some(item), Some(row)) = (dialog.upgrade(), item.upgrade(), gesture.widget()) else { + return; + }; + // Shift and Ctrl clicks keep the list view's range and toggle. + let modified = gesture + .current_event_state() + .intersects(gdk::ModifierType::SHIFT_MASK | gdk::ModifierType::CONTROL_MASK); + if !dialog.selecting() || modified { + return; + } + gesture.set_state(gtk::EventSequenceState::Claimed); + let toggle = (item.position(), true, false).to_variant(); + if let Err(error) = row.activate_action("list.select-item", Some(&toggle)) { + tracing::warn!(%error, "could not toggle a history row"); + } + }); + row.add_controller(gesture); + } + + fn attach_row_menu(&self, row: &HistoryRow) { + let gesture = gtk::GestureClick::new(); + gesture.set_button(gdk::BUTTON_SECONDARY); + let dialog = self.downgrade(); + gesture.connect_pressed(move |gesture, _, x, y| { + let (Some(dialog), Some(row)) = (dialog.upgrade(), gesture.widget().and_downcast::()) else { + return; + }; + gesture.set_state(gtk::EventSequenceState::Claimed); + dialog.popup_row_menu(&row, Some(graphene::Point::new(x as f32, y as f32))); + }); + row.add_controller(gesture); + } + + /// Open the menu for `row`'s entry, at `at` in the row, or over the + /// whole row for the Menu key. + fn popup_row_menu(&self, row: &HistoryRow, at: Option) { + let imp = self.imp(); + let (Some(parts), Some(entry)) = (imp.parts.get(), row.entry()) else { + return; + }; + let list_view = imp.list_view.get(); + // The menu is parented to the list view, so it points in the + // view's coordinates, not the row's. + let pointing = match at { + Some(point) => row + .compute_point(&list_view, &point) + .map(|point| gdk::Rectangle::new(point.x() as i32, point.y() as i32, 1, 1)), + None => row.compute_bounds(&list_view).map(|bounds| { + gdk::Rectangle::new( + bounds.x() as i32, + bounds.y() as i32, + bounds.width() as i32, + bounds.height() as i32, + ) + }), + }; + parts.menu.set_menu_model(Some(&row_menu(&entry))); + parts.menu.set_pointing_to(pointing.as_ref()); + parts.menu.popup(); + } + + fn popup_focused_row_menu(&self) { + let Some(item) = self.imp().list_view.focus_child() else { + return; + }; + let mut child = item.first_child(); + while let Some(widget) = child { + if let Some(row) = widget.downcast_ref::() { + self.popup_row_menu(row, None); + return; + } + child = widget.next_sibling(); + } + } + + fn selection_mode_changed(&self) { + let imp = self.imp(); + let Some(parts) = imp.parts.get() else { + return; + }; + let selecting = self.selecting(); + if selecting { + imp.list_view.set_model(Some(&parts.multiple)); + } else { + parts.multiple.unselect_all(); + imp.list_view.set_model(Some(&parts.plain)); + } + // A click picks a row in select mode rather than opening it. + imp.list_view.set_single_click_activate(!selecting); + self.selection_changed(); + } + + fn selection_changed(&self) { + let imp = self.imp(); + let count = if self.selecting() { self.selected_ids().len() } else { 0 }; + if count == 0 { + imp.selection_bar.set_reveal_child(false); + return; + } + let n = count as u64; + imp.selection_label.set_label(&ngettext_f( + "{n} selected", + "{n} selected", + plural_count(n), + &[("n", &n.to_string())], + )); + imp.selection_bar.set_reveal_child(true); + } + + fn selected_ids(&self) -> Vec { + let Some(parts) = self.imp().parts.get() else { + return Vec::new(); + }; + let selection = &parts.multiple; + (0..selection.n_items()) + .filter(|position| selection.is_selected(*position)) + .filter_map(|position| selection.item(position).and_downcast::()) + .filter_map(|entry| entry.id()) + .collect() + } + + fn reselect(&self, ids: &HashSet) { + let Some(parts) = self.imp().parts.get() else { + return; + }; + if ids.is_empty() { + return; + } + let selection = &parts.multiple; + for position in 0..selection.n_items() { + let keep = selection + .item(position) + .and_downcast::() + .and_then(|entry| entry.id()) + .is_some_and(|id| ids.contains(&id)); + if keep { + selection.select_item(position, false); + } + } + } + + fn entry_by_id(&self, id: i64) -> Option { + let parts = self.imp().parts.get()?; + parts + .entries + .iter::() + .filter_map(Result::ok) + .find(|entry| entry.id() == Some(id)) + } + + fn open_at(&self, position: u32) { + if self.selecting() { + return; + } + let entry = self + .imp() + .list_view + .model() + .and_then(|model| model.item(position)) + .and_downcast::(); + if let Some(id) = entry.and_then(|entry| entry.id()) { + self.send_query(id, QueryDestination::NewTab); + } + } + + fn send_query(&self, id: i64, destination: QueryDestination) { + let Some(query) = self.entry_by_id(id).and_then(|entry| entry.query()) else { + return; + }; + self.emit_by_name::<()>(destination.signal(), &[&query]); + self.close(); + } + + fn copy_query(&self, id: i64) { + if let Some(query) = self.entry_by_id(id).and_then(|entry| entry.query()) { + crate::ui::clipboard::copy_text(self, query, |message| self.add_toast(message)); + } + } + + fn storage(&self) -> Option<(QueryHistory, Tasks)> { + let parts = self.imp().parts.get()?; + Some((parts.history.clone()?, parts.tasks.clone())) + } + + /// Run `work` against the history off the GTK thread, then read the + /// list and the connections it holds again once it has landed. + /// `failed` is what a toast says when the change does not land. + fn change_then_refresh( + &self, + what: &'static str, + failed: String, + work: impl FnOnce(QueryHistory) -> W + Send + 'static, + ) where + W: Future> + Send + 'static, + { + let Some((history, tasks)) = self.storage() else { + return; + }; + let dialog = self.downgrade(); + drop(spawn_and_deliver( + &tasks, + async move { work(history).await }, + move |done| { + let Some(dialog) = dialog.upgrade() else { + return; + }; + match done { + Ok(Ok(())) => {} + Ok(Err(error)) => { + tracing::warn!(%error, what, "a query history change failed"); + dialog.add_toast(&failed); + } + Err(failure) => { + tracing::warn!(%failure, what, "a query history task failed"); + dialog.add_toast(&failed); + } + } + dialog.refresh(); + dialog.reload_connections(); + }, + )); + } + + /// The row moves to where the history lists it now, and the pages + /// read so far stay, with the scroll position and the selection. + fn toggle_pin(&self, id: i64) { + let Some(entry) = self.entry_by_id(id) else { + return; + }; + let Some((history, tasks)) = self.storage() else { + return; + }; + let pinned = !entry.is_pinned(); + let dialog = self.downgrade(); + drop(spawn_and_deliver( + &tasks, + async move { history.set_pinned(id, pinned).await }, + move |done| { + let Some(dialog) = dialog.upgrade() else { + return; + }; + let failure = match done { + Ok(Ok(())) => { + dialog.pin_changed(id, pinned); + return; + } + Ok(Err(error)) => error.to_string(), + Err(failure) => failure.to_string(), + }; + tracing::warn!(%failure, pinned, "could not change a query history pin"); + dialog.add_toast(&if pinned { + gettext("The query could not be pinned.") + } else { + gettext("The query could not be unpinned.") + }); + }, + )); + } + + fn pin_changed(&self, id: i64, pinned: bool) { + let imp = self.imp(); + let Some(parts) = imp.parts.get() else { + return; + }; + let Some(position) = parts + .entries + .iter::() + .position(|object| object.is_ok_and(|object| object.id() == Some(id))) + else { + return; + }; + let Some(mut entry) = parts + .entries + .item(position as u32) + .and_downcast::() + .and_then(|object| object.entry().cloned()) + else { + return; + }; + // A refresh that read the row after the change already shows it. + if entry.pinned == pinned { + return; + } + entry.pinned = pinned; + let selected = self.selected_ids().contains(&id); + parts.entries.remove(position as u32); + let key = HistoryCursor::after(&entry); + // One that now sorts past the pages read comes back with its page. + if imp.paging.borrow().covers(key) { + let at = parts + .entries + .iter::() + .filter_map(Result::ok) + .position(|other| other.entry().is_some_and(|other| HistoryCursor::after(other) < key)) + .unwrap_or(parts.entries.n_items() as usize); + parts.entries.insert(at as u32, &HistoryEntryObject::new(entry)); + if selected { + self.reselect(&HashSet::from([id])); + } + } + self.selection_changed(); + self.show_results(); + } + + /// Delete at once and offer Undo, rather than ask first: the entries + /// come back as they were. + fn delete_entries(&self, ids: Vec) { + if ids.is_empty() { + return; + } + let Some((history, tasks)) = self.storage() else { + return; + }; + let dialog = self.downgrade(); + drop(spawn_and_deliver( + &tasks, + async move { + let deleted = history.delete_returning(&ids).await; + (ids, deleted) + }, + move |done| { + let Some(dialog) = dialog.upgrade() else { + return; + }; + match done { + Ok((ids, Ok(entries))) => dialog.entries_deleted(&ids, entries), + Ok((_, Err(error))) => { + tracing::warn!(%error, "could not delete from the query history"); + dialog.add_toast(&gettext("The queries could not be deleted.")); + } + Err(failure) => tracing::warn!(%failure, "the query history delete failed"), + } + }, + )); + } + + /// Every row asked for goes, including one the history no longer + /// had; only what was deleted here can be undone. + fn entries_deleted(&self, requested: &[i64], entries: Vec) { + let imp = self.imp(); + let Some(parts) = imp.parts.get() else { + return; + }; + let gone: HashSet = requested.iter().copied().collect(); + parts.entries.retain(|object| { + object + .downcast_ref::() + .and_then(HistoryEntryObject::id) + .is_none_or(|id| !gone.contains(&id)) + }); + // The next page loads when the list is scrolled to its end, which + // rows too few to fill it never are. + if parts.entries.n_items() < PAGE_SIZE as u32 { + self.load_more(); + } + self.selection_changed(); + if !imp.paging.borrow().is_loading_more() { + self.show_results(); + } + self.reload_connections(); + if entries.is_empty() { + return; + } + + let count = entries.len() as u64; + let toast = crate::ui::plain_toast(&ngettext_f( + "{count} query deleted", + "{count} queries deleted", + plural_count(count), + &[("count", &count.to_string())], + )); + toast.set_button_label(Some(&gettext("_Undo"))); + let dialog = self.downgrade(); + toast.connect_button_clicked(move |_| { + if let Some(dialog) = dialog.upgrade() { + let entries = entries.clone(); + dialog.change_then_refresh( + "restore", + gettext("The queries could not be restored."), + move |history| async move { history.restore(&entries).await }, + ); + } + }); + imp.toast_overlay.add_toast(toast); + } + + fn delete_selected(&self) { + self.delete_entries(self.selected_ids()); + } + + fn confirm_clear_all(&self) { + let alert = adw::AlertDialog::new( + Some(&gettext("Clear All Query History?")), + Some(&gettext( + "This permanently deletes every saved query, including pinned ones.", + )), + ); + alert.add_response("cancel", &gettext("Cancel")); + alert.add_response("clear", &gettext("Clear")); + alert.set_response_appearance("clear", adw::ResponseAppearance::Destructive); + alert.set_default_response(Some("cancel")); + alert.set_close_response("cancel"); + let dialog = self.downgrade(); + alert.connect_response(None, move |_, response| { + if response == "clear" + && let Some(dialog) = dialog.upgrade() + { + dialog.change_then_refresh( + "clear", + gettext("The query history could not be cleared."), + |history| async move { history.clear_all().await.map(drop) }, + ); + } + }); + alert.present(Some(self)); + } + + /// Show the database file selected in its folder; a failure says so + /// in the dialog. + fn show_in_files(&self) { + let Some(parts) = self.imp().parts.get() else { + return; + }; + let Some(history) = parts.history.as_ref() else { + return; + }; + let revealing = parts + .revealer + .reveal(gio::File::for_path(history.path()), self.root().and_downcast()); + let dialog = self.downgrade(); + glib::spawn_future_local(async move { + let revealed = revealing.await; + if let (Some(toast), Some(dialog)) = (reveal_failure_toast(&revealed), dialog.upgrade()) { + dialog.imp().toast_overlay.add_toast(toast); + } + }); + } + + fn export_selected(&self, kind: ExportKind) { + let ids = self.selected_ids(); + if ids.is_empty() { + return; + } + let Some((history, tasks)) = self.storage() else { + return; + }; + let dialog = self.downgrade(); + drop(spawn_and_deliver(&tasks, kind.render(history, ids), move |rendered| { + let Some(dialog) = dialog.upgrade() else { + return; + }; + match rendered { + Ok(Ok(content)) => save_export(dialog.upcast_ref(), kind, content), + Ok(Err(error)) => { + tracing::warn!(%error, "history export failed"); + show_export_failure(dialog.upcast_ref(), &history_export_failure_text(&error)); + } + Err(failure) => tracing::warn!(%failure, "the history export task failed"), + } + })); + } + + /// List every connection the history holds entries for, so one that + /// is not open can still be picked, then read the first page with + /// the filter on the presenting window's connection. + fn load_connections(&self) { + let Some(parts) = self.imp().parts.get() else { + return; + }; + if parts.history.is_none() { + self.connect_filters(); + self.show_unavailable(); + return; + } + let preferred = parts.preferred_connection; + self.read_connections(move |dialog, connections| { + if let Some((listed, saved)) = connections { + dialog.fill_connections(&listed, &saved, preferred); + } + dialog.connect_filters(); + dialog.refresh(); + }); + } + + /// After a change that can add a connection's first entry or drop + /// its last. The filter stays on its connection while that one + /// still has history. + fn reload_connections(&self) { + self.read_connections(|dialog, connections| { + let (Some((listed, saved)), Some(parts)) = (connections, dialog.imp().parts.get()) else { + return; + }; + let current = parts.filters.state().connection; + dialog.fill_connections(&listed, &saved, current); + }); + } + + /// `then` gets the connections with history and the saved ones, or + /// `None` when the history could not list them. + fn read_connections( + &self, + then: impl FnOnce(&Self, Option<(Vec, Arc<[SavedConnection]>)>) + 'static, + ) { + let Some(parts) = self.imp().parts.get() else { + return; + }; + let Some(history) = parts.history.clone() else { + return; + }; + let (store, tasks) = (parts.connections.clone(), parts.tasks.clone()); + let blocking = tasks.clone(); + let dialog = self.downgrade(); + drop(spawn_and_deliver( + &tasks, + async move { + let listed = history.connections_with_history().await; + let saved = blocking.spawn_blocking_task(move || store.load_blocking()).await; + (listed, saved) + }, + move |loaded| { + let Some(dialog) = dialog.upgrade() else { + return; + }; + let connections = match loaded { + Ok((listed, saved)) => connections_read(listed, saved), + Err(failure) => { + tracing::warn!(%failure, "the history connections task failed"); + None + } + }; + then(&dialog, connections); + }, + )); + } + + fn fill_connections(&self, listed: &[HistoryConnection], saved: &[SavedConnection], select: Option) { + let Some(parts) = self.imp().parts.get() else { + return; + }; + parts.filters.connections().fill(listed, saved); + parts.filters.select_connection(select); + } + + fn query_from_filters(&self) -> HistoryQuery { + let imp = self.imp(); + let mut query = HistoryQuery::from_input(&imp.search_entry.text()); + if let Some(parts) = imp.parts.get() { + let state = parts.filters.state(); + query.connection = state.connection; + query.status = state.status; + query.executed_after = state.window.executed_after(SystemTime::now()); + } + query + } + + /// Read the first page for the current search. The rows on screen + /// stay until it arrives, so typing never blanks the list. + fn refresh(&self) { + let imp = self.imp(); + let query = self.query_from_filters(); + imp.query.replace(query.clone()); + let generation = imp.paging.borrow_mut().start_refresh(); + let Some((history, tasks)) = self.storage() else { + self.show_unavailable(); + return; + }; + let dialog = self.downgrade(); + drop(spawn_and_deliver( + &tasks, + async move { history.search_page(&query, HistoryPageRequest::first(PAGE_SIZE)).await }, + move |page| { + if let Some(dialog) = dialog.upgrade() { + dialog.first_page_arrived(generation, page); + } + }, + )); + } + + fn first_page_arrived(&self, generation: u64, page: Result, TaskFailure>) { + let imp = self.imp(); + let Some(parts) = imp.parts.get() else { + return; + }; + let page = match page { + Ok(Ok(page)) => page, + Ok(Err(error)) => { + tracing::warn!(%error, "could not read the query history"); + self.first_page_failed(generation); + return; + } + Err(failure) => { + tracing::warn!(%failure, "the query history read failed"); + self.first_page_failed(generation); + return; + } + }; + if !imp.paging.borrow_mut().first_page_arrived(generation, page.next) { + return; + } + let selected: HashSet = self.selected_ids().into_iter().collect(); + let objects: Vec = page.entries.into_iter().map(HistoryEntryObject::new).collect(); + parts.entries.splice(0, parts.entries.n_items(), &objects); + self.reselect(&selected); + // Rows that went with the splice leave the selection without a + // selection-changed, so the count is read again here. + self.selection_changed(); + self.show_results(); + } + + /// A refresh that fails keeps the rows the last one showed. + fn first_page_failed(&self, generation: u64) { + let imp = self.imp(); + let paging = imp.paging.borrow(); + if !paging.is_current(generation) { + return; + } + if paging.has_loaded() { + drop(paging); + self.add_toast(&gettext("The query history could not be read.")); + } else { + drop(paging); + self.show_unavailable(); + } + } + + fn load_more(&self) { + let imp = self.imp(); + let Some((history, tasks)) = self.storage() else { + return; + }; + let Some((generation, after)) = imp.paging.borrow_mut().start_more() else { + return; + }; + let query = imp.query.borrow().clone(); + let dialog = self.downgrade(); + let request = HistoryPageRequest { + after: Some(after), + limit: PAGE_SIZE, + }; + drop(spawn_and_deliver( + &tasks, + async move { history.search_page(&query, request).await }, + move |page| { + if let Some(dialog) = dialog.upgrade() { + dialog.more_arrived(generation, page); + } + }, + )); + } + + fn more_arrived(&self, generation: u64, page: Result, TaskFailure>) { + let imp = self.imp(); + let Some(parts) = imp.parts.get() else { + return; + }; + let page = match page { + Ok(Ok(page)) => page, + Ok(Err(error)) => { + tracing::warn!(%error, "could not read the next page of the query history"); + self.more_failed(generation); + return; + } + Err(failure) => { + tracing::warn!(%failure, "the next query history page failed"); + self.more_failed(generation); + return; + } + }; + if !imp.paging.borrow_mut().more_arrived(generation, page.next) { + return; + } + let objects: Vec = page.entries.into_iter().map(HistoryEntryObject::new).collect(); + parts.entries.splice(parts.entries.n_items(), 0, &objects); + // A delete that emptied the list waits for this page to say + // whether anything is left. + self.show_results(); + } + + /// The next edge reached tries again. With no rows left on screen + /// there is no edge to reach, so the list says it could not read. + fn more_failed(&self, generation: u64) { + let imp = self.imp(); + imp.paging.borrow_mut().more_failed(generation); + let empty = imp.parts.get().is_some_and(|parts| parts.entries.n_items() == 0); + if empty && imp.paging.borrow().is_current(generation) { + self.show_unavailable(); + } + } + + fn show_results(&self) { + let imp = self.imp(); + let Some(parts) = imp.parts.get() else { + return; + }; + if parts.entries.n_items() > 0 { + imp.stack.set_visible_child_name("list"); + return; + } + let query = imp.query.borrow(); + let narrowed = !query.terms().is_empty() + || query.connection.is_some() + || query.status != StatusFilter::Any + || query.executed_after.is_some(); + let page = &imp.status_page; + if narrowed { + page.set_icon_name(Some(crate::ui::icons::SYSTEM_SEARCH)); + page.set_title(&gettext("No Matches")); + page.set_description(Some(&gettext("Try a different search term or change the filters."))); + } else { + page.set_icon_name(Some(crate::ui::icons::DOCUMENT_OPEN_RECENT)); + page.set_title(&gettext("No Queries Yet")); + page.set_description(Some(&gettext("Run a query in the SQL editor and it will appear here."))); + } + imp.stack.set_visible_child_name("empty"); + } + + fn show_unavailable(&self) { + let imp = self.imp(); + imp.status_page.set_icon_name(Some(crate::ui::icons::DIALOG_ERROR)); + imp.status_page.set_title(&gettext("History Unavailable")); + imp.status_page + .set_description(Some(&gettext("The query history could not be read."))); + imp.stack.set_visible_child_name("empty"); + } + + fn add_toast(&self, message: &str) { + self.imp().toast_overlay.add_toast(crate::ui::plain_toast(message)); + } +} + +/// Without the saved list the history's own names still serve. +fn connections_read( + listed: Result, StorageError>, + saved: Result, StorageError>, TaskFailure>, +) -> Option<(Vec, Arc<[SavedConnection]>)> { + let listed = match listed { + Ok(listed) => listed, + Err(error) => { + tracing::warn!(%error, "could not list the connections in the history"); + return None; + } + }; + let saved = match saved { + Ok(Ok(saved)) => saved, + Ok(Err(error)) => { + tracing::warn!(%error, "could not read the saved connections for the history filter"); + Arc::from([]) + } + Err(failure) => { + tracing::warn!(%failure, "the saved connections read failed"); + Arc::from([]) + } + }; + Some((listed, saved)) +} + +/// Pinned entries form the first section. Every entry within a section +/// compares equal, so the order the history returned holds inside it. +fn pinned_first() -> gtk::CustomSorter { + gtk::CustomSorter::new(|a, b| { + let pinned = |object: &glib::Object| { + object + .downcast_ref::() + .is_some_and(HistoryEntryObject::is_pinned) + }; + pinned(b).cmp(&pinned(a)).into() + }) +} + +fn section_header_factory() -> gtk::SignalListItemFactory { + let factory = gtk::SignalListItemFactory::new(); + factory.connect_setup(|_, object| { + let Some(header) = object.downcast_ref::() else { + return; + }; + let label = gtk::Label::builder() + .xalign(0.0) + .margin_top(12) + .margin_bottom(6) + .margin_start(6) + .build(); + label.add_css_class("heading"); + header.set_child(Some(&label)); + }); + factory.connect_bind(|_, object| { + let Some(header) = object.downcast_ref::() else { + return; + }; + let Some(label) = header.child().and_downcast::() else { + return; + }; + let pinned = header + .item() + .and_downcast::() + .is_some_and(|entry| entry.is_pinned()); + label.set_label(&if pinned { + gettext("Pinned") + } else { + gettext("All Queries") + }); + }); + factory +} + +/// The row menu for one entry. Each item carries the entry's id, so the +/// one popover serves every row. +fn row_menu(entry: &HistoryEntryObject) -> gio::Menu { + let Some(id) = entry.id() else { + return gio::Menu::new(); + }; + let target = id.to_variant(); + let item = |label: String, action: &str| { + let item = gio::MenuItem::new(Some(&label), None); + item.set_action_and_target_value(Some(action), Some(&target)); + item + }; + let open = gio::Menu::new(); + open.append_item(&item(gettext("Open in New Tab"), "history.open")); + open.append_item(&item(gettext("Replace Current Tab"), "history.replace")); + let keep = gio::Menu::new(); + let pin = if entry.is_pinned() { + gettext("Unpin") + } else { + gettext("Pin") + }; + keep.append_item(&item(pin, "history.pin")); + keep.append_item(&item(gettext("Copy SQL"), "history.copy")); + let remove = gio::Menu::new(); + remove.append_item(&item(gettext("Delete"), "history.delete")); + let menu = gio::Menu::new(); + menu.append_section(None, &open); + menu.append_section(None, &keep); + menu.append_section(None, &remove); + menu +} diff --git a/linux/crates/app/src/ui/history_dialog/tests.rs b/linux/crates/app/src/ui/history_dialog/tests.rs new file mode 100644 index 0000000000..aeeba8237b --- /dev/null +++ b/linux/crates/app/src/ui/history_dialog/tests.rs @@ -0,0 +1,827 @@ +use std::cell::RefCell; +use std::rc::Rc; +use std::time::{Duration, SystemTime}; + +use gtk4 as gtk; +use gtk4::prelude::*; +use gtk4::subclass::prelude::*; +use gtk4::{gio, glib}; +use libadwaita as adw; +use libadwaita::prelude::*; +use tablepro_storage::query_history::{NewEntry, Outcome}; +use tablepro_storage::{ConnectionStore, QueryHistory, SavedConnection, StoragePaths}; +use tokio::sync::watch; +use uuid::Uuid; + +use super::*; +use crate::runtime::AppRuntime; +use crate::services::history_preferences::HistoryPreferences; +use crate::test_support::{descendants, test_runtime, wait_until}; +use crate::ui::file_reveal::RecordingRevealer; + +/// Long enough that a loaded machine cannot decide the outcome; a +/// passing test returns as soon as its condition holds. +pub(super) const WAIT: Duration = Duration::from_secs(30); + +pub(super) struct Harness { + pub runtime: AppRuntime, + pub service: HistoryService, + pub store: QueryHistory, + pub connections: ConnectionStore, + pub revealer: Rc, + pub window: adw::Window, + _root: tempfile::TempDir, +} + +impl Harness { + pub fn new() -> Self { + Self::revealing_with(RecordingRevealer::succeeding()) + } + + pub fn revealing_with(revealer: RecordingRevealer) -> Self { + crate::register_test_resources(); + let runtime = test_runtime(); + let root = tempfile::tempdir().expect("tempdir"); + let paths = StoragePaths::under(root.path(), "tablepro-test", "app.tablepro.TablePro.Devel"); + let preferences = HistoryPreferences { + retention_days: watch::channel(0).1, + save: watch::channel(true).1, + }; + let connections = ConnectionStore::new(&paths); + let service = HistoryService::start(paths, preferences, &runtime.tasks()); + let availability = service.availability(); + wait_until(WAIT, || availability.borrow().is_ready()).expect("the history to open"); + let store = service.store().expect("the history"); + let window = adw::Window::new(); + window.set_default_size(640, 640); + window.present(); + Self { + runtime, + service, + store, + connections, + revealer: Rc::new(revealer), + window, + _root: root, + } + } + + pub fn record(&self, query: &str) -> i64 { + self.record_on(query, Uuid::nil(), "local") + } + + pub fn record_on(&self, query: &str, connection: Uuid, name: &str) -> i64 { + block_on( + &self.runtime, + self.store.record(NewEntry { + query: query.to_owned(), + driver_id: "postgres".to_owned(), + connection_id: connection, + connection_name: name.to_owned(), + executed_at: SystemTime::now(), + duration_ms: Some(1), + rows_affected: Some(1), + outcome: Outcome::Success, + }), + ) + .expect("record") + } + + pub fn pin(&self, id: i64) { + block_on(&self.runtime, self.store.set_pinned(id, true)).expect("pin"); + } + + pub fn save_connection(&self, id: Uuid, name: &str) { + self.connections + .upsert_blocking(SavedConnection { + id, + name: name.to_owned(), + driver_id: "postgres".to_owned(), + host: "db".to_owned(), + port: 5432, + database: "app".to_owned(), + username: "postgres".to_owned(), + tls: tablepro_core::TlsConfig::verify_full(), + read_only: false, + auth_mode: tablepro_core::AuthMode::Password, + ssh: None, + last_opened_at: None, + color: None, + group: None, + }) + .expect("save the connection"); + } + + pub fn dialog(&self) -> HistoryDialog { + self.dialog_for(None) + } + + pub fn dialog_for(&self, preferred_connection: Option) -> HistoryDialog { + HistoryDialog::new(HistoryDialogInit { + history: self.service.clone(), + connections: self.connections.clone(), + preferred_connection, + revealer: self.revealer.clone(), + tasks: self.runtime.tasks(), + }) + } + + /// A dialog on the test window, so its list view lays out rows. + pub fn presented_dialog(&self) -> HistoryDialog { + self.present(self.dialog()) + } + + pub fn present(&self, dialog: HistoryDialog) -> HistoryDialog { + dialog.present(Some(&self.window)); + dialog + } + + pub fn stored_queries(&self) -> Vec { + block_on( + &self.runtime, + self.store + .search_page(&HistoryQuery::default(), HistoryPageRequest::first(100)), + ) + .expect("search") + .entries + .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" +)] +pub(super) fn block_on(runtime: &AppRuntime, future: F) -> F::Output { + runtime.tasks().handle().block_on(future) +} + +pub(super) fn entries(dialog: &HistoryDialog) -> Vec { + let Some(parts) = dialog.imp().parts.get() else { + return Vec::new(); + }; + parts + .entries + .iter::() + .filter_map(Result::ok) + .filter_map(|entry| entry.query()) + .collect() +} + +pub(super) fn rows(dialog: &HistoryDialog) -> Vec { + descendants(&dialog.imp().list_view.get()) + .into_iter() + .filter_map(|widget| widget.downcast::().ok()) + .filter(|row| row.entry().is_some()) + .collect() +} + +pub(super) fn row_titled(dialog: &HistoryDialog, title: &str) -> Option { + rows(dialog).into_iter().find(|row| row.title() == title) +} + +/// What the menu's item called `label` activates, and with what target. +pub(super) fn menu_item(menu: &gio::MenuModel, label: &str) -> Option<(String, Option)> { + for index in 0..menu.n_items() { + if let Some(section) = menu.item_link(index, gio::MENU_LINK_SECTION) + && let Some(found) = menu_item(§ion, label) + { + return Some(found); + } + let text = menu.item_attribute_value(index, gio::MENU_ATTRIBUTE_LABEL, Some(glib::VariantTy::STRING)); + if text.and_then(|text| text.get::()).as_deref() == Some(label) { + let action = menu + .item_attribute_value(index, gio::MENU_ATTRIBUTE_ACTION, Some(glib::VariantTy::STRING))? + .get::()?; + let target = menu.item_attribute_value(index, gio::MENU_ATTRIBUTE_TARGET, None); + return Some((action, target)); + } + } + None +} + +#[gtk4::test] +fn list_view_shows_pinned_section_header() { + let harness = Harness::new(); + let pinned = harness.record("SELECT pinned"); + harness.pin(pinned); + harness.record("SELECT plain"); + + let dialog = harness.presented_dialog(); + let list_view = dialog.imp().list_view.get(); + let header = |text: &str| { + descendants(&list_view) + .into_iter() + .filter_map(|widget| widget.downcast::().ok()) + .find(|label| label.has_css_class("heading") && label.label() == text) + }; + wait_until(WAIT, || header("Pinned").is_some() && header("All Queries").is_some()) + .expect("a header for each section"); + + let top_of = |widget: Option| { + widget + .filter(|widget| widget.height() > 0) + .and_then(|widget| widget.compute_point(&list_view, &graphene::Point::new(0.0, 0.0))) + .map(|point| point.y()) + }; + let order = || { + [ + top_of(header("Pinned").map(Cast::upcast)), + top_of(row_titled(&dialog, "SELECT pinned").map(Cast::upcast)), + top_of(header("All Queries").map(Cast::upcast)), + top_of(row_titled(&dialog, "SELECT plain").map(Cast::upcast)), + ] + }; + let in_order = || { + let tops = order(); + tops.iter().all(Option::is_some) && tops.windows(2).all(|pair| pair[0] < pair[1]) + }; + let laid_out = wait_until(WAIT, in_order); + assert!( + laid_out.is_ok(), + "headers and rows are not in section order: {:?}", + order() + ); + dialog.close(); +} + +#[gtk4::test] +fn edge_reached_appends_next_page() { + let harness = Harness::new(); + let total = PAGE_SIZE + 30; + for index in 0..total { + harness.record(&format!("SELECT {index}")); + } + + let dialog = harness.dialog(); + wait_until(WAIT, || entries(&dialog).len() == PAGE_SIZE).expect("the first page"); + + let scroll = dialog.imp().scroll.get(); + scroll.emit_by_name::<()>("edge-reached", &[>k::PositionType::Bottom]); + // A second edge while the first read is out must not read the same + // page again. + scroll.emit_by_name::<()>("edge-reached", &[>k::PositionType::Bottom]); + wait_until(WAIT, || entries(&dialog).len() >= total).expect("the next page"); + scroll.emit_by_name::<()>("edge-reached", &[>k::PositionType::Bottom]); + crate::test_support::drain_main_context(); + std::thread::sleep(Duration::from_millis(50)); + crate::test_support::drain_main_context(); + + let shown = entries(&dialog); + assert_eq!(shown.len(), total, "a page was read twice or past the end"); + let mut distinct = shown.clone(); + distinct.sort(); + distinct.dedup(); + assert_eq!(distinct.len(), total); + assert_eq!( + shown.first().map(String::as_str), + Some(format!("SELECT {}", total - 1).as_str()) + ); +} + +#[gtk4::test] +fn refresh_keeps_rows_until_results_arrive() { + let harness = Harness::new(); + harness.record("SELECT alpha"); + harness.record("SELECT beta"); + let dialog = harness.presented_dialog(); + wait_until(WAIT, || entries(&dialog).len() == 2).expect("the first page"); + + dialog.imp().search_entry.set_text("alpha"); + dialog.refresh(); + + assert_eq!(entries(&dialog).len(), 2, "the rows went before the new ones came"); + assert_eq!(dialog.imp().stack.visible_child_name().as_deref(), Some("list")); + wait_until(WAIT, || entries(&dialog) == ["SELECT alpha"]).expect("the search result"); + dialog.close(); +} + +#[gtk4::test] +fn row_context_menu_open_emits_output_for_clicked_id() { + let harness = Harness::new(); + harness.record("SELECT first"); + harness.record("SELECT second"); + let dialog = harness.presented_dialog(); + let chosen: Rc>> = Rc::default(); + let sink = chosen.clone(); + dialog.connect_query_chosen(move |_, destination, query| sink.borrow_mut().push((destination, query))); + wait_until(WAIT, || row_titled(&dialog, "SELECT first").is_some()).expect("the rows"); + let row = row_titled(&dialog, "SELECT first").expect("the row"); + + let controllers = row.observe_controllers(); + let gesture = (0..controllers.n_items()) + .filter_map(|index| controllers.item(index).and_downcast::()) + .find(|gesture| gesture.button() == gtk::gdk::BUTTON_SECONDARY) + .expect("a row answers a right-click"); + gesture.emit_by_name::<()>("pressed", &[&1i32, &4.0f64, &4.0f64]); + + let menu = dialog.imp().parts.get().expect("parts").menu.clone(); + assert!(menu.is_visible(), "the menu did not open"); + let model = menu.menu_model().expect("a menu model"); + let (action, target) = menu_item(&model, "Open in New Tab").expect("an Open item"); + menu.popdown(); + dialog.activate_action(&action, target.as_ref()).expect("the action"); + + assert_eq!( + *chosen.borrow(), + [(QueryDestination::NewTab, "SELECT first".to_owned())] + ); +} + +pub(super) fn filter_labels(dialog: &HistoryDialog) -> Vec { + let Some(parts) = dialog.imp().parts.get() else { + return Vec::new(); + }; + let labels = parts.filters.connections().labels(); + (0..labels.n_items()) + .filter_map(|position| labels.string(position)) + .map(String::from) + .collect() +} + +/// The text of the toast on screen, and its button. +pub(super) fn toast(window: &adw::Window, text: &str) -> Option { + let toast = descendants(window) + .into_iter() + .find(|widget| widget.type_().name() == "AdwToastWidget" && has_label(widget, text))?; + descendants(&toast) + .into_iter() + .filter_map(|widget| widget.downcast::().ok()) + .find(|button| button.label().is_some_and(|label| label.contains("Undo"))) +} + +fn has_label(root: >k::Widget, text: &str) -> bool { + descendants(root) + .into_iter() + .filter_map(|widget| widget.downcast::().ok()) + .any(|label| label.label() == text) +} + +#[gtk4::test] +fn filter_lists_saved_connection_without_live_session() { + let harness = Harness::new(); + let (saved, unsaved) = (Uuid::new_v4(), Uuid::new_v4()); + harness.record_on("SELECT 1", saved, "Warehouse (old name)"); + harness.record_on("SELECT 2", unsaved, "Scratch"); + harness.save_connection(saved, "Warehouse"); + + let dialog = harness.dialog(); + + wait_until(WAIT, || filter_labels(&dialog).len() == 3).expect("the connections"); + assert_eq!(filter_labels(&dialog), ["All Connections", "Scratch", "Warehouse"]); + connection_drop_down(&dialog).set_selected(2); + wait_until(WAIT, || entries(&dialog) == ["SELECT 1"]).expect("the saved connection's queries"); +} + +#[gtk4::test] +fn filter_defaults_to_presenting_window_connection() { + let harness = Harness::new(); + let (other, presenting) = (Uuid::new_v4(), Uuid::new_v4()); + harness.record_on("SELECT other", other, "Other"); + harness.record_on("SELECT presenting", presenting, "Presenting"); + + let dialog = harness.dialog_for(Some(presenting)); + + wait_until(WAIT, || entries(&dialog) == ["SELECT presenting"]).expect("the presenting connection's queries"); + let selected = connection_drop_down(&dialog).selected(); + assert_eq!(filter_labels(&dialog)[selected as usize], "Presenting"); + + let without_history = harness.dialog_for(Some(Uuid::new_v4())); + wait_until(WAIT, || entries(&without_history).len() == 2).expect("every query"); + assert_eq!(connection_drop_down(&without_history).selected(), 0); +} + +#[gtk4::test] +fn delete_undo_restores_row() { + let harness = Harness::new(); + harness.record("SELECT kept"); + let gone = harness.record("SELECT gone"); + let dialog = harness.presented_dialog(); + wait_until(WAIT, || entries(&dialog).len() == 2).expect("the first page"); + + dialog + .activate_action("history.delete", Some(&gone.to_variant())) + .expect("the action"); + + wait_until(WAIT, || entries(&dialog) == ["SELECT kept"]).expect("the row to go"); + assert_eq!(harness.stored_queries(), ["SELECT kept"]); + wait_until(WAIT, || toast(&harness.window, "1 query deleted").is_some()).expect("a toast"); + toast(&harness.window, "1 query deleted").expect("Undo").emit_clicked(); + + wait_until(WAIT, || entries(&dialog) == ["SELECT gone", "SELECT kept"]).expect("the row back"); + assert_eq!(harness.stored_queries(), ["SELECT gone", "SELECT kept"]); + let found = block_on( + &harness.runtime, + harness + .store + .search_page(&HistoryQuery::from_input("gone"), HistoryPageRequest::first(10)), + ) + .expect("search"); + assert_eq!(found.entries.len(), 1, "the restored row is not searchable"); + assert_eq!(found.entries[0].id, gone); +} + +#[gtk4::test] +fn bulk_delete_shows_toast_not_alert() { + let harness = Harness::new(); + for query in ["SELECT 1", "SELECT 2", "SELECT 3"] { + harness.record(query); + } + let dialog = harness.presented_dialog(); + wait_until(WAIT, || entries(&dialog).len() == 3).expect("the first page"); + dialog.set_selecting(true); + let parts = dialog.imp().parts.get().expect("parts"); + parts.multiple.select_item(0, false); + parts.multiple.select_item(2, false); + + dialog + .activate_action("history.delete-selected", None) + .expect("the action"); + + wait_until(WAIT, || entries(&dialog) == ["SELECT 2"]).expect("the selected rows to go"); + assert!( + crate::workspace::visible_alert(&harness.window).is_none(), + "a bulk delete asked first" + ); + wait_until(WAIT, || toast(&harness.window, "2 queries deleted").is_some()).expect("a toast"); +} + +#[gtk4::test] +fn connection_named_with_ampersand_renders_in_filter() { + let harness = Harness::new(); + let connection = Uuid::new_v4(); + let name = "R&D "; + harness.record_on("SELECT 1", connection, name); + + let dialog = harness.present(harness.dialog_for(Some(connection))); + + wait_until(WAIT, || filter_labels(&dialog).len() == 2).expect("the connections"); + assert_eq!(filter_labels(&dialog)[1], name); + let drop_down = connection_drop_down(&dialog); + let shown = || { + descendants(&drop_down) + .into_iter() + .filter_map(|widget| widget.downcast::().ok()) + .find(|label| label.is_mapped() && label.label() == name) + }; + wait_until(WAIT, || shown().is_some()).expect("the name on the drop-down"); + assert!( + shown().is_some_and(|label| !label.uses_markup()), + "the name was read as markup" + ); +} + +fn connection_drop_down(dialog: &HistoryDialog) -> gtk::DropDown { + dialog.imp().parts.get().expect("parts").filters.controls().0 +} + +#[gtk4::test] +fn history_filters_use_dropdowns_and_toggle_group() { + let harness = Harness::new(); + + let dialog = harness.presented_dialog(); + + let header = crate::test_support::first_descendant_of_type::(&dialog).expect("a header bar"); + let popovers: Vec = descendants(&header) + .into_iter() + .filter(|widget| widget.is::() && !widget.is::()) + .collect(); + assert!(popovers.is_empty(), "the header still has a filter popover"); + assert!(crate::test_support::first_descendant_of_type::(&dialog).is_none()); + let root = dialog.imp().parts.get().expect("parts").filters.root().clone(); + let children: Vec = std::iter::successors(root.first_child(), gtk::Widget::next_sibling).collect(); + assert_eq!(children.iter().filter(|child| child.is::()).count(), 2); + assert_eq!( + children.iter().filter(|child| child.is::()).count(), + 1 + ); + let search_bar = dialog.imp().search_bar.get().upcast::(); + assert_eq!( + root.prev_sibling(), + Some(search_bar), + "the filters are not under the search bar" + ); +} + +#[gtk4::test] +fn connection_dropdown_follows_model_and_preferred_connection() { + let harness = Harness::new(); + let (alpha, beta) = (Uuid::new_v4(), Uuid::new_v4()); + harness.record_on("SELECT a", alpha, "Alpha"); + harness.record_on("SELECT b", beta, "Beta"); + + let dialog = harness.dialog_for(Some(beta)); + let drop_down = connection_drop_down(&dialog); + let labels = dialog + .imp() + .parts + .get() + .expect("parts") + .filters + .connections() + .labels() + .clone(); + + assert_eq!(drop_down.model().and_downcast::(), Some(labels)); + wait_until(WAIT, || drop_down.model().map(|model| model.n_items()) == Some(3)).expect("the connections"); + assert_eq!(drop_down.selected(), 2); + wait_until(WAIT, || entries(&dialog) == ["SELECT b"]).expect("Beta's queries"); + drop_down.set_selected(1); + wait_until(WAIT, || entries(&dialog) == ["SELECT a"]).expect("Alpha's queries"); +} + +#[gtk4::test] +fn filters_are_labelled() { + let filters = HistoryFilters::new(&ConnectionFilterModel::new()); + let (connection, status, window) = filters.controls(); + + for control in [connection.upcast::(), status.upcast(), window.upcast()] { + assert!( + gtk::test_accessible_has_property(&control, gtk::AccessibleProperty::Label), + "{} has no accessible label", + control.type_().name() + ); + } +} + +#[gtk4::test] +fn history_dialog_show_in_files_reveals_the_database_file() { + let harness = Harness::new(); + let dialog = harness.presented_dialog(); + let header = crate::test_support::first_descendant_of_type::(&dialog).expect("a header bar"); + let menu = descendants(&header) + .into_iter() + .filter_map(|widget| widget.downcast::().ok()) + .find_map(|button| button.menu_model()) + .expect("the dialog's menu"); + let (action, target) = menu_item(&menu, "Show in Files").expect("a Show in Files item"); + + dialog.activate_action(&action, target.as_ref()).expect("the action"); + + assert_eq!(harness.revealer.revealed(), [harness.store.path().to_path_buf()]); + assert!(harness.store.path().ends_with("history.db")); +} + +#[gtk4::test] +fn history_dialog_reveal_failure_shows_a_toast() { + let harness = Harness::revealing_with(RecordingRevealer::failing()); + let dialog = harness.presented_dialog(); + + dialog + .activate_action("history.show-in-files", None) + .expect("the action"); + + let window: gtk::Widget = harness.window.clone().upcast(); + wait_until(WAIT, || has_label(&window, "Could not show the file in Files")).expect("a toast"); +} + +#[gtk4::test] +fn menu_key_opens_the_menu_of_the_focused_row() { + let harness = Harness::new(); + let first = harness.record("SELECT first"); + harness.record("SELECT second"); + let dialog = harness.presented_dialog(); + wait_until(WAIT, || row_titled(&dialog, "SELECT first").is_some()).expect("the rows"); + let list_view = dialog.imp().list_view.get(); + // Newest first, so the older entry is the second row. The scroll + // marks the row the list focuses next; the grab carries it there. + list_view.scroll_to(1, gtk::ListScrollFlags::FOCUS, None); + list_view.grab_focus(); + let focused_title = || { + list_view + .focus_child() + .and_then(|item| item.first_child()) + .and_downcast::() + .map(|row| row.title()) + }; + wait_until(WAIT, || focused_title().as_deref() == Some("SELECT first")).expect("the second row focused"); + + dialog.activate_action("history-row.menu", None).expect("the action"); + + let menu = dialog.imp().parts.get().expect("parts").menu.clone(); + assert!(menu.is_visible(), "the menu did not open"); + let model = menu.menu_model().expect("a menu model"); + let (_, target) = menu_item(&model, "Delete").expect("a Delete item"); + assert_eq!(target.and_then(|target| target.get::()), Some(first)); + menu.popdown(); +} + +fn select_entry(dialog: &HistoryDialog, id: i64) { + let selection = &dialog.imp().parts.get().expect("parts").multiple; + let position = (0..selection.n_items()) + .find(|position| { + selection + .item(*position) + .and_downcast::() + .and_then(|entry| entry.id()) + == Some(id) + }) + .expect("the entry on screen"); + selection.select_item(position, false); +} + +fn scroll_to_end(dialog: &HistoryDialog) { + dialog + .imp() + .scroll + .emit_by_name::<()>("edge-reached", &[>k::PositionType::Bottom]); +} + +#[gtk4::test] +fn deleting_every_loaded_row_loads_the_rest() { + let harness = Harness::new(); + for index in 0..PAGE_SIZE + 5 { + harness.record(&format!("SELECT {index}")); + } + let dialog = harness.dialog(); + wait_until(WAIT, || entries(&dialog).len() == PAGE_SIZE).expect("the first page"); + dialog.set_selecting(true); + dialog.imp().parts.get().expect("parts").multiple.select_all(); + + dialog + .activate_action("history.delete-selected", None) + .expect("the action"); + + wait_until(WAIT, || entries(&dialog).len() == 5).expect("the rest of the history"); + assert_eq!( + entries(&dialog), + ["SELECT 4", "SELECT 3", "SELECT 2", "SELECT 1", "SELECT 0"] + ); + assert_eq!(dialog.imp().stack.visible_child_name().as_deref(), Some("list")); +} + +#[gtk4::test] +fn deleting_a_row_already_gone_removes_it_without_undo() { + let harness = Harness::new(); + harness.record("SELECT kept"); + let gone = harness.record("SELECT gone"); + let dialog = harness.presented_dialog(); + wait_until(WAIT, || entries(&dialog).len() == 2).expect("the first page"); + block_on(&harness.runtime, harness.store.delete_returning(&[gone])).expect("a delete elsewhere"); + + dialog + .activate_action("history.delete", Some(&gone.to_variant())) + .expect("the action"); + + wait_until(WAIT, || entries(&dialog) == ["SELECT kept"]).expect("the row to go"); + assert!( + toast(&harness.window, "1 query deleted").is_none(), + "Undo offered for nothing deleted" + ); +} + +#[gtk4::test] +fn undo_that_fails_says_so() { + let harness = Harness::new(); + let gone = harness.record("SELECT gone"); + let dialog = harness.presented_dialog(); + wait_until(WAIT, || entries(&dialog).len() == 1).expect("the first page"); + let entry = block_on( + &harness.runtime, + harness + .store + .search_page(&HistoryQuery::default(), HistoryPageRequest::first(10)), + ) + .expect("search") + .entries + .remove(0); + dialog + .activate_action("history.delete", Some(&gone.to_variant())) + .expect("the action"); + wait_until(WAIT, || toast(&harness.window, "1 query deleted").is_some()).expect("a toast"); + // Back under its id before Undo runs, so the restore collides. + block_on(&harness.runtime, harness.store.restore(&[entry])).expect("a restore elsewhere"); + + toast(&harness.window, "1 query deleted").expect("Undo").emit_clicked(); + + let window: gtk::Widget = harness.window.clone().upcast(); + wait_until(WAIT, || has_label(&window, "The queries could not be restored.")).expect("a toast"); +} + +#[gtk4::test] +fn click_in_select_mode_toggles_the_row() { + let harness = Harness::new(); + let first = harness.record("SELECT 1"); + harness.record("SELECT 2"); + let third = harness.record("SELECT 3"); + let dialog = harness.presented_dialog(); + dialog.set_selecting(true); + wait_until(WAIT, || row_titled(&dialog, "SELECT 1").is_some()).expect("the rows"); + select_entry(&dialog, third); + let click = || { + let row = row_titled(&dialog, "SELECT 1").expect("the row"); + let controllers = row.observe_controllers(); + let gesture = (0..controllers.n_items()) + .filter_map(|index| controllers.item(index).and_downcast::()) + .find(|gesture| gesture.button() == gtk::gdk::BUTTON_PRIMARY) + .expect("a row answers a click"); + gesture.emit_by_name::<()>("released", &[&1i32, &4.0f64, &4.0f64]); + }; + + click(); + let mut selected = dialog.selected_ids(); + selected.sort_unstable(); + assert_eq!(selected, [first, third], "the click replaced the selection"); + + click(); + assert_eq!(dialog.selected_ids(), [third]); +} + +#[gtk4::test] +fn pin_keeps_the_pages_read_and_the_selection() { + let harness = Harness::new(); + let ids: Vec = (0..PAGE_SIZE + 30) + .map(|index| harness.record(&format!("SELECT {index}"))) + .collect(); + let dialog = harness.dialog(); + wait_until(WAIT, || entries(&dialog).len() == PAGE_SIZE).expect("the first page"); + scroll_to_end(&dialog); + wait_until(WAIT, || entries(&dialog).len() == ids.len()).expect("the next page"); + dialog.set_selecting(true); + select_entry(&dialog, ids[5]); + + dialog + .activate_action("history.pin", Some(&ids[10].to_variant())) + .expect("the action"); + + wait_until(WAIT, || { + entries(&dialog).first().map(String::as_str) == Some("SELECT 10") + }) + .expect("the pinned row first"); + assert_eq!(entries(&dialog).len(), ids.len(), "the pages read were dropped"); + assert_eq!(dialog.selected_ids(), [ids[5]]); +} + +#[gtk4::test] +fn unpinned_row_past_the_pages_read_comes_back_with_its_page() { + let harness = Harness::new(); + let oldest = harness.record("SELECT 0"); + for index in 1..PAGE_SIZE + 30 { + harness.record(&format!("SELECT {index}")); + } + harness.pin(oldest); + let dialog = harness.dialog(); + wait_until(WAIT, || entries(&dialog).len() == PAGE_SIZE).expect("the first page"); + assert_eq!(entries(&dialog).first().map(String::as_str), Some("SELECT 0")); + + dialog + .activate_action("history.pin", Some(&oldest.to_variant())) + .expect("the action"); + + wait_until(WAIT, || entries(&dialog).len() == PAGE_SIZE - 1).expect("the row to leave"); + assert!(!entries(&dialog).iter().any(|query| query == "SELECT 0")); + scroll_to_end(&dialog); + wait_until(WAIT, || entries(&dialog).len() == PAGE_SIZE + 30).expect("the next page"); + let shown = entries(&dialog); + let mut distinct = shown.clone(); + distinct.sort(); + distinct.dedup(); + assert_eq!(distinct.len(), shown.len(), "a row is shown twice"); + assert_eq!(shown.last().map(String::as_str), Some("SELECT 0")); +} + +#[gtk4::test] +fn deleting_a_connections_last_query_drops_it_from_the_filter() { + let harness = Harness::new(); + let connection = Uuid::new_v4(); + let only = harness.record_on("SELECT 1", connection, "Warehouse"); + let dialog = harness.dialog_for(Some(connection)); + wait_until(WAIT, || entries(&dialog) == ["SELECT 1"]).expect("the connection's queries"); + assert_eq!(connection_drop_down(&dialog).selected(), 1); + + dialog + .activate_action("history.delete", Some(&only.to_variant())) + .expect("the action"); + + wait_until(WAIT, || filter_labels(&dialog) == ["All Connections"]).expect("the connection to go"); + assert_eq!(connection_drop_down(&dialog).selected(), 0); + let imp = dialog.imp(); + wait_until(WAIT, || { + imp.stack.visible_child_name().as_deref() == Some("empty") && imp.status_page.title() == "No Queries Yet" + }) + .expect("the empty history, not an empty search"); +} + +#[gtk4::test] +fn copy_query_puts_it_on_the_clipboard_and_says_so() { + let harness = Harness::new(); + let id = harness.record("SELECT copied"); + let dialog = harness.presented_dialog(); + wait_until(WAIT, || entries(&dialog).len() == 1).expect("the entry"); + + dialog + .activate_action("history.copy", Some(&id.to_variant())) + .expect("the action"); + + let text = dialog + .clipboard() + .content() + .and_then(|content| content.value(String::static_type()).ok()) + .and_then(|value| value.get::().ok()); + assert_eq!(text.as_deref(), Some("SELECT copied")); + wait_until(WAIT, || has_label(harness.window.upcast_ref(), "Copied to clipboard")).expect("a toast"); +} diff --git a/linux/crates/app/src/ui/icons.rs b/linux/crates/app/src/ui/icons.rs index 3665033c59..081bdc549e 100644 --- a/linux/crates/app/src/ui/icons.rs +++ b/linux/crates/app/src/ui/icons.rs @@ -3,16 +3,23 @@ pub const DIALOG_ERROR: &str = "dialog-error-symbolic"; pub const DIALOG_WARNING: &str = "dialog-warning-symbolic"; +pub const DOCUMENT_EDIT: &str = "document-edit-symbolic"; +/// Not in Adwaita; the app's resources carry it, as GNOME Text Editor's do. +pub const DOCUMENT_MODIFIED: &str = "document-modified-symbolic"; pub const DOCUMENT_OPEN: &str = "document-open-symbolic"; pub const DOCUMENT_OPEN_RECENT: &str = "document-open-recent-symbolic"; pub const DOCUMENT_SAVE: &str = "document-save-symbolic"; pub const EDIT_CLEAR: &str = "edit-clear-symbolic"; pub const EDIT_COPY: &str = "edit-copy-symbolic"; +pub const EDIT_DELETE: &str = "edit-delete-symbolic"; +pub const EDIT_FIND_REPLACE: &str = "edit-find-replace-symbolic"; pub const FOLDER: &str = "folder-symbolic"; +pub const GO_DOWN: &str = "go-down-symbolic"; pub const GO_FIRST: &str = "go-first-symbolic"; 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 GO_UP: &str = "go-up-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"; @@ -34,6 +41,7 @@ pub const USER_TRASH: &str = "user-trash-symbolic"; pub const VIEW_GRID: &str = "view-grid-symbolic"; pub const VIEW_LIST: &str = "view-list-symbolic"; pub const VIEW_MORE: &str = "view-more-symbolic"; +pub const VIEW_PAGED: &str = "view-paged-symbolic"; pub const VIEW_PIN: &str = "view-pin-symbolic"; pub const VIEW_REVEAL: &str = "view-reveal-symbolic"; pub const VIEW_SORT_ASCENDING: &str = "view-sort-ascending-symbolic"; @@ -49,16 +57,22 @@ pub const WINDOW_CLOSE: &str = "window-close-symbolic"; pub const ALL: &[&str] = &[ DIALOG_ERROR, DIALOG_WARNING, + DOCUMENT_EDIT, + DOCUMENT_MODIFIED, DOCUMENT_OPEN, DOCUMENT_OPEN_RECENT, DOCUMENT_SAVE, EDIT_CLEAR, EDIT_COPY, + EDIT_DELETE, + EDIT_FIND_REPLACE, FOLDER, + GO_DOWN, GO_FIRST, GO_LAST, GO_NEXT, GO_PREVIOUS, + GO_UP, LIST_ADD, LIST_REMOVE, MEDIA_PLAYBACK_STOP, @@ -80,6 +94,7 @@ pub const ALL: &[&str] = &[ VIEW_GRID, VIEW_LIST, VIEW_MORE, + VIEW_PAGED, VIEW_PIN, VIEW_REVEAL, VIEW_SORT_ASCENDING, diff --git a/linux/crates/app/src/ui/mod.rs b/linux/crates/app/src/ui/mod.rs index 11c46e6ed7..b5c8d36802 100644 --- a/linux/crates/app/src/ui/mod.rs +++ b/linux/crates/app/src/ui/mod.rs @@ -1,17 +1,20 @@ +mod a11y; mod browse_tab; +pub(crate) mod clipboard; 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; mod export_dialog; mod file_read; +pub(crate) mod file_reveal; mod file_write; mod filter_strip; +mod format; mod grid; mod handler_guard; mod history_dialog; @@ -19,25 +22,24 @@ mod host_key_failure; mod host_key_review; pub(crate) mod icons; mod import_dialog; +mod picker; +mod plain_toast; mod preferences_dialog; +mod result_pane; mod saved_queries_dialog; mod session_window; -mod sidebar_row; +mod shell; mod ssh_inputs; mod ssh_section; mod structure_tab; mod style_scheme_grid; mod style_scheme_resolver; +mod tab_load; mod welcome_view; mod window_geometry; mod workspace; -#[cfg(test)] -pub(crate) use browse_tab::BrowseTab; -#[cfg(test)] -pub(crate) use editor::{SqlEditor, SqlEditorInit}; +pub(crate) use plain_toast::plain_toast; 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/picker/connection_menu.rs b/linux/crates/app/src/ui/picker/connection_menu.rs new file mode 100644 index 0000000000..33dc6aa97d --- /dev/null +++ b/linux/crates/app/src/ui/picker/connection_menu.rs @@ -0,0 +1,205 @@ +use gtk4::prelude::*; +use gtk4::{gio, glib}; +use tablepro_storage::ConnectionColor; +use uuid::Uuid; + +pub(super) const PICKER: &str = "picker"; +pub(super) const CONNECT: &str = "connect"; +pub(super) const DELETE: &str = "delete"; +pub(super) const DUPLICATE: &str = "duplicate"; +pub(super) const NEW_GROUP: &str = "new-group"; +pub(super) const SET_COLOR: &str = "set-color"; +pub(super) const SET_GROUP: &str = "set-group"; + +/// The row's own actions: its menu, and the colour and group radios, +/// whose state is that row's connection. +pub(super) const ROW: &str = "picker-row"; +pub(super) const ROW_MENU: &str = "menu"; +pub(super) const ROW_COLOR: &str = "color"; +pub(super) const ROW_GROUP: &str = "group"; + +/// The radio state standing for "no colour" or "no group". An empty +/// string rather than a missing state, because the action's parameter +/// type is a string and a radio group needs every entry to carry one. +pub(super) const NONE: &str = ""; + +/// One connection's menu. Every item names the connection by id, so the +/// menu acts on the row it opened on whatever the list does meanwhile. +/// Delete sits in its own section so a destructive action is never the +/// neighbour of an ordinary one. +pub(crate) fn connection_menu(id: Uuid, groups: &[String]) -> gio::Menu { + let target = id.to_string().to_variant(); + let menu = gio::Menu::new(); + let open = gio::Menu::new(); + open.append_item(&item(&crate::i18n::gettext("Connect"), PICKER, CONNECT, &target)); + open.append_item(&item(&crate::i18n::gettext("Duplicate"), PICKER, DUPLICATE, &target)); + open.append_submenu(Some(&crate::i18n::gettext("Colour")), &color_menu()); + open.append_submenu(Some(&crate::i18n::gettext("Group")), &group_menu(&target, groups)); + menu.append_section(None, &open); + let danger = gio::Menu::new(); + // Named as the dialog it opens names it. + danger.append_item(&item(&crate::i18n::gettext("Delete…"), PICKER, DELETE, &target)); + menu.append_section(None, &danger); + menu +} + +fn item(label: &str, group: &str, action: &str, target: &glib::Variant) -> gio::MenuItem { + let item = gio::MenuItem::new(Some(label), None); + item.set_action_and_target_value(Some(&format!("{group}.{action}")), Some(target)); + item +} + +/// The colour choices, as a radio group. "No Colour" leads because it +/// is the state every connection starts in. +fn color_menu() -> gio::Menu { + let menu = gio::Menu::new(); + menu.append_item(&item( + &crate::i18n::gettext("No Colour"), + ROW, + ROW_COLOR, + &NONE.to_variant(), + )); + let colors = gio::Menu::new(); + for color in ConnectionColor::ALL { + colors.append_item(&item(&color_name(color), ROW, ROW_COLOR, &color.id().to_variant())); + } + menu.append_section(None, &colors); + menu +} + +/// The groups already in the list, as a radio group, with a way to +/// start a new one at the end. +fn group_menu(target: &glib::Variant, groups: &[String]) -> gio::Menu { + let menu = gio::Menu::new(); + menu.append_item(&item( + &crate::i18n::gettext("No Group"), + ROW, + ROW_GROUP, + &NONE.to_variant(), + )); + if !groups.is_empty() { + let existing = gio::Menu::new(); + for group in groups { + existing.append_item(&item(&menu_label(group), ROW, ROW_GROUP, &group.to_variant())); + } + menu.append_section(None, &existing); + } + let new = gio::Menu::new(); + new.append_item(&item(&crate::i18n::gettext("New Group…"), PICKER, NEW_GROUP, target)); + menu.append_section(None, &new); + menu +} + +/// A GtkPopoverMenu reads an underscore in a label as a mnemonic, so a +/// group the user named keeps its underscores by doubling them. +fn menu_label(text: &str) -> String { + text.replace('_', "__") +} + +pub(super) fn color_name(color: ConnectionColor) -> String { + match color { + ConnectionColor::Blue => crate::i18n::gettext("Blue"), + ConnectionColor::Teal => crate::i18n::gettext("Teal"), + ConnectionColor::Green => crate::i18n::gettext("Green"), + ConnectionColor::Yellow => crate::i18n::gettext("Yellow"), + ConnectionColor::Orange => crate::i18n::gettext("Orange"), + ConnectionColor::Red => crate::i18n::gettext("Red"), + ConnectionColor::Pink => crate::i18n::gettext("Pink"), + ConnectionColor::Purple => crate::i18n::gettext("Purple"), + ConnectionColor::Slate => crate::i18n::gettext("Slate"), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Each item's label, action and target, in order, sections and + /// submenus flattened. + fn items(menu: &gio::MenuModel) -> Vec<(String, Option, Option)> { + let mut found = Vec::new(); + for index in 0..menu.n_items() { + if let Some(label) = menu + .item_attribute_value(index, "label", None) + .and_then(|value| value.get::()) + { + let action = menu + .item_attribute_value(index, "action", None) + .and_then(|value| value.get::()); + found.push((label, action, menu.item_attribute_value(index, "target", None))); + } + for link in ["section", "submenu"] { + if let Some(linked) = menu.item_link(index, link) { + found.extend(items(&linked)); + } + } + } + found + } + + fn labels(menu: &gio::Menu) -> Vec { + items(menu.upcast_ref()).into_iter().map(|(label, ..)| label).collect() + } + + #[test] + fn connect_leads_and_delete_closes_the_menu() { + let id = Uuid::new_v4(); + + let found = items(connection_menu(id, &[]).upcast_ref()); + + let first = found.first().expect("an item"); + assert_eq!(first.0, "Connect"); + assert_eq!(first.1.as_deref(), Some("picker.connect")); + let last = found.last().expect("an item"); + assert_eq!(last.0, "Delete…"); + assert_eq!(last.1.as_deref(), Some("picker.delete")); + for (label, action, target) in &found { + if action.as_deref().is_some_and(|action| action.starts_with("picker.")) { + assert_eq!( + target.as_ref().and_then(|target| target.get::()), + Some(id.to_string()), + "{label} does not name the row's connection" + ); + } + } + } + + #[test] + fn the_group_menu_offers_every_group_the_list_already_has() { + let labels = labels(&connection_menu( + Uuid::new_v4(), + &["Archive".to_owned(), "Work".to_owned()], + )); + + assert!(labels.contains(&"Archive".to_owned()), "{labels:?}"); + assert!(labels.contains(&"Work".to_owned()), "{labels:?}"); + assert!(labels.contains(&"No Group".to_owned()), "{labels:?}"); + assert!(labels.contains(&"New Group…".to_owned()), "{labels:?}"); + } + + #[test] + fn a_group_name_keeps_its_underscores_in_the_menu() { + let found = items(connection_menu(Uuid::new_v4(), &["dev_db".to_owned()]).upcast_ref()); + + let group = found + .iter() + .find(|(_, _, target)| { + target.as_ref().and_then(|target| target.get::()).as_deref() == Some("dev_db") + }) + .expect("the group's item"); + assert_eq!(group.0, "dev__db"); + } + + #[test] + fn the_radio_target_tells_no_colour_apart_from_a_colour() { + let found = items(color_menu().upcast_ref()); + + let targets: Vec = found + .iter() + .filter_map(|(_, _, target)| target.as_ref().and_then(|target| target.get::())) + .collect(); + assert_eq!(targets.first().map(String::as_str), Some(NONE)); + assert_eq!(targets.len(), ConnectionColor::ALL.len() + 1); + assert!(targets.iter().skip(1).all(|id| ConnectionColor::from_id(id).is_some())); + } +} diff --git a/linux/crates/app/src/ui/picker/connection_row.rs b/linux/crates/app/src/ui/picker/connection_row.rs new file mode 100644 index 0000000000..484f178b46 --- /dev/null +++ b/linux/crates/app/src/ui/picker/connection_row.rs @@ -0,0 +1,194 @@ +use std::path::PathBuf; + +use gtk4 as gtk; +use gtk4::prelude::*; +use gtk4::{gdk, gio, glib}; +use libadwaita as adw; +use libadwaita::prelude::*; +use tablepro_session::runtime::Tasks; +use tablepro_storage::{ConnectionColor, SavedConnection}; + +use super::connection_menu::{ + CONNECT, NONE, PICKER, ROW, ROW_COLOR, ROW_GROUP, ROW_MENU, SET_COLOR, SET_GROUP, color_name, connection_menu, +}; +use crate::application::{ShortcutOwner, local_shortcut_controller}; +use crate::services::file_location; +use crate::services::saved_connection_object::{SavedConnectionObject, is_sqlite, subtitle_for}; +use crate::ui::a11y::describe_icon_button; + +/// The row for one saved connection: activating it connects, and its +/// menu holds the rest. `groups` are the list's groups, which the menu +/// offers. `tasks` reads the file a portal path stands for; `None` +/// names the file alone. +pub(crate) fn connection_row(object: &glib::Object, groups: &[String], tasks: Option<&Tasks>) -> gtk::Widget { + let Some(saved) = object + .downcast_ref::() + .and_then(SavedConnectionObject::saved) + .cloned() + else { + return adw::ActionRow::new().upcast(); + }; + let row = adw::ActionRow::builder() + .title(&saved.name) + .subtitle(subtitle_for(&saved, None)) + // A name is the user's text, never markup. + .use_markup(false) + .activatable(true) + .build(); + row.set_action_target_value(Some(&saved.id.to_string().to_variant())); + row.set_action_name(Some(&format!("{PICKER}.{CONNECT}"))); + row.add_prefix(&color_tag(saved.color)); + // One menu rather than a button per action: the row gains actions as + // the app does, and a row of icons stops reading as a row. + let menu_button = gtk::MenuButton::builder() + .icon_name(crate::ui::icons::VIEW_MORE) + .valign(gtk::Align::Center) + .menu_model(&connection_menu(saved.id, groups)) + .build(); + menu_button.add_css_class("flat"); + describe_icon_button( + &menu_button, + &crate::i18n::gettext("Connection Options"), + Some(&format!("{ROW}.{ROW_MENU}")), + ); + row.add_suffix(&menu_button); + row.insert_action_group(ROW, Some(&row_actions(&row, &saved, &menu_button))); + row.add_controller(local_shortcut_controller(ShortcutOwner::PickerRow)); + row.add_controller(secondary_click(&menu_button)); + if let Some(tasks) = tasks { + show_host_path(&row, &saved, tasks); + } + row.upcast() +} + +/// The row's menu, and the colour and group radios ticked at what the +/// connection has. A pick goes to the picker's action with the id. +fn row_actions(row: &adw::ActionRow, saved: &SavedConnection, menu_button: >k::MenuButton) -> gio::SimpleActionGroup { + let actions = gio::SimpleActionGroup::new(); + let menu = gio::SimpleAction::new(ROW_MENU, None); + let button = menu_button.downgrade(); + menu.connect_activate(move |_, _| { + if let Some(button) = button.upgrade() { + button.popup(); + } + }); + actions.add_action(&menu); + let current_color = saved.color.map_or(NONE, |color| color.id()); + let current_group = crate::services::saved_connection_order::group_of(saved).unwrap_or(NONE); + for (name, state, forward) in [ + (ROW_COLOR, current_color, SET_COLOR), + (ROW_GROUP, current_group, SET_GROUP), + ] { + let radio = gio::SimpleAction::new_stateful(name, Some(&String::static_variant_type()), &state.to_variant()); + let (row, id) = (row.downgrade(), saved.id.to_string()); + radio.connect_activate(move |radio, picked| { + let (Some(row), Some(picked)) = (row.upgrade(), picked.and_then(|value| value.get::())) else { + return; + }; + radio.set_state(&picked.to_variant()); + let target = (id.clone(), picked).to_variant(); + if let Err(error) = row.activate_action(&format!("{PICKER}.{forward}"), Some(&target)) { + tracing::warn!(%error, "a connection row's pick reached no picker"); + } + }); + actions.add_action(&radio); + } + actions +} + +fn secondary_click(menu_button: >k::MenuButton) -> gtk::GestureClick { + let gesture = gtk::GestureClick::new(); + gesture.set_button(gdk::BUTTON_SECONDARY); + let button = menu_button.downgrade(); + gesture.connect_pressed(move |gesture, _, _, _| { + gesture.set_state(gtk::EventSequenceState::Claimed); + if let Some(button) = button.upgrade() { + button.popup(); + } + }); + gesture +} + +/// The colour the user put on this connection, as a dot ahead of the +/// name. Absent rather than grey when there is none, so an untagged list +/// has no decoration in it. +fn color_tag(color: Option) -> gtk::Box { + let tag = gtk::Box::builder().valign(gtk::Align::Center).build(); + tag.set_css_classes(&tag_classes(color)); + tag.set_visible(color.is_some()); + if let Some(color) = color { + tag.set_tooltip_text(Some(&color_name(color))); + } + tag +} + +/// The dot's classes. Set as a whole list rather than added one by one +/// so a colour change never leaves the previous colour's class behind. +pub(super) fn tag_classes(color: Option) -> Vec<&'static str> { + let mut classes = vec!["tp-connection-tag"]; + if let Some(color) = color { + classes.push(match color { + ConnectionColor::Blue => "tp-tag-blue", + ConnectionColor::Teal => "tp-tag-teal", + ConnectionColor::Green => "tp-tag-green", + ConnectionColor::Yellow => "tp-tag-yellow", + ConnectionColor::Orange => "tp-tag-orange", + ConnectionColor::Red => "tp-tag-red", + ConnectionColor::Pink => "tp-tag-pink", + ConnectionColor::Purple => "tp-tag-purple", + ConnectionColor::Slate => "tp-tag-slate", + }); + } + classes +} + +/// 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(row: &adw::ActionRow, saved: &SavedConnection, tasks: &Tasks) { + let path = PathBuf::from(&saved.database); + if !is_sqlite(saved) || !file_location::is_portal_path(&path) { + return; + } + let looking = tasks.spawn_blocking_task(move || file_location::portal_host_path_blocking(&path)); + let (saved, row) = (saved.clone(), row.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))); + } + }); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_connection_with_no_colour_carries_no_colour_class() { + assert_eq!(tag_classes(None), vec!["tp-connection-tag"]); + } + + /// A class with no rule behind it paints nothing, and nothing is + /// exactly what a missing tag looks like. + #[test] + fn every_colour_class_has_a_rule_in_the_stylesheet() { + let css = include_str!("../../../../../data/resources/style.css"); + + for color in ConnectionColor::ALL { + let class = tag_classes(Some(color))[1]; + assert!( + css.contains(&format!(".tp-connection-tag.{class} {{")), + "{class} has no rule in style.css" + ); + } + } + + #[test] + fn every_colour_has_a_class_of_its_own() { + let classes: std::collections::HashSet<&str> = ConnectionColor::ALL + .into_iter() + .map(|color| tag_classes(Some(color))[1]) + .collect(); + + assert_eq!(classes.len(), ConnectionColor::ALL.len()); + } +} diff --git a/linux/crates/app/src/ui/picker/mod.rs b/linux/crates/app/src/ui/picker/mod.rs new file mode 100644 index 0000000000..aa6d7b2f74 --- /dev/null +++ b/linux/crates/app/src/ui/picker/mod.rs @@ -0,0 +1,10 @@ +//! The connection picker's rows, their menu and the actions they reach. + +mod connection_menu; +mod connection_row; +mod picker_actions; +#[cfg(test)] +mod tests; + +pub(crate) use connection_row::connection_row; +pub(crate) use picker_actions::install_picker_actions; diff --git a/linux/crates/app/src/ui/picker/picker_actions.rs b/linux/crates/app/src/ui/picker/picker_actions.rs new file mode 100644 index 0000000000..fa20cb3522 --- /dev/null +++ b/linux/crates/app/src/ui/picker/picker_actions.rs @@ -0,0 +1,122 @@ +use std::rc::Rc; + +use gtk4 as gtk; +use gtk4::gio; +use gtk4::prelude::*; +use libadwaita as adw; +use libadwaita::prelude::*; +use tablepro_storage::{ConnectionColor, SavedConnection}; +use uuid::Uuid; + +use super::connection_menu::{CONNECT, DELETE, DUPLICATE, NEW_GROUP, NONE, PICKER, SET_COLOR, SET_GROUP}; +use crate::runtime::{DeliverInput, InputSender}; +use crate::services::saved_connection_list::SavedConnectionList; +use crate::ui::SessionWindowInput; + +/// The `picker` actions on `root`, which every row and menu inside it +/// reach. Each names a connection by id, read again from the list at the +/// moment it runs, so a row or a menu left over from an older list acts +/// on nothing rather than on another connection. +pub(crate) fn install_picker_actions( + root: &impl IsA, + list: Rc, + sender: InputSender, +) { + let actions = gio::SimpleActionGroup::new(); + let by_id = [ + (CONNECT, OnConnection::Connect), + (DELETE, OnConnection::Delete), + (DUPLICATE, OnConnection::Duplicate), + (NEW_GROUP, OnConnection::NewGroup), + ]; + for (name, picked) in by_id { + let action = gio::SimpleAction::new(name, Some(&String::static_variant_type())); + let (list, sender, root) = (list.clone(), sender.clone(), root.as_ref().downgrade()); + action.connect_activate(move |_, target| { + let Some(saved) = connection(&list, target.and_then(|target| target.get::()), name) else { + return; + }; + match picked { + OnConnection::Connect => sender.deliver(SessionWindowInput::OpenSaved(Box::new(saved))), + OnConnection::Delete => sender.deliver(SessionWindowInput::DeleteConnection(saved.id)), + OnConnection::Duplicate => sender.deliver(SessionWindowInput::DuplicateConnection(Box::new(saved))), + OnConnection::NewGroup => ask_for_group(root.upgrade().as_ref(), saved.id, &sender), + } + }); + actions.add_action(&action); + } + for (name, picked) in [(SET_COLOR, Setting::Color), (SET_GROUP, Setting::Group)] { + let action = gio::SimpleAction::new(name, Some(&<(String, String)>::static_variant_type())); + let (list, sender) = (list.clone(), sender.clone()); + action.connect_activate(move |_, target| { + let Some((id, value)) = target.and_then(|target| target.get::<(String, String)>()) else { + tracing::warn!(action = name, "a picker action came without its target"); + return; + }; + let Some(saved) = connection(&list, Some(id), name) else { + return; + }; + sender.deliver(match picked { + Setting::Color => SessionWindowInput::SetConnectionColor(saved.id, ConnectionColor::from_id(&value)), + Setting::Group => { + SessionWindowInput::SetConnectionGroup(saved.id, Some(value).filter(|group| group != NONE)) + } + }); + }); + actions.add_action(&action); + } + root.insert_action_group(PICKER, Some(&actions)); +} + +/// What an action that names only a connection does with it. +#[derive(Debug, Clone, Copy)] +enum OnConnection { + Connect, + Delete, + Duplicate, + NewGroup, +} + +/// What an action that names a connection and a value sets. +#[derive(Debug, Clone, Copy)] +enum Setting { + Color, + Group, +} + +/// The connection a target names, or `None`, with a warning, for a +/// target that is not an id or names a connection the list no longer +/// has. +fn connection(list: &SavedConnectionList, target: Option, action: &str) -> Option { + let Some(id) = target.as_deref().and_then(|target| Uuid::parse_str(target).ok()) else { + tracing::warn!(action, "a picker action came without a connection id"); + return None; + }; + let saved = list.get(id); + if saved.is_none() { + tracing::warn!(action, %id, "a picker action named a connection the list no longer has"); + } + saved +} + +/// Ask for a group's name, then file the connection under it. +fn ask_for_group(parent: Option<>k::Widget>, id: Uuid, sender: &InputSender) { + let dialog = adw::AlertDialog::new(Some(&crate::i18n::gettext("New Group")), None); + let entry = adw::EntryRow::builder().title(crate::i18n::gettext("Name")).build(); + 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("add", &crate::i18n::gettext("Add")); + dialog.set_response_appearance("add", adw::ResponseAppearance::Suggested); + dialog.set_default_response(Some("add")); + dialog.set_close_response("cancel"); + let sender = sender.clone(); + dialog.connect_response(Some("add"), move |_, _| { + let name = entry.text().trim().to_owned(); + if !name.is_empty() { + sender.deliver(SessionWindowInput::SetConnectionGroup(id, Some(name))); + } + }); + dialog.present(parent); +} diff --git a/linux/crates/app/src/ui/picker/tests.rs b/linux/crates/app/src/ui/picker/tests.rs new file mode 100644 index 0000000000..0429df5d2c --- /dev/null +++ b/linux/crates/app/src/ui/picker/tests.rs @@ -0,0 +1,362 @@ +use std::rc::Rc; +use std::sync::Arc; +use std::time::Duration; + +use gtk4 as gtk; +use gtk4::glib; +use gtk4::prelude::*; +use libadwaita as adw; +use libadwaita::prelude::*; +use tablepro_storage::{ConnectionListState, ConnectionStore, SavedConnection, StoragePaths}; +use uuid::Uuid; + +use crate::runtime::AppRuntime; +use crate::services::saved_connection_list::SavedConnectionList; +use crate::test_support::{descendants, drain_main_context, respond, test_runtime, wait_until}; +use crate::ui::delete_connection_dialog; +use crate::ui::session_window::SessionWindow; +use crate::ui::session_window::tests::{Script, TestWindow}; +use crate::ui::welcome_view::WelcomeView; + +const WAIT: Duration = Duration::from_secs(10); + +fn connection(name: &str, group: Option<&str>) -> SavedConnection { + SavedConnection { + id: Uuid::new_v4(), + name: name.to_owned(), + driver_id: "postgres".to_owned(), + host: "db".to_owned(), + port: 5432, + database: "app".to_owned(), + username: "postgres".to_owned(), + tls: tablepro_core::TlsConfig::verify_full(), + read_only: false, + auth_mode: tablepro_core::AuthMode::Password, + ssh: None, + last_opened_at: None, + color: None, + group: group.map(str::to_owned), + } +} + +/// One connection store, the list that follows it, and two pickers on +/// that list, as two windows have. +struct Pickers { + runtime: AppRuntime, + store: ConnectionStore, + list: Rc, + views: [WelcomeView; 2], + _dir: tempfile::TempDir, +} + +impl Pickers { + fn new() -> Self { + adw::init().expect("libadwaita initialises under the test backend"); + crate::register_test_resources(); + let dir = tempfile::tempdir().expect("a temporary directory"); + let store = ConnectionStore::new(&StoragePaths::under(dir.path(), "tablepro", crate::config::APP_ID)); + let list = SavedConnectionList::follow(&store); + let views = [ + WelcomeView::new(list.clone(), None), + WelcomeView::new(list.clone(), None), + ]; + Self { + runtime: test_runtime(), + store, + list, + views, + _dir: dir, + } + } + + /// Write as the connect dialog and a connect do: off the GTK thread. + fn write(&self, write: impl FnOnce(&ConnectionStore) + Send + 'static) { + let store = self.store.clone(); + let writing = self.runtime.tasks().spawn_blocking_task(move || write(&store)); + crate::test_support::finish_on_main(WAIT, writing) + .expect("the write to end") + .expect("the write task"); + } + + fn every_picker(&self, mut shows: impl FnMut(&[(String, Vec)]) -> bool) -> bool { + self.views.iter().all(|view| shows(&view.sections())) + } +} + +impl Drop for Pickers { + fn drop(&mut self) { + for view in &self.views { + view.shutdown(); + } + drain_main_context(); + } +} + +fn rows(sections: &[(String, Vec)]) -> Vec { + sections.iter().flat_map(|(_, rows)| rows.clone()).collect() +} + +#[gtk4::test] +fn connect_dialog_save_updates_every_picker() { + let pickers = Pickers::new(); + let corp = connection("Corp", None); + + pickers.write(move |store| store.upsert_blocking(corp).expect("the save")); + + wait_until(WAIT, || pickers.every_picker(|sections| rows(sections) == ["Corp"])) + .expect("every picker to list the new connection"); +} + +#[gtk4::test] +fn successful_connect_stamp_reorders_every_picker() { + let pickers = Pickers::new(); + let (first, second) = (connection("Alpha", None), connection("Beta", None)); + let stamped = second.id; + pickers.write(move |store| { + store.upsert_blocking(first).expect("the save"); + store.upsert_blocking(second).expect("the save"); + }); + wait_until(WAIT, || { + pickers.every_picker(|sections| rows(sections) == ["Alpha", "Beta"]) + }) + .expect("both connections, by name"); + + pickers.write(move |store| store.touch_last_opened_blocking(stamped).expect("the stamp")); + + wait_until(WAIT, || { + pickers.every_picker(|sections| rows(sections) == ["Beta", "Alpha"]) + }) + .expect("the connection opened last to lead every picker"); +} + +#[gtk4::test] +fn a_group_of_connections_is_a_section_of_its_own() { + let pickers = Pickers::new(); + let (loose, a, b, c) = ( + connection("loose", None), + connection("a", Some("Work")), + connection("b", Some("Work")), + connection("c", Some("R&D")), + ); + pickers + .list + .apply(ConnectionListState::Ready(Arc::from([loose, a.clone(), b, c]))); + + let view = &pickers.views[0]; + assert_eq!( + view.sections(), + [ + ("Saved Connections".to_owned(), vec!["loose".to_owned()]), + ("R&D".to_owned(), vec!["c".to_owned()]), + ("Work".to_owned(), vec!["a".to_owned(), "b".to_owned()]), + ] + ); + + pickers + .list + .apply(ConnectionListState::Ready(Arc::from([a, connection("d", None)]))); + + assert_eq!( + view.sections(), + [ + ("Saved Connections".to_owned(), vec!["d".to_owned()]), + ("Work".to_owned(), vec!["a".to_owned()]), + ], + "a group with no connection left kept its section" + ); + pickers + .list + .apply(ConnectionListState::Ready(Arc::from([connection("e", Some("Work"))]))); + assert_eq!( + view.sections(), + [("Work".to_owned(), vec!["e".to_owned()])], + "an empty ungrouped section stayed" + ); +} + +#[gtk4::test] +fn the_empty_page_shows_only_for_a_list_that_is_read_and_empty() { + let pickers = Pickers::new(); + let view = &pickers.views[0]; + assert_eq!( + view.visible_page().as_deref(), + Some("list"), + "a loading list offered to add one" + ); + + pickers.list.apply(ConnectionListState::Ready(Arc::from([]))); + assert_eq!(view.visible_page().as_deref(), Some("empty")); + + pickers + .list + .apply(ConnectionListState::Ready(Arc::from([connection("Corp", None)]))); + assert_eq!(view.visible_page().as_deref(), Some("list")); +} + +#[gtk4::test] +fn a_name_is_shown_as_text_not_markup() { + let pickers = Pickers::new(); + pickers + .list + .apply(ConnectionListState::Ready(Arc::from([connection("R&D ", None)]))); + + let row = descendants(&pickers.views[0]) + .into_iter() + .find_map(|widget| widget.downcast::().ok()) + .expect("the row"); + + assert!(!row.uses_markup()); + assert_eq!(row.title(), "R&D "); +} + +fn picker_row(window: &SessionWindow, name: &str) -> adw::ActionRow { + let mut found = None; + wait_until(WAIT, || { + found = descendants(window) + .into_iter() + .filter_map(|widget| widget.downcast::().ok()) + .find(|row| row.title() == name); + found.is_some() + }) + .expect("the connection's row"); + found.expect("the row") +} + +fn alert_dialogs(window: &SessionWindow) -> Vec { + window + .dialogs() + .iter::() + .filter_map(|item| item.ok()?.downcast::().ok()) + .collect() +} + +#[gtk4::test] +fn delete_action_presents_exactly_one_dialog() { + let test = TestWindow::picker("PickerDelete"); + let row = picker_row(&test.window, "Fake"); + + row.activate_action("picker.delete", Some(&test.saved.id.to_string().to_variant())) + .expect("picker.delete"); + + wait_until(WAIT, || test.window.visible_dialog().is_some()).expect("a dialog"); + let shown = test + .window + .visible_dialog() + .and_downcast::() + .expect("an alert"); + assert_eq!(shown.heading().as_deref(), Some("Delete “Fake”?")); + assert_eq!( + alert_dialogs(&test.window), + std::slice::from_ref(&shown), + "the delete asked twice" + ); + + respond(&shown, delete_connection_dialog::CANCEL); + wait_until(WAIT, || test.window.visible_dialog().is_none()).expect("the dialog to close"); + drain_main_context(); + assert!( + test.services.saved_connections.get(test.saved.id).is_some(), + "Cancel deleted" + ); + assert!(alert_dialogs(&test.window).is_empty()); +} + +#[gtk4::test] +fn activating_a_row_connects_to_its_connection() { + let test = TestWindow::picker("PickerActivate"); + let row = picker_row(&test.window, "Fake"); + + WidgetExt::activate(&row); + + wait_until(WAIT, || !test.connector.calls().is_empty()).expect("the connect"); + assert_eq!(test.connector.calls()[0].request.metadata.id, test.saved.id); +} + +#[gtk4::test] +fn invalid_action_target_is_ignored() { + let test = TestWindow::picker("PickerInvalid"); + let row = picker_row(&test.window, "Fake"); + + for target in ["not an id".to_owned(), Uuid::new_v4().to_string()] { + row.activate_action("picker.delete", Some(&target.to_variant())) + .expect("picker.delete"); + row.activate_action("picker.connect", Some(&target.to_variant())) + .expect("picker.connect"); + } + drain_main_context(); + + assert!( + test.window.visible_dialog().is_none(), + "a target that names nothing asked" + ); + assert_eq!(test.window.page_name(), "picker"); + assert!( + test.connector.calls().is_empty(), + "a target that names nothing connected" + ); +} + +#[gtk4::test] +fn row_menu_key_opens_menu() { + let test = TestWindow::picker("PickerRowMenu"); + let row = picker_row(&test.window, "Fake"); + let button = descendants(&row) + .into_iter() + .find_map(|widget| widget.downcast::().ok()) + .expect("the row's menu button"); + + row.activate_action("picker-row.menu", None).expect("picker-row.menu"); + + wait_until(WAIT, || button.popover().is_some_and(|popover| popover.is_visible())).expect("the row's menu to open"); + assert_eq!(button.tooltip_text().as_deref(), Some("Connection Options")); + button.popdown(); +} + +#[gtk4::test] +fn the_menu_keys_reach_the_row_menu_action() { + let test = TestWindow::picker("PickerRowKeys"); + let row = picker_row(&test.window, "Fake"); + + let bound = + crate::test_support::shortcut_keys::owner_controller(&row, crate::application::ShortcutOwner::PickerRow) + .expect("the row's keys"); + let actions: Vec = crate::test_support::shortcut_keys::shortcuts(&bound) + .into_iter() + .filter_map(|shortcut| shortcut.action().and_downcast::()) + .map(|action| action.action_name().to_string()) + .collect(); + + assert_eq!(actions, ["picker-row.menu", "picker-row.menu"]); +} + +#[gtk4::test] +fn a_colour_picked_from_the_row_reaches_the_store() { + let test = TestWindow::picker("PickerColour"); + let row = picker_row(&test.window, "Fake"); + + row.activate_action("picker-row.color", Some(&"red".to_variant())) + .expect("picker-row.color"); + + wait_until(WAIT, || { + test.services + .saved_connections + .get(test.saved.id) + .is_some_and(|saved| saved.color == Some(tablepro_storage::ConnectionColor::Red)) + }) + .expect("the colour saved and shown"); +} + +#[gtk4::test] +fn unreadable_list_reveals_banner() { + let test = TestWindow::with_script( + "PickerUnreadable", + Script { + unreadable_list: true, + ..Script::default() + }, + ); + let banner = test.window.connection_list_banner().expect("the picker's banner"); + + wait_until(WAIT, || banner.is_revealed()).expect("the banner"); + assert_eq!(banner.title(), "Saved connections could not be read"); +} diff --git a/linux/crates/app/src/ui/plain_toast.rs b/linux/crates/app/src/ui/plain_toast.rs new file mode 100644 index 0000000000..2cc9aee77d --- /dev/null +++ b/linux/crates/app/src/ui/plain_toast.rs @@ -0,0 +1,64 @@ +use libadwaita as adw; + +/// Toast text carries file names, column names, paths and server +/// messages, which an `&` or `<` would break as markup, leaving an +/// empty toast. +pub(crate) fn plain_toast(message: &str) -> adw::Toast { + let toast = adw::Toast::new(message); + toast.set_use_markup(false); + toast +} + +#[cfg(test)] +mod tests { + use std::path::Path; + + use super::plain_toast; + + #[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)); + } + + fn sources(dir: &Path, found: &mut Vec<(String, String)>) { + for entry in std::fs::read_dir(dir).expect("the source directory") { + let path = entry.expect("an entry").path(); + if path.is_dir() { + sources(&path, found); + } else if path.extension().is_some_and(|extension| extension == "rs") { + let text = std::fs::read_to_string(&path).expect("a source file"); + found.push((path.display().to_string(), text)); + } + } + } + + /// Every toast is plain text, so a name with an `&` in it still + /// shows. + #[test] + fn every_toast_is_built_as_plain_text() { + let mut files = Vec::new(); + sources(&Path::new(env!("CARGO_MANIFEST_DIR")).join("src"), &mut files); + let mut offenders = Vec::new(); + for (path, text) in &files { + if path.ends_with("ui/plain_toast.rs") { + continue; + } + if text.contains("adw::Toast::new(") { + offenders.push(format!("{path}: adw::Toast::new")); + } + for (start, _) in text.match_indices("Toast::builder()") { + let chain = &text[start..]; + let chain = &chain[..chain.find(".build()").unwrap_or(chain.len())]; + if !chain.contains(".use_markup(false)") { + offenders.push(format!("{path}: a Toast::builder() without use_markup(false)")); + } + } + } + assert!(offenders.is_empty(), "build toasts with plain_toast: {offenders:#?}"); + } +} diff --git a/linux/crates/app/src/ui/preferences_dialog/mod.rs b/linux/crates/app/src/ui/preferences_dialog/mod.rs index 51450a1178..5eedaef3e1 100644 --- a/linux/crates/app/src/ui/preferences_dialog/mod.rs +++ b/linux/crates/app/src/ui/preferences_dialog/mod.rs @@ -12,6 +12,7 @@ use tablepro_session::runtime::Tasks; use tablepro_storage::AppSettings; use tablepro_storage::settings::keys; +use crate::ui::file_reveal::{FileRevealer, reveal_failure_toast}; use crate::ui::style_scheme_grid; glib::wrapper! { @@ -21,16 +22,17 @@ glib::wrapper! { } impl PreferencesDialog { - pub fn new( + pub(crate) fn new( settings: &Rc, storage: &crate::storage::SharedStorage, history: Option, tasks: &Tasks, + revealer: Rc, ) -> Self { let dialog: Self = glib::Object::new(); dialog.imp().settings.replace(Some(settings.clone())); dialog.bind(settings, storage); - dialog.connect_history_actions(storage, history, tasks); + dialog.connect_history_actions(storage, history, tasks, revealer); dialog } @@ -98,6 +100,7 @@ impl PreferencesDialog { storage: &crate::storage::SharedStorage, history: Option, tasks: &Tasks, + revealer: Rc, ) { let imp = self.imp(); let history_path = storage.paths().history_database(); @@ -154,19 +157,25 @@ impl PreferencesDialog { crate::i18n::gettext("The query history could not be cleared.") } }; - dialog.add_toast(adw::Toast::new(&message)); + dialog.add_toast(crate::ui::plain_toast(&message)); }); }); alert.present(Some(&dialog)); }); - let dialog = self.clone(); + let dialog = self.downgrade(); imp.storage_button.connect_clicked(move |_| { - let path = history_path.clone(); - let directory = path.parent().map(std::path::Path::to_path_buf).unwrap_or(path); - let launcher = gtk4::FileLauncher::new(Some(&gio::File::for_path(&directory))); - let window = dialog.root().and_downcast::(); - launcher.launch(window.as_ref(), gio::Cancellable::NONE, |_| {}); + let Some(shown_from) = dialog.upgrade() else { + return; + }; + let revealing = revealer.reveal(gio::File::for_path(&history_path), shown_from.root().and_downcast()); + let dialog = dialog.clone(); + glib::spawn_future_local(async move { + let revealed = revealing.await; + if let (Some(toast), Some(dialog)) = (reveal_failure_toast(&revealed), dialog.upgrade()) { + dialog.add_toast(toast); + } + }); }); } } @@ -176,6 +185,7 @@ mod tests { use tablepro_storage::{EditorFont, StoragePaths}; use crate::test_support::{MemorySettings, test_runtime}; + use crate::ui::file_reveal::RecordingRevealer; use super::*; @@ -189,7 +199,13 @@ mod tests { tasks: &Tasks, ) -> PreferencesDialog { crate::register_test_resources(); - PreferencesDialog::new(settings.get(), storage, history, tasks) + PreferencesDialog::new( + settings.get(), + storage, + history, + tasks, + Rc::new(RecordingRevealer::succeeding()), + ) } fn test_storage(tasks: &Tasks) -> crate::storage::SharedStorage { @@ -306,6 +322,20 @@ mod tests { assert!(subtitle.contains("history.db"), "{subtitle}"); } + #[gtk4::test] + fn preferences_show_in_files_reveals_the_database_file_not_its_folder() { + crate::register_test_resources(); + let settings = MemorySettings::new(); + let runtime = test_runtime(); + let storage = test_storage(&runtime.tasks()); + let revealer = Rc::new(RecordingRevealer::succeeding()); + let dialog = PreferencesDialog::new(settings.get(), &storage, None, &runtime.tasks(), revealer.clone()); + + dialog.imp().storage_button.emit_clicked(); + + assert_eq!(revealer.revealed(), [storage.paths().history_database()]); + } + #[gtk4::test] fn clear_history_is_insensitive_until_the_database_opens() { let settings = MemorySettings::new(); @@ -405,12 +435,13 @@ mod tests { fn count(runtime: &crate::runtime::AppRuntime, history: &tablepro_storage::QueryHistory) -> usize { block_on( runtime, - history.search(tablepro_storage::query_history::SearchFilter { - limit: 100, - ..Default::default() - }), + history.search_page( + &tablepro_storage::query_history::HistoryQuery::default(), + tablepro_storage::query_history::HistoryPageRequest::first(100), + ), ) .expect("search") + .entries .len() } diff --git a/linux/crates/app/src/ui/result_pane/mod.rs b/linux/crates/app/src/ui/result_pane/mod.rs new file mode 100644 index 0000000000..ed8be63dd5 --- /dev/null +++ b/linux/crates/app/src/ui/result_pane/mod.rs @@ -0,0 +1,68 @@ +//! One result set of an editor run as the results list shows it: a +//! banner when the run's row limit cut it short, its grid, and how +//! many rows it holds. + +mod result_limit_banner; +mod result_set_view; +mod result_summary; + +use gtk4 as gtk; +use gtk4::prelude::*; + +pub(crate) use self::result_limit_banner::ResultLimitBanner; +pub(crate) use self::result_set_view::ResultSetView; +pub(crate) use self::result_summary::ResultSummary; +use crate::i18n::ngettext_f; +use crate::ui::export::ExportSource; +use crate::ui::format::{format_thousands, plural_count}; +use crate::ui::grid::{GridSink, RowObject, TabGridContext, build_result_grid, export_snapshot}; + +/// The page for `view`. Export All on its banner hands `export_all` the +/// rows the grid shows, in the order it shows them, for the dialog's +/// Shown Rows. +pub(crate) fn result_set_page( + view: &ResultSetView, + sink: GridSink, + export_all: impl Fn(ExportSource) + 'static, +) -> gtk::Widget { + let rows = view.rows.iter().cloned().map(RowObject::result).collect(); + let (column_view, _selection) = build_result_grid(view.columns.clone(), rows, sink); + let banner = ResultLimitBanner::new(&view.summary); + let columns = view.columns.clone(); + let grid = column_view.downgrade(); + banner.connect_export_all(move || { + let Some(column_view) = grid.upgrade() else { + return; + }; + export_all(ExportSource::Rows { + columns: columns.clone(), + rows: export_snapshot(&column_view, &TabGridContext::default()), + }); + }); + let scrolled = gtk::ScrolledWindow::builder() + .child(&column_view) + .hexpand(true) + .vexpand(true) + .build(); + let shown = view.row_count() as u64; + let footer = gtk::Label::builder() + .label(ngettext_f( + "{n} row", + "{n} rows", + plural_count(shown), + &[("n", &format_thousands(shown))], + )) + .xalign(0.0) + .margin_start(12) + .margin_end(12) + .margin_top(4) + .margin_bottom(4) + .build(); + footer.add_css_class("dim-label"); + footer.add_css_class("caption"); + let page = gtk::Box::builder().orientation(gtk::Orientation::Vertical).build(); + page.append(banner.widget()); + page.append(&scrolled); + page.append(&footer); + page.upcast() +} diff --git a/linux/crates/app/src/ui/result_pane/result_limit_banner.rs b/linux/crates/app/src/ui/result_pane/result_limit_banner.rs new file mode 100644 index 0000000000..9f7ac7dd57 --- /dev/null +++ b/linux/crates/app/src/ui/result_pane/result_limit_banner.rs @@ -0,0 +1,37 @@ +use libadwaita as adw; + +use super::ResultSummary; +use crate::i18n::gettext; + +/// The banner over a result the run's row limit cut short, saying how +/// much of it the grid holds and offering every row as a file. +#[derive(Debug, Clone)] +pub(crate) struct ResultLimitBanner { + banner: adw::Banner, +} + +impl ResultLimitBanner { + pub(crate) fn new(summary: &ResultSummary) -> Self { + let banner = adw::Banner::new(""); + // The title holds the locale's digit grouping, which is not + // markup. + banner.set_use_markup(false); + banner.set_button_label(Some(&gettext("Export All…"))); + match summary.limit_title() { + Some(title) => { + banner.set_title(&title); + banner.set_revealed(true); + } + None => banner.set_revealed(false), + } + Self { banner } + } + + pub(crate) fn connect_export_all(&self, export_all: impl Fn() + 'static) { + self.banner.connect_button_clicked(move |_| export_all()); + } + + pub(crate) fn widget(&self) -> &adw::Banner { + &self.banner + } +} diff --git a/linux/crates/app/src/ui/result_pane/result_set_view.rs b/linux/crates/app/src/ui/result_pane/result_set_view.rs new file mode 100644 index 0000000000..e329af13bf --- /dev/null +++ b/linux/crates/app/src/ui/result_pane/result_set_view.rs @@ -0,0 +1,33 @@ +use std::sync::Arc; + +use tablepro_core::column::ResultColumn; +use tablepro_core::{UserSql, Value}; + +use super::ResultSummary; + +/// One result set of an editor run, with the statement that produced +/// 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 ResultSetView { + pub columns: Arc<[ResultColumn]>, + pub rows: Vec>, + pub summary: ResultSummary, + /// The batch the result came from, which Export All runs again. + pub statement: UserSql, +} + +impl ResultSetView { + pub(crate) fn new(columns: Arc<[ResultColumn]>, statement: UserSql) -> Self { + Self { + columns, + rows: Vec::new(), + summary: ResultSummary::complete(0), + statement, + } + } + + pub(crate) fn row_count(&self) -> usize { + self.rows.len() + } +} diff --git a/linux/crates/app/src/ui/result_pane/result_summary.rs b/linux/crates/app/src/ui/result_pane/result_summary.rs new file mode 100644 index 0000000000..baa8bc8474 --- /dev/null +++ b/linux/crates/app/src/ui/result_pane/result_summary.rs @@ -0,0 +1,174 @@ +use tablepro_core::Truncation; + +use crate::i18n::{gettext_f, ngettext_f}; +use crate::ui::format::{format_thousands, plural_count}; + +/// How much of a result set an editor run kept, as the driver reported +/// it when the set ended. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ResultSummary { + /// The rows the result holds. + pub retained: u64, + /// What the server says the whole set held, when it says. + pub total_rows: Option, + pub truncation: Truncation, +} + +impl ResultSummary { + /// A set that has not ended, or ended with every row it held. + pub(crate) fn complete(retained: u64) -> Self { + Self { + retained, + total_rows: None, + truncation: Truncation::Complete, + } + } + + pub(crate) fn is_truncated(&self) -> bool { + self.truncation != Truncation::Complete + } + + /// What the banner over a cut-short result says. `None` for a + /// complete one, which shows no banner. + pub(crate) fn limit_title(&self) -> Option { + self.limit_title_with(format_thousands) + } + + fn limit_title_with(&self, format: impl Fn(u64) -> String) -> Option { + let shown = format(self.retained); + match (self.truncation, self.total_rows) { + (Truncation::Complete, _) => None, + (Truncation::ReadPastLimit, Some(total)) => Some(ngettext_f( + "Showing {shown} of {total} row", + "Showing {shown} of {total} rows", + plural_count(total), + &[("shown", &shown), ("total", &format(total))], + )), + (Truncation::ReadPastLimit | Truncation::ServerStopped, _) => Some(ngettext_f( + "Showing the first {shown} row", + "Showing the first {shown} rows", + plural_count(self.retained), + &[("shown", &shown)], + )), + } + } + + /// The result's line in the results list: its place in the run and + /// how many rows it has, with a `+` when there were more than it + /// kept and nobody counted them. + pub(crate) fn tab_label(&self, index: usize) -> String { + self.tab_label_with(index, format_thousands) + } + + fn tab_label_with(&self, index: usize, format: impl Fn(u64) -> String) -> String { + let rows = match (self.is_truncated(), self.total_rows) { + (false, _) => format(self.retained), + (true, Some(total)) => format(total), + (true, None) => gettext_f("{rows}+", &[("rows", &format(self.retained))]), + }; + gettext_f( + "Result {n} ({rows})", + &[("n", &(index + 1).to_string()), ("rows", &rows)], + ) + } + + /// The rows the history line records for this result: every row it + /// had, which is unknown for a result cut short with no total. + pub(crate) fn history_row_count(&self) -> Option { + let rows = match (self.is_truncated(), self.total_rows) { + (false, _) => Some(self.retained), + (true, total) => total, + }; + rows.map(|rows| i64::try_from(rows).unwrap_or(i64::MAX)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ui::format::Thousands; + + fn english(n: u64) -> String { + Thousands::for_posix_locale("en_US.UTF-8").format(n) + } + + fn summary(retained: u64, total_rows: Option, truncation: Truncation) -> ResultSummary { + ResultSummary { + retained, + total_rows, + truncation, + } + } + + #[test] + fn limit_title_for_server_stopped_without_total() { + let stopped = summary(1_000, None, Truncation::ServerStopped); + let read_past = summary(1_000, None, Truncation::ReadPastLimit); + + assert_eq!( + stopped.limit_title_with(english).as_deref(), + Some("Showing the first 1,000 rows") + ); + assert_eq!( + read_past.limit_title_with(english).as_deref(), + Some("Showing the first 1,000 rows") + ); + } + + #[test] + fn limit_title_for_read_past_limit_with_total_uses_thousands() { + let read_past = summary(10_000, Some(1_234_567), Truncation::ReadPastLimit); + + assert_eq!( + read_past.limit_title_with(english).as_deref(), + Some("Showing 10,000 of 1,234,567 rows") + ); + } + + #[test] + fn limit_title_none_when_complete() { + assert_eq!( + summary(5, Some(5), Truncation::Complete).limit_title_with(english), + None + ); + assert_eq!(ResultSummary::complete(0).limit_title_with(english), None); + } + + #[test] + fn tab_label_marks_truncated_count_with_plus() { + assert_eq!( + summary(10_000, None, Truncation::ServerStopped).tab_label_with(0, english), + "Result 1 (10,000+)" + ); + assert_eq!( + summary(10_000, Some(25_000), Truncation::ReadPastLimit).tab_label_with(1, english), + "Result 2 (25,000)" + ); + assert_eq!( + summary(3, Some(3), Truncation::Complete).tab_label_with(2, english), + "Result 3 (3)" + ); + } + + #[test] + fn history_row_count_is_none_for_capped_result_without_total() { + assert_eq!( + summary(1_000, None, Truncation::ServerStopped).history_row_count(), + None + ); + assert_eq!( + summary(1_000, None, Truncation::ReadPastLimit).history_row_count(), + None + ); + } + + #[test] + fn history_row_count_is_total_for_read_past_limit() { + assert_eq!( + summary(1_000, Some(4_321), Truncation::ReadPastLimit).history_row_count(), + Some(4_321) + ); + assert_eq!(summary(7, Some(7), Truncation::Complete).history_row_count(), Some(7)); + assert_eq!(ResultSummary::complete(2).history_row_count(), Some(2)); + } +} diff --git a/linux/crates/app/src/ui/saved_queries_dialog.rs b/linux/crates/app/src/ui/saved_queries_dialog.rs index ec09fc1ae3..b6a0510696 100644 --- a/linux/crates/app/src/ui/saved_queries_dialog.rs +++ b/linux/crates/app/src/ui/saved_queries_dialog.rs @@ -327,10 +327,10 @@ impl SavedQueriesDialog { let button = gtk::MenuButton::builder() .icon_name(crate::ui::icons::VIEW_MORE) .valign(gtk::Align::Center) - .tooltip_text(crate::i18n::gettext("Saved query options")) .menu_model(&menu) .build(); button.add_css_class("flat"); + crate::ui::a11y::describe_icon_button(&button, &crate::i18n::gettext("Saved Query Options"), None); button.insert_action_group("saved", Some(&actions)); row.add_suffix(&button); 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 index bbfd601a5d..cffbf1b497 100644 --- a/linux/crates/app/src/ui/session_window/connect_failed_page.rs +++ b/linux/crates/app/src/ui/session_window/connect_failed_page.rs @@ -12,6 +12,10 @@ mod imp { #[derive(Default, gtk::CompositeTemplate)] #[template(resource = "/app/tablepro/TablePro/ui/connect-failed-page.ui")] pub struct ConnectFailedPage { + #[template_child] + pub toast_overlay: TemplateChild, + #[template_child] + pub header_bar: TemplateChild, #[template_child] pub status_page: TemplateChild, #[template_child] @@ -55,10 +59,14 @@ impl ConnectFailedPage { /// `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 { + pub fn new(message: &str, edit: Option<&str>, reenter: Option, app: Option<>k::Application>) -> Self { let page: Self = glib::Object::new(); let imp = page.imp(); - imp.status_page.set_description(Some(message)); + imp.header_bar.pack_end(&crate::ui::shell::primary_menu_button(app)); + // The description is markup, and the reason quotes server text, + // file paths and host names. + imp.status_page + .set_description(Some(&glib::markup_escape_text(message))); if let Some(label) = edit { imp.edit_button.set_label(label); imp.edit_button.set_visible(true); @@ -73,9 +81,14 @@ impl ConnectFailedPage { page } + pub fn toast_overlay(&self) -> adw::ToastOverlay { + self.imp().toast_overlay.get() + } + + /// The reason as the page shows it. #[cfg(test)] pub fn message(&self) -> Option { - self.imp().status_page.description().map(|text| text.to_string()) + crate::test_support::status_page_description(&self.imp().status_page) } /// The button that types a refused secret again, while it shows. diff --git a/linux/crates/app/src/ui/session_window/connecting_page.rs b/linux/crates/app/src/ui/session_window/connecting_page.rs index 11f690353a..bf4c540588 100644 --- a/linux/crates/app/src/ui/session_window/connecting_page.rs +++ b/linux/crates/app/src/ui/session_window/connecting_page.rs @@ -10,6 +10,10 @@ mod imp { #[derive(Default, gtk::CompositeTemplate)] #[template(resource = "/app/tablepro/TablePro/ui/connecting-page.ui")] pub struct ConnectingPage { + #[template_child] + pub toast_overlay: TemplateChild, + #[template_child] + pub header_bar: TemplateChild, #[template_child] pub status_page: TemplateChild, } @@ -43,11 +47,16 @@ glib::wrapper! { } impl ConnectingPage { - pub fn new(name: &str) -> Self { + pub fn new(name: &str, app: Option<>k::Application>) -> Self { let page: Self = glib::Object::new(); - page.imp() - .status_page + let imp = page.imp(); + imp.status_page .set_title(&crate::i18n::gettext_f("Connecting to {name}", &[("name", name)])); + imp.header_bar.pack_end(&crate::ui::shell::primary_menu_button(app)); page } + + pub fn toast_overlay(&self) -> adw::ToastOverlay { + self.imp().toast_overlay.get() + } } diff --git a/linux/crates/app/src/ui/session_window/connections.rs b/linux/crates/app/src/ui/session_window/connections.rs index dd8f0a5114..891068a9bf 100644 --- a/linux/crates/app/src/ui/session_window/connections.rs +++ b/linux/crates/app/src/ui/session_window/connections.rs @@ -1,14 +1,11 @@ //! 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}; @@ -95,7 +92,7 @@ impl SessionWindow { connector: context.connector.clone(), interaction: context.attended(), tasks: state.services.tasks.clone(), - saved_connections: Arc::from(self.imp().saved_connections.borrow().clone()), + saved_connections: state.services.saved_connections.connections(), remembered_driver: state.services.settings.connect_dialog_driver(), ssh_environment: ssh, prefill, @@ -141,15 +138,13 @@ impl SessionWindow { ))); } } - 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. + /// Stamp the connection's last-opened time, which every picker sorts + /// by once the store publishes the list with it. pub(super) fn touch_last_opened(&self, id: Uuid) { - let (Some(state), Some(input)) = (self.state(), self.input()) else { + let Some(state) = self.state() else { return; }; let connections = state.services.storage.connections().clone(); @@ -165,38 +160,26 @@ impl SessionWindow { 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 { + /// Read the connection list, once per process: the store publishes + /// it, and every picker follows the store from then on. + pub(super) fn on_load_connections(&self) { + let Some(state) = self.state() 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); - } + Ok(Ok(_)) => {} + Ok(Err(error)) => tracing::warn!(%error, "the saved connections could not be read"), 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. @@ -211,12 +194,7 @@ impl SessionWindow { } fn saved_connection(&self, id: Uuid) -> Option { - self.imp() - .saved_connections - .borrow() - .iter() - .find(|saved| saved.id == id) - .cloned() + self.state()?.services.saved_connections.get(id) } /// The name a dialog or toast calls a saved connection by. @@ -320,7 +298,6 @@ impl SessionWindow { } drop(claim); drop(hold); - input.deliver(SessionWindowInput::ReloadConnections); }); } @@ -334,7 +311,7 @@ impl SessionWindow { let (Some(state), Some(input)) = (self.state(), self.input()) else { return; }; - let copy = tablepro_storage::duplicate(&source, &self.imp().saved_connections.borrow()); + let copy = tablepro_storage::duplicate(&source, &state.services.saved_connections.connections()); let name = copy.name.clone(); let connections = state.services.storage.connections().clone(); let secrets = state.services.storage.secrets().clone(); @@ -355,7 +332,6 @@ impl SessionWindow { // 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 { @@ -414,7 +390,6 @@ impl SessionWindow { tracing::warn!(%error, "could not save the connection"); input.deliver(SessionWindowInput::ShowToast(failure)); } - input.deliver(SessionWindowInput::ReloadConnections); }); } @@ -501,7 +476,6 @@ impl SessionWindow { crate::i18n::gettext("The connections could not be imported.") } }; - input.deliver(SessionWindowInput::ReloadConnections); input.deliver(SessionWindowInput::ShowToast(message)); }); } @@ -514,7 +488,7 @@ impl SessionWindow { }; let banner = picker.banner(); let Some(content) = - crate::ui::connection_list_banner::banner_content(&state.services.storage.connections().snapshot().state) + crate::ui::connection_list_banner::banner_content(&state.services.saved_connections.state()) else { banner.set_revealed(false); return; @@ -531,7 +505,7 @@ impl SessionWindow { let Some(input) = self.input() else { return; }; - let toast = adw::Toast::new(&crate::i18n::gettext( + let toast = crate::ui::plain_toast(&crate::i18n::gettext( "The stored passwords could not be deleted, so the connection was kept.", )); toast.set_button_label(Some(&crate::i18n::gettext("Retry"))); @@ -565,7 +539,7 @@ impl SessionWindow { /// 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 { + let (Some(state), Some(picker)) = (self.state(), self.imp().picker.get()) else { return; }; let store = state.services.storage.connections().clone(); @@ -575,24 +549,23 @@ impl SessionWindow { .tasks .spawn_blocking_task(move || store.reset_unreadable_blocking(std::time::SystemTime::now())); let banner = picker.banner(); - let toasts = self.imp().toast_overlay.get(); + let toasts = picker.toast_overlay(); 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); + toasts.add_toast(crate::ui::plain_toast( + &crate::ui::connection_list_banner::reset_toast_text(&moved), + )); 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( + toasts.add_toast(crate::ui::plain_toast(&crate::i18n::gettext_f( "Could not reset the saved connections: {error}", &[("error", &error)], ))); @@ -767,7 +740,13 @@ mod tests { /// 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"); + let list = window + .state() + .expect("the window's state") + .services + .saved_connections + .clone(); + wait_until(WAIT, || !list.connections().is_empty()).expect("the connection list"); } fn listed(test: &TestWindow, id: Uuid) -> bool { @@ -834,7 +813,7 @@ mod tests { 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()) + wait_until(WAIT, || test.services.saved_connections.connections().is_empty()) .expect("the picker to drop the entry"); } diff --git a/linux/crates/app/src/ui/session_window/imp.rs b/linux/crates/app/src/ui/session_window/imp.rs index 5585d79729..5260a96a0d 100644 --- a/linux/crates/app/src/ui/session_window/imp.rs +++ b/linux/crates/app/src/ui/session_window/imp.rs @@ -6,9 +6,7 @@ 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; @@ -16,6 +14,7 @@ use super::session_page::SessionPage; use super::{SessionWindowInput, WindowState}; use crate::runtime::InputSender; use crate::services::file_location::LocationClassifier; +use crate::services::saved_connection_list::StateObserverId; use crate::ui::connect_dialog::ConnectDialog; use crate::ui::welcome_view::WelcomeView; use crate::workspace::{CloseGuards, WindowInhibitor}; @@ -23,9 +22,9 @@ 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, - + /// The toasts of the page on screen. Each page has its own, under + /// its header bar, so a workspace's sit inside the tab overview. + pub(super) page_toasts: RefCell>, /// What the application handed the window, set once it is added. pub(super) state: OnceCell, pub(super) input: OnceCell>, @@ -47,8 +46,9 @@ pub struct SessionWindow { pub(super) inhibitor: RefCell>, pub(super) dirty: Cell, pub(super) saving: Cell, - pub(super) saved_connections: RefCell>, - pub(super) welcome: RefCell>>, + pub(super) welcome: RefCell>, + /// Shows the banner while the shared connection list cannot be read. + pub(super) list_observer: Cell>, pub(super) picker: OnceCell, pub(super) dialog: RefCell>, /// The connect dialog was asked for and opens once the SSH keys are diff --git a/linux/crates/app/src/ui/session_window/locate_file.rs b/linux/crates/app/src/ui/session_window/locate_file.rs index e38e66e4e4..21978df47d 100644 --- a/linux/crates/app/src/ui/session_window/locate_file.rs +++ b/linux/crates/app/src/ui/session_window/locate_file.rs @@ -141,7 +141,6 @@ impl SessionWindow { "The new location could not be saved.", ))); } - input.deliver(SessionWindowInput::ReloadConnections); input.deliver(SessionWindowInput::LocatedSaved(Box::new(located))); }, ); diff --git a/linux/crates/app/src/ui/session_window/mod.rs b/linux/crates/app/src/ui/session_window/mod.rs index d6faea6c30..9989252198 100644 --- a/linux/crates/app/src/ui/session_window/mod.rs +++ b/linux/crates/app/src/ui/session_window/mod.rs @@ -33,7 +33,7 @@ use gtk4::glib; use gtk4::prelude::*; use gtk4::subclass::prelude::*; use libadwaita as adw; -use relm4::prelude::*; +use libadwaita::prelude::AdwApplicationWindowExt; use tablepro_core::DriverError; use tablepro_session::connect::StoreConnectionWrites; @@ -45,20 +45,19 @@ use tablepro_session::{ }; use tablepro_storage::{CredentialDemand, SavedConnection}; -use self::connect_failed_page::ConnectFailedPage; -use self::connecting_page::ConnectingPage; -use self::picker_page::PickerPage; +pub(super) use self::connect_failed_page::ConnectFailedPage; +pub(super) use self::connecting_page::ConnectingPage; +pub(super) 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::welcome_view::WelcomeView; use crate::ui::workspace::{Workspace, WorkspaceInit, WorkspaceInput}; use crate::workspace::{ ApplicationInhibit, CloseDecision, CloseGuards, CloseReason, InhibitReason, WindowInhibitor, WorkspaceHandle, @@ -122,7 +121,6 @@ impl SessionWindow { } 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, @@ -139,26 +137,20 @@ impl SessionWindow { 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 list = services.saved_connections.clone(); + let welcome = WelcomeView::new(list.clone(), Some(services.tasks.clone())); + crate::ui::picker::install_picker_actions(&welcome, list.clone(), input.clone()); + let picker = PickerPage::new(&welcome, self.application().as_ref()); let reset_input = input.clone(); picker .banner() .connect_button_clicked(move |_| reset_input.deliver(SessionWindowInput::ResetConnectionList)); + let window = self.downgrade(); + imp.list_observer.set(Some(list.connect_state_changed(move |_| { + if let Some(window) = window.upgrade() { + window.show_connection_list_banner(); + } + }))); imp.welcome.replace(Some(welcome)); let _ = imp.picker.set(picker); let _ = imp.state.set(WindowState { @@ -168,7 +160,8 @@ impl SessionWindow { }); self.show_page(SessionPage::Picker); - input.deliver(SessionWindowInput::ReloadConnections); + self.show_connection_list_banner(); + input.deliver(SessionWindowInput::LoadConnections); match launch { WindowLaunch::Picker => {} WindowLaunch::Connect(saved) => input.deliver(SessionWindowInput::OpenSaved(saved)), @@ -184,6 +177,12 @@ impl SessionWindow { } } + /// The banner over the picker that says the list cannot be read. + #[cfg(test)] + pub(crate) fn connection_list_banner(&self) -> Option { + self.imp().picker.get().map(PickerPage::banner) + } + /// What the window's actions, pages and background work send it. pub(crate) fn input(&self) -> Option> { self.imp().input.get().cloned() @@ -211,13 +210,20 @@ impl SessionWindow { self.page_is(|page| matches!(page, SessionPage::Connecting { .. } | SessionPage::Workspace { .. })) } - /// Whether the window holds logout back now. + /// Whether the window holds logout back now, for its unsaved + /// changes or for an export its workspace is writing. pub(crate) fn is_inhibiting(&self) -> bool { - self.imp() - .inhibitor - .borrow() - .as_ref() - .is_some_and(WindowInhibitor::is_held) + let exporting = self + .workspace() + .and_then(|workspace| workspace.exports()) + .is_some_and(|exports| exports.is_inhibiting()); + exporting + || self + .imp() + .inhibitor + .borrow() + .as_ref() + .is_some_and(WindowInhibitor::is_held) } /// A test session has no session manager to grant an inhibition. @@ -242,7 +248,12 @@ impl SessionWindow { } self.release_session(); drop(imp.dialog.take()); - drop(imp.welcome.take()); + if let Some(welcome) = imp.welcome.take() { + welcome.shutdown(); + } + if let (Some(observer), Some(state)) = (imp.list_observer.take(), imp.state.get()) { + state.services.saved_connections.disconnect_state_changed(observer); + } drop(imp.inhibitor.take()); } @@ -335,9 +346,7 @@ impl SessionWindow { 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::LoadConnections => self.on_load_connections(), 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), @@ -353,13 +362,18 @@ impl SessionWindow { } fn show_toast(&self, message: &str) { - self.add_toast(adw::Toast::new(message)); + self.add_toast(crate::ui::plain_toast(message)); } + /// On the page on screen: a toast for a page left behind would never + /// show. fn add_toast(&self, toast: adw::Toast) { #[cfg(test)] self.imp().toasts.borrow_mut().push(toast.clone()); - self.imp().toast_overlay.add_toast(toast); + match self.imp().page_toasts.borrow().as_ref() { + Some(overlay) => overlay.add_toast(toast), + None => tracing::debug!(title = ?toast.title(), "a toast with no page to show it on"), + } } /// Move to `page`. The page left behind drops with what it held, @@ -368,21 +382,34 @@ impl SessionWindow { fn show_page(&self, page: SessionPage) { self.tear_down(); let imp = self.imp(); - let child: Option = match &page { + let app = self.application(); + let (child, toasts): (Option, Option) = match &page { SessionPage::Picker => { self.set_title(Some(&crate::i18n::gettext("TablePro"))); - imp.picker.get().map(|picker| picker.clone().upcast()) + let picker = imp.picker.get(); + ( + picker.map(|picker| picker.clone().upcast()), + picker.map(PickerPage::toast_overlay), + ) + } + SessionPage::Connecting { source, .. } => { + let connecting = ConnectingPage::new(&source_name(source), app.as_ref()); + let toasts = connecting.toast_overlay(); + (Some(connecting.upcast()), Some(toasts)) } - SessionPage::Connecting { source, .. } => Some(ConnectingPage::new(&source_name(source)).upcast()), - SessionPage::ConnectFailed { source, failure, .. } => Some( - ConnectFailedPage::new( + SessionPage::ConnectFailed { source, failure, .. } => { + let failed = ConnectFailedPage::new( &failure.message, edit_label(source, failure).as_deref(), failure.reenter, - ) - .upcast(), - ), - SessionPage::Workspace { workspace, .. } => Some(workspace.clone().upcast()), + app.as_ref(), + ); + let toasts = failed.toast_overlay(); + (Some(failed.upcast()), Some(toasts)) + } + SessionPage::Workspace { workspace, .. } => { + (Some(workspace.clone().upcast()), Some(workspace.toast_overlay())) + } }; let editable = match &page { SessionPage::ConnectFailed { source, .. } => is_editable(source), @@ -393,7 +420,8 @@ impl SessionWindow { imp.opened_for_file.set(false); } let left = imp.page.replace(page); - imp.toast_overlay.set_child(child.as_ref()); + imp.page_toasts.replace(toasts); + self.set_content(child.as_ref()); drop(left); } @@ -551,7 +579,6 @@ impl SessionWindow { // 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)) { diff --git a/linux/crates/app/src/ui/session_window/picker_page.rs b/linux/crates/app/src/ui/session_window/picker_page.rs index e71d85659d..d145ff56a1 100644 --- a/linux/crates/app/src/ui/session_window/picker_page.rs +++ b/linux/crates/app/src/ui/session_window/picker_page.rs @@ -11,6 +11,12 @@ mod imp { #[derive(Default, gtk::CompositeTemplate)] #[template(resource = "/app/tablepro/TablePro/ui/picker-page.ui")] pub struct PickerPage { + #[template_child] + pub toast_overlay: TemplateChild, + #[template_child] + pub header_bar: TemplateChild, + #[template_child] + pub new_connection_button: TemplateChild, #[template_child] pub connection_list_banner: TemplateChild, #[template_child] @@ -32,26 +38,42 @@ mod imp { } } - impl ObjectImpl for PickerPage {} + impl ObjectImpl for PickerPage { + fn constructed(&self) { + self.parent_constructed(); + crate::ui::a11y::describe_icon_button( + &self.new_connection_button.get(), + &crate::i18n::gettext("New Connection"), + None, + ); + } + } 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. + /// banner that says when the list could not be read. It sits outside + /// any split view, so no empty sidebar can show while disconnected. 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 { + pub fn new(welcome: &impl IsA, app: Option<>k::Application>) -> Self { let page: Self = glib::Object::new(); - page.imp().welcome_slot.set_child(Some(welcome)); + let imp = page.imp(); + imp.welcome_slot.set_child(Some(welcome)); + imp.header_bar.pack_end(&crate::ui::shell::primary_menu_button(app)); page } + pub fn toast_overlay(&self) -> adw::ToastOverlay { + self.imp().toast_overlay.get() + } + pub fn banner(&self) -> adw::Banner { self.imp().connection_list_banner.get() } diff --git a/linux/crates/app/src/ui/session_window/secret_retry.rs b/linux/crates/app/src/ui/session_window/secret_retry.rs index c65d1853fc..29d914638c 100644 --- a/linux/crates/app/src/ui/session_window/secret_retry.rs +++ b/linux/crates/app/src/ui/session_window/secret_retry.rs @@ -277,9 +277,7 @@ mod window_tests { fn failed_page(window: &SessionWindow) -> ConnectFailedPage { window - .imp() - .toast_overlay - .child() + .content() .and_then(|child| child.downcast::().ok()) .expect("the failed page") } diff --git a/linux/crates/app/src/ui/session_window/session_teardown.rs b/linux/crates/app/src/ui/session_window/session_teardown.rs index 27a21600f2..f73dd201ce 100644 --- a/linux/crates/app/src/ui/session_window/session_teardown.rs +++ b/linux/crates/app/src/ui/session_window/session_teardown.rs @@ -8,6 +8,7 @@ use gtk4 as gtk; use gtk4::glib; use gtk4::prelude::*; use gtk4::subclass::prelude::*; +use libadwaita::prelude::AdwApplicationWindowExt; use tablepro_core::EditorSession; use tablepro_session::Session; use tablepro_session::runtime::{ProcessHold, Tasks}; @@ -48,7 +49,8 @@ impl SessionWindow { drop(page); workspace.flush(); let editor_sessions = workspace.take_editor_sessions(); - imp.toast_overlay.set_child(gtk::Widget::NONE); + self.set_content(gtk::Widget::NONE); + imp.page_toasts.replace(None); self.set_title(Some(&crate::i18n::gettext("TablePro"))); workspace.shutdown(); drop(workspace); @@ -152,7 +154,7 @@ mod tests { "a step ran before the one it depends on" ); - let child = test.window.imp().toast_overlay.child(); + let child = test.window.content(); assert!( child.is_some_and(|child| child.type_().name() == "TableProPickerPage"), "the picker did not replace the workspace" diff --git a/linux/crates/app/src/ui/session_window/session_window_input.rs b/linux/crates/app/src/ui/session_window/session_window_input.rs index cfbf5eeaed..efd048cb55 100644 --- a/linux/crates/app/src/ui/session_window/session_window_input.rs +++ b/linux/crates/app/src/ui/session_window/session_window_input.rs @@ -78,9 +78,8 @@ pub(crate) enum SessionWindowInput { }, /// The located file's path is saved; connect to it. LocatedSaved(Box), - ReloadConnections, - ConnectionsLoaded(Vec), - ConnectionListUnavailable, + /// Read the connection list, which every picker follows from then on. + LoadConnections, ResetConnectionList, /// The keyring kept a deleted connection's secrets, so the entry /// was kept too. The toast's Retry runs the same delete again. diff --git a/linux/crates/app/src/ui/session_window/tests.rs b/linux/crates/app/src/ui/session_window/tests.rs index ad4270658c..e4686c8de9 100644 --- a/linux/crates/app/src/ui/session_window/tests.rs +++ b/linux/crates/app/src/ui/session_window/tests.rs @@ -51,6 +51,8 @@ pub(crate) struct Script { pub tunnel: Option, /// The connection list on disk cannot be read, so it holds nothing. pub unreadable_list: bool, + /// The size the window opens at, as the settings remember it. + pub window_size: Option<(i32, i32)>, } /// A TablePro over fakes with one window open on the picker and `saved` @@ -98,7 +100,18 @@ impl TestWindow { network, tunnel, unreadable_list, + window_size, } = script; + if let Some((width, height)) = window_size { + settings + .get() + .set_window_geometry(tablepro_storage::WindowGeometry { + width, + height, + maximized: false, + }) + .expect("store the window size"); + } let network = network || tunnel.is_some(); let saved = match tunnel { Some(tunnel) => SavedConnection { @@ -222,7 +235,7 @@ impl TestWindow { /// 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"); + WidgetExt::activate_action(&self.window, "win.new-editor-tab", None).expect("win.new-editor-tab"); wait_until(WAIT, || editors(&self.window).len() == before.len() + 1).expect("a new editor tab"); let editor = editors(&self.window) .into_iter() @@ -330,19 +343,27 @@ 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() - }); + // The tab overview's own header is libadwaita's, outside the + // workspace's breakpoint bin. + let panes = test.workspace().imp().breakpoint_bin.get(); + for root in [picker.upcast::(), panes.upcast()] { + let headers = descendants(&root) + .into_iter() + .filter(|widget| widget.is::()); let buttons: Vec = headers .flat_map(|header| descendants(&header)) - // A menu button's own toggle is labelled through the menu - // button. + // A menu button's own toggle, and the tab button's inner + // button, are labelled through their outer widget. .filter(|widget| { + let inner = widget + .parent() + .is_some_and(|parent| parent.is::() || parent.is::()); widget.is::() - || (widget.is::() - && !widget.parent().is_some_and(|parent| parent.is::())) + || widget.is::() + || (widget.is::() && !inner) + || widget + .downcast_ref::() + .is_some_and(|button| button.icon_name().is_some() && !inner) }) .collect(); assert!(!buttons.is_empty(), "no header buttons under {}", root.type_().name()); @@ -352,6 +373,40 @@ fn the_header_icon_buttons_are_labelled() { } } +fn shows_text(root: &impl IsA, text: &str) -> bool { + descendants(root) + .iter() + .filter_map(|widget| widget.downcast_ref::()) + .any(|label| label.label() == text) +} + +#[gtk4::test] +fn toast_routes_to_current_page_overlay() { + let test = TestWindow::picker("ToastRoutes"); + let picker = test.window.imp().picker.get().expect("the picker").clone(); + + test.window.show_toast("on the picker"); + drain_main_context(); + assert!(shows_text(&picker.toast_overlay(), "on the picker")); + + test.window + .send(SessionWindowInput::OpenSaved(Box::new(test.saved.clone()))); + test.wait_for_page("workspace"); + test.window.show_toast("on the workspace"); + drain_main_context(); + + let workspace = test.workspace(); + assert!(shows_text(&workspace.toast_overlay(), "on the workspace")); + assert!(!shows_text(&picker.toast_overlay(), "on the workspace")); + assert!( + workspace + .toast_overlay() + .ancestor(adw::TabOverview::static_type()) + .is_some(), + "the workspace's toasts sit above the tab overview" + ); +} + #[gtk4::test] fn the_connect_dialog_opens_once_over_its_own_window() { let test = TestWindow::picker("ConnectDialog"); @@ -482,9 +537,7 @@ fn a_failed_connect_shows_why_and_choose_connection_returns_to_the_picker() { test.wait_for_page("connect-failed"); let page = test .window - .imp() - .toast_overlay - .child() + .content() .and_then(|child| child.downcast::().ok()) .expect("the failed page"); assert!(page.message().is_some_and(|message| !message.is_empty())); @@ -605,9 +658,7 @@ fn missing_file_goes_to_connect_failed() { let message = test .window - .imp() - .toast_overlay - .child() + .content() .and_then(|child| child.downcast::().ok()) .and_then(|page| page.message()) .expect("the failed page's message"); @@ -615,6 +666,26 @@ fn missing_file_goes_to_connect_failed() { assert!(test.connector.calls().is_empty(), "a missing file was connected to"); } +/// The page reads its description as markup, where the `&` would +/// leave the reason empty. +#[gtk4::test] +fn a_reason_with_an_ampersand_shows_on_the_failed_page() { + let test = TestWindow::picker("MissingAmpersand"); + let missing = test._dir.path().join("R&D").join("gone.db"); + + test.window + .send(SessionWindowInput::OpenFile(gio::File::for_path(&missing))); + test.wait_for_page("connect-failed"); + + let message = test + .window + .content() + .and_then(|child| child.downcast::().ok()) + .and_then(|page| page.message()) + .expect("the failed page's message"); + assert!(message.contains("R&D"), "{message}"); +} + #[gtk4::test] fn a_file_whose_resolve_never_ran_goes_to_connect_failed() { let mut test = TestWindow::picker("ResolveStopped"); diff --git a/linux/crates/app/src/ui/session_window/window_actions.rs b/linux/crates/app/src/ui/session_window/window_actions.rs index ea6b1e2159..6a756372fa 100644 --- a/linux/crates/app/src/ui/session_window/window_actions.rs +++ b/linux/crates/app/src/ui/session_window/window_actions.rs @@ -1,10 +1,10 @@ -//! The window's own actions and shortcuts. +//! The window's own actions. Their keys are application accelerators, +//! from the accelerator table. //! //! 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; @@ -19,7 +19,8 @@ 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] = [ +const ACTIONS: [(&str, ActionInput); 10] = [ + ("new-connection", || SessionWindowInput::OpenConnectDialog), ("close-current", || SessionWindowInput::CloseCurrent), ("disconnect", || SessionWindowInput::Disconnect), ("reconnect", || SessionWindowInput::Reconnect), @@ -82,39 +83,6 @@ pub(super) fn sync(window: &adw::ApplicationWindow, page: &SessionPage, editable } } -/// 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::*; @@ -122,41 +90,6 @@ mod tests { 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(); @@ -186,8 +119,8 @@ mod tests { let test = TestWindow::connected("ActionAfterDrop"); let action = test .window - .lookup_action("refresh-page") - .expect("the workspace's refresh action"); + .lookup_action("reopen-closed-tab") + .expect("the workspace's reopen action"); let input = test.workspace().input().expect("the workspace's input"); test.window.send(SessionWindowInput::Disconnect); @@ -195,7 +128,7 @@ mod tests { crate::test_support::drain_main_context(); assert!( - test.window.lookup_action("refresh-page").is_none(), + test.window.lookup_action("reopen-closed-tab").is_none(), "the action outlived its workspace" ); action.activate(None); @@ -205,6 +138,25 @@ mod tests { ); } + /// An accelerator for an action nobody adds does nothing, silently. + #[gtk4::test] + fn application_accelerators_name_actions_a_connected_window_has() { + use crate::application::{ACCELERATORS, AcceleratorScope}; + let test = TestWindow::connected("AcceleratorActions"); + + for entry in ACCELERATORS + .iter() + .filter(|entry| entry.scope == AcceleratorScope::Application) + { + let found = match entry.action.split_once('.') { + Some(("win", name)) => test.window.lookup_action(name).is_some(), + Some(("app", name)) => test.app.lookup_action(name).is_some(), + _ => false, + }; + assert!(found, "{} has an accelerator and no action", entry.action); + } + } + #[gtk4::test] fn close_current_closes_a_window_with_no_workspace() { let test = TestWindow::picker("CloseCurrentPicker"); @@ -217,7 +169,7 @@ mod tests { #[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"); + WidgetExt::activate_action(&test.window, "win.new-editor-tab", None).expect("win.new-editor-tab"); 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"); diff --git a/linux/crates/app/src/ui/shell/mod.rs b/linux/crates/app/src/ui/shell/mod.rs new file mode 100644 index 0000000000..6dc6e9532e --- /dev/null +++ b/linux/crates/app/src/ui/shell/mod.rs @@ -0,0 +1,5 @@ +//! What every page of a window shares in its header bar. + +mod primary_menu_button; + +pub(crate) use primary_menu_button::{TAB_OVERVIEW_MENU, app_menu, primary_menu_button}; diff --git a/linux/crates/app/src/ui/shell/primary_menu_button.rs b/linux/crates/app/src/ui/shell/primary_menu_button.rs new file mode 100644 index 0000000000..d72e9026e3 --- /dev/null +++ b/linux/crates/app/src/ui/shell/primary_menu_button.rs @@ -0,0 +1,47 @@ +use gtk4 as gtk; +use gtk4::prelude::*; + +/// The ids of the menus in `gtk/menus.ui`. +const PRIMARY_MENU: &str = "primary_menu"; +pub(crate) const TAB_OVERVIEW_MENU: &str = "tab_overview_menu"; + +/// A menu GtkApplication read from `gtk/menus.ui` at startup. A missing +/// one is a broken build, not a state the user can fix. +pub(crate) fn app_menu(app: Option<>k::Application>, id: &str) -> Option { + let menu = app.and_then(|app| app.menu_by_id(id)); + if menu.is_none() { + tracing::error!( + id, + "the application has no such menu; is gtk/menus.ui in the resources?" + ); + } + menu.map(|menu| menu.upcast()) +} + +/// The header bar's main menu, which F10 opens. +pub(crate) fn primary_menu_button(app: Option<>k::Application>) -> gtk::MenuButton { + let button = gtk::MenuButton::builder() + .icon_name(crate::ui::icons::OPEN_MENU) + .primary(true) + .build(); + crate::ui::a11y::describe_icon_button(&button, &crate::i18n::gettext("Main Menu"), None); + match app_menu(app, PRIMARY_MENU) { + Some(menu) => button.set_menu_model(Some(&menu)), + None => button.set_sensitive(false), + } + button +} + +#[cfg(test)] +mod tests { + use super::*; + + #[gtk4::test] + fn without_its_menu_the_button_is_insensitive() { + let button = primary_menu_button(None); + + assert!(!button.is_sensitive()); + assert!(button.is_primary()); + assert_eq!(button.tooltip_text().as_deref(), Some("Main Menu")); + } +} diff --git a/linux/crates/app/src/ui/sidebar_row.rs b/linux/crates/app/src/ui/sidebar_row.rs deleted file mode 100644 index 07ea56e934..0000000000 --- a/linux/crates/app/src/ui/sidebar_row.rs +++ /dev/null @@ -1,320 +0,0 @@ -use relm4::factory::{DynamicIndex, FactoryComponent, FactorySender}; -use relm4::gtk; -use relm4::gtk::gdk; -use relm4::gtk::glib; -use relm4::gtk::pango; -use relm4::gtk::prelude::*; - -use tablepro_core::meta::ObjectInfo; - -use crate::runtime::DeliverInput; - -#[derive(Debug)] -pub struct SidebarRow { - pub info: ObjectInfo, - /// The eagerly-parented context-menu popover. Held on the model - /// so `shutdown` can `unparent()` it before the row's root widget - /// is finalized — without this, GTK warns - /// "Finalizing widget, but it still has children left" whenever - /// the sidebar rebuilds. - popover: Option, -} - -#[derive(Debug)] -pub enum SidebarRowMsg { - OpenInNewTab, - EditStructure, - ShowCreateTable, - ImportCsv, - DropTable, -} - -#[derive(Debug)] -pub enum SidebarRowOutput { - /// Ctrl+click or right-click "Open in new tab" — App always appends a - /// new tab even if the same table is already open. Plain click / - /// Enter activation does NOT flow through here; it's handled at the - /// parent ListBox via the `row-activated` signal, which is the only - /// GTK signal that fires for both mouse and keyboard activation. - OpenInNewTab { schema: Option, name: String }, - /// Right-click "Edit Structure" → opens an Edit-mode Structure tab - /// for this table. - EditStructure { schema: Option, name: String }, - /// Right-click "Show CREATE TABLE" → App synthesises the full - /// CREATE statement (columns + indexes + foreign keys) and opens - /// it in a fresh editor tab. Useful for schema export, sharing, - /// or just reading the canonical DDL without going through pg_dump. - ShowCreateTable { schema: Option, name: String }, - /// Right-click "Import CSV…" → App asks for a file and opens the - /// mapping dialog against this table's columns. - ImportCsv { schema: Option, name: String }, - /// Right-click "Drop Table…" → App presents the AdwAlertDialog - /// confirmation; on confirm runs DROP TABLE and closes any open - /// tabs for the dropped table. - DropTable { schema: Option, name: String }, -} - -#[relm4::factory(pub)] -impl FactoryComponent for SidebarRow { - type Init = ObjectInfo; - type Input = SidebarRowMsg; - type Output = SidebarRowOutput; - type CommandOutput = (); - type ParentWidget = gtk::ListBox; - - view! { - // Compact navigation row matching GNOME Files / Builder density - // (~36-40px). AdwActionRow was the wrong widget here — it's for - // settings entries (title + subtitle + suffix) and forces - // ~50px height even with single-line content. The parent - // ListBox carries the `.navigation-sidebar` style class which - // does the rest of the visual work. - gtk::ListBoxRow { - set_activatable: true, - // No connect_activate here: gtk::ListBoxRow::activate is a - // keybinding signal that fires only on Enter, not on mouse - // click. The unified handler lives on the parent ListBox - // (`row-activated`), which fires for both keyboard and mouse. - // - // Stash the table name for filter_func / sync_sidebar_selection - // / row-activated lookup. widget-name is unused for CSS in - // this app, so no styling collision risk. - set_widget_name: &self.info.name, - - #[wrap(Some)] - set_child = >k::Box { - set_orientation: gtk::Orientation::Horizontal, - // Icon-to-label spacing matches GtkPlacesSidebar's - // ~8px standard. 12 was loose enough to read as two - // separate columns rather than one labelled icon. - set_spacing: 8, - set_margin_start: 12, - set_margin_end: 12, - set_margin_top: 6, - set_margin_bottom: 6, - - gtk::Image { - set_icon_name: Some(crate::ui::icons::VIEW_LIST), - set_pixel_size: 16, - }, - - gtk::Label { - set_label: &self.info.name, - set_xalign: 0.0, - set_hexpand: true, - set_ellipsize: pango::EllipsizeMode::End, - }, - }, - } - } - - fn init_model(info: Self::Init, _index: &DynamicIndex, _sender: FactorySender) -> Self { - Self { info, popover: None } - } - - fn shutdown(&mut self, _widgets: &mut Self::Widgets, _output: relm4::Sender) { - // Eagerly-parented popovers must be unparented before the - // row is finalized — GTK warns about leftover children - // otherwise. shutdown() runs on factory removal (sidebar - // rebuild, disconnect, search filter), the natural hook. - if let Some(popover) = self.popover.take() { - popover.popdown(); - popover.unparent(); - } - } - - fn init_widgets( - &mut self, - _index: &DynamicIndex, - root: Self::Root, - _returned_widget: &::ReturnedWidget, - sender: FactorySender, - ) -> Self::Widgets { - let widgets = view_output!(); - - // Tooltip surfaces the fully-qualified name (`schema.table`) - // for multi-schema connections so the user can disambiguate - // sibling tables without a tab open. Single-schema connections - // get a plain table-name tooltip — redundant with the visible - // label, but harmless and keeps screen-reader output uniform. - let tooltip = match self.info.schema.as_deref().filter(|s| !s.is_empty()) { - Some(schema) => format!("{schema}.{}", self.info.name), - None => self.info.name.clone(), - }; - root.set_tooltip_text(Some(&tooltip)); - - // Ctrl+click → "Open in new tab". A button=1 GestureClick fires - // before the ListBoxRow's own activate signal, so we can intercept - // and short-circuit when CONTROL is held; without claiming the - // gesture, normal clicks fall through to connect_activate. - let click_gesture = gtk::GestureClick::builder().button(1).build(); - let sender_for_ctrl = sender.clone(); - click_gesture.connect_pressed(move |gesture, _, _, _| { - let state = gesture.current_event_state(); - if state.contains(gdk::ModifierType::CONTROL_MASK) { - gesture.set_state(gtk::EventSequenceState::Claimed); - sender_for_ctrl.deliver(SidebarRowMsg::OpenInNewTab); - } - }); - root.add_controller(click_gesture); - - // GtkPopoverMenu must be parented eagerly at row init. - // - // Why: PopoverMenu resolves "namespace.action" names through - // an internal GtkActionMuxer that snapshots the parent's - // action-group observation chain at set_parent() time. A - // lazy set_parent inside the gesture handler creates the - // popover in a standalone muxer scope; later insert_action_group - // calls on either the popover or the row are invisible to the - // muxer. The menu still renders (model is read directly) but - // every item-click is silently dropped because lookup finds - // no group. - // - // Defence against the "PopoverMenu destroyed while visible" - // warning that motivated the (failed) lazy refactor: a - // connect_unmap on the row pops down the menu before the - // factory finalises the widget. - let menu = gtk::gio::Menu::new(); - let open_section = gtk::gio::Menu::new(); - open_section.append( - Some(&crate::i18n::gettext("Open in new tab")), - Some("sidebar-row.open-in-new-tab"), - ); - menu.append_section(None, &open_section); - let structure_section = gtk::gio::Menu::new(); - structure_section.append( - Some(&crate::i18n::gettext("Edit Structure")), - Some("sidebar-row.edit-structure"), - ); - structure_section.append( - Some(&crate::i18n::gettext("Show CREATE TABLE")), - Some("sidebar-row.show-create-table"), - ); - menu.append_section(None, &structure_section); - let data_section = gtk::gio::Menu::new(); - data_section.append( - Some(&crate::i18n::gettext("Import CSV…")), - Some("sidebar-row.import-csv"), - ); - menu.append_section(None, &data_section); - let mutate_section = gtk::gio::Menu::new(); - mutate_section.append( - Some(&crate::i18n::gettext("Drop Table…")), - Some("sidebar-row.drop-table"), - ); - menu.append_section(None, &mutate_section); - - let popover = gtk::PopoverMenu::from_model(Some(&menu)); - popover.set_has_arrow(true); - popover.set_parent(&root); - // Stash on the model so `shutdown` can unparent it before - // the row is finalized. - self.popover = Some(popover.clone()); - - // Action group on the row (same widget the popover is - // parented to). The muxer walks up from the popover surface - // through its set_parent anchor; the row is the first widget - // in that chain that holds an action group. - 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.deliver(SidebarRowMsg::OpenInNewTab)) - .build(); - let sender_edit = sender.clone(); - let edit_action = gtk::gio::ActionEntry::builder("edit-structure") - .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.deliver(SidebarRowMsg::ShowCreateTable)) - .build(); - let sender_import = sender.clone(); - let import_action = gtk::gio::ActionEntry::builder("import-csv") - .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.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)); - - // Defence against the factory-clears-row-while-menu-is-open - // race: if the row is being removed from the view, the menu - // pops down before disposal so the popover doesn't get - // finalised mid-display. - let popover_for_unmap = popover.clone(); - root.connect_unmap(move |_| { - popover_for_unmap.popdown(); - }); - - let right_click = gtk::GestureClick::builder().button(3).build(); - let popover_for_right = popover.clone(); - right_click.connect_pressed(move |g, _, x, y| { - g.set_state(gtk::EventSequenceState::Claimed); - popover_for_right.set_pointing_to(Some(&gdk::Rectangle::new(x as i32, y as i32, 1, 1))); - popover_for_right.popup(); - }); - root.add_controller(right_click); - - // Keyboard Menu key opens the same context menu, anchored to - // the row centre (no pointer position). - let popover_for_menu = popover; - let menu_shortcut = gtk::Shortcut::builder() - .trigger(>k::KeyvalTrigger::new( - gtk::gdk::Key::Menu, - gtk::gdk::ModifierType::empty(), - )) - .action(>k::CallbackAction::new(move |_, _| { - popover_for_menu.popup(); - glib::Propagation::Stop - })) - .build(); - let shortcut_controller = gtk::ShortcutController::new(); - shortcut_controller.add_shortcut(menu_shortcut); - root.add_controller(shortcut_controller); - - widgets - } - - fn update(&mut self, msg: Self::Input, sender: FactorySender) { - tracing::trace!( - target: "tablepro_app::sidebar_row", - table = %self.info.name, - ?msg, - "input" - ); - match msg { - SidebarRowMsg::OpenInNewTab => { - let _ = sender.output(SidebarRowOutput::OpenInNewTab { - schema: self.info.schema.clone(), - name: self.info.name.clone(), - }); - } - SidebarRowMsg::EditStructure => { - let _ = sender.output(SidebarRowOutput::EditStructure { - schema: self.info.schema.clone(), - name: self.info.name.clone(), - }); - } - SidebarRowMsg::ShowCreateTable => { - let _ = sender.output(SidebarRowOutput::ShowCreateTable { - schema: self.info.schema.clone(), - name: self.info.name.clone(), - }); - } - SidebarRowMsg::ImportCsv => { - let _ = sender.output(SidebarRowOutput::ImportCsv { - schema: self.info.schema.clone(), - name: self.info.name.clone(), - }); - } - SidebarRowMsg::DropTable => { - let _ = sender.output(SidebarRowOutput::DropTable { - schema: self.info.schema.clone(), - name: self.info.name.clone(), - }); - } - } - } -} diff --git a/linux/crates/app/src/ui/structure_tab/columns.rs b/linux/crates/app/src/ui/structure_tab/columns.rs index ba4bec6607..3fec403af3 100644 --- a/linux/crates/app/src/ui/structure_tab/columns.rs +++ b/linux/crates/app/src/ui/structure_tab/columns.rs @@ -12,6 +12,7 @@ use tablepro_core::dialect::SqlDialect; use super::context::StructureContext; use super::draft_column_object::DraftColumnObject; use super::{ColumnField, StructureTabInput, StructureTabSender}; +use crate::ui::a11y::describe_icon_button; use crate::ui::error_text::refusal_text; use crate::ui::handler_guard::HandlerGuard; @@ -126,10 +127,10 @@ pub(super) fn build_column_row( // On the header, so it shows whether or not the row is expanded. let remove_button = gtk::Button::builder() .icon_name(crate::ui::icons::USER_TRASH) - .tooltip_text(crate::i18n::gettext("Remove column")) .valign(gtk::Align::Center) .build(); remove_button.add_css_class("flat"); + describe_icon_button(&remove_button, &crate::i18n::gettext("Remove Column"), None); remove_button.set_widget_name("col-remove"); gate(&remove_button, &permissions.drop); let column_for_remove = column.downgrade(); @@ -307,11 +308,11 @@ fn build_type_suggestions_button(suggestions: &'static [&'static str], target: & let button = gtk::MenuButton::builder() .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"); + describe_icon_button(&button, &crate::i18n::gettext("Suggested Types"), None); button.insert_action_group("types", Some(&action_group)); button } diff --git a/linux/crates/app/src/ui/structure_tab/fks.rs b/linux/crates/app/src/ui/structure_tab/fks.rs index e9679ec587..5e07b0e830 100644 --- a/linux/crates/app/src/ui/structure_tab/fks.rs +++ b/linux/crates/app/src/ui/structure_tab/fks.rs @@ -46,10 +46,10 @@ pub(super) fn build_fk_row( let remove_button = gtk::Button::builder() .icon_name(crate::ui::icons::USER_TRASH) - .tooltip_text(crate::i18n::gettext("Remove foreign key")) .valign(gtk::Align::Center) .build(); remove_button.add_css_class("flat"); + crate::ui::a11y::describe_icon_button(&remove_button, &crate::i18n::gettext("Remove Foreign Key"), None); remove_button.set_widget_name("fk-remove"); gate(&remove_button, removable); let key_for_remove = object.downgrade(); diff --git a/linux/crates/app/src/ui/structure_tab/imp.rs b/linux/crates/app/src/ui/structure_tab/imp.rs index bde7daad35..5c26e20be4 100644 --- a/linux/crates/app/src/ui/structure_tab/imp.rs +++ b/linux/crates/app/src/ui/structure_tab/imp.rs @@ -16,6 +16,8 @@ use super::{StructureModel, StructureTabInput}; #[derive(Default, gtk::CompositeTemplate)] #[template(resource = "/app/tablepro/TablePro/ui/structure-tab.ui")] pub struct StructureTab { + #[template_child] + pub stale_banner: TemplateChild, #[template_child] pub inner_stack: TemplateChild, #[template_child] @@ -91,6 +93,11 @@ impl ObjectSubclass for StructureTab { } impl ObjectImpl for StructureTab { + fn constructed(&self) { + self.parent_constructed(); + crate::ui::a11y::describe_icon_button(&self.copy_sql_button.get(), &crate::i18n::gettext("Copy SQL"), None); + } + fn signals() -> &'static [Signal] { static SIGNALS: OnceLock> = OnceLock::new(); SIGNALS.get_or_init(|| { @@ -104,6 +111,9 @@ impl ObjectImpl for StructureTab { .param_types([String::static_type(), String::static_type()]) .build(), Signal::builder("toast").param_types([String::static_type()]).build(), + Signal::builder("loading-changed") + .param_types([bool::static_type()]) + .build(), Signal::builder("alert") .param_types([String::static_type(), String::static_type()]) .build(), diff --git a/linux/crates/app/src/ui/structure_tab/index_dialog.rs b/linux/crates/app/src/ui/structure_tab/index_dialog.rs index 0f26c5ea01..6aba701031 100644 --- a/linux/crates/app/src/ui/structure_tab/index_dialog.rs +++ b/linux/crates/app/src/ui/structure_tab/index_dialog.rs @@ -59,13 +59,10 @@ impl IndexDialog { 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", - ))]); + crate::ui::a11y::describe_icon_button(&add_key_button, &crate::i18n::gettext("Add Key Column"), None); let keys_group = adw::PreferencesGroup::builder() .title(crate::i18n::gettext("Key Columns")) .header_suffix(&add_key_button) @@ -222,11 +219,10 @@ impl Inner { 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"))]); + crate::ui::a11y::describe_icon_button(&button, &crate::i18n::gettext("Key Options"), None); row.add_suffix(&button); let actions = gio::SimpleActionGroup::new(); diff --git a/linux/crates/app/src/ui/structure_tab/indexes.rs b/linux/crates/app/src/ui/structure_tab/indexes.rs index c68fc3d6d4..6e7e7826f4 100644 --- a/linux/crates/app/src/ui/structure_tab/indexes.rs +++ b/linux/crates/app/src/ui/structure_tab/indexes.rs @@ -40,10 +40,10 @@ pub(super) fn build_index_row( let remove_button = gtk::Button::builder() .icon_name(crate::ui::icons::USER_TRASH) - .tooltip_text(crate::i18n::gettext("Remove index")) .valign(gtk::Align::Center) .build(); remove_button.add_css_class("flat"); + crate::ui::a11y::describe_icon_button(&remove_button, &crate::i18n::gettext("Remove Index"), None); remove_button.set_widget_name("index-remove"); // The primary key's constraint owns its index. if idx.primary { diff --git a/linux/crates/app/src/ui/structure_tab/mod.rs b/linux/crates/app/src/ui/structure_tab/mod.rs index 9b1e950759..ded58e523c 100644 --- a/linux/crates/app/src/ui/structure_tab/mod.rs +++ b/linux/crates/app/src/ui/structure_tab/mod.rs @@ -33,7 +33,9 @@ //! and reads it again once the session reconnects if the last read //! 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. +//! writes the table again from the old definition refuses to save. A +//! read that fails with a definition on screen leaves it there under a +//! banner with Retry. mod columns; mod context; @@ -51,7 +53,9 @@ mod index_object; mod indexes; mod referential_action_label; pub(crate) mod review_dialog; +mod structure_actions; mod structure_command; +mod structure_fetch; mod table_choice_object; #[cfg(test)] mod tests; @@ -71,7 +75,7 @@ use tablepro_core::ddl::{DraftColumn, DraftType, TableChanges, TableDraft, diff_ use tablepro_core::meta::TableRef; use tablepro_core::{ForeignKeyInfo, IndexInfo}; use tablepro_session::runtime::{Tasks, WatchForwarder}; -use tablepro_session::{LinkGeneration, OperationError, OperationKind, Session}; +use tablepro_session::{LinkGeneration, Session}; use self::columns::build_column_row; use self::context::StructureContext; @@ -83,11 +87,13 @@ 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 self::structure_fetch::fetch_structure; use crate::runtime::{FailedRequests, RequestSequencer, RequestTicket, Stamped}; 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::tab_load::{ContentLoad, ContentPage, LoadFailure, LoadPresentation, StaleBanner}; glib::wrapper! { pub struct StructureTab(ObjectSubclass) @@ -153,21 +159,35 @@ impl StructureTab { // Saves the user clicking into the preview, selecting all and // copying every time they want the DDL in another tool. let buffer_for_copy = sql_buffer.downgrade(); + let tab_for_copy = tab.downgrade(); imp.copy_sql_button.connect_clicked(move |button| { if let Some(buffer) = buffer_for_copy.upgrade() { let text = buffer.text(&buffer.start_iter(), &buffer.end_iter(), false); - button.clipboard().set_text(text.as_str()); + crate::ui::clipboard::copy_text(button, text.to_string(), |message| { + if let Some(tab) = tab_for_copy.upgrade() { + tab.emit_output(StructureTabOutput::ShowToast(message.to_owned())); + } + }); } }); imp.name_entry.set_text(&init.table); imp.name_row.set_visible(matches!(init.mode, StructureMode::New)); - imp.drop_button.set_visible(matches!(init.mode, StructureMode::Edit)); + // A read-only connection is offered nothing that writes, as the + // sidebar's table menu offers no Drop Table on one. + let read_only = init.session.read_only(); + imp.drop_button + .set_visible(matches!(init.mode, StructureMode::Edit) && !read_only); imp.inner_stack.set_visible_child_name(match init.mode { StructureMode::New => "editor", StructureMode::Edit => "loading", }); let name_changed = tab.connect_widgets(); + let sender = tab.sender(); + let stale_banner = StaleBanner::new(imp.stale_banner.get(), move || { + sender.input(StructureTabInput::Retry); + }); + tab.install_actions(); let context = Rc::new(RefCell::new(StructureContext { mode: init.mode, @@ -221,12 +241,19 @@ impl StructureTab { save_button: imp.save_button.get(), discard_button: imp.discard_button.get(), drop_button: imp.drop_button.get(), + read_only, saving_spinner: imp.saving_spinner.get(), stop_button: imp.stop_button.get(), saving: false, last_dirty: false, refetching: false, - definition_shown: false, + load: match init.mode { + // A new table's draft is the content from the start. + StructureMode::New => ContentLoad::Loaded, + StructureMode::Edit => ContentLoad::default(), + }, + tab_loading: false, + stale_banner, definition_stale: false, _theme_handler: theme_handler, }; @@ -346,6 +373,25 @@ impl StructureTab { ) } + /// A read of the table's definition started or ended. + pub fn connect_loading_changed(&self, f: F) -> glib::SignalHandlerId { + self.connect_closure( + "loading-changed", + false, + glib::closure_local!(move |tab: &Self, loading: bool| f(tab, loading)), + ) + } + + /// A read of the table's definition is running. + pub fn is_loading(&self) -> bool { + self.imp() + .model + .try_borrow() + .ok() + .and_then(|model| model.as_ref().map(|model| model.tab_loading)) + .unwrap_or(false) + } + fn emit_output(&self, output: StructureTabOutput) { match output { StructureTabOutput::DirtyChanged(dirty) => self.emit_by_name::<()>("dirty-changed", &[&dirty]), @@ -356,6 +402,7 @@ impl StructureTab { } StructureTabOutput::ShowToast(text) => self.emit_by_name::<()>("toast", &[&text]), StructureTabOutput::ShowAlert { title, body } => self.emit_by_name::<()>("alert", &[&title, &body]), + StructureTabOutput::LoadingChanged(loading) => self.emit_by_name::<()>("loading-changed", &[&loading]), } } @@ -546,6 +593,8 @@ pub(crate) struct StructureModel { save_button: gtk::Button, discard_button: gtk::Button, drop_button: gtk::Button, + /// The connection refuses writes, so Drop is not offered. + read_only: bool, saving_spinner: adw::Spinner, stop_button: gtk::Button, /// A save of the tab is running, so Save and Discard stay off. @@ -557,9 +606,12 @@ pub(crate) struct StructureModel { /// pre-save state, so diffing them would surface phantom pending /// changes; `recompute_dirty_state` waits it out. refetching: bool, - /// 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, + /// Where the read of the definition stands. A failed read with a + /// definition on screen leaves it there under the banner. + load: ContentLoad, + /// What the tab bar was last told, so it hears only changes. + tab_loading: bool, + stale_banner: StaleBanner, /// 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. @@ -579,7 +631,7 @@ pub(crate) enum StructureTabInput { LinkChanged(LinkGeneration), /// The table's definition changed elsewhere. SchemaChanged, - /// The error page's Try Again. + /// Try Again on the error page, or Retry on the banner. Retry, Save, /// The window started saving the tab's pending structure. @@ -656,6 +708,8 @@ pub(crate) enum StructureTabOutput { title: String, body: String, }, + /// A read of the definition started or ended, for the tab bar. + LoadingChanged(bool), } impl StructureModel { @@ -808,31 +862,51 @@ impl StructureModel { self.name_entry.unblock_signal(&self.name_changed); } - /// Read the table's definition in one call, so the tab rebuilds - /// once with columns, indexes and foreign keys from the same moment. + /// Read the table's definition. A definition on screen stays there + /// while it is read, and through a failure. + fn read_definition(&mut self, fx: &mut StructureEffects) { + let presentation = self.load.begin(); + self.present(presentation, fx); + self.fetch_definition(); + } + + /// Show where the read stands: the spinner, the editor or the error + /// page, the banner over a definition that could not be read again, + /// and the tab bar's spinner. + fn present(&mut self, presentation: LoadPresentation, fx: &mut StructureEffects) { + match &presentation.page { + ContentPage::Spinner => self.inner_stack.set_visible_child_name("loading"), + ContentPage::Content => self.inner_stack.set_visible_child_name("editor"), + ContentPage::Error(failure) => { + // Inline on the status page: a modal alert would say the + // same and make the user dismiss it before seeing the + // page. The page reads its description as markup. + self.error_status + .set_description(Some(&glib::markup_escape_text(&failure.message()))); + self.inner_stack.set_visible_child_name("error"); + } + } + self.stale_banner.show(presentation.stale.as_ref()); + if self.tab_loading != presentation.tab_loading { + self.tab_loading = presentation.tab_loading; + fx.output(StructureTabOutput::LoadingChanged(presentation.tab_loading)); + } + } + + /// One read brings columns, indexes and foreign keys from the same + /// moment, so the tab rebuilds once. fn fetch_definition(&mut self) { self.issued_on .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.clone(); - let work = async move { - session - .run_cancellable(OperationKind::Metadata, &cancel, move |lease, options| { - let table = table.clone(); - async move { - let definition = lease.fetch_table_definition(&table, options).await?; - Ok(LoadedStructure { - definition, - dialect: lease.dialect(), - }) - } - }) - .await - }; + let work = fetch_structure(self.session.clone(), self.table.clone(), cancel); let tab = self.this.clone(); let _delivery = crate::runtime::spawn_and_deliver(&self.tasks, work, move |joined| { - let value = joined.unwrap_or_else(|failure| Err(OperationError::from(failure))); + let value = joined.unwrap_or_else(|failure| { + Err(LoadFailure::Definition(Arc::new( + tablepro_session::OperationError::from(failure), + ))) + }); if let Some(tab) = tab.upgrade() { tab.send(StructureTabInput::Command(StructureCommand::Loaded(Stamped { stamp, @@ -842,10 +916,6 @@ impl StructureModel { }); } - fn show_loading(&self) { - self.inner_stack.set_visible_child_name("loading"); - } - fn on_loaded(&mut self, loaded: LoadedStructure, fx: &mut StructureEffects) { let draft = TableDraft::from_definition(&loaded.definition); { @@ -855,8 +925,8 @@ impl StructureModel { } self.show_draft(draft); self.sync_add_rows(); - self.inner_stack.set_visible_child_name("editor"); - self.definition_shown = true; + let presentation = self.load.succeed(); + self.present(presentation, fx); self.definition_stale = false; // The definition is current again, so the diff counts. self.refetching = false; @@ -864,19 +934,17 @@ impl StructureModel { self.recompute_dirty_state(fx); } - fn on_load_failed(&mut self, message: &str, fx: &mut StructureEffects) { - if self.definition_shown { - self.inner_stack.set_visible_child_name("editor"); - fx.output(StructureTabOutput::ShowToast(crate::i18n::gettext_f( - "Couldn't refresh the structure: {error}", - &[("error", message)], - ))); + /// With a definition on screen it stays, under the banner. A read + /// after a committed save keeps the draft locked: the definition on + /// screen is from before the save, so the draft's diff against it + /// would offer the saved changes again. + fn on_load_failed(&mut self, failure: LoadFailure, fx: &mut StructureEffects) { + let presentation = self.load.fail(failure); + let content_kept = !matches!(presentation.page, ContentPage::Error(_)); + self.present(presentation, fx); + if content_kept { return; } - // Inline on the status page: a modal alert would say the same - // and make the user dismiss it before seeing the page. - self.error_status.set_description(Some(message)); - self.inner_stack.set_visible_child_name("error"); // A retry from the error page must not find dirty tracking still // frozen. self.refetching = false; @@ -891,11 +959,11 @@ impl StructureModel { self.failed.record(StructureRequestKind::Definition, &value); match value { Ok(loaded) => self.on_loaded(loaded, fx), - Err(error) => { - self.on_load_failed(&crate::ui::error_text::operation_error_message(&error), fx); + Err(failure) => { + self.on_load_failed(failure, fx); let issued = self.issued_on.get(&StructureRequestKind::Definition).copied(); if issued.is_some_and(|issued| self.session.link_generation() > issued) { - self.retry_failed(); + self.retry_failed(fx); } } } @@ -914,15 +982,12 @@ impl StructureModel { self.sync_add_rows(); self.recompute_dirty_state(fx); } - self.retry_failed(); + self.retry_failed(fx); } - fn retry_failed(&mut self) { + fn retry_failed(&mut self, fx: &mut StructureEffects) { if self.failed.take().contains(&StructureRequestKind::Definition) { - if !self.definition_shown { - self.show_loading(); - } - self.fetch_definition(); + self.read_definition(fx); } } @@ -934,7 +999,7 @@ impl StructureModel { match msg { StructureTabInput::Start => { if self.mode() == StructureMode::Edit { - self.fetch_definition(); + self.read_definition(fx); } self.sync_add_rows(); self.regenerate_sql_preview(&self.pending()); @@ -947,16 +1012,11 @@ impl StructureModel { if self.mode() == StructureMode::Edit { match self.last_dirty { true => self.definition_stale = true, - false => self.fetch_definition(), + false => self.read_definition(fx), } } } - StructureTabInput::Retry => { - // Without the loading page the click reads as a no-op - // until the reply arrives. - self.show_loading(); - self.fetch_definition(); - } + StructureTabInput::Retry => self.read_definition(fx), StructureTabInput::TableNameEdited(text) => { if self.table.name == text { return; @@ -1009,10 +1069,7 @@ impl StructureModel { // A table the save may have created has nothing to read // yet, so a new one is only cleared. match self.mode() { - StructureMode::Edit => { - self.show_loading(); - self.fetch_definition(); - } + StructureMode::Edit => self.read_definition(fx), StructureMode::New => fx.input(StructureTabInput::Discard), } } @@ -1033,6 +1090,10 @@ impl StructureModel { self.recompute_dirty_state(fx); } StructureTabInput::DropTableRequested => { + if self.read_only { + tracing::error!("Drop reached a structure tab on a read-only connection"); + return; + } let table = match &self.context.borrow().definition { Some(definition) => definition.table.clone(), None => self.table.clone(), @@ -1185,9 +1246,16 @@ impl StructureModel { { 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.", - ))); + // The save landed and the read after it failed, so only Retry + // unlocks the draft. + let read_failed = self.refetching && !self.saving && !self.tracker.is_saving() && !self.load.is_running(); + let text = match read_failed { + true => { + crate::i18n::gettext("The table couldn't be read after the save. Retry, then make the change again.") + } + false => crate::i18n::gettext("Wait for the save to finish, then make the change again."), + }; + fx.output(StructureTabOutput::ShowToast(text)); } fn save_completed(&mut self, new_table_name: Option, fx: &mut StructureEffects) { @@ -1196,7 +1264,7 @@ impl StructureModel { self.context.borrow_mut().mode = StructureMode::Edit; self.table.name = name; self.show_table_name(); - self.drop_button.set_visible(true); + self.drop_button.set_visible(!self.read_only); self.name_row.set_visible(false); } if self.mode() != StructureMode::Edit { @@ -1217,8 +1285,7 @@ impl StructureModel { } self.refetching = true; self.sync_editable(); - self.definition_shown = false; - self.fetch_definition(); + self.read_definition(fx); } } diff --git a/linux/crates/app/src/ui/structure_tab/structure_actions.rs b/linux/crates/app/src/ui/structure_tab/structure_actions.rs new file mode 100644 index 0000000000..e970072d72 --- /dev/null +++ b/linux/crates/app/src/ui/structure_tab/structure_actions.rs @@ -0,0 +1,25 @@ +use gtk4::gio; +use gtk4::prelude::*; +use gtk4::subclass::prelude::*; + +use super::{StructureTab, StructureTabInput}; +use crate::application::{ShortcutOwner, local_shortcut_controller}; + +impl StructureTab { + /// Ctrl+S saves as the Save button does, and only while it could. + pub(super) fn install_actions(&self) { + let imp = self.imp(); + let save = gio::SimpleAction::new("save", None); + let sender = self.sender(); + save.connect_activate(move |_, _| sender.input(StructureTabInput::Save)); + imp.save_button + .bind_property("sensitive", &save, "enabled") + .sync_create() + .build(); + let group = gio::SimpleActionGroup::new(); + group.add_action(&save); + self.insert_action_group("structure", Some(&group)); + self.add_controller(local_shortcut_controller(ShortcutOwner::StructureTab)); + crate::ui::a11y::announce_key_shortcuts(&imp.save_button.get(), "structure.save"); + } +} diff --git a/linux/crates/app/src/ui/structure_tab/structure_command.rs b/linux/crates/app/src/ui/structure_tab/structure_command.rs index 4da5eeb6bb..7abf8cabe9 100644 --- a/linux/crates/app/src/ui/structure_tab/structure_command.rs +++ b/linux/crates/app/src/ui/structure_tab/structure_command.rs @@ -2,9 +2,9 @@ use std::sync::Arc; use tablepro_core::ddl::TableDefinition; use tablepro_core::dialect::SqlDialect; -use tablepro_session::OperationError; use crate::runtime::Stamped; +use crate::ui::tab_load::LoadFailure; /// The one read a structure tab makes. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] @@ -24,5 +24,5 @@ pub(crate) struct LoadedStructure { /// reply to a replaced request is dropped. #[derive(Debug)] pub(crate) enum StructureCommand { - Loaded(Stamped>), + Loaded(Stamped>), } diff --git a/linux/crates/app/src/ui/structure_tab/structure_fetch.rs b/linux/crates/app/src/ui/structure_tab/structure_fetch.rs new file mode 100644 index 0000000000..67cea5dcc3 --- /dev/null +++ b/linux/crates/app/src/ui/structure_tab/structure_fetch.rs @@ -0,0 +1,105 @@ +use std::sync::Arc; + +use tablepro_core::meta::TableRef; +use tablepro_session::{OperationError, OperationKind, Session}; +use tokio_util::sync::CancellationToken; + +use super::LoadedStructure; +use crate::ui::tab_load::LoadFailure; + +/// The table's columns, indexes and foreign keys from one read, so the +/// tab never shows lists from different moments side by side, and any +/// part that fails fails the whole read rather than showing an empty +/// list as if the table had none. The read always asks the server: the +/// catalog keeps columns only, and a definition is edited against what +/// the table is now. +pub(crate) async fn fetch_structure( + session: Arc, + table: TableRef, + cancel: CancellationToken, +) -> Result { + let loaded = session + .run_cancellable(OperationKind::Metadata, &cancel, move |lease, options| { + let table = table.clone(); + async move { + let definition = lease.fetch_table_definition(&table, options).await?; + Ok(LoadedStructure { + definition, + dialect: lease.dialect(), + }) + } + }) + .await + .map_err(|error| { + if error != OperationError::Cancelled { + tracing::warn!(category = ?error.category(), "structure read failed"); + } + LoadFailure::Definition(Arc::new(error)) + })?; + match loaded.definition.columns.is_empty() { + true => Err(LoadFailure::NoColumns), + false => Ok(loaded), + } +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use tablepro_core::DriverError; + use tablepro_core::ddl::TableDefinition; + use tablepro_test_support::{ConnectionScript, FakeReply}; + + use super::*; + use crate::test_support::{FakeSession, finish_on_main}; + + const WAIT: Duration = Duration::from_secs(10); + + fn read(script: ConnectionScript) -> Result { + let runtime = crate::test_support::test_runtime(); + let fake = FakeSession::start(script, [], runtime.tasks()); + let running = runtime.tasks().spawn_task(fetch_structure( + fake.session.clone(), + TableRef::unqualified("users"), + CancellationToken::new(), + )); + let outcome = finish_on_main(WAIT, running).expect("the read ends"); + runtime.shutdown(); + outcome.expect("the read ran to its end") + } + + #[gtk4::test] + fn fetch_structure_fails_whole_on_foreign_key_error() { + let mut script = ConnectionScript::default(); + script + .fetch_table_definition + .always(FakeReply::err(DriverError::server("foreign keys unreadable"))); + + let failure = read(script).expect_err("a failed read"); + + assert!(matches!(failure, LoadFailure::Definition(_)), "{failure:?}"); + assert!( + failure.message().contains("foreign keys unreadable"), + "{}", + failure.message() + ); + } + + #[gtk4::test] + fn a_definition_without_columns_is_a_failure() { + let mut script = ConnectionScript::default(); + script.fetch_table_definition.always(FakeReply::ok(TableDefinition { + table: TableRef::unqualified("users"), + columns: Vec::new(), + primary_key: None, + indexes: Vec::new(), + foreign_keys: Vec::new(), + inbound_foreign_keys: Vec::new(), + source: None, + })); + + let failure = read(script).expect_err("a failed read"); + + assert!(matches!(failure, LoadFailure::NoColumns), "{failure:?}"); + } +} diff --git a/linux/crates/app/src/ui/structure_tab/tests.rs b/linux/crates/app/src/ui/structure_tab/tests.rs index 591e5475cc..e827d5095c 100644 --- a/linux/crates/app/src/ui/structure_tab/tests.rs +++ b/linux/crates/app/src/ui/structure_tab/tests.rs @@ -312,6 +312,29 @@ fn drop_asks_the_window_with_the_table_it_shows() { assert_eq!(*asked.borrow(), [(None, "users".to_owned())]); } +/// The driver refuses the DROP on a read-only connection, so the tab +/// does not offer it, as the sidebar's table menu does not. +#[gtk4::test] +fn a_read_only_connection_offers_no_drop() { + let mut script = ConnectionScript { + read_only: true, + ..ConnectionScript::default() + }; + script.fetch_table_definition.always(FakeReply::ok(definition("id"))); + let harness = Harness::open(StructureMode::Edit, script, []); + assert!(harness.fake.session.read_only()); + wait_until(WAIT, || harness.page().as_deref() == Some("editor")).expect("the structure"); + let asked = std::rc::Rc::new(std::cell::Cell::new(0)); + let sink = asked.clone(); + harness + .tab() + .connect_drop_table_requested(move |_, _, _| sink.set(sink.get() + 1)); + + assert!(!harness.tab().imp().drop_button.is_visible()); + harness.tab().send(StructureTabInput::DropTableRequested); + assert_eq!(asked.get(), 0, "the tab asked to drop the table"); +} + fn toasts(tab: &StructureTab) -> std::rc::Rc>> { let toasts = std::rc::Rc::new(std::cell::RefCell::new(Vec::new())); let sink = toasts.clone(); @@ -319,6 +342,24 @@ fn toasts(tab: &StructureTab) -> std::rc::Rc>> { toasts } +#[gtk4::test] +fn copy_sql_puts_the_statement_on_the_clipboard_and_says_so() { + let harness = Harness::open(StructureMode::New, ConnectionScript::default(), []); + let toasts = toasts(harness.tab()); + sql_buffer(harness.tab()).set_text("CREATE TABLE users (id int);"); + + harness.tab().imp().copy_sql_button.emit_clicked(); + + let text = harness + .tab() + .clipboard() + .content() + .and_then(|content| content.value(String::static_type()).ok()) + .and_then(|value| value.get::().ok()); + assert_eq!(text.as_deref(), Some("CREATE TABLE users (id int);")); + assert_eq!(*toasts.borrow(), ["Copied to clipboard"]); +} + /// The table reads once, then every read after it fails. fn readable_once() -> ConnectionScript { let mut script = ConnectionScript::default(); @@ -329,31 +370,200 @@ fn readable_once() -> ConnectionScript { script } +fn banner(harness: &Harness) -> adw::Banner { + harness.tab().imp().stale_banner.get() +} + +fn banner_tooltip(harness: &Harness) -> String { + banner(harness) + .tooltip_text() + .map(|text| text.to_string()) + .unwrap_or_default() +} + #[gtk4::test] -fn a_failed_read_after_a_schema_change_keeps_the_definition() { +fn structure_refresh_failure_keeps_editor_and_shows_banner() { let harness = Harness::open(StructureMode::Edit, readable_once(), []); wait_until(WAIT, || harness.page().as_deref() == Some("editor")).expect("the structure"); let toasts = toasts(harness.tab()); harness.tab().send(StructureTabInput::SchemaChanged); - wait_until(WAIT, || !toasts.borrow().is_empty()).expect("the failed read was not reported"); + wait_until(WAIT, || banner(&harness).is_revealed()).expect("the failed read was not shown"); assert_eq!(harness.page().as_deref(), Some("editor")); assert_eq!(harness.column_names(), ["id"]); - assert!(toasts.borrow()[0].contains("denied"), "{:?}", toasts.borrow()); + assert!( + banner_tooltip(&harness).contains("denied"), + "{}", + banner_tooltip(&harness) + ); + assert!(toasts.borrow().is_empty(), "{:?}", toasts.borrow()); + assert!(!harness.tab().is_loading()); + assert!(harness.tab().imp().columns_box.is_sensitive()); } +/// The definition on screen is from before the save, so the draft holds +/// still until a read lands: unlocked, its diff would offer the saved +/// changes again. #[gtk4::test] -fn a_failed_read_after_a_save_shows_the_error() { +fn a_failed_read_after_a_save_keeps_the_saved_draft_locked() { let harness = Harness::open(StructureMode::Edit, readable_once(), []); wait_until(WAIT, || harness.page().as_deref() == Some("editor")).expect("the structure"); harness .tab() .send(StructureTabInput::SaveCompleted { new_table_name: None }); + wait_until(WAIT, || banner(&harness).is_revealed()).expect("the failed read was not shown"); - wait_until(WAIT, || harness.page().as_deref() == Some("error")) - .expect("a definition from before the save stayed editable"); + assert_eq!(harness.page().as_deref(), Some("editor")); + assert!(!harness.tab().imp().columns_box.is_sensitive()); + assert!( + !harness.tracker().has_pending(), + "the saved changes came back as pending" + ); +} + +/// The save landed, so waiting for it is no help: the edit waits for a +/// read of the table that only Retry starts. +#[gtk4::test] +fn an_edit_after_a_failed_read_after_a_save_says_to_retry() { + let harness = Harness::open(StructureMode::Edit, readable_once(), []); + wait_until(WAIT, || harness.page().as_deref() == Some("editor")).expect("the structure"); + harness + .tab() + .send(StructureTabInput::SaveCompleted { new_table_name: None }); + wait_until(WAIT, || banner(&harness).is_revealed()).expect("the failed read was not shown"); + let toasts = toasts(harness.tab()); + + harness.tab().send(StructureTabInput::AddColumn); + + assert_eq!( + *toasts.borrow(), + ["The table couldn't be read after the save. Retry, then make the change again."] + ); + assert_eq!(harness.column_names(), ["id"]); +} + +#[gtk4::test] +fn structure_first_load_failure_shows_error_page() { + let mut script = ConnectionScript::default(); + script + .fetch_table_definition + .always(FakeReply::err(DriverError::server("no table"))); + let harness = Harness::open(StructureMode::Edit, script, []); + + wait_until(WAIT, || harness.page().as_deref() == Some("error")).expect("the error page"); + + let imp = harness.tab().imp(); + let description = imp + .error_status + .description() + .map(|text| text.to_string()) + .unwrap_or_default(); + assert!(description.contains("no <such> table"), "{description}"); + assert!(imp.retry_button.is_visible()); + assert!(!banner(&harness).is_revealed()); + assert!(!harness.tab().is_loading()); +} + +#[gtk4::test] +fn index_fetch_failure_keeps_loaded_indexes() { + let indexed = TableDefinition { + indexes: vec![tablepro_core::IndexInfo { + name: "users_id_idx".to_owned(), + keys: vec![tablepro_core::meta::IndexKey::Column { + name: "id".to_owned(), + descending: false, + }], + unique: true, + primary: false, + predicate: None, + }], + ..definition("id") + }; + let mut script = ConnectionScript::default(); + script + .fetch_table_definition + .once(FakeReply::ok(indexed)) + .always(FakeReply::err(DriverError::server("indexes unreadable"))); + let harness = Harness::open(StructureMode::Edit, script, []); + wait_until(WAIT, || harness.page().as_deref() == Some("editor")).expect("the structure"); + + harness.tab().send(StructureTabInput::SchemaChanged); + wait_until(WAIT, || banner(&harness).is_revealed()).expect("the failed read was not shown"); + + let indexes: Vec = harness.draft().indexes.into_iter().map(|index| index.name).collect(); + assert_eq!(indexes, ["users_id_idx"]); + assert_eq!(harness.page().as_deref(), Some("editor")); +} + +#[gtk4::test] +fn structure_retry_after_failure_hides_banner() { + let mut script = ConnectionScript::default(); + script + .fetch_table_definition + .once(FakeReply::ok(definition("id"))) + .once(FakeReply::err(DriverError::server("denied"))) + .always(FakeReply::ok(definition("email"))); + let harness = Harness::open(StructureMode::Edit, script, []); + wait_until(WAIT, || harness.page().as_deref() == Some("editor")).expect("the structure"); + harness.tab().send(StructureTabInput::SchemaChanged); + wait_until(WAIT, || banner(&harness).is_revealed()).expect("the stale banner"); + + banner(&harness).emit_by_name::<()>("button-clicked", &[]); + + wait_until(WAIT, || harness.column_names() == ["email"]).expect("Retry read the table again"); + assert!( + !banner(&harness).is_revealed(), + "the banner outlived the read that landed" + ); + assert_eq!( + CallCounters::get(&harness.fake.connection.counters().fetch_table_definition), + 3 + ); +} + +#[gtk4::test] +fn schema_save_shows_new_column_list() { + let mut script = ConnectionScript::default(); + script + .fetch_table_definition + .once(FakeReply::ok(TableDefinition { + columns: vec![integer_column("id", true), integer_column("name", false)], + ..definition("id") + })) + .always(FakeReply::ok(TableDefinition { + columns: vec![ + integer_column("id", true), + integer_column("name", false), + integer_column("email", false), + ], + ..definition("id") + })); + let harness = Harness::open(StructureMode::Edit, script, []); + wait_until(WAIT, || harness.page().as_deref() == Some("editor")).expect("the structure"); + let loading = Rc::new(RefCell::new(Vec::new())); + let sink = loading.clone(); + harness + .tab() + .connect_loading_changed(move |_, busy| sink.borrow_mut().push(busy)); + + harness + .tab() + .send(StructureTabInput::SaveCompleted { new_table_name: None }); + + assert_eq!( + harness.page().as_deref(), + Some("editor"), + "the read after the save blanked the tab" + ); + wait_until(WAIT, || column_rows(harness.tab()).len() == 3).expect("the columns the save left"); + assert_eq!(harness.column_names(), ["id", "name", "email"]); + assert_eq!( + CallCounters::get(&harness.fake.connection.counters().fetch_table_definition), + 2 + ); + assert_eq!(*loading.borrow(), [true, false]); } /// The control named `name` in the row the Columns list shows at @@ -1195,3 +1405,114 @@ fn add_foreign_key_from_the_tab_adds_the_key() { ); window.destroy(); } + +/// Ctrl+S saves as the Save button does, and only while the button could. +#[gtk4::test] +fn ctrl_s_saves_while_save_is_on() { + use crate::application::ShortcutOwner; + use crate::test_support::shortcut_keys::{keystroke, owner_controller, shortcuts, trigger_keys}; + let mut script = postgres_script(); + script.fetch_table_definition.always(FakeReply::ok(TableDefinition { + columns: vec![integer_column("id", true)], + ..definition("id") + })); + adw::init().expect("libadwaita initialises under the test backend"); + crate::register_test_resources(); + let runtime = crate::test_support::test_runtime(); + let fake = FakeSession::start(script, [], runtime.tasks()); + let tab = StructureTab::new(StructureTabInit { + tracker: StructureTrackerHandle::default(), + schema: None, + table: "users".to_owned(), + mode: StructureMode::Edit, + session: fake.session.clone(), + tasks: runtime.tasks(), + }); + wait_until(WAIT, || { + tab.imp().inner_stack.visible_child_name().as_deref() == Some("editor") + }) + .expect("the structure"); + let saves = std::rc::Rc::new(std::cell::Cell::new(0)); + let counter = saves.clone(); + tab.connect_save_requested(move |_| counter.set(counter.get() + 1)); + let ctrl_s = shortcuts(&owner_controller(&tab, ShortcutOwner::StructureTab).expect("the tab's keys")) + .into_iter() + .find(|shortcut| { + shortcut + .trigger() + .is_some_and(|trigger| trigger_keys(&trigger).contains(&keystroke("s"))) + }) + .and_then(|shortcut| shortcut.action()) + .expect("Ctrl+S is bound"); + let press = || ctrl_s.activate(gtk::ShortcutActionFlags::empty(), &tab, None); + + assert!(!press(), "Ctrl+S was taken with nothing to save"); + tab.send(StructureTabInput::ColumnEdited { + column: column_object(&tab, 0), + field: ColumnField::Default("42".to_owned()), + }); + assert!(press()); + + assert_eq!(saves.get(), 1); + drop(tab); + drain_main_context(); + runtime.shutdown(); +} + +#[gtk4::test] +fn icon_buttons_are_labelled_without_accelerator_hints() { + let table = TableDefinition { + table: TableRef::unqualified("users"), + columns: vec![integer_column("id", true), integer_column("team_id", false)], + primary_key: Some(PrimaryKeyConstraint { + name: Some("users_pkey".to_owned()), + columns: vec!["id".to_owned()], + }), + indexes: vec![tablepro_core::IndexInfo { + name: "users_team_idx".to_owned(), + keys: vec![tablepro_core::meta::IndexKey::Column { + name: "team_id".to_owned(), + descending: false, + }], + unique: false, + primary: false, + predicate: None, + }], + foreign_keys: vec![tablepro_core::ForeignKeyInfo { + name: "users_team_fk".to_owned(), + columns: vec!["team_id".to_owned()], + ref_schema: None, + ref_table: "teams".to_owned(), + ref_columns: vec!["id".to_owned()], + on_delete: None, + on_update: None, + }], + inbound_foreign_keys: Vec::new(), + source: None, + }; + let mut script = postgres_script(); + script.fetch_table_definition.always(FakeReply::ok(table)); + let harness = Harness::open(StructureMode::Edit, script, []); + wait_until(WAIT, || harness.page().as_deref() == Some("editor")).expect("the structure"); + drain_main_context(); + let tab = harness.tab(); + for row in descendants(tab) + .into_iter() + .filter_map(|widget| widget.downcast::().ok()) + { + row.set_expanded(true); + } + let removes = descendants(tab) + .into_iter() + .filter(|widget| ["col-remove", "index-remove", "fk-remove"].contains(&widget.widget_name().as_str())) + .count(); + let suggestions = descendants(tab) + .into_iter() + .filter(|widget| widget.is::()) + .count(); + + assert_eq!(removes, 4, "a column, index or foreign key row is missing"); + assert!(suggestions > 0, "no column offers its suggested types"); + assert_eq!(crate::test_support::unlabelled_icon_buttons(tab), Vec::::new()); + assert_eq!(crate::test_support::tooltips_with_keys(tab), Vec::::new()); +} diff --git a/linux/crates/app/src/ui/tab_load/content_load.rs b/linux/crates/app/src/ui/tab_load/content_load.rs new file mode 100644 index 0000000000..29713b07c6 --- /dev/null +++ b/linux/crates/app/src/ui/tab_load/content_load.rs @@ -0,0 +1,227 @@ +use super::{ContentPage, LoadFailure, LoadPresentation}; + +/// Where a tab's read of its content stands. +/// +/// Content on screen stays there while it is read again, and stays when +/// that read fails, under a banner until a read lands. Only a tab with +/// nothing to show shows the spinner or the error page. +#[derive(Debug, Clone, Default)] +pub(crate) enum ContentLoad { + #[default] + Initial, + Loading, + Loaded, + /// Reading the content on screen again. `stale` is the failure the + /// banner keeps showing until this read lands. + Refreshing { + stale: Option, + }, + /// Reading other content than the one on screen, which stays until + /// the new content lands but is not kept if the read fails: it is no + /// longer what the tab was asked for. + Replacing, + Stale(LoadFailure), + Failed(LoadFailure), +} + +impl ContentLoad { + /// A read of the content on screen, or of the first content. + pub(crate) fn begin(&mut self) -> LoadPresentation { + *self = match std::mem::take(self) { + Self::Initial | Self::Loading | Self::Failed(_) => Self::Loading, + Self::Loaded => Self::Refreshing { stale: None }, + Self::Stale(failure) => Self::Refreshing { stale: Some(failure) }, + running @ (Self::Refreshing { .. } | Self::Replacing) => running, + }; + self.presentation() + } + + /// A read of other content than the one on screen: another page, or + /// the same table under another filter. + pub(crate) fn begin_replacing(&mut self) -> LoadPresentation { + *self = match std::mem::take(self) { + Self::Initial | Self::Loading | Self::Failed(_) => Self::Loading, + Self::Loaded | Self::Stale(_) | Self::Refreshing { .. } | Self::Replacing => Self::Replacing, + }; + self.presentation() + } + + pub(crate) fn succeed(&mut self) -> LoadPresentation { + *self = Self::Loaded; + self.presentation() + } + + pub(crate) fn fail(&mut self, failure: LoadFailure) -> LoadPresentation { + *self = match std::mem::take(self) { + Self::Loaded | Self::Stale(_) | Self::Refreshing { .. } => Self::Stale(failure), + Self::Initial | Self::Loading | Self::Replacing | Self::Failed(_) => Self::Failed(failure), + }; + self.presentation() + } + + /// The read stopped before it asked for anything, so what was on + /// screen before it stands as it was. + pub(crate) fn abandon(&mut self) -> LoadPresentation { + *self = match std::mem::take(self) { + Self::Loading => Self::Initial, + Self::Refreshing { stale: None } | Self::Replacing => Self::Loaded, + Self::Refreshing { stale: Some(failure) } => Self::Stale(failure), + settled => settled, + }; + self.presentation() + } + + /// The content on screen no longer fits what the tab reads, so the + /// read running goes on with nothing shown. + pub(crate) fn drop_content(&mut self) -> LoadPresentation { + *self = match std::mem::take(self) { + Self::Loading | Self::Refreshing { .. } | Self::Replacing => Self::Loading, + Self::Loaded | Self::Stale(_) => Self::Initial, + settled => settled, + }; + self.presentation() + } + + pub(crate) fn has_content(&self) -> bool { + matches!( + self, + Self::Loaded | Self::Refreshing { .. } | Self::Replacing | Self::Stale(_) + ) + } + + pub(crate) fn is_running(&self) -> bool { + matches!(self, Self::Loading | Self::Refreshing { .. } | Self::Replacing) + } + + pub(crate) fn presentation(&self) -> LoadPresentation { + let (page, stale) = match self { + Self::Initial | Self::Loading => (ContentPage::Spinner, None), + Self::Loaded | Self::Replacing => (ContentPage::Content, None), + Self::Refreshing { stale } => (ContentPage::Content, stale.clone()), + Self::Stale(failure) => (ContentPage::Content, Some(failure.clone())), + Self::Failed(failure) => (ContentPage::Error(failure.clone()), None), + }; + LoadPresentation { + page, + tab_loading: self.is_running(), + stale, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[derive(Debug, PartialEq)] + enum Shown { + Spinner, + Content, + Error, + } + + /// The page, whether the tab spins, and whether the banner shows. + fn shown(presentation: &LoadPresentation) -> (Shown, bool, bool) { + let page = match presentation.page { + ContentPage::Spinner => Shown::Spinner, + ContentPage::Content => Shown::Content, + ContentPage::Error(_) => Shown::Error, + }; + (page, presentation.tab_loading, presentation.stale.is_some()) + } + + type Step = fn(&mut ContentLoad) -> LoadPresentation; + /// The steps from a new tab, and the page, spinner and banner after. + type Case = (&'static [Step], (Shown, bool, bool)); + + fn after(steps: &[Step]) -> (ContentLoad, LoadPresentation) { + let mut load = ContentLoad::default(); + let mut last = load.presentation(); + for step in steps { + last = step(&mut load); + } + (load, last) + } + + fn begin(load: &mut ContentLoad) -> LoadPresentation { + load.begin() + } + + fn replace(load: &mut ContentLoad) -> LoadPresentation { + load.begin_replacing() + } + + fn succeed(load: &mut ContentLoad) -> LoadPresentation { + load.succeed() + } + + fn failure() -> LoadFailure { + LoadFailure::Page(std::sync::Arc::new(tablepro_session::OperationError::Closed)) + } + + fn fail(load: &mut ContentLoad) -> LoadPresentation { + load.fail(failure()) + } + + fn abandon(load: &mut ContentLoad) -> LoadPresentation { + load.abandon() + } + + fn drop_content(load: &mut ContentLoad) -> LoadPresentation { + load.drop_content() + } + + #[test] + fn content_load_transition_table() { + let table: &[Case] = &[ + (&[], (Shown::Spinner, false, false)), + (&[begin], (Shown::Spinner, true, false)), + (&[begin, fail, begin], (Shown::Spinner, true, false)), + (&[begin, succeed, begin], (Shown::Content, true, false)), + (&[begin, succeed, begin, fail, begin], (Shown::Content, true, true)), + (&[begin, fail], (Shown::Error, false, false)), + (&[begin, succeed, begin, fail], (Shown::Content, false, true)), + (&[begin, succeed], (Shown::Content, false, false)), + ( + &[begin, succeed, begin, fail, begin, succeed], + (Shown::Content, false, false), + ), + (&[begin, fail, begin, succeed], (Shown::Content, false, false)), + (&[begin, succeed, replace], (Shown::Content, true, false)), + (&[begin, succeed, replace, fail], (Shown::Error, false, false)), + (&[begin, succeed, begin, fail, replace], (Shown::Content, true, false)), + (&[begin, succeed, begin, abandon], (Shown::Content, false, false)), + ( + &[begin, succeed, begin, fail, begin, abandon], + (Shown::Content, false, true), + ), + (&[begin, abandon], (Shown::Spinner, false, false)), + (&[begin, succeed, begin, drop_content], (Shown::Spinner, true, false)), + ( + &[begin, succeed, begin, drop_content, fail], + (Shown::Error, false, false), + ), + ]; + for (steps, expected) in table { + let (_, presentation) = after(steps); + assert_eq!(&shown(&presentation), expected, "after {} steps", steps.len()); + } + } + + #[test] + fn a_refresh_that_fails_again_shows_the_newest_failure() { + let mut load = ContentLoad::default(); + load.begin(); + load.succeed(); + load.begin(); + load.fail(failure()); + load.begin(); + + let presentation = load.fail(LoadFailure::Columns(std::sync::Arc::new( + tablepro_session::OperationError::Cancelled, + ))); + + assert!(matches!(presentation.stale, Some(LoadFailure::Columns(_)))); + assert!(load.has_content()); + } +} diff --git a/linux/crates/app/src/ui/tab_load/load_failure.rs b/linux/crates/app/src/ui/tab_load/load_failure.rs new file mode 100644 index 0000000000..17f743ccad --- /dev/null +++ b/linux/crates/app/src/ui/tab_load/load_failure.rs @@ -0,0 +1,88 @@ +use std::sync::Arc; + +use tablepro_core::ErrorCategory; +use tablepro_session::OperationError; + +/// Why a tab's read showed nothing new. +#[derive(Debug, Clone)] +pub(crate) enum LoadFailure { + /// The table's columns, or what tells its rows apart. + Columns(Arc), + /// The catalog lists no column the account can read. + NoColumns, + /// The page came back with other columns than the ones read for it, + /// and again after they were read again. + ColumnsChanged, + Page(Arc), + /// The table's definition, for the structure tab. + Definition(Arc), +} + +impl LoadFailure { + /// Plain text: a server's message can hold `<` and `&`, so whatever + /// shows it must not read it as markup. + pub(crate) fn message(&self) -> String { + match self { + Self::Columns(error) | Self::Page(error) | Self::Definition(error) => { + crate::ui::error_text::operation_error_message(error) + } + Self::NoColumns => crate::i18n::gettext( + "This table has no columns you can read. It may have been dropped, or your account can't read it.", + ), + Self::ColumnsChanged => crate::i18n::gettext( + "The table's columns changed while its rows were read. Try again to read it as it is now.", + ), + } + } + + /// What kind of problem it was, for the log, which never takes the + /// server's text. + pub(crate) fn category(&self) -> Option { + match self { + Self::Columns(error) | Self::Page(error) | Self::Definition(error) => error.category(), + Self::NoColumns | Self::ColumnsChanged => None, + } + } +} + +#[cfg(test)] +mod tests { + use tablepro_core::{DriverError, LossPhase, TimeoutPhase}; + + use super::*; + + #[test] + fn load_failure_message_uses_error_category() { + let lost = LoadFailure::Page(Arc::new(OperationError::Driver(DriverError::ConnectionLost { + during: LossPhase::Statement, + }))); + let timed_out = LoadFailure::Columns(Arc::new(OperationError::Driver(DriverError::Timeout { + phase: TimeoutPhase::Statement, + server_cancelled: true, + }))); + + assert_eq!(lost.message(), "The connection was closed. Try reconnecting."); + assert_eq!( + timed_out.message(), + "The statement ran past its time limit, so the server stopped it." + ); + } + + #[test] + fn no_columns_failure_message() { + assert_eq!( + LoadFailure::NoColumns.message(), + "This table has no columns you can read. It may have been dropped, or your account can't read it." + ); + assert_eq!(LoadFailure::NoColumns.category(), None); + } + + #[test] + fn a_server_message_stays_plain_text() { + let failure = LoadFailure::Page(Arc::new(OperationError::Driver(DriverError::server( + "relation users & co", + )))); + + assert!(failure.message().contains("users & co"), "{}", failure.message()); + } +} diff --git a/linux/crates/app/src/ui/tab_load/load_presentation.rs b/linux/crates/app/src/ui/tab_load/load_presentation.rs new file mode 100644 index 0000000000..bbd14835f5 --- /dev/null +++ b/linux/crates/app/src/ui/tab_load/load_presentation.rs @@ -0,0 +1,18 @@ +use super::LoadFailure; + +/// What a tab shows for where its read stands. +#[derive(Debug, Clone)] +pub(crate) struct LoadPresentation { + pub page: ContentPage, + /// The tab's page in the tab bar spins while a read runs. + pub tab_loading: bool, + /// The read that failed while the content stayed on screen. + pub stale: Option, +} + +#[derive(Debug, Clone)] +pub(crate) enum ContentPage { + Spinner, + Content, + Error(LoadFailure), +} diff --git a/linux/crates/app/src/ui/tab_load/mod.rs b/linux/crates/app/src/ui/tab_load/mod.rs new file mode 100644 index 0000000000..ce95019793 --- /dev/null +++ b/linux/crates/app/src/ui/tab_load/mod.rs @@ -0,0 +1,14 @@ +//! How a tab shows what it reads: a spinner until the first read lands, +//! the content from then on, and an error page only while there is +//! nothing to show. A read that fails with content on screen leaves the +//! content there under a banner, so a blip never blanks a tab. + +mod content_load; +mod load_failure; +mod load_presentation; +mod stale_banner; + +pub(crate) use self::content_load::ContentLoad; +pub(crate) use self::load_failure::LoadFailure; +pub(crate) use self::load_presentation::{ContentPage, LoadPresentation}; +pub(crate) use self::stale_banner::StaleBanner; diff --git a/linux/crates/app/src/ui/tab_load/stale_banner.rs b/linux/crates/app/src/ui/tab_load/stale_banner.rs new file mode 100644 index 0000000000..21a97459c0 --- /dev/null +++ b/linux/crates/app/src/ui/tab_load/stale_banner.rs @@ -0,0 +1,30 @@ +use gtk4 as gtk; +use gtk4::prelude::*; +use libadwaita as adw; + +use super::LoadFailure; + +/// The banner over content a tab could not read again. Its title is the +/// tab's own, from its template; the failure goes in the tooltip, so the +/// banner stays one line. +pub(crate) struct StaleBanner { + banner: adw::Banner, +} + +impl StaleBanner { + pub(crate) fn new(banner: adw::Banner, retry: impl Fn() + 'static) -> Self { + banner.set_use_markup(false); + banner.set_button_label(Some(&crate::i18n::gettext("Retry"))); + banner.connect_button_clicked(move |_| retry()); + Self { banner } + } + + pub(crate) fn show(&self, stale: Option<&LoadFailure>) { + let message = stale.map(LoadFailure::message); + self.banner.set_tooltip_text(message.as_deref()); + self.banner + .upcast_ref::() + .update_property(&[gtk::accessible::Property::Description(message.as_deref().unwrap_or(""))]); + self.banner.set_revealed(stale.is_some()); + } +} diff --git a/linux/crates/app/src/ui/welcome_view.rs b/linux/crates/app/src/ui/welcome_view.rs index 222dfbc090..3ab0fb8a4a 100644 --- a/linux/crates/app/src/ui/welcome_view.rs +++ b/linux/crates/app/src/ui/welcome_view.rs @@ -1,288 +1,223 @@ -use relm4::adw::prelude::*; -use relm4::factory::FactoryVecDeque; -use relm4::prelude::*; -use relm4::{adw, gtk}; - +//! The picker's saved connections: a preferences page bound to the list +//! every window shares, with a group for the connections in no group +//! and one for each group the user made. + +use std::cell::{OnceCell, RefCell}; +use std::rc::Rc; + +use gtk4 as gtk; +use gtk4::glib; +use gtk4::prelude::*; +use gtk4::subclass::prelude::*; +use libadwaita as adw; +use libadwaita::prelude::*; 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, - factory: FactoryVecDeque, - /// The factory's own list box, kept so the group headers can be - /// reinstalled whenever the list changes. - listbox: gtk::ListBox, - stack: gtk::Stack, - tasks: Option, -} +use tablepro_storage::ConnectionListState; -#[derive(Debug)] -pub enum WelcomeViewInput { - SetConnections(Vec), - OpenConnect, - OpenSaved(SavedConnection), - Delete(Uuid), - Duplicate(SavedConnection), - SetColor(Uuid, Option), - SetGroup(Uuid, Option), -} +use crate::services::saved_connection_list::{SavedConnectionList, StateObserverId}; +use crate::services::saved_connection_object::SavedConnectionObject; +use crate::ui::picker::connection_row; -#[derive(Debug)] -pub enum WelcomeViewOutput { - OpenConnect, - OpenSaved(SavedConnection), - Delete(Uuid), - Duplicate(SavedConnection), - SetColor(Uuid, Option), - SetGroup(Uuid, Option), -} +mod imp { + use super::*; + use libadwaita::subclass::prelude::*; + + #[derive(Default, gtk::CompositeTemplate)] + #[template(resource = "/app/tablepro/TablePro/ui/welcome-view.ui")] + pub struct WelcomeView { + #[template_child] + pub stack: TemplateChild, + #[template_child] + pub page: TemplateChild, + #[template_child] + pub ungrouped: TemplateChild, + pub(super) parts: OnceCell, + } -#[derive(Debug, Default)] -pub struct WelcomeViewInit { - /// Reads what the rows show that is on disk. - pub tasks: Option, -} + #[glib::object_subclass] + impl ObjectSubclass for WelcomeView { + const NAME: &'static str = "TableProWelcomeView"; + type Type = super::WelcomeView; + type ParentType = adw::Bin; -impl SimpleComponent for WelcomeView { - type Init = WelcomeViewInit; - type Input = WelcomeViewInput; - type Output = WelcomeViewOutput; - type Root = gtk::Stack; - type Widgets = (); + fn class_init(klass: &mut Self::Class) { + klass.bind_template(); + } - fn init_root() -> Self::Root { - gtk::Stack::builder().build() + fn instance_init(object: &glib::subclass::InitializingObject) { + object.init_template(); + } } - 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"]) - .build(); - let factory: FactoryVecDeque = - FactoryVecDeque::builder() - .launch(listbox.clone()) - .forward(sender.input_sender(), |out| match out { - ConnectionRowOutput::Open(saved) => WelcomeViewInput::OpenSaved(saved), - ConnectionRowOutput::Delete(id) => WelcomeViewInput::Delete(id), - ConnectionRowOutput::Duplicate(saved) => WelcomeViewInput::Duplicate(saved), - ConnectionRowOutput::SetColor(id, color) => WelcomeViewInput::SetColor(id, color), - ConnectionRowOutput::SetGroup(id, group) => WelcomeViewInput::SetGroup(id, group), - }); - - // Empty page — no saved connections yet. GNOME convention is - // state / instruction / action — title states the situation, - // description tells the user what to do, the button restates - // the action with verb-first phrasing (matches Settings's - // "No printers found" / "Add a printer to begin." / "Add - // Printer" pattern). - let empty_page = adw::StatusPage::builder() - .icon_name(crate::ui::icons::NETWORK_SERVER) - .title(crate::i18n::gettext("No connections yet")) - .description(crate::i18n::gettext("Add a database connection to get started.")) - .build(); - let empty_btn = gtk::Button::builder() - .label(crate::i18n::gettext("Add Connection")) - .halign(gtk::Align::Center) - .build(); - 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.deliver(WelcomeViewInput::OpenConnect)); - empty_page.set_child(Some(&empty_btn)); - root.add_named(&empty_page, Some("empty")); - - // Populated page — saved connections list. AdwClamp is the - // GNOME pattern for "constrain reading width to a sensible - // max in a scrollable area"; it centres + caps width without - // the manual `gtk::Box` halign/margin gymnastics. - let scroller = gtk::ScrolledWindow::builder() - .hexpand(true) - .vexpand(true) - .hscrollbar_policy(gtk::PolicyType::Never) - .build(); - let clamp = adw::Clamp::builder().maximum_size(560).build(); - let outer = gtk::Box::builder() - .orientation(gtk::Orientation::Vertical) - .spacing(12) - .margin_top(24) - .margin_bottom(24) - .margin_start(12) - .margin_end(12) - .build(); + impl ObjectImpl for WelcomeView { + fn dispose(&self) { + self.obj().shutdown(); + } + } - // Single CTA on the populated page: the "+" button in the - // group header. Previously we also rendered a bottom pill - // labelled "New connection", which duplicated the affordance — - // ambiguity at different visual weights. Empty-page pill - // stays (it's the only CTA there); on this page the header - // suffix is sufficient. - let group = adw::PreferencesGroup::builder() - .title(crate::i18n::gettext("Saved connections")) - .build(); - let header_btn = gtk::Button::builder() - .icon_name(crate::ui::icons::LIST_ADD) - .tooltip_text(crate::i18n::gettext("Add Connection")) - .valign(gtk::Align::Center) - .build(); - header_btn.add_css_class("flat"); - let s_header = sender.clone(); - header_btn.connect_clicked(move |_| s_header.deliver(WelcomeViewInput::OpenConnect)); - group.set_header_suffix(Some(&header_btn)); - group.add(factory.widget()); - outer.append(&group); - clamp.set_child(Some(&outer)); + impl WidgetImpl for WelcomeView {} + impl BinImpl for WelcomeView {} +} - scroller.set_child(Some(&clamp)); - root.add_named(&scroller, Some("populated")); - root.set_visible_child_name("empty"); +glib::wrapper! { + pub struct WelcomeView(ObjectSubclass) + @extends adw::Bin, gtk::Widget, + @implements gtk::Accessible, gtk::Buildable, gtk::ConstraintTarget; +} - let model = WelcomeView { - connections: Vec::new(), - factory, - listbox, - stack: root.clone(), - tasks: init.tasks, - }; - ComponentParts { model, widgets: () } - } +pub(super) struct Parts { + list: Rc, + tasks: Option, + /// The groups after the ungrouped one, by name, in list order. + groups: RefCell>, + rows_changed: RefCell>, + state_changed: RefCell>, +} - fn update(&mut self, msg: Self::Input, sender: ComponentSender) { - match msg { - WelcomeViewInput::SetConnections(connections) => { - self.connections = connections; - connection_list::sort(&mut self.connections); - let groups = connection_list::groups(&self.connections); - let mut guard = self.factory.guard(); - guard.clear(); - for saved in &self.connections { - guard.push_back(ConnectionRowInit { - saved: saved.clone(), - groups: groups.clone(), - tasks: self.tasks.clone(), - }); - } - drop(guard); - connection_list::install_group_headers(&self.listbox, std::rc::Rc::new(self.connections.clone())); - let name = if self.connections.is_empty() { - "empty" - } else { - "populated" - }; - self.stack.set_visible_child_name(name); - } - WelcomeViewInput::OpenConnect => { - let _ = sender.output(WelcomeViewOutput::OpenConnect); - } - WelcomeViewInput::OpenSaved(saved) => { - let _ = sender.output(WelcomeViewOutput::OpenSaved(saved)); +impl WelcomeView { + /// A view of `list`. `tasks` reads the file a portal path stands for. + pub(crate) fn new(list: Rc, tasks: Option) -> Self { + let view: Self = glib::Object::new(); + let imp = view.imp(); + let _ = imp.parts.set(Parts { + list: list.clone(), + tasks, + groups: RefCell::new(Vec::new()), + rows_changed: RefCell::new(None), + state_changed: RefCell::new(None), + }); + let ungrouped = view.filtered(None); + let group = imp.ungrouped.get(); + group.set_visible(ungrouped.n_items() > 0); + let shown = group.downgrade(); + ungrouped.connect_items_changed(move |model, _, _, _| { + if let Some(group) = shown.upgrade() { + group.set_visible(model.n_items() > 0); } - WelcomeViewInput::Delete(id) => { - let _ = sender.output(WelcomeViewOutput::Delete(id)); + }); + view.bind(&group, &ungrouped); + let weak = view.downgrade(); + let rows_changed = list.model().connect_items_changed(move |_, _, _, _| { + if let Some(view) = weak.upgrade() { + view.refresh(); } - WelcomeViewInput::Duplicate(saved) => { - let _ = sender.output(WelcomeViewOutput::Duplicate(saved)); - } - WelcomeViewInput::SetColor(id, color) => { - let _ = sender.output(WelcomeViewOutput::SetColor(id, color)); - } - WelcomeViewInput::SetGroup(id, group) => { - let _ = sender.output(WelcomeViewOutput::SetGroup(id, group)); + }); + let weak = view.downgrade(); + let state_changed = list.connect_state_changed(move |_| { + if let Some(view) = weak.upgrade() { + view.refresh_page(); } + }); + if let Some(parts) = imp.parts.get() { + parts.rows_changed.replace(Some(rows_changed)); + parts.state_changed.replace(Some(state_changed)); } + view.refresh(); + view } -} -#[cfg(test)] -mod tests { - use super::*; - - fn connection(name: &str, group: Option<&str>) -> SavedConnection { - SavedConnection { - id: Uuid::new_v4(), - name: name.to_owned(), - driver_id: "postgres".to_owned(), - host: "db".to_owned(), - port: 5432, - database: "app".to_owned(), - username: "postgres".to_owned(), - tls: tablepro_core::TlsConfig::verify_full(), - read_only: false, - auth_mode: tablepro_core::AuthMode::Password, - ssh: None, - last_opened_at: None, - color: None, - group: group.map(str::to_owned), + /// Stop following the list, which outlives every window. + pub(crate) fn shutdown(&self) { + let Some(parts) = self.imp().parts.get() else { + return; + }; + if let Some(handler) = parts.rows_changed.take() { + parts.list.model().disconnect(handler); + } + if let Some(observer) = parts.state_changed.take() { + parts.list.disconnect_state_changed(observer); } } - /// The header text on each row, in list order, with `None` where a - /// row carries no header. - fn headers(view: &WelcomeView) -> Vec> { - (0..) - .map_while(|index| view.listbox.row_at_index(index)) - .map(|row| { - row.header() - .and_then(|widget| widget.downcast::().ok()) - .map(|label| label.label().to_string()) - }) - .collect() + /// The rows of the connections in `group`, or in none. + fn filtered(&self, group: Option<&str>) -> gtk::FilterListModel { + let list = self.imp().parts.get().map(|parts| parts.list.model()); + let group = group.map(str::to_owned); + let filter = gtk::CustomFilter::new(move |item| { + item.downcast_ref::() + .is_some_and(|object| object.group() == group) + }); + gtk::FilterListModel::new(list, Some(filter)) } - #[gtk4::test] - fn a_grouped_list_carries_one_header_per_group() { - let view = WelcomeView::builder().launch(WelcomeViewInit::default()); - view.sender() - .send(WelcomeViewInput::SetConnections(vec![ - connection("loose", None), - connection("a", Some("Work")), - connection("b", Some("Work")), - connection("c", Some("Zoo")), - ])) - .expect("send"); - - crate::test_support::drain_main_context(); - - assert_eq!( - headers(&view.model()), - vec![None, Some("Work".to_owned()), None, Some("Zoo".to_owned())] - ); + fn bind(&self, group: &adw::PreferencesGroup, rows: >k::FilterListModel) { + let Some(parts) = self.imp().parts.get() else { + return; + }; + let (list, tasks) = (parts.list.clone(), parts.tasks.clone()); + group.bind_model(Some(rows), move |object| { + connection_row(object, &list.groups(), tasks.as_ref()) + }); } - #[gtk4::test] - fn an_ungrouped_list_carries_no_headers_at_all() { - let view = WelcomeView::builder().launch(WelcomeViewInit::default()); - view.sender() - .send(WelcomeViewInput::SetConnections(vec![ - connection("a", None), - connection("b", None), - ])) - .expect("send"); - - crate::test_support::drain_main_context(); + fn refresh(&self) { + self.refresh_groups(); + self.refresh_page(); + } - assert_eq!(headers(&view.model()), vec![None, None]); + /// A group of its own for each group the list has, after the + /// connections in none, rebuilt only when the set of groups changes. + fn refresh_groups(&self) { + let imp = self.imp(); + let Some(parts) = imp.parts.get() else { + return; + }; + let names = parts.list.groups(); + let unchanged = parts.groups.borrow().iter().map(|(name, _)| name).eq(names.iter()); + if unchanged { + return; + } + for (_, group) in parts.groups.take() { + imp.page.remove(&group); + } + let mut built = Vec::with_capacity(names.len()); + for name in names { + // A group's title is markup, and the name is the user's text. + let group = adw::PreferencesGroup::builder() + .title(glib::markup_escape_text(&name).as_str()) + .build(); + self.bind(&group, &self.filtered(Some(&name))); + imp.page.add(&group); + built.push((name, group)); + } + parts.groups.replace(built); } - #[gtk4::test] - fn taking_the_last_connection_out_of_a_group_takes_its_header_with_it() { - let view = WelcomeView::builder().launch(WelcomeViewInit::default()); - view.sender() - .send(WelcomeViewInput::SetConnections(vec![connection("a", Some("Work"))])) - .expect("send"); - crate::test_support::drain_main_context(); - assert_eq!(headers(&view.model()), vec![Some("Work".to_owned())]); + /// The empty page only once the list is read and holds nothing. A + /// list still loading, or one that cannot be read, shows no promise + /// to add to it; the banner says why. + fn refresh_page(&self) { + let imp = self.imp(); + let Some(parts) = imp.parts.get() else { + return; + }; + let empty = parts.list.model().n_items() == 0 && matches!(parts.list.state(), ConnectionListState::Ready(_)); + imp.stack.set_visible_child_name(if empty { "empty" } else { "list" }); + } - view.sender() - .send(WelcomeViewInput::SetConnections(vec![connection("a", None)])) - .expect("send"); - crate::test_support::drain_main_context(); + #[cfg(test)] + pub(crate) fn sections(&self) -> Vec<(String, Vec)> { + let imp = self.imp(); + let mut groups = vec![imp.ungrouped.get()]; + if let Some(parts) = imp.parts.get() { + groups.extend(parts.groups.borrow().iter().map(|(_, group)| group.clone())); + } + groups + .into_iter() + .filter(|group| group.is_visible()) + .map(|group| { + let rows = crate::test_support::descendants(&group) + .into_iter() + .filter_map(|widget| widget.downcast::().ok()) + .map(|row| row.title().to_string()) + .collect(); + (group.title().to_string(), rows) + }) + .collect() + } - assert_eq!(headers(&view.model()), vec![None]); + #[cfg(test)] + pub(crate) fn visible_page(&self) -> Option { + self.imp().stack.visible_child_name().map(|name| name.to_string()) } } diff --git a/linux/crates/app/src/ui/workspace/close_flow.rs b/linux/crates/app/src/ui/workspace/close_flow.rs index 558d57fb2f..298b723f56 100644 --- a/linux/crates/app/src/ui/workspace/close_flow.rs +++ b/linux/crates/app/src/ui/workspace/close_flow.rs @@ -1,3 +1,4 @@ +use std::collections::HashSet; use std::sync::Arc; use gtk4::glib; @@ -5,6 +6,7 @@ use gtk4::prelude::*; use tablepro_core::EditorSession; use uuid::Uuid; +use super::unsaved_icon::apply_unsaved_icon; use super::workspace_persistence::WorkspaceTabs; use super::{WorkspaceInput, WorkspaceModel, WorkspaceTab}; use crate::runtime::DeliverInput; @@ -62,9 +64,7 @@ impl WorkspaceModel { /// 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; - }; + let tab_view = self.tab_view.clone(); if self.close_guards.may_close_tab(&self.workspace_handle, id) { self.finish_close_workspace_tab(id, &tab_view); return; @@ -83,9 +83,7 @@ impl WorkspaceModel { } pub(super) fn on_tab_close_decided(&mut self, id: Uuid, approved: bool) { - let Some(tab_view) = self.workspace_tab_view.clone() else { - return; - }; + let tab_view = self.tab_view.clone(); if approved { self.finish_close_workspace_tab(id, &tab_view); return; @@ -96,10 +94,14 @@ impl WorkspaceModel { } } - /// 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(); + /// Mark each tab that closing would lose something in, and tell the + /// window whether a logout would, so it holds the session back. + pub(super) fn refresh_unsaved(&self) { + let unsaved: HashSet = self.workspace_handle.dirty_tabs().iter().map(|tab| tab.id).collect(); + for (id, tab) in self.workspace_tabs.borrow().iter() { + apply_unsaved_icon(tab.page(), unsaved.contains(id)); + } + let dirty = !unsaved.is_empty(); if self.reported_dirty.replace(Some(dirty)) == Some(dirty) { return; } @@ -126,8 +128,9 @@ mod tests { use crate::services::active_session::ActiveSession; use crate::test_support::{FakeSession, MemorySettings, wait_until}; use crate::ui::editor::{SqlEditor, SqlEditorInit}; + use crate::ui::workspace::EditorTabSlot; + use crate::ui::workspace::tab_index::TabIndex; 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); @@ -162,19 +165,21 @@ mod tests { 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"); + page.set_title("Query 1"); let id = Uuid::new_v4(); - write_workspace_tab_id(&page, id); + let index = Rc::new(TabIndex::default()); + index.insert(&page, id); let tabs: WorkspaceTabs = Rc::new(RefCell::new(HashMap::from([( id, WorkspaceTab::Editor(EditorTabSlot { editor: editor.clone(), page, draft: DraftId::new(), + ordinal: 1, + label: crate::ui::workspace::editor_label::EditorLabel::Untitled(1), }), )]))); - let directory = WindowTabs::new(tabs.clone()); - directory.set_view(Some(&view)); + let directory = WindowTabs::new(tabs.clone(), index, &view); assert_eq!( directory.tabs(), diff --git a/linux/crates/app/src/ui/workspace/editor_label.rs b/linux/crates/app/src/ui/workspace/editor_label.rs new file mode 100644 index 0000000000..63f9740a06 --- /dev/null +++ b/linux/crates/app/src/ui/workspace/editor_label.rs @@ -0,0 +1,23 @@ +/// What an editor tab is named after. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum EditorLabel { + /// No text to name it by: the tab's number among the editor tabs. + Untitled(usize), + /// The start of its script, at most the 200 characters the tab + /// reads, and whether the script goes on past it. + Query { prefix: String, truncated: bool }, +} + +impl EditorLabel { + /// A blank script is named by `ordinal`, so a label is never empty. + pub(crate) fn of(ordinal: usize, prefix: &str, truncated: bool) -> Self { + if !truncated && prefix.trim().is_empty() { + Self::Untitled(ordinal) + } else { + Self::Query { + prefix: prefix.to_owned(), + truncated, + } + } + } +} diff --git a/linux/crates/app/src/ui/workspace/imp.rs b/linux/crates/app/src/ui/workspace/imp.rs index dddbe4dbdd..b9ed9f42f2 100644 --- a/linux/crates/app/src/ui/workspace/imp.rs +++ b/linux/crates/app/src/ui/workspace/imp.rs @@ -10,21 +10,26 @@ use libadwaita as adw; use libadwaita::subclass::prelude::*; use super::WorkspaceModel; +use crate::ui::export::ExportOperationsButton; #[derive(Default, gtk::CompositeTemplate)] #[template(resource = "/app/tablepro/TablePro/ui/workspace.ui")] pub struct Workspace { #[template_child] - pub window_title: TemplateChild, + pub tab_overview: TemplateChild, #[template_child] - pub read_only_badge: TemplateChild, + pub breakpoint_bin: TemplateChild, #[template_child] - pub row_op_spinner: TemplateChild, + pub narrow_breakpoint: TemplateChild, #[template_child] - pub primary_menu_button: TemplateChild, + pub toast_overlay: TemplateChild, + #[template_child] + pub split_view: TemplateChild, #[template_child] pub sidebar_root: TemplateChild, #[template_child] + pub sidebar_header: TemplateChild, + #[template_child] pub sidebar_title: TemplateChild, #[template_child] pub table_search_toggle: TemplateChild, @@ -33,13 +38,31 @@ pub struct Workspace { #[template_child] pub table_search: TemplateChild, #[template_child] - pub sidebar_scroll: TemplateChild, + pub sidebar_slot: TemplateChild, #[template_child] - pub toast_overlay: TemplateChild, + pub content_root: TemplateChild, + #[template_child] + pub content_header: TemplateChild, + #[template_child] + pub sidebar_toggle: TemplateChild, + #[template_child] + pub new_tab_button: TemplateChild, + #[template_child] + pub window_title: TemplateChild, + #[template_child] + pub tab_button: TemplateChild, + #[template_child] + pub export_operations_button: TemplateChild, + #[template_child] + pub row_op_spinner: TemplateChild, + #[template_child] + pub tab_bar: TemplateChild, #[template_child] pub reconnect_banner: TemplateChild, #[template_child] - pub workspace_outer_stack: TemplateChild, + pub tab_stack: TemplateChild, + #[template_child] + pub tab_view: TemplateChild, pub(super) model: RefCell>, /// Handles the queued input, and with it every reply the workspace @@ -51,9 +74,10 @@ pub struct Workspace { impl ObjectSubclass for Workspace { const NAME: &'static str = "TableProWorkspace"; type Type = super::Workspace; - type ParentType = adw::BreakpointBin; + type ParentType = adw::Bin; fn class_init(klass: &mut Self::Class) { + ExportOperationsButton::ensure_type(); klass.bind_template(); } @@ -63,6 +87,11 @@ impl ObjectSubclass for Workspace { } impl ObjectImpl for Workspace { + fn constructed(&self) { + self.parent_constructed(); + super::shell::describe_buttons(&self.obj()); + } + fn signals() -> &'static [Signal] { static SIGNALS: OnceLock> = OnceLock::new(); SIGNALS.get_or_init(|| { @@ -88,4 +117,4 @@ impl ObjectImpl for Workspace { } impl WidgetImpl for Workspace {} -impl BreakpointBinImpl for Workspace {} +impl BinImpl for Workspace {} diff --git a/linux/crates/app/src/ui/workspace/mod.rs b/linux/crates/app/src/ui/workspace/mod.rs index a7be927c98..58bdc858c7 100644 --- a/linux/crates/app/src/ui/workspace/mod.rs +++ b/linux/crates/app/src/ui/workspace/mod.rs @@ -3,16 +3,28 @@ //! disconnect drops it and the window shows the connection picker. mod close_flow; +mod editor_label; mod health_banner; mod imp; mod import; mod row_ops; mod saves; +mod shell; mod sidebar; -mod sidebar_section_header; mod status_pages; mod structure; +mod structure_target; +mod tab_factory; +mod tab_identity; +mod tab_index; +mod tab_spec; +mod tab_title; +#[cfg(test)] +mod tab_title_tests; +mod table_naming; +mod unsaved_icon; mod window_tabs; +mod window_title; mod workspace_actions; mod workspace_persistence; mod workspace_tab_view; @@ -29,30 +41,30 @@ 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_core::EditorSession; +use tablepro_core::meta::{ObjectInfo, SchemaInfo}; use tablepro_session::runtime::WatchForwarder; use tablepro_session::{ObjectListSnapshot, Session, SessionHealth, SessionKey}; use uuid::Uuid; -use super::browse_tab::{BrowseTab, BrowseTabInput}; +use super::browse_tab::BrowseTab; use super::editor::SqlEditor; +use super::export::{ExportLedger, ExportLedgerInit, ExportOffer, ExportReporter}; 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}; +use crate::workspace::{ApplicationInhibit, CloseBlocker, CloseGuards, WindowInhibitor, WorkspaceHandle}; glib::wrapper! { pub struct Workspace(ObjectSubclass) - @extends adw::BreakpointBin, gtk::Widget, + @extends adw::Bin, gtk::Widget, @implements gtk::Accessible, gtk::Buildable, gtk::ConstraintTarget; } @@ -85,30 +97,41 @@ pub(crate) struct WorkspaceModel { drafts: Rc, window_title: adw::WindowTitle, sidebar_title: adw::WindowTitle, + /// The header bars' buttons and bindings the workspace set up, let go + /// of at shutdown. + shell: shell::ShellHandles, /// 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>>>, + sidebar: sidebar::SidebarView, + /// The schemas the sidebar's sections are made from, which also + /// decide whether tab titles name their schema. + catalog_schemas: Arc<[SchemaInfo]>, + /// The last schema read the sidebar asked for; an older one landing + /// late is not shown. + sidebar_read: u64, + /// Whether every schema's list has landed once. Until then the + /// sidebar shows the default schema's. + all_schemas_listed: bool, 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, + file_revealer: Rc, /// 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, + tab_view: adw::TabView, + /// Which tab each page shows. + index: Rc, + /// Builds every tab the workspace opens. + factory: Rc, + /// Whether tab titles name the schema, from the session's schemas. + naming: Rc>, /// Per-tab state, by tab id. Display order is read from the tab /// view's pages. workspace_tabs: Rc>>, @@ -119,15 +142,17 @@ pub(crate) struct WorkspaceModel { /// 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, + /// The exports running from this workspace's grids. + exports: Rc, /// Whether the window was last told there is something to lose. reported_dirty: Cell>, - history_dialog: Option>, + history_dialog: Option, saved_queries_dialog: Option>, import_dialog: Option>, driver_id: String, + /// The driver as the user knows it, for the header's subtitle. + driver_name: String, read_only: bool, /// Writes the tab list a moment after the last change. workspace: Rc, @@ -138,11 +163,13 @@ pub(crate) struct WorkspaceModel { /// workspace, so nothing reads for a session it let go of. _health_watch: WatchForwarder, _catalog_watch: WatchForwarder, + _reconnect_watch: WatchForwarder, + _link_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. +/// reopen, so a reopen builds its tab the way a restore does, through +/// a `TabSpec`. #[derive(Debug, Clone)] pub enum ClosedTabDescriptor { Editor { @@ -169,6 +196,12 @@ pub struct EditorTabSlot { /// The file this tab's text is written to. The text itself lives in /// the buffer, not here. pub draft: tablepro_storage::DraftId, + /// Its number among the editor tabs when it opened, which names it + /// while it has no text. + pub ordinal: usize, + /// What the tab is named after, kept so titling the tabs again does + /// not read the buffer. + pub label: editor_label::EditorLabel, } pub struct StructureTabSlot { @@ -243,6 +276,29 @@ impl WorkspaceTab { WorkspaceTab::Table(slot) => &slot.page, } } + + /// What the tab's title is made from. + pub(crate) fn identity(&self) -> tab_identity::TabIdentity { + use tab_identity::TabIdentity; + match self { + WorkspaceTab::Editor(slot) => TabIdentity::Editor(slot.label.clone()), + WorkspaceTab::Structure(slot) => TabIdentity::Structure(structure_target::StructureTarget::of( + slot.schema.as_deref(), + &slot.table, + )), + WorkspaceTab::Table(slot) => TabIdentity::Table(tablepro_core::meta::TableRef::new( + slot.schema.clone(), + slot.table.clone(), + )), + } + } +} + +/// Give `page` its title and tooltip. libadwaita treats an empty tooltip +/// as none, which clears the last one. +pub(crate) fn apply_tab_title(page: &adw::TabPage, title: &tab_title::TabTitle) { + page.set_title(&title.label); + page.set_tooltip(title.tooltip.as_deref().unwrap_or_default()); } #[derive(Debug, Clone, Copy)] @@ -254,22 +310,6 @@ pub enum OpenMode { 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) { @@ -292,9 +332,22 @@ pub(crate) enum WorkspaceInput { 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. + /// Every schema's object list landed: a reconnect read it again, or + /// a schema change refreshed it. CatalogChanged(ObjectListSnapshot), + /// A refresh of that list failed, and the list stays as it was. + CatalogReadFailed(String), + /// The default schema's list landed, as a reconnect commits it. + DefaultListCommitted(ObjectListSnapshot), + /// The schemas the sidebar's sections are made from, read for + /// `objects`. `None` when they could not be read. + SidebarRead { + read: u64, + objects: Arc<[ObjectInfo]>, + schemas: Option>, + }, + /// A reconnect replaced the connection. + LinkChanged, SelectTable { schema: Option, name: String, @@ -337,12 +390,9 @@ pub(crate) enum WorkspaceInput { approved: bool, }, RefreshPage, - ExportResults { - result: QueryResult, - name: String, - target: Option, - }, - CopyToClipboard(String), + ExportResults(Box), + /// Text to copy as it is, such as a saved query. + CopyText(String), WorkspaceTabClosed(Uuid), /// Each tab still asks about its own unsaved changes. CloseOtherWorkspaceTabs(Uuid), @@ -357,18 +407,13 @@ pub(crate) enum WorkspaceInput { 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), + /// A table or structure tab gained or lost unsaved changes. + TabDirtyChanged, NewTableTab { schema: Option, }, @@ -391,22 +436,13 @@ pub(crate) enum WorkspaceInput { }, /// 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 + /// A browse tab's F5. + RefreshTab(Uuid), } impl Workspace { @@ -448,7 +484,13 @@ impl Workspace { 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 index = Rc::new(tab_index::TabIndex::default()); + let tab_view = imp.tab_view.get(); + let window_tabs = Rc::new(window_tabs::WindowTabs::new( + workspace_tabs.clone(), + index.clone(), + &tab_view, + )); let events = input.clone(); let workspace_handle = WorkspaceHandle::new(crate::workspace::WorkspaceDeps { session: context.active.clone(), @@ -457,7 +499,7 @@ impl Workspace { history: Some(history.clone()), rows: change_trackers.clone(), structures: structure_trackers.clone(), - directory: window_tabs.clone(), + directory: window_tabs, events: Rc::new(move |event| events.deliver(WorkspaceInput::Workspace(event))), engine: crate::workspace::EngineProfile::of(&context.drivers, &session.metadata().driver_id), }); @@ -466,8 +508,37 @@ impl Workspace { .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 exports = ExportLedger::new(ExportLedgerInit { + tasks: tasks.clone(), + hold: context.hold.clone(), + reporter: ExportReporter::new(&window, &imp.toast_overlay.get()), + inhibitor: WindowInhibitor::new(ApplicationInhibit::new(&window)), + }); + imp.export_operations_button.bind(&exports.jobs()); + + let sidebar = sidebar::build(&workspace, &input, sidebar::SidebarAffordances::for_session(&session)); + let naming = Rc::new(Cell::new(table_naming::TableNaming::default())); + let factory = tab_factory::TabFactory::new(tab_factory::TabFactoryParts { + view: tab_view.clone(), + tabs: workspace_tabs.clone(), + index: index.clone(), + input: input.clone(), + toasts: imp.toast_overlay.downgrade(), + context: context.clone(), + settings: settings.clone(), + storage: storage.clone(), + history: Some(history.clone()), + drafts: drafts.clone(), + draft_scope: crate::workspace::WorkspaceFileName::for_key(&key).draft_scope(), + change_trackers: change_trackers.clone(), + structure_trackers: structure_trackers.clone(), + read_only: session.read_only(), + naming: naming.clone(), + }); + workspace_tabs::connect_tab_view(&tab_view, &index, &input); + let mut actions = workspace_actions::install(&window, &input); + let shell = shell::install(&workspace, &window, &factory); + actions.push(shell::SHOW_SIDEBAR); let history_gate = workspace_actions::spawn_history_gate(&window, history.availability()); let health_input = input.clone(); @@ -476,12 +547,32 @@ impl Workspace { 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 (listed, failed) = (input.clone(), input.clone()); + let catalog_watch = sidebar::catalog_watch( + &session, + move |snapshot| listed.deliver(WorkspaceInput::CatalogChanged(snapshot)), + move |error| { + failed.deliver(WorkspaceInput::CatalogReadFailed( + crate::ui::error_text::operation_error_message(&error), + )); + }, + ); + let committed = input.clone(); + let reconnect_watch = sidebar::reconnect_watch(&session, move |snapshot| { + committed.deliver(WorkspaceInput::DefaultListCommitted(snapshot)); }); + let relinked = input.clone(); + let link_watch = WatchForwarder::new( + session.link_changes(), + move |_| relinked.deliver(WorkspaceInput::LinkChanged), + sidebar::spawn_local, + ); let driver_id = session.metadata().driver_id.clone(); + let driver_name = context + .drivers + .get(&driver_id) + .map_or_else(|| driver_id.clone(), |driver| driver.display_name().to_owned()); let model = WorkspaceModel { context, key, @@ -495,40 +586,46 @@ impl Workspace { drafts, window_title: imp.window_title.get(), sidebar_title: imp.sidebar_title.get(), + shell, history_gate, actions, - sidebar_factory: sidebar.factory, - sidebar_bridge: sidebar.bridge, - sidebar_schemas: sidebar.schemas, + sidebar, + catalog_schemas: Arc::from([]), + sidebar_read: 0, + all_schemas_listed: false, 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(), + file_revealer: services.file_revealer.clone(), 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), + tab_view, + index, + factory, + naming, workspace_tabs, change_trackers, structure_trackers, workspace_handle, - window_tabs, close_guards, + exports: exports.clone(), reported_dirty: Cell::new(None), history_dialog: None, saved_queries_dialog: None, import_dialog: None, driver_id, + driver_name, 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, + _reconnect_watch: reconnect_watch, + _link_watch: link_watch, }; imp.model.replace(Some(model)); + workspace.register_close_blocker(exports); workspace } @@ -539,6 +636,12 @@ impl Workspace { self.with_model(WorkspaceModel::open); } + /// Where the window shows its toasts while it shows this workspace: + /// under the header bars, inside the tab overview. + pub(crate) fn toast_overlay(&self) -> adw::ToastOverlay { + self.imp().toast_overlay.get() + } + /// What the workspace's actions, tabs and background work send it. pub(crate) fn input(&self) -> Option { self.read(|model| model.input.clone()) @@ -549,6 +652,16 @@ impl Workspace { self.read(|model| model.workspace_handle.clone()) } + /// The exports running from this workspace. + pub(crate) fn exports(&self) -> Option> { + self.read(|model| model.exports.clone()) + } + + /// Have every close of the window ask about `blocker`'s work first. + pub(crate) fn register_close_blocker(&self, blocker: Rc) { + self.with_model(|model| model.workspace_handle.register_close_blocker(blocker)); + } + /// 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. @@ -625,10 +738,6 @@ impl Workspace { } 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() @@ -645,56 +754,71 @@ impl WorkspaceModel { /// 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); + tracing::info!(driver = %self.driver_id, "workspace ready"); + self.open_sidebar(); self.refresh_window_title(); self.restore_workspace_tabs(self.key.clone()); - self.refresh_inhibitor(); + self.refresh_unsaved(); } - /// 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. + /// The window is named after the tab in front of the user and the + /// connection; the sidebar's header names the connection and its + /// driver. 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)); + let active = self.selected_workspace_tab_id().and_then(|id| self.tab_title(id)); + let title = window_title::resolve_window_title(active.as_ref(), name, &self.driver_name, self.read_only); + self.window_title.set_title(&title.heading); + self.window_title.set_subtitle(&title.subtitle); self.sidebar_title.set_title(name); + self.sidebar_title.set_subtitle(&self.driver_name); if let Some(workspace) = self.this.upgrade() { - workspace.emit_by_name::<()>("title-changed", &[&title]); + workspace.emit_by_name::<()>("title-changed", &[&title.window]); + } + } + + /// The title tab `id` shows. + pub(super) fn tab_title(&self, id: Uuid) -> Option { + let identity = self.workspace_tabs.borrow().get(&id)?.identity(); + Some(tab_title::resolve_tab_title(&identity, self.naming.get())) + } + + /// Title tab `id` again, after what it shows changed. + pub(super) fn retitle_tab(&self, id: Uuid) { + let Some(title) = self.tab_title(id) else { + return; + }; + if let Some(tab) = self.workspace_tabs.borrow().get(&id) { + apply_tab_title(tab.page(), &title); + } + if self.selected_workspace_tab_id() == Some(id) { + self.refresh_window_title(); + } + } + + /// Title every tab again when the schemas change whether table tabs + /// name their schema. + pub(super) fn on_table_naming(&self, naming: table_naming::TableNaming) { + if self.naming.replace(naming) == naming { + return; + } + let ids: Vec = self.workspace_tabs.borrow().keys().copied().collect(); + for id in ids { + self.retitle_tab(id); } + self.refresh_window_title(); } fn shutdown(mut self) { self.history_gate.abort(); - self.sidebar_bridge.abort(); + self.sidebar.shutdown(); + self.shell.release(); // Their entries name this session's tables and queries. if let Some(dialog) = self.history_dialog.take() { - close_if_presented(dialog.model().dialog()); + close_if_presented(dialog.upcast_ref()); } if let Some(dialog) = self.saved_queries_dialog.take() { close_if_presented(dialog.model().dialog()); @@ -708,6 +832,9 @@ impl WorkspaceModel { } } self.close_tabs(); + // The close asked about them already; one started since has no + // window left to finish in. + self.exports.shut_down(); self.context.active.take(); } @@ -719,7 +846,11 @@ impl WorkspaceModel { WorkspaceInput::OldHostKeyForgotten { failure, forgotten } => { self.on_old_host_key_forgotten(failure, forgotten); } - WorkspaceInput::CatalogChanged(snapshot) => self.on_catalog_changed(&snapshot), + WorkspaceInput::CatalogChanged(snapshot) => self.on_catalog_changed(snapshot), + WorkspaceInput::CatalogReadFailed(message) => self.on_catalog_read_failed(&message), + WorkspaceInput::DefaultListCommitted(snapshot) => self.on_default_list_committed(snapshot), + WorkspaceInput::SidebarRead { read, objects, schemas } => self.on_sidebar_read(read, &objects, schemas), + WorkspaceInput::LinkChanged => self.on_link_changed(), WorkspaceInput::TabCloseDecided { id, approved } => self.on_tab_close_decided(id, approved), WorkspaceInput::SelectTable { schema, @@ -731,47 +862,23 @@ impl WorkspaceModel { 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::EditorTransactionChanged => self.refresh_unsaved(), 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::TabDirtyChanged => self.refresh_unsaved(), 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::ShowCreateTableLoaded(sql) => self.open_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::NewEditorTab => self.open_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), @@ -789,33 +896,24 @@ impl WorkspaceModel { 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::OpenHistoryQuery(text) => self.open_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 } => { + WorkspaceInput::ExportResults(offer) => { 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, + super::export_dialog::present(super::export_dialog::ExportDialogInit { + parent: &window, + exports: &self.exports, + offer: *offer, dialect, - &self.settings, - ); + settings: &self.settings, + }); } } - WorkspaceInput::CopyToClipboard(text) => self.on_copy_to_clipboard(text), + WorkspaceInput::CopyText(text) => self.on_copy_text(text), WorkspaceInput::ReopenClosedTab => self.on_reopen_closed_tab(), - WorkspaceInput::ShowFilterDialog => self.on_show_filter_dialog(), + WorkspaceInput::RefreshTab(id) => self.on_refresh_tab(id), } } } - -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 index 5fde8f49fc..c31980f7bc 100644 --- a/linux/crates/app/src/ui/workspace/row_ops.rs +++ b/linux/crates/app/src/ui/workspace/row_ops.rs @@ -7,26 +7,11 @@ impl WorkspaceModel { 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")); - } - } + pub(super) fn on_copy_text(&self, text: String) { + crate::ui::clipboard::copy_text(&self.toast_overlay, text, |message| self.show_toast(message)); } } -/// 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::*; @@ -35,12 +20,6 @@ mod tests { 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"); @@ -48,11 +27,11 @@ mod tests { clipboard.set_text("before"); let input = test.workspace().input().expect("the workspace's input"); - input.deliver(WorkspaceInput::CopyToClipboard("a\0b".to_owned())); + input.deliver(WorkspaceInput::CopyText("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"); + let refusal = crate::ui::clipboard::ClipboardError::ContainsNul.message(); assert!( descendants(&test.workspace()) .iter() @@ -61,7 +40,7 @@ mod tests { "no toast said why" ); - input.deliver(WorkspaceInput::CopyToClipboard("a\tb".to_owned())); + input.deliver(WorkspaceInput::CopyText("a\tb".to_owned())); drain_main_context(); assert_eq!(local_text(&clipboard).as_deref(), Some("a\tb")); } diff --git a/linux/crates/app/src/ui/workspace/saves.rs b/linux/crates/app/src/ui/workspace/saves.rs index a57275733f..daf4f7fe21 100644 --- a/linux/crates/app/src/ui/workspace/saves.rs +++ b/linux/crates/app/src/ui/workspace/saves.rs @@ -61,7 +61,7 @@ impl WorkspaceModel { } } } - self.refresh_inhibitor(); + self.refresh_unsaved(); } pub(super) fn on_saves_in_flight_changed(&mut self, count: usize) { @@ -70,7 +70,7 @@ impl WorkspaceModel { let count = u32::try_from(count).unwrap_or(u32::MAX); workspace.emit_by_name::<()>("saves-in-flight-changed", &[&count]); } - self.refresh_inhibitor(); + self.refresh_unsaved(); } fn on_save_settled(&mut self, settled: SaveSettled) { @@ -80,6 +80,7 @@ impl WorkspaceModel { outcome, failing, saved_table_name, + origins, } = settled; match (kind, outcome) { (WriteKind::RowChanges, SaveOutcome::Committed { .. }) => { @@ -88,10 +89,11 @@ impl WorkspaceModel { // keeps the confirmation from outstaying the moment. let toast = adw::Toast::builder() .title(crate::i18n::gettext("Saved")) + .use_markup(false) .timeout(4) .build(); self.toast_overlay.add_toast(toast); - self.dispatch_to_tab(tab, BrowseTabInput::SaveCommitted); + self.dispatch_to_tab(tab, BrowseTabInput::SaveCommitted(origins)); } (WriteKind::RowChanges, SaveOutcome::RolledBack(cause)) => { // The row behind the failing step is flashed before the @@ -125,10 +127,11 @@ impl WorkspaceModel { /// The tab's name for a sentence, quoted as the close prompts quote /// it. fn tab_name(&self, tab: Uuid) -> String { - let title = self.workspace_tabs.borrow().get(&tab).map(|slot| { - let title = slot.page().title(); - title.strip_prefix("• ").unwrap_or(&title).to_owned() - }); + let title = self + .workspace_tabs + .borrow() + .get(&tab) + .map(|slot| slot.page().title().to_string()); DirtyTab::quote(&title.unwrap_or_default()) } } diff --git a/linux/crates/app/src/ui/workspace/shell.rs b/linux/crates/app/src/ui/workspace/shell.rs new file mode 100644 index 0000000000..ef8d8d70d0 --- /dev/null +++ b/linux/crates/app/src/ui/workspace/shell.rs @@ -0,0 +1,406 @@ +//! The workspace's two header bars, the sidebar's and the content's, +//! and what they need beyond the template: the main menu, the button +//! labels, the sidebar toggle and the overview's new tab. + +use std::rc::Rc; + +use gtk4 as gtk; +use gtk4::glib; +use gtk4::prelude::*; +use gtk4::subclass::prelude::*; +use libadwaita as adw; + +use super::Workspace; +use super::tab_factory::TabFactory; +use crate::ui::a11y::describe_icon_button; +use crate::ui::shell::{TAB_OVERVIEW_MENU, app_menu, primary_menu_button}; + +/// The window action that shows and hides the sidebar, as GNOME Files +/// names it. +pub(super) const SHOW_SIDEBAR: &str = "show-sidebar"; + +/// What `install` connected outside the template, let go of at shutdown. +pub(super) struct ShellHandles { + overview: glib::WeakRef, + create_tab: Option, + sidebar_toggle: Option, +} + +impl ShellHandles { + pub(super) fn release(&mut self) { + if let Some(watch) = self.sidebar_toggle.take() { + watch.unwatch(); + } + if let Some(handler) = self.create_tab.take() + && let Some(overview) = self.overview.upgrade() + { + overview.disconnect(handler); + } + } +} + +/// Run as the workspace is built, so its buttons are named before any +/// window holds it. +pub(super) fn describe_buttons(workspace: &Workspace) { + let imp = workspace.imp(); + describe_icon_button( + &imp.table_search_toggle.get(), + &crate::i18n::gettext("Search Tables"), + None, + ); + describe_icon_button( + &imp.sidebar_toggle.get(), + &crate::i18n::gettext("Show Sidebar"), + Some("win.show-sidebar"), + ); + describe_icon_button( + &imp.new_tab_button.get(), + &crate::i18n::gettext("New Query"), + Some("win.new-editor-tab"), + ); + describe_icon_button(&imp.tab_button.get(), &crate::i18n::gettext("View Open Tabs"), None); +} + +pub(super) fn install( + workspace: &Workspace, + window: &adw::ApplicationWindow, + factory: &Rc, +) -> ShellHandles { + let imp = workspace.imp(); + let app = window.application(); + imp.sidebar_header.pack_end(&primary_menu_button(app.as_ref())); + + window.add_action(&gio::PropertyAction::new( + SHOW_SIDEBAR, + &imp.split_view.get(), + "show-sidebar", + )); + let sidebar_toggle = follow_sidebar(&imp.split_view, &imp.sidebar_toggle); + + imp.tab_view + .bind_property("n-pages", &imp.tab_stack.get(), "visible-child-name") + .transform_to(|_, pages: i32| Some(if pages > 0 { "tabs" } else { "empty" })) + .sync_create() + .build(); + + let overview = imp.tab_overview.get(); + if let Some(menu) = app_menu(app.as_ref(), TAB_OVERVIEW_MENU) { + overview.set_secondary_menu(Some(&menu)); + } + // The overview selects the page it gets back. + let factory = factory.clone(); + let create_tab = overview.connect_create_tab(move |_| factory.create_editor()); + ShellHandles { + overview: overview.downgrade(), + create_tab: Some(create_tab), + sidebar_toggle: Some(sidebar_toggle), + } +} + +/// The toggle shows only while the sidebar is out of view: collapsed +/// into an overlay, or hidden. Beside a shown sidebar it would only hide +/// the list the user is looking at. +fn follow_sidebar(split_view: &adw::OverlaySplitView, toggle: >k::ToggleButton) -> gtk::ExpressionWatch { + let collapsed = gtk::PropertyExpression::new( + adw::OverlaySplitView::static_type(), + None::<>k::Expression>, + "collapsed", + ); + let shown = gtk::PropertyExpression::new( + adw::OverlaySplitView::static_type(), + None::<>k::Expression>, + "show-sidebar", + ); + let visible = gtk::ClosureExpression::new::( + [collapsed, shown], + glib::closure!(|_: Option, collapsed: bool, shown: bool| collapsed || !shown), + ); + visible.bind(toggle, "visible", Some(split_view)) +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use libadwaita::prelude::*; + + use super::*; + use crate::test_support::{descendants, wait_until}; + use crate::ui::SessionWindowInput; + use crate::ui::session_window::tests::{Script, TestWindow}; + use crate::ui::workspace::WorkspaceTab; + + fn overview_new_tab_button(overview: &adw::TabOverview) -> gtk::Button { + descendants(overview) + .into_iter() + .find_map(|widget| { + let button = widget.downcast::().ok()?; + let content = button.child()?.downcast::().ok()?; + (content.icon_name() == "adw-tab-new-symbolic").then_some(button) + }) + .expect("the overview's new-tab button") + } + + fn top_header(toolbar: &adw::ToolbarView) -> Option { + let mut child = toolbar.first_child(); + while let Some(widget) = child { + if let Some(header) = descendants(&widget) + .into_iter() + .chain(std::iter::once(widget.clone())) + .find_map(|widget| widget.downcast::().ok()) + { + return Some(header); + } + child = widget.next_sibling(); + } + None + } + + fn sized(name: &str, width: i32, height: i32) -> TestWindow { + let test = TestWindow::with_script( + name, + Script { + window_size: Some((width, height)), + ..Script::default() + }, + ); + test.window + .send(SessionWindowInput::OpenSaved(Box::new(test.saved.clone()))); + test.wait_for_page("workspace"); + test + } + + #[gtk4::test] + fn each_pane_owns_its_header_bar() { + let test = TestWindow::connected("PaneHeaders"); + let workspace = test.workspace(); + let imp = workspace.imp(); + + let sidebar = top_header(&imp.sidebar_root).expect("the sidebar's header bar"); + let content = top_header(&imp.content_root).expect("the content's header bar"); + + assert_eq!(sidebar, imp.sidebar_header.get()); + assert_eq!(content, imp.content_header.get()); + // The overview draws a header bar of its own while it is open. + let headers: Vec = descendants(&imp.breakpoint_bin.get()) + .into_iter() + .filter_map(|widget| widget.downcast::().ok()) + .collect(); + assert_eq!(headers.len(), 2, "a header bar outside the two panes"); + assert!(headers.contains(&sidebar) && headers.contains(&content)); + assert!( + descendants(&imp.sidebar_header.get()).iter().any(|widget| { + widget + .downcast_ref::() + .is_some_and(gtk::MenuButton::is_primary) + }), + "the main menu is not in the sidebar's header bar" + ); + } + + #[gtk4::test] + fn tab_overview_is_the_window_content() { + let test = TestWindow::connected("OverviewRoot"); + + let content = test.window.content().expect("the window's content"); + let workspace = content.downcast::().expect("the workspace page"); + let overview = workspace + .child() + .and_downcast::() + .expect("the overview as the workspace's only child"); + + assert_eq!(overview.view(), Some(workspace.imp().tab_view.get())); + assert!( + overview.child().is_some_and(|child| child.is::()), + "the overview does not hold the header bars" + ); + } + + #[gtk4::test] + fn tab_bar_is_a_content_top_bar() { + let test = TestWindow::connected("TabBarTop"); + let workspace = test.workspace(); + let imp = workspace.imp(); + + let bar = imp.tab_bar.get(); + + assert_eq!(bar.view(), Some(imp.tab_view.get())); + assert!(bar.is_autohide()); + assert_eq!( + bar.ancestor(adw::ToolbarView::static_type()).as_ref(), + Some(imp.content_root.upcast_ref::()) + ); + assert!( + bar.ancestor(gtk::Stack::static_type()).is_none(), + "the tab bar sits in the tab area, not above it" + ); + } + + #[gtk4::test] + fn show_sidebar_action_toggles_split_view() { + let test = TestWindow::connected("ShowSidebar"); + let split = test.workspace().imp().split_view.get(); + assert!(split.shows_sidebar()); + + WidgetExt::activate_action(&test.window, "win.show-sidebar", None).expect("win.show-sidebar"); + assert!(!split.shows_sidebar()); + + WidgetExt::activate_action(&test.window, "win.show-sidebar", None).expect("win.show-sidebar"); + assert!(split.shows_sidebar()); + } + + #[gtk4::test] + fn sidebar_toggle_visible_only_when_collapsed_or_hidden() { + let test = TestWindow::connected("SidebarToggle"); + let workspace = test.workspace(); + let imp = workspace.imp(); + let (split, toggle) = (imp.split_view.get(), imp.sidebar_toggle.get()); + split.set_collapsed(false); + split.set_show_sidebar(true); + assert!(!toggle.is_visible(), "shown beside a sidebar in view"); + + split.set_show_sidebar(false); + assert!(toggle.is_visible(), "hidden with the sidebar hidden"); + + split.set_show_sidebar(true); + split.set_collapsed(true); + assert!(toggle.is_visible(), "hidden with the sidebar collapsed"); + assert_eq!(toggle.action_name().as_deref(), Some("win.show-sidebar")); + } + + #[gtk4::test] + fn narrow_window_swaps_tab_bar_for_tab_button() { + let test = sized("Narrow", 360, 294); + let workspace = test.workspace(); + let imp = workspace.imp(); + + wait_until(Duration::from_secs(2), || { + imp.breakpoint_bin.current_breakpoint().as_ref() == Some(&imp.narrow_breakpoint.get()) + }) + .expect("the narrow breakpoint to apply"); + + assert!(!imp.tab_bar.is_visible(), "the tab bar stayed"); + assert!(imp.tab_button.is_visible(), "no tab button took its place"); + assert!(imp.split_view.is_collapsed()); + } + + #[gtk4::test] + fn wide_window_shows_tab_bar() { + let test = sized("Wide", 1200, 760); + let workspace = test.workspace(); + let imp = workspace.imp(); + wait_until(Duration::from_secs(2), || imp.breakpoint_bin.width() > 0).expect("the workspace laid out"); + + assert_eq!(imp.breakpoint_bin.current_breakpoint(), None); + assert!(imp.tab_bar.is_visible()); + assert!(!imp.tab_button.is_visible()); + assert!(!imp.split_view.is_collapsed()); + } + + fn fits_the_floor(root: >k::Widget) { + let (min_width, ..) = root.measure(gtk::Orientation::Horizontal, -1); + let (min_height, ..) = root.measure(gtk::Orientation::Vertical, 360); + assert!(min_width <= 360, "{} needs {min_width} px wide", root.type_().name()); + assert!(min_height <= 294, "{} needs {min_height} px high", root.type_().name()); + } + + #[gtk4::test] + fn every_page_fits_the_window_floor() { + use tablepro_session::fakes::FakeEstablish; + use tablepro_test_support::ConnectionScript; + let test = TestWindow::with_script( + "Floor", + Script { + establish: vec![ + FakeEstablish::Late { + after: Duration::from_secs(3), + script: ConnectionScript::default(), + }, + crate::ui::session_window::tests::refused(), + ], + ..Script::default() + }, + ); + assert_eq!(test.window.size_request(), (360, 294)); + let content = || test.window.content().expect("a page"); + fits_the_floor(&content()); + + test.window + .send(SessionWindowInput::OpenSaved(Box::new(test.saved.clone()))); + test.wait_for_page("connecting"); + fits_the_floor(&content()); + // A cancel before the driver is asked leaves the late connect for + // the next attempt. + wait_until(Duration::from_secs(10), || !test.connector.calls().is_empty()) + .expect("the connect to reach the driver"); + + test.window.send(SessionWindowInput::CancelConnect); + test.wait_for_page("picker"); + test.window + .send(SessionWindowInput::OpenSaved(Box::new(test.saved.clone()))); + test.wait_for_page("connect-failed"); + fits_the_floor(&content()); + + test.window.send(SessionWindowInput::RetryConnect); + test.wait_for_page("workspace"); + fits_the_floor(&content()); + } + + #[gtk4::test] + fn overview_new_tab_uses_tab_factory() { + let test = TestWindow::connected("OverviewNewTab"); + let workspace = test.workspace(); + let overview = workspace.imp().tab_overview.get(); + + // As the overview's own button emits it: create-tab hands back a + // page it does not own, which emit_by_name would release. + overview_new_tab_button(&overview).emit_clicked(); + let page = workspace.imp().tab_view.selected_page().expect("the new tab's page"); + + let slot = workspace + .read(|model| { + let id = model.index.id_of(&page)?; + let tabs = model.workspace_tabs.borrow(); + Some(matches!(tabs.get(&id), Some(WorkspaceTab::Editor(_)))) + }) + .flatten(); + assert_eq!(slot, Some(true), "the page has no editor slot in the index"); + assert_eq!(page.title(), "Query 1"); + } + + #[gtk4::test] + fn primary_menu_has_no_quit() { + let test = TestWindow::picker("PrimaryMenu"); + let menu = test.app.menu_by_id("primary_menu").expect("the primary menu"); + let mut actions = Vec::new(); + for section in 0..menu.n_items() { + let Some(items) = menu.item_link(section, "section") else { + continue; + }; + for item in 0..items.n_items() { + if let Some(action) = items + .item_attribute_value(item, "action", None) + .and_then(|value| value.get::()) + { + actions.push(action); + } + } + } + + for expected in ["app.new-window", "app.preferences", "app.shortcuts", "app.about"] { + assert!(actions.iter().any(|action| action == expected), "{expected} is missing"); + } + assert!(!actions.iter().any(|action| action == "app.quit"), "the menu has Quit"); + assert!( + test.app.menu_by_id("tab_overview_menu").is_some(), + "the overview's menu is missing" + ); + } + + #[gtk4::test] + fn f9_toggles_sidebar() { + let test = TestWindow::picker("F9"); + + assert_eq!(test.app.accels_for_action("win.show-sidebar"), ["F9"]); + } +} diff --git a/linux/crates/app/src/ui/workspace/sidebar.rs b/linux/crates/app/src/ui/workspace/sidebar.rs deleted file mode 100644 index 7d7e39aefb..0000000000 --- a/linux/crates/app/src/ui/workspace/sidebar.rs +++ /dev/null @@ -1,358 +0,0 @@ -//! 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/workspace/sidebar/catalog_watch.rs b/linux/crates/app/src/ui/workspace/sidebar/catalog_watch.rs new file mode 100644 index 0000000000..f2ef4c82b9 --- /dev/null +++ b/linux/crates/app/src/ui/workspace/sidebar/catalog_watch.rs @@ -0,0 +1,197 @@ +use std::sync::Arc; + +use gtk4::glib; +use tablepro_core::meta::ObjectScope; +use tablepro_session::runtime::WatchForwarder; +use tablepro_session::{ObjectListSnapshot, OperationError, Session}; + +pub(in crate::ui::workspace) fn spawn_local(future: futures::future::LocalBoxFuture<'static, ()>) { + glib::spawn_future_local(future); +} + +/// Deliver each object list the catalog commits for every schema, and +/// each refresh of it that failed, which keeps the list it had. The list +/// on screen when the watch starts is not delivered again. +pub(in crate::ui::workspace) fn catalog_watch( + session: &Arc, + mut listed: impl FnMut(ObjectListSnapshot) + 'static, + mut failed: impl FnMut(Arc) + 'static, +) -> WatchForwarder { + let objects = session.catalog().subscribe(ObjectScope::AllSchemas); + let (mut shown, mut reported) = { + let current = objects.borrow(); + (current.generation, current.last_error.clone()) + }; + WatchForwarder::new( + objects, + move |snapshot: ObjectListSnapshot| { + if snapshot.generation != shown { + shown = snapshot.generation; + listed(snapshot); + return; + } + let Some(error) = snapshot.last_error else { + return; + }; + if !reported.as_ref().is_some_and(|seen| Arc::ptr_eq(seen, &error)) { + reported = Some(error.clone()); + failed(error); + } + }, + spawn_local, + ) +} + +/// Deliver each list a reconnect commits for the default schema. The +/// reconnect marks every other list stale without reading it, so this is +/// when the sidebar's list is read again. +pub(in crate::ui::workspace) fn reconnect_watch( + session: &Arc, + mut committed: impl FnMut(ObjectListSnapshot) + 'static, +) -> WatchForwarder { + let objects = session.catalog().subscribe(ObjectScope::DefaultSchema); + let mut seen = objects.borrow().generation; + WatchForwarder::new( + objects, + move |snapshot: ObjectListSnapshot| { + if snapshot.generation != seen { + seen = snapshot.generation; + committed(snapshot); + } + }, + spawn_local, + ) +} + +#[cfg(test)] +mod tests { + use std::cell::RefCell; + use std::collections::VecDeque; + use std::rc::Rc; + use std::time::Duration; + + use tablepro_core::DriverError; + use tablepro_core::meta::{ObjectInfo, 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(T) + '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::AllSchemas).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 failures = Queue::default(); + let watching = catalog_watch(&fake.session, post_to(&queue), post_to(&failures)); + 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::AllSchemas).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 failures = Queue::default(); + let _watching = catalog_watch(&fake.session, post_to(&queue), post_to(&failures)); + + 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"]); + assert!(failures.borrow().is_empty()); + } + + #[gtk4::test] + fn a_failed_refresh_is_delivered_without_a_list() { + let runtime = crate::test_support::test_runtime(); + let mut script = ConnectionScript::default(); + script + .list_objects + .once(FakeReply::ok(vec![object("users", ObjectKind::Table)])) + .always(FakeReply::err(DriverError::Protocol("catalog gone".to_owned()))); + let fake = FakeSession::start(script, [], runtime.tasks()); + let queue = Queue::default(); + let failures = Queue::default(); + let _watching = catalog_watch(&fake.session, post_to(&queue), post_to(&failures)); + + crate::test_support::finish_on_main(Duration::from_secs(5), refresh_objects(&fake.session, &runtime)) + .expect("the list to land"); + crate::test_support::finish_on_main(Duration::from_secs(5), refresh_objects(&fake.session, &runtime)) + .expect("the refresh to fail"); + crate::test_support::wait_until(Duration::from_secs(5), || failures.borrow().len() == 1) + .expect("the failure was delivered"); + + assert_eq!(queue.borrow().len(), 1, "a failed refresh delivered a list"); + assert_eq!( + names(&fake.session.subscribe_objects(ObjectScope::AllSchemas).borrow()), + ["users"], + "the failure emptied the list" + ); + } +} diff --git a/linux/crates/app/src/ui/workspace/sidebar/mod.rs b/linux/crates/app/src/ui/workspace/sidebar/mod.rs new file mode 100644 index 0000000000..a78a1785c1 --- /dev/null +++ b/linux/crates/app/src/ui/workspace/sidebar/mod.rs @@ -0,0 +1,238 @@ +//! The sidebar of the session's tables: every schema's tables and views +//! in one recycling list, a section per schema, with one shared menu. + +mod catalog_watch; +mod object_key; +mod sidebar_affordances; +mod sidebar_event; +mod sidebar_header_factory; +mod sidebar_menu; +mod sidebar_model; +mod sidebar_object; +mod sidebar_object_imp; +mod sidebar_row_factory; +mod sidebar_view; +#[cfg(test)] +mod tests; + +use std::collections::BTreeSet; +use std::sync::Arc; + +use gtk4::prelude::*; +use gtk4::subclass::prelude::*; +use libadwaita::prelude::*; +use tablepro_core::meta::{ObjectInfo, ObjectScope, SchemaInfo, TableRef}; +use tablepro_session::ObjectListSnapshot; + +pub(super) use catalog_watch::{catalog_watch, reconnect_watch, spawn_local}; +pub(super) use sidebar_affordances::SidebarAffordances; +pub(super) use sidebar_view::SidebarView; + +use super::table_naming::TableNaming; +use super::{OpenMode, Workspace, WorkspaceInput, WorkspaceModel, WorkspaceSender}; +use crate::runtime::DeliverInput; +use sidebar_event::SidebarEvent; + +/// Build the sidebar into the workspace's template, sending what the +/// user asks of it to `input`. +pub(super) fn build(workspace: &Workspace, input: &WorkspaceSender, affordances: SidebarAffordances) -> SidebarView { + let imp = workspace.imp(); + let events = input.clone(); + let view = SidebarView::new(move |event| events.deliver(workspace_input(event)), affordances); + imp.sidebar_slot.set_child(Some(view.widget())); + view.follow_search(&imp.table_search); + imp.table_search_bar.connect_entry(&*imp.table_search); + imp.table_search_bar.set_key_capture_widget(Some(&*imp.sidebar_root)); + imp.table_search_toggle + .bind_property("active", &*imp.table_search_bar, "search-mode-enabled") + .bidirectional() + .sync_create() + .build(); + view +} + +fn workspace_input(event: SidebarEvent) -> WorkspaceInput { + let select = |table: TableRef, open_mode| WorkspaceInput::SelectTable { + schema: table.schema, + name: table.name, + open_mode, + }; + match event { + SidebarEvent::Open(table) => select(table, OpenMode::SwitchOrAppend), + SidebarEvent::OpenInNewTab(table) => select(table, OpenMode::NewTab), + SidebarEvent::EditStructure(table) => WorkspaceInput::EditStructureTab { + schema: table.schema, + table: table.name, + }, + SidebarEvent::ShowCreateTable(table) => WorkspaceInput::ShowCreateTableForExisting { + schema: table.schema, + table: table.name, + }, + SidebarEvent::ImportCsv(table) => WorkspaceInput::ImportCsvRequested { + schema: table.schema, + table: table.name, + }, + SidebarEvent::DropTable(table) => WorkspaceInput::DropTablePrompt { + schema: table.schema, + table: table.name, + }, + SidebarEvent::NewTable(schema) => WorkspaceInput::NewTableTab { schema }, + } +} + +/// The schemas the sidebar shows sections for: the session's own, and +/// any an object names that the session does not list, as a server whose +/// databases are its schemas lists only the one it is in. +fn shown_schemas(schemas: &[SchemaInfo], objects: &[ObjectInfo]) -> Vec { + let known: BTreeSet<&str> = schemas.iter().map(|schema| schema.name.as_str()).collect(); + let extra: BTreeSet<&str> = objects + .iter() + .filter(|object| object.kind.is_browsable()) + .filter_map(|object| object.schema.as_deref()) + .filter(|schema| !known.contains(schema)) + .collect(); + schemas + .iter() + .cloned() + .chain(extra.into_iter().map(|name| SchemaInfo { + name: name.to_owned(), + is_default: false, + is_system: false, + })) + .collect() +} + +impl WorkspaceModel { + /// What the sidebar shows before the list of every schema lands: that + /// list when the session read it, or else the default schema's, which + /// the connect read. Then every schema's list is asked for. + pub(super) fn open_sidebar(&mut self) { + let Some(session) = self.context.active.get() else { + return; + }; + let all = session.catalog().subscribe(ObjectScope::AllSchemas).borrow().clone(); + self.all_schemas_listed = all.generation > 0; + let objects = if self.all_schemas_listed { + all.objects + } else { + session + .subscribe_objects(ObjectScope::DefaultSchema) + .borrow() + .objects + .clone() + }; + self.sidebar.apply_objects(&objects, &self.catalog_schemas); + self.read_sidebar(objects); + self.list_all_schemas(); + } + + fn list_all_schemas(&self) { + let Some(session) = self.context.active.get() else { + return; + }; + // The catalog watch delivers what lands, and a failure keeps the + // list there was. + drop( + self.context + .tasks + .spawn_task(async move { session.objects(ObjectScope::AllSchemas).await }), + ); + } + + /// Read the session's schemas, which place the sidebar's sections and + /// decide whether tab titles name their schema, then show `objects` + /// under them. Only the last read asked for is shown. + fn read_sidebar(&mut self, objects: Arc<[ObjectInfo]>) { + let Some(session) = self.context.active.get() else { + return; + }; + self.sidebar_read += 1; + let read = self.sidebar_read; + let input = self.input.clone(); + crate::runtime::spawn_and_deliver( + &self.context.tasks, + async move { session.schemas().await }, + move |schemas| { + let schemas = match schemas { + Ok(Ok(schemas)) => Some(schemas), + Ok(Err(error)) => { + tracing::debug!(%error, "the schemas could not be read for the sidebar"); + None + } + Err(failure) => { + tracing::debug!(%failure, "the schema read for the sidebar did not finish"); + None + } + }; + input.deliver(WorkspaceInput::SidebarRead { read, objects, schemas }); + }, + ); + } + + pub(super) fn on_sidebar_read( + &mut self, + read: u64, + objects: &Arc<[ObjectInfo]>, + schemas: Option>, + ) { + if read != self.sidebar_read { + return; + } + if let Some(schemas) = schemas { + self.catalog_schemas = shown_schemas(&schemas, objects).into(); + } + self.sidebar.apply_objects(objects, &self.catalog_schemas); + self.on_table_naming(TableNaming::of(&self.catalog_schemas)); + } + + /// Every schema's list landed: a reconnect, a schema change or the + /// first read. + pub(super) fn on_catalog_changed(&mut self, snapshot: ObjectListSnapshot) { + self.all_schemas_listed = true; + self.read_sidebar(snapshot.objects); + } + + pub(super) fn on_catalog_read_failed(&self, message: &str) { + self.show_toast(&crate::i18n::gettext_f( + "Could not refresh the table list: {error}", + &[("error", message)], + )); + } + + /// A reconnect committed the default schema's list and its schemas, + /// and left every other list stale, so the sidebar's is read again. + /// The schemas are read now, under the list on screen, so they name + /// the tabs even when every schema's list cannot be read again. + /// Until every schema's list first lands, the default schema's is + /// what the sidebar shows. + pub(super) fn on_default_list_committed(&mut self, snapshot: ObjectListSnapshot) { + let objects = match (self.all_schemas_listed, self.context.active.get()) { + (true, Some(session)) => session + .catalog() + .subscribe(ObjectScope::AllSchemas) + .borrow() + .objects + .clone(), + _ => snapshot.objects, + }; + self.read_sidebar(objects); + self.list_all_schemas(); + } + + /// The connection was replaced, perhaps by one to another server + /// version, so what the sidebar offers is read again. + pub(super) fn on_link_changed(&self) { + if let Some(session) = self.context.active.get() { + self.sidebar.set_affordances(SidebarAffordances::for_session(&session)); + } + } + + /// With no table tab in front, no row stays selected: one would say + /// the editor shows that table. + pub(super) fn sync_sidebar_selection(&self) { + let table = self + .selected_browse_slot_table() + .map(|(schema, name)| TableRef::new(schema, name)); + self.sidebar.sync_selection(table.as_ref()); + } +} diff --git a/linux/crates/app/src/ui/workspace/sidebar/object_key.rs b/linux/crates/app/src/ui/workspace/sidebar/object_key.rs new file mode 100644 index 0000000000..aa95d78c35 --- /dev/null +++ b/linux/crates/app/src/ui/workspace/sidebar/object_key.rs @@ -0,0 +1,61 @@ +use std::borrow::Cow; + +use gtk4::glib; +use gtk4::prelude::*; +use tablepro_core::meta::TableRef; + +/// The table a sidebar menu item or action names, as its `(mss)` target: +/// the schema or nothing, then the name. The target travels through +/// GAction, so it is checked again where an action reads it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct ObjectKey(pub TableRef); + +impl ObjectKey { + pub(crate) fn variant_type() -> Cow<'static, glib::VariantTy> { + <(Option, String)>::static_variant_type() + } + + pub(crate) fn to_variant(&self) -> glib::Variant { + (self.0.schema.clone(), self.0.name.clone()).to_variant() + } + + /// `None` for a target of another shape, an empty name, or an empty + /// schema, which no engine has. + pub(crate) fn from_variant(variant: &glib::Variant) -> Option { + let (schema, name) = variant.get::<(Option, String)>()?; + if name.is_empty() || schema.as_deref().is_some_and(str::is_empty) { + return None; + } + Some(Self(TableRef::new(schema, name))) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn object_key_variant_round_trips() { + let qualified = ObjectKey(TableRef::new(Some("sales".to_owned()), "orders")); + let bare = ObjectKey(TableRef::unqualified("notes")); + + assert_eq!(qualified.to_variant().type_().as_str(), "(mss)"); + assert_eq!(ObjectKey::variant_type().as_str(), "(mss)"); + assert_eq!(ObjectKey::from_variant(&qualified.to_variant()), Some(qualified)); + assert_eq!(ObjectKey::from_variant(&bare.to_variant()), Some(bare)); + } + + #[test] + fn a_malformed_target_gives_no_key() { + assert_eq!(ObjectKey::from_variant(&"orders".to_variant()), None); + assert_eq!(ObjectKey::from_variant(&("sales", "orders").to_variant()), None); + assert_eq!( + ObjectKey::from_variant(&(None::, String::new()).to_variant()), + None + ); + assert_eq!( + ObjectKey::from_variant(&(Some(String::new()), "orders".to_owned()).to_variant()), + None + ); + } +} diff --git a/linux/crates/app/src/ui/workspace/sidebar/sidebar_affordances.rs b/linux/crates/app/src/ui/workspace/sidebar/sidebar_affordances.rs new file mode 100644 index 0000000000..58908200b8 --- /dev/null +++ b/linux/crates/app/src/ui/workspace/sidebar/sidebar_affordances.rs @@ -0,0 +1,73 @@ +use tablepro_core::dialect::SqlDialect; +use tablepro_session::Session; + +/// What the sidebar offers for the connection: the structure tab's +/// actions follow its DDL dialect, the way the structure tab does, and +/// a read-only connection is offered nothing that writes. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub(crate) struct SidebarAffordances { + pub read_only: bool, + pub can_create_table: bool, + pub can_edit_structure: bool, +} + +impl SidebarAffordances { + pub(crate) fn of(read_only: bool, dialect: Option<&dyn SqlDialect>) -> Self { + let ddl = dialect.and_then(|dialect| dialect.ddl()); + Self { + read_only, + can_create_table: ddl.is_some_and(|ddl| ddl.capabilities().create_table), + can_edit_structure: ddl.is_some(), + } + } + + /// Read again after each reconnect, which can reach another server + /// version. + pub(crate) fn for_session(session: &Session) -> Self { + Self::of(session.read_only(), session.current_dialect().as_deref()) + } + + /// Whether a section header has its New Table button. + pub(crate) fn offers_new_table(self) -> bool { + self.can_create_table && !self.read_only + } +} + +#[cfg(test)] +mod tests { + use tablepro_core::ServerInfo; + use tablepro_test_support::FakeDialect; + + use super::*; + + #[test] + fn affordances_from_ddl_capabilities() { + let postgres = drivers_postgres::PgDialect::new(&ServerInfo::new("PostgreSQL", "18.0", 18, 0, 0)); + let clickhouse = drivers_clickhouse::ClickHouseDialect::new(&ServerInfo::new("ClickHouse", "25.8", 25, 8, 0)); + + let none = SidebarAffordances::of(false, Some(&FakeDialect)); + let creates = SidebarAffordances::of(false, Some(&postgres)); + let edits_only = SidebarAffordances::of(false, Some(&clickhouse)); + let gone = SidebarAffordances::of(false, None); + + assert!(!none.can_edit_structure && !none.offers_new_table()); + assert!(creates.can_edit_structure && creates.offers_new_table()); + assert!(edits_only.can_edit_structure); + assert!( + !edits_only.offers_new_table(), + "ClickHouse tables are created in the editor" + ); + assert_eq!(gone, SidebarAffordances::default()); + } + + #[test] + fn affordances_hide_drop_when_read_only() { + let postgres = drivers_postgres::PgDialect::new(&ServerInfo::new("PostgreSQL", "18.0", 18, 0, 0)); + + let read_only = SidebarAffordances::of(true, Some(&postgres)); + + assert!(read_only.read_only); + assert!(read_only.can_create_table, "the dialect still creates tables"); + assert!(!read_only.offers_new_table()); + } +} diff --git a/linux/crates/app/src/ui/workspace/sidebar/sidebar_event.rs b/linux/crates/app/src/ui/workspace/sidebar/sidebar_event.rs new file mode 100644 index 0000000000..ca7c294729 --- /dev/null +++ b/linux/crates/app/src/ui/workspace/sidebar/sidebar_event.rs @@ -0,0 +1,15 @@ +use tablepro_core::meta::TableRef; + +/// What the user asked the sidebar for. The workspace carries it out. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum SidebarEvent { + /// A click, Enter or a double click: the table's tab, or a new one. + Open(TableRef), + OpenInNewTab(TableRef), + EditStructure(TableRef), + ShowCreateTable(TableRef), + ImportCsv(TableRef), + DropTable(TableRef), + /// A new table in this schema, or the default one. + NewTable(Option), +} diff --git a/linux/crates/app/src/ui/workspace/sidebar/sidebar_header_factory.rs b/linux/crates/app/src/ui/workspace/sidebar/sidebar_header_factory.rs new file mode 100644 index 0000000000..e6eedc7031 --- /dev/null +++ b/linux/crates/app/src/ui/workspace/sidebar/sidebar_header_factory.rs @@ -0,0 +1,71 @@ +use std::rc::Weak; + +use gtk4 as gtk; +use gtk4::prelude::*; + +use super::sidebar_menu::{GROUP, NEW_TABLE}; +use super::sidebar_object::SidebarObject; +use super::sidebar_view::SidebarInner; +use crate::ui::a11y::describe_icon_button; + +/// A schema's heading, with its New Table button where the connection +/// creates tables. ClickHouse does not: a table there needs an engine and +/// a sorting key the structure tab has no place for, so the button would +/// only fail. +pub(super) fn header_factory(view: Weak) -> gtk::SignalListItemFactory { + let factory = gtk::SignalListItemFactory::new(); + factory.connect_setup(|_, object| { + let Some(header) = object.downcast_ref::() else { + return; + }; + let row = gtk::Box::builder() + .orientation(gtk::Orientation::Horizontal) + .spacing(6) + .margin_top(12) + .margin_bottom(6) + .margin_start(12) + .margin_end(12) + .build(); + let title = gtk::Label::builder().xalign(0.0).hexpand(true).build(); + // GtkPlacesSidebar's section header: small, bold and dim. + title.add_css_class("caption-heading"); + title.add_css_class("dimmed"); + let add = gtk::Button::builder() + .icon_name(crate::ui::icons::LIST_ADD) + .valign(gtk::Align::Center) + .build(); + add.add_css_class("flat"); + // The target first, or GTK warns that the action takes one. + add.set_action_target_value(Some(&None::.to_variant())); + add.set_action_name(Some(&format!("{GROUP}.{NEW_TABLE}"))); + row.append(&title); + row.append(&add); + header.set_child(Some(&row)); + }); + factory.connect_bind(move |_, object| { + let Some(header) = object.downcast_ref::() else { + return; + }; + let (Some(row), Some(object)) = (header.child(), header.item().and_downcast::()) else { + return; + }; + let title = object.section_title(); + if let Some(label) = row.first_child().and_downcast::() { + label.set_label(&title); + } + let Some(add) = row.last_child().and_downcast::() else { + return; + }; + let offered = view.upgrade().is_some_and(|view| view.affordances().offers_new_table()); + add.set_visible(offered); + let schema = object.schema(); + add.set_action_target_value(Some(&schema.to_variant())); + // The label says which schema the table goes in. + let label = match schema { + Some(_) => crate::i18n::gettext_f("New Table in {schema}…", &[("schema", &title)]), + None => crate::i18n::gettext("New Table…"), + }; + describe_icon_button(&add, &label, None); + }); + factory +} diff --git a/linux/crates/app/src/ui/workspace/sidebar/sidebar_menu.rs b/linux/crates/app/src/ui/workspace/sidebar/sidebar_menu.rs new file mode 100644 index 0000000000..44b98727eb --- /dev/null +++ b/linux/crates/app/src/ui/workspace/sidebar/sidebar_menu.rs @@ -0,0 +1,162 @@ +use gtk4::gio; +use tablepro_core::meta::ObjectKind; + +use super::object_key::ObjectKey; +use super::sidebar_affordances::SidebarAffordances; + +pub(super) const OPEN_IN_NEW_TAB: &str = "open-in-new-tab"; +pub(super) const EDIT_STRUCTURE: &str = "edit-structure"; +pub(super) const SHOW_CREATE_TABLE: &str = "show-create-table"; +pub(super) const IMPORT_CSV: &str = "import-csv"; +pub(super) const DROP_TABLE: &str = "drop-table"; +pub(super) const NEW_TABLE: &str = "new-table"; +pub(super) const CONTEXT_MENU: &str = "context-menu"; +pub(super) const GROUP: &str = "sidebar"; + +/// The menu of one table. Every item carries the table's key, so the one +/// popover serves every row and acts on the table it opened for, even +/// after the list changes under it. What writes sits in sections of its +/// own, and a read-only connection is not offered it. A view's shape +/// comes from its query, so only a table offers to change or drop it. +pub(crate) fn object_menu(key: &ObjectKey, kind: ObjectKind, affordances: SidebarAffordances) -> gio::Menu { + let menu = gio::Menu::new(); + let open = gio::Menu::new(); + open.append_item(&item(&crate::i18n::gettext("Open in New Tab"), OPEN_IN_NEW_TAB, key)); + menu.append_section(None, &open); + let table = kind.is_structure_editable(); + let structure = gio::Menu::new(); + if table && affordances.can_edit_structure { + structure.append_item(&item(&crate::i18n::gettext("Edit Structure"), EDIT_STRUCTURE, key)); + } + structure.append_item(&item( + &crate::i18n::gettext("Show CREATE TABLE"), + SHOW_CREATE_TABLE, + key, + )); + menu.append_section(None, &structure); + if affordances.read_only || !table { + return menu; + } + let data = gio::Menu::new(); + data.append_item(&item(&crate::i18n::gettext("Import CSV…"), IMPORT_CSV, key)); + menu.append_section(None, &data); + let danger = gio::Menu::new(); + danger.append_item(&item(&crate::i18n::gettext("Drop Table…"), DROP_TABLE, key)); + menu.append_section(None, &danger); + menu +} + +/// Whether `action` is offered for a `kind` on a connection with +/// `affordances`, as `object_menu` offers it. +pub(super) fn offers(action: &str, kind: ObjectKind, affordances: SidebarAffordances) -> bool { + let table = kind.is_structure_editable(); + match action { + OPEN_IN_NEW_TAB | SHOW_CREATE_TABLE => true, + EDIT_STRUCTURE => table && affordances.can_edit_structure, + IMPORT_CSV | DROP_TABLE => table && !affordances.read_only, + _ => false, + } +} + +fn item(label: &str, action: &str, key: &ObjectKey) -> gio::MenuItem { + let item = gio::MenuItem::new(Some(label), None); + item.set_action_and_target_value(Some(&format!("{GROUP}.{action}")), Some(&key.to_variant())); + item +} + +#[cfg(test)] +mod tests { + use gtk4::glib; + use gtk4::prelude::*; + use tablepro_core::meta::TableRef; + + use super::*; + + /// Each item's action and target, in menu order, sections flattened. + fn items(menu: &gio::MenuModel) -> Vec<(String, Option)> { + let mut found = Vec::new(); + for index in 0..menu.n_items() { + if let Some(action) = menu + .item_attribute_value(index, "action", None) + .and_then(|value| value.get::()) + { + found.push((action, menu.item_attribute_value(index, "target", None))); + } + if let Some(section) = menu.item_link(index, "section") { + found.extend(items(§ion)); + } + } + found + } + + fn actions(menu: &gio::Menu) -> Vec { + items(menu.upcast_ref()).into_iter().map(|(action, _)| action).collect() + } + + fn all() -> SidebarAffordances { + SidebarAffordances { + read_only: false, + can_create_table: true, + can_edit_structure: true, + } + } + + #[test] + fn every_item_carries_the_object_key() { + let key = ObjectKey(TableRef::new(Some("sales".to_owned()), "orders")); + + let menu = object_menu(&key, ObjectKind::Table, all()); + + let found = items(menu.upcast_ref()); + assert_eq!(found.len(), 5); + for (action, target) in found { + let target = target.unwrap_or_else(|| panic!("{action} has no target")); + assert_eq!(ObjectKey::from_variant(&target).as_ref(), Some(&key), "{action}"); + } + } + + #[test] + fn a_read_only_connection_is_offered_nothing_that_writes() { + let key = ObjectKey(TableRef::unqualified("orders")); + let read_only = SidebarAffordances { + read_only: true, + ..all() + }; + + assert_eq!( + actions(&object_menu(&key, ObjectKind::Table, read_only)), + [ + "sidebar.open-in-new-tab", + "sidebar.edit-structure", + "sidebar.show-create-table" + ] + ); + } + + #[test] + fn edit_structure_follows_the_dialect() { + let key = ObjectKey(TableRef::unqualified("orders")); + let without = SidebarAffordances { + can_edit_structure: false, + ..all() + }; + + assert!( + !actions(&object_menu(&key, ObjectKind::Table, without)).contains(&"sidebar.edit-structure".to_owned()) + ); + assert!(actions(&object_menu(&key, ObjectKind::Table, all())).contains(&"sidebar.drop-table".to_owned())); + } + + #[test] + fn a_view_offers_no_structure_change() { + let key = ObjectKey(TableRef::unqualified("active_users")); + + let offered = actions(&object_menu(&key, ObjectKind::View, all())); + + assert_eq!(offered, ["sidebar.open-in-new-tab", "sidebar.show-create-table"]); + for action in [EDIT_STRUCTURE, IMPORT_CSV, DROP_TABLE] { + assert!(!offers(action, ObjectKind::View, all()), "{action}"); + assert!(offers(action, ObjectKind::PartitionedTable, all()), "{action}"); + } + } +} diff --git a/linux/crates/app/src/ui/workspace/sidebar/sidebar_model.rs b/linux/crates/app/src/ui/workspace/sidebar/sidebar_model.rs new file mode 100644 index 0000000000..725f78c715 --- /dev/null +++ b/linux/crates/app/src/ui/workspace/sidebar/sidebar_model.rs @@ -0,0 +1,298 @@ +use gtk4 as gtk; +use gtk4::gio; +use gtk4::prelude::*; +use tablepro_core::meta::{ObjectInfo, ObjectKind, SchemaInfo, TableRef}; + +use super::sidebar_object::SidebarObject; + +/// The sidebar's objects, sorted into schema sections and filtered by +/// the search. A new object list replaces the store in one splice, and a +/// search changes only the filter, so neither builds a widget per table. +pub(crate) struct SidebarModel { + store: gio::ListStore, + filter: gtk::StringFilter, + filtered: gtk::FilterListModel, + selection: gtk::SingleSelection, +} + +impl SidebarModel { + pub(crate) fn new() -> Self { + let store = gio::ListStore::new::(); + let by_name = gtk::StringSorter::new(Some(property("name"))); + by_name.set_ignore_case(true); + let sorted = gtk::SortListModel::new(Some(store.clone()), Some(by_name)); + let sections = gtk::MultiSorter::new(); + sections.append(gtk::NumericSorter::new(Some(property("section-rank")))); + sections.append(gtk::StringSorter::new(Some(property("section-title")))); + sorted.set_section_sorter(Some(§ions)); + let filter = gtk::StringFilter::new(Some(property("search-text"))); + filter.set_match_mode(gtk::StringFilterMatchMode::Substring); + filter.set_ignore_case(true); + let filtered = gtk::FilterListModel::new(Some(sorted), Some(filter.clone())); + let selection = gtk::SingleSelection::new(Some(filtered.clone())); + selection.set_autoselect(false); + selection.set_can_unselect(true); + Self { + store, + filter, + filtered, + selection, + } + } + + /// Show `objects`, keeping only what the grid can page through. + pub(crate) fn replace(&self, objects: &[ObjectInfo], schemas: &[SchemaInfo]) { + let shown: Vec = objects + .iter() + .filter(|object| object.kind.is_browsable()) + .map(|object| SidebarObject::new(object, schemas)) + .collect(); + self.store.splice(0, self.store.n_items(), &shown); + } + + pub(crate) fn set_query(&self, query: &str) { + let query = query.trim(); + if self.filter.search().as_deref().unwrap_or_default() == query { + return; + } + self.filter.set_search(Some(query).filter(|query| !query.is_empty())); + } + + pub(crate) fn has_query(&self) -> bool { + self.filter.search().is_some_and(|search| !search.is_empty()) + } + + /// Where `table` shows under the search, if it does. + pub(crate) fn position_of(&self, table: &TableRef) -> Option { + (0..self.filtered.n_items()).find(|&position| { + self.object_at(position) + .is_some_and(|object| object.table_ref() == *table) + }) + } + + /// The kind of `table` when it is listed at all, searched for or not. + pub(crate) fn kind_of(&self, table: &TableRef) -> Option { + (0..self.store.n_items()).find_map(|position| { + self.store + .item(position) + .and_downcast::() + .filter(|object| object.table_ref() == *table) + .map(|object| object.kind()) + }) + } + + /// Select `table`'s row, or none. A table the search hides selects + /// none. + pub(crate) fn select(&self, table: Option<&TableRef>) { + let position = table + .and_then(|table| self.position_of(table)) + .unwrap_or(gtk::INVALID_LIST_POSITION); + if self.selection.selected() != position { + self.selection.set_selected(position); + } + } + + pub(crate) fn object_at(&self, position: u32) -> Option { + self.filtered.item(position).and_downcast::() + } + + pub(crate) fn selection(&self) -> >k::SingleSelection { + &self.selection + } + + pub(crate) fn shown(&self) -> u32 { + self.filtered.n_items() + } + + #[cfg(test)] + pub(crate) fn store(&self) -> &gio::ListStore { + &self.store + } +} + +fn property(name: &str) -> gtk::PropertyExpression { + gtk::PropertyExpression::new(SidebarObject::static_type(), None::<>k::Expression>, name) +} + +#[cfg(test)] +mod tests { + use std::cell::Cell; + use std::rc::Rc; + + use super::*; + + fn object(schema: &str, name: &str) -> ObjectInfo { + ObjectInfo { + schema: Some(schema.to_owned()), + name: name.to_owned(), + kind: ObjectKind::Table, + } + } + + fn schema(name: &str, is_default: bool, is_system: bool) -> SchemaInfo { + SchemaInfo { + name: name.to_owned(), + is_default, + is_system, + } + } + + /// Each section's title and its rows' names, in list order. + fn sections(model: &SidebarModel) -> Vec<(String, Vec)> { + let shown = model.selection(); + let mut found = Vec::new(); + let mut position = 0; + while position < shown.n_items() { + let (start, end) = shown.section(position); + let title = model + .object_at(start) + .map(|object| object.section_title()) + .unwrap_or_default(); + let names = (start..end) + .filter_map(|at| model.object_at(at)) + .map(|object| object.name()) + .collect(); + found.push((title, names)); + position = end; + } + found + } + + #[gtk4::test] + fn sections_put_default_schema_first_then_alphabetical_then_system() { + let model = SidebarModel::new(); + let schemas = [ + schema("public", true, false), + schema("information_schema", false, true), + schema("sales", false, false), + schema("audit", false, false), + ]; + + model.replace( + &[ + object("sales", "orders"), + object("information_schema", "tables"), + object("public", "users"), + object("audit", "log"), + object("public", "Accounts"), + object("sales", "Customers"), + ], + &schemas, + ); + + assert_eq!( + sections(&model), + [ + ("public".to_owned(), vec!["Accounts".to_owned(), "users".to_owned()]), + ("audit".to_owned(), vec!["log".to_owned()]), + ("sales".to_owned(), vec!["Customers".to_owned(), "orders".to_owned()]), + ("information_schema".to_owned(), vec!["tables".to_owned()]), + ] + ); + } + + #[gtk4::test] + fn replace_emits_one_items_changed() { + let model = SidebarModel::new(); + model.replace(&[object("public", "old")], &[]); + let objects: Vec = (0..5_000) + .map(|index| object(if index % 2 == 0 { "public" } else { "sales" }, &format!("t{index}"))) + .collect(); + let stored = Rc::new(Cell::new(0)); + let shown = Rc::new(Cell::new(0)); + let count = stored.clone(); + model + .store() + .connect_items_changed(move |_, _, _, _| count.set(count.get() + 1)); + let count = shown.clone(); + model + .selection() + .connect_items_changed(move |_, _, _, _| count.set(count.get() + 1)); + + model.replace(&objects, &[schema("public", true, false)]); + + assert_eq!(stored.get(), 1, "the store changed more than once"); + assert_eq!(shown.get(), 1, "the list view was told more than once"); + assert_eq!(model.shown(), 5_000); + } + + #[gtk4::test] + fn search_filters_case_insensitively_without_touching_store() { + let model = SidebarModel::new(); + model.replace( + &[ + object("public", "Users"), + object("auth", "tokens"), + object("public", "orders"), + ], + &[schema("public", true, false)], + ); + let changed = Rc::new(Cell::new(0)); + let count = changed.clone(); + model + .store() + .connect_items_changed(move |_, _, _, _| count.set(count.get() + 1)); + + model.set_query("USE"); + let names = |model: &SidebarModel| -> Vec { + (0..model.shown()) + .filter_map(|at| model.object_at(at)) + .map(|object| object.name()) + .collect() + }; + assert_eq!(names(&model), ["Users"]); + model.set_query("auth.TOK"); + assert_eq!(names(&model), ["tokens"]); + model.set_query("auth"); + assert_eq!(names(&model), ["tokens"], "the schema matches too"); + model.set_query(""); + assert_eq!(model.shown(), 3); + + assert_eq!(changed.get(), 0, "a search rebuilt the store"); + assert_eq!(model.store().n_items(), 3); + } + + #[gtk4::test] + fn only_what_the_grid_can_page_is_listed() { + let model = SidebarModel::new(); + let routine = ObjectInfo { + kind: ObjectKind::Function, + ..object("public", "refresh") + }; + let view = ObjectInfo { + kind: ObjectKind::View, + ..object("public", "active_users") + }; + + model.replace(&[object("public", "users"), routine, view], &[]); + + assert_eq!(model.shown(), 2); + assert_eq!( + model.kind_of(&TableRef::new(Some("public".to_owned()), "refresh")), + None + ); + assert_eq!( + model.kind_of(&TableRef::new(Some("public".to_owned()), "active_users")), + Some(ObjectKind::View) + ); + } + + #[gtk4::test] + fn select_finds_the_table_under_the_search_or_clears() { + let model = SidebarModel::new(); + model.replace(&[object("public", "orders"), object("public", "users")], &[]); + let users = TableRef::new(Some("public".to_owned()), "users"); + + model.select(Some(&users)); + assert_eq!(model.selection().selected(), 1); + + model.set_query("ord"); + model.select(Some(&users)); + assert_eq!(model.selection().selected(), gtk::INVALID_LIST_POSITION); + + model.set_query(""); + model.select(Some(&users)); + model.select(None); + assert_eq!(model.selection().selected(), gtk::INVALID_LIST_POSITION); + } +} diff --git a/linux/crates/app/src/ui/workspace/sidebar/sidebar_object.rs b/linux/crates/app/src/ui/workspace/sidebar/sidebar_object.rs new file mode 100644 index 0000000000..1b3148f956 --- /dev/null +++ b/linux/crates/app/src/ui/workspace/sidebar/sidebar_object.rs @@ -0,0 +1,160 @@ +use gtk4::glib; +use gtk4::subclass::prelude::*; +use tablepro_core::meta::{ObjectInfo, ObjectKind, SchemaInfo, TableRef}; + +use super::sidebar_object_imp as imp; + +glib::wrapper! { + /// One table or view as the sidebar's list model holds it. It never + /// changes: a new object list replaces every object. + pub struct SidebarObject(ObjectSubclass); +} + +/// The section a schema's objects go in. +const DEFAULT_RANK: u32 = 0; +const OTHER_RANK: u32 = 1; +const SYSTEM_RANK: u32 = 2; + +impl SidebarObject { + /// `schemas` places the object's section: an object without a + /// schema is in the connection's default one. + pub(crate) fn new(object: &ObjectInfo, schemas: &[SchemaInfo]) -> Self { + let this: Self = glib::Object::new(); + let imp = this.imp(); + let table = TableRef::new(object.schema.clone(), object.name.clone()); + let (title, rank) = section_of(object.schema.as_deref(), schemas); + let search = match &table.schema { + Some(schema) => format!("{schema}.{}", table.name), + None => table.name.clone(), + }; + let _ = imp.search.set(search.to_lowercase()); + let _ = imp.title.set(title); + imp.rank.set(rank); + let _ = imp.kind.set(object.kind); + let _ = imp.table.set(table); + this + } + + /// Always set: `new` is the only way to build one. + pub(crate) fn table_ref(&self) -> TableRef { + self.imp() + .table + .get() + .cloned() + .unwrap_or_else(|| TableRef::unqualified(String::new())) + } + + pub(crate) fn kind(&self) -> ObjectKind { + self.imp().kind.get().copied().unwrap_or(ObjectKind::Table) + } + + /// What the row's tooltip calls the table. + pub(crate) fn qualified_name(&self) -> String { + let table = self.table_ref(); + match table.schema { + Some(schema) => format!("{schema}.{}", table.name), + None => table.name, + } + } + + /// What a screen reader says the row is, which sighted users read + /// from its icon. + pub(crate) fn kind_name(&self) -> String { + match self.kind() { + ObjectKind::View => crate::i18n::pgettext("object kind", "View"), + ObjectKind::MaterializedView => crate::i18n::pgettext("object kind", "Materialized View"), + _ => crate::i18n::pgettext("object kind", "Table"), + } + } +} + +fn section_of(schema: Option<&str>, schemas: &[SchemaInfo]) -> (String, u32) { + let Some(schema) = schema else { + let title = schemas + .iter() + .find(|known| known.is_default) + .map_or_else(|| crate::i18n::gettext("Tables"), |known| known.name.clone()); + return (title, DEFAULT_RANK); + }; + let rank = match schemas.iter().find(|known| known.name == schema) { + Some(known) if known.is_default => DEFAULT_RANK, + Some(known) if known.is_system => SYSTEM_RANK, + _ => OTHER_RANK, + }; + (schema.to_owned(), rank) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn schema(name: &str, is_default: bool, is_system: bool) -> SchemaInfo { + SchemaInfo { + name: name.to_owned(), + is_default, + is_system, + } + } + + fn object(schema: Option<&str>, name: &str, kind: ObjectKind) -> ObjectInfo { + ObjectInfo { + schema: schema.map(str::to_owned), + name: name.to_owned(), + kind, + } + } + + #[gtk4::test] + fn an_object_carries_its_section_icon_and_search_text() { + let schemas = [ + schema("public", true, false), + schema("pg_catalog", false, true), + schema("sales", false, false), + ]; + + let default = SidebarObject::new(&object(Some("public"), "Users", ObjectKind::Table), &schemas); + let other = SidebarObject::new(&object(Some("sales"), "totals", ObjectKind::MaterializedView), &schemas); + let system = SidebarObject::new(&object(Some("pg_catalog"), "pg_class", ObjectKind::View), &schemas); + + assert_eq!( + (default.section_title(), default.section_rank()), + ("public".to_owned(), 0) + ); + assert_eq!((other.section_title(), other.section_rank()), ("sales".to_owned(), 1)); + assert_eq!(system.section_rank(), 2); + assert_eq!(default.search_text(), "public.users"); + assert_eq!(default.icon_name(), "view-grid-symbolic"); + assert_eq!(other.icon_name(), "view-paged-symbolic"); + assert_eq!(system.icon_name(), "view-list-symbolic"); + assert_eq!( + [default.kind_name(), other.kind_name(), system.kind_name()], + ["Table", "Materialized View", "View"] + ); + assert_eq!(default.table_ref(), TableRef::new(Some("public".to_owned()), "Users")); + } + + #[gtk4::test] + fn an_object_without_a_schema_is_in_the_default_section() { + let attached = [schema("main", true, false), schema("archive", false, false)]; + + let main = SidebarObject::new(&object(None, "notes", ObjectKind::Table), &attached); + let bare = SidebarObject::new(&object(None, "notes", ObjectKind::Table), &[]); + + assert_eq!((main.section_title(), main.section_rank()), ("main".to_owned(), 0)); + assert_eq!(main.schema(), None); + assert_eq!(main.search_text(), "notes"); + assert_eq!(bare.section_title(), "Tables"); + } + + #[gtk4::test] + fn a_schema_the_list_does_not_name_is_an_ordinary_section() { + let listed = [schema("shop", true, false)]; + + let other = SidebarObject::new(&object(Some("analytics"), "events", ObjectKind::Table), &listed); + + assert_eq!( + (other.section_title(), other.section_rank()), + ("analytics".to_owned(), 1) + ); + } +} diff --git a/linux/crates/app/src/ui/workspace/sidebar/sidebar_object_imp.rs b/linux/crates/app/src/ui/workspace/sidebar/sidebar_object_imp.rs new file mode 100644 index 0000000000..42f8674039 --- /dev/null +++ b/linux/crates/app/src/ui/workspace/sidebar/sidebar_object_imp.rs @@ -0,0 +1,73 @@ +use std::cell::{Cell, OnceCell}; +use std::marker::PhantomData; + +use gtk4::glib; +use gtk4::prelude::*; +use gtk4::subclass::prelude::*; +use tablepro_core::meta::{ObjectKind, TableRef}; + +#[derive(Default, glib::Properties)] +#[properties(wrapper_type = super::sidebar_object::SidebarObject)] +pub struct SidebarObject { + pub(super) table: OnceCell, + pub(super) kind: OnceCell, + pub(super) title: OnceCell, + pub(super) rank: Cell, + pub(super) search: OnceCell, + #[property(get = Self::schema)] + schema: PhantomData>, + #[property(get = Self::name)] + name: PhantomData, + #[property(get = Self::icon_name)] + icon_name: PhantomData, + /// The schema's heading, which groups the rows into sections. + #[property(get = Self::section_title)] + section_title: PhantomData, + /// 0 for the default schema, 1 for the others, 2 for the engine's + /// own, so sections sort in that order before their titles. + #[property(get = Self::section_rank)] + section_rank: PhantomData, + /// `schema.name` in lowercase, which the search matches against. + #[property(get = Self::search_text)] + search_text: PhantomData, +} + +#[glib::object_subclass] +impl ObjectSubclass for SidebarObject { + const NAME: &'static str = "TableProSidebarObject"; + type Type = super::sidebar_object::SidebarObject; +} + +#[glib::derived_properties] +impl ObjectImpl for SidebarObject {} + +impl SidebarObject { + fn schema(&self) -> Option { + self.table.get().and_then(|table| table.schema.clone()) + } + + fn name(&self) -> String { + self.table.get().map(|table| table.name.clone()).unwrap_or_default() + } + + fn icon_name(&self) -> String { + let icon = match self.kind.get() { + Some(ObjectKind::View) => crate::ui::icons::VIEW_LIST, + Some(ObjectKind::MaterializedView) => crate::ui::icons::VIEW_PAGED, + _ => crate::ui::icons::VIEW_GRID, + }; + icon.to_owned() + } + + fn section_title(&self) -> String { + self.title.get().cloned().unwrap_or_default() + } + + fn section_rank(&self) -> u32 { + self.rank.get() + } + + fn search_text(&self) -> String { + self.search.get().cloned().unwrap_or_default() + } +} diff --git a/linux/crates/app/src/ui/workspace/sidebar/sidebar_row_factory.rs b/linux/crates/app/src/ui/workspace/sidebar/sidebar_row_factory.rs new file mode 100644 index 0000000000..34b5dc8022 --- /dev/null +++ b/linux/crates/app/src/ui/workspace/sidebar/sidebar_row_factory.rs @@ -0,0 +1,91 @@ +use std::rc::Weak; + +use gtk4 as gtk; +use gtk4::prelude::*; +use gtk4::{gdk, graphene, pango}; + +use super::sidebar_object::SidebarObject; +use super::sidebar_view::SidebarInner; + +/// Builds the rows the list view recycles: an icon and the table's name, +/// with one click gesture for what activation does not cover. +pub(super) fn row_factory(view: Weak) -> gtk::SignalListItemFactory { + let factory = gtk::SignalListItemFactory::new(); + factory.connect_setup(move |_, object| { + let Some(item) = object.downcast_ref::() else { + return; + }; + let row = gtk::Box::builder() + .orientation(gtk::Orientation::Horizontal) + .spacing(8) + .build(); + // The name says what the row is; the icon only tells a view from + // a table at a glance. + let icon = gtk::Image::builder() + .pixel_size(16) + .accessible_role(gtk::AccessibleRole::Presentation) + .build(); + let label = gtk::Label::builder() + .xalign(0.0) + .hexpand(true) + .ellipsize(pango::EllipsizeMode::End) + .build(); + row.append(&icon); + row.append(&label); + row.add_controller(click_gesture(view.clone(), item)); + item.set_child(Some(&row)); + if let Some(view) = view.upgrade() { + view.track_row(item); + } + }); + factory.connect_bind(|_, object| { + let Some(item) = object.downcast_ref::() else { + return; + }; + let (Some(row), Some(object)) = (item.child(), item.item().and_downcast::()) else { + return; + }; + if let Some(icon) = row.first_child().and_downcast::() { + icon.set_icon_name(Some(&object.icon_name())); + } + if let Some(label) = row.last_child().and_downcast::() { + label.set_label(&object.name()); + } + row.set_tooltip_text(Some(&object.qualified_name())); + item.set_accessible_description(&object.kind_name()); + }); + factory +} + +/// Button 0, so one gesture sees every button. It claims only what it +/// handles, which cancels the row's own click for that press. +fn click_gesture(view: Weak, item: >k::ListItem) -> gtk::GestureClick { + let gesture = gtk::GestureClick::new(); + gesture.set_button(0); + let (pressed_view, pressed_item) = (view.clone(), item.downgrade()); + gesture.connect_pressed(move |gesture, _, x, y| { + let (Some(view), Some(item), Some(row)) = (pressed_view.upgrade(), pressed_item.upgrade(), gesture.widget()) + else { + return; + }; + let at = row.compute_point(&view.list_view, &graphene::Point::new(x as f32, y as f32)); + let pointing = at.map(|point| gdk::Rectangle::new(point.x() as i32, point.y() as i32, 1, 1)); + if view.pressed( + item.position(), + gesture.current_button(), + gesture.current_event_state(), + pointing, + ) { + gesture.set_state(gtk::EventSequenceState::Claimed); + } + }); + gesture.connect_released(move |gesture, _, _, _| { + let Some(view) = view.upgrade() else { + return; + }; + if view.released(gesture.current_button()) { + gesture.set_state(gtk::EventSequenceState::Claimed); + } + }); + gesture +} diff --git a/linux/crates/app/src/ui/workspace/sidebar/sidebar_view.rs b/linux/crates/app/src/ui/workspace/sidebar/sidebar_view.rs new file mode 100644 index 0000000000..8ede0c2578 --- /dev/null +++ b/linux/crates/app/src/ui/workspace/sidebar/sidebar_view.rs @@ -0,0 +1,465 @@ +use std::cell::{Cell, RefCell}; +use std::rc::{Rc, Weak}; + +use gtk4 as gtk; +use gtk4::prelude::*; +use gtk4::{gdk, gio, glib}; +use libadwaita as adw; +use tablepro_core::meta::{ObjectInfo, SchemaInfo, TableRef}; + +use super::object_key::ObjectKey; +use super::sidebar_affordances::SidebarAffordances; +use super::sidebar_event::SidebarEvent; +use super::sidebar_header_factory::header_factory; +use super::sidebar_menu::{ + CONTEXT_MENU, DROP_TABLE, EDIT_STRUCTURE, GROUP, IMPORT_CSV, NEW_TABLE, OPEN_IN_NEW_TAB, SHOW_CREATE_TABLE, + object_menu, offers, +}; +use super::sidebar_model::SidebarModel; +use super::sidebar_row_factory::row_factory; +use crate::application::{ShortcutOwner, local_shortcut_controller}; + +const LIST_PAGE: &str = "list"; +const EMPTY_PAGE: &str = "empty"; + +/// The session's tables in one list view that recycles its rows, in a +/// section per schema, with one shared menu. +#[derive(Clone)] +pub(crate) struct SidebarView { + inner: Rc, +} + +pub(crate) struct SidebarInner { + root: gtk::Stack, + pub(super) list_view: gtk::ListView, + model: SidebarModel, + pub(super) popover: gtk::PopoverMenu, + actions: gio::SimpleActionGroup, + empty: adw::StatusPage, + events: Box, + affordances: Cell, + header_factory: RefCell>, + /// The table of the tab in front. Activation on a single click also + /// selects rows on hover, so the selection returns here. + active: RefCell>, + /// The recycled rows, to point the keyboard's menu at one. + rows: RefCell>>, + /// The table under a middle press. A new list can bind its row to + /// another table before the release. + middle_pressed: RefCell>, + handlers: RefCell, glib::SignalHandlerId)>>, + hover: RefCell>, +} + +impl SidebarView { + pub(crate) fn new(events: impl Fn(SidebarEvent) + 'static, affordances: SidebarAffordances) -> Self { + let model = SidebarModel::new(); + let list_view = gtk::ListView::new(Some(model.selection().clone()), None::); + list_view.add_css_class("navigation-sidebar"); + // A click opens the table, as the list always did; Enter and a + // double click reach the same activate. + list_view.set_single_click_activate(true); + list_view.update_property(&[gtk::accessible::Property::Label(&crate::i18n::gettext("Tables"))]); + let scroll = gtk::ScrolledWindow::builder() + .hscrollbar_policy(gtk::PolicyType::Never) + .vexpand(true) + .child(&list_view) + .build(); + let empty = adw::StatusPage::new(); + empty.add_css_class("compact"); + let root = gtk::Stack::new(); + root.add_named(&scroll, Some(LIST_PAGE)); + root.add_named(&empty, Some(EMPTY_PAGE)); + let actions = gio::SimpleActionGroup::new(); + // Before the popover is parented: a popover menu finds its + // actions through the parent it had when it was set up. + list_view.insert_action_group(GROUP, Some(&actions)); + let popover = gtk::PopoverMenu::from_model(None::<&gio::MenuModel>); + popover.set_has_arrow(true); + popover.set_parent(&list_view); + let inner = Rc::new(SidebarInner { + root, + list_view, + model, + popover, + actions, + empty, + events: Box::new(events), + affordances: Cell::new(affordances), + header_factory: RefCell::new(None), + active: RefCell::new(None), + rows: RefCell::new(Vec::new()), + middle_pressed: RefCell::new(None), + handlers: RefCell::new(Vec::new()), + hover: RefCell::new(None), + }); + inner.list_view.set_factory(Some(&row_factory(Rc::downgrade(&inner)))); + let headers = header_factory(Rc::downgrade(&inner)); + inner.list_view.set_header_factory(Some(&headers)); + inner.header_factory.replace(Some(headers)); + inner + .list_view + .add_controller(local_shortcut_controller(ShortcutOwner::SidebarList)); + install_actions(&inner); + connect_view(&inner); + inner.refresh_placeholder(); + Self { inner } + } + + pub(crate) fn widget(&self) -> >k::Stack { + &self.inner.root + } + + /// Show a new object list. The selection goes back to the tab in + /// front's table, which the new list holds as a new object. + pub(crate) fn apply_objects(&self, objects: &[ObjectInfo], schemas: &[SchemaInfo]) { + self.inner.model.replace(objects, schemas); + self.inner.reselect(); + // An empty list replacing an empty list changes no item, so the + // list's signal never says the placeholder is out of date. + self.inner.refresh_placeholder(); + } + + pub(crate) fn set_query(&self, query: &str) { + self.inner.model.set_query(query); + self.inner.reselect(); + self.inner.refresh_placeholder(); + } + + /// Select the row of the table the tab in front shows, or none for + /// any other tab: a selected row would say the editor shows it. + pub(crate) fn sync_selection(&self, table: Option<&TableRef>) { + self.inner.active.replace(table.cloned()); + self.inner.model.select(table); + } + + /// A reconnect can reach a server that edits less, so the headers are + /// bound again with what it offers. + pub(crate) fn set_affordances(&self, affordances: SidebarAffordances) { + if self.inner.affordances.replace(affordances) == affordances { + return; + } + if let Some(headers) = self.inner.header_factory.borrow().as_ref() { + self.inner.list_view.set_header_factory(None::<>k::ListItemFactory>); + self.inner.list_view.set_header_factory(Some(headers)); + } + } + + /// Filter as the user types in `entry`. + pub(crate) fn follow_search(&self, entry: >k::SearchEntry) { + let view = Rc::downgrade(&self.inner); + let handler = entry.connect_search_changed(move |entry| { + if let Some(view) = view.upgrade() { + SidebarView { inner: view }.set_query(&entry.text()); + } + }); + self.inner.remember(entry, handler); + } + + /// Let go of the menu and the signals, before the widgets go. + pub(crate) fn shutdown(&self) { + let inner = &self.inner; + inner.popover.popdown(); + if inner.popover.parent().is_some() { + inner.popover.unparent(); + } + for (object, handler) in inner.handlers.take() { + if let Some(object) = object.upgrade() { + object.disconnect(handler); + } + } + if let Some(hover) = inner.hover.take() { + inner.list_view.remove_controller(&hover); + } + inner.list_view.insert_action_group(GROUP, None::<&gio::ActionGroup>); + inner.rows.take(); + } + + #[cfg(test)] + pub(crate) fn inner(&self) -> &Rc { + &self.inner + } + + #[cfg(test)] + pub(crate) fn shown(&self) -> u32 { + self.inner.model.shown() + } + + /// Each bound row's table name and what a screen reader says it is. + #[cfg(test)] + pub(crate) fn row_descriptions(&self) -> Vec<(String, String)> { + let mut found: Vec<(String, String)> = self + .inner + .bound_rows() + .filter_map(|item| { + let object = item.item().and_downcast::()?; + Some((object.name(), item.accessible_description().to_string())) + }) + .collect(); + found.sort(); + found + } + + #[cfg(test)] + pub(crate) fn selected_table(&self) -> Option { + let model = &self.inner.model; + model + .object_at(model.selection().selected()) + .map(|object| object.table_ref()) + } +} + +impl SidebarInner { + pub(super) fn affordances(&self) -> SidebarAffordances { + self.affordances.get() + } + + pub(super) fn track_row(&self, item: >k::ListItem) { + let mut rows = self.rows.borrow_mut(); + rows.retain(|row| row.upgrade().is_some()); + rows.push(item.downgrade()); + } + + /// A press on the row at `position`. True when the sidebar handled + /// it, so the row's own click does not select or open it too. + pub(crate) fn pressed( + &self, + position: u32, + button: u32, + modifiers: gdk::ModifierType, + pointing: Option, + ) -> bool { + match button { + gdk::BUTTON_SECONDARY => { + self.popup_menu(position, pointing); + true + } + gdk::BUTTON_PRIMARY if modifiers.contains(gdk::ModifierType::CONTROL_MASK) => { + self.open_in_new_tab(position); + true + } + gdk::BUTTON_MIDDLE => { + self.middle_pressed + .replace(self.model.object_at(position).map(|object| object.table_ref())); + false + } + _ => false, + } + } + + /// A middle click opens the table it was pressed on in a new tab, as + /// a link does. + pub(super) fn released(&self, button: u32) -> bool { + if button != gdk::BUTTON_MIDDLE { + return false; + } + let Some(table) = self.middle_pressed.take() else { + return false; + }; + if self.model.kind_of(&table).is_some() { + (self.events)(SidebarEvent::OpenInNewTab(table)); + } + true + } + + fn open_in_new_tab(&self, position: u32) { + if let Some(object) = self.model.object_at(position) { + (self.events)(SidebarEvent::OpenInNewTab(object.table_ref())); + } + } + + fn activate(&self, position: u32) { + if let Some(object) = self.model.object_at(position) { + (self.events)(SidebarEvent::Open(object.table_ref())); + } + } + + /// Select the row at `position` and open its menu, pointing at + /// `pointing` in the list view, or at the whole list. + pub(crate) fn popup_menu(&self, position: u32, pointing: Option) { + let Some(object) = self.model.object_at(position) else { + return; + }; + self.model.selection().set_selected(position); + let menu = object_menu(&ObjectKey(object.table_ref()), object.kind(), self.affordances.get()); + self.popover.set_menu_model(Some(&menu)); + self.popover.set_pointing_to(pointing.as_ref()); + self.popover.popup(); + } + + /// The Menu key and Shift+F10: the menu of the row the keyboard is + /// on, over that row. The selection follows the tab in front, not + /// the keyboard, so it stands in only when no row has focus. + fn popup_keyboard_menu(&self) { + let position = self + .focused_position() + .unwrap_or_else(|| self.model.selection().selected()); + if position == gtk::INVALID_LIST_POSITION { + return; + } + let pointing = self.row_bounds(position).or_else(|| { + self.list_view + .focus_child() + .and_then(|row| bounds_in(&row, &self.list_view)) + }); + self.popup_menu(position, pointing); + } + + fn row_bounds(&self, position: u32) -> Option { + let row = self.bound_rows().find(|item| item.position() == position)?.child()?; + bounds_in(&row, &self.list_view) + } + + fn focused_position(&self) -> Option { + let focused = self.list_view.focus_child()?; + self.bound_rows() + .find(|item| { + item.child() + .is_some_and(|row| row == focused || row.is_ancestor(&focused)) + }) + .map(|item| item.position()) + } + + fn bound_rows(&self) -> impl Iterator { + let rows: Vec = self.rows.borrow().iter().filter_map(glib::WeakRef::upgrade).collect(); + rows.into_iter().filter(|item| item.item().is_some()) + } + + /// Run a table action whose target came through GAction: a target of + /// the wrong shape, or for a table the list no longer has, does + /// nothing. + fn act_on(&self, action: &str, target: Option<&glib::Variant>) { + let Some(ObjectKey(table)) = target.and_then(ObjectKey::from_variant) else { + tracing::warn!(action, "a sidebar action came without a table"); + return; + }; + let Some(kind) = self.model.kind_of(&table) else { + tracing::warn!(action, "a sidebar action named a table the list no longer has"); + return; + }; + if !offers(action, kind, self.affordances.get()) { + tracing::warn!(action, "a sidebar action the table or the connection does not offer"); + return; + } + let event = match action { + OPEN_IN_NEW_TAB => SidebarEvent::OpenInNewTab(table), + EDIT_STRUCTURE => SidebarEvent::EditStructure(table), + SHOW_CREATE_TABLE => SidebarEvent::ShowCreateTable(table), + IMPORT_CSV => SidebarEvent::ImportCsv(table), + DROP_TABLE => SidebarEvent::DropTable(table), + _ => return, + }; + (self.events)(event); + } + + fn new_table(&self, target: Option<&glib::Variant>) { + let Some(schema) = target.and_then(|target| target.get::>()) else { + tracing::warn!("a new table was asked for without a schema"); + return; + }; + if !self.affordances.get().offers_new_table() { + return; + } + (self.events)(SidebarEvent::NewTable(schema.filter(|schema| !schema.is_empty()))); + } + + fn reselect(&self) { + self.model.select(self.active.borrow().as_ref()); + } + + fn refresh_placeholder(&self) { + if self.model.shown() > 0 { + self.root.set_visible_child_name(LIST_PAGE); + return; + } + if self.model.has_query() { + self.empty.set_icon_name(Some(crate::ui::icons::SYSTEM_SEARCH)); + self.empty.set_title(&crate::i18n::gettext("No Matching Tables")); + self.empty + .set_description(Some(&crate::i18n::gettext("Try a different search."))); + } else { + self.empty.set_icon_name(Some(crate::ui::icons::VIEW_GRID)); + self.empty.set_title(&crate::i18n::gettext("No Tables")); + self.empty.set_description(None); + } + self.root.set_visible_child_name(EMPTY_PAGE); + } + + fn remember(&self, object: &impl IsA, handler: glib::SignalHandlerId) { + self.handlers + .borrow_mut() + .push((object.upcast_ref::().downgrade(), handler)); + } +} + +fn install_actions(inner: &Rc) { + for name in [ + OPEN_IN_NEW_TAB, + EDIT_STRUCTURE, + SHOW_CREATE_TABLE, + IMPORT_CSV, + DROP_TABLE, + ] { + let action = gio::SimpleAction::new(name, Some(&ObjectKey::variant_type())); + let view = Rc::downgrade(inner); + action.connect_activate(move |_, target| { + if let Some(view) = view.upgrade() { + view.act_on(name, target); + } + }); + inner.actions.add_action(&action); + } + let new_table = gio::SimpleAction::new(NEW_TABLE, Some(&Option::::static_variant_type())); + let view = Rc::downgrade(inner); + new_table.connect_activate(move |_, target| { + if let Some(view) = view.upgrade() { + view.new_table(target); + } + }); + inner.actions.add_action(&new_table); + let menu = gio::SimpleAction::new(CONTEXT_MENU, None); + let view = Rc::downgrade(inner); + menu.connect_activate(move |_, _| { + if let Some(view) = view.upgrade() { + view.popup_keyboard_menu(); + } + }); + inner.actions.add_action(&menu); +} + +fn connect_view(inner: &Rc) { + let view: Weak = Rc::downgrade(inner); + let handler = inner.list_view.connect_activate(move |_, position| { + if let Some(view) = view.upgrade() { + view.activate(position); + } + }); + inner.remember(&inner.list_view, handler); + let view = Rc::downgrade(inner); + let handler = inner.model.selection().connect_items_changed(move |_, _, _, _| { + if let Some(view) = view.upgrade() { + view.refresh_placeholder(); + } + }); + inner.remember(inner.model.selection(), handler); + let hover = gtk::EventControllerMotion::new(); + let view = Rc::downgrade(inner); + hover.connect_leave(move |_| { + if let Some(view) = view.upgrade() { + view.reselect(); + } + }); + inner.list_view.add_controller(hover.clone()); + inner.hover.replace(Some(hover)); +} + +fn bounds_in(widget: &impl IsA, target: >k::ListView) -> Option { + widget.compute_bounds(target).map(|bounds| { + gdk::Rectangle::new( + bounds.x() as i32, + bounds.y() as i32, + bounds.width() as i32, + bounds.height() as i32, + ) + }) +} diff --git a/linux/crates/app/src/ui/workspace/sidebar/tests.rs b/linux/crates/app/src/ui/workspace/sidebar/tests.rs new file mode 100644 index 0000000000..e919be8294 --- /dev/null +++ b/linux/crates/app/src/ui/workspace/sidebar/tests.rs @@ -0,0 +1,667 @@ +use std::cell::RefCell; +use std::rc::Rc; +use std::time::Duration; + +use gtk4 as gtk; +use gtk4::prelude::*; +use gtk4::subclass::prelude::*; +use gtk4::{gdk, gio, glib}; +use libadwaita as adw; +use tablepro_core::DriverError; +use tablepro_core::meta::{ObjectInfo, ObjectKind, ObjectScope, SchemaInfo, TableRef}; +use tablepro_test_support::FakeReply; + +use super::object_key::ObjectKey; +use super::sidebar_event::SidebarEvent; +use super::*; +use crate::test_support::{descendants, drain_main_context, wait_until}; +use crate::ui::session_window::tests::{TestWindow, WAIT}; +use crate::ui::workspace::WorkspaceTab; +use crate::ui::workspace::tab_spec::{TabSpec, TablePage}; + +type Events = Rc>>; + +fn object(schema: &str, name: &str) -> ObjectInfo { + ObjectInfo { + schema: Some(schema.to_owned()), + name: name.to_owned(), + kind: ObjectKind::Table, + } +} + +fn schema(name: &str, is_default: bool) -> SchemaInfo { + SchemaInfo { + name: name.to_owned(), + is_default, + is_system: false, + } +} + +fn public() -> [SchemaInfo; 1] { + [schema("public", true)] +} + +fn table(name: &str) -> TableRef { + TableRef::new(Some("public".to_owned()), name) +} + +fn every_affordance() -> SidebarAffordances { + SidebarAffordances { + read_only: false, + can_create_table: true, + can_edit_structure: true, + } +} + +/// A sidebar in a window of its own, with what it sent. +struct Shown { + view: SidebarView, + events: Events, + window: gtk::Window, +} + +impl Shown { + fn new(names: &[&str], affordances: SidebarAffordances) -> Self { + adw::init().expect("libadwaita initialises under the test backend"); + let events = Events::default(); + let sink = events.clone(); + let view = SidebarView::new(move |event| sink.borrow_mut().push(event), affordances); + let objects: Vec = names.iter().map(|name| object("public", name)).collect(); + view.apply_objects(&objects, &public()); + let window = gtk::Window::new(); + window.set_default_size(300, 400); + window.set_child(Some(view.widget())); + window.present(); + drain_main_context(); + Self { view, events, window } + } + + fn list_view(&self) -> gtk::ListView { + self.view.inner().list_view.clone() + } + + fn selection(&self) -> gtk::SingleSelection { + self.list_view() + .model() + .and_downcast::() + .expect("a single selection") + } + + fn popover(&self) -> gtk::PopoverMenu { + self.view.inner().popover.clone() + } + + fn taken(&self) -> Vec { + self.events.take() + } +} + +impl Drop for Shown { + fn drop(&mut self) { + self.view.shutdown(); + self.window.destroy(); + drain_main_context(); + } +} + +/// Each menu item's action and target, sections flattened. +fn menu_items(menu: &gio::MenuModel) -> Vec<(String, Option)> { + let mut found = Vec::new(); + for index in 0..menu.n_items() { + if let Some(action) = menu + .item_attribute_value(index, "action", None) + .and_then(|value| value.get::()) + { + found.push((action, menu.item_attribute_value(index, "target", None))); + } + if let Some(section) = menu.item_link(index, "section") { + found.extend(menu_items(§ion)); + } + } + found +} + +fn names(count: usize) -> Vec { + (0..count).map(|index| format!("t{index:05}")).collect() +} + +/// The placeholder's title and description, when it shows. +fn placeholder(view: &SidebarView) -> Option<(String, Option)> { + view.widget() + .visible_child() + .and_downcast::() + .map(|page| { + ( + page.title().to_string(), + page.description() + .filter(|text| !text.is_empty()) + .map(|text| text.to_string()), + ) + }) +} + +#[gtk4::test] +fn empty_filter_shows_placeholder() { + let shown = Shown::new(&["orders", "users"], every_affordance()); + assert_eq!(placeholder(&shown.view), None, "a list with rows shows its placeholder"); + + shown.view.set_query("nothing like it"); + assert_eq!( + placeholder(&shown.view), + Some(( + "No Matching Tables".to_owned(), + Some("Try a different search.".to_owned()) + )) + ); + + shown.view.set_query(""); + shown.view.apply_objects(&[], &public()); + assert_eq!(placeholder(&shown.view), Some(("No Tables".to_owned(), None))); +} + +#[gtk4::test] +fn clearing_a_search_over_an_emptied_list_says_there_are_no_tables() { + let shown = Shown::new(&["orders"], every_affordance()); + shown.view.set_query("zzz"); + // An empty list under a search that matched nothing: no row changes. + shown.view.apply_objects(&[], &public()); + + shown.view.set_query(""); + + assert_eq!(placeholder(&shown.view), Some(("No Tables".to_owned(), None))); +} + +#[gtk4::test] +fn rows_are_recycled_for_large_catalogs() { + let owned = names(10_000); + let borrowed: Vec<&str> = owned.iter().map(String::as_str).collect(); + let shown = Shown::new(&borrowed, every_affordance()); + wait_until(WAIT, || shown.list_view().height() > 0).expect("the list laid out"); + drain_main_context(); + + let labels = descendants(&shown.list_view()) + .into_iter() + .filter(|widget| widget.is::()) + .count(); + + assert!(labels > 0, "no row was built at all"); + // GtkListView keeps about 200 rows around its viewport, whatever the + // model holds, and the section header adds a label. + assert!(labels <= 210, "{labels} labels for 10,000 tables"); +} + +#[gtk4::test] +fn single_click_activation_opens_table() { + let shown = Shown::new(&["a", "b", "c", "d", "e"], every_affordance()); + + assert!(shown.list_view().is_single_click_activate()); + shown.list_view().emit_by_name::<()>("activate", &[&3u32]); + + assert_eq!(shown.taken(), [SidebarEvent::Open(table("d"))]); +} + +#[gtk4::test] +fn secondary_press_selects_row_and_opens_menu() { + let shown = Shown::new(&["a", "b", "c", "d", "e", "f", "g"], every_affordance()); + + let claimed = shown + .view + .inner() + .pressed(5, gdk::BUTTON_SECONDARY, gdk::ModifierType::empty(), None); + + assert!(claimed, "the row's own click would also run"); + assert_eq!(shown.selection().selected(), 5); + let popover = shown.popover(); + assert!(popover.is_visible(), "the menu did not open"); + let model = popover.menu_model().expect("a menu model"); + let targets: Vec> = menu_items(&model) + .into_iter() + .map(|(_, target)| target.as_ref().and_then(ObjectKey::from_variant)) + .collect(); + assert!( + targets.iter().all(|key| key.as_ref() == Some(&ObjectKey(table("f")))), + "{targets:?}" + ); + popover.popdown(); + assert!(shown.taken().is_empty(), "opening the menu opened the table"); +} + +#[gtk4::test] +fn ctrl_click_and_middle_click_open_a_new_tab() { + let shown = Shown::new(&["a", "b", "c"], every_affordance()); + let inner = shown.view.inner().clone(); + + assert!(inner.pressed(1, gdk::BUTTON_PRIMARY, gdk::ModifierType::CONTROL_MASK, None)); + assert!(!inner.pressed(1, gdk::BUTTON_PRIMARY, gdk::ModifierType::empty(), None)); + assert!(!inner.pressed(2, gdk::BUTTON_MIDDLE, gdk::ModifierType::empty(), None)); + assert!(inner.released(gdk::BUTTON_MIDDLE)); + + assert_eq!( + shown.taken(), + [ + SidebarEvent::OpenInNewTab(table("b")), + SidebarEvent::OpenInNewTab(table("c")) + ] + ); +} + +#[gtk4::test] +fn a_middle_click_opens_the_table_it_was_pressed_on() { + let shown = Shown::new(&["a", "b", "c"], every_affordance()); + let inner = shown.view.inner().clone(); + assert!( + !inner.released(gdk::BUTTON_MIDDLE), + "a release with no press opened a table" + ); + + inner.pressed(1, gdk::BUTTON_MIDDLE, gdk::ModifierType::empty(), None); + // A refresh lands between the press and the release, and binds the + // pressed row to another table. + shown.view.apply_objects( + &[ + object("public", "a"), + object("public", "aa"), + object("public", "b"), + object("public", "c"), + ], + &public(), + ); + + assert!(inner.released(gdk::BUTTON_MIDDLE)); + assert_eq!(shown.taken(), [SidebarEvent::OpenInNewTab(table("b"))]); +} + +#[gtk4::test] +fn context_menu_action_carries_object_key() { + let shown = Shown::new(&["orders", "users"], every_affordance()); + let list_view = shown.list_view(); + let key = ObjectKey(table("users")).to_variant(); + + for action in [ + "sidebar.open-in-new-tab", + "sidebar.edit-structure", + "sidebar.show-create-table", + "sidebar.import-csv", + "sidebar.drop-table", + ] { + list_view.activate_action(action, Some(&key)).expect(action); + } + list_view + .activate_action( + "sidebar.drop-table", + Some(&(None::, String::new()).to_variant()), + ) + .expect("the action"); + list_view + .activate_action("sidebar.drop-table", Some(&ObjectKey(table("gone")).to_variant())) + .expect("the action"); + + assert_eq!( + shown.taken(), + [ + SidebarEvent::OpenInNewTab(table("users")), + SidebarEvent::EditStructure(table("users")), + SidebarEvent::ShowCreateTable(table("users")), + SidebarEvent::ImportCsv(table("users")), + SidebarEvent::DropTable(table("users")), + ], + "a table the list no longer has was acted on" + ); +} + +#[gtk4::test] +fn a_read_only_connection_refuses_writes_from_a_stale_menu() { + let shown = Shown::new( + &["orders"], + SidebarAffordances { + read_only: true, + ..every_affordance() + }, + ); + let key = ObjectKey(table("orders")).to_variant(); + + shown + .list_view() + .activate_action("sidebar.drop-table", Some(&key)) + .expect("the action"); + shown + .list_view() + .activate_action("sidebar.new-table", Some(&Some("public".to_owned()).to_variant())) + .expect("the action"); + + assert!(shown.taken().is_empty()); +} + +#[gtk4::test] +fn keyboard_menu_falls_back_to_the_selected_row() { + let shown = Shown::new(&["a", "b", "c", "d"], every_affordance()); + // Showing the window focuses its first control, which may be a row. + gtk::prelude::GtkWindowExt::set_focus(&shown.window, None::<>k::Widget>); + shown.selection().set_selected(2); + + shown + .list_view() + .activate_action("sidebar.context-menu", None) + .expect("the action"); + + let popover = shown.popover(); + assert!(popover.is_visible(), "the menu did not open"); + let model = popover.menu_model().expect("a menu model"); + let (_, target) = menu_items(&model).into_iter().next().expect("an item"); + assert_eq!( + target.as_ref().and_then(ObjectKey::from_variant), + Some(ObjectKey(table("c"))) + ); + assert!(popover.pointing_to().0, "the menu points at nothing"); + popover.popdown(); +} + +/// The table the open menu acts on, if a menu is open. +fn menu_table(shown: &Shown) -> Option { + let popover = shown.popover(); + if !popover.is_visible() { + return None; + } + let (_, target) = menu_items(&popover.menu_model()?).into_iter().next()?; + target.as_ref().and_then(ObjectKey::from_variant) +} + +/// The name on the row the keyboard is on. +fn focused_name(list_view: >k::ListView) -> Option { + list_view + .focus_child() + .and_then(|item| item.first_child()) + .and_then(|row| row.last_child()) + .and_downcast::() + .map(|label| label.label().to_string()) +} + +/// The scroll marks the row the list focuses next; the grab carries the +/// focus there. +fn focus_row(list_view: >k::ListView, position: u32, name: &str) { + list_view.scroll_to(position, gtk::ListScrollFlags::FOCUS, None); + list_view.grab_focus(); + wait_until(WAIT, || focused_name(list_view).as_deref() == Some(name)).expect("the row focused"); +} + +#[gtk4::test] +fn keyboard_menu_opens_for_the_focused_row() { + let shown = Shown::new(&["a", "b", "c", "d"], every_affordance()); + let list_view = shown.list_view(); + wait_until(WAIT, || list_view.height() > 0).expect("the list laid out"); + + // An editor tab in front: no row is selected. + focus_row(&list_view, 2, "c"); + shown.view.sync_selection(None); + list_view + .activate_action("sidebar.context-menu", None) + .expect("the action"); + assert_eq!(menu_table(&shown), Some(ObjectKey(table("c")))); + shown.popover().popdown(); + + // A table tab in front selects its own row, not the focused one. + focus_row(&list_view, 2, "c"); + shown.view.sync_selection(Some(&table("a"))); + list_view + .activate_action("sidebar.context-menu", None) + .expect("the action"); + assert_eq!(menu_table(&shown), Some(ObjectKey(table("c")))); + shown.popover().popdown(); +} + +#[gtk4::test] +fn a_row_tells_a_screen_reader_its_kind() { + let shown = Shown::new(&[], every_affordance()); + shown.view.apply_objects( + &[ + object("public", "orders"), + ObjectInfo { + schema: Some("public".to_owned()), + name: "totals".to_owned(), + kind: ObjectKind::View, + }, + ], + &public(), + ); + + wait_until(WAIT, || shown.view.row_descriptions().len() == 2).expect("both rows bound"); + assert_eq!( + shown.view.row_descriptions(), + [ + ("orders".to_owned(), "Table".to_owned()), + ("totals".to_owned(), "View".to_owned()) + ] + ); +} + +#[gtk4::test] +fn the_menu_keys_reach_the_context_menu_action() { + let shown = Shown::new(&["a"], every_affordance()); + + let bound = crate::test_support::shortcut_keys::owner_controller( + &shown.list_view(), + crate::application::ShortcutOwner::SidebarList, + ) + .expect("the list's keys"); + let actions: Vec = crate::test_support::shortcut_keys::shortcuts(&bound) + .into_iter() + .filter_map(|shortcut| shortcut.action().and_downcast::()) + .map(|action| action.action_name().to_string()) + .collect(); + + assert_eq!(actions, ["sidebar.context-menu", "sidebar.context-menu"]); +} + +/// Each section header's title and whether its New Table button shows. +fn headers(list_view: >k::ListView) -> Vec<(String, bool)> { + descendants(list_view) + .into_iter() + .filter(|widget| widget.css_name() == "header") + .filter_map(|header| { + let row = header.first_child()?; + let title = row.first_child().and_downcast::()?.label().to_string(); + let add = row.last_child().and_downcast::()?; + Some((title, add.is_visible())) + }) + .collect() +} + +#[gtk4::test] +fn the_new_table_button_follows_the_dialect() { + let shown = Shown::new(&["orders"], every_affordance()); + wait_until(WAIT, || !headers(&shown.list_view()).is_empty()).expect("a section header"); + assert_eq!(headers(&shown.list_view()), [("public".to_owned(), true)]); + let button = descendants(&shown.list_view()) + .into_iter() + .find_map(|widget| widget.downcast::().ok()) + .expect("the New Table button"); + assert_eq!(button.tooltip_text().as_deref(), Some("New Table in public…")); + button.emit_clicked(); + assert_eq!(shown.taken(), [SidebarEvent::NewTable(Some("public".to_owned()))]); + + shown.view.set_affordances(SidebarAffordances { + can_create_table: false, + ..every_affordance() + }); + wait_until(WAIT, || headers(&shown.list_view()) == [("public".to_owned(), false)]) + .expect("the button hidden once the server cannot create tables"); +} + +#[gtk4::test] +fn sync_selection_follows_the_tab_and_survives_a_new_list() { + let shown = Shown::new(&["orders", "users"], every_affordance()); + + shown.view.sync_selection(Some(&table("users"))); + assert_eq!(shown.selection().selected(), 1); + shown.view.apply_objects( + &[ + object("public", "accounts"), + object("public", "orders"), + object("public", "users"), + ], + &public(), + ); + assert_eq!(shown.selection().selected(), 2, "the new list dropped the selection"); + + shown.view.sync_selection(None); + assert_eq!(shown.selection().selected(), gtk::INVALID_LIST_POSITION); +} + +fn sidebar_selected(workspace: &crate::ui::workspace::Workspace) -> Option { + workspace.read(|model| model.sidebar.selected_table()).flatten() +} + +#[gtk4::test] +fn sync_selection_selects_matching_row_and_clears_for_editor() { + let test = TestWindow::connected_with("SidebarSync", |script| { + script.list_objects.once(FakeReply::ok(vec![ + object("public", "orders"), + object("public", "users"), + ])); + }); + let workspace = test.workspace(); + wait_until(WAIT, || workspace.read(|model| model.sidebar.shown()) == Some(2)).expect("the tables listed"); + + workspace.with_model(|model| { + model.open_tab(TabSpec::Table { + table: table("users"), + page: TablePage::first(100), + }) + }); + wait_until(WAIT, || sidebar_selected(&workspace) == Some(table("users"))).expect("the table's row selected"); + + WidgetExt::activate_action(&test.window, "win.new-editor-tab", None).expect("win.new-editor-tab"); + wait_until(WAIT, || { + workspace + .read(|model| { + let tabs = model.workspace_tabs.borrow(); + model + .selected_workspace_tab_id() + .and_then(|id| tabs.get(&id).map(|tab| matches!(tab, WorkspaceTab::Editor(_)))) + }) + .flatten() + == Some(true) + }) + .expect("the editor in front"); + wait_until(WAIT, || sidebar_selected(&workspace).is_none()).expect("no row selected for the editor"); +} + +#[gtk4::test] +fn sidebar_lists_every_schema_after_the_connect() { + let test = TestWindow::connected_with("SidebarAllSchemas", |script| { + script.list_objects.once(FakeReply::ok(vec![ + object("public", "users"), + object("sales", "orders"), + ])); + }); + let workspace = test.workspace(); + + wait_until(WAIT, || workspace.read(|model| model.sidebar.shown()) == Some(2)) + .expect("the second schema's table listed"); + let session = workspace + .read(|model| model.context.active.get()) + .flatten() + .expect("the session"); + let listed = session.catalog().subscribe(ObjectScope::AllSchemas).borrow().clone(); + assert!(listed.generation > 0, "every schema's list was never asked for"); + assert_eq!(listed.objects.len(), 2); +} + +#[gtk4::test] +fn catalog_error_keeps_list_and_toasts() { + let test = TestWindow::connected_with("SidebarCatalogError", |script| { + script.list_objects.once(FakeReply::ok(vec![ + object("public", "orders"), + object("public", "users"), + ])); + script + .list_objects + .once(FakeReply::err(DriverError::Protocol("catalog gone".to_owned()))); + }); + let workspace = test.workspace(); + wait_until(WAIT, || workspace.read(|model| model.sidebar.shown()) == Some(2)).expect("the tables listed"); + let toasts_before = toast_count(&workspace); + + let input = workspace.input().expect("the workspace's input"); + input.deliver(WorkspaceInput::SchemaChanged { + schema: Some("public".to_owned()), + table: None, + }); + + wait_until(WAIT, || toast_count(&workspace) > toasts_before).expect("a toast for the failed refresh"); + assert_eq!( + workspace.read(|model| model.sidebar.shown()), + Some(2), + "the failure emptied the list" + ); +} + +fn toast_count(workspace: &crate::ui::workspace::Workspace) -> usize { + descendants(&workspace.imp().toast_overlay.get()) + .into_iter() + .filter(|widget| widget.css_name() == "toast") + .count() +} + +#[gtk4::test] +fn a_reconnect_reads_every_schema_again() { + let test = TestWindow::connected_with("SidebarReconnect", |script| { + script.list_objects.once(FakeReply::ok(vec![object("public", "users")])); + script.list_objects.once(FakeReply::ok(vec![ + object("public", "users"), + object("sales", "orders"), + ])); + }); + let workspace = test.workspace(); + wait_until(WAIT, || workspace.read(|model| model.sidebar.shown()) == Some(1)).expect("the table listed"); + let session = workspace + .read(|model| model.context.active.get()) + .flatten() + .expect("the session"); + + let listed = || session.catalog().subscribe(ObjectScope::AllSchemas).borrow().clone(); + let before = listed().generation; + + // What a reconnect does once the new connection read its catalog. + session.catalog().commit(Vec::new(), vec![object("public", "users")]); + + wait_until(Duration::from_secs(10), || { + workspace.read(|model| model.sidebar.shown()) == Some(2) + }) + .expect("every schema's list read again"); + assert!(listed().generation > before, "every schema's list was not read again"); + assert_eq!(listed().objects.len(), 2); +} + +#[gtk4::test] +fn a_reconnect_names_tabs_by_its_schemas_when_the_list_fails() { + let test = TestWindow::connected_with("SidebarReconnectNaming", |script| { + script.list_objects.once(FakeReply::ok(vec![object("public", "users")])); + script + .list_objects + .once(FakeReply::err(DriverError::Protocol("catalog gone".to_owned()))); + }); + let workspace = test.workspace(); + wait_until(WAIT, || workspace.read(|model| model.sidebar.shown()) == Some(1)).expect("the table listed"); + let naming = || workspace.read(|model| model.naming.get()); + assert_eq!(naming(), Some(TableNaming::Bare)); + let session = workspace + .read(|model| model.context.active.get()) + .flatten() + .expect("the session"); + let toasts_before = toast_count(&workspace); + + session.catalog().commit( + vec![schema("public", true), schema("sales", false)], + vec![object("public", "users")], + ); + + wait_until(WAIT, || toast_count(&workspace) > toasts_before).expect("the list read to fail"); + wait_until(WAIT, || naming() == Some(TableNaming::SchemaQualified)) + .expect("the reconnect's schemas never named the tabs"); + assert_eq!( + workspace.read(|model| model.sidebar.shown()), + Some(1), + "the failure emptied the list" + ); +} diff --git a/linux/crates/app/src/ui/workspace/sidebar_section_header.rs b/linux/crates/app/src/ui/workspace/sidebar_section_header.rs deleted file mode 100644 index 9690084a7a..0000000000 --- a/linux/crates/app/src/ui/workspace/sidebar_section_header.rs +++ /dev/null @@ -1,69 +0,0 @@ -use gtk4 as gtk; -use gtk4::prelude::*; - -/// A schema's heading in the sidebar, with its "+" button for a new -/// table where the connection's DDL dialect creates tables. ClickHouse -/// does not: a table there needs an engine and a sorting key the -/// structure tab has no place for, so the button would only fail. -pub(super) fn sidebar_section_header( - schema: Option<&str>, - can_create_table: bool, - on_new_table: impl Fn() + 'static, -) -> gtk::Box { - let header = gtk::Box::builder() - .orientation(gtk::Orientation::Horizontal) - .spacing(6) - .margin_top(12) - .margin_bottom(6) - .margin_start(12) - // Lines the "+" up with the end of the row content. - .margin_end(12) - .build(); - let label_text = schema - .map(str::to_owned) - .unwrap_or_else(|| crate::i18n::gettext("Tables")); - let label = gtk::Label::builder() - .label(&label_text) - .xalign(0.0) - .hexpand(true) - .build(); - // GtkPlacesSidebar's section header: small, bold and dim. - label.add_css_class("caption-heading"); - label.add_css_class("dim-label"); - header.append(&label); - if !can_create_table { - return header; - } - // The tooltip says which schema the table goes in. - let new_table_button = gtk::Button::builder() - .icon_name(crate::ui::icons::LIST_ADD) - .tooltip_text(match schema { - Some(schema) => crate::i18n::gettext_f("New Table in {schema}…", &[("schema", schema)]), - None => crate::i18n::gettext("New Table…"), - }) - .valign(gtk::Align::Center) - .build(); - new_table_button.add_css_class("flat"); - new_table_button.connect_clicked(move |_| on_new_table()); - header.append(&new_table_button); - header -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::test_support::first_descendant_of_type; - - #[gtk4::test] - fn new_table_button_hidden_without_create_table_capability() { - let with = sidebar_section_header(Some("public"), true, || {}); - let without = sidebar_section_header(Some("analytics"), false, || {}); - - let button = first_descendant_of_type::(&with).expect("the new table button"); - assert_eq!(button.tooltip_text().as_deref(), Some("New Table in public…")); - assert!( - first_descendant_of_type::(&without).is_none(), - "a connection that cannot create tables offers to" - ); - } -} diff --git a/linux/crates/app/src/ui/workspace/status_pages.rs b/linux/crates/app/src/ui/workspace/status_pages.rs index 37c68f7f06..1a15e55d74 100644 --- a/linux/crates/app/src/ui/workspace/status_pages.rs +++ b/linux/crates/app/src/ui/workspace/status_pages.rs @@ -2,11 +2,12 @@ use libadwaita as adw; use libadwaita::prelude::*; use relm4::{Component, ComponentController}; -use crate::ui::history_dialog::{HistoryDialog, HistoryDialogInit, HistoryDialogOutput}; +use crate::ui::history_dialog::{HistoryDialog, HistoryDialogInit, QueryDestination}; use crate::ui::saved_queries_dialog::{SavedQueriesDialog, SavedQueriesDialogInit, SavedQueriesDialogOutput}; use super::{WorkspaceInput, WorkspaceModel}; use crate::runtime::DeliverInput; +use crate::ui::plain_toast; impl WorkspaceModel { pub(super) fn show_toast(&self, msg: &str) { @@ -25,23 +26,21 @@ impl WorkspaceModel { } 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) - } - }); - } + let dialog = HistoryDialog::new(HistoryDialogInit { + history: self.history.clone(), + connections: self.storage.connections().clone(), + preferred_connection: self.context.active.metadata().map(|metadata| metadata.id), + revealer: self.file_revealer.clone(), + tasks: self.tasks.clone(), + }); + let input = self.input.clone(); + dialog.connect_query_chosen(move |_, destination, text| { + input.deliver(match destination { + QueryDestination::NewTab => WorkspaceInput::OpenHistoryQuery(text), + QueryDestination::CurrentTab => WorkspaceInput::ReplaceActiveTabQuery(text), }); - dialog.model().dialog().present(self.dialog_parent().as_ref()); + }); + dialog.present(self.dialog_parent().as_ref()); self.history_dialog = Some(dialog); } @@ -59,7 +58,7 @@ impl WorkspaceModel { SavedQueriesDialogOutput::ReplaceCurrentTabQuery(text) => { WorkspaceInput::ReplaceActiveTabQuery(text) } - SavedQueriesDialogOutput::CopyToClipboard(text) => WorkspaceInput::CopyToClipboard(text), + SavedQueriesDialogOutput::CopyToClipboard(text) => WorkspaceInput::CopyText(text), SavedQueriesDialogOutput::ShowToast(text) => WorkspaceInput::ShowToast(text), }); } @@ -145,14 +144,6 @@ impl WorkspaceModel { } } -/// 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. @@ -175,7 +166,7 @@ fn suggested_query_name(query: &str) -> String { #[cfg(test)] mod tests { - use super::{plain_toast, suggested_query_name}; + use super::suggested_query_name; use crate::test_support::{finish_on_main, wait_until}; use crate::ui::session_window::tests::{TestWindow, WAIT}; @@ -208,7 +199,6 @@ mod tests { 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 @@ -218,7 +208,7 @@ mod tests { model .history_dialog .as_ref() - .map(|dialog| dialog.model().dialog().clone()) + .map(|dialog| dialog.clone().upcast::()) }) .flatten() .expect("the history dialog"); @@ -236,16 +226,6 @@ mod tests { 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!( diff --git a/linux/crates/app/src/ui/workspace/structure.rs b/linux/crates/app/src/ui/workspace/structure.rs index 26bdeafaa3..a5fa743865 100644 --- a/linux/crates/app/src/ui/workspace/structure.rs +++ b/linux/crates/app/src/ui/workspace/structure.rs @@ -19,6 +19,9 @@ use crate::runtime::DeliverInput; use crate::ui::error_text; use crate::ui::structure_tab::{StructureMode, StructureTabInput}; use crate::ui::workspace::{WorkspaceInput, WorkspaceModel, WorkspaceTab}; + +use super::structure_target::StructureTarget; +use super::tab_spec::{TabSpec, TablePage}; use crate::workspace::run_schema_plan; impl WorkspaceModel { @@ -37,7 +40,9 @@ impl WorkspaceModel { )); return; } - self.append_new_structure_tab(schema); + self.open_tab(TabSpec::Structure { + target: StructureTarget::NewDraft { schema }, + }); } /// Select the table's Structure tab, or open one. Its Table tab @@ -51,18 +56,23 @@ impl WorkspaceModel { } _ => None, }); - if let Some(page) = existing_structure - && let Some(tab_view) = self.workspace_tab_view.as_ref() - { - tab_view.set_selected_page(&page); + if let Some(page) = existing_structure { + self.tab_view.set_selected_page(&page); return; } - self.append_existing_structure_tab(schema, table); + self.open_tab(TabSpec::Structure { + target: StructureTarget::Existing(TableRef::new(schema, table)), + }); } /// Right-click → "Drop Table…", or in-tab destructive button. pub(super) fn on_drop_table_prompt(&mut self, schema: Option, table: String) { - let title = crate::i18n::gettext_f("Drop {table}?", &[("table", &table)]); + // The sidebar lists every schema, so two tables can share a name. + let named = match &schema { + Some(schema) => format!("{schema}.{table}"), + None => table.clone(), + }; + let title = crate::i18n::gettext_f("Drop {table}?", &[("table", &named)]); let body = crate::i18n::gettext( "All rows and the table definition will be removed. This can't be undone from inside TablePro.", ); @@ -141,9 +151,7 @@ impl WorkspaceModel { /// pointing at `(schema, table)`. Skips the per-tab pending-changes /// dialog because the underlying table is gone. pub(super) fn close_tabs_for_table(&mut self, schema: Option<&str>, table: &str) { - let Some(tab_view) = self.workspace_tab_view.clone() else { - return; - }; + let tab_view = self.tab_view.clone(); let mut targets: Vec = Vec::new(); for (id, tab) in self.workspace_tabs.borrow().iter() { match tab { @@ -193,18 +201,23 @@ impl WorkspaceModel { match kind { SaveKind::PromoteNewToTable(schema, name) => { - if let Some(tab_view) = self.workspace_tab_view.clone() { - self.finish_close_workspace_tab(tab_id, &tab_view); - } + let tab_view = self.tab_view.clone(); + self.finish_close_workspace_tab(tab_id, &tab_view); // A table just designed is filled in next. let page_size = u64::from(self.settings.default_page_size()); - self.append_table_tab(schema.clone(), name.clone(), 0, page_size, None); + self.open_tab(TabSpec::Table { + table: TableRef::new(schema.clone(), name.clone()), + page: TablePage::first(page_size), + }); self.input.deliver(WorkspaceInput::SchemaChanged { schema, table: Some(name), }); } SaveKind::UpdateInPlace(schema, table) => { + if new_table_name.is_some() { + self.retitle_tab(tab_id); + } self.send_to_structure_tab(tab_id, StructureTabInput::SaveCompleted { new_table_name }); self.input.deliver(WorkspaceInput::SchemaChanged { schema, @@ -291,52 +304,14 @@ impl WorkspaceModel { structure.send(StructureTabInput::SchemaChanged); } } - // The catalog watch takes the new list to the sidebar, and logs - // the error of one that could not be read. + // The catalog watch takes the new list to the sidebar, and a + // failure to it as a toast. The default schema's list, which the + // editor's completion reads, follows, one read at a time. self.context.tasks.spawn_task(async move { + let _ = session.refresh_objects(ObjectScope::AllSchemas).await; let _ = session.refresh_objects(ObjectScope::DefaultSchema).await; }); } - - /// The sidebar follows the session's object list. A failed refresh - /// keeps it as it was. - pub(super) fn on_catalog_changed(&mut self, snapshot: &tablepro_session::ObjectListSnapshot) { - self.show_objects(&snapshot.objects); - } - - pub(super) fn refresh_structure_tab_dirty(&self, tab_id: Uuid, dirty: bool) { - let schemas_count = self.sidebar_schemas_distinct(); - let tabs = self.workspace_tabs.borrow(); - let Some(slot) = tabs.get(&tab_id) else { - return; - }; - let (page, schema, table_name, combined_dirty) = match slot { - WorkspaceTab::Structure(s) => (&s.page, s.schema.as_deref(), s.table.clone(), dirty), - WorkspaceTab::Table(s) => { - let data = self - .change_trackers - .get(s.id) - .is_some_and(|tracker| tracker.pending_count() > 0); - (&s.page, s.schema.as_deref(), s.table.clone(), dirty || data) - } - _ => return, - }; - let base = if table_name.is_empty() { - crate::i18n::gettext("New Table") - } else { - super::workspace_tabs::qualified_browse_tab_label(schemas_count, schema, &table_name) - }; - let title = if combined_dirty { format!("• {base}") } else { base }; - page.set_title(&title); - let is_selected = self - .workspace_tab_view - .as_ref() - .and_then(|tv| tv.selected_page()) - .map(|p| &p == page) - .unwrap_or(false); - page.set_needs_attention(combined_dirty && !is_selected); - self.refresh_window_title(); - } } /// The table's definition and the dialect of the connection that read @@ -504,4 +479,21 @@ mod tests { assert!(answered, "the prompt has no Cancel button"); assert!(visible_alert(&test.window).is_none(), "the prompt stayed open"); } + + #[gtk4::test] + fn the_drop_prompt_names_the_schema() { + use crate::test_support::wait_until; + use crate::ui::session_window::tests::{TestWindow, WAIT}; + use crate::workspace::{answer, visible_alert}; + + let test = TestWindow::connected("DropPromptNamesSchema"); + test.workspace() + .with_model(|model| model.on_drop_table_prompt(Some("sales".to_owned()), "orders".to_owned())); + wait_until(WAIT, || visible_alert(&test.window).is_some()).expect("the drop prompt"); + + let heading = visible_alert(&test.window).and_then(|alert| alert.heading()); + + assert_eq!(heading.as_deref(), Some("Drop sales.orders?")); + assert!(answer(&test.window, "cancel"), "the prompt has no Cancel button"); + } } diff --git a/linux/crates/app/src/ui/workspace/structure_target.rs b/linux/crates/app/src/ui/workspace/structure_target.rs new file mode 100644 index 0000000000..996571d2b7 --- /dev/null +++ b/linux/crates/app/src/ui/workspace/structure_target.rs @@ -0,0 +1,26 @@ +use tablepro_core::meta::TableRef; + +/// What a structure tab edits: a table that exists, or the draft of one +/// that does not yet. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum StructureTarget { + Existing(TableRef), + /// The schema the table will be created in. + NewDraft { + schema: Option, + }, +} + +impl StructureTarget { + /// A tab with no table name yet is a draft, so its title is never + /// empty. + pub(crate) fn of(schema: Option<&str>, table: &str) -> Self { + if table.trim().is_empty() { + Self::NewDraft { + schema: schema.map(str::to_owned), + } + } else { + Self::Existing(TableRef::new(schema.map(str::to_owned), table)) + } + } +} diff --git a/linux/crates/app/src/ui/workspace/tab_factory.rs b/linux/crates/app/src/ui/workspace/tab_factory.rs new file mode 100644 index 0000000000..96c66e418d --- /dev/null +++ b/linux/crates/app/src/ui/workspace/tab_factory.rs @@ -0,0 +1,375 @@ +use std::cell::Cell; +use std::rc::Rc; + +use gtk4 as gtk; +use gtk4::glib; +use libadwaita as adw; +use libadwaita::prelude::*; +use tablepro_core::meta::TableRef; +use tablepro_storage::{DraftId, DraftScope}; +use uuid::Uuid; + +use super::editor_label::EditorLabel; +use super::structure_target::StructureTarget; +use super::tab_index::TabIndex; +use super::tab_spec::{TabSpec, TablePage}; +use super::tab_title::resolve_tab_title; +use super::table_naming::TableNaming; +use super::workspace_persistence::WorkspaceTabs; +use super::{ + EditorTabSlot, StructureTabSlot, TableTabSlot, WorkspaceInput, WorkspaceSender, WorkspaceTab, apply_tab_title, +}; +use crate::runtime::DeliverInput; +use crate::services::change_tracker_registry::ChangeTrackerRegistry; +use crate::services::history_service::HistoryService; +use crate::services::session_context::SessionContext; +use crate::services::structure_tracker_registry::StructureTrackerRegistry; +use crate::ui::browse_tab::{BrowseTab, BrowseTabInit}; +use crate::ui::editor::{SqlEditor, SqlEditorInit}; +use crate::ui::structure_tab::{StructureMode, StructureTab, StructureTabInit}; +use crate::workspace::DraftWriter; + +/// What a factory builds its tabs from: the workspace's session, stores +/// and trackers. +pub(super) struct TabFactoryParts { + pub view: adw::TabView, + pub tabs: WorkspaceTabs, + pub index: Rc, + pub input: WorkspaceSender, + pub toasts: glib::WeakRef, + pub context: Rc, + pub settings: Rc, + pub storage: crate::storage::SharedStorage, + pub history: Option, + pub drafts: Rc, + pub draft_scope: DraftScope, + pub change_trackers: Rc, + pub structure_trackers: Rc, + pub read_only: bool, + /// Whether a tab's title names the table's schema. + pub naming: Rc>, +} + +/// The one place a tab is built: its widget, its page, its slot and its +/// index entry. The workspace's own calls and the overview's new-tab +/// button go through it alike. +pub(super) struct TabFactory { + parts: TabFactoryParts, +} + +impl TabFactory { + pub(super) fn new(parts: TabFactoryParts) -> Rc { + Rc::new(Self { parts }) + } + + /// Build the tab and append its page, unselected. `None` when the + /// spec needs a session and the workspace has none any more. + pub(super) fn create(&self, spec: TabSpec) -> Option { + let id = Uuid::new_v4(); + let slot = match spec { + TabSpec::Editor { initial_query, draft } => self.editor(id, initial_query, draft), + TabSpec::Table { table, page } => self.table(id, table, page)?, + TabSpec::Structure { target } => self.structure(id, target)?, + }; + Some(self.insert(id, slot)) + } + + /// The overview's new-tab button needs its page back at once. + pub(super) fn create_editor(&self) -> adw::TabPage { + let id = Uuid::new_v4(); + let slot = self.editor(id, None, None); + self.insert(id, slot) + } + + fn insert(&self, id: Uuid, slot: WorkspaceTab) -> adw::TabPage { + let page = slot.page().clone(); + apply_tab_title(&page, &resolve_tab_title(&slot.identity(), self.parts.naming.get())); + self.parts.tabs.borrow_mut().insert(id, slot); + self.parts.input.deliver(WorkspaceInput::WorkspaceTabsChanged); + page + } + + fn append(&self, child: &impl IsA, id: Uuid) -> adw::TabPage { + let page = self.parts.view.append(child); + self.parts.index.insert(&page, id); + page + } + + fn editor(&self, id: Uuid, initial_query: Option, draft: Option) -> WorkspaceTab { + let parts = &self.parts; + // Such a tab shows the editor's sample query, which names nothing. + let opened_empty = initial_query.is_none(); + let editor = SqlEditor::new(SqlEditorInit { + initial_query, + settings: parts.settings.clone(), + history: parts.history.clone(), + tasks: parts.context.tasks.clone(), + session: parts.context.active.clone(), + hold: parts.context.hold.clone(), + close_timeout: parts.context.close_timeout(), + }); + connect_editor(&editor, id, &parts.input, &parts.toasts); + let ordinal = self.editor_count() + 1; + let page = self.append(&editor, id); + let label = match opened_empty { + true => EditorLabel::Untitled(ordinal), + false => EditorPreview::read(&editor.buffer()).label(ordinal), + }; + let draft = draft.unwrap_or_else(|| { + // Text a new tab opens with is written even if the user + // never types in it. + let draft = DraftId::new(); + parts.drafts.schedule(&parts.draft_scope, draft, &editor.buffer()); + draft + }); + WorkspaceTab::Editor(EditorTabSlot { + editor, + page, + draft, + ordinal, + label, + }) + } + + fn table(&self, id: Uuid, table: TableRef, page: TablePage) -> Option { + let parts = &self.parts; + let session = parts.context.active.get()?; + let TableRef { schema, name } = table; + let browse = BrowseTab::new(BrowseTabInit { + tracker: parts.change_trackers.register(id), + schema: schema.clone(), + table: name.clone(), + connection_id: parts.context.active.metadata().map(|metadata| metadata.id), + read_only: parts.read_only, + page_size: page.page_size, + initial_offset: page.offset, + initial_sort: page.sort, + column_widths: parts.storage.column_widths().clone(), + filter_settings: parts.storage.filter_settings().clone(), + session, + tasks: parts.context.tasks.clone(), + deadlines: parts.context.deadlines.clone(), + }); + connect_browse(&browse, id, &parts.input); + let tab_page = self.append(&browse, id); + let follow = follow_loading(&tab_page, browse.is_loading()); + browse.connect_loading_changed(move |_, loading| follow(loading)); + Some(WorkspaceTab::Table(TableTabSlot { + id, + page: tab_page, + schema, + table: name, + browse, + })) + } + + fn structure(&self, id: Uuid, target: StructureTarget) -> Option { + let parts = &self.parts; + let session = parts.context.active.get()?; + let (schema, table, mode) = match target { + StructureTarget::Existing(TableRef { schema, name }) => (schema, name, StructureMode::Edit), + StructureTarget::NewDraft { schema } => (schema, String::new(), StructureMode::New), + }; + let structure = StructureTab::new(StructureTabInit { + tracker: parts.structure_trackers.register(id), + schema: schema.clone(), + table: table.clone(), + mode, + session, + tasks: parts.context.tasks.clone(), + }); + connect_structure(&structure, id, &parts.input); + let page = self.append(&structure, id); + let follow = follow_loading(&page, structure.is_loading()); + structure.connect_loading_changed(move |_, loading| follow(loading)); + Some(WorkspaceTab::Structure(StructureTabSlot { + id, + structure, + page, + schema, + table, + mode, + })) + } + + fn editor_count(&self) -> usize { + self.parts + .tabs + .borrow() + .values() + .filter(|tab| matches!(tab, WorkspaceTab::Editor(_))) + .count() + } +} + +pub(super) const PREVIEW_CHARS: i32 = 200; + +/// The start of an editor's script, read without copying the rest. +pub(super) struct EditorPreview { + pub text: String, + pub truncated: bool, +} + +impl EditorPreview { + pub(super) fn read(buffer: >k::TextBuffer) -> Self { + let end = buffer.iter_at_offset(PREVIEW_CHARS); + Self { + text: buffer.text(&buffer.start_iter(), &end, false).to_string(), + truncated: !end.is_end(), + } + } + + /// What tab number `ordinal` with this script is named after. + pub(super) fn label(&self, ordinal: usize) -> EditorLabel { + EditorLabel::of(ordinal, &self.text, self.truncated) + } +} + +/// 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 |_, _| dirty.deliver(WorkspaceInput::TabDirtyChanged)); + let save = input.clone(); + structure.connect_save_requested(move |_| save.deliver(WorkspaceInput::SaveTab(tab_id))); + let stop = input.clone(); + 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.deliver(WorkspaceInput::DropTablePrompt { schema, table }); + }); + let toast = input.clone(); + structure.connect_toast(move |_, text| toast.deliver(WorkspaceInput::ShowToast(text))); + let alert = input.clone(); + structure.connect_alert(move |_, title, body| alert.deliver(WorkspaceInput::ShowAlert { title, body })); +} + +/// The tab bar spins on a tab's page while the tab reads. The tab +/// started reading before it had a page, so the page starts from where +/// the read stands. The tab holds the handler and the page holds the +/// tab, so the handler holds the page weakly. +fn follow_loading(page: &adw::TabPage, loading: bool) -> impl Fn(bool) + 'static { + page.set_loading(loading); + let page = page.downgrade(); + move |loading| { + if let Some(page) = page.upgrade() { + page.set_loading(loading); + } + } +} + +/// 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.deliver(WorkspaceInput::WorkspaceTabsChanged)); + let export = input.clone(); + browse.connect_export_results(move |_, offer| export.deliver(WorkspaceInput::ExportResults(Box::new(offer)))); + let alert = input.clone(); + browse.connect_alert(move |_, title, body| alert.deliver(WorkspaceInput::ShowAlert { title, body })); + let toast = input.clone(); + browse.connect_toast(move |_, text| toast.deliver(WorkspaceInput::ShowToast(text))); + let dirty = input.clone(); + browse.connect_dirty_changed(move |_, _| dirty.deliver(WorkspaceInput::TabDirtyChanged)); + let save = input.clone(); + browse.connect_save_requested(move |_| save.deliver(WorkspaceInput::SaveTab(tab_id))); + let stop = input.clone(); + browse.connect_stop_save_requested(move |_| stop.deliver(WorkspaceInput::StopSaving(tab_id))); + let refresh = input.clone(); + browse.connect_refresh_requested(move |_| refresh.deliver(WorkspaceInput::RefreshTab(tab_id))); +} + +/// 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. +pub(super) fn connect_editor( + editor: &SqlEditor, + tab_id: Uuid, + input: &WorkspaceSender, + toasts: &glib::WeakRef, +) { + let run_state = input.clone(); + editor.connect_run_state_changed(move |_, running| { + run_state.deliver(WorkspaceInput::EditorTabRunStateChanged(tab_id, running)); + }); + let content = input.clone(); + editor.connect_content_changed(move |_| content.deliver(WorkspaceInput::EditorTabContentChanged(tab_id))); + let transaction = input.clone(); + editor.connect_transaction_changed(move |_, _| transaction.deliver(WorkspaceInput::EditorTransactionChanged)); + let toast = input.clone(); + editor.connect_toast(move |_, text| toast.deliver(WorkspaceInput::ShowToast(text))); + let export = input.clone(); + editor.connect_export_results(move |_, offer| export.deliver(WorkspaceInput::ExportResults(Box::new(offer)))); + // The overlay is an ancestor of the editor, so a strong capture + // would keep both alive once the window lets go of them. + let toasts = toasts.clone(); + editor.connect_undoable_toast(move |editor, title| { + let Some(toasts) = toasts.upgrade() else { + return; + }; + let toast = crate::ui::plain_toast(&title); + toast.set_button_label(Some(&crate::i18n::gettext("Undo"))); + let editor = editor.downgrade(); + toast.connect_button_clicked(move |_| { + if let Some(editor) = editor.upgrade() { + editor.undo(); + } + }); + toasts.add_toast(toast); + }); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_support::drain_main_context; + + #[gtk4::test] + fn an_editor_tab_is_named_from_the_start_of_its_script_only() { + let buffer = gtk::TextBuffer::new(None); + buffer.set_text(&format!("SELECT 1;\n{}", "-- é\n".repeat(10_000))); + + let preview = EditorPreview::read(&buffer); + + let limit = usize::try_from(PREVIEW_CHARS).expect("a positive limit"); + assert_eq!(preview.text.chars().count(), limit); + assert!(preview.truncated); + assert!(matches!(preview.label(1), EditorLabel::Query { truncated: true, .. })); + } + + #[gtk4::test] + fn a_blank_script_is_named_by_its_number() { + let buffer = gtk::TextBuffer::new(None); + buffer.set_text(" \n"); + + assert_eq!(EditorPreview::read(&buffer).label(4), EditorLabel::Untitled(4)); + } + + #[gtk4::test] + fn an_editor_tab_leaves_the_toast_overlay_free() { + crate::register_test_resources(); + let runtime = crate::test_support::test_runtime(); + let settings = crate::test_support::MemorySettings::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(tablepro_session::runtime::CountingHold::new()), + close_timeout: std::time::Duration::from_secs(5), + }); + let toasts = adw::ToastOverlay::new(); + toasts.set_child(Some(&editor)); + let (input, _receiver) = crate::runtime::input_queue::(); + connect_editor(&editor, Uuid::new_v4(), &input, &toasts.downgrade()); + let overlay = toasts.downgrade(); + + drop(editor); + drop(toasts); + drain_main_context(); + + assert!( + overlay.upgrade().is_none(), + "an editor tab kept the window's toast overlay alive" + ); + } +} diff --git a/linux/crates/app/src/ui/workspace/tab_identity.rs b/linux/crates/app/src/ui/workspace/tab_identity.rs new file mode 100644 index 0000000000..e28a375bb4 --- /dev/null +++ b/linux/crates/app/src/ui/workspace/tab_identity.rs @@ -0,0 +1,12 @@ +use tablepro_core::meta::TableRef; + +use super::editor_label::EditorLabel; +use super::structure_target::StructureTarget; + +/// What a tab shows, as far as its title goes. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum TabIdentity { + Table(TableRef), + Structure(StructureTarget), + Editor(EditorLabel), +} diff --git a/linux/crates/app/src/ui/workspace/tab_index.rs b/linux/crates/app/src/ui/workspace/tab_index.rs new file mode 100644 index 0000000000..74df7a1484 --- /dev/null +++ b/linux/crates/app/src/ui/workspace/tab_index.rs @@ -0,0 +1,74 @@ +use std::cell::RefCell; +use std::collections::HashMap; + +use libadwaita as adw; +use uuid::Uuid; + +/// Which tab each page of the tab view shows. A page is in it from the +/// moment it is appended until its close finishes. +#[derive(Debug, Default)] +pub(crate) struct TabIndex { + ids: RefCell>, +} + +impl TabIndex { + pub(crate) fn insert(&self, page: &adw::TabPage, id: Uuid) { + self.ids.borrow_mut().insert(page.clone(), id); + } + + pub(crate) fn id_of(&self, page: &adw::TabPage) -> Option { + self.ids.borrow().get(page).copied() + } + + pub(crate) fn remove(&self, page: &adw::TabPage) -> Option { + self.ids.borrow_mut().remove(page) + } + + /// Let go of every page, so a closed workspace holds none of them. + pub(crate) fn clear(&self) { + self.ids.borrow_mut().clear(); + } +} + +#[cfg(test)] +mod tests { + use gtk4 as gtk; + use gtk4::prelude::*; + + use super::*; + + #[gtk4::test] + fn tab_index_round_trips() { + adw::init().expect("libadwaita initialises under the test backend"); + let view = adw::TabView::new(); + let first = view.append(>k::Label::new(None)); + let second = view.append(>k::Label::new(None)); + let index = TabIndex::default(); + let (a, b) = (Uuid::new_v4(), Uuid::new_v4()); + + index.insert(&first, a); + index.insert(&second, b); + + assert_eq!(index.id_of(&first), Some(a)); + assert_eq!(index.id_of(&second), Some(b)); + assert_eq!(index.remove(&first), Some(a)); + assert_eq!(index.id_of(&first), None); + assert_eq!(index.id_of(&second), Some(b)); + } + + #[gtk4::test] + fn a_page_removed_from_the_index_is_freed() { + adw::init().expect("libadwaita initialises under the test backend"); + let view = adw::TabView::new(); + let page = view.append(>k::Label::new(None)); + let index = TabIndex::default(); + index.insert(&page, Uuid::new_v4()); + let freed = page.downgrade(); + + view.close_page(&page); + index.remove(&page); + drop(page); + + assert!(freed.upgrade().is_none(), "the index kept a closed page alive"); + } +} diff --git a/linux/crates/app/src/ui/workspace/tab_spec.rs b/linux/crates/app/src/ui/workspace/tab_spec.rs new file mode 100644 index 0000000000..c9c247649a --- /dev/null +++ b/linux/crates/app/src/ui/workspace/tab_spec.rs @@ -0,0 +1,42 @@ +use tablepro_core::meta::TableRef; +use tablepro_storage::DraftId; + +use super::structure_target::StructureTarget; + +/// A tab to open. Every way a tab opens builds one of these: the sidebar, +/// the overview's new-tab button, a restore and a reopen. +#[derive(Debug, Clone)] +pub(crate) enum TabSpec { + Editor { + initial_query: Option, + /// The draft a restored tab keeps writing to. `None` gives a new + /// tab a new draft, written at once. + draft: Option, + }, + Table { + table: TableRef, + page: TablePage, + }, + Structure { + target: StructureTarget, + }, +} + +/// Where a table tab opens in its rows. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct TablePage { + pub offset: u64, + pub page_size: u64, + /// The column index and whether it sorts ascending. + pub sort: Option<(usize, bool)>, +} + +impl TablePage { + pub(crate) fn first(page_size: u64) -> Self { + Self { + offset: 0, + page_size, + sort: None, + } + } +} diff --git a/linux/crates/app/src/ui/workspace/tab_title.rs b/linux/crates/app/src/ui/workspace/tab_title.rs new file mode 100644 index 0000000000..82778adfdd --- /dev/null +++ b/linux/crates/app/src/ui/workspace/tab_title.rs @@ -0,0 +1,156 @@ +use tablepro_core::meta::TableRef; + +use super::editor_label::EditorLabel; +use super::structure_target::StructureTarget; +use super::tab_identity::TabIdentity; +use super::table_naming::TableNaming; +use crate::ui::editor::derive_tab_label; + +/// A tab's title and the tooltip that says more than it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct TabTitle { + pub label: String, + pub tooltip: Option, +} + +/// The one place a tab's title is made. Unsaved changes never change it; +/// the page's icon says so. +pub(crate) fn resolve_tab_title(identity: &TabIdentity, naming: TableNaming) -> TabTitle { + match identity { + TabIdentity::Table(table) => table_title(table, naming), + TabIdentity::Structure(StructureTarget::Existing(table)) => { + let structure = |name: &str| crate::i18n::gettext_f("{table} · Structure", &[("table", name)]); + let base = table_title(table, naming); + TabTitle { + label: structure(&base.label), + tooltip: base.tooltip.as_deref().map(structure), + } + } + TabIdentity::Structure(StructureTarget::NewDraft { .. }) => TabTitle { + label: crate::i18n::gettext("New Table"), + tooltip: None, + }, + TabIdentity::Editor(EditorLabel::Untitled(ordinal)) => TabTitle { + label: crate::i18n::gettext_f("Query {n}", &[("n", &ordinal.to_string())]), + tooltip: None, + }, + TabIdentity::Editor(EditorLabel::Query { prefix, truncated }) => { + let label = derive_tab_label(prefix); + let text = prefix.trim(); + let tooltip = match truncated { + true => format!("{}…", prefix.trim_start()), + false => text.to_owned(), + }; + let tooltip = (!text.is_empty() && tooltip != label).then_some(tooltip); + TabTitle { label, tooltip } + } + } +} + +/// `schema.table` when the naming asks for it; a bare name has the +/// qualified one as its tooltip. +fn table_title(table: &TableRef, naming: TableNaming) -> TabTitle { + let qualified = table.schema.as_ref().map(|schema| format!("{schema}.{}", table.name)); + match (naming, qualified) { + (TableNaming::SchemaQualified, Some(qualified)) => TabTitle { + label: qualified, + tooltip: None, + }, + (_, qualified) => TabTitle { + label: table.name.clone(), + tooltip: qualified, + }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn orders() -> TableRef { + TableRef::new(Some("public".to_owned()), "orders") + } + + #[test] + fn a_table_is_named_bare_with_its_schema_in_the_tooltip() { + let title = resolve_tab_title(&TabIdentity::Table(orders()), TableNaming::Bare); + + assert_eq!(title.label, "orders"); + assert_eq!(title.tooltip.as_deref(), Some("public.orders")); + assert_eq!( + resolve_tab_title( + &TabIdentity::Table(TableRef::unqualified("t")), + TableNaming::SchemaQualified + ), + TabTitle { + label: "t".to_owned(), + tooltip: None, + } + ); + } + + #[test] + fn structure_title_keeps_suffix_regardless_of_dirty_state() { + let identity = TabIdentity::Structure(StructureTarget::Existing(orders())); + + let title = resolve_tab_title(&identity, TableNaming::Bare); + + assert_eq!(title.label, "orders · Structure"); + assert_eq!(title.tooltip.as_deref(), Some("public.orders · Structure")); + assert!(!title.label.contains('•')); + } + + #[test] + fn structure_title_is_schema_qualified_with_multiple_schemas() { + let identity = TabIdentity::Structure(StructureTarget::Existing(orders())); + + let title = resolve_tab_title(&identity, TableNaming::SchemaQualified); + + assert_eq!(title.label, "public.orders · Structure"); + assert_eq!(title.tooltip, None); + } + + #[test] + fn blank_table_name_resolves_to_new_table() { + let identity = TabIdentity::Structure(StructureTarget::of(Some("public"), "")); + + assert_eq!(resolve_tab_title(&identity, TableNaming::Bare).label, "New Table"); + } + + #[test] + fn blank_query_resolves_to_query_ordinal() { + let identity = TabIdentity::Editor(EditorLabel::of(3, " \n\t", false)); + + let title = resolve_tab_title(&identity, TableNaming::Bare); + + assert_eq!(title.label, "Query 3"); + assert_eq!(title.tooltip, None); + } + + #[test] + fn a_query_is_named_from_its_start_with_the_rest_in_the_tooltip() { + let long = format!("SELECT * FROM orders WHERE id = 1;\n{}", "-- x\n".repeat(50)); + let prefix: String = long.chars().take(200).collect(); + + let title = resolve_tab_title( + &TabIdentity::Editor(EditorLabel::of(1, &prefix, true)), + TableNaming::Bare, + ); + + assert_eq!(title.label, derive_tab_label(&prefix)); + let tooltip = title.tooltip.expect("a tooltip"); + assert!(tooltip.starts_with("SELECT * FROM orders"), "{tooltip}"); + assert!(tooltip.ends_with('…'), "{tooltip}"); + } + + #[test] + fn a_script_its_label_shows_whole_has_no_tooltip() { + let title = resolve_tab_title( + &TabIdentity::Editor(EditorLabel::of(1, "SELECT 1;", false)), + TableNaming::Bare, + ); + + assert_eq!(title.label, "SELECT 1;"); + assert_eq!(title.tooltip, None); + } +} diff --git a/linux/crates/app/src/ui/workspace/tab_title_tests.rs b/linux/crates/app/src/ui/workspace/tab_title_tests.rs new file mode 100644 index 0000000000..74d19e3dcd --- /dev/null +++ b/linux/crates/app/src/ui/workspace/tab_title_tests.rs @@ -0,0 +1,217 @@ +use std::sync::Arc; + +use gtk4::prelude::*; +use gtk4::subclass::prelude::*; +use libadwaita as adw; +use tablepro_core::ddl::{DraftColumn, DraftType, TableChanges, TableDraft}; +use tablepro_core::meta::TableRef; +use tablepro_core::{EditorSession, TransactionState}; +use tablepro_storage::DraftId; +use tablepro_test_support::{FakeEditorSession, FakeReply, FakeRun, Replies}; +use uuid::Uuid; + +use super::structure_target::StructureTarget; +use super::tab_spec::TabSpec; +use super::{Workspace, WorkspaceInput, WorkspaceTab}; +use crate::runtime::DeliverInput; +use crate::services::pending_structure::PendingStructure; +use crate::test_support::{finish_on_main, wait_until}; +use crate::ui::SessionWindowInput; +use crate::ui::session_window::tests::{TestWindow, WAIT, editors}; +use crate::ui::structure_tab::StructureMode; +use crate::workspace::{TabRecord, WorkspaceRecord}; + +fn modified(page: &adw::TabPage) -> bool { + page.icon().and_downcast::().is_some_and(|icon| { + icon.names() + .iter() + .any(|name| name == crate::ui::icons::DOCUMENT_MODIFIED) + }) +} + +/// A new table draft's tab, unselected behind the tab in front, and its +/// id. +fn new_table_tab(workspace: &Workspace) -> (adw::TabPage, Uuid) { + workspace + .with_model(|model| { + let page = model.factory.create(TabSpec::Structure { + target: StructureTarget::NewDraft { schema: None }, + })?; + let id = model.index.id_of(&page)?; + Some((page, id)) + }) + .flatten() + .expect("a structure tab") +} + +fn a_column_added() -> Option { + let mut draft = TableDraft::new(TableRef::unqualified("t")); + draft + .columns + .push(DraftColumn::new("a", DraftType::Typed("int".to_owned()))); + Some(PendingStructure { + original: None, + draft, + changes: TableChanges::default(), + unparsed_defaults: Vec::new(), + }) +} + +/// What a structure tab's dirty-changed signal does. +fn edit_structure(workspace: &Workspace, id: Uuid, pending: Option) { + let input = workspace + .with_model(|model| { + if let Some(tracker) = model.structure_trackers.get(id) { + tracker.set_pending(pending); + } + model.input.clone() + }) + .expect("the workspace's input"); + input.deliver(WorkspaceInput::TabDirtyChanged); +} + +#[gtk4::test] +fn unsaved_icon_follows_structure_tracker() { + let test = TestWindow::connected("UnsavedIcon"); + let workspace = test.workspace(); + let (page, id) = new_table_tab(&workspace); + assert!(!modified(&page)); + + edit_structure(&workspace, id, a_column_added()); + wait_until(WAIT, || modified(&page)).expect("the unsaved icon"); + assert_eq!(page.title(), "New Table", "the title changed with the edit"); + + edit_structure(&workspace, id, None); + wait_until(WAIT, || page.icon().is_none()).expect("the icon to go"); +} + +#[gtk4::test] +fn unsaved_marker_never_sets_needs_attention() { + let test = TestWindow::connected("UnsavedAttention"); + let workspace = test.workspace(); + let (page, id) = new_table_tab(&workspace); + WidgetExt::activate_action(&test.window, "win.new-editor-tab", None).expect("win.new-editor-tab"); + wait_until(WAIT, || editors(&test.window).len() == 1).expect("an editor tab in front"); + assert_ne!(workspace.imp().tab_view.selected_page().as_ref(), Some(&page)); + + edit_structure(&workspace, id, a_column_added()); + wait_until(WAIT, || modified(&page)).expect("the unsaved icon"); + + assert!( + !page.needs_attention(), + "a background tab with changes asks for attention" + ); + assert!(page.indicator_icon().is_none()); +} + +#[gtk4::test] +fn open_transaction_marks_editor_tab() { + let mut runs = Replies::default(); + runs.always(FakeRun::completed(Vec::new()).leaving(TransactionState::InTransaction)); + let editor_session: Arc = Arc::new(FakeEditorSession::new(runs)); + let test = TestWindow::connected_with("OpenTransactionIcon", |script| { + script.open_editor_session.always(FakeReply::ok(editor_session)); + }); + + test.run_in_new_editor_tab(); + + let page = test + .workspace() + .imp() + .tab_view + .selected_page() + .expect("the editor's page"); + wait_until(WAIT, || modified(&page)).expect("the open transaction to mark the tab"); +} + +#[gtk4::test] +fn restored_workspace_selects_saved_active_tab() { + let test = TestWindow::picker("RestoreActive"); + let store = test.services.storage.workspaces().clone(); + let orders = TabRecord::Structure { + schema: None, + table: "orders".to_owned(), + }; + store + .schedule( + &test.key(), + WorkspaceRecord { + active_tab: Some(1), + tabs: vec![TabRecord::Editor { draft: DraftId::new() }, orders], + }, + ) + .expect("the record to queue"); + finish_on_main(WAIT, store.flush(&test.key())) + .expect("the flush to end") + .expect("the record written"); + + test.window + .send(SessionWindowInput::OpenSaved(Box::new(test.saved.clone()))); + test.wait_for_page("workspace"); + let workspace = test.workspace(); + wait_until(WAIT, || workspace.imp().tab_view.n_pages() == 2).expect("both tabs restored"); + + let selected = workspace + .read(|model| { + let id = model.selected_workspace_tab_id()?; + match model.workspace_tabs.borrow().get(&id)? { + WorkspaceTab::Structure(slot) => Some((slot.mode, slot.table.clone())), + _ => None, + } + }) + .flatten(); + assert_eq!(selected, Some((StructureMode::Edit, "orders".to_owned()))); +} + +#[gtk4::test] +fn window_title_follows_the_tab_in_front() { + let test = TestWindow::connected("WindowTitle"); + wait_until(WAIT, || test.window.title().as_deref() == Some("Fake - TablePro")).expect("the connection's title"); + + WidgetExt::activate_action(&test.window, "win.new-editor-tab", None).expect("win.new-editor-tab"); + + wait_until(WAIT, || { + test.window.title().as_deref() == Some("Query 1 (Fake) - TablePro") + }) + .expect("the tab's title"); + let heading = test.workspace().imp().window_title.get(); + assert_eq!(heading.title(), "Query 1"); + assert_eq!(heading.subtitle(), "Fake"); +} + +#[gtk4::test] +fn tabs_name_the_schema_once_the_session_has_two() { + let test = TestWindow::connected("NamingFollowsSchemas"); + let workspace = test.workspace(); + let page = workspace + .with_model(|model| { + model.open_tab(TabSpec::Table { + table: TableRef::new(Some("public".to_owned()), "orders"), + page: super::tab_spec::TablePage::first(100), + }) + }) + .flatten() + .expect("a table tab"); + assert_eq!(page.title(), "orders"); + assert_eq!(page.tooltip().as_deref(), Some("public.orders")); + + let schema = |name: &str| tablepro_core::meta::SchemaInfo { + name: name.to_owned(), + is_default: name == "public", + is_system: false, + }; + let session = workspace + .read(|model| model.context.active.get()) + .flatten() + .expect("the session"); + // What a reconnect to a database with a second schema commits. + session + .catalog() + .commit(vec![schema("public"), schema("sales")], Vec::new()); + + wait_until(WAIT, || page.title() == "public.orders").expect("the tab to name its schema"); + assert!( + page.tooltip().unwrap_or_default().is_empty(), + "the tooltip repeats the title" + ); +} diff --git a/linux/crates/app/src/ui/workspace/table_naming.rs b/linux/crates/app/src/ui/workspace/table_naming.rs new file mode 100644 index 0000000000..0e9c5c604d --- /dev/null +++ b/linux/crates/app/src/ui/workspace/table_naming.rs @@ -0,0 +1,44 @@ +use tablepro_core::meta::SchemaInfo; + +/// Whether a table tab names the table's schema. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub(crate) enum TableNaming { + #[default] + Bare, + SchemaQualified, +} + +impl TableNaming { + /// Qualified once the user has two schemas of their own to tell + /// apart; the engine's own schemas do not count. + pub(crate) fn of(schemas: &[SchemaInfo]) -> Self { + match schemas.iter().filter(|schema| !schema.is_system).count() { + 0 | 1 => Self::Bare, + _ => Self::SchemaQualified, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn schema(name: &str, is_system: bool) -> SchemaInfo { + SchemaInfo { + name: name.to_owned(), + is_default: name == "public", + is_system, + } + } + + #[test] + fn system_schemas_do_not_make_names_qualified() { + let schemas = [schema("public", false), schema("pg_catalog", true)]; + + assert_eq!(TableNaming::of(&schemas), TableNaming::Bare); + assert_eq!( + TableNaming::of(&[schema("public", false), schema("sales", false)]), + TableNaming::SchemaQualified + ); + } +} diff --git a/linux/crates/app/src/ui/workspace/unsaved_icon.rs b/linux/crates/app/src/ui/workspace/unsaved_icon.rs new file mode 100644 index 0000000000..2e6ec1348f --- /dev/null +++ b/linux/crates/app/src/ui/workspace/unsaved_icon.rs @@ -0,0 +1,13 @@ +use libadwaita as adw; + +use crate::ui::icons::DOCUMENT_MODIFIED; + +/// Mark a tab with unsaved changes by its page icon, as GNOME Text Editor +/// does, never by its title or needs-attention, which the tab bar draws +/// as a call to look at it. +pub(crate) fn apply_unsaved_icon(page: &adw::TabPage, unsaved: bool) { + match unsaved { + true => page.set_icon(Some(&gio::ThemedIcon::new(DOCUMENT_MODIFIED))), + false => page.set_icon(None::<&gio::Icon>), + } +} diff --git a/linux/crates/app/src/ui/workspace/window_tabs.rs b/linux/crates/app/src/ui/workspace/window_tabs.rs index a143573543..deb2c83766 100644 --- a/linux/crates/app/src/ui/workspace/window_tabs.rs +++ b/linux/crates/app/src/ui/workspace/window_tabs.rs @@ -1,40 +1,37 @@ -use std::cell::RefCell; +use std::rc::Rc; use gtk4::glib; use libadwaita as adw; use libadwaita::prelude::*; +use super::WorkspaceTab; +use super::tab_index::TabIndex; use super::workspace_persistence::WorkspaceTabs; -use super::{WorkspaceTab, read_workspace_tab_id}; use crate::workspace::{TabDirectory, TabEntry}; /// The window's tabs as the close prompts read them: in tab bar order, /// by the names their tabs show. pub(super) struct WindowTabs { tabs: WorkspaceTabs, - view: RefCell>, + index: Rc, + /// Held weakly: the workspace owns the view, and the close prompts + /// can outlive it. + view: glib::WeakRef, } impl WindowTabs { - pub(super) fn new(tabs: WorkspaceTabs) -> Self { + pub(super) fn new(tabs: WorkspaceTabs, index: Rc, view: &adw::TabView) -> Self { Self { tabs, - view: RefCell::new(glib::WeakRef::new()), + index, + view: view.downgrade(), } } - - /// The tab view built for a connection, or `None` once it is torn - /// down. - pub(super) fn set_view(&self, view: Option<&adw::TabView>) { - let weak = glib::WeakRef::new(); - weak.set(view); - self.view.replace(weak); - } } impl TabDirectory for WindowTabs { fn tabs(&self) -> Vec { - let Some(view) = self.view.borrow().upgrade() else { + let Some(view) = self.view.upgrade() else { return Vec::new(); }; let tabs = self.tabs.borrow(); @@ -42,12 +39,11 @@ impl TabDirectory for WindowTabs { (0..pages.n_items()) .filter_map(|index| pages.item(index).and_downcast::()) .filter_map(|page| { - let id = read_workspace_tab_id(&page)?; + let id = self.index.id_of(&page)?; let tab = tabs.get(&id)?; - let title = page.title(); Some(TabEntry { id, - title: title.strip_prefix("• ").unwrap_or(&title).to_owned(), + title: page.title().to_string(), open_transaction: matches!( tab, WorkspaceTab::Editor(slot) if slot.editor.has_open_transaction() diff --git a/linux/crates/app/src/ui/workspace/window_title.rs b/linux/crates/app/src/ui/workspace/window_title.rs new file mode 100644 index 0000000000..54770dff03 --- /dev/null +++ b/linux/crates/app/src/ui/workspace/window_title.rs @@ -0,0 +1,88 @@ +use super::tab_title::TabTitle; + +/// What the window is called: in the window list and Alt+Tab, and in its +/// header bar's title and subtitle. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct WindowTitle { + pub window: String, + pub heading: String, + pub subtitle: String, +} + +/// Named the way GNOME Text Editor names its window, "[document] +/// ([location]) - Text Editor": the tab in front, then the connection. +pub(crate) fn resolve_window_title( + active: Option<&TabTitle>, + connection: &str, + driver: &str, + read_only: bool, +) -> WindowTitle { + let (window, heading, subtitle) = match active { + Some(tab) => ( + crate::i18n::gettext_f( + "{tab} ({connection}) - TablePro", + &[("tab", &tab.label), ("connection", connection)], + ), + tab.label.clone(), + connection.to_owned(), + ), + None => ( + crate::i18n::gettext_f("{connection} - TablePro", &[("connection", connection)]), + connection.to_owned(), + driver.to_owned(), + ), + }; + let subtitle = match read_only { + true => crate::i18n::gettext_f("{name} · Read-Only", &[("name", &subtitle)]), + false => subtitle, + }; + WindowTitle { + window, + heading, + subtitle, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn tab(label: &str) -> TabTitle { + TabTitle { + label: label.to_owned(), + tooltip: None, + } + } + + #[test] + fn window_title_names_tab_and_connection_without_em_dash() { + let title = resolve_window_title(Some(&tab("orders")), "Shop", "PostgreSQL", false); + + assert_eq!(title.window, "orders (Shop) - TablePro"); + assert_eq!(title.heading, "orders"); + assert_eq!(title.subtitle, "Shop"); + assert!(!title.window.contains('\u{2014}')); + assert!(!title.window.contains('•')); + } + + #[test] + fn with_no_tab_the_connection_heads_the_window() { + let title = resolve_window_title(None, "Shop", "PostgreSQL", false); + + assert_eq!(title.window, "Shop - TablePro"); + assert_eq!(title.heading, "Shop"); + assert_eq!(title.subtitle, "PostgreSQL"); + } + + #[test] + fn read_only_subtitle() { + assert_eq!( + resolve_window_title(None, "Shop", "PostgreSQL", true).subtitle, + "PostgreSQL · Read-Only" + ); + assert_eq!( + resolve_window_title(Some(&tab("orders")), "Shop", "PostgreSQL", true).subtitle, + "Shop · Read-Only" + ); + } +} diff --git a/linux/crates/app/src/ui/workspace/workspace_actions.rs b/linux/crates/app/src/ui/workspace/workspace_actions.rs index ae8729f433..71ad900850 100644 --- a/linux/crates/app/src/ui/workspace/workspace_actions.rs +++ b/linux/crates/app/src/ui/workspace/workspace_actions.rs @@ -1,5 +1,6 @@ //! The window actions a workspace adds while it is open, and takes off -//! the window again when it goes. +//! the window again when it goes. Their keys are application +//! accelerators; a tab's own keys belong to the tab. use gtk4::glib; use gtk4::prelude::*; @@ -21,14 +22,9 @@ const HISTORY_ACTIONS: [&str; 2] = ["show-history", "show-saved-queries"]; /// 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), + let entries: [(&'static str, ActionInput); 6] = [ + ("new-editor-tab", || WorkspaceInput::NewEditorTab), ("reopen-closed-tab", || WorkspaceInput::ReopenClosedTab), - ("open-filter", || WorkspaceInput::ShowFilterDialog), ("save-query", || WorkspaceInput::SaveActiveQuery), ("show-history", || WorkspaceInput::ShowHistory), ("show-saved-queries", || WorkspaceInput::ShowSavedQueries), @@ -122,8 +118,8 @@ mod tests { 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"); + assert!(names.contains(&"new-editor-tab")); + WidgetExt::activate_action(&window, "win.new-editor-tab", 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/workspace/workspace_persistence.rs b/linux/crates/app/src/ui/workspace/workspace_persistence.rs index c59bb827c5..63c6d2cadc 100644 --- a/linux/crates/app/src/ui/workspace/workspace_persistence.rs +++ b/linux/crates/app/src/ui/workspace/workspace_persistence.rs @@ -9,9 +9,12 @@ use libadwaita::prelude::*; use tablepro_session::SessionKey; use uuid::Uuid; -use super::{WorkspaceTab, read_workspace_tab_id}; +use super::WorkspaceTab; +use super::tab_index::TabIndex; use crate::ui::structure_tab::StructureMode; -use crate::workspace::{PersistDebouncer, SortRecord, TabRecord, WorkspaceRecord, WorkspaceSaveError, WorkspaceStore}; +use crate::workspace::{ + PersistDebouncer, SortRecord, TabRecord, TabSnapshot, WorkspaceRecord, WorkspaceSaveError, WorkspaceStore, +}; /// How long after the last tab change the tab list is written. const PERSIST_DELAY: Duration = Duration::from_millis(500); @@ -32,12 +35,13 @@ struct Target { key: SessionKey, view: glib::WeakRef, tabs: WorkspaceTabs, + index: Rc, } impl Target { fn record(&self) -> Option { let view = self.view.upgrade()?; - Some(workspace_record(&view, &self.tabs.borrow())) + Some(workspace_record(&view, &self.tabs.borrow(), &self.index)) } } @@ -51,12 +55,13 @@ impl WorkspacePersistence { } /// Start writing the tabs of `key`, whose saved tabs have been read. - pub fn attach(&self, key: SessionKey, view: &adw::TabView, tabs: WorkspaceTabs) { + pub fn attach(&self, key: SessionKey, view: &adw::TabView, tabs: WorkspaceTabs, index: Rc) { self.debouncer.cancel(); *self.target.borrow_mut() = Some(Target { key, view: view.downgrade(), tabs, + index, }); } @@ -109,26 +114,26 @@ impl WorkspacePersistence { } } -pub(super) fn workspace_record(view: &adw::TabView, tabs: &HashMap) -> WorkspaceRecord { - let selected = view.selected_page(); +/// The record of the tabs `view` shows, in its order. +pub(super) fn workspace_record( + view: &adw::TabView, + tabs: &HashMap, + index: &TabIndex, +) -> WorkspaceRecord { let pages = view.pages(); - let mut record = WorkspaceRecord::default(); - for index in 0..pages.n_items() { - let Some(page) = pages.item(index).and_downcast::() else { - continue; - }; - let Some(tab) = read_workspace_tab_id(&page).and_then(|id| tabs.get(&id)) else { - continue; - }; - let Some(saved) = tab_record(tab) else { - continue; - }; - if selected.as_ref() == Some(&page) { - record.active_tab = u32::try_from(record.tabs.len()).ok(); - } - record.tabs.push(saved); - } - record + let snapshots: Vec = (0..pages.n_items()) + .filter_map(|position| pages.item(position).and_downcast::()) + .filter_map(|page| { + let id = index.id_of(&page)?; + let tab = tabs.get(&id)?; + Some(TabSnapshot { + id, + record: tab_record(tab), + }) + }) + .collect(); + let selected = view.selected_page().and_then(|page| index.id_of(&page)); + crate::workspace::workspace_record(&snapshots, selected) } fn tab_record(tab: &WorkspaceTab) -> Option { @@ -184,7 +189,7 @@ mod tests { use super::*; use crate::test_support::{finish_on_main, wait_until}; use crate::ui::editor::{SqlEditor, SqlEditorInit}; - use crate::ui::workspace::{EditorTabSlot, write_workspace_tab_id}; + use crate::ui::workspace::EditorTabSlot; use crate::workspace::WorkspaceLoad; const WAIT: Duration = Duration::from_secs(5); @@ -196,6 +201,7 @@ mod tests { store: WorkspaceStore, view: adw::TabView, tabs: WorkspaceTabs, + index: Rc, } impl Fixture { @@ -216,6 +222,7 @@ mod tests { store, view: adw::TabView::new(), tabs: WorkspaceTabs::default(), + index: Rc::default(), } } @@ -231,7 +238,7 @@ mod tests { }); let page = self.view.append(&editor); let id = Uuid::new_v4(); - write_workspace_tab_id(&page, id); + self.index.insert(&page, id); let draft = DraftId::new(); self.tabs.borrow_mut().insert( id, @@ -239,6 +246,8 @@ mod tests { editor, page: page.clone(), draft, + ordinal: 1, + label: crate::ui::workspace::editor_label::EditorLabel::Untitled(1), }), ); (page, draft) @@ -276,7 +285,7 @@ mod tests { let (selected, second) = fixture.add_editor(); fixture.view.set_selected_page(&selected); - let record = workspace_record(&fixture.view, &fixture.tabs.borrow()); + let record = workspace_record(&fixture.view, &fixture.tabs.borrow(), &fixture.index); assert_eq!(editor_drafts(&record), [first, second]); assert_eq!(record.active_tab, Some(1)); @@ -301,7 +310,7 @@ mod tests { }); let page = fixture.view.append(&structure); let id = Uuid::new_v4(); - write_workspace_tab_id(&page, id); + fixture.index.insert(&page, id); fixture.tabs.borrow_mut().insert( id, WorkspaceTab::Structure(crate::ui::workspace::StructureTabSlot { @@ -315,7 +324,7 @@ mod tests { ); } - let record = workspace_record(&fixture.view, &fixture.tabs.borrow()); + let record = workspace_record(&fixture.view, &fixture.tabs.borrow(), &fixture.index); assert_eq!( record.tabs, @@ -379,7 +388,7 @@ mod tests { let persistence = WorkspacePersistence::new(fixture.store.clone()); let key = SessionKey::Saved(Uuid::new_v4()); let (_, draft) = fixture.add_editor(); - persistence.attach(key.clone(), &fixture.view, fixture.tabs.clone()); + persistence.attach(key.clone(), &fixture.view, fixture.tabs.clone(), fixture.index.clone()); let flushing = persistence.detach().expect("an attached session"); finish_on_main(WAIT, flushing) @@ -399,7 +408,7 @@ mod tests { let persistence = WorkspacePersistence::new(fixture.store.clone()); let key = SessionKey::Saved(Uuid::new_v4()); let (_, draft) = fixture.add_editor(); - persistence.attach(key.clone(), &fixture.view, fixture.tabs.clone()); + persistence.attach(key.clone(), &fixture.view, fixture.tabs.clone(), fixture.index.clone()); persistence.request(); let flushing = persistence.detach().expect("an attached session"); diff --git a/linux/crates/app/src/ui/workspace/workspace_tab_view.rs b/linux/crates/app/src/ui/workspace/workspace_tab_view.rs index 0769c9f677..27881ba7f1 100644 --- a/linux/crates/app/src/ui/workspace/workspace_tab_view.rs +++ b/linux/crates/app/src/ui/workspace/workspace_tab_view.rs @@ -8,16 +8,20 @@ const CHILD_NAVIGATION_KEYS: adw::TabViewShortcuts = adw::TabViewShortcuts::CONT .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(); +pub(super) fn keep_child_navigation_keys(view: &adw::TabView) { view.remove_shortcuts(CHILD_NAVIGATION_KEYS); - view } #[cfg(test)] mod tests { use super::*; + fn new_workspace_tab_view() -> adw::TabView { + let view = adw::TabView::new(); + keep_child_navigation_keys(&view); + view + } + #[gtk4::test] fn workspace_tab_view_leaves_buffer_navigation_keys_to_children() { adw::init().expect("libadwaita initialises under the test backend"); @@ -43,4 +47,52 @@ mod tests { switching - shortcuts ); } + + /// The table lists AdwTabView's keys as stock; it must list exactly + /// the ones the workspace's tab view keeps. + #[gtk4::test] + fn tab_view_keys_in_the_table_are_the_ones_it_keeps() { + use std::collections::HashSet; + + use crate::application::{ACCELERATORS, AcceleratorScope}; + use crate::test_support::shortcut_keys::keystroke; + adw::init().expect("libadwaita initialises under the test backend"); + let alt_digits: Vec = (1..=9).map(|digit| format!("{digit}")).collect(); + let keys_of = |flag: adw::TabViewShortcuts| -> Vec { + let accels: &[&str] = match flag { + adw::TabViewShortcuts::CONTROL_TAB => &["Tab"], + adw::TabViewShortcuts::CONTROL_SHIFT_TAB => &["Tab"], + adw::TabViewShortcuts::CONTROL_PAGE_UP => &["Page_Up"], + adw::TabViewShortcuts::CONTROL_PAGE_DOWN => &["Page_Down"], + adw::TabViewShortcuts::CONTROL_HOME => &["Home"], + adw::TabViewShortcuts::CONTROL_END => &["End"], + adw::TabViewShortcuts::CONTROL_SHIFT_PAGE_UP => &["Page_Up"], + adw::TabViewShortcuts::CONTROL_SHIFT_PAGE_DOWN => &["Page_Down"], + adw::TabViewShortcuts::CONTROL_SHIFT_HOME => &["Home"], + adw::TabViewShortcuts::CONTROL_SHIFT_END => &["End"], + adw::TabViewShortcuts::ALT_ZERO => &["0"], + _ => return alt_digits.clone(), + }; + accels.iter().map(|accel| (*accel).to_owned()).collect() + }; + let kept = new_workspace_tab_view().shortcuts(); + let mut expected = HashSet::new(); + for flag in adw::TabViewShortcuts::ALL_SHORTCUTS.iter() { + if kept.contains(flag) { + expected.extend(keys_of(flag).iter().map(|accel| keystroke(accel))); + } + } + + let listed: HashSet<_> = ACCELERATORS + .iter() + .filter(|entry| entry.scope == AcceleratorScope::Stock && entry.action.starts_with("tab-view.")) + .flat_map(|entry| entry.triggers.iter().map(|accel| keystroke(accel))) + .collect(); + + assert_eq!(listed, expected); + assert!( + !listed.contains(&keystroke("Home")), + "Ctrl+Home belongs to the tabs' content" + ); + } } diff --git a/linux/crates/app/src/ui/workspace/workspace_tabs.rs b/linux/crates/app/src/ui/workspace/workspace_tabs.rs index df43371e5a..2f4caeaeb4 100644 --- a/linux/crates/app/src/ui/workspace/workspace_tabs.rs +++ b/linux/crates/app/src/ui/workspace/workspace_tabs.rs @@ -7,71 +7,29 @@ use gtk4::{gio, glib}; use libadwaita as adw; use libadwaita::prelude::*; +use tablepro_core::meta::TableRef; use tablepro_session::SessionKey; use tablepro_storage::{DraftId, DraftScope}; use uuid::Uuid; -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}; +use crate::ui::browse_tab::BrowseTabInput; +use crate::ui::structure_tab::StructureMode; use crate::workspace::{ RestoredDrafts, TabRecord, WorkspaceFileName, WorkspaceLoad, WorkspaceRecord, WorkspaceSaveError, }; +use super::structure_target::StructureTarget; +use super::tab_factory::EditorPreview; +use super::tab_index::TabIndex; +use super::tab_spec::{TabSpec, TablePage}; use super::workspace_persistence::reopen; -use super::workspace_tab_view::new_workspace_tab_view; +use super::workspace_tab_view::keep_child_navigation_keys; use super::{ - CLOSED_TABS_CAPACITY, ClosedTabDescriptor, EditorTabSlot, OpenMode, WorkspaceInput, WorkspaceModel, - WorkspaceSender, WorkspaceTab, read_workspace_tab_id, write_workspace_tab_id, + CLOSED_TABS_CAPACITY, ClosedTabDescriptor, OpenMode, WorkspaceInput, WorkspaceModel, WorkspaceSender, WorkspaceTab, }; use crate::runtime::DeliverInput; impl WorkspaceModel { - /// Builds the unified AdwTabOverview tree once per connection. - /// Idempotent via `workspace_root_added`. - 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(); - } - if let Some(root) = self.workspace_root.as_ref() - && self.workspace_outer_stack.child_by_name("tabs").is_none() - { - self.workspace_outer_stack.add_named(root, Some("tabs")); - } - self.workspace_root_added.set(true); - } - - fn build_workspace_root(&mut self) { - let settings = self.settings.clone(); - let history = Some(self.history.clone()); - let tasks = self.tasks.clone(); - let session = self.context.active.clone(); - let hold = self.context.hold.clone(); - let close_timeout = self.context.close_timeout(); - let editor_init = move || SqlEditorInit { - initial_query: None, - settings: settings.clone(), - history: history.clone(), - tasks: tasks.clone(), - session: session.clone(), - hold: hold.clone(), - close_timeout, - }; - let create_tab = overview_editor_tab( - self.workspace_tabs.clone(), - editor_init, - self.input.clone(), - self.toast_overlay.downgrade(), - ); - 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); - } - /// 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) { @@ -102,9 +60,8 @@ impl WorkspaceModel { 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)); - } + let view = self.tab_view.clone(); + reopen(&view, record, |tab| self.reopen_tab(tab, &mut texts)); } WorkspaceLoad::Empty => self.retain_drafts(&key, std::iter::empty()), // Which drafts the file names is unknown, so none is deleted. @@ -119,10 +76,9 @@ impl WorkspaceModel { &[("n", &unreadable.to_string())], )); } - if let Some(view) = self.workspace_tab_view.as_ref() { - self.workspace.attach(key, view, self.workspace_tabs.clone()); - self.persist_workspace_state(); - } + self.workspace + .attach(key, &self.tab_view, self.workspace_tabs.clone(), self.index.clone()); + self.persist_workspace_state(); } /// Delete the session's drafts that neither the saved tabs nor an @@ -144,34 +100,38 @@ impl WorkspaceModel { } fn reopen_tab(&mut self, tab: TabRecord, drafts: &mut HashMap) -> Option { - match tab { + let spec = match tab { TabRecord::Table { schema, table, offset, page_size, sort, - } => self.append_table_tab( - schema, - table, - offset, - page_size, - sort.map(|sort| (sort.column, sort.ascending)), - ), - TabRecord::Structure { schema, table } => self.append_existing_structure_tab(schema, table), + } => TabSpec::Table { + table: TableRef::new(schema, table), + page: TablePage { + offset, + page_size, + sort: sort.map(|sort| (sort.column, sort.ascending)), + }, + }, + TabRecord::Structure { schema, table } => TabSpec::Structure { + target: StructureTarget::Existing(TableRef::new(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, - ), - } + TabRecord::Editor { draft } => TabSpec::Editor { + initial_query: Some(drafts.remove(&draft).unwrap_or_default()), + draft: Some(draft), + }, + }; + self.open_tab(spec) } fn show_unreadable_workspace_toast(&self) { let toast = adw::Toast::builder() .title(crate::i18n::gettext("Saved tabs could not be read")) + .use_markup(false) .button_label(crate::i18n::gettext("Reset")) .action_name("win.reset-workspace-layout") .build(); @@ -204,223 +164,18 @@ impl WorkspaceModel { } } - /// Public entry: append a Structure draft tab for the New-Table - /// 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) { - self.ensure_workspace_root(); - let Some(tab_view) = self.workspace_tab_view.clone() else { - return; - }; - let tab_id = Uuid::new_v4(); - let Some(session) = self.context.active.get() else { - return; - }; - let structure = StructureTab::new(StructureTabInit { - tracker: self.structure_trackers.register(tab_id), - schema: schema.clone(), - table: String::new(), - mode: StructureMode::New, - session, - tasks: self.context.tasks.clone(), - }); - connect_structure(&structure, tab_id, &self.input); - - let page = tab_view.append(&structure); - page.set_title(&crate::i18n::gettext("New Table")); - write_workspace_tab_id(&page, tab_id); - - let slot = super::StructureTabSlot { - id: tab_id, - structure, - page: page.clone(), - schema, - table: String::new(), - mode: StructureMode::New, - }; - self.workspace_tabs - .borrow_mut() - .insert(tab_id, WorkspaceTab::Structure(slot)); - tab_view.set_selected_page(&page); - self.workspace_outer_stack.set_visible_child_name("tabs"); - self.refresh_window_title(); - self.persist_workspace_state(); - } - - /// Public entry: append a unified Table tab (Browse + Structure - /// share one AdwTabPage with an `AdwViewSwitcher` toggle). Both - /// sub-controllers initialise eagerly so switching modes is - /// instant and per-side state (pagination, sort, search, - /// pending edits) survives a round trip. - /// Append a Browse (Data-view) tab for an existing - /// `(schema, table)`. The DDL editor for the same table opens as - /// a separate `WorkspaceTab::Structure` page via the sidebar - /// right-click "Edit Structure" action (`append_existing_structure_tab`). - pub(super) fn append_table_tab( - &mut self, - schema: Option, - table: String, - offset: u64, - page_size: u64, - sort: Option<(usize, bool)>, - ) -> Option { - 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); - let read_only = self.read_only; - let session = self.context.active.get()?; - let browse = BrowseTab::new(BrowseTabInit { - tracker: self.change_trackers.register(tab_id), - schema: schema.clone(), - table: table.clone(), - connection_id, - read_only, - page_size, - initial_offset: offset, - initial_sort: sort, - column_widths: self.storage.column_widths().clone(), - filter_settings: self.storage.filter_settings().clone(), - session, - tasks: self.context.tasks.clone(), - }); - 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); - page.set_title(&label); - if let Some(tip) = browse_tab_tooltip(schema.as_deref(), &table, &label) { - page.set_tooltip(&tip); - } - write_workspace_tab_id(&page, tab_id); - - let slot = super::TableTabSlot { - id: tab_id, - page: page.clone(), - schema, - table, - browse, - }; - self.workspace_tabs - .borrow_mut() - .insert(tab_id, WorkspaceTab::Table(slot)); - tab_view.set_selected_page(&page); - self.workspace_outer_stack.set_visible_child_name("tabs"); - self.refresh_window_title(); - self.persist_workspace_state(); - Some(page) - } - - /// Open a Structure (DDL editor) tab for an EXISTING - /// `(schema, table)`. Mirrors `append_new_structure_tab` shape - /// but uses `StructureMode::Edit` and fetches the table's columns - /// / indexes / FKs immediately. Called from - /// `on_edit_structure_tab` (sidebar right-click → Edit Structure). - pub(super) fn append_existing_structure_tab( - &mut self, - schema: Option, - table: String, - ) -> Option { - self.ensure_workspace_root(); - let tab_view = self.workspace_tab_view.clone()?; - let tab_id = Uuid::new_v4(); - let session = self.context.active.get()?; - let mode = StructureMode::Edit; - let structure = StructureTab::new(StructureTabInit { - tracker: self.structure_trackers.register(tab_id), - schema: schema.clone(), - table: table.clone(), - mode, - session, - tasks: self.context.tasks.clone(), - }); - 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); - // Told apart from the table's own tab, with the middle dot GNOME - // apps use in compound titles. - page.set_title(&format!("{title} · {}", crate::i18n::gettext("Structure"))); - write_workspace_tab_id(&page, tab_id); - - let slot = super::StructureTabSlot { - id: tab_id, - structure, - page: page.clone(), - schema, - table, - mode, - }; - self.workspace_tabs - .borrow_mut() - .insert(tab_id, WorkspaceTab::Structure(slot)); - tab_view.set_selected_page(&page); - self.workspace_outer_stack.set_visible_child_name("tabs"); - self.refresh_window_title(); - self.persist_workspace_state(); + /// Build the tab `spec` names and select it. + pub(super) fn open_tab(&mut self, spec: TabSpec) -> Option { + let page = self.factory.create(spec)?; + self.tab_view.set_selected_page(&page); Some(page) } - /// Public entry: append an Editor tab with optional initial query. - 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 - /// tab keeps the file it was already using. - fn append_editor_tab_with_draft( - &mut self, - initial_query: Option, - draft: DraftId, - write: DraftWrite, - ) -> Option { - 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. - let opened_empty = initial_query.is_none(); - let editor = SqlEditor::new(SqlEditorInit { + pub(super) fn open_editor_tab(&mut self, initial_query: Option) { + self.open_tab(TabSpec::Editor { initial_query, - settings: self.settings.clone(), - 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(), + draft: None, }); - 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() { - default_editor_tab_label(self.editor_tab_count() + 1) - } else { - derive_tab_label(&preview.text) - }; - page.set_title(&label); - if !opened_empty && let Some(tip) = editor_tab_tooltip(&preview, &label) { - page.set_tooltip(&tip); - } - write_workspace_tab_id(&page, tab_id); - - if write == DraftWrite::Now { - // Text a new tab opens with is written even if the user - // never types in it. - self.drafts.schedule(&self.draft_scope(), draft, &editor.buffer()); - } - let slot = EditorTabSlot { - editor, - page: page.clone(), - draft, - }; - self.workspace_tabs - .borrow_mut() - .insert(tab_id, WorkspaceTab::Editor(slot)); - tab_view.set_selected_page(&page); - self.workspace_outer_stack.set_visible_child_name("tabs"); - self.refresh_window_title(); - self.persist_workspace_state(); - Some(page) } /// The directory this session's editor drafts live in. @@ -456,29 +211,21 @@ impl WorkspaceModel { self.change_trackers.unregister(slot.id); } } - let page = match &removed { - WorkspaceTab::Editor(s) => s.page.clone(), - WorkspaceTab::Structure(s) => s.page.clone(), - WorkspaceTab::Table(s) => s.page.clone(), - }; + let page = removed.page().clone(); + self.index.remove(&page); tab_view.close_page_finish(&page, true); drop(removed); self.persist_workspace_state(); - if self.workspace_tabs.borrow().is_empty() { - self.workspace_outer_stack.set_visible_child_name("empty"); - } self.refresh_window_title(); - self.refresh_inhibitor(); + self.refresh_unsaved(); } /// 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) { + match self.tab_view.selected_page() { Some(page) => { - if let Some(tab_view) = self.workspace_tab_view.as_ref() { - tab_view.close_page(&page); - } + self.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 @@ -497,16 +244,14 @@ impl WorkspaceModel { /// 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) { - let Some(tab_view) = self.workspace_tab_view.clone() else { - return; - }; + let tab_view = self.tab_view.clone(); let pages = tab_view.pages(); let mut targets: Vec = Vec::with_capacity(pages.n_items() as usize); for i in 0..pages.n_items() { let Some(page) = pages.item(i).and_downcast::() else { continue; }; - if read_workspace_tab_id(&page) == Some(keep_id) { + if self.index.id_of(&page) == Some(keep_id) { continue; } targets.push(page); @@ -520,16 +265,14 @@ impl WorkspaceModel { /// 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) { - let Some(tab_view) = self.workspace_tab_view.clone() else { - return; - }; + let tab_view = self.tab_view.clone(); let pages = tab_view.pages(); let mut anchor_idx: Option = None; for i in 0..pages.n_items() { let Some(page) = pages.item(i).and_downcast::() else { continue; }; - if read_workspace_tab_id(&page) == Some(anchor_id) { + if self.index.id_of(&page) == Some(anchor_id) { anchor_idx = Some(i); break; } @@ -558,7 +301,7 @@ impl WorkspaceModel { // selected tab when it matches, then any other match. let existing = { let tabs = self.workspace_tabs.borrow(); - let selected_page = self.workspace_tab_view.as_ref().and_then(|tv| tv.selected_page()); + let selected_page = self.tab_view.selected_page(); let matches_slot = |t: &WorkspaceTab| match t { WorkspaceTab::Table(s) if s.schema.as_deref() == schema.as_deref() && s.table == name => { Some(s.page.clone()) @@ -566,15 +309,13 @@ impl WorkspaceModel { _ => None, }; let selected_match = selected_page.and_then(|sp| { - let id = read_workspace_tab_id(&sp)?; + let id = self.index.id_of(&sp)?; matches_slot(tabs.get(&id)?) }); selected_match.or_else(|| tabs.values().find_map(matches_slot)) }; - if let Some(page) = existing - && let Some(tab_view) = self.workspace_tab_view.as_ref() - { - tab_view.set_selected_page(&page); + if let Some(page) = existing { + self.tab_view.set_selected_page(&page); return; } } @@ -582,23 +323,19 @@ impl WorkspaceModel { // 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); + self.open_tab(TabSpec::Table { + table: TableRef::new(schema, name), + page: TablePage::first(page_size), + }); } - /// Ctrl+F / Filter button: toggle the inline filter strip on the - /// active Browse tab. - pub(super) fn on_show_filter_dialog(&self) { - let Some(id) = self.selected_browse_tab_id() else { - self.show_toast(&crate::i18n::gettext("Open a table to filter rows.")); - return; - }; - self.dispatch_to_tab(id, BrowseTabInput::ToggleFilterStrip); + pub(super) fn on_refresh_active_tab(&self) { + if let Some(id) = self.selected_browse_tab_id() { + self.on_refresh_tab(id); + } } - pub(super) fn on_refresh_active_tab(&self) { - let Some(id) = self.selected_browse_tab_id() else { - return; - }; + pub(super) fn on_refresh_tab(&self, id: Uuid) { let dirty = self .change_trackers .get(id) @@ -653,37 +390,8 @@ impl WorkspaceModel { self.sync_sidebar_selection(); } - /// With no Table tab active, no row stays selected: one would say - /// the editor shows that table. - fn sync_sidebar_selection(&self) { - let listbox = self.sidebar_factory.widget(); - let Some((schema, table)) = self.selected_browse_slot_table() else { - listbox.unselect_all(); - return; - }; - let schemas = self.sidebar_schemas.borrow(); - let mut idx = 0_i32; - while let Some(row) = listbox.row_at_index(idx) { - // The factory builds one row per TableInfo, in the same order - // as `sidebar_schemas`, so we can pair each row with its - // schema-Option by index. SidebarRow stashes its table name - // in widget-name (no CSS conflict, no qdata machinery). - let row_table = row.widget_name(); - let row_schema = schemas.get(idx as usize).cloned().unwrap_or(None); - if row_table.as_str() == table && row_schema.as_deref() == schema.as_deref() { - // select_row doesn't trigger row-activated (user-only - // signal), so this won't recurse into SelectTable. - listbox.select_row(Some(&row)); - return; - } - idx += 1; - } - } - pub(super) fn selected_workspace_tab_id(&self) -> Option { - let tab_view = self.workspace_tab_view.as_ref()?; - let page = tab_view.selected_page()?; - read_workspace_tab_id(&page) + self.index.id_of(&self.tab_view.selected_page()?) } pub(super) fn selected_browse_tab_id(&self) -> Option { @@ -700,42 +408,6 @@ impl WorkspaceModel { Some((schema.map(str::to_owned), table.to_string())) } - pub(super) fn sidebar_schemas_distinct(&self) -> usize { - let schemas = self.sidebar_schemas.borrow(); - let distinct: std::collections::BTreeSet<&str> = schemas.iter().filter_map(|s| s.as_deref()).collect(); - distinct.len() - } - - /// The label is built again rather than stored, so the schema - /// prefix follows the other tabs. Only a background tab asks for - /// attention; the selected one has its "•" in view. - pub(super) fn refresh_browse_tab_dirty(&self, tab_id: uuid::Uuid, dirty: bool) { - let schemas_count = self.sidebar_schemas_distinct(); - let tabs = self.workspace_tabs.borrow(); - let Some(slot) = tabs.get(&tab_id) else { - return; - }; - // Combined dirty state: data-side OR structure-side. The - // Structure-side input also calls this helper via the - // dirty-changed bus, so the Boolean ORs naturally compose. - let WorkspaceTab::Table(s) = slot else { - return; - }; - let (page, schema, table) = (&s.page, s.schema.as_deref(), s.table.as_str()); - let combined_dirty = dirty || self.dirty_tabs().contains(&s.id); - let base = qualified_browse_tab_label(schemas_count, schema, table); - let title = if combined_dirty { format!("• {base}") } else { base }; - page.set_title(&title); - let is_selected = self - .workspace_tab_view - .as_ref() - .and_then(|tv| tv.selected_page()) - .map(|p| &p == page) - .unwrap_or(false); - page.set_needs_attention(combined_dirty && !is_selected); - self.refresh_window_title(); - } - /// 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. @@ -753,14 +425,7 @@ impl WorkspaceModel { } } } - if let Some(root) = self.workspace_root.take() - && self.workspace_root_added.replace(false) - && self.workspace_outer_stack.child_by_name("tabs").is_some() - { - self.workspace_outer_stack.remove(&root); - } - self.window_tabs.set_view(None); - self.workspace_tab_view = None; + self.index.clear(); self.workspace_tabs.borrow_mut().clear(); } @@ -783,23 +448,16 @@ impl WorkspaceModel { /// a moment later. Only the first `PREVIEW_CHARS` are read here, so a /// long script costs nothing to type in. pub(super) fn on_editor_tab_content_changed(&self, id: Uuid) { - let tabs = self.workspace_tabs.borrow(); - let Some(WorkspaceTab::Editor(slot)) = tabs.get(&id) else { - return; - }; - let buffer = slot.editor.buffer(); - let preview = EditorPreview::read(&buffer); - let label = if preview.is_blank() { - crate::i18n::gettext("Empty query") - } else { - derive_tab_label(&preview.text) - }; - slot.page.set_title(&label); - // libadwaita treats an empty tooltip as none, which clears the - // last one. - slot.page - .set_tooltip(&editor_tab_tooltip(&preview, &label).unwrap_or_default()); - self.drafts.schedule(&self.draft_scope(), slot.draft, &buffer); + { + let mut tabs = self.workspace_tabs.borrow_mut(); + let Some(WorkspaceTab::Editor(slot)) = tabs.get_mut(&id) else { + return; + }; + let buffer = slot.editor.buffer(); + slot.label = EditorPreview::read(&buffer).label(slot.ordinal); + self.drafts.schedule(&self.draft_scope(), slot.draft, &buffer); + } + self.retitle_tab(id); } pub(super) fn on_editor_tab_run_state_changed(&self, id: Uuid, running: bool) { @@ -815,7 +473,7 @@ impl WorkspaceModel { slot.editor.replace_query(&text); return; } - self.append_editor_tab(Some(text)); + self.open_editor_tab(Some(text)); } /// The SQL in the editor tab the user is looking at, or `None` @@ -827,68 +485,18 @@ impl WorkspaceModel { _ => None, } } - - fn editor_tab_count(&self) -> usize { - self.workspace_tabs - .borrow() - .values() - .filter(|t| matches!(t, WorkspaceTab::Editor(_))) - .count() - } -} - -/// What a connected window's tabs live in: the overview, and the view -/// inside it that holds the pages. -struct WorkspaceChrome { - overview: adw::TabOverview, - view: adw::TabView, } -/// 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: &WorkspaceSender, - create_tab: impl Fn(&adw::TabView) -> adw::TabPage + 'static, -) -> WorkspaceChrome { - let tab_view = new_workspace_tab_view(); - let tab_bar = adw::TabBar::builder() - .view(&tab_view) - .autohide(false) - .expand_tabs(true) - .build(); - - let overview_button = adw::TabButton::builder() - .view(&tab_view) - .action_name("overview.open") - .tooltip_text(crate::i18n::gettext("View open tabs")) - .valign(gtk::Align::Center) - .build(); - // With one page the overview has nothing to choose between. A - // binding, unlike a notify handler, holds the button weakly, and the - // button holds the view. - tab_view - .bind_property("n-pages", &overview_button, "visible") - .transform_to(|_, pages: i32| Some(pages > 1)) - .sync_create() - .build(); - tab_bar.set_start_action_widget(Some(&overview_button)); - - // Browse tabs need a table to show, so they open from the sidebar, - // and this button only makes editor tabs. - let new_query_button = gtk::Button::builder() - .icon_name(crate::ui::icons::TAB_NEW) - .tooltip_text(crate::i18n::gettext("New query (Ctrl+E)")) - .valign(gtk::Align::Center) - .build(); - new_query_button.add_css_class("flat"); - let new_tab_input = input.clone(); - new_query_button.connect_clicked(move |_| new_tab_input.deliver(WorkspaceInput::NewEditorTab)); - tab_bar.set_end_action_widget(Some(&new_query_button)); +/// The tab view's own signals, as the workspace's inputs. The view and +/// its tab bar are in the template. +pub(super) fn connect_tab_view(tab_view: &adw::TabView, index: &Rc, input: &WorkspaceSender) { + keep_child_navigation_keys(tab_view); // A tab with unsaved work asks first, so the workspace finishes the close. let close_input = input.clone(); + let close_index = index.clone(); tab_view.connect_close_page(move |_view, page| { - if let Some(id) = read_workspace_tab_id(page) { + if let Some(id) = close_index.id_of(page) { close_input.deliver(WorkspaceInput::WorkspaceTabClosed(id)); } glib::Propagation::Stop @@ -902,30 +510,19 @@ fn build_workspace_chrome( *menu_target_setup.borrow_mut() = page.cloned(); }); let action_group = gio::SimpleActionGroup::new(); - let close_others_action = gio::SimpleAction::new("close-others", None); - let menu_target_others = menu_target.clone(); - let close_others_input = input.clone(); - close_others_action.connect_activate(move |_, _| { - let target = menu_target_others.borrow().clone(); - if let Some(page) = target - && let Some(id) = read_workspace_tab_id(&page) - { - close_others_input.deliver(WorkspaceInput::CloseOtherWorkspaceTabs(id)); - } - }); - action_group.add_action(&close_others_action); - let close_right_action = gio::SimpleAction::new("close-right", None); - let menu_target_right = menu_target; - let close_right_input = input.clone(); - close_right_action.connect_activate(move |_, _| { - let target = menu_target_right.borrow().clone(); - if let Some(page) = target - && let Some(id) = read_workspace_tab_id(&page) - { - close_right_input.deliver(WorkspaceInput::CloseWorkspaceTabsToRight(id)); - } - }); - action_group.add_action(&close_right_action); + let menu_action = |name: &str, message: fn(Uuid) -> WorkspaceInput| { + let action = gio::SimpleAction::new(name, None); + let (target, index, input) = (menu_target.clone(), index.clone(), input.clone()); + action.connect_activate(move |_, _| { + let page = target.borrow().clone(); + if let Some(id) = page.and_then(|page| index.id_of(&page)) { + input.deliver(message(id)); + } + }); + action + }; + action_group.add_action(&menu_action("close-others", WorkspaceInput::CloseOtherWorkspaceTabs)); + action_group.add_action(&menu_action("close-right", WorkspaceInput::CloseWorkspaceTabsToRight)); tab_view.insert_action_group("tab", Some(&action_group)); let bulk_menu = gio::Menu::new(); @@ -944,82 +541,6 @@ fn build_workspace_chrome( 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.deliver(WorkspaceInput::WorkspaceTabsChanged)); - - let inner = gtk::Box::builder().orientation(gtk::Orientation::Vertical).build(); - inner.append(&tab_bar); - inner.append(&tab_view); - - let overview = adw::TabOverview::builder() - .view(&tab_view) - .enable_new_tab(true) - .enable_search(true) - .child(&inner) - .build(); - // `create-tab` returns its page (transfer none): a generic closure adds a - // reference that outlives the page's close. The overview holds the view - // already, so this capture adds no cycle. - let create_view = tab_view.clone(); - overview.connect_create_tab(move |_| create_tab(&create_view)); - - WorkspaceChrome { - overview, - view: tab_view, - } -} - -// Numbered like the tabs `append_editor_tab` makes, so the two ways of -// opening one look the same. -fn overview_editor_tab( - workspace_tabs: Rc>>, - editor_init: impl Fn() -> SqlEditorInit + 'static, - input: WorkspaceSender, - toasts: glib::WeakRef, -) -> impl Fn(&adw::TabView) -> adw::TabPage + 'static { - move |tab_view| { - let tab_id = Uuid::new_v4(); - let editor = SqlEditor::new(editor_init()); - connect_editor(&editor, tab_id, &input, &toasts); - let page = tab_view.append(&editor); - let editor_count = workspace_tabs - .borrow() - .values() - .filter(|t| matches!(t, WorkspaceTab::Editor(_))) - .count(); - page.set_title(&default_editor_tab_label(editor_count + 1)); - write_workspace_tab_id(&page, tab_id); - let slot = EditorTabSlot { - editor, - page: page.clone(), - draft: DraftId::new(), - }; - workspace_tabs.borrow_mut().insert(tab_id, WorkspaceTab::Editor(slot)); - input.deliver(WorkspaceInput::WorkspaceTabsChanged); - page - } -} - -pub(super) fn qualified_browse_tab_label(schemas_count: usize, schema: Option<&str>, table: &str) -> String { - if schemas_count >= 2 - && let Some(s) = schema - { - format!("{s}.{table}") - } else { - table.to_string() - } -} - -fn default_editor_tab_label(n: usize) -> String { - crate::i18n::gettext_f("Query {n}", &[("n", &n.to_string())]) -} - -/// Returns a tooltip for a Browse tab, but only when it would add info -/// beyond the visible label. When the label is already -/// `schema.table`, or there is no schema, the tooltip would just -/// duplicate the tab title and we skip it. -fn browse_tab_tooltip(schema: Option<&str>, table: &str, label: &str) -> Option { - let s = schema?; - let qualified = format!("{s}.{table}"); - if qualified == label { None } else { Some(qualified) } } fn describe_closed_tab(slot: &WorkspaceTab) -> Option { @@ -1067,7 +588,7 @@ impl WorkspaceModel { match descriptor { ClosedTabDescriptor::Editor { query } => { let initial = if query.is_empty() { None } else { Some(query) }; - self.append_editor_tab(initial); + self.open_editor_tab(initial); } ClosedTabDescriptor::Table { schema, @@ -1076,63 +597,29 @@ impl WorkspaceModel { page_size, sort, } => { - self.append_table_tab(schema, table, offset, page_size, sort); + self.open_tab(TabSpec::Table { + table: TableRef::new(schema, table), + page: TablePage { + offset, + page_size, + sort, + }, + }); } ClosedTabDescriptor::Structure { schema, table } => { - self.append_existing_structure_tab(schema, table); + self.open_tab(TabSpec::Structure { + target: StructureTarget::Existing(TableRef::new(schema, table)), + }); } } } } -const PREVIEW_CHARS: i32 = 200; - -/// The start of an editor's script, read without copying the rest. -struct EditorPreview { - text: String, - truncated: bool, -} - -impl EditorPreview { - fn read(buffer: >k::TextBuffer) -> Self { - let end = buffer.iter_at_offset(PREVIEW_CHARS); - Self { - text: buffer.text(&buffer.start_iter(), &end, false).to_string(), - truncated: !end.is_end(), - } - } - - fn is_blank(&self) -> bool { - !self.truncated && self.text.trim().is_empty() - } -} - -/// The start of the script, and none when the label already shows it. -fn editor_tab_tooltip(preview: &EditorPreview, label: &str) -> Option { - let text = preview.text.trim(); - if text.is_empty() { - return None; - } - let tooltip = match preview.truncated { - true => format!("{}…", preview.text.trim_start()), - false => text.to_owned(), - }; - (tooltip != label).then_some(tooltip) -} - /// The whole script, for the few places that need all of it at once. fn whole_text(buffer: >k::TextBuffer) -> String { buffer.text(&buffer.start_iter(), &buffer.end_iter(), false).to_string() } -/// Whether a new editor tab's draft is written at once or only after -/// the user changes it. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum DraftWrite { - Now, - OnChange, -} - #[derive(Debug)] pub(crate) struct LoadedWorkspace { key: SessionKey, @@ -1147,109 +634,17 @@ fn saved_drafts(record: &WorkspaceRecord) -> impl Iterator + '_ }) } -/// 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.deliver(WorkspaceInput::StructureTabDirtyChanged(tab_id, is_dirty)) - }); - let save = input.clone(); - structure.connect_save_requested(move |_| save.deliver(WorkspaceInput::SaveTab(tab_id))); - let stop = input.clone(); - 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.deliver(WorkspaceInput::DropTablePrompt { schema, table }); - }); - let toast = input.clone(); - structure.connect_toast(move |_, text| toast.deliver(WorkspaceInput::ShowToast(text))); - let alert = input.clone(); - structure.connect_alert(move |_, title, body| alert.deliver(WorkspaceInput::ShowAlert { title, body })); -} - -/// 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.deliver(WorkspaceInput::WorkspaceTabsChanged)); - let clipboard = input.clone(); - 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.deliver(WorkspaceInput::ExportResults { result, name, target }); - }); - let alert = input.clone(); - browse.connect_alert(move |_, title, body| alert.deliver(WorkspaceInput::ShowAlert { title, body })); - let toast = input.clone(); - browse.connect_toast(move |_, text| toast.deliver(WorkspaceInput::ShowToast(text))); - let dirty = input.clone(); - 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.deliver(WorkspaceInput::SaveTab(tab_id))); - let stop = input.clone(); - browse.connect_stop_save_requested(move |_| stop.deliver(WorkspaceInput::StopSaving(tab_id))); -} - -/// 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: &WorkspaceSender, - toasts: &glib::WeakRef, -) { - let run_state = input.clone(); - editor.connect_run_state_changed(move |_, running| { - run_state.deliver(WorkspaceInput::EditorTabRunStateChanged(tab_id, running)); - }); - let content = input.clone(); - editor.connect_content_changed(move |_| content.deliver(WorkspaceInput::EditorTabContentChanged(tab_id))); - let transaction = input.clone(); - editor.connect_transaction_changed(move |_, _| transaction.deliver(WorkspaceInput::EditorTransactionChanged)); - let clipboard = input.clone(); - editor.connect_copy_to_clipboard(move |_, text| clipboard.deliver(WorkspaceInput::CopyToClipboard(text))); - let toast = input.clone(); - editor.connect_toast(move |_, text| toast.deliver(WorkspaceInput::ShowToast(text))); - let export = input.clone(); - editor.connect_export_results(move |_, result, name| { - export.deliver(WorkspaceInput::ExportResults { - result, - name, - target: None, - }); - }); - // The overlay is an ancestor of the editor, so a strong capture - // would keep both alive once the window lets go of them. - let toasts = toasts.clone(); - editor.connect_undoable_toast(move |editor, title| { - let Some(toasts) = toasts.upgrade() else { - return; - }; - let toast = adw::Toast::new(&title); - toast.set_button_label(Some(&crate::i18n::gettext("Undo"))); - let editor = editor.downgrade(); - toast.connect_button_clicked(move |_| { - if let Some(editor) = editor.upgrade() { - editor.undo(); - } - }); - toasts.add_toast(toast); - }); -} - #[cfg(test)] mod tests { - use super::*; - use crate::test_support::{drain_main_context, find_by_action_name}; + use gtk4::subclass::prelude::*; - fn blank_page(view: &adw::TabView) -> adw::TabPage { - view.append(>k::Label::new(None)) - } + use super::*; + use crate::test_support::{descendants, drain_main_context, wait_until}; + use crate::ui::SessionWindowInput; + use crate::ui::session_window::tests::{TestWindow, WAIT, editors}; fn overview_new_tab_button(overview: &adw::TabOverview) -> gtk::Button { - crate::test_support::descendants(overview) + descendants(overview) .into_iter() .find_map(|widget| { let button = widget.downcast::().ok()?; @@ -1259,170 +654,55 @@ mod tests { .expect("the overview's new-tab button") } - fn editor_chrome( - runtime: &crate::runtime::AppRuntime, - settings: &crate::test_support::MemorySettings, - tabs: &Rc>>, - toasts: &adw::ToastOverlay, - input: &WorkspaceSender, - ) -> WorkspaceChrome { - adw::init().expect("libadwaita initialises under the test backend"); - crate::register_test_resources(); - let settings = settings.get().clone(); - let tasks = runtime.tasks(); - let editor_init = move || SqlEditorInit { - initial_query: None, - settings: settings.clone(), - history: None, - tasks: tasks.clone(), - session: crate::services::active_session::ActiveSession::new(), - hold: Rc::new(tablepro_session::runtime::CountingHold::new()), - close_timeout: std::time::Duration::from_secs(5), - }; - let create_tab = overview_editor_tab(tabs.clone(), editor_init, input.clone(), toasts.downgrade()); - build_workspace_chrome(input, create_tab) - } - #[gtk4::test] fn a_closed_tab_from_the_overview_is_freed() { - let runtime = crate::test_support::test_runtime(); - let settings = crate::test_support::MemorySettings::new(); - let tabs = Rc::new(RefCell::new(HashMap::new())); - let toasts = adw::ToastOverlay::new(); - let (input, _receiver) = crate::runtime::input_queue::(); - let chrome = editor_chrome(&runtime, &settings, &tabs, &toasts, &input); - toasts.set_child(Some(&chrome.overview)); - - overview_new_tab_button(&chrome.overview).emit_clicked(); - let page = chrome.view.selected_page().expect("the new-tab button made a page"); - let id = read_workspace_tab_id(&page).expect("the page names its tab"); - assert!(tabs.borrow().contains_key(&id), "the window does not know the new tab"); + let test = TestWindow::connected("OverviewTabFreed"); + let workspace = test.workspace(); + let (view, overview) = (workspace.imp().tab_view.get(), workspace.imp().tab_overview.get()); + + overview_new_tab_button(&overview).emit_clicked(); + let page = view.selected_page().expect("the new-tab button made a page"); + let known = workspace + .read(|model| { + model + .index + .id_of(&page) + .is_some_and(|id| model.workspace_tabs.borrow().contains_key(&id)) + }) + .unwrap_or_default(); + assert!(known, "the workspace does not know the new tab"); let editor = page.child().downgrade(); let freed_page = page.downgrade(); - // What the window does once the tab may close. - chrome.view.close_page(&page); - let removed = tabs.borrow_mut().remove(&id); - if let Some(WorkspaceTab::Editor(slot)) = &removed { - slot.editor.shutdown(); - } - chrome.view.close_page_finish(&page, true); - drop((removed, page)); - drain_main_context(); + view.close_page(&page); + drop(page); + wait_until(WAIT, || freed_page.upgrade().is_none()).expect("the closed page to be freed"); - assert!(freed_page.upgrade().is_none(), "the closed page is still alive"); assert!(editor.upgrade().is_none(), "the closed page's editor is still alive"); } #[gtk4::test] fn tab_view_freed_after_teardown() { - let runtime = crate::test_support::test_runtime(); - let settings = crate::test_support::MemorySettings::new(); - let tabs = Rc::new(RefCell::new(HashMap::new())); - let toasts = adw::ToastOverlay::new(); - 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")); - toasts.set_child(Some(&stack)); - overview_new_tab_button(&chrome.overview).emit_clicked(); - overview_new_tab_button(&chrome.overview).emit_clicked(); - assert_eq!(chrome.view.n_pages(), 2); - let selected = chrome.view.selected_page().expect("a selected page"); - let editor = selected.child().downgrade(); - let page = selected.downgrade(); - drop(selected); - let view = chrome.view.downgrade(); - let overview = chrome.overview.downgrade(); - - // What the window does on disconnect. - for tab in tabs.borrow().values() { - if let WorkspaceTab::Editor(slot) = tab { - slot.editor.shutdown(); - } - } - stack.remove(&chrome.overview); - drop(chrome); - tabs.borrow_mut().clear(); + let test = TestWindow::connected("TabViewFreed"); + for _ in 0..2 { + WidgetExt::activate_action(&test.window, "win.new-editor-tab", None).expect("win.new-editor-tab"); + } + wait_until(WAIT, || editors(&test.window).len() == 2).expect("two editor tabs"); + let workspace = test.workspace(); + let view = workspace.imp().tab_view.downgrade(); + let overview = workspace.imp().tab_overview.downgrade(); + let pages: Vec<_> = editors(&test.window).iter().map(|editor| editor.downgrade()).collect(); + drop(workspace); + + test.window.send(SessionWindowInput::Disconnect); + test.wait_for_page("picker"); drain_main_context(); assert!(view.upgrade().is_none(), "the tab view outlived its teardown"); assert!(overview.upgrade().is_none(), "the overview outlived its teardown"); - assert!(page.upgrade().is_none(), "a page outlived its teardown"); - assert!(editor.upgrade().is_none(), "an editor outlived its teardown"); - } - - #[gtk4::test] - fn overview_button_shows_from_the_second_tab() { - adw::init().expect("libadwaita initialises under the test backend"); - 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"); - - let first = blank_page(&chrome.view); - assert!(!button.is_visible(), "shown with one tab"); - blank_page(&chrome.view); - assert!(button.is_visible(), "hidden with two tabs"); - - chrome.view.close_page(&first); - chrome.view.close_page_finish(&first, true); - assert!(!button.is_visible(), "still shown after closing back to one tab"); - } - - #[gtk4::test] - fn an_editor_tab_is_named_from_the_start_of_its_script_only() { - let buffer = gtk::TextBuffer::new(None); - buffer.set_text(&format!("SELECT 1;\n{}", "-- é\n".repeat(10_000))); - - let preview = EditorPreview::read(&buffer); - - let limit = usize::try_from(PREVIEW_CHARS).expect("a positive limit"); - assert_eq!(preview.text.chars().count(), limit); - assert!(preview.truncated); - let tooltip = editor_tab_tooltip(&preview, "SELECT 1;").expect("a tooltip"); - assert!(tooltip.starts_with("SELECT 1;"), "{tooltip}"); - assert!(tooltip.ends_with('…'), "{tooltip}"); - } - - #[gtk4::test] - fn a_script_its_label_shows_whole_has_no_tooltip() { - let buffer = gtk::TextBuffer::new(None); - buffer.set_text("SELECT 1;"); - - let preview = EditorPreview::read(&buffer); - - assert!(!preview.truncated); - assert_eq!(editor_tab_tooltip(&preview, &derive_tab_label(&preview.text)), None); - } - - #[gtk4::test] - fn an_editor_tab_leaves_the_toast_overlay_free() { - crate::register_test_resources(); - let runtime = crate::test_support::test_runtime(); - let settings = crate::test_support::MemorySettings::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(tablepro_session::runtime::CountingHold::new()), - close_timeout: std::time::Duration::from_secs(5), - }); - let toasts = adw::ToastOverlay::new(); - toasts.set_child(Some(&editor)); - let (input, _receiver) = crate::runtime::input_queue::(); - connect_editor(&editor, Uuid::new_v4(), &input, &toasts.downgrade()); - let overlay = toasts.downgrade(); - - drop(editor); - drop(toasts); - drain_main_context(); - assert!( - overlay.upgrade().is_none(), - "an editor tab kept the window's toast overlay alive" + pages.iter().all(|editor| editor.upgrade().is_none()), + "an editor outlived its teardown" ); } } diff --git a/linux/crates/app/src/workspace/close_blocker.rs b/linux/crates/app/src/workspace/close_blocker.rs new file mode 100644 index 0000000000..5b4c40a579 --- /dev/null +++ b/linux/crates/app/src/workspace/close_blocker.rs @@ -0,0 +1,21 @@ +use futures::future::LocalBoxFuture; + +/// Work a window's close would cut short that is not a tab's, such as a +/// running export. A close asks about it first, and either keeps the +/// window or stops the work and goes on. +pub trait CloseBlocker { + /// What to ask, while the work runs. + fn blocking(&self) -> Option; + + /// Stop the work. Resolves once it has stopped. + fn stop(&self) -> LocalBoxFuture<'static, ()>; +} + +/// The alert a blocker asks with. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CloseBlockPrompt { + pub heading: String, + pub body: String, + pub keep_label: String, + pub stop_label: String, +} diff --git a/linux/crates/app/src/workspace/close_blocker_dialog.rs b/linux/crates/app/src/workspace/close_blocker_dialog.rs new file mode 100644 index 0000000000..9d3cee551f --- /dev/null +++ b/linux/crates/app/src/workspace/close_blocker_dialog.rs @@ -0,0 +1,21 @@ +use gtk4 as gtk; +use libadwaita as adw; +use libadwaita::prelude::*; + +use super::CloseBlockPrompt; +use super::dialog_response::respond; + +const KEEP: &str = "keep"; +const STOP: &str = "stop"; + +/// Whether the user stops the work `prompt` asks about. Dismissing the +/// alert keeps it running. +pub(crate) async fn ask_stop(parent: >k::Widget, prompt: &CloseBlockPrompt) -> bool { + let dialog = adw::AlertDialog::new(Some(&prompt.heading), Some(&prompt.body)); + dialog.add_response(KEEP, &prompt.keep_label); + dialog.add_response(STOP, &prompt.stop_label); + dialog.set_response_appearance(STOP, adw::ResponseAppearance::Destructive); + dialog.set_default_response(Some(KEEP)); + dialog.set_close_response(KEEP); + respond(&dialog, parent).await == STOP +} diff --git a/linux/crates/app/src/workspace/close_guard.rs b/linux/crates/app/src/workspace/close_guard.rs index 96e859fc26..47d5328351 100644 --- a/linux/crates/app/src/workspace/close_guard.rs +++ b/linux/crates/app/src/workspace/close_guard.rs @@ -5,6 +5,7 @@ use libadwaita as adw; use libadwaita::prelude::*; use uuid::Uuid; +use super::close_blocker_dialog::ask_stop; use super::open_transaction_dialog::confirm_open_transactions; use super::save_changes_dialog::{SaveChoice, ask_save_changes}; use super::unknown_outcome_dialog::{UnknownChoice, resolve_unknown_outcome}; @@ -30,6 +31,12 @@ impl CloseGuard { loop { match handle.close_step(scope, acknowledged) { CloseStep::Approve => return CloseDecision::Approved, + CloseStep::StopBlocker { blocker, prompt } => { + if !ask_stop(parent, &prompt).await { + return CloseDecision::Cancelled; + } + handle.stop_blocker(blocker).await; + } CloseStep::WaitForSaves => { if !wait_for_saves(parent, handle, scope).await { return CloseDecision::Cancelled; diff --git a/linux/crates/app/src/workspace/close_step.rs b/linux/crates/app/src/workspace/close_step.rs index 948bc2ba60..dcc8fec06b 100644 --- a/linux/crates/app/src/workspace/close_step.rs +++ b/linux/crates/app/src/workspace/close_step.rs @@ -1,10 +1,16 @@ -use super::{CloseScope, DirtyKind, DirtyTab}; +use super::{CloseBlockPrompt, CloseScope, DirtyKind, DirtyTab}; -/// What a close has to settle next, in the order it settles them: a +/// What a close has to settle next, in the order it settles them: work +/// that is not a tab's is asked about before anything is discarded, a /// save still running decides what the others are, and an outcome /// nobody knows comes before anything is saved again. #[derive(Debug, Clone, PartialEq, Eq)] pub enum CloseStep { + /// The workspace's `blocker`th close blocker has work running. + StopBlocker { + blocker: usize, + prompt: CloseBlockPrompt, + }, WaitForSaves, ResolveUnknownOutcome(Vec), ConfirmOpenTransactions(Vec), diff --git a/linux/crates/app/src/workspace/inhibit_reason.rs b/linux/crates/app/src/workspace/inhibit_reason.rs index 7dfed00566..108cf27cab 100644 --- a/linux/crates/app/src/workspace/inhibit_reason.rs +++ b/linux/crates/app/src/workspace/inhibit_reason.rs @@ -6,6 +6,9 @@ pub enum InhibitReason { UnsavedChanges, /// A write is running. Suspending would cut it off mid-commit. SavingChanges, + /// An export is writing a file, which a logout or suspend would + /// leave unfinished. + Exporting, } impl InhibitReason { @@ -22,7 +25,9 @@ impl InhibitReason { pub fn flags(self) -> gtk::ApplicationInhibitFlags { match self { Self::UnsavedChanges => gtk::ApplicationInhibitFlags::LOGOUT, - Self::SavingChanges => gtk::ApplicationInhibitFlags::LOGOUT | gtk::ApplicationInhibitFlags::SUSPEND, + Self::SavingChanges | Self::Exporting => { + gtk::ApplicationInhibitFlags::LOGOUT | gtk::ApplicationInhibitFlags::SUSPEND + } } } @@ -31,6 +36,7 @@ impl InhibitReason { match self { Self::UnsavedChanges => crate::i18n::gettext("There are unsaved changes"), Self::SavingChanges => crate::i18n::gettext("Saving changes"), + Self::Exporting => crate::i18n::gettext("Exporting data"), } } } diff --git a/linux/crates/app/src/workspace/mod.rs b/linux/crates/app/src/workspace/mod.rs index 4afc4ac9f8..585865f4fe 100644 --- a/linux/crates/app/src/workspace/mod.rs +++ b/linux/crates/app/src/workspace/mod.rs @@ -3,6 +3,8 @@ //! prompts that decide whether they may close. Also which sessions the //! windows show, so a launch opens them again. +mod close_blocker; +mod close_blocker_dialog; mod close_decision; mod close_guard; mod close_guards; @@ -42,6 +44,7 @@ mod structure_review_tests; mod tab_directory; mod tab_entry; mod tab_record; +mod tab_snapshot; #[cfg(test)] mod test_workspace; mod unknown_cause; @@ -54,11 +57,13 @@ mod workspace_handle; mod workspace_load; mod workspace_load_error; mod workspace_record; +mod workspace_record_builder; mod workspace_save_error; mod workspace_store; mod write_job; mod writes_in_flight; +pub use close_blocker::{CloseBlockPrompt, CloseBlocker}; pub use close_decision::CloseDecision; pub(crate) use close_guard::CloseGuard; pub(crate) use close_guards::CloseGuards; @@ -96,6 +101,7 @@ pub use sort_record::SortRecord; pub use tab_directory::TabDirectory; pub use tab_entry::TabEntry; pub use tab_record::TabRecord; +pub use tab_snapshot::TabSnapshot; pub use unknown_cause::UnknownCause; #[cfg(test)] pub use window_inhibitor::InhibitTarget; @@ -106,5 +112,6 @@ pub use workspace_handle::{WorkspaceDeps, WorkspaceHandle}; pub use workspace_load::WorkspaceLoad; pub use workspace_load_error::WorkspaceLoadError; pub use workspace_record::WorkspaceRecord; +pub use workspace_record_builder::workspace_record; pub use workspace_save_error::WorkspaceSaveError; pub use workspace_store::WorkspaceStore; diff --git a/linux/crates/app/src/workspace/ran_statements.rs b/linux/crates/app/src/workspace/ran_statements.rs index c78e1ebac6..ea31692339 100644 --- a/linux/crates/app/src/workspace/ran_statements.rs +++ b/linux/crates/app/src/workspace/ran_statements.rs @@ -139,12 +139,13 @@ mod tests { let store = history.ready_store().await.expect("the store"); let queries: Vec = store - .search(tablepro_storage::query_history::SearchFilter { - limit: 100, - ..Default::default() - }) + .search_page( + &tablepro_storage::query_history::HistoryQuery::default(), + tablepro_storage::query_history::HistoryPageRequest::first(100), + ) .await .expect("search") + .entries .into_iter() .map(|entry| entry.query) .collect(); diff --git a/linux/crates/app/src/workspace/save_settled.rs b/linux/crates/app/src/workspace/save_settled.rs index 8d52dd0b98..d37732f4ac 100644 --- a/linux/crates/app/src/workspace/save_settled.rs +++ b/linux/crates/app/src/workspace/save_settled.rs @@ -2,6 +2,7 @@ use tablepro_core::WriteKind; use uuid::Uuid; use super::SaveOutcome; +use crate::services::change_origins::ChangeOrigins; use crate::services::change_tracker::StatementSource; /// A save that ended, after its tracker and the ledger took the outcome @@ -16,4 +17,6 @@ pub struct SaveSettled { /// The name a structure save leaves the table with, when it created /// or renamed one. pub saved_table_name: Option, + /// The rows a row save adds and removes. + pub origins: ChangeOrigins, } diff --git a/linux/crates/app/src/workspace/tab_entry.rs b/linux/crates/app/src/workspace/tab_entry.rs index a10f1b133a..65f664c3d1 100644 --- a/linux/crates/app/src/workspace/tab_entry.rs +++ b/linux/crates/app/src/workspace/tab_entry.rs @@ -4,7 +4,7 @@ use uuid::Uuid; #[derive(Debug, Clone, PartialEq, Eq)] pub struct TabEntry { pub id: Uuid, - /// The tab's title without the unsaved-changes bullet. + /// The title its tab shows. pub title: String, /// The tab's editor connection holds a transaction the user opened. pub open_transaction: bool, diff --git a/linux/crates/app/src/workspace/tab_snapshot.rs b/linux/crates/app/src/workspace/tab_snapshot.rs new file mode 100644 index 0000000000..b64434c86e --- /dev/null +++ b/linux/crates/app/src/workspace/tab_snapshot.rs @@ -0,0 +1,11 @@ +use uuid::Uuid; + +use super::TabRecord; + +/// One page of the tab view, in its order: the tab it shows and what of +/// it is saved. A tab with nothing to reopen has no record. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TabSnapshot { + pub id: Uuid, + pub record: Option, +} diff --git a/linux/crates/app/src/workspace/window_inhibitor.rs b/linux/crates/app/src/workspace/window_inhibitor.rs index 11b6999f72..0e1e024b54 100644 --- a/linux/crates/app/src/workspace/window_inhibitor.rs +++ b/linux/crates/app/src/workspace/window_inhibitor.rs @@ -167,6 +167,10 @@ mod tests { InhibitReason::SavingChanges.flags(), gtk::ApplicationInhibitFlags::LOGOUT | gtk::ApplicationInhibitFlags::SUSPEND ); + assert_eq!( + InhibitReason::Exporting.flags(), + gtk::ApplicationInhibitFlags::LOGOUT | gtk::ApplicationInhibitFlags::SUSPEND + ); } #[gtk4::test] diff --git a/linux/crates/app/src/workspace/workspace_handle.rs b/linux/crates/app/src/workspace/workspace_handle.rs index 79f086f221..7f9c71df4b 100644 --- a/linux/crates/app/src/workspace/workspace_handle.rs +++ b/linux/crates/app/src/workspace/workspace_handle.rs @@ -3,6 +3,7 @@ use std::collections::HashSet; use std::rc::Rc; use std::sync::Arc; +use futures::future::LocalBoxFuture; use gtk4 as gtk; use tablepro_core::ddl::{BuildDdlError, ChangeReview}; use tablepro_core::save::{SavePlan, plan_save}; @@ -15,11 +16,12 @@ use uuid::Uuid; use super::ran_statements::RanStatements; use super::schema_write::run_schema_plan; use super::{ - CloseScope, CloseStep, DirtyKind, DirtyTab, EngineProfile, PendingState, RollbackCause, SaveId, SaveLedger, - SaveOutcome, SaveRejection, SaveSettled, SaveStart, SaveTicket, TabDirectory, UnknownCause, WorkspaceEvent, - next_close_step, + CloseBlocker, 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_origins::ChangeOrigins; use crate::services::change_tracker::{StatementSource, TrackedChanges, pk_identity}; use crate::services::change_tracker_registry::ChangeTrackerRegistry; use crate::services::history_service::HistoryService; @@ -52,6 +54,8 @@ struct Inner { ledger: SaveLedger, /// The structure tabs whose save waits on a review. reviewing: RefCell>, + /// Work a window close asks about before it closes any tab. + blockers: RefCell>>, } impl Inner { @@ -108,9 +112,26 @@ impl WorkspaceHandle { deps, ledger: SaveLedger::new(), reviewing: RefCell::default(), + blockers: RefCell::default(), })) } + /// Have every close of the whole window ask about `blocker`'s work + /// while it runs. + pub fn register_close_blocker(&self, blocker: Rc) { + self.0.blockers.borrow_mut().push(blocker); + } + + /// Stop the work of the blocker `close_step` named. A blocker that + /// is not there has nothing to stop. + pub fn stop_blocker(&self, blocker: usize) -> LocalBoxFuture<'static, ()> { + let blocker = self.0.blockers.borrow().get(blocker).cloned(); + match blocker { + Some(blocker) => blocker.stop(), + None => Box::pin(std::future::ready(())), + } + } + pub fn ledger(&self) -> &SaveLedger { &self.0.ledger } @@ -176,6 +197,15 @@ impl WorkspaceHandle { /// What closing `scope` has to settle next. Open transactions the /// user already agreed to lose in this close are not asked again. pub fn close_step(&self, scope: CloseScope, acknowledged: &HashSet) -> CloseStep { + if scope == CloseScope::Window { + let blocked = + self.0.blockers.borrow().iter().enumerate().find_map(|(blocker, work)| { + work.blocking().map(|prompt| CloseStep::StopBlocker { blocker, prompt }) + }); + if let Some(step) = blocked { + return step; + } + } let dirty: Vec = self .dirty_tabs() .into_iter() @@ -204,8 +234,8 @@ impl WorkspaceHandle { return self.save_rows(tab, &tracker); } if let Some(tracker) = self.0.deps.structures.get(tab) { - // Ctrl+S still reaches the window while a review is open, and - // the review on screen is the one to answer. + // A close that saves its tabs can ask again while the 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); }; @@ -311,6 +341,7 @@ impl WorkspaceHandle { let inner = self.0.clone(); let tracker = tracker.clone(); let (changes, snapshot, id) = (batch.changes, batch.snapshot, ticket.id); + let origins = ChangeOrigins::of(&changes.changes); let _delivery = crate::runtime::spawn_and_deliver(&self.0.deps.tasks, work, move |finished| { let work = finished.unwrap_or_else(|failure| SaveWork::failed(OperationError::from(failure))); let failing = failing_step(&work, &changes); @@ -324,6 +355,7 @@ impl WorkspaceHandle { outcome, failing, saved_table_name: None, + origins, }, ); }); @@ -344,8 +376,8 @@ impl WorkspaceHandle { 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. + // The draft and the connection can still change while the + // review is open. let (_, now) = self.plan_review(tracker)?; if now.statements != review.statements { return Err(SaveStart::Rejected(SaveRejection::Unplannable( @@ -429,6 +461,7 @@ impl WorkspaceHandle { outcome, failing: None, saved_table_name, + origins: ChangeOrigins::default(), }, ); }); diff --git a/linux/crates/app/src/workspace/workspace_record_builder.rs b/linux/crates/app/src/workspace/workspace_record_builder.rs new file mode 100644 index 0000000000..ca873c3761 --- /dev/null +++ b/linux/crates/app/src/workspace/workspace_record_builder.rs @@ -0,0 +1,79 @@ +use uuid::Uuid; + +use super::{TabSnapshot, WorkspaceRecord}; + +/// The record of `pages`, in their order. The active tab is the index of +/// the selected page's record among the saved ones, not among the pages, +/// and none when the selected page saves nothing. +pub fn workspace_record(pages: &[TabSnapshot], selected: Option) -> WorkspaceRecord { + let mut record = WorkspaceRecord::default(); + for page in pages { + let Some(saved) = &page.record else { + continue; + }; + if selected == Some(page.id) { + record.active_tab = u32::try_from(record.tabs.len()).ok(); + } + record.tabs.push(saved.clone()); + } + record +} + +#[cfg(test)] +mod tests { + use tablepro_storage::DraftId; + + use super::*; + use crate::workspace::TabRecord; + + fn page(record: Option) -> TabSnapshot { + TabSnapshot { + id: Uuid::new_v4(), + record, + } + } + + fn table(name: &str) -> TabRecord { + TabRecord::Table { + schema: Some("public".to_owned()), + table: name.to_owned(), + offset: 0, + page_size: 1_000, + sort: None, + } + } + + fn structure(name: &str) -> TabRecord { + TabRecord::Structure { + schema: Some("public".to_owned()), + table: name.to_owned(), + } + } + + #[test] + fn active_tab_counts_only_persisted_records() { + let pages = [page(None), page(Some(table("a"))), page(Some(structure("b")))]; + + let record = workspace_record(&pages, Some(pages[2].id)); + + assert_eq!(record.tabs, [table("a"), structure("b")]); + assert_eq!(record.active_tab, Some(1)); + } + + #[test] + fn selected_new_draft_gives_no_active_tab() { + let pages = [page(Some(TabRecord::Editor { draft: DraftId::new() })), page(None)]; + + let record = workspace_record(&pages, Some(pages[1].id)); + + assert_eq!(record.tabs.len(), 1); + assert_eq!(record.active_tab, None); + } + + #[test] + fn nothing_selected_gives_no_active_tab() { + let pages = [page(Some(table("a")))]; + + assert_eq!(workspace_record(&pages, None).active_tab, None); + } +} diff --git a/linux/crates/app/tests/application_lifecycle.rs b/linux/crates/app/tests/application_lifecycle.rs index 51a072bfe4..7d8bcd3865 100644 --- a/linux/crates/app/tests/application_lifecycle.rs +++ b/linux/crates/app/tests/application_lifecycle.rs @@ -376,7 +376,8 @@ async fn script( /// 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}"))?; + WidgetExt::activate_action(window, "win.new-editor-tab", None) + .map_err(|error| format!("win.new-editor-tab: {error}"))?; let mut run = None; until("the editor's Run button", || { run = descendants(window.upcast_ref()) diff --git a/linux/crates/app/tests/application_session_restore.rs b/linux/crates/app/tests/application_session_restore.rs index 2537fb2e09..a36a45b291 100644 --- a/linux/crates/app/tests/application_session_restore.rs +++ b/linux/crates/app/tests/application_session_restore.rs @@ -263,15 +263,15 @@ impl Sandbox { /// Write the files as the sessions open at the last quit. fn record(&self, uris: &[&str]) -> Result<(), String> { - let sessions: Vec = uris + let sessions: Vec = uris .iter() - .map(|uri| serde_json::json!({ "kind": "file", "uri": uri })) + .map(|uri| format!(r#"{{"kind":"file","uri":{}}}"#, serde_json::Value::from(*uri))) .collect(); - let record = serde_json::json!({ "version": 1, "sessions": sessions }); + let record = format!(r#"{{"version":1,"sessions":[{}]}}"#, sessions.join(",")); 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}")) + std::fs::write(&path, record).map_err(|error| format!("could not write the record: {error}")) } /// The file URIs recorded, in order. diff --git a/linux/crates/core/Cargo.toml b/linux/crates/core/Cargo.toml index 94f05c4323..5ff6acc20e 100644 --- a/linux/crates/core/Cargo.toml +++ b/linux/crates/core/Cargo.toml @@ -28,6 +28,7 @@ uuid.workspace = true [dev-dependencies] proptest.workspace = true +zip.workspace = true tokio = { workspace = true, features = ["macros", "rt", "test-util"] } [lints] diff --git a/linux/crates/core/src/export/csv.rs b/linux/crates/core/src/export/csv.rs index 44e72c4ea8..f3ff38e741 100644 --- a/linux/crates/core/src/export/csv.rs +++ b/linux/crates/core/src/export/csv.rs @@ -2,8 +2,9 @@ use crate::column::{ColumnType, ResultColumn}; use crate::value::Value; use super::csv_options::{CsvDecimal, CsvDelimiter, CsvLineBreak, CsvOptions, CsvQuote}; +use super::encoder::{CsvEncoder, EncodeError, encode_rows}; use super::value_text::value_text; -use super::{EncodeError, column_type, finish}; +use super::{RenderError, finish}; /// A neutralised cell keeps its leading apostrophe as data only when the /// field is quoted, so sanitising quotes every field. @@ -62,7 +63,7 @@ fn collapse_line_breaks(text: String, opts: &CsvOptions) -> String { } } -fn csv_cell(value: &Value, column: &ColumnType, opts: &CsvOptions) -> String { +pub(super) fn csv_cell(value: &Value, column: &ColumnType, opts: &CsvOptions) -> String { let Some(text) = value_text(value, column) else { return if opts.null_to_empty { String::new() @@ -82,7 +83,7 @@ fn csv_cell(value: &Value, column: &ColumnType, opts: &CsvOptions) -> String { } } -fn csv_header(name: &str, opts: &CsvOptions) -> String { +pub(super) fn csv_header(name: &str, opts: &CsvOptions) -> String { if opts.sanitize_formulas { neutralise_formula(&Value::Null, name.to_string(), true) } else { @@ -100,23 +101,12 @@ fn csv_text_field(text: &str, opts: &CsvOptions) -> String { } pub fn render_csv(columns: &[ResultColumn], rows: &[Vec], opts: &CsvOptions) -> Result { - let mut writer = csv_writer_builder(opts).from_writer(Vec::new()); - if opts.header_row { - writer.write_record(columns.iter().map(|column| csv_header(&column.name, opts)))?; - } - for row in rows { - writer.write_record( - row.iter() - .enumerate() - .map(|(index, value)| csv_cell(value, column_type(columns, index), opts)), - )?; - } - finish(writer) + encode_rows(CsvEncoder::new(columns.into(), opts.clone()), rows) } /// CSV for records whose fields are all free text, such as exported query /// history. -pub fn render_text_csv(header: &[&str], records: &[Vec], opts: &CsvOptions) -> Result { +pub fn render_text_csv(header: &[&str], records: &[Vec], opts: &CsvOptions) -> Result { let mut writer = csv_writer_builder(opts).from_writer(Vec::new()); if opts.header_row { writer.write_record(header.iter().map(|name| csv_header(name, opts)))?; diff --git a/linux/crates/core/src/export/encoder/csv_encoder.rs b/linux/crates/core/src/export/encoder/csv_encoder.rs new file mode 100644 index 0000000000..a42b639668 --- /dev/null +++ b/linux/crates/core/src/export/encoder/csv_encoder.rs @@ -0,0 +1,55 @@ +use std::sync::Arc; + +use crate::column::ResultColumn; +use crate::value::Value; + +use super::super::column_type; +use super::super::csv::{csv_cell, csv_header, csv_writer_builder}; +use super::super::csv_options::CsvOptions; +use super::{EncodeError, RecordWriter, RowEncoder, readable_cells}; + +/// CSV under the user's options, with formula-like text neutralised +/// when they ask for it. +pub struct CsvEncoder { + columns: Arc<[ResultColumn]>, + options: CsvOptions, + writer: RecordWriter, +} + +impl CsvEncoder { + pub fn new(columns: Arc<[ResultColumn]>, options: CsvOptions) -> Self { + let writer = RecordWriter::new(csv_writer_builder(&options)); + Self { + columns, + options, + writer, + } + } +} + +impl RowEncoder for CsvEncoder { + fn begin(&mut self, out: &mut Vec) { + if self.options.header_row { + self.writer.write( + out, + self.columns + .iter() + .map(|column| csv_header(&column.name, &self.options)), + ); + } + } + + fn row(&mut self, out: &mut Vec, cells: &mut dyn Iterator) -> Result<(), EncodeError> { + let cells = readable_cells(&self.columns, cells)?; + self.writer.write( + out, + cells + .iter() + .enumerate() + .map(|(index, value)| csv_cell(value, column_type(&self.columns, index), &self.options)), + ); + Ok(()) + } + + fn finish(&mut self, _out: &mut Vec) {} +} diff --git a/linux/crates/core/src/export/encoder/encode_error.rs b/linux/crates/core/src/export/encoder/encode_error.rs new file mode 100644 index 0000000000..4a7c6c2eef --- /dev/null +++ b/linux/crates/core/src/export/encoder/encode_error.rs @@ -0,0 +1,9 @@ +use thiserror::Error; + +/// Why a row could not be encoded. The text is for logs; the app says it +/// in its own words. +#[derive(Debug, Clone, PartialEq, Eq, Error)] +pub enum EncodeError { + #[error("column {column} has an undecodable value")] + Undecodable { column: String }, +} diff --git a/linux/crates/core/src/export/encoder/html_encoder.rs b/linux/crates/core/src/export/encoder/html_encoder.rs new file mode 100644 index 0000000000..e465124cbb --- /dev/null +++ b/linux/crates/core/src/export/encoder/html_encoder.rs @@ -0,0 +1,42 @@ +use std::sync::Arc; + +use crate::column::ResultColumn; +use crate::value::Value; + +use super::super::html::{HTML_TAIL, html_head, html_row}; +use super::{EncodeError, RowEncoder, readable_cells}; + +/// The document `render_html` writes, a row at a time. +pub struct HtmlEncoder { + columns: Arc<[ResultColumn]>, + title: String, +} + +impl HtmlEncoder { + pub fn new(columns: Arc<[ResultColumn]>, title: impl Into) -> Self { + Self { + columns, + title: title.into(), + } + } +} + +impl RowEncoder for HtmlEncoder { + fn begin(&mut self, out: &mut Vec) { + let mut head = String::new(); + html_head(&mut head, &self.columns, &self.title); + out.extend_from_slice(head.as_bytes()); + } + + fn row(&mut self, out: &mut Vec, cells: &mut dyn Iterator) -> Result<(), EncodeError> { + let cells = readable_cells(&self.columns, cells)?; + let mut row = String::new(); + html_row(&mut row, &self.columns, cells); + out.extend_from_slice(row.as_bytes()); + Ok(()) + } + + fn finish(&mut self, out: &mut Vec) { + out.extend_from_slice(HTML_TAIL.as_bytes()); + } +} diff --git a/linux/crates/core/src/export/encoder/json_array_encoder.rs b/linux/crates/core/src/export/encoder/json_array_encoder.rs new file mode 100644 index 0000000000..867f2d791b --- /dev/null +++ b/linux/crates/core/src/export/encoder/json_array_encoder.rs @@ -0,0 +1,83 @@ +use std::sync::Arc; + +use serde::Serialize; + +use crate::column::ResultColumn; +use crate::value::Value; + +use super::super::column_type; +use super::super::json::JsonCell; +use super::super::json_field_names::json_field_names; +use super::{EncodeError, RowEncoder, readable_cells}; + +/// A JSON array with one object per row, each on a line of its own. +/// +/// The objects are written key by key in column order rather than +/// built as a map first: a map would cost a copy of every row and, with +/// serde_json's default map, sort the keys. +pub struct JsonArrayEncoder { + columns: Arc<[ResultColumn]>, + /// Each column's key as JSON, followed by its colon. + keys: Vec>, + rows: u64, +} + +impl JsonArrayEncoder { + pub fn new(columns: Arc<[ResultColumn]>) -> Self { + let keys = json_field_names(&columns) + .iter() + .map(|name| { + let mut key = Vec::with_capacity(name.len() + 3); + write_json(&mut key, name); + key.push(b':'); + key + }) + .collect(); + Self { columns, keys, rows: 0 } + } +} + +impl RowEncoder for JsonArrayEncoder { + fn begin(&mut self, out: &mut Vec) { + out.push(b'['); + } + + fn row(&mut self, out: &mut Vec, cells: &mut dyn Iterator) -> Result<(), EncodeError> { + let cells = readable_cells(&self.columns, cells)?; + let lead: &[u8] = if self.rows == 0 { b"\n{" } else { b",\n{" }; + out.extend_from_slice(lead); + for (index, key) in self.keys.iter().enumerate() { + if index > 0 { + out.push(b','); + } + out.extend_from_slice(key); + match cells.get(index).copied() { + Some(value) => write_json( + out, + &JsonCell { + value, + kind: &column_type(&self.columns, index).kind, + }, + ), + // A row shorter than its header: the cell is missing, + // which is not the same as a NULL the row holds. + None => out.extend_from_slice(b"null"), + } + } + out.push(b'}'); + self.rows += 1; + Ok(()) + } + + fn finish(&mut self, out: &mut Vec) { + if self.rows > 0 { + out.push(b'\n'); + } + out.push(b']'); + } +} + +/// serde_json fails only when its writer does, and a Vec never does. +fn write_json(out: &mut Vec, value: &impl Serialize) { + let _ = serde_json::to_writer(out, value); +} diff --git a/linux/crates/core/src/export/encoder/markdown_encoder.rs b/linux/crates/core/src/export/encoder/markdown_encoder.rs new file mode 100644 index 0000000000..66347c3e01 --- /dev/null +++ b/linux/crates/core/src/export/encoder/markdown_encoder.rs @@ -0,0 +1,40 @@ +use std::sync::Arc; + +use crate::column::ResultColumn; +use crate::value::Value; + +use super::super::column_type; +use super::super::markdown::{markdown_cell, markdown_text}; +use super::{EncodeError, RowEncoder, readable_cells}; + +/// A Markdown table. Each row starts on a line of its own, so the table +/// ends without a trailing line break. +pub struct MarkdownEncoder { + columns: Arc<[ResultColumn]>, +} + +impl MarkdownEncoder { + pub fn new(columns: Arc<[ResultColumn]>) -> Self { + Self { columns } + } +} + +impl RowEncoder for MarkdownEncoder { + fn begin(&mut self, out: &mut Vec) { + let header: Vec = self.columns.iter().map(|column| markdown_text(&column.name)).collect(); + let separator: Vec<&str> = self.columns.iter().map(|_| "---").collect(); + out.extend_from_slice(format!("| {} |\n| {} |", header.join(" | "), separator.join(" | ")).as_bytes()); + } + + fn row(&mut self, out: &mut Vec, cells: &mut dyn Iterator) -> Result<(), EncodeError> { + let cells: Vec = readable_cells(&self.columns, cells)? + .into_iter() + .enumerate() + .map(|(index, value)| markdown_cell(value, column_type(&self.columns, index))) + .collect(); + out.extend_from_slice(format!("\n| {} |", cells.join(" | ")).as_bytes()); + Ok(()) + } + + fn finish(&mut self, _out: &mut Vec) {} +} diff --git a/linux/crates/core/src/export/encoder/mod.rs b/linux/crates/core/src/export/encoder/mod.rs new file mode 100644 index 0000000000..a45a7683cb --- /dev/null +++ b/linux/crates/core/src/export/encoder/mod.rs @@ -0,0 +1,113 @@ +mod csv_encoder; +mod encode_error; +mod html_encoder; +mod json_array_encoder; +mod markdown_encoder; +mod row_encoder; +mod sql_insert_encoder; +mod tsv_encoder; +mod xml_encoder; + +use std::cell::Cell; +use std::io; + +use crate::column::ResultColumn; +use crate::value::Value; + +pub use csv_encoder::CsvEncoder; +pub use encode_error::EncodeError; +pub use html_encoder::HtmlEncoder; +pub use json_array_encoder::JsonArrayEncoder; +pub use markdown_encoder::MarkdownEncoder; +pub use row_encoder::RowEncoder; +pub use sql_insert_encoder::SqlInsertEncoder; +pub use tsv_encoder::TsvEncoder; +pub use xml_encoder::XmlEncoder; + +/// A whole result through `encoder`, as text. +pub(super) fn encode_rows(mut encoder: impl RowEncoder, rows: &[Vec]) -> Result { + let mut out = Vec::new(); + encoder.begin(&mut out); + for row in rows { + encoder.row(&mut out, &mut row.iter())?; + } + encoder.finish(&mut out); + // Every encoder writes UTF-8 text and ASCII punctuation, so the + // lossy branch is never taken. + Ok(String::from_utf8(out).unwrap_or_else(|error| String::from_utf8_lossy(error.as_bytes()).into_owned())) +} + +/// The row's cells, refused when one of them could not be read: a value +/// the driver could not decode has no text, and writing it as a NULL +/// would put a value in the file that the table does not hold. +fn readable_cells<'a>( + columns: &[ResultColumn], + cells: &mut dyn Iterator, +) -> Result, EncodeError> { + cells + .enumerate() + .map(|(index, value)| match value { + Value::Undecodable(_) => Err(EncodeError::Undecodable { + column: column_name(columns, index), + }), + value => Ok(value), + }) + .collect() +} + +/// A column's name, or its position for a cell past the header. +fn column_name(columns: &[ResultColumn], index: usize) -> String { + columns + .get(index) + .map_or_else(|| (index + 1).to_string(), |column| column.name.clone()) +} + +/// Writes one record at a time into the caller's buffer, through one +/// CSV writer kept for the whole export: building a writer allocates and +/// zeroes its buffer, which per row would cost more than the row. +struct RecordWriter { + writer: csv::Writer, +} + +/// The caller's buffer, lent to the writer for one record. csv::Writer +/// has no `get_mut`, so the buffer goes in and out through a `Cell`. +#[derive(Default)] +struct LentBuffer(Cell>); + +impl io::Write for LentBuffer { + fn write(&mut self, bytes: &[u8]) -> io::Result { + self.0.get_mut().extend_from_slice(bytes); + Ok(bytes.len()) + } + + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } +} + +impl RecordWriter { + /// A row may have more cells than the header, so the writer does + /// not hold every record to the first one's length. + fn new(mut builder: csv::WriterBuilder) -> Self { + builder.flexible(true); + Self { + writer: builder.from_writer(LentBuffer::default()), + } + } + + /// Writing into memory cannot fail, and a flexible writer has no + /// field count to disagree with, so neither result carries anything. + fn write(&mut self, out: &mut Vec, record: I) + where + I: IntoIterator, + T: AsRef<[u8]>, + { + self.writer.get_ref().0.set(std::mem::take(out)); + let _ = self.writer.write_record(record); + let _ = self.writer.flush(); + *out = self.writer.get_ref().0.take(); + } +} + +#[cfg(test)] +mod tests; diff --git a/linux/crates/core/src/export/encoder/row_encoder.rs b/linux/crates/core/src/export/encoder/row_encoder.rs new file mode 100644 index 0000000000..2c6b26c096 --- /dev/null +++ b/linux/crates/core/src/export/encoder/row_encoder.rs @@ -0,0 +1,17 @@ +use crate::value::Value; + +use super::EncodeError; + +/// Writes a result one row at a time into a buffer the caller owns, so +/// the caller decides how much to hold before handing it on. +pub trait RowEncoder: Send { + /// What comes before the first row: a header line, an opening + /// bracket. + fn begin(&mut self, out: &mut Vec); + + /// One row. A row that fails leaves `out` as it was. + fn row(&mut self, out: &mut Vec, cells: &mut dyn Iterator) -> Result<(), EncodeError>; + + /// What comes after the last row. + fn finish(&mut self, out: &mut Vec); +} diff --git a/linux/crates/core/src/export/encoder/sql_insert_encoder.rs b/linux/crates/core/src/export/encoder/sql_insert_encoder.rs new file mode 100644 index 0000000000..f0af1dbf1e --- /dev/null +++ b/linux/crates/core/src/export/encoder/sql_insert_encoder.rs @@ -0,0 +1,44 @@ +use std::sync::Arc; + +use crate::column::ResultColumn; +use crate::dialect::SqlDialect; +use crate::value::Value; + +use super::super::sql::{insert_row, insert_start}; +use super::{EncodeError, RowEncoder, readable_cells}; + +/// The statements `render_sql_insert` writes, a row at a time. +pub struct SqlInsertEncoder { + dialect: Arc, + columns: Arc<[ResultColumn]>, + /// `None` for a result with no columns, which writes nothing. + start: Option, +} + +impl SqlInsertEncoder { + pub fn new(dialect: Arc, table: &str, columns: Arc<[ResultColumn]>) -> Self { + let start = insert_start(&*dialect, table, &columns); + Self { + dialect, + columns, + start, + } + } +} + +impl RowEncoder for SqlInsertEncoder { + fn begin(&mut self, _out: &mut Vec) {} + + fn row(&mut self, out: &mut Vec, cells: &mut dyn Iterator) -> Result<(), EncodeError> { + let cells = readable_cells(&self.columns, cells)?; + let Some(start) = &self.start else { + return Ok(()); + }; + let mut row = String::new(); + insert_row(&mut row, &*self.dialect, start, &self.columns, cells); + out.extend_from_slice(row.as_bytes()); + Ok(()) + } + + fn finish(&mut self, _out: &mut Vec) {} +} diff --git a/linux/crates/core/src/export/encoder/tests.rs b/linux/crates/core/src/export/encoder/tests.rs new file mode 100644 index 0000000000..c9ae68794b --- /dev/null +++ b/linux/crates/core/src/export/encoder/tests.rs @@ -0,0 +1,377 @@ +use std::sync::Arc; + +use super::super::csv::{csv_cell, csv_header, csv_writer_builder, neutralise_formula}; +use super::super::csv_options::{CsvDecimal, CsvDelimiter, CsvLineBreak, CsvOptions, CsvQuote}; +use super::super::test_columns::{cols, typed}; +use super::super::tsv::tsv_writer_builder; +use super::super::value_text::value_text; +use super::super::{ + column_type, render_csv, render_html, render_json, render_sql_insert, render_tsv, render_xml, row_to_json, +}; +use super::*; +use crate::column::{ColumnKind, IntegerKind, IntegerWidth}; +use crate::dialect::{SqlDialect, TestDialect}; +use crate::value::{JsonText, UndecodableReason, UndecodedValue}; + +/// Every call's output taken away as soon as it is written, the way a +/// sink hands each chunk on, so an encoder that leaned on what it wrote +/// earlier would show here. +fn streamed(mut encoder: impl RowEncoder, rows: &[Vec]) -> String { + let mut taken = Vec::new(); + let mut out = Vec::new(); + encoder.begin(&mut out); + taken.append(&mut out); + for row in rows { + encoder.row(&mut out, &mut row.iter()).expect("the row"); + taken.append(&mut out); + } + encoder.finish(&mut out); + taken.append(&mut out); + String::from_utf8(taken).expect("utf-8") +} + +fn fixture() -> (Arc<[ResultColumn]>, Vec>) { + let columns: Arc<[ResultColumn]> = vec![ + typed( + "id", + "bigint", + ColumnKind::Integer(IntegerKind { + width: IntegerWidth::W64, + signed: true, + }), + ), + typed( + "amount", + "numeric(10,2)", + ColumnKind::Decimal { + precision: Some(10), + scale: Some(2), + }, + ), + cols(&["=note"]).remove(0), + ] + .into(); + let rows = vec![ + vec![ + Value::Int(-5), + Value::Decimal("-1.5".parse().expect("a decimal")), + Value::Text("=cmd|'/C calc'!A0".into()), + ], + vec![ + Value::Int(7), + Value::Float64(2.25), + Value::Text("line1\r\nline2, \"quoted\"\ttab".into()), + ], + vec![Value::Null, Value::Null, Value::Text(String::new())], + vec![ + Value::UInt(u64::MAX), + Value::Decimal("12.30".parse().expect("a decimal")), + Value::Text("-12".into()), + ], + ]; + (columns, rows) +} + +/// CSV as one writer over the whole result, the way it was written +/// before the encoders. +fn one_writer_csv(columns: &[ResultColumn], rows: &[Vec], options: &CsvOptions) -> String { + let mut writer = csv_writer_builder(options).from_writer(Vec::new()); + if options.header_row { + writer + .write_record(columns.iter().map(|column| csv_header(&column.name, options))) + .expect("the header"); + } + for row in rows { + writer + .write_record( + row.iter() + .enumerate() + .map(|(index, value)| csv_cell(value, column_type(columns, index), options)), + ) + .expect("the row"); + } + String::from_utf8(writer.into_inner().expect("flushed")).expect("utf-8") +} + +#[test] +fn csv_encoder_matches_render_csv_fixture_matrix() { + let (columns, rows) = fixture(); + let mut checked = 0; + for header_row in [true, false] { + for sanitize_formulas in [true, false] { + for null_to_empty in [true, false] { + for line_break_to_space in [true, false] { + for delimiter in CsvDelimiter::ALL { + for quote in CsvQuote::ALL { + for line_break in CsvLineBreak::ALL { + for decimal in CsvDecimal::ALL { + let options = CsvOptions { + null_to_empty, + line_break_to_space, + header_row, + sanitize_formulas, + delimiter, + quote, + line_break, + decimal, + }; + let expected = one_writer_csv(&columns, &rows, &options); + + let encoded = streamed(CsvEncoder::new(columns.clone(), options.clone()), &rows); + + assert_eq!(encoded, expected, "{options:?}"); + assert_eq!( + render_csv(&columns, &rows, &options).expect("the csv"), + expected, + "{options:?}" + ); + checked += 1; + } + } + } + } + } + } + } + } + assert_eq!(checked, 2 * 2 * 2 * 2 * 4 * 3 * 3 * 2); +} + +/// One writer serves the whole export, so a row wider than the header +/// must not trip the check a writer makes on record lengths. +#[test] +fn csv_and_tsv_encoders_take_rows_wider_than_the_header() { + let columns: Arc<[ResultColumn]> = cols(&["a"]).into(); + let rows = vec![ + vec![Value::Int(1)], + vec![Value::Int(2), Value::Int(3)], + vec![Value::Int(4)], + ]; + let options = CsvOptions { + sanitize_formulas: false, + ..CsvOptions::default() + }; + + assert_eq!( + streamed(CsvEncoder::new(columns.clone(), options), &rows), + "a\n1\n2,3\n4\n" + ); + assert_eq!(streamed(TsvEncoder::new(columns, true), &rows), "a\n1\n2\t3\n4\n"); +} + +#[test] +fn tsv_encoder_matches_render_tsv() { + let (columns, rows) = fixture(); + for with_headers in [true, false] { + let mut writer = tsv_writer_builder().from_writer(Vec::new()); + if with_headers { + writer + .write_record( + columns + .iter() + .map(|column| neutralise_formula(&Value::Null, column.name.clone(), true)), + ) + .expect("the header"); + } + for row in &rows { + writer + .write_record(row.iter().enumerate().map(|(index, value)| { + value_text(value, column_type(&columns, index)) + .map_or_else(|| "NULL".to_owned(), |text| neutralise_formula(value, text, false)) + })) + .expect("the row"); + } + let expected = String::from_utf8(writer.into_inner().expect("flushed")).expect("utf-8"); + + assert_eq!( + streamed(TsvEncoder::new(columns.clone(), with_headers), &rows), + expected + ); + assert_eq!(render_tsv(&columns, &rows, with_headers).expect("the tsv"), expected); + } +} + +#[test] +fn json_encoder_output_parses_and_matches_render_json() { + const CHUNK: usize = 64 * 1024; + let columns: Arc<[ResultColumn]> = cols(&["id", "name", "payload"]).into(); + let rows: Vec> = (0..10_000) + .map(|id| { + vec![ + Value::Int(id), + Value::Text(format!("row \"{id}\"\n")), + Value::Json(JsonText::parse(format!("{{\"n\": {id}}}")).expect("valid json")), + ] + }) + .collect(); + + let mut encoder = JsonArrayEncoder::new(columns.clone()); + let mut chunks: Vec> = Vec::new(); + let mut out = Vec::with_capacity(CHUNK); + encoder.begin(&mut out); + for row in &rows { + encoder.row(&mut out, &mut row.iter()).expect("the row"); + if out.len() >= CHUNK { + chunks.push(std::mem::replace(&mut out, Vec::with_capacity(CHUNK))); + } + } + encoder.finish(&mut out); + chunks.push(out); + + assert!(chunks.len() > 2, "the export fit in one buffer"); + assert!(chunks[..chunks.len() - 1].iter().all(|chunk| chunk.len() >= CHUNK)); + let text = String::from_utf8(chunks.concat()).expect("utf-8"); + // render_json runs this encoder into one buffer, so this shows only + // that the chunks change nothing. + assert_eq!(text, render_json(&columns, &rows).expect("the json")); + let parsed: Vec = serde_json::from_str(&text).expect("a JSON array"); + // The values themselves, against the serializer that wrote whole + // rows before the encoders. + let expected: Vec = rows + .iter() + .map(|row| serde_json::from_str(&row_to_json(&columns, row)).expect("a JSON row")) + .collect(); + assert_eq!(parsed, expected); + assert_eq!(parsed.len(), 10_000); + assert_eq!(parsed[9_999]["id"], serde_json::json!(9_999)); + assert_eq!(parsed[3]["name"], serde_json::json!("row \"3\"\n")); + assert_eq!(parsed[3]["payload"]["n"], serde_json::json!(3)); +} + +#[test] +fn json_encoder_keeps_column_order_without_preserve_order() { + let columns: Arc<[ResultColumn]> = cols(&["b", "a", "c"]).into(); + let rows = vec![vec![Value::Int(1), Value::Int(2), Value::Int(3)]]; + + assert_eq!( + streamed(JsonArrayEncoder::new(columns), &rows), + "[\n{\"b\":1,\"a\":2,\"c\":3}\n]" + ); +} + +#[test] +fn json_encoder_duplicate_column_names_use_suffixes() { + let columns: Arc<[ResultColumn]> = cols(&["id", "name", "id"]).into(); + let rows = vec![ + vec![Value::Int(1), Value::Text("a".into()), Value::Int(2)], + vec![Value::Int(3), Value::Null, Value::Int(4)], + ]; + + assert_eq!( + streamed(JsonArrayEncoder::new(columns), &rows), + "[\n{\"id\":1,\"name\":\"a\",\"id_2\":2},\n{\"id\":3,\"name\":null,\"id_2\":4}\n]" + ); +} + +#[test] +fn empty_json_export_is_empty_array() { + let columns: Arc<[ResultColumn]> = cols(&["a"]).into(); + + assert_eq!(streamed(JsonArrayEncoder::new(columns), &[]), "[]"); +} + +#[test] +fn document_encoders_match_their_renderers() { + let (columns, mut rows) = fixture(); + rows.push(vec![Value::Int(1)]); + rows.push(vec![ + Value::Int(2), + Value::Null, + Value::Text("&'".into()), + Value::Int(3), + ]); + let dialect: Arc = Arc::new(TestDialect::default()); + + assert_eq!( + streamed(HtmlEncoder::new(columns.clone(), "rows & "), &rows), + render_html(&columns, &rows, "rows & ") + ); + assert_eq!( + streamed(XmlEncoder::new(columns.clone()), &rows), + render_xml(&columns, &rows) + ); + assert_eq!( + streamed(SqlInsertEncoder::new(dialect.clone(), "t", columns.clone()), &rows), + render_sql_insert(&*dialect, "t", &columns, &rows) + ); +} + +#[test] +fn document_encoders_write_whole_documents_for_no_rows() { + let columns: Arc<[ResultColumn]> = cols(&["a"]).into(); + let dialect: Arc = Arc::new(TestDialect::default()); + + assert_eq!( + streamed(HtmlEncoder::new(columns.clone(), "t"), &[]), + render_html(&columns, &[], "t") + ); + assert_eq!( + streamed(XmlEncoder::new(columns.clone()), &[]), + render_xml(&columns, &[]) + ); + assert_eq!(streamed(SqlInsertEncoder::new(dialect.clone(), "t", columns), &[]), ""); + assert_eq!( + streamed(SqlInsertEncoder::new(dialect, "t", Vec::new().into()), &[Vec::new()]), + "", + "a result with no columns has no statement" + ); +} + +#[test] +fn encoder_reports_undecodable_cell() { + let columns: Arc<[ResultColumn]> = cols(&["id", "shape"]).into(); + let undecodable = Value::Undecodable(Box::new(UndecodedValue { + type_name: "geometry".to_owned(), + reason: UndecodableReason::UnsupportedType, + })); + let encoders: Vec> = vec![ + Box::new(CsvEncoder::new(columns.clone(), CsvOptions::default())), + Box::new(TsvEncoder::new(columns.clone(), true)), + Box::new(JsonArrayEncoder::new(columns.clone())), + Box::new(MarkdownEncoder::new(columns.clone())), + Box::new(HtmlEncoder::new(columns.clone(), "t")), + Box::new(XmlEncoder::new(columns.clone())), + Box::new(SqlInsertEncoder::new( + Arc::new(TestDialect::default()), + "t", + columns.clone(), + )), + ]; + for mut encoder in encoders { + let mut out = Vec::new(); + encoder.begin(&mut out); + let before = out.clone(); + + let error = encoder + .row(&mut out, &mut [Value::Int(1), undecodable.clone()].iter()) + .expect_err("an undecodable cell"); + + assert_eq!( + error, + EncodeError::Undecodable { + column: "shape".to_owned() + } + ); + assert_eq!(out, before, "a failed row left part of itself behind"); + } +} + +#[test] +fn csv_neutralises_text_not_negative_numbers_through_encoder() { + let columns: Arc<[ResultColumn]> = cols(&["a"]).into(); + let rows = vec![ + vec![Value::Text("=cmd".into())], + vec![Value::Text("-cmd".into())], + vec![Value::Int(-5)], + vec![Value::Decimal("-12.30".parse().expect("a decimal"))], + vec![Value::Float64(-1.5)], + vec![Value::Text("-12".into())], + ]; + + let out = streamed(CsvEncoder::new(columns, CsvOptions::default()), &rows); + + assert_eq!( + out, + "\"a\"\n\"'=cmd\"\n\"'-cmd\"\n\"-5\"\n\"-12.30\"\n\"-1.5\"\n\"-12\"\n" + ); +} diff --git a/linux/crates/core/src/export/encoder/tsv_encoder.rs b/linux/crates/core/src/export/encoder/tsv_encoder.rs new file mode 100644 index 0000000000..deb9f9cdd3 --- /dev/null +++ b/linux/crates/core/src/export/encoder/tsv_encoder.rs @@ -0,0 +1,59 @@ +use std::sync::Arc; + +use crate::column::ResultColumn; +use crate::value::Value; + +use super::super::column_type; +use super::super::csv::neutralise_formula; +use super::super::tsv::tsv_writer_builder; +use super::super::value_text::value_text; +use super::{EncodeError, RecordWriter, RowEncoder, readable_cells}; + +/// Tab-separated values, the form a spreadsheet pastes into cells. A +/// paste reads a cell that starts like a formula as one, so free text +/// gets the apostrophe a CSV export gives it. +pub struct TsvEncoder { + columns: Arc<[ResultColumn]>, + with_headers: bool, + writer: RecordWriter, +} + +impl TsvEncoder { + pub fn new(columns: Arc<[ResultColumn]>, with_headers: bool) -> Self { + let writer = RecordWriter::new(tsv_writer_builder()); + Self { + columns, + with_headers, + writer, + } + } +} + +impl RowEncoder for TsvEncoder { + fn begin(&mut self, out: &mut Vec) { + if self.with_headers { + self.writer.write( + out, + self.columns + .iter() + .map(|column| neutralise_formula(&Value::Null, column.name.clone(), true)), + ); + } + } + + fn row(&mut self, out: &mut Vec, cells: &mut dyn Iterator) -> Result<(), EncodeError> { + let cells = readable_cells(&self.columns, cells)?; + self.writer.write( + out, + cells.iter().enumerate().map( + |(index, value)| match value_text(value, column_type(&self.columns, index)) { + Some(text) => neutralise_formula(value, text, false), + None => "NULL".to_owned(), + }, + ), + ); + Ok(()) + } + + fn finish(&mut self, _out: &mut Vec) {} +} diff --git a/linux/crates/core/src/export/encoder/xml_encoder.rs b/linux/crates/core/src/export/encoder/xml_encoder.rs new file mode 100644 index 0000000000..866bfa0093 --- /dev/null +++ b/linux/crates/core/src/export/encoder/xml_encoder.rs @@ -0,0 +1,36 @@ +use std::sync::Arc; + +use crate::column::ResultColumn; +use crate::value::Value; + +use super::super::xml::{XML_HEAD, XML_TAIL, xml_row}; +use super::{EncodeError, RowEncoder, readable_cells}; + +/// The document `render_xml` writes, a row at a time. +pub struct XmlEncoder { + columns: Arc<[ResultColumn]>, +} + +impl XmlEncoder { + pub fn new(columns: Arc<[ResultColumn]>) -> Self { + Self { columns } + } +} + +impl RowEncoder for XmlEncoder { + fn begin(&mut self, out: &mut Vec) { + out.extend_from_slice(XML_HEAD.as_bytes()); + } + + fn row(&mut self, out: &mut Vec, cells: &mut dyn Iterator) -> Result<(), EncodeError> { + let cells = readable_cells(&self.columns, cells)?; + let mut row = String::new(); + xml_row(&mut row, &self.columns, cells); + out.extend_from_slice(row.as_bytes()); + Ok(()) + } + + fn finish(&mut self, out: &mut Vec) { + out.extend_from_slice(XML_TAIL.as_bytes()); + } +} diff --git a/linux/crates/core/src/export/html.rs b/linux/crates/core/src/export/html.rs index 2c656309da..6fe31c0270 100644 --- a/linux/crates/core/src/export/html.rs +++ b/linux/crates/core/src/export/html.rs @@ -12,38 +12,47 @@ use super::value_text::value_text; /// non-ASCII name into mojibake. pub fn render_html(columns: &[ResultColumn], rows: &[Vec], title: &str) -> String { let mut out = String::with_capacity(rows.len() * columns.len() * 16); + html_head(&mut out, columns, title); + for row in rows { + html_row(&mut out, columns, row); + } + out.push_str(HTML_TAIL); + out +} + +pub(super) fn html_head(out: &mut String, columns: &[ResultColumn], title: &str) { out.push_str("\n\n\n\n"); - escape_into(&mut out, title); + escape_into(out, title); out.push_str("\n\n\n\n\n\n"); for column in columns { out.push_str(""); } out.push_str("\n\n\n"); - for row in rows { - out.push_str(""); - for (index, value) in row.iter().enumerate() { - match value_text(value, column_type(columns, index)) { - Some(text) => { - out.push_str(""); + for (index, value) in row.into_iter().enumerate() { + match value_text(value, column_type(columns, index)) { + Some(text) => { + out.push_str(""); + // A NULL is marked rather than written as the word, so it + // is not read back as a row holding the text "NULL". + None => out.push_str("\n"); + out.push_str(""); } - out.push_str("\n
"); - escape_into(&mut out, &column.name); + escape_into(out, &column.name); out.push_str("
"); - escape_into(&mut out, &text); - } - // A NULL is marked rather than written as the word, - // so it is not read back as a row holding the text - // "NULL". - None => out.push_str(""), +} + +pub(super) fn html_row<'a>(out: &mut String, columns: &[ResultColumn], row: impl IntoIterator) { + out.push_str("
"); + escape_into(out, &text); } - out.push_str(""), } - out.push_str("
\n\n\n"); - out + out.push_str("\n"); } +pub(super) const HTML_TAIL: &str = "\n\n\n\n"; + const STYLE: &str = "table { border-collapse: collapse; font-family: sans-serif; font-size: 14px }\n\ th, td { border: 1px solid #ccc; padding: 4px 8px; text-align: left; vertical-align: top }\n\ th { background: #f0f0f0 }\n\ diff --git a/linux/crates/core/src/export/in_clause.rs b/linux/crates/core/src/export/in_clause.rs index 15ddaf3ac2..ce23b1bd60 100644 --- a/linux/crates/core/src/export/in_clause.rs +++ b/linux/crates/core/src/export/in_clause.rs @@ -1,21 +1,27 @@ +use crate::column::ColumnType; +use crate::dialect::{LiteralError, SqlDialect}; use crate::value::Value; -use super::value_text::value_to_text; - -fn in_clause_literal(v: &Value) -> Option { - match v { +/// The value as the engine's own literal, or `None` for one an IN list +/// leaves out. The engine's literal and not a quoted copy of the text: +/// MySQL and ClickHouse read a backslash in a string as an escape, so a +/// value ending in one would close the string early. +fn in_clause_literal( + dialect: &dyn SqlDialect, + column: &ColumnType, + value: &Value, +) -> Option> { + match value { // NULL never matches an IN list and turns a NOT IN into a // list that matches nothing at all; a binary literal has a // different spelling on every engine. Both are reported to // the caller rather than written. Value::Null | Value::Bytes(_) => None, - Value::Bool(b) => Some(if *b { "TRUE".to_string() } else { "FALSE".to_string() }), // The widened double, not the real's own digits: an engine // compares a `real` with a numeric literal as a double, and // `0.1` as a double is not the `real` 0.1. - Value::Float32(real) => value_to_text(&Value::Float64(f64::from(*real))), - Value::Int(_) | Value::UInt(_) | Value::WideInt(_) | Value::Float64(_) | Value::Decimal(_) => value_to_text(v), - other => value_to_text(other).map(|s| format!("'{}'", s.replace('\'', "''"))), + Value::Float32(real) => Some(dialect.literal(&Value::Float64(f64::from(*real)), column)), + other => Some(dialect.literal(other, column)), } } @@ -29,54 +35,75 @@ pub struct InClause { pub skipped: usize, } -pub fn render_in_clause(rows: &[Vec], col_index: usize) -> InClause { - let values: Vec<&Value> = rows.iter().filter_map(|row| row.get(col_index)).collect(); - let literals: Vec = values.iter().filter_map(|v| in_clause_literal(v)).collect(); - InClause { - skipped: values.len() - literals.len(), +/// One column's values, from the rows a user picked, as an IN list in +/// the engine's spelling. A value the engine has no literal for fails +/// the list, since one left out would select other rows. +pub fn render_in_clause<'a>( + dialect: &dyn SqlDialect, + column: &ColumnType, + values: impl IntoIterator, +) -> Result { + let mut skipped = 0; + let mut literals = Vec::new(); + for value in values { + match in_clause_literal(dialect, column, value) { + Some(literal) => literals.push(literal?), + None => skipped += 1, + } + } + Ok(InClause { + skipped, sql: if literals.is_empty() { String::new() } else { format!("({})", literals.join(", ")) }, - } + }) } #[cfg(test)] mod tests { use super::*; + use crate::dialect::TestDialect; + + fn text_column() -> ColumnType { + ColumnType::untyped_text("text") + } #[test] fn in_clause_reports_the_values_it_skips() { - let rows = vec![ - vec![Value::Text("O'Brien".into())], - vec![Value::Null], - vec![Value::Int(5)], - vec![Value::Bool(true)], + let values = vec![ + Value::Text("O'Brien".into()), + Value::Null, + Value::Int(5), + Value::Bool(true), ]; - let out = render_in_clause(&rows, 0); + let out = render_in_clause(&TestDialect::default(), &text_column(), &values).expect("the list"); - assert_eq!(out.sql, "('O''Brien', 5, TRUE)"); + assert_eq!(out.sql, "('O''Brien', 5, true)"); assert_eq!(out.skipped, 1); } #[test] - fn in_clause_writes_a_real_as_the_double_it_compares_as() { - let rows = vec![vec![Value::Float32(0.1)]]; + fn in_clause_is_empty_rather_than_invalid_when_all_skipped() { + let values = vec![Value::Null, Value::Bytes(vec![1, 2])]; - let out = render_in_clause(&rows, 0); + let out = render_in_clause(&TestDialect::default(), &text_column(), &values).expect("the list"); - assert_eq!(out.sql, format!("({})", f64::from(0.1_f32))); + assert_eq!(out.sql, ""); + assert_eq!(out.skipped, 2); } #[test] - fn in_clause_is_empty_rather_than_invalid_when_all_skipped() { - let rows = vec![vec![Value::Null], vec![Value::Bytes(vec![1, 2])]]; + fn in_clause_fails_on_a_value_the_engine_cannot_write() { + let values = vec![Value::Int(1), Value::Array(vec![Value::Int(2)])]; - let out = render_in_clause(&rows, 0); + let refused = render_in_clause(&TestDialect::default(), &text_column(), &values); - assert_eq!(out.sql, ""); - assert_eq!(out.skipped, 2); + assert!( + matches!(refused, Err(LiteralError::Unrepresentable { .. })), + "{refused:?}" + ); } } diff --git a/linux/crates/core/src/export/json.rs b/linux/crates/core/src/export/json.rs index 81585d0424..f2bcb8b417 100644 --- a/linux/crates/core/src/export/json.rs +++ b/linux/crates/core/src/export/json.rs @@ -5,6 +5,7 @@ use crate::column::{ColumnKind, ResultColumn}; use crate::value::Value; use super::column_type; +use super::encoder::{EncodeError, JsonArrayEncoder, encode_rows}; use super::json_field_names::json_field_names; use super::value_text::{element_kind, kind_text}; @@ -41,9 +42,9 @@ impl Serialize for JsonRow<'_> { /// Numbers go out as the digits the server sent rather than through a /// float: `18446744073709551615` and `1.0000000000` both lose their /// value on the way through one. -struct JsonCell<'a> { - value: &'a Value, - kind: &'a ColumnKind, +pub(super) struct JsonCell<'a> { + pub(super) value: &'a Value, + pub(super) kind: &'a ColumnKind, } impl Serialize for JsonCell<'_> { @@ -113,17 +114,8 @@ pub fn row_to_json(columns: &[ResultColumn], row: &[Value]) -> String { serde_json::to_string_pretty(&value).unwrap_or_else(|_| "{}".to_owned()) } -pub fn render_json(columns: &[ResultColumn], rows: &[Vec]) -> String { - let names = json_field_names(columns); - let values: Vec> = rows - .iter() - .map(|row| JsonRow { - names: &names, - columns, - row, - }) - .collect(); - serde_json::to_string_pretty(&values).unwrap_or_else(|_| "[]".to_owned()) +pub fn render_json(columns: &[ResultColumn], rows: &[Vec]) -> Result { + encode_rows(JsonArrayEncoder::new(columns.into()), rows) } #[cfg(test)] @@ -326,6 +318,6 @@ mod tests { fn render_json_empty_rows_is_empty_array() { let columns = cols(&["a"]); - assert_eq!(render_json(&columns, &[]), "[]"); + assert_eq!(render_json(&columns, &[]).expect("the array"), "[]"); } } diff --git a/linux/crates/core/src/export/markdown.rs b/linux/crates/core/src/export/markdown.rs index b97c7f0cf1..b4adff2e0b 100644 --- a/linux/crates/core/src/export/markdown.rs +++ b/linux/crates/core/src/export/markdown.rs @@ -1,36 +1,23 @@ use crate::column::{ColumnType, ResultColumn}; use crate::value::Value; -use super::column_type; +use super::encoder::{EncodeError, MarkdownEncoder, encode_rows}; use super::value_text::value_text; -fn markdown_cell(value: &Value, column: &ColumnType) -> String { +pub(super) fn markdown_cell(value: &Value, column: &ColumnType) -> String { markdown_text(&value_text(value, column).unwrap_or_else(|| "NULL".to_string())) } /// A pipe or a line break, in a cell or a column name alike, would end /// the cell or the row early. -fn markdown_text(text: &str) -> String { +pub(super) fn markdown_text(text: &str) -> String { text.replace('|', "\\|") .replace("\r\n", "
") .replace(['\r', '\n'], "
") } -pub fn render_markdown(columns: &[ResultColumn], rows: &[Vec]) -> String { - let mut lines: Vec = Vec::new(); - let header: Vec = columns.iter().map(|c| markdown_text(&c.name)).collect(); - lines.push(format!("| {} |", header.join(" | "))); - let separator: Vec<&str> = columns.iter().map(|_| "---").collect(); - lines.push(format!("| {} |", separator.join(" | "))); - for row in rows { - let cells: Vec = row - .iter() - .enumerate() - .map(|(index, value)| markdown_cell(value, column_type(columns, index))) - .collect(); - lines.push(format!("| {} |", cells.join(" | "))); - } - lines.join("\n") +pub fn render_markdown(columns: &[ResultColumn], rows: &[Vec]) -> Result { + encode_rows(MarkdownEncoder::new(columns.into()), rows) } #[cfg(test)] @@ -43,7 +30,7 @@ mod tests { let columns = cols(&["a"]); let rows = vec![vec![Value::Text("has|pipe\nand newline".into())]]; - let out = render_markdown(&columns, &rows); + let out = render_markdown(&columns, &rows).expect("the table"); assert_eq!(out, "| a |\n| --- |\n| has\\|pipe
and newline |"); } @@ -53,7 +40,7 @@ mod tests { let columns = cols(&["a|b", "c"]); let rows = vec![vec![Value::Int(1), Value::Int(2)]]; - let out = render_markdown(&columns, &rows); + let out = render_markdown(&columns, &rows).expect("the table"); assert_eq!(out, "| a\\|b | c |\n| --- | --- |\n| 1 | 2 |"); } diff --git a/linux/crates/core/src/export/mod.rs b/linux/crates/core/src/export/mod.rs index 05b46ad821..3079cde8aa 100644 --- a/linux/crates/core/src/export/mod.rs +++ b/linux/crates/core/src/export/mod.rs @@ -1,5 +1,6 @@ mod csv; mod csv_options; +mod encoder; mod html; mod in_clause; mod json; @@ -19,6 +20,10 @@ use crate::value::Value; pub use csv::{FORMULA_LEADS, csv_writer_builder, neutralise_formula, render_csv, render_text_csv}; pub use csv_options::{CsvDecimal, CsvDelimiter, CsvLineBreak, CsvOptions, CsvQuote}; +pub use encoder::{ + CsvEncoder, EncodeError, HtmlEncoder, JsonArrayEncoder, MarkdownEncoder, RowEncoder, SqlInsertEncoder, TsvEncoder, + XmlEncoder, +}; pub use html::render_html; pub use in_clause::{InClause, render_in_clause}; pub use json::{render_json, row_to_json}; @@ -28,11 +33,13 @@ pub use render_insert_error::RenderInsertError; pub use sql::{literal, render_insert, render_sql_insert}; pub use tsv::{render_tsv, tsv_writer_builder}; pub use value_text::{value_text, value_to_text}; -pub use xlsx::render_xlsx; +pub use xlsx::{XLSX_MAX_DATA_ROWS, XlsxSheetWriter, render_xlsx}; pub use xml::render_xml; +/// Why a whole document could not be rendered: a workbook past what a +/// sheet holds, or free-text CSV the writer refused. #[derive(Debug, Error)] -pub enum EncodeError { +pub enum RenderError { #[error("CSV encoding failed: {0}")] Csv(#[from] ::csv::Error), @@ -68,10 +75,10 @@ fn column_type(columns: &[ResultColumn], index: usize) -> &ColumnType { .unwrap_or_else(|| &UNTYPED) } -fn finish(writer: ::csv::Writer>) -> Result { +fn finish(writer: ::csv::Writer>) -> Result { let bytes = writer .into_inner() - .map_err(|error| EncodeError::Flush(error.into_error()))?; + .map_err(|error| RenderError::Flush(error.into_error()))?; Ok(String::from_utf8(bytes)?) } diff --git a/linux/crates/core/src/export/sql.rs b/linux/crates/core/src/export/sql.rs index d7acf85f7e..aa631e2602 100644 --- a/linux/crates/core/src/export/sql.rs +++ b/linux/crates/core/src/export/sql.rs @@ -22,34 +22,55 @@ pub fn render_sql_insert( columns: &[ResultColumn], rows: &[Vec], ) -> String { - if columns.is_empty() { + let Some(start) = insert_start(dialect, table, columns) else { return String::new(); + }; + let mut out = String::with_capacity(rows.len() * columns.len() * 12); + for row in rows { + insert_row(&mut out, dialect, &start, columns, row); + } + out +} + +/// `INSERT INTO table (columns) VALUES `, which every row's statement +/// starts with. `None` for a result with no columns, which has no +/// statement to write. +pub(super) fn insert_start(dialect: &dyn SqlDialect, table: &str, columns: &[ResultColumn]) -> Option { + if columns.is_empty() { + return None; } - let qualified = dialect.quote_identifier(table); let names: Vec = columns .iter() .map(|column| dialect.quote_identifier(&column.name)) .collect(); - let names = names.join(", "); + Some(format!( + "INSERT INTO {} ({}) VALUES ", + dialect.quote_identifier(table), + names.join(", ") + )) +} - let mut out = String::with_capacity(rows.len() * columns.len() * 12); - for row in rows { - let values: Vec = columns - .iter() - .enumerate() - .map(|(index, column)| match row.get(index) { - Some(value) => literal(dialect, value, column), - // A row shorter than its header: the cell is absent, - // which is as close to unknown as SQL has. - None => "NULL".to_owned(), - }) - .collect(); - out.push_str(&format!( - "INSERT INTO {qualified} ({names}) VALUES ({});\n", - values.join(", ") - )); - } - out +pub(super) fn insert_row<'a>( + out: &mut String, + dialect: &dyn SqlDialect, + start: &str, + columns: &[ResultColumn], + row: impl IntoIterator, +) { + let mut cells = row.into_iter(); + let values: Vec = columns + .iter() + .map(|column| match cells.next() { + Some(value) => literal(dialect, value, column), + // A row shorter than its header: the cell is absent, which + // is as close to unknown as SQL has. + None => "NULL".to_owned(), + }) + .collect(); + out.push_str(start); + out.push('('); + out.push_str(&values.join(", ")); + out.push_str(");\n"); } /// One row as an `INSERT` the user can paste and run. diff --git a/linux/crates/core/src/export/tsv.rs b/linux/crates/core/src/export/tsv.rs index a69720f472..d20c685163 100644 --- a/linux/crates/core/src/export/tsv.rs +++ b/linux/crates/core/src/export/tsv.rs @@ -1,8 +1,7 @@ use crate::column::ResultColumn; use crate::value::Value; -use super::value_text::value_text; -use super::{EncodeError, column_type, finish}; +use super::encoder::{EncodeError, TsvEncoder, encode_rows}; /// A tab, a line break or a quote inside a value would move the following /// text into the next column or the next row, so such a value is quoted @@ -19,16 +18,7 @@ pub fn tsv_writer_builder() -> csv::WriterBuilder { } pub fn render_tsv(columns: &[ResultColumn], rows: &[Vec], with_headers: bool) -> Result { - let mut writer = tsv_writer_builder().from_writer(Vec::new()); - if with_headers { - writer.write_record(columns.iter().map(|column| column.name.as_str()))?; - } - for row in rows { - writer.write_record(row.iter().enumerate().map(|(index, value)| { - value_text(value, column_type(columns, index)).unwrap_or_else(|| "NULL".to_string()) - }))?; - } - finish(writer) + encode_rows(TsvEncoder::new(columns.into(), with_headers), rows) } #[cfg(test)] @@ -61,6 +51,22 @@ mod tests { assert_eq!(render_tsv(&columns, &rows, false).unwrap(), "\"say \"\"hi\"\"\"\n"); } + /// A spreadsheet runs a pasted cell that starts like a formula. + #[test] + fn tsv_neutralises_a_formula_in_free_text() { + let columns = cols(&["=sum", "b", "c"]); + let rows = vec![vec![ + Value::Text("=cmd|' /C calc'!A0".into()), + Value::Int(-5), + Value::Text("-5".into()), + ]]; + + assert_eq!( + render_tsv(&columns, &rows, true).unwrap(), + "'=sum\tb\tc\n'=cmd|' /C calc'!A0\t-5\t-5\n" + ); + } + #[test] fn tsv_leaves_ordinary_values_bare() { let columns = cols(&["a"]); diff --git a/linux/crates/core/src/export/xlsx.rs b/linux/crates/core/src/export/xlsx.rs index 8dac182854..6b4a3a1ce4 100644 --- a/linux/crates/core/src/export/xlsx.rs +++ b/linux/crates/core/src/export/xlsx.rs @@ -1,6 +1,8 @@ +use std::sync::Arc; + use rust_xlsxwriter::{Format, Workbook, Worksheet}; -use super::{EncodeError, cell_text}; +use super::{RenderError, cell_text}; use crate::column::ResultColumn; use crate::value::{Temporal, Value}; @@ -9,53 +11,113 @@ use crate::value::{Temporal, Value}; const MAX_ROWS: usize = 1_048_576; const MAX_COLUMNS: usize = 16_384; +/// The rows a sheet holds under its header row. +pub const XLSX_MAX_DATA_ROWS: usize = MAX_ROWS - 1; + +/// A whole result as one workbook. +pub fn render_xlsx(columns: &[ResultColumn], rows: &[Vec], sheet_name: &str) -> Result, RenderError> { + if rows.len() > XLSX_MAX_DATA_ROWS { + return Err(RenderError::TooManyRows { + got: rows.len(), + limit: XLSX_MAX_DATA_ROWS, + }); + } + let mut writer = XlsxSheetWriter::new(columns.into(), sheet_name)?; + writer.write_rows(rows)?; + writer.finish() +} + +/// A one-sheet workbook written a batch of rows at a time. +/// +/// The sheet is in rust_xlsxwriter's constant memory mode: it keeps the +/// row it is writing and spills the rows before it to a temporary file, +/// so an export of a million rows holds none of them. Only the finished +/// workbook, compressed, is held at the end, since a zip archive is +/// written whole. +/// /// The whole point of XLSX over CSV is that a number arrives as a /// number, so each value is written in the type Excel has for it and /// dates carry a display format rather than a serial number. /// /// A string cell is never read as a formula, whatever it starts with, /// so unlike CSV this needs no formula neutralising. -pub fn render_xlsx(columns: &[ResultColumn], rows: &[Vec], sheet_name: &str) -> Result, EncodeError> { - // The header takes a row, so the data has one fewer to fill. - if rows.len() + 1 > MAX_ROWS { - return Err(EncodeError::TooManyRows { - got: rows.len(), - limit: MAX_ROWS - 1, - }); - } - if columns.len() > MAX_COLUMNS { - return Err(EncodeError::TooManyColumns { - got: columns.len(), - limit: MAX_COLUMNS, - }); - } +pub struct XlsxSheetWriter { + workbook: Workbook, + columns: Arc<[ResultColumn]>, + rows: usize, + date: Format, + timestamp: Format, +} - let mut workbook = Workbook::new(); - let sheet = workbook.add_worksheet(); - sheet.set_name(sheet_title(sheet_name)).map_err(xlsx_error)?; +impl XlsxSheetWriter { + pub fn new(columns: Arc<[ResultColumn]>, sheet_name: &str) -> Result { + if columns.len() > MAX_COLUMNS { + return Err(RenderError::TooManyColumns { + got: columns.len(), + limit: MAX_COLUMNS, + }); + } + let mut workbook = Workbook::new(); + let sheet = workbook.add_worksheet_with_constant_memory(); + sheet.set_name(sheet_title(sheet_name)).map_err(xlsx_error)?; + let header = Format::new().set_bold(); + for (index, column) in columns.iter().enumerate() { + sheet + .write_string_with_format(0, index as u16, &column.name, &header) + .map_err(xlsx_error)?; + } + // A header that scrolls away is a sheet of unlabelled numbers. + sheet.set_freeze_panes(1, 0).map_err(xlsx_error)?; + Ok(Self { + workbook, + columns, + rows: 0, + date: Format::new().set_num_format("yyyy-mm-dd"), + timestamp: Format::new().set_num_format("yyyy-mm-dd hh:mm:ss"), + }) + } - let header = Format::new().set_bold(); - for (index, column) in columns.iter().enumerate() { - sheet - .write_string_with_format(0, index as u16, &column.name, &header) - .map_err(xlsx_error)?; + /// The data rows written so far. + pub fn rows(&self) -> usize { + self.rows } - // A header that scrolls away is a sheet of unlabelled numbers. - sheet.set_freeze_panes(1, 0).map_err(xlsx_error)?; - - let date = Format::new().set_num_format("yyyy-mm-dd"); - let timestamp = Format::new().set_num_format("yyyy-mm-dd hh:mm:ss"); - for (row_index, row) in rows.iter().enumerate() { - let sheet_row = (row_index + 1) as u32; - for (column_index, value) in row.iter().enumerate() { - if column_index >= MAX_COLUMNS { - break; + + /// Write `rows` under the ones already written. A sheet past its + /// last row cannot be opened, so the batch that would fill it past + /// that is refused whole. + pub fn write_rows(&mut self, rows: &[Vec]) -> Result<(), RenderError> { + let got = self.rows + rows.len(); + if got > XLSX_MAX_DATA_ROWS { + return Err(RenderError::TooManyRows { + got, + limit: XLSX_MAX_DATA_ROWS, + }); + } + let Self { + workbook, + columns, + rows: written, + date, + timestamp, + } = self; + let sheet = workbook.worksheet_from_index(0).map_err(xlsx_error)?; + for row in rows { + // The header takes the first row. + let sheet_row = (*written + 1) as u32; + for (column_index, value) in row.iter().enumerate() { + if column_index >= MAX_COLUMNS { + break; + } + write_cell(sheet, sheet_row, column_index as u16, columns, value, date, timestamp)?; } - write_cell(sheet, sheet_row, column_index as u16, columns, value, &date, ×tamp)?; + *written += 1; } + Ok(()) } - workbook.save_to_buffer().map_err(xlsx_error) + pub fn finish(mut self) -> Result, RenderError> { + self.workbook.save_to_buffer().map_err(xlsx_error) + } } fn write_cell( @@ -66,7 +128,7 @@ fn write_cell( value: &Value, date: &Format, timestamp: &Format, -) -> Result<(), EncodeError> { +) -> Result<(), RenderError> { match value { // A blank cell, not an empty string: Excel's COUNT and AVERAGE // read the two differently, and NULL is the absent one. @@ -165,8 +227,8 @@ fn sheet_title(name: &str) -> String { } } -fn xlsx_error(error: rust_xlsxwriter::XlsxError) -> EncodeError { - EncodeError::Xlsx(error.to_string()) +fn xlsx_error(error: rust_xlsxwriter::XlsxError) -> RenderError { + RenderError::Xlsx(error.to_string()) } #[cfg(test)] @@ -276,7 +338,54 @@ mod tests { let error = render_xlsx(&columns, &[], "Results").expect_err("too wide"); - assert!(matches!(error, EncodeError::TooManyColumns { .. }), "{error:?}"); + assert!(matches!(error, RenderError::TooManyColumns { .. }), "{error:?}"); + } + + fn sheet_xml(bytes: &[u8]) -> String { + use std::io::Read; + + let mut archive = zip::ZipArchive::new(std::io::Cursor::new(bytes)).expect("a zip archive"); + let mut sheet = archive.by_name("xl/worksheets/sheet1.xml").expect("the sheet"); + let mut xml = String::new(); + sheet.read_to_string(&mut xml).expect("the sheet's XML"); + xml + } + + /// Rows go into the sheet a batch at a time, as an export reads + /// them, and every one of them lands under the header. + #[test] + fn a_sheet_written_in_batches_holds_every_row() { + let columns: Arc<[ResultColumn]> = cols(&["id", "name"]).into(); + let batch = |first: i64| -> Vec> { + (first..first + 1_000) + .map(|id| vec![Value::Int(id), Value::Text(format!("name {id}"))]) + .collect() + }; + let mut writer = XlsxSheetWriter::new(columns, "Results").expect("the writer"); + + writer.write_rows(&batch(0)).expect("the first batch"); + writer.write_rows(&batch(1_000)).expect("the second batch"); + assert_eq!(writer.rows(), 2_000); + let xml = sheet_xml(&writer.finish().expect("the workbook")); + + assert_eq!(xml.matches("]) -> String { let mut out = String::with_capacity(rows.len() * columns.len() * 24); - out.push_str("\n\n"); + out.push_str(XML_HEAD); for row in rows { - out.push_str(" \n"); - for (index, value) in row.iter().enumerate() { - let name = columns - .get(index) - .map(|column| column.name.as_str()) - .unwrap_or_default(); - out.push_str(" { - out.push_str("\">"); - escape_into(&mut out, &text); - out.push_str("\n"); - } - // An empty element with the flag, so a NULL reads - // back as one rather than as an empty string. - None => out.push_str("\" null=\"true\"/>\n"), + xml_row(&mut out, columns, row); + } + out.push_str(XML_TAIL); + out +} + +pub(super) const XML_HEAD: &str = "\n\n"; + +pub(super) const XML_TAIL: &str = "\n"; + +pub(super) fn xml_row<'a>(out: &mut String, columns: &[ResultColumn], row: impl IntoIterator) { + out.push_str(" \n"); + for (index, value) in row.into_iter().enumerate() { + let name = columns + .get(index) + .map(|column| column.name.as_str()) + .unwrap_or_default(); + out.push_str(" { + out.push_str("\">"); + escape_into(out, &text); + out.push_str("\n"); } + // An empty element with the flag, so a NULL reads back as + // one rather than as an empty string. + None => out.push_str("\" null=\"true\"/>\n"), } - out.push_str(" \n"); } - out.push_str("\n"); - out + out.push_str(" \n"); } /// Escape for both text and attribute positions, and drop what XML 1.0 diff --git a/linux/crates/core/src/value/bit_string.rs b/linux/crates/core/src/value/bit_string.rs index d612ebdbc8..0643dc8ed8 100644 --- a/linux/crates/core/src/value/bit_string.rs +++ b/linux/crates/core/src/value/bit_string.rs @@ -61,6 +61,11 @@ impl BitString { self.bit_len } + /// The bits packed high bit first, the last byte padded with zeros. + pub fn as_bytes(&self) -> &[u8] { + &self.bytes + } + fn bit(&self, position: u32) -> u8 { self.bytes .get((position / 8) as usize) diff --git a/linux/crates/drivers/clickhouse/src/dialect.rs b/linux/crates/drivers/clickhouse/src/dialect.rs index 2b77d439b8..83d57f6126 100644 --- a/linux/crates/drivers/clickhouse/src/dialect.rs +++ b/linux/crates/drivers/clickhouse/src/dialect.rs @@ -514,6 +514,21 @@ mod tests { assert_eq!(placeholder.sql, "{p4:String}"); } + /// ClickHouse reads a backslash in a string as an escape, so a value + /// that ends in one would close the string early. + #[test] + fn an_in_clause_keeps_a_backslash_inside_its_value() { + let values = [ + Value::Text("\\') OR 1=1 -- ".to_owned()), + Value::Text("plain".to_owned()), + ]; + + let clause = tablepro_core::export::render_in_clause(&dialect(), &ColumnType::untyped_text("String"), &values) + .expect("the list"); + + assert_eq!(clause.sql, r"('\\\') OR 1=1 -- ', 'plain')"); + } + #[test] fn literals_escape_and_fall_back_to_hex() { assert_eq!( diff --git a/linux/crates/drivers/clickhouse/tests/integration/keys_and_decimals.rs b/linux/crates/drivers/clickhouse/tests/integration/keys_and_decimals.rs index d8179ae8d8..0455eea87f 100644 --- a/linux/crates/drivers/clickhouse/tests/integration/keys_and_decimals.rs +++ b/linux/crates/drivers/clickhouse/tests/integration/keys_and_decimals.rs @@ -3,9 +3,9 @@ use tablepro_core::save::{ChangeSet, RowChange, RowUpdate}; use tablepro_core::value::{SqlDecimal, Temporal}; -use tablepro_core::{Connection, Value}; +use tablepro_core::{Connection, RowLimit, Value}; -use crate::support::{CALL, connect, count, planned, quoted, rows, setup, start, table, unique, within}; +use crate::support::{CALL, connect, count, planned, quoted, rows, setup, start, statement, table, unique, within}; async fn edit(connection: &dyn Connection, name: &str, key: Vec, column: usize, value: Value) { let changes = ChangeSet { @@ -194,3 +194,44 @@ async fn invalid_utf8_string_key_row_editable_via_unhex() { assert_eq!((edited, untouched), (1, 1)); connection.close().await.expect("a close"); } + +/// Whether `text` holds the JSON key `b` before the key `a`. +fn b_before_a(text: &str) -> bool { + match (text.find("\"b\""), text.find("\"a\"")) { + (Some(b), Some(a)) => b < a, + _ => false, + } +} + +/// A map and a named tuple keep the order the server wrote their keys +/// in, through the driver and through a JSON export, which a sorted +/// JSON map would lose. +#[tokio::test] +#[ignore = "requires docker"] +async fn map_and_tuple_keep_server_key_order() { + let fixture = start().await; + let connection = connect(&fixture, false).await; + + let result = connection + .query_bound( + &statement("SELECT map('b', 1, 'a', 2) AS m, CAST((1, 2) AS Tuple(b UInt8, a UInt8)) AS t"), + RowLimit::EDITOR_DEFAULT, + within(CALL), + ) + .await + .expect("a read"); + + let row = result.rows.first().expect("one row"); + assert_eq!(row.len(), 2); + for value in row { + let text = tablepro_core::export::value_to_text(value).expect("the value's text"); + assert!(b_before_a(&text), "the keys were reordered: {text}"); + } + let json = tablepro_core::export::render_json(&result.columns, &result.rows).expect("the export"); + let exported: serde_json::Value = serde_json::from_str(&json).expect("the export is JSON"); + for column in ["m", "t"] { + let text = exported[0][column].as_str().unwrap_or_default(); + assert!(b_before_a(text), "the export reordered {column}: {json}"); + } + connection.close().await.expect("a close"); +} diff --git a/linux/crates/drivers/mysql/src/dialect.rs b/linux/crates/drivers/mysql/src/dialect.rs index 26751e1189..262c11741f 100644 --- a/linux/crates/drivers/mysql/src/dialect.rs +++ b/linux/crates/drivers/mysql/src/dialect.rs @@ -415,6 +415,33 @@ mod tests { MySqlDialect::new(flavour, &ServerInfo::new(flavour.product(), "8.4.3", 8, 4, 3)) } + /// MySQL and MariaDB read a backslash in a string as an escape + /// unless NO_BACKSLASH_ESCAPES is set, so a value that ends in one + /// would close the string early. + #[test] + fn an_in_clause_keeps_a_backslash_inside_its_value() { + let values = [ + Value::Text("\\') OR 1=1 -- ".to_owned()), + Value::Text("plain".to_owned()), + Value::Float32(0.1), + ]; + + for flavour in [MySqlFlavour::MySql, MySqlFlavour::MariaDb] { + let clause = + tablepro_core::export::render_in_clause(&dialect(flavour), &ColumnType::untyped_text("text"), &values) + .expect("the list"); + + assert_eq!( + clause.sql, + format!( + "(_utf8mb4 X'{}', 'plain', {})", + tablepro_core::hex::encode_lower("\\') OR 1=1 -- ".as_bytes()), + f64::from(0.1_f32) + ) + ); + } + } + #[test] fn an_sql_export_quotes_with_backticks() { let columns = [tablepro_core::column::ResultColumn::new( diff --git a/linux/crates/session/src/operation/default_deadlines.rs b/linux/crates/session/src/operation/default_deadlines.rs index 71920d3c32..e185b4c062 100644 --- a/linux/crates/session/src/operation/default_deadlines.rs +++ b/linux/crates/session/src/operation/default_deadlines.rs @@ -26,7 +26,7 @@ impl DeadlinePolicy for DefaultDeadlines { OperationKind::Metadata => Some(Self::METADATA), OperationKind::Page | OperationKind::Count => Some(Self::PAGE), OperationKind::Save(WriteKind::RowChanges) => Some(Self::ROW_CHANGES), - OperationKind::Save(WriteKind::SchemaChanges) => None, + OperationKind::Save(WriteKind::SchemaChanges) | OperationKind::Export => None, OperationKind::Probe => Some(self.liveness.probe_timeout), OperationKind::Close => Some(self.liveness.close_timeout), } @@ -50,4 +50,11 @@ mod tests { assert_eq!(seconds(OperationKind::Probe), Some(5)); assert_eq!(seconds(OperationKind::Close), Some(10)); } + + #[test] + fn an_export_has_no_client_deadline() { + let deadlines = DefaultDeadlines::new(LivenessPolicy::DESKTOP); + + assert_eq!(deadlines.deadline(OperationKind::Export), None); + } } diff --git a/linux/crates/session/src/operation/operation_kind.rs b/linux/crates/session/src/operation/operation_kind.rs index 3a03760940..3a98893bfa 100644 --- a/linux/crates/session/src/operation/operation_kind.rs +++ b/linux/crates/session/src/operation/operation_kind.rs @@ -10,6 +10,10 @@ pub enum OperationKind { Save(WriteKind), Probe, Close, + /// Every row of a read streamed to a file, on a connection of its + /// own. It runs as long as the rows keep coming, and a second run + /// would write the file twice. + Export, } impl OperationKind { diff --git a/linux/crates/session/src/operation/replay_rule.rs b/linux/crates/session/src/operation/replay_rule.rs index 85293f3fd1..446611626d 100644 --- a/linux/crates/session/src/operation/replay_rule.rs +++ b/linux/crates/session/src/operation/replay_rule.rs @@ -67,6 +67,7 @@ mod tests { (OperationKind::Save(WriteKind::SchemaChanges), false), (OperationKind::Probe, false), (OperationKind::Close, false), + (OperationKind::Export, false), ]; let categories = [ (ErrorCategory::ConnectionLost, true), diff --git a/linux/crates/storage/migrations/20261014000000_history_substring_search.sql b/linux/crates/storage/migrations/20261014000000_history_substring_search.sql new file mode 100644 index 0000000000..35dcdd5de4 --- /dev/null +++ b/linux/crates/storage/migrations/20261014000000_history_substring_search.sql @@ -0,0 +1,39 @@ +-- Search by substring: a trigram index over the query folded to lower +-- case replaces the word index. The fold follows Unicode rules, which +-- SQLite's lower() does not, so the app writes it. Rows from before this +-- migration start with NULL and QueryHistory::open folds them. +ALTER TABLE history ADD COLUMN query_folded TEXT; + +DROP TRIGGER IF EXISTS history_ai; +DROP TRIGGER IF EXISTS history_ad; +DROP TRIGGER IF EXISTS history_au; +DROP TABLE IF EXISTS history_fts; + +CREATE VIRTUAL TABLE history_search USING fts5( + query_folded, + content='history', + content_rowid='id', + tokenize='trigram case_sensitive 1' +); + +-- Index the NULLs now, so the update trigger's delete of each one +-- removes a row the index holds. +INSERT INTO history_search(history_search) VALUES('rebuild'); + +CREATE TRIGGER history_search_ai AFTER INSERT ON history BEGIN + INSERT INTO history_search(rowid, query_folded) VALUES (new.id, new.query_folded); +END; + +CREATE TRIGGER history_search_ad AFTER DELETE ON history BEGIN + INSERT INTO history_search(history_search, rowid, query_folded) VALUES('delete', old.id, old.query_folded); +END; + +CREATE TRIGGER history_search_au AFTER UPDATE OF query_folded ON history BEGIN + INSERT INTO history_search(history_search, rowid, query_folded) VALUES('delete', old.id, old.query_folded); + INSERT INTO history_search(rowid, query_folded) VALUES (new.id, new.query_folded); +END; + +DROP INDEX IF EXISTS history_pinned_idx; +CREATE INDEX history_page_idx ON history (pinned DESC, executed_at DESC, id DESC); +-- Empty once the rows are folded, so finding none left costs nothing. +CREATE INDEX history_unfolded_idx ON history (id) WHERE query_folded IS NULL; diff --git a/linux/crates/storage/src/error.rs b/linux/crates/storage/src/error.rs index f4d0bf4367..5ae6f29443 100644 --- a/linux/crates/storage/src/error.rs +++ b/linux/crates/storage/src/error.rs @@ -45,7 +45,7 @@ pub enum StorageError { EmptyName, #[error("export encoding failed: {0}")] - Encode(#[from] tablepro_core::export::EncodeError), + Encode(#[from] tablepro_core::export::RenderError), #[error("settings error: {0}")] Settings(#[from] crate::settings::SettingsError), diff --git a/linux/crates/storage/src/query_history/entry.rs b/linux/crates/storage/src/query_history/entry.rs index bcb491cc36..ddec18ab30 100644 --- a/linux/crates/storage/src/query_history/entry.rs +++ b/linux/crates/storage/src/query_history/entry.rs @@ -2,7 +2,7 @@ use std::time::SystemTime; use uuid::Uuid; -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq, Eq)] pub struct Entry { pub id: i64, pub query: String, diff --git a/linux/crates/storage/src/query_history/export.rs b/linux/crates/storage/src/query_history/export.rs index 85bd9d2ba6..b9d9bacdcc 100644 --- a/linux/crates/storage/src/query_history/export.rs +++ b/linux/crates/storage/src/query_history/export.rs @@ -44,7 +44,51 @@ fn flag(value: bool) -> String { if value { "1" } else { "0" }.to_string() } -pub(super) fn outcome_summary(entry: &Entry) -> String { +/// The entries as a script: each query as it ran, under `--` comments +/// that say where and how. +pub(super) fn history_sql(entries: &[Entry], generated_at: chrono::DateTime) -> String { + let mut out = String::new(); + out.push_str("-- TablePro query history export\n"); + out.push_str(&format!("-- Generated at {}\n", generated_at.to_rfc3339())); + out.push_str(&format!("-- Entries: {}\n\n", entries.len())); + for entry in entries { + let when = chrono::DateTime::::from(entry.executed_at).to_rfc3339(); + out.push_str(&format!( + "-- [{}] {} · {} · {}\n", + when, + comment_line(&entry.connection_name), + comment_line(&entry.driver_id), + outcome_summary(entry), + )); + if let Some(err) = &entry.error { + for line in comment_lines(err) { + out.push_str("-- error: "); + out.push_str(line); + out.push('\n'); + } + } + out.push_str(entry.query.trim_end()); + if !entry.query.trim_end().ends_with(';') { + out.push(';'); + } + out.push_str("\n\n"); + } + out +} + +/// A `--` comment ends at a carriage return as well as a line feed in +/// PostgreSQL, so text that holds either would run what follows it. +const LINE_ENDS: [char; 2] = ['\r', '\n']; + +fn comment_line(text: &str) -> String { + text.replace(LINE_ENDS, " ") +} + +fn comment_lines(text: &str) -> impl Iterator { + text.split(LINE_ENDS).filter(|line| !line.is_empty()) +} + +fn outcome_summary(entry: &Entry) -> String { if entry.cancelled { "cancelled".into() } else if entry.success { @@ -58,3 +102,57 @@ pub(super) fn outcome_summary(entry: &Entry) -> String { "error".into() } } + +#[cfg(test)] +mod tests { + use std::time::SystemTime; + + use uuid::Uuid; + + use super::*; + + fn failed(connection_name: &str, error: &str) -> Entry { + Entry { + id: 1, + query: "SELECT 1".to_owned(), + driver_id: "postgres".to_owned(), + connection_id: Uuid::nil(), + connection_name: connection_name.to_owned(), + executed_at: SystemTime::UNIX_EPOCH, + duration_ms: Some(3), + rows_affected: None, + success: false, + cancelled: false, + pinned: false, + error: Some(error.to_owned()), + } + } + + /// Every line PostgreSQL would read, ending at either break, is a + /// comment or the query itself. + #[test] + fn a_line_break_in_a_name_or_an_error_stays_inside_its_comment() { + let entry = failed( + "prod\rDROP TABLE users;\nDROP TABLE orders;", + "denied\rDROP TABLE users;\r\nhint: ask\n", + ); + + let script = history_sql(&[entry], chrono::DateTime::UNIX_EPOCH); + + let code: Vec<&str> = script + .split(LINE_ENDS) + .filter(|line| !line.is_empty() && !line.starts_with("--")) + .collect(); + assert_eq!(code, ["SELECT 1;"], "{script}"); + assert!( + script.contains( + "-- [1970-01-01T00:00:00+00:00] prod DROP TABLE users; DROP TABLE orders; · postgres · error\n" + ), + "{script}" + ); + assert!( + script.contains("-- error: denied\n-- error: DROP TABLE users;\n-- error: hint: ask\n"), + "{script}" + ); + } +} diff --git a/linux/crates/storage/src/query_history/history_connection.rs b/linux/crates/storage/src/query_history/history_connection.rs new file mode 100644 index 0000000000..30f709fc00 --- /dev/null +++ b/linux/crates/storage/src/query_history/history_connection.rs @@ -0,0 +1,9 @@ +use uuid::Uuid; + +/// A connection the history holds entries for. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HistoryConnection { + pub id: Uuid, + /// The name it had when it last ran a query. + pub name: String, +} diff --git a/linux/crates/storage/src/query_history/history_cursor.rs b/linux/crates/storage/src/query_history/history_cursor.rs new file mode 100644 index 0000000000..2df93d5c11 --- /dev/null +++ b/linux/crates/storage/src/query_history/history_cursor.rs @@ -0,0 +1,26 @@ +use crate::unix_time::to_unix; + +use super::Entry; + +/// Where the next page starts: just after the last entry shown, in the +/// order pinned first, then newest, then highest id. The id breaks the +/// tie between runs stamped in the same second. +/// +/// Cursors compare as the page query compares rows, so an entry whose +/// cursor is below a page's `next` belongs to a later page. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub struct HistoryCursor { + pub(super) pinned: bool, + pub(super) executed_at: i64, + pub(super) id: i64, +} + +impl HistoryCursor { + pub fn after(entry: &Entry) -> Self { + Self { + pinned: entry.pinned, + executed_at: to_unix(entry.executed_at), + id: entry.id, + } + } +} diff --git a/linux/crates/storage/src/query_history/history_page.rs b/linux/crates/storage/src/query_history/history_page.rs new file mode 100644 index 0000000000..54c1f8656f --- /dev/null +++ b/linux/crates/storage/src/query_history/history_page.rs @@ -0,0 +1,25 @@ +use super::{Entry, HistoryCursor}; + +/// The most entries one page returns, whatever the caller asks for. +pub const MAX_PAGE_SIZE: usize = 1_000; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct HistoryPageRequest { + /// `None` for the first page. + pub after: Option, + /// Clamped to 1 through `MAX_PAGE_SIZE`. + pub limit: usize, +} + +impl HistoryPageRequest { + pub fn first(limit: usize) -> Self { + Self { after: None, limit } + } +} + +#[derive(Debug, Clone, Default)] +pub struct HistoryPage { + pub entries: Vec, + /// Where the next page starts, or `None` when this was the last. + pub next: Option, +} diff --git a/linux/crates/storage/src/query_history/history_query.rs b/linux/crates/storage/src/query_history/history_query.rs new file mode 100644 index 0000000000..a01e975b07 --- /dev/null +++ b/linux/crates/storage/src/query_history/history_query.rs @@ -0,0 +1,83 @@ +use std::time::SystemTime; + +use uuid::Uuid; + +use super::StatusFilter; +use super::search_fold::fold; + +/// The trigram index has nothing to match a shorter term against. +const TRIGRAM_LENGTH: usize = 3; + +/// What the history is searched for. Every term has to appear in the +/// query, anywhere and in any case. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct HistoryQuery { + terms: Vec, + pub connection: Option, + pub status: StatusFilter, + pub executed_after: Option, +} + +/// One term as SQLite is asked for it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) enum TermMatch { + /// An FTS5 string: a quoted phrase in which every character is + /// literal, operators and quotes included. + Trigram(String), + /// Too short for the trigram index, so a scan of the folded text. + Substring(String), +} + +impl HistoryQuery { + /// The terms are the input's whitespace-separated words. + pub fn from_input(input: &str) -> Self { + Self { + terms: fold(input).split_whitespace().map(str::to_owned).collect(), + ..Self::default() + } + } + + pub fn terms(&self) -> &[String] { + &self.terms + } + + pub(super) fn term_matches(&self) -> impl Iterator + '_ { + self.terms.iter().map(|term| { + if term.chars().count() >= TRIGRAM_LENGTH { + TermMatch::Trigram(format!("\"{}\"", term.replace('"', "\"\""))) + } else { + TermMatch::Substring(term.clone()) + } + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn input_is_folded_and_split_on_whitespace() { + let query = HistoryQuery::from_input(" SELECT\tÉlèves \n x "); + + assert_eq!(query.terms(), ["select", "élèves", "x"]); + } + + #[test] + fn a_quote_in_a_long_term_is_doubled_inside_the_phrase() { + let query = HistoryQuery::from_input("say\"hi"); + + let matches: Vec = query.term_matches().collect(); + + assert_eq!(matches, vec![TermMatch::Trigram("\"say\"\"hi\"".to_owned())]); + } + + #[test] + fn length_is_counted_in_characters_not_bytes() { + let query = HistoryQuery::from_input("éé"); + + let matches: Vec = query.term_matches().collect(); + + assert_eq!(matches, vec![TermMatch::Substring("éé".to_owned())]); + } +} diff --git a/linux/crates/storage/src/query_history/mod.rs b/linux/crates/storage/src/query_history/mod.rs index 8e03c50455..fec605694f 100644 --- a/linux/crates/storage/src/query_history/mod.rs +++ b/linux/crates/storage/src/query_history/mod.rs @@ -1,10 +1,16 @@ mod entry; mod export; +mod history_connection; +mod history_cursor; +mod history_page; +mod history_query; mod new_entry; mod outcome; mod prune_report; pub mod retention; -mod search_filter; +mod schema; +mod search_fold; +mod status_filter; use std::path::{Path, PathBuf}; use std::time::SystemTime; @@ -18,12 +24,18 @@ use crate::paths::StoragePaths; use crate::unix_time::{from_unix, to_unix}; pub use entry::Entry; +pub use history_connection::HistoryConnection; +pub use history_cursor::HistoryCursor; +pub use history_page::{HistoryPage, HistoryPageRequest, MAX_PAGE_SIZE}; +pub use history_query::HistoryQuery; pub use new_entry::NewEntry; pub use outcome::Outcome; pub use prune_report::PruneReport; -pub use search_filter::SearchFilter; +pub use status_filter::StatusFilter; -use export::{history_csv, outcome_summary}; +use export::{history_csv, history_sql}; +use history_query::TermMatch; +use search_fold::fold; const MAX_QUERY_BYTES: usize = 1024 * 1024; @@ -32,9 +44,18 @@ const MAX_QUERY_BYTES: usize = 1024 * 1024; /// search slower for everyone. const MAX_UNPINNED_ENTRIES: i64 = 100_000; -/// An applied migration is never edited. Each schema change is a new -/// timestamped file with its own fixture test. -static MIGRATOR: sqlx::migrate::Migrator = sqlx::migrate!("./migrations"); +/// Ids per statement when a list of them is bound, well under SQLite's +/// limit on parameters. +const IDS_PER_STATEMENT: usize = 500; + +const ENTRY_COLUMNS: &str = "h.id, h.query, h.driver_id, h.connection_id, h.connection_name, h.executed_at, \ + h.duration_ms, h.rows_affected, h.success, h.cancelled, h.pinned, h.error"; + +/// A value for a placeholder of a statement built from parts. +enum Bind { + Text(String), + Integer(i64), +} /// The query-history database. Cloning shares the pool, so a clone per /// consumer is cheap and they all see the same rows. @@ -62,12 +83,7 @@ impl QueryHistory { .max_connections(1) .connect_with(options) .await?; - MIGRATOR.run(&pool).await.map_err(|error| match error { - // The database carries a version this build does not know, - // which means a newer binary created it. - sqlx::migrate::MigrateError::VersionMissing(version) => StorageError::HistoryNewerThanApp { version }, - other => StorageError::HistoryMigration(other), - })?; + schema::migrate(&pool).await?; Ok(Self { pool, path }) } @@ -98,14 +114,15 @@ impl QueryHistory { let id = sqlx::query( r#" INSERT INTO history ( - query, driver_id, connection_id, connection_name, + query, query_folded, driver_id, connection_id, connection_name, executed_at, duration_ms, rows_affected, success, cancelled, error ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) "#, ) .bind(&entry.query) + .bind(fold(&entry.query)) .bind(&entry.driver_id) .bind(entry.connection_id.to_string()) .bind(&entry.connection_name) @@ -121,65 +138,75 @@ impl QueryHistory { Ok(id) } - pub async fn search(&self, filter: SearchFilter) -> Result, StorageError> { - let limit_usize = if filter.limit == 0 { 200 } else { filter.limit }; - // Cap to a value that fits losslessly in i64 (no negative-LIMIT surprise - // in SQLite, which would silently disable the limit). - let limit = limit_usize.min(i64::MAX as usize) as i64; - - let mut sql = String::from( - "SELECT h.id, h.query, h.driver_id, h.connection_id, h.connection_name, \ - h.executed_at, h.duration_ms, h.rows_affected, h.success, h.cancelled, h.pinned, h.error \ - FROM history h ", - ); - let mut wheres: Vec<&str> = Vec::new(); - // FTS5 requires the MATCH operator to be applied directly to the - // virtual-table reference; combining it with other WHERE predicates - // via AND raises "unable to use function MATCH in the requested - // context" on some SQLite builds. Pinning the predicate to the JOIN - // condition keeps it isolated from the user-filter predicates below. - if filter.needle.is_some() { - sql.push_str("JOIN history_fts fts ON fts.rowid = h.id AND history_fts MATCH ? "); - } - if filter.connection_id.is_some() { - wheres.push("h.connection_id = ?"); - } - if let Some(success_only) = filter.success_only { - if success_only { - wheres.push("h.success = 1 AND h.cancelled = 0"); - } else { - wheres.push("h.success = 0"); + /// One page of the entries `query` matches, pinned first, then + /// newest. Paging is by keyset, so rows recorded while the user + /// scrolls shift nothing and no row is shown twice. + pub async fn search_page( + &self, + query: &HistoryQuery, + request: HistoryPageRequest, + ) -> Result { + let limit = request.limit.clamp(1, MAX_PAGE_SIZE); + let mut clauses: Vec<&'static str> = Vec::new(); + let mut binds: Vec = Vec::new(); + for term in query.term_matches() { + match term { + TermMatch::Trigram(phrase) => { + clauses.push("h.id IN (SELECT rowid FROM history_search WHERE history_search MATCH ?)"); + binds.push(Bind::Text(phrase)); + } + TermMatch::Substring(text) => { + clauses.push("instr(h.query_folded, ?) > 0"); + binds.push(Bind::Text(text)); + } } } - if filter.exclude_cancelled == Some(true) { - wheres.push("h.cancelled = 0"); - } else if filter.exclude_cancelled == Some(false) { - wheres.push("h.cancelled = 1"); + if let Some(connection) = query.connection { + clauses.push("h.connection_id = ?"); + binds.push(Bind::Text(connection.to_string())); } - if filter.min_executed_at.is_some() { - wheres.push("h.executed_at >= ?"); + if let Some(predicate) = query.status.predicate() { + clauses.push(predicate); } - if !wheres.is_empty() { - sql.push_str("WHERE "); - sql.push_str(&wheres.join(" AND ")); - sql.push(' '); + if let Some(after) = query.executed_after { + clauses.push("h.executed_at >= ?"); + binds.push(Bind::Integer(to_unix(after))); } - sql.push_str("ORDER BY h.pinned DESC, h.executed_at DESC LIMIT ?"); - - let mut q = sqlx::query(AssertSqlSafe(sql.as_str())); - if let Some(needle) = &filter.needle { - q = q.bind(needle); + if let Some(cursor) = request.after { + clauses.push("(h.pinned, h.executed_at, h.id) < (?, ?, ?)"); + binds.push(Bind::Integer(i64::from(cursor.pinned))); + binds.push(Bind::Integer(cursor.executed_at)); + binds.push(Bind::Integer(cursor.id)); } - if let Some(conn_id) = filter.connection_id { - q = q.bind(conn_id.to_string()); + + let mut sql = format!("SELECT {ENTRY_COLUMNS} FROM history h"); + if !clauses.is_empty() { + sql.push_str(" WHERE "); + sql.push_str(&clauses.join(" AND ")); } - if let Some(min_ts) = filter.min_executed_at { - q = q.bind(to_unix(min_ts)); + // One row past the page says whether there is another. + sql.push_str(" ORDER BY h.pinned DESC, h.executed_at DESC, h.id DESC LIMIT ?"); + binds.push(Bind::Integer(limit as i64 + 1)); + + let mut statement = sqlx::query(AssertSqlSafe(sql)); + for bind in binds { + statement = match bind { + Bind::Text(text) => statement.bind(text), + Bind::Integer(value) => statement.bind(value), + }; } - q = q.bind(limit); - - let rows = q.fetch_all(&self.pool).await?; - rows.into_iter().map(Self::row_to_entry).collect() + let rows = statement.fetch_all(&self.pool).await?; + let mut entries = rows + .into_iter() + .map(Self::row_to_entry) + .collect::, _>>()?; + let next = if entries.len() > limit { + entries.truncate(limit); + entries.last().map(HistoryCursor::after) + } else { + None + }; + Ok(HistoryPage { entries, next }) } fn row_to_entry(row: sqlx::sqlite::SqliteRow) -> Result { @@ -214,26 +241,60 @@ impl QueryHistory { Ok(()) } - pub async fn delete(&self, id: i64) -> Result<(), StorageError> { - sqlx::query("DELETE FROM history WHERE id = ?") - .bind(id) - .execute(&self.pool) - .await?; - Ok(()) + /// Delete the entries and hand back what they held, so the delete + /// can be undone with `restore`. + pub async fn delete_returning(&self, ids: &[i64]) -> Result, StorageError> { + let mut transaction = self.pool.begin().await?; + let mut deleted = Vec::with_capacity(ids.len()); + for chunk in ids.chunks(IDS_PER_STATEMENT) { + let placeholders = vec!["?"; chunk.len()].join(","); + let sql = format!( + "DELETE FROM history WHERE id IN ({placeholders}) RETURNING id, query, driver_id, connection_id, \ + connection_name, executed_at, duration_ms, rows_affected, success, cancelled, pinned, error" + ); + let mut statement = sqlx::query(AssertSqlSafe(sql)); + for id in chunk { + statement = statement.bind(id); + } + for row in statement.fetch_all(&mut *transaction).await? { + deleted.push(Self::row_to_entry(row)?); + } + } + transaction.commit().await?; + deleted.sort_by_key(|entry| entry.id); + Ok(deleted) } - pub async fn delete_many(&self, ids: &[i64]) -> Result { - if ids.is_empty() { - return Ok(0); - } - let placeholders = vec!["?"; ids.len()].join(","); - let sql = format!("DELETE FROM history WHERE id IN ({placeholders})"); - let mut q = sqlx::query(AssertSqlSafe(sql.as_str())); - for id in ids { - q = q.bind(id); + /// Put deleted entries back under their own ids, pinned or not as + /// they were. The insert triggers index them again. + pub async fn restore(&self, entries: &[Entry]) -> Result<(), StorageError> { + let mut transaction = self.pool.begin().await?; + for entry in entries { + sqlx::query( + "INSERT INTO history ( + id, query, query_folded, driver_id, connection_id, connection_name, + executed_at, duration_ms, rows_affected, success, cancelled, pinned, error + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + ) + .bind(entry.id) + .bind(&entry.query) + .bind(fold(&entry.query)) + .bind(&entry.driver_id) + .bind(entry.connection_id.to_string()) + .bind(&entry.connection_name) + .bind(to_unix(entry.executed_at)) + .bind(entry.duration_ms) + .bind(entry.rows_affected) + .bind(i64::from(entry.success)) + .bind(i64::from(entry.cancelled)) + .bind(i64::from(entry.pinned)) + .bind(&entry.error) + .execute(&mut *transaction) + .await?; } - let affected = q.execute(&self.pool).await?.rows_affected(); - Ok(affected as usize) + transaction.commit().await?; + Ok(()) } pub async fn clear_all(&self) -> Result { @@ -275,7 +336,7 @@ impl QueryHistory { // The delete triggers queue tombstones in the FTS index; without // this the index keeps growing even as the table shrinks. if report.removed_anything() { - sqlx::query("INSERT INTO history_fts(history_fts) VALUES('optimize')") + sqlx::query("INSERT INTO history_search(history_search) VALUES('optimize')") .execute(&self.pool) .await?; } @@ -302,77 +363,56 @@ impl QueryHistory { // 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')") + sqlx::query("INSERT INTO history_search(history_search) 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", + /// Every connection the history holds entries for, under the name + /// of its latest run, so a renamed connection is listed once. + pub async fn connections_with_history(&self) -> Result, StorageError> { + let rows: Vec<(String, String)> = sqlx::query_as( + "SELECT connection_id, connection_name FROM ( + SELECT connection_id, connection_name, + ROW_NUMBER() OVER ( + PARTITION BY connection_id ORDER BY executed_at DESC, id DESC + ) AS recency + FROM history + ) + WHERE recency = 1 + ORDER BY connection_name COLLATE NOCASE, connection_id", ) .fetch_all(&self.pool) .await?; - let mut out = Vec::with_capacity(rows.len()); - for row in rows { - let id_str: String = row.try_get("connection_id")?; - let name: String = row.try_get("connection_name")?; - if let Ok(id) = Uuid::parse_str(&id_str) { - out.push((id, name)); - } - } - Ok(out) + Ok(rows + .into_iter() + .filter_map(|(id, name)| Uuid::parse_str(&id).ok().map(|id| HistoryConnection { id, name })) + .collect()) } + /// The entries with these ids, in the order the history lists them. pub async fn fetch_by_ids(&self, ids: &[i64]) -> Result, StorageError> { - if ids.is_empty() { - return Ok(Vec::new()); - } - let placeholders = vec!["?"; ids.len()].join(","); - let sql = format!( - "SELECT id, query, driver_id, connection_id, connection_name, executed_at, \ - duration_ms, rows_affected, success, cancelled, pinned, error \ - FROM history WHERE id IN ({placeholders}) ORDER BY pinned DESC, executed_at DESC" - ); - let mut q = sqlx::query(AssertSqlSafe(sql.as_str())); - for id in ids { - q = q.bind(id); + let mut entries = Vec::with_capacity(ids.len()); + for chunk in ids.chunks(IDS_PER_STATEMENT) { + let placeholders = vec!["?"; chunk.len()].join(","); + let sql = format!("SELECT {ENTRY_COLUMNS} FROM history h WHERE h.id IN ({placeholders})"); + let mut statement = sqlx::query(AssertSqlSafe(sql)); + for id in chunk { + statement = statement.bind(id); + } + for row in statement.fetch_all(&self.pool).await? { + entries.push(Self::row_to_entry(row)?); + } } - let rows = q.fetch_all(&self.pool).await?; - rows.into_iter().map(Self::row_to_entry).collect() + entries.sort_by_key(|entry| std::cmp::Reverse(HistoryCursor::after(entry))); + Ok(entries) } pub async fn export_sql(&self, ids: &[i64]) -> Result { let entries = self.fetch_by_ids(ids).await?; - let mut out = String::new(); - out.push_str("-- TablePro query history export\n"); - out.push_str(&format!("-- Generated at {}\n", chrono::Utc::now().to_rfc3339())); - out.push_str(&format!("-- Entries: {}\n\n", entries.len())); - for entry in &entries { - let when = chrono::DateTime::::from(entry.executed_at).to_rfc3339(); - out.push_str(&format!( - "-- [{}] {} · {} · {}\n", - when, - entry.connection_name, - entry.driver_id, - outcome_summary(entry), - )); - if let Some(err) = &entry.error { - for line in err.lines() { - out.push_str("-- error: "); - out.push_str(line); - out.push('\n'); - } - } - out.push_str(entry.query.trim_end()); - if !entry.query.trim_end().ends_with(';') { - out.push(';'); - } - out.push_str("\n\n"); - } - Ok(out) + Ok(history_sql(&entries, chrono::Utc::now())) } pub async fn export_csv(&self, ids: &[i64]) -> Result { @@ -384,6 +424,7 @@ impl QueryHistory { #[cfg(test)] mod tests { use std::os::unix::fs::PermissionsExt; + use std::time::Duration; use super::*; @@ -404,6 +445,26 @@ mod tests { } } + async fn find(history: &QueryHistory, query: &HistoryQuery) -> Vec { + history + .search_page(query, HistoryPageRequest::first(MAX_PAGE_SIZE)) + .await + .expect("search") + .entries + } + + async fn all(history: &QueryHistory) -> Vec { + find(history, &HistoryQuery::default()).await + } + + async fn matching(history: &QueryHistory, input: &str) -> Vec { + find(history, &HistoryQuery::from_input(input)) + .await + .into_iter() + .map(|entry| entry.query) + .collect() + } + #[tokio::test] async fn history_open_creates_0600_db_and_wal() { let root = tempfile::tempdir().expect("tempdir"); @@ -429,14 +490,7 @@ mod tests { .record(entry("SELECT name FROM widgets", connection)) .await .expect("record"); - let found = history - .search(SearchFilter { - needle: Some("widgets".to_owned()), - limit: 10, - ..SearchFilter::default() - }) - .await - .expect("search"); + let found = find(&history, &HistoryQuery::from_input("widgets")).await; assert!(id > 0); assert_eq!(found.len(), 1); @@ -463,16 +517,13 @@ mod tests { .await .expect("record"); - let from_installed = installed.search(SearchFilter::default()).await.expect("search"); - let from_devel = devel.search(SearchFilter::default()).await.expect("search"); - assert_ne!(installed.path(), devel.path()); - assert_eq!(from_installed.len(), 1); - assert!(from_devel.is_empty()); + assert_eq!(all(&installed).await.len(), 1); + assert!(all(&devel).await.is_empty()); } #[tokio::test] - async fn fresh_database_migrates() { + async fn fresh_database_applies_every_migration() { let root = tempfile::tempdir().expect("tempdir"); let history = QueryHistory::open(&paths(&root)).await.expect("open"); @@ -481,7 +532,68 @@ mod tests { .fetch_one(&history.pool) .await .expect("count migrations"); - assert_eq!(applied, 2); + assert_eq!(applied, 3); + } + + /// The fixture for the substring-search migration: a database the + /// two migrations before it built, with rows in it. + #[tokio::test] + async fn opens_database_created_with_previous_schema() { + const BEFORE_SUBSTRING_SEARCH: i64 = 20260918000000; + let root = tempfile::tempdir().expect("tempdir"); + let paths = paths(&root); + crate::fs::create_private_file_if_missing(&paths.history_database()).expect("the database file"); + let options = SqliteConnectOptions::new() + .filename(paths.history_database()) + .create_if_missing(true); + let pool = SqlitePoolOptions::new() + .max_connections(1) + .connect_with(options) + .await + .expect("connect"); + schema::MIGRATOR + .run_to(BEFORE_SUBSTRING_SEARCH, &pool) + .await + .expect("the earlier schema"); + for (query, executed_at) in [("SELECT * FROM ÉLÈVES", 100_i64), ("SELECT id FROM orders", 200)] { + sqlx::query( + "INSERT INTO history (query, driver_id, connection_id, connection_name, executed_at, success) \ + VALUES (?, 'sqlite', ?, 'old', ?, 1)", + ) + .bind(query) + .bind(Uuid::nil().to_string()) + .bind(executed_at) + .execute(&pool) + .await + .expect("an old row"); + } + pool.close().await; + + let history = QueryHistory::open(&paths).await.expect("open"); + history + .record(entry("SELECT élèves FROM classes", Uuid::nil())) + .await + .expect("record"); + + assert_eq!(matching(&history, "élèves").await.len(), 2, "an old row was not folded"); + assert_eq!( + matching(&history, "ORD").await, + vec!["SELECT id FROM orders".to_owned()] + ); + let unfolded: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM history WHERE query_folded IS NULL") + .fetch_one(&history.pool) + .await + .expect("count"); + assert_eq!(unfolded, 0); + let word_index: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM sqlite_master WHERE name = 'history_fts'") + .fetch_one(&history.pool) + .await + .expect("look up the old index"); + assert_eq!(word_index, 0, "the word index outlived its migration"); + let integrity = sqlx::query("INSERT INTO history_search(history_search) VALUES('integrity-check')") + .execute(&history.pool) + .await; + assert!(integrity.is_ok(), "{integrity:?}"); } #[tokio::test] @@ -494,8 +606,7 @@ mod tests { drop(first); let second = QueryHistory::open(&paths).await.expect("second open"); - let found = second.search(SearchFilter::default()).await.expect("search"); - assert_eq!(found.len(), 1); + assert_eq!(all(&second).await.len(), 1); } #[tokio::test] @@ -534,13 +645,14 @@ mod tests { for expected in [ "history", - "history_ad", - "history_ai", - "history_au", "history_connection_idx", "history_executed_at_idx", - "history_fts", - "history_pinned_idx", + "history_page_idx", + "history_search", + "history_search_ad", + "history_search_ai", + "history_search_au", + "history_unfolded_idx", ] { assert!( names.iter().any(|name| name == expected), @@ -564,7 +676,7 @@ mod tests { } let report = history.prune_with_cap(0, 2).await.expect("prune"); - let left = history.search(SearchFilter::default()).await.expect("search"); + let left = all(&history).await; assert_eq!(report.expired, 0); assert_eq!(report.over_cap, 3); @@ -597,21 +709,14 @@ mod tests { 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"); + let found = find(&history, &HistoryQuery::from_input("widgets")).await; 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() { + async fn search_still_matches_after_optimize() { let root = tempfile::tempdir().expect("tempdir"); let history = QueryHistory::open(&paths(&root)).await.expect("open"); for index in 0..4 { @@ -622,17 +727,9 @@ mod tests { } let report = history.prune_with_cap(0, 2).await.expect("prune"); - let found = history - .search(SearchFilter { - needle: Some("widgets".to_owned()), - limit: 10, - ..SearchFilter::default() - }) - .await - .expect("search"); assert!(report.removed_anything()); - assert_eq!(found.len(), 2); + assert_eq!(matching(&history, "widgets").await.len(), 2); } /// Bump this with each new migration file. An applied migration is @@ -640,7 +737,7 @@ mod tests { /// means a change went into a file that has already run somewhere. #[test] fn migrator_embeds_every_migration() { - assert_eq!(MIGRATOR.iter().count(), 2); + assert_eq!(schema::MIGRATOR.iter().count(), 3); } #[tokio::test] @@ -655,7 +752,7 @@ mod tests { } #[tokio::test] - async fn history_fts5_available_on_system_sqlite() { + async fn trigram_index_available_on_system_sqlite() { let root = tempfile::tempdir().expect("tempdir"); let history = QueryHistory::open(&paths(&root)).await.expect("open"); history @@ -663,12 +760,322 @@ mod tests { .await .expect("record"); - let matches: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM history_fts WHERE history_fts MATCH ?") - .bind("widgets") + let matches: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM history_search WHERE history_search MATCH ?") + .bind("\"idge\"") .fetch_one(&history.pool) .await - .expect("fts5 match"); + .expect("trigram match"); assert_eq!(matches, 1); } + + #[tokio::test] + async fn partial_token_matches() { + let root = tempfile::tempdir().expect("tempdir"); + let history = QueryHistory::open(&paths(&root)).await.expect("open"); + history + .record(entry("SELECT * FROM customer_orders", Uuid::nil())) + .await + .expect("record"); + history + .record(entry("SELECT * FROM products", Uuid::nil())) + .await + .expect("record"); + + assert_eq!( + matching(&history, "tomer_ord").await, + vec!["SELECT * FROM customer_orders"] + ); + assert_eq!(matching(&history, "ROM").await.len(), 2); + } + + #[tokio::test] + async fn two_char_term_matches_via_instr() { + let root = tempfile::tempdir().expect("tempdir"); + let history = QueryHistory::open(&paths(&root)).await.expect("open"); + history + .record(entry("SELECT a.id FROM t1 a", Uuid::nil())) + .await + .expect("record"); + history.record(entry("VACUUM", Uuid::nil())).await.expect("record"); + + assert_eq!(matching(&history, "T1").await, vec!["SELECT a.id FROM t1 a"]); + assert_eq!(matching(&history, ".").await, vec!["SELECT a.id FROM t1 a"]); + assert!(matching(&history, "zz").await.is_empty()); + } + + #[tokio::test] + async fn unicode_case_fold() { + let root = tempfile::tempdir().expect("tempdir"); + let history = QueryHistory::open(&paths(&root)).await.expect("open"); + history + .record(entry("SELECT * FROM ÉLÈVES WHERE nom = 'Ærø'", Uuid::nil())) + .await + .expect("record"); + + assert_eq!(matching(&history, "élèves").await.len(), 1); + assert_eq!(matching(&history, "ÉlÈv").await.len(), 1); + assert_eq!(matching(&history, "æR").await.len(), 1, "a short term was not folded"); + } + + #[tokio::test] + async fn quote_and_fts_operators_are_literal() { + let root = tempfile::tempdir().expect("tempdir"); + let history = QueryHistory::open(&paths(&root)).await.expect("open"); + let tricky = "SELECT \"a b\" FROM t WHERE x = 'OR' AND y NEAR(z) -- *"; + history.record(entry(tricky, Uuid::nil())).await.expect("record"); + history.record(entry("SELECT 1", Uuid::nil())).await.expect("record"); + + for input in [ + "\"a", "b\"", "NEAR(z)", "'OR'", "OR", "AND", "*", "-- *", "\"", "y NEAR", + ] { + assert_eq!(matching(&history, input).await, vec![tricky.to_owned()], "{input}"); + } + assert!(matching(&history, "NEAR(zz)").await.is_empty()); + } + + #[tokio::test] + async fn a_quote_in_a_long_term_matches_literally() { + let root = tempfile::tempdir().expect("tempdir"); + let history = QueryHistory::open(&paths(&root)).await.expect("open"); + let quoted = "SELECT \"total\" FROM sales"; + history.record(entry(quoted, Uuid::nil())).await.expect("record"); + history + .record(entry("SELECT total FROM sales", Uuid::nil())) + .await + .expect("record"); + + for input in ["\"total\"", "total\"", "\"tot"] { + assert_eq!(matching(&history, input).await, vec![quoted.to_owned()], "{input}"); + } + for input in ["\"totals\"", "t\"\"", "\"FROM"] { + assert!(matching(&history, input).await.is_empty(), "{input}"); + } + assert_eq!(matching(&history, "total").await.len(), 2); + } + + #[tokio::test] + async fn fetch_by_ids_reads_past_one_statement_in_list_order() { + let root = tempfile::tempdir().expect("tempdir"); + let history = QueryHistory::open(&paths(&root)).await.expect("open"); + let mut ids = Vec::new(); + for index in 0..IDS_PER_STATEMENT + 10 { + ids.push( + history + .record(entry(&format!("SELECT {index}"), Uuid::nil())) + .await + .expect("record"), + ); + } + // In the last statement's ids, but first in the list. + let pinned = ids[ids.len() - 1]; + history.set_pinned(pinned, true).await.expect("pin"); + + let fetched = history.fetch_by_ids(&ids).await.expect("fetch"); + + assert_eq!(fetched.len(), ids.len()); + assert_eq!(fetched.first().map(|entry| entry.id), Some(pinned)); + assert_eq!(fetched, all(&history).await); + } + + #[tokio::test] + async fn terms_are_anded() { + let root = tempfile::tempdir().expect("tempdir"); + let history = QueryHistory::open(&paths(&root)).await.expect("open"); + for query in ["SELECT id FROM users", "SELECT id FROM orders", "DELETE FROM users"] { + history.record(entry(query, Uuid::nil())).await.expect("record"); + } + + assert_eq!(matching(&history, "users select").await, vec!["SELECT id FROM users"]); + assert_eq!(matching(&history, "id rs").await.len(), 2); + assert!(matching(&history, "users orders").await.is_empty()); + } + + #[tokio::test] + async fn keyset_pages_cover_all_rows_once() { + let root = tempfile::tempdir().expect("tempdir"); + let history = QueryHistory::open(&paths(&root)).await.expect("open"); + // Groups of runs in the same second, so only the id orders them. + let base = SystemTime::UNIX_EPOCH + Duration::from_secs(1_800_000_000); + for index in 0..450_u64 { + let mut run = entry(&format!("SELECT {index}"), Uuid::nil()); + run.executed_at = base + Duration::from_secs(index / 7); + let id = history.record(run).await.expect("record"); + if index % 50 == 0 { + history.set_pinned(id, true).await.expect("pin"); + } + } + + let mut seen = Vec::new(); + let mut request = HistoryPageRequest::first(100); + let mut pages = 0; + loop { + let page = history + .search_page(&HistoryQuery::default(), request) + .await + .expect("a page"); + pages += 1; + for entry in &page.entries { + let cursor = HistoryCursor::after(entry); + assert!( + request.after.is_none_or(|after| cursor < after), + "an entry on a later page does not sort below the cursor" + ); + assert!( + page.next.is_none_or(|next| cursor >= next), + "an entry on this page sorts below its own cursor" + ); + } + seen.extend( + page.entries + .iter() + .map(|entry| (entry.pinned, entry.executed_at, entry.id)), + ); + match page.next { + Some(next) => request.after = Some(next), + None => break, + } + } + + assert_eq!(pages, 5); + assert_eq!(seen.len(), 450); + let mut sorted = seen.clone(); + sorted.sort_by(|a, b| b.cmp(a)); + assert_eq!(seen, sorted, "pages are not in pinned, newest, id order"); + sorted.dedup(); + assert_eq!(sorted.len(), 450, "a row was shown twice"); + assert_eq!(seen.iter().take_while(|(pinned, _, _)| *pinned).count(), 9); + } + + #[tokio::test] + async fn status_and_connection_filters() { + let root = tempfile::tempdir().expect("tempdir"); + let history = QueryHistory::open(&paths(&root)).await.expect("open"); + let (mine, theirs) = (Uuid::new_v4(), Uuid::new_v4()); + let old = SystemTime::now() - Duration::from_secs(10 * 86_400); + for (query, connection, outcome, executed_at) in [ + ("SELECT ok", mine, Outcome::Success, SystemTime::now()), + ("SELECT bad", mine, Outcome::Error("boom".to_owned()), SystemTime::now()), + ("SELECT stopped", mine, Outcome::Cancelled, SystemTime::now()), + ("SELECT theirs", theirs, Outcome::Success, SystemTime::now()), + ("SELECT old", mine, Outcome::Success, old), + ] { + let mut run = entry(query, connection); + run.outcome = outcome; + run.executed_at = executed_at; + history.record(run).await.expect("record"); + } + let queries = |entries: Vec| -> Vec { + let mut queries: Vec = entries.into_iter().map(|entry| entry.query).collect(); + queries.sort(); + queries + }; + let filtered = |status: StatusFilter, connection: Option, executed_after: Option| { + let mut query = HistoryQuery::default(); + query.connection = connection; + query.status = status; + query.executed_after = executed_after; + query + }; + + assert_eq!( + queries(find(&history, &filtered(StatusFilter::Succeeded, Some(mine), None)).await), + ["SELECT ok", "SELECT old"] + ); + assert_eq!( + queries(find(&history, &filtered(StatusFilter::Failed, None, None)).await), + ["SELECT bad"] + ); + assert_eq!( + queries(find(&history, &filtered(StatusFilter::Cancelled, None, None)).await), + ["SELECT stopped"] + ); + assert_eq!( + queries(find(&history, &filtered(StatusFilter::Any, Some(theirs), None)).await), + ["SELECT theirs"] + ); + let last_week = SystemTime::now() - Duration::from_secs(7 * 86_400); + assert_eq!( + queries( + find( + &history, + &filtered(StatusFilter::Succeeded, Some(mine), Some(last_week)) + ) + .await + ), + ["SELECT ok"] + ); + } + + #[tokio::test] + async fn connections_with_history_uses_latest_name() { + let root = tempfile::tempdir().expect("tempdir"); + let history = QueryHistory::open(&paths(&root)).await.expect("open"); + let (renamed, other) = (Uuid::new_v4(), Uuid::new_v4()); + let now = SystemTime::now(); + for (connection, name, ago) in [ + (renamed, "Old name", 300), + (renamed, "New name", 10), + // Recorded last but ran first, as a restore leaves it. + (renamed, "Oldest name", 900), + (other, "beta", 60), + ] { + let mut run = entry("SELECT 1", connection); + run.connection_name = name.to_owned(); + run.executed_at = now - Duration::from_secs(ago); + history.record(run).await.expect("record"); + } + + let connections = history.connections_with_history().await.expect("list"); + + assert_eq!( + connections, + vec![ + HistoryConnection { + id: other, + name: "beta".to_owned() + }, + HistoryConnection { + id: renamed, + name: "New name".to_owned() + }, + ] + ); + } + + #[tokio::test] + async fn delete_returning_then_restore_roundtrips_entries_and_search() { + let root = tempfile::tempdir().expect("tempdir"); + let history = QueryHistory::open(&paths(&root)).await.expect("open"); + let mut ids = Vec::new(); + for query in ["SELECT widgets 1", "SELECT widgets 2", "SELECT gadgets"] { + let mut run = entry(query, Uuid::new_v4()); + run.outcome = Outcome::Error("no such table".to_owned()); + ids.push(history.record(run).await.expect("record")); + } + history.set_pinned(ids[0], true).await.expect("pin"); + let before = all(&history).await; + + let deleted = history + .delete_returning(&[ids[0], ids[2], 9_999]) + .await + .expect("delete"); + + assert_eq!( + deleted.iter().map(|entry| entry.id).collect::>(), + vec![ids[0], ids[2]] + ); + assert_eq!(matching(&history, "widgets").await, vec!["SELECT widgets 2"]); + assert!(matching(&history, "gadg").await.is_empty()); + + history.restore(&deleted).await.expect("restore"); + + assert_eq!(all(&history).await, before); + assert_eq!(matching(&history, "widgets").await.len(), 2); + assert_eq!(matching(&history, "gadg").await, vec!["SELECT gadgets"]); + let integrity = sqlx::query("INSERT INTO history_search(history_search) VALUES('integrity-check')") + .execute(&history.pool) + .await; + assert!(integrity.is_ok(), "{integrity:?}"); + } } diff --git a/linux/crates/storage/src/query_history/schema.rs b/linux/crates/storage/src/query_history/schema.rs new file mode 100644 index 0000000000..3588015364 --- /dev/null +++ b/linux/crates/storage/src/query_history/schema.rs @@ -0,0 +1,49 @@ +use sqlx::SqlitePool; + +use crate::error::StorageError; + +use super::search_fold::fold; + +/// An applied migration is never edited. Each schema change is a new +/// timestamped file with its own fixture test. +pub(super) static MIGRATOR: sqlx::migrate::Migrator = sqlx::migrate!("./migrations"); + +/// Rows folded per transaction, so a large history neither holds one +/// long write nor commits once per row. +const FOLD_BATCH: i64 = 500; + +/// Bring the database to this build's schema, refusing one a newer +/// build wrote. +pub(super) async fn migrate(pool: &SqlitePool) -> Result<(), StorageError> { + MIGRATOR.run(pool).await.map_err(|error| match error { + // The database carries a version this build does not know, + // which means a newer binary created it. + sqlx::migrate::MigrateError::VersionMissing(version) => StorageError::HistoryNewerThanApp { version }, + other => StorageError::HistoryMigration(other), + })?; + fold_unfolded_rows(pool).await +} + +/// Rows recorded before the search used the folded text have none. SQLite +/// cannot fold beyond ASCII, so the migration leaves them for this. +async fn fold_unfolded_rows(pool: &SqlitePool) -> Result<(), StorageError> { + loop { + let rows: Vec<(i64, String)> = + sqlx::query_as("SELECT id, query FROM history WHERE query_folded IS NULL LIMIT ?") + .bind(FOLD_BATCH) + .fetch_all(pool) + .await?; + if rows.is_empty() { + return Ok(()); + } + let mut transaction = pool.begin().await?; + for (id, query) in &rows { + sqlx::query("UPDATE history SET query_folded = ? WHERE id = ?") + .bind(fold(query)) + .bind(id) + .execute(&mut *transaction) + .await?; + } + transaction.commit().await?; + } +} diff --git a/linux/crates/storage/src/query_history/search_filter.rs b/linux/crates/storage/src/query_history/search_filter.rs deleted file mode 100644 index 8dc931d49e..0000000000 --- a/linux/crates/storage/src/query_history/search_filter.rs +++ /dev/null @@ -1,13 +0,0 @@ -use std::time::SystemTime; - -use uuid::Uuid; - -#[derive(Debug, Clone, Default)] -pub struct SearchFilter { - pub needle: Option, - pub connection_id: Option, - pub success_only: Option, - pub exclude_cancelled: Option, - pub min_executed_at: Option, - pub limit: usize, -} diff --git a/linux/crates/storage/src/query_history/search_fold.rs b/linux/crates/storage/src/query_history/search_fold.rs new file mode 100644 index 0000000000..41f823a813 --- /dev/null +++ b/linux/crates/storage/src/query_history/search_fold.rs @@ -0,0 +1,16 @@ +/// The form a stored query and a search term are compared in. The +/// trigram index is case sensitive and only ever sees this form, so `É` +/// finds `é` while the index stays usable for every term. +pub(crate) fn fold(text: &str) -> String { + text.to_lowercase() +} + +#[cfg(test)] +mod tests { + use super::fold; + + #[test] + fn folds_beyond_ascii() { + assert_eq!(fold("SELECT * FROM ÉLÈVES"), "select * from élèves"); + } +} diff --git a/linux/crates/storage/src/query_history/status_filter.rs b/linux/crates/storage/src/query_history/status_filter.rs new file mode 100644 index 0000000000..91282b3d64 --- /dev/null +++ b/linux/crates/storage/src/query_history/status_filter.rs @@ -0,0 +1,22 @@ +/// Which outcomes a history search keeps. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum StatusFilter { + #[default] + Any, + Succeeded, + Failed, + Cancelled, +} + +impl StatusFilter { + /// A cancelled run is stored with `success = 0`, so Failed has to + /// leave it out explicitly. + pub(super) fn predicate(self) -> Option<&'static str> { + match self { + Self::Any => None, + Self::Succeeded => Some("h.success = 1 AND h.cancelled = 0"), + Self::Failed => Some("h.success = 0 AND h.cancelled = 0"), + Self::Cancelled => Some("h.cancelled = 1"), + } + } +} diff --git a/linux/crates/storage/tests/isolation.rs b/linux/crates/storage/tests/isolation.rs index 57caf42ec6..d2e4a7a899 100644 --- a/linux/crates/storage/tests/isolation.rs +++ b/linux/crates/storage/tests/isolation.rs @@ -10,7 +10,7 @@ use std::os::unix::fs::PermissionsExt; use std::path::Path; use std::time::SystemTime; -use tablepro_storage::query_history::{NewEntry, Outcome, SearchFilter}; +use tablepro_storage::query_history::{HistoryPageRequest, HistoryQuery, NewEntry, Outcome}; use tablepro_storage::{ConnectionListState, ConnectionStore, QueryHistory, SavedConnection, StoragePaths}; use uuid::Uuid; @@ -54,15 +54,8 @@ fn connection(name: &str) -> SavedConnection { } } -fn all_queries(entries: &[tablepro_storage::query_history::Entry]) -> Vec { - entries.iter().map(|entry| entry.query.clone()).collect() -} - -fn search_all() -> SearchFilter { - SearchFilter { - limit: 100, - ..SearchFilter::default() - } +fn all_queries(page: &tablepro_storage::query_history::HistoryPage) -> Vec { + page.entries.iter().map(|entry| entry.query.clone()).collect() } #[tokio::test(flavor = "multi_thread")] @@ -84,7 +77,11 @@ async fn two_history_databases_in_one_process_keep_their_own_rows() -> TestResul recorded_left?; recorded_right?; - let (found_left, found_right) = tokio::join!(left.search(search_all()), right.search(search_all())); + let everything = HistoryQuery::default(); + let (found_left, found_right) = tokio::join!( + left.search_page(&everything, HistoryPageRequest::first(100)), + right.search_page(&everything, HistoryPageRequest::first(100)) + ); assert_eq!(all_queries(&found_left?), vec!["SELECT 1".to_owned()]); assert_eq!(all_queries(&found_right?), vec!["SELECT 2".to_owned()]); assert_ne!(left.path(), right.path()); diff --git a/linux/crates/test-support/src/connection_script.rs b/linux/crates/test-support/src/connection_script.rs index ed92e0b1b2..7a1806b27c 100644 --- a/linux/crates/test-support/src/connection_script.rs +++ b/linux/crates/test-support/src/connection_script.rs @@ -8,7 +8,7 @@ use tablepro_core::{ WriteReport, }; -use crate::{CloseBehaviour, FakeDialect, FakeReply, Replies}; +use crate::{CloseBehaviour, FakeDialect, FakeReply, Replies, StreamEnd}; /// Everything a `FakeConnection` answers, set up before the code under /// test gets the connection. @@ -35,6 +35,8 @@ pub struct ConnectionScript { pub count_rows: Replies>, /// The events each read sends to its sink, in order. pub stream_read: Replies>>, + /// How each read ends once its events are sent. + pub stream_end: StreamEnd, pub write_batch: Replies>, pub resolve_commit: Replies>, pub open_editor_session: Replies>>, @@ -59,6 +61,7 @@ impl Default for ConnectionScript { query_bound: Replies::default(), count_rows: Replies::default(), stream_read: Replies::default(), + stream_end: StreamEnd::default(), write_batch: Replies::default(), resolve_commit: Replies::default(), open_editor_session: Replies::default(), diff --git a/linux/crates/test-support/src/fake_connection.rs b/linux/crates/test-support/src/fake_connection.rs index 7bed407872..6550851ecc 100644 --- a/linux/crates/test-support/src/fake_connection.rs +++ b/linux/crates/test-support/src/fake_connection.rs @@ -11,10 +11,11 @@ use tablepro_core::{ EditorSessionOptions, ErrorCategory, ForeignKeyInfo, IndexInfo, LinkState, LinkWatch, QueryResult, ReadStatement, ResultSink, RowLimit, ServerInfo, SinkFlow, StreamSummary, TableRef, TimeoutPhase, WriteBatch, WriteReport, }; +use tokio::time::Instant; use tokio_util::sync::{CancellationToken, WaitForCancellationFutureOwned}; use crate::fake_reply::Stop; -use crate::{CallCounters, CloseBehaviour, ConnectionScript, FakeReply, Replies, lock}; +use crate::{CallCounters, CloseBehaviour, ConnectionScript, FakeReply, Replies, StreamEnd, lock}; /// A `Connection` that answers from a `ConnectionScript`. /// @@ -33,8 +34,17 @@ pub struct FakeConnection { link: LinkWatch, closed: CancellationToken, counters: CallCounters, - /// The cancel token each call was handed, by method, in call order. - cancels: Mutex>, + /// What each call was handed, in call order. + calls: Mutex>, +} + +/// One call as the fake saw it. +#[derive(Debug)] +struct ObservedCall { + method: &'static str, + cancel: CancellationToken, + deadline: Option, + sql: Option, } impl FakeConnection { @@ -50,7 +60,7 @@ impl FakeConnection { link: LinkWatch::new(), closed: CancellationToken::new(), counters: CallCounters::default(), - cancels: Mutex::new(Vec::new()), + calls: Mutex::new(Vec::new()), } } @@ -77,10 +87,24 @@ impl FakeConnection { /// 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 { - lock(&self.cancels) + self.observed(method, |call| call.cancel.clone()) + } + + /// The deadline every call to `method` was handed, oldest first. + pub fn observed_deadlines(&self, method: &str) -> Vec> { + self.observed(method, |call| call.deadline) + } + + /// The SQL every call to `method` was handed, oldest first. + pub fn observed_sql(&self, method: &str) -> Vec> { + self.observed(method, |call| call.sql.clone()) + } + + fn observed(&self, method: &str, read: impl Fn(&ObservedCall) -> T) -> Vec { + lock(&self.calls) .iter() - .filter(|(called, _)| *called == method) - .map(|(_, cancel)| cancel.clone()) + .filter(|call| call.method == method) + .map(read) .collect() } @@ -97,7 +121,12 @@ impl FakeConnection { T: Clone, { CallCounters::record(counter); - lock(&self.cancels).push((method, options.cancel().clone())); + lock(&self.calls).push(ObservedCall { + method, + cancel: options.cancel().clone(), + deadline: options.deadline(), + sql: sql.map(str::to_owned), + }); if self.closed.is_cancelled() { CallCounters::record(&self.counters.stopped); return Err(DriverError::Cancelled); @@ -331,16 +360,27 @@ impl Connection for FakeConnection { TimeoutPhase::Statement, ) .await?; + let end = lock(&self.script).stream_end.clone(); let mut rows = 0_u64; for event in events { if let tablepro_core::ResultEvent::Rows(batch) = &event { rows += batch.len() as u64; } if sink.accept(event).await == SinkFlow::ConsumerGone { - return Err(DriverError::Cancelled); + return match end { + StreamEnd::SummaryWhenStopped => Ok(StreamSummary::new(rows)), + StreamEnd::Summary | StreamEnd::Failed(_) => Err(DriverError::Cancelled), + }; + } + } + match end { + StreamEnd::Summary | StreamEnd::SummaryWhenStopped => Ok(StreamSummary::new(rows)), + StreamEnd::Failed(error) => { + let outcome: Result = Err(error); + self.observe(&outcome); + outcome } } - Ok(StreamSummary::new(rows)) } async fn write_batch(&self, batch: &WriteBatch, options: CallOptions) -> Result { diff --git a/linux/crates/test-support/src/lib.rs b/linux/crates/test-support/src/lib.rs index 21a17fd313..63dfdf4552 100644 --- a/linux/crates/test-support/src/lib.rs +++ b/linux/crates/test-support/src/lib.rs @@ -23,6 +23,7 @@ mod fake_secret_vault; mod fault; pub mod history_cases; mod replies; +mod stream_end; pub use call_counters::CallCounters; pub use close_behaviour::CloseBehaviour; @@ -37,6 +38,7 @@ pub use fake_run::{FakeRun, RunWait}; pub use fake_secret_vault::{Call, FakeSecretVault}; pub use fault::FaultTransport; pub use replies::Replies; +pub use stream_end::StreamEnd; fn lock(mutex: &Mutex) -> MutexGuard<'_, T> { mutex.lock().unwrap_or_else(PoisonError::into_inner) diff --git a/linux/crates/test-support/src/stream_end.rs b/linux/crates/test-support/src/stream_end.rs new file mode 100644 index 0000000000..b1cb3c6241 --- /dev/null +++ b/linux/crates/test-support/src/stream_end.rs @@ -0,0 +1,15 @@ +use tablepro_core::DriverError; + +/// How a scripted read ends once it has sent its events. +#[derive(Debug, Clone, Default, PartialEq)] +pub enum StreamEnd { + /// The summary, or `Cancelled` when the sink stopped the read. + #[default] + Summary, + /// The summary even when the sink stopped the read early, which the + /// contract allows and SQL Server's driver does. + SummaryWhenStopped, + /// `error` once every event is sent, as a statement that writes + /// after it reads fails once its rows are in. + Failed(DriverError), +} diff --git a/linux/data/licenses/THIRD-PARTY-NOTICE.md b/linux/data/licenses/THIRD-PARTY-NOTICE.md new file mode 100644 index 0000000000..aabf7ea4a2 --- /dev/null +++ b/linux/data/licenses/THIRD-PARTY-NOTICE.md @@ -0,0 +1,7899 @@ +# Third-party licenses + +TablePro is licensed under the GNU Affero General Public License, version 3 or later, in `LICENSE`. It is built with the Rust crates below, each listed under the license it is used under. + +`build-aux/update-third-party-licenses.sh` writes this file from `Cargo.lock`. Do not edit it by hand. + +## Overview + +- MIT License (MIT) +- Unicode License v3 (Unicode-3.0) +- Apache License 2.0 (Apache-2.0) +- BSD 3-Clause "New" or "Revised" License (BSD-3-Clause) +- ISC License (ISC) +- zlib License (Zlib) +- Community Data License Agreement Permissive 2.0 (CDLA-Permissive-2.0) + +## License texts + +### Apache License 2.0 + +Used by: + +- flagset 0.4.7 +- fragile 2.1.0 + +~~~~text + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +~~~~ + +### Apache License 2.0 + +Used by: + +- zopfli 0.8.3 + +~~~~text + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2011 Google Inc. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +~~~~ + +### Apache License 2.0 + +Used by: + +- ryu 1.0.23 + +~~~~text + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +~~~~ + +### Apache License 2.0 + +Used by: + +- sqlparser 0.63.0 + +~~~~text + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright [yyyy] [name of copyright owner] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +~~~~ + +### Apache License 2.0 + +Used by: + +- aws-lc-sys 0.45.0 + +~~~~text +AWS Libcrypto (AWS-LC) + +AWS-LC is a fork of BoringSSL, which is itself a fork of OpenSSL. +Content from these and other sources retains their original licensing, +as described below. New files from AWS-LC are made available under the Apache-2.0 license OR the ISC +license. These licenses are reproduced at the bottom of this file. + +``` +Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +SPDX-License-Identifier: Apache-2.0 OR ISC +``` + + +================================================================================ +BoringSSL +================================================================================ + +BoringSSL is a Google-maintained fork of OpenSSL. Historically, code +authored by Google for BoringSSL was licensed under the ISC License. +BoringSSL has since relicensed upstream to Apache License 2.0. Existing +AWS-LC code originating from BoringSSL retains its ISC license, while +newer code taken from BoringSSL is licensed under Apache License 2.0. + +``` +Copyright (c) 2014-2024 Google Inc. +SPDX-License-Identifier: ISC +``` + +Additional individual contributions to BoringSSL-derived code are +covered under the ISC license: + + - Brian Smith (Copyright 2016) + - Robert Nagy (Copyright 2022) + - Arm Ltd (Copyright 2020) + +``` +Copyright (c) 2025-2026 Google Inc. +SPDX-License-Identifier: Apache-2.0 +``` + +================================================================================ +OpenSSL +================================================================================ + +Code derived from the OpenSSL project is licensed under the +Apache License, Version 2.0. + +``` +Copyright (c) 1998-2011 The OpenSSL Project. All rights reserved. +SPDX-License-Identifier: Apache-2.0 +``` + +Some OpenSSL-derived files also carry the original SSLeay copyright: + +``` +Copyright (c) 1995-1998 Eric Young (eay@cryptsoft.com). All rights reserved. +SPDX-License-Identifier: Apache-2.0 +``` + +Portions of OpenSSL-derived code include contributions from: + + - Sun Microsystems, Inc. (Copyright 2002) + - Nokia (Copyright 2005) + - Intel Corporation (Copyright 2012-2021) + +These contributions are covered under the Apache-2.0 license. + +================================================================================ +mlkem-native +================================================================================ + +Code from the mlkem-native project is licensed under Apache License 2.0 or MIT or ISC. + +``` +Copyright (c) The mlkem-native project authors. +SPDX-License-Identifier: Apache-2.0 OR ISC OR MIT +``` + +================================================================================ +mldsa-native +================================================================================ + +Code from the mldsa-native project is licensed under Apache License 2.0 or MIT or ISC + +``` +Copyright (c) The mldsa-native project authors. +SPDX-License-Identifier: Apache-2.0 OR ISC OR MIT +``` + +================================================================================ +Third-Party Libraries (compiled into libcrypto/libssl) +================================================================================ + +Fiat Cryptography +----------------- +Synthesizing Correct-by-Construction Code for Cryptographic Primitives. +See third_party/fiat/LICENSE. + +``` +Copyright (c) 2015-2020 the fiat-crypto authors. +SPDX-License-Identifier: MIT +``` + +s2n-bignum +---------- +Integer arithmetic routines for cryptography. +See third_party/s2n-bignum/s2n-bignum-imported/LICENSE. + +``` +Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +SPDX-License-Identifier: Apache-2.0 OR ISC OR MIT-0 +``` + +Note: ML-KEM/SHA3 code within s2n-bignum is licensed as +Apache-2.0 OR ISC OR MIT (with attribution), sourced from the +mlkem-native project. + +Jitter Entropy RNG +------------------- +CPU Jitter Random Number Generator Library. +See third_party/jitterentropy/jitterentropy-library/LICENSE. + +The Jitter Entropy library is dual-licensed under a BSD-style license +and the GNU General Public License Version 2. Amazon expressly elects +to distribute the package under the 3-Clause BSD License and NOT under +GNU General Public License Version 2. + +``` +Copyright (C) 2017 - 2025, Stephan Mueller . +SPDX-License-Identifier: BSD-3-Clause +``` + +Keccak / AES Reference Implementations +--------------------------------------- +Public domain (CC0) contributions. +https://creativecommons.org/public-domain/cc0 + +Code from sources and by authors listed in comments on top of the respective files. + + +================================================================================ +Third-Party Libraries (NOT compiled into libcrypto/libssl) +================================================================================ + +The following are used for testing and build tooling only. Distributing +code linked against AWS-LC (libcrypto/libssl) does NOT trigger these +license obligations. + +Google Test +----------- +See third_party/googletest/LICENSE. + +``` +Copyright 2008 Google Inc. +SPDX-License-Identifier: BSD-3-Clause +``` + +Go Standard Library +------------------- +Code in ssl/test/runner/ is derived from the Go standard library. + +``` +Copyright (c) The Go Authors. All rights reserved. +SPDX-License-Identifier: BSD-3-Clause +``` + +Wycheproof Test Vectors +------------------------ +Project Wycheproof is a community managed repository of test vectors that can be used by cryptography library +developers to test against known attacks, specification inconsistencies, and other various implementation bugs. + +See third_party/wycheproof_testvectors/LICENSE. + +``` +SPDX-License-Identifier: Apache-2.0 +``` + + +================================================================================ +Full License Texts +================================================================================ + +Apache License 2.0 +------------------ +Apache License +Version 2.0, January 2004 +http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. + + "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. + 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. + 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. + 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: + (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and + (b) You must cause any modified files to carry prominent notices stating that You changed the files; and + (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and + (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. + + You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. + 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. + 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. + 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. + 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. + 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + +To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. + +Copyright [yyyy] [name of copyright owner] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +ISC License +----------- +Permission to use, copy, modify, and/or distribute this software for +any purpose with or without fee is hereby granted, provided that the +above copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL +WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE +AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL +DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR +PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +PERFORMANCE OF THIS SOFTWARE. + +MIT License +----------- +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +BSD 3-Clause License +-------------------- +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + +1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +MIT No Attribution (MIT-0) +-------------------------- +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +~~~~ + +### BSD 3-Clause "New" or "Revised" License + +Used by: + +- encoding_rs 0.8.41 + +~~~~text +// Copyright © WHATWG (Apple, Google, Mozilla, Microsoft). +// +// Redistribution and use in source and binary forms, with or without +// modification, are permitted provided that the following conditions are met: +// +// 1. Redistributions of source code must retain the above copyright notice, this +// list of conditions and the following disclaimer. +// +// 2. Redistributions in binary form must reproduce the above copyright notice, +// this list of conditions and the following disclaimer in the documentation +// and/or other materials provided with the distribution. +// +// 3. Neither the name of the copyright holder nor the names of its +// contributors may be used to endorse or promote products derived from +// this software without specific prior written permission. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +// FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +// DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +// SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +// CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +// OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +/// The PUA code points special-cased in the GB18030 encoder. +pub(crate) static GB18030_2022_OVERRIDE_PUA: [u16; 18] = [ + 0xE78D, 0xE78E, 0xE78F, 0xE790, 0xE791, 0xE792, 0xE793, 0xE794, 0xE795, 0xE796, 0xE81E, 0xE826, + 0xE82B, 0xE82C, 0xE832, 0xE843, 0xE854, 0xE864, +]; + +/// The bytes corresponding to the PUA code points special-cased in the GB18030 encoder. +pub(crate) static GB18030_2022_OVERRIDE_BYTES: [[u8; 2]; 18] = [ + [0xA6, 0xD9], + [0xA6, 0xDA], + [0xA6, 0xDB], + [0xA6, 0xDC], + [0xA6, 0xDD], + [0xA6, 0xDE], + [0xA6, 0xDF], + [0xA6, 0xEC], + [0xA6, 0xED], + [0xA6, 0xF3], + [0xFE, 0x59], + [0xFE, 0x61], + [0xFE, 0x66], + [0xFE, 0x67], + [0xFE, 0x6D], + [0xFE, 0x7E], + [0xFE, 0x90], + [0xFE, 0xA0], +]; + +~~~~ + +### BSD 3-Clause "New" or "Revised" License + +Used by: + +- aws-lc-sys 0.45.0 + +~~~~text +AWS Libcrypto (AWS-LC) + +AWS-LC is a fork of BoringSSL, which is itself a fork of OpenSSL. +Content from these and other sources retains their original licensing, +as described below. New files from AWS-LC are made available under the Apache-2.0 license OR the ISC +license. These licenses are reproduced at the bottom of this file. + +``` +Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +SPDX-License-Identifier: Apache-2.0 OR ISC +``` + + +================================================================================ +BoringSSL +================================================================================ + +BoringSSL is a Google-maintained fork of OpenSSL. Historically, code +authored by Google for BoringSSL was licensed under the ISC License. +BoringSSL has since relicensed upstream to Apache License 2.0. Existing +AWS-LC code originating from BoringSSL retains its ISC license, while +newer code taken from BoringSSL is licensed under Apache License 2.0. + +``` +Copyright (c) 2014-2024 Google Inc. +SPDX-License-Identifier: ISC +``` + +Additional individual contributions to BoringSSL-derived code are +covered under the ISC license: + + - Brian Smith (Copyright 2016) + - Robert Nagy (Copyright 2022) + - Arm Ltd (Copyright 2020) + +``` +Copyright (c) 2025-2026 Google Inc. +SPDX-License-Identifier: Apache-2.0 +``` + +================================================================================ +OpenSSL +================================================================================ + +Code derived from the OpenSSL project is licensed under the +Apache License, Version 2.0. + +``` +Copyright (c) 1998-2011 The OpenSSL Project. All rights reserved. +SPDX-License-Identifier: Apache-2.0 +``` + +Some OpenSSL-derived files also carry the original SSLeay copyright: + +``` +Copyright (c) 1995-1998 Eric Young (eay@cryptsoft.com). All rights reserved. +SPDX-License-Identifier: Apache-2.0 +``` + +Portions of OpenSSL-derived code include contributions from: + + - Sun Microsystems, Inc. (Copyright 2002) + - Nokia (Copyright 2005) + - Intel Corporation (Copyright 2012-2021) + +These contributions are covered under the Apache-2.0 license. + +================================================================================ +mlkem-native +================================================================================ + +Code from the mlkem-native project is licensed under Apache License 2.0 or MIT or ISC. + +``` +Copyright (c) The mlkem-native project authors. +SPDX-License-Identifier: Apache-2.0 OR ISC OR MIT +``` + +================================================================================ +mldsa-native +================================================================================ + +Code from the mldsa-native project is licensed under Apache License 2.0 or MIT or ISC + +``` +Copyright (c) The mldsa-native project authors. +SPDX-License-Identifier: Apache-2.0 OR ISC OR MIT +``` + +================================================================================ +Third-Party Libraries (compiled into libcrypto/libssl) +================================================================================ + +Fiat Cryptography +----------------- +Synthesizing Correct-by-Construction Code for Cryptographic Primitives. +See third_party/fiat/LICENSE. + +``` +Copyright (c) 2015-2020 the fiat-crypto authors. +SPDX-License-Identifier: MIT +``` + +s2n-bignum +---------- +Integer arithmetic routines for cryptography. +See third_party/s2n-bignum/s2n-bignum-imported/LICENSE. + +``` +Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +SPDX-License-Identifier: Apache-2.0 OR ISC OR MIT-0 +``` + +Note: ML-KEM/SHA3 code within s2n-bignum is licensed as +Apache-2.0 OR ISC OR MIT (with attribution), sourced from the +mlkem-native project. + +Jitter Entropy RNG +------------------- +CPU Jitter Random Number Generator Library. +See third_party/jitterentropy/jitterentropy-library/LICENSE. + +The Jitter Entropy library is dual-licensed under a BSD-style license +and the GNU General Public License Version 2. Amazon expressly elects +to distribute the package under the 3-Clause BSD License and NOT under +GNU General Public License Version 2. + +``` +Copyright (C) 2017 - 2025, Stephan Mueller . +SPDX-License-Identifier: BSD-3-Clause +``` + +Keccak / AES Reference Implementations +--------------------------------------- +Public domain (CC0) contributions. +https://creativecommons.org/public-domain/cc0 + +Code from sources and by authors listed in comments on top of the respective files. + + +================================================================================ +Third-Party Libraries (NOT compiled into libcrypto/libssl) +================================================================================ + +The following are used for testing and build tooling only. Distributing +code linked against AWS-LC (libcrypto/libssl) does NOT trigger these +license obligations. + +Google Test +----------- +See third_party/googletest/LICENSE. + +``` +Copyright 2008 Google Inc. +SPDX-License-Identifier: BSD-3-Clause +``` + +Go Standard Library +------------------- +Code in ssl/test/runner/ is derived from the Go standard library. + +``` +Copyright (c) The Go Authors. All rights reserved. +SPDX-License-Identifier: BSD-3-Clause +``` + +Wycheproof Test Vectors +------------------------ +Project Wycheproof is a community managed repository of test vectors that can be used by cryptography library +developers to test against known attacks, specification inconsistencies, and other various implementation bugs. + +See third_party/wycheproof_testvectors/LICENSE. + +``` +SPDX-License-Identifier: Apache-2.0 +``` + + +================================================================================ +Full License Texts +================================================================================ + +Apache License 2.0 +------------------ +Apache License +Version 2.0, January 2004 +http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. + + "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. + 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. + 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. + 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: + (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and + (b) You must cause any modified files to carry prominent notices stating that You changed the files; and + (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and + (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. + + You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. + 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. + 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. + 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. + 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. + 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + +To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. + +Copyright [yyyy] [name of copyright owner] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +ISC License +----------- +Permission to use, copy, modify, and/or distribute this software for +any purpose with or without fee is hereby granted, provided that the +above copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL +WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE +AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL +DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR +PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +PERFORMANCE OF THIS SOFTWARE. + +MIT License +----------- +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +BSD 3-Clause License +-------------------- +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + +1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +MIT No Attribution (MIT-0) +-------------------------- +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +~~~~ + +### BSD 3-Clause "New" or "Revised" License + +Used by: + +- subtle 2.6.1 + +~~~~text +Copyright (c) 2016-2017 Isis Agora Lovecruft, Henry de Valence. All rights reserved. +Copyright (c) 2016-2024 Isis Agora Lovecruft. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + +1. Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright +notice, this list of conditions and the following disclaimer in the +documentation and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS +IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED +TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A +PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED +TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR +PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF +LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +~~~~ + +### BSD 3-Clause "New" or "Revised" License + +Used by: + +- encoding_rs 0.8.41 + +~~~~text +Copyright © WHATWG (Apple, Google, Mozilla, Microsoft). + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +~~~~ + +### Community Data License Agreement Permissive 2.0 + +Used by: + +- webpki-roots 1.0.9 + +~~~~text +# Community Data License Agreement - Permissive - Version 2.0 + +This is the Community Data License Agreement - Permissive, Version +2.0 (the "agreement"). Data Provider(s) and Data Recipient(s) agree +as follows: + +## 1. Provision of the Data + +1.1. A Data Recipient may use, modify, and share the Data made +available by Data Provider(s) under this agreement if that Data +Recipient follows the terms of this agreement. + +1.2. This agreement does not impose any restriction on a Data +Recipient's use, modification, or sharing of any portions of the +Data that are in the public domain or that may be used, modified, +or shared under any other legal exception or limitation. + +## 2. Conditions for Sharing Data + +2.1. A Data Recipient may share Data, with or without modifications, so +long as the Data Recipient makes available the text of this agreement +with the shared Data. + +## 3. No Restrictions on Results + +3.1. This agreement does not impose any restriction or obligations +with respect to the use, modification, or sharing of Results. + +## 4. No Warranty; Limitation of Liability + +4.1. All Data Recipients receive the Data subject to the following +terms: + +THE DATA IS PROVIDED ON AN "AS IS" BASIS, WITHOUT REPRESENTATIONS, +WARRANTIES OR CONDITIONS OF ANY KIND, EITHER EXPRESS OR IMPLIED +INCLUDING, WITHOUT LIMITATION, ANY WARRANTIES OR CONDITIONS OF TITLE, +NON-INFRINGEMENT, MERCHANTABILITY OR FITNESS FOR A PARTICULAR PURPOSE. + +NO DATA PROVIDER SHALL HAVE ANY LIABILITY FOR ANY DIRECT, INDIRECT, +INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING +WITHOUT LIMITATION LOST PROFITS), HOWEVER CAUSED AND ON ANY THEORY OF +LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE DATA OR RESULTS, +EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. + +## 5. Definitions + +5.1. "Data" means the material received by a Data Recipient under +this agreement. + +5.2. "Data Provider" means any person who is the source of Data +provided under this agreement and in reliance on a Data Recipient's +agreement to its terms. + +5.3. "Data Recipient" means any person who receives Data directly +or indirectly from a Data Provider and agrees to the terms of this +agreement. + +5.4. "Results" means any outcome obtained by computational analysis +of Data, including for example machine learning models and models' +insights. + +~~~~ + +### ISC License + +Used by: + +- untrusted 0.9.0 + +~~~~text +// Copyright 2015-2016 Brian Smith. +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHORS DISCLAIM ALL WARRANTIES +// WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +// MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR +// ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +// WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +// ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +// OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +~~~~ + +### ISC License + +Used by: + +- aws-lc-sys 0.45.0 + +~~~~text +AWS Libcrypto (AWS-LC) + +AWS-LC is a fork of BoringSSL, which is itself a fork of OpenSSL. +Content from these and other sources retains their original licensing, +as described below. New files from AWS-LC are made available under the Apache-2.0 license OR the ISC +license. These licenses are reproduced at the bottom of this file. + +``` +Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +SPDX-License-Identifier: Apache-2.0 OR ISC +``` + + +================================================================================ +BoringSSL +================================================================================ + +BoringSSL is a Google-maintained fork of OpenSSL. Historically, code +authored by Google for BoringSSL was licensed under the ISC License. +BoringSSL has since relicensed upstream to Apache License 2.0. Existing +AWS-LC code originating from BoringSSL retains its ISC license, while +newer code taken from BoringSSL is licensed under Apache License 2.0. + +``` +Copyright (c) 2014-2024 Google Inc. +SPDX-License-Identifier: ISC +``` + +Additional individual contributions to BoringSSL-derived code are +covered under the ISC license: + + - Brian Smith (Copyright 2016) + - Robert Nagy (Copyright 2022) + - Arm Ltd (Copyright 2020) + +``` +Copyright (c) 2025-2026 Google Inc. +SPDX-License-Identifier: Apache-2.0 +``` + +================================================================================ +OpenSSL +================================================================================ + +Code derived from the OpenSSL project is licensed under the +Apache License, Version 2.0. + +``` +Copyright (c) 1998-2011 The OpenSSL Project. All rights reserved. +SPDX-License-Identifier: Apache-2.0 +``` + +Some OpenSSL-derived files also carry the original SSLeay copyright: + +``` +Copyright (c) 1995-1998 Eric Young (eay@cryptsoft.com). All rights reserved. +SPDX-License-Identifier: Apache-2.0 +``` + +Portions of OpenSSL-derived code include contributions from: + + - Sun Microsystems, Inc. (Copyright 2002) + - Nokia (Copyright 2005) + - Intel Corporation (Copyright 2012-2021) + +These contributions are covered under the Apache-2.0 license. + +================================================================================ +mlkem-native +================================================================================ + +Code from the mlkem-native project is licensed under Apache License 2.0 or MIT or ISC. + +``` +Copyright (c) The mlkem-native project authors. +SPDX-License-Identifier: Apache-2.0 OR ISC OR MIT +``` + +================================================================================ +mldsa-native +================================================================================ + +Code from the mldsa-native project is licensed under Apache License 2.0 or MIT or ISC + +``` +Copyright (c) The mldsa-native project authors. +SPDX-License-Identifier: Apache-2.0 OR ISC OR MIT +``` + +================================================================================ +Third-Party Libraries (compiled into libcrypto/libssl) +================================================================================ + +Fiat Cryptography +----------------- +Synthesizing Correct-by-Construction Code for Cryptographic Primitives. +See third_party/fiat/LICENSE. + +``` +Copyright (c) 2015-2020 the fiat-crypto authors. +SPDX-License-Identifier: MIT +``` + +s2n-bignum +---------- +Integer arithmetic routines for cryptography. +See third_party/s2n-bignum/s2n-bignum-imported/LICENSE. + +``` +Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +SPDX-License-Identifier: Apache-2.0 OR ISC OR MIT-0 +``` + +Note: ML-KEM/SHA3 code within s2n-bignum is licensed as +Apache-2.0 OR ISC OR MIT (with attribution), sourced from the +mlkem-native project. + +Jitter Entropy RNG +------------------- +CPU Jitter Random Number Generator Library. +See third_party/jitterentropy/jitterentropy-library/LICENSE. + +The Jitter Entropy library is dual-licensed under a BSD-style license +and the GNU General Public License Version 2. Amazon expressly elects +to distribute the package under the 3-Clause BSD License and NOT under +GNU General Public License Version 2. + +``` +Copyright (C) 2017 - 2025, Stephan Mueller . +SPDX-License-Identifier: BSD-3-Clause +``` + +Keccak / AES Reference Implementations +--------------------------------------- +Public domain (CC0) contributions. +https://creativecommons.org/public-domain/cc0 + +Code from sources and by authors listed in comments on top of the respective files. + + +================================================================================ +Third-Party Libraries (NOT compiled into libcrypto/libssl) +================================================================================ + +The following are used for testing and build tooling only. Distributing +code linked against AWS-LC (libcrypto/libssl) does NOT trigger these +license obligations. + +Google Test +----------- +See third_party/googletest/LICENSE. + +``` +Copyright 2008 Google Inc. +SPDX-License-Identifier: BSD-3-Clause +``` + +Go Standard Library +------------------- +Code in ssl/test/runner/ is derived from the Go standard library. + +``` +Copyright (c) The Go Authors. All rights reserved. +SPDX-License-Identifier: BSD-3-Clause +``` + +Wycheproof Test Vectors +------------------------ +Project Wycheproof is a community managed repository of test vectors that can be used by cryptography library +developers to test against known attacks, specification inconsistencies, and other various implementation bugs. + +See third_party/wycheproof_testvectors/LICENSE. + +``` +SPDX-License-Identifier: Apache-2.0 +``` + + +================================================================================ +Full License Texts +================================================================================ + +Apache License 2.0 +------------------ +Apache License +Version 2.0, January 2004 +http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. + + "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. + 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. + 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. + 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: + (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and + (b) You must cause any modified files to carry prominent notices stating that You changed the files; and + (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and + (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. + + You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. + 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. + 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. + 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. + 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. + 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + +To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. + +Copyright [yyyy] [name of copyright owner] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +ISC License +----------- +Permission to use, copy, modify, and/or distribute this software for +any purpose with or without fee is hereby granted, provided that the +above copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL +WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE +AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL +DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR +PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +PERFORMANCE OF THIS SOFTWARE. + +MIT License +----------- +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +BSD 3-Clause License +-------------------- +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + +1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +MIT No Attribution (MIT-0) +-------------------------- +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +~~~~ + +### ISC License + +Used by: + +- rustls-webpki 0.103.15 + +~~~~text +Except as otherwise noted, this project is licensed under the following +(ISC-style) terms: + +Copyright 2015 Brian Smith. + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHORS DISCLAIM ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +The files under third-party/chromium are licensed as described in +third-party/chromium/LICENSE. + +~~~~ + +### ISC License + +Used by: + +- aws-lc-rs 1.18.1 + +~~~~text +SPDX-License-Identifier: ISC AND (Apache-2.0 OR ISC) + + +Apache 2.0 license +------------------------------------- + + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + +ISC license +------------------------------------- + + +Copyright Amazon.com, Inc. or its affiliates. + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- dotenvy 0.15.7 + +~~~~text +# The MIT License (MIT) + +Copyright (c) 2014 Santiago Lapresta and contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +~~~~ + +### MIT License + +Used by: + +- atomic-waker 1.1.2 +- futures-lite 2.6.1 + +~~~~text +=============================================================================== + +Copyright (c) 2016 Alex Crichton +Copyright (c) 2017 The Tokio Authors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +=============================================================================== + +Copyright (c) 2016 Alex Crichton +Copyright (c) 2017 The Tokio Authors + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- aws-lc-sys 0.45.0 + +~~~~text +AWS Libcrypto (AWS-LC) + +AWS-LC is a fork of BoringSSL, which is itself a fork of OpenSSL. +Content from these and other sources retains their original licensing, +as described below. New files from AWS-LC are made available under the Apache-2.0 license OR the ISC +license. These licenses are reproduced at the bottom of this file. + +``` +Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +SPDX-License-Identifier: Apache-2.0 OR ISC +``` + + +================================================================================ +BoringSSL +================================================================================ + +BoringSSL is a Google-maintained fork of OpenSSL. Historically, code +authored by Google for BoringSSL was licensed under the ISC License. +BoringSSL has since relicensed upstream to Apache License 2.0. Existing +AWS-LC code originating from BoringSSL retains its ISC license, while +newer code taken from BoringSSL is licensed under Apache License 2.0. + +``` +Copyright (c) 2014-2024 Google Inc. +SPDX-License-Identifier: ISC +``` + +Additional individual contributions to BoringSSL-derived code are +covered under the ISC license: + + - Brian Smith (Copyright 2016) + - Robert Nagy (Copyright 2022) + - Arm Ltd (Copyright 2020) + +``` +Copyright (c) 2025-2026 Google Inc. +SPDX-License-Identifier: Apache-2.0 +``` + +================================================================================ +OpenSSL +================================================================================ + +Code derived from the OpenSSL project is licensed under the +Apache License, Version 2.0. + +``` +Copyright (c) 1998-2011 The OpenSSL Project. All rights reserved. +SPDX-License-Identifier: Apache-2.0 +``` + +Some OpenSSL-derived files also carry the original SSLeay copyright: + +``` +Copyright (c) 1995-1998 Eric Young (eay@cryptsoft.com). All rights reserved. +SPDX-License-Identifier: Apache-2.0 +``` + +Portions of OpenSSL-derived code include contributions from: + + - Sun Microsystems, Inc. (Copyright 2002) + - Nokia (Copyright 2005) + - Intel Corporation (Copyright 2012-2021) + +These contributions are covered under the Apache-2.0 license. + +================================================================================ +mlkem-native +================================================================================ + +Code from the mlkem-native project is licensed under Apache License 2.0 or MIT or ISC. + +``` +Copyright (c) The mlkem-native project authors. +SPDX-License-Identifier: Apache-2.0 OR ISC OR MIT +``` + +================================================================================ +mldsa-native +================================================================================ + +Code from the mldsa-native project is licensed under Apache License 2.0 or MIT or ISC + +``` +Copyright (c) The mldsa-native project authors. +SPDX-License-Identifier: Apache-2.0 OR ISC OR MIT +``` + +================================================================================ +Third-Party Libraries (compiled into libcrypto/libssl) +================================================================================ + +Fiat Cryptography +----------------- +Synthesizing Correct-by-Construction Code for Cryptographic Primitives. +See third_party/fiat/LICENSE. + +``` +Copyright (c) 2015-2020 the fiat-crypto authors. +SPDX-License-Identifier: MIT +``` + +s2n-bignum +---------- +Integer arithmetic routines for cryptography. +See third_party/s2n-bignum/s2n-bignum-imported/LICENSE. + +``` +Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +SPDX-License-Identifier: Apache-2.0 OR ISC OR MIT-0 +``` + +Note: ML-KEM/SHA3 code within s2n-bignum is licensed as +Apache-2.0 OR ISC OR MIT (with attribution), sourced from the +mlkem-native project. + +Jitter Entropy RNG +------------------- +CPU Jitter Random Number Generator Library. +See third_party/jitterentropy/jitterentropy-library/LICENSE. + +The Jitter Entropy library is dual-licensed under a BSD-style license +and the GNU General Public License Version 2. Amazon expressly elects +to distribute the package under the 3-Clause BSD License and NOT under +GNU General Public License Version 2. + +``` +Copyright (C) 2017 - 2025, Stephan Mueller . +SPDX-License-Identifier: BSD-3-Clause +``` + +Keccak / AES Reference Implementations +--------------------------------------- +Public domain (CC0) contributions. +https://creativecommons.org/public-domain/cc0 + +Code from sources and by authors listed in comments on top of the respective files. + + +================================================================================ +Third-Party Libraries (NOT compiled into libcrypto/libssl) +================================================================================ + +The following are used for testing and build tooling only. Distributing +code linked against AWS-LC (libcrypto/libssl) does NOT trigger these +license obligations. + +Google Test +----------- +See third_party/googletest/LICENSE. + +``` +Copyright 2008 Google Inc. +SPDX-License-Identifier: BSD-3-Clause +``` + +Go Standard Library +------------------- +Code in ssl/test/runner/ is derived from the Go standard library. + +``` +Copyright (c) The Go Authors. All rights reserved. +SPDX-License-Identifier: BSD-3-Clause +``` + +Wycheproof Test Vectors +------------------------ +Project Wycheproof is a community managed repository of test vectors that can be used by cryptography library +developers to test against known attacks, specification inconsistencies, and other various implementation bugs. + +See third_party/wycheproof_testvectors/LICENSE. + +``` +SPDX-License-Identifier: Apache-2.0 +``` + + +================================================================================ +Full License Texts +================================================================================ + +Apache License 2.0 +------------------ +Apache License +Version 2.0, January 2004 +http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. + + "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. + 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. + 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. + 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: + (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and + (b) You must cause any modified files to carry prominent notices stating that You changed the files; and + (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and + (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. + + You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. + 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. + 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. + 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. + 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. + 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + +To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. + +Copyright [yyyy] [name of copyright owner] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +ISC License +----------- +Permission to use, copy, modify, and/or distribute this software for +any purpose with or without fee is hereby granted, provided that the +above copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL +WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE +AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL +DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR +PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +PERFORMANCE OF THIS SOFTWARE. + +MIT License +----------- +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +BSD 3-Clause License +-------------------- +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + +1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +MIT No Attribution (MIT-0) +-------------------------- +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- sqlx-core 0.9.0 +- sqlx-macros-core 0.9.0 +- sqlx-macros 0.9.0 +- sqlx-mysql 0.9.0 +- sqlx-sqlite 0.9.0 +- sqlx 0.9.0 + +~~~~text +Copyright (C) SQLx Contributors +Portions of this work Copyright (C) LaunchBadge, LLC + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- md-5 0.10.6 +- sha2 0.10.9 + +~~~~text +Copyright (c) 2006-2009 Graydon Hoare +Copyright (c) 2009-2013 Mozilla Foundation +Copyright (c) 2016 Artyom Pavlov + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- lazy_static 1.5.0 + +~~~~text +Copyright (c) 2010 The Rust Project Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- hex 0.4.3 + +~~~~text +Copyright (c) 2013-2014 The Rust Project Developers. +Copyright (c) 2015-2020 The rust-hex Developers + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- form_urlencoded 1.2.2 + +~~~~text +Copyright (c) 2013-2016 The rust-url developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- idna 1.1.0 +- percent-encoding 2.3.2 +- url 2.5.8 + +~~~~text +Copyright (c) 2013-2025 The rust-url developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- cfg-if 1.0.4 +- futures-timer 3.0.4 +- openssl-probe 0.2.1 +- socket2 0.6.5 + +~~~~text +Copyright (c) 2014 Alex Crichton + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- mio 1.2.3 + +~~~~text +Copyright (c) 2014 Carl Lerche and other MIO contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- errno 0.3.14 + +~~~~text +Copyright (c) 2014 Chris Wong + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- base64ct 1.8.3 + +~~~~text +Copyright (c) 2014 Steve "Sc00bz" Thomas (steve at tobtu dot com) +Copyright (c) 2021-2025 The RustCrypto Project Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- bnum 0.13.0 + +~~~~text +Copyright (c) 2014 The Rust Project Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +~~~~ + +### MIT License + +Used by: + +- bitflags 2.13.2 +- log 0.4.34 +- num-bigint-dig 0.9.1 +- num-bigint 0.4.8 +- num-complex 0.4.6 +- num-integer 0.1.47 +- num-iter 0.1.46 +- num-rational 0.4.2 +- num-traits 0.2.19 +- num 0.4.3 +- regex-automata 0.4.18 +- regex-syntax 0.8.11 +- regex 1.13.1 + +~~~~text +Copyright (c) 2014 The Rust Project Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- uuid 1.26.1 + +~~~~text +Copyright (c) 2014 The Rust Project Developers +Copyright (c) 2018 Ashley Mannix, Christopher Armstrong, Dylan DPC, Hunar Roop Kahlon + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- libsqlite3-sys 0.37.0 + +~~~~text +Copyright (c) 2014 The rusqlite developers + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- flate2 1.1.10 + +~~~~text +Copyright (c) 2014-2026 Alex Crichton + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- hyper 1.11.1 + +~~~~text +Copyright (c) 2014-2026 Sean McArthur + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- either 1.18.0 + +~~~~text +Copyright (c) 2015 + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- tempfile 3.27.0 + +~~~~text +Copyright (c) 2015 Steven Allen + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- heck 0.5.0 +- unicode-bidi 0.3.18 +- unicode-normalization 0.1.25 +- unicode-properties 0.1.4 + +~~~~text +Copyright (c) 2015 The Rust Project Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- fallible-iterator 0.2.0 + +~~~~text +Copyright (c) 2015 The rust-openssl-verify Developers + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- unicode_categories 0.1.1 + +~~~~text +Copyright (c) 2015 The unicode-categories Developers + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- hkdf 0.12.4 + +~~~~text +Copyright (c) 2015-2018 Vlad Filippov +Copyright (c) 2018-2021 RustCrypto Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- httparse 1.10.1 + +~~~~text +Copyright (c) 2015-2025 Sean McArthur + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + + +~~~~ + +### MIT License + +Used by: + +- futures-channel 0.3.34 +- futures-core 0.3.34 +- futures-executor 0.3.34 +- futures-io 0.3.34 +- futures-macro 0.3.34 +- futures-sink 0.3.34 +- futures-task 0.3.34 +- futures-util 0.3.34 +- futures 0.3.34 + +~~~~text +Copyright (c) 2016 Alex Crichton +Copyright (c) 2017 The Tokio Authors + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- hashbrown 0.16.1 +- hashbrown 0.17.1 + +~~~~text +Copyright (c) 2016 Amanieu d'Antras + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- hyper-rustls 0.27.9 +- rustls-native-certs 0.8.4 +- rustls 0.23.46 + +~~~~text +Copyright (c) 2016 Joseph Birr-Pixton + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- lock_api 0.4.14 +- parking_lot 0.12.5 +- parking_lot_core 0.9.12 +- thread_local 1.1.10 + +~~~~text +Copyright (c) 2016 The Rust Project Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- indexmap 2.14.2 + +~~~~text +Copyright (c) 2016--2017 + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- equivalent 1.0.2 + +~~~~text +Copyright (c) 2016--2023 + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- scopeguard 1.2.0 + +~~~~text +Copyright (c) 2016-2019 Ulrik Sverdrup "bluss" and scopeguard developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- cipher 0.4.4 + +~~~~text +Copyright (c) 2016-2020 RustCrypto Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- md-5 0.11.0 +- sha1 0.11.0 +- sha2 0.11.0 + +~~~~text +Copyright (c) 2016-2026 The RustCrypto Project Developers +Copyright (c) 2016 Artyom Pavlov +Copyright (c) 2009-2013 Mozilla Foundation +Copyright (c) 2006-2009 Graydon Hoare + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- digest 0.10.7 +- hmac 0.12.1 +- hmac 0.13.0 + +~~~~text +Copyright (c) 2017 Artyom Pavlov + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- pbkdf2 0.12.2 + +~~~~text +Copyright (c) 2017 Artyom Pavlov +Copyright (c) 2018-2023 The RustCrypto Project Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- memoffset 0.9.1 + +~~~~text +Copyright (c) 2017 Gilad Naaman + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +~~~~ + +### MIT License + +Used by: + +- enumflags2_derive 0.7.12 + +~~~~text +Copyright (c) 2017 Maik Klein + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- stable_deref_trait 1.2.1 + +~~~~text +Copyright (c) 2017 Robert Grosse + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +~~~~ + +### MIT License + +Used by: + +- stringprep 0.1.5 + +~~~~text +Copyright (c) 2017 The rust-stringprep Developers + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- http 1.5.0 + +~~~~text +Copyright (c) 2017 http-rs authors + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- tokio-rustls 0.26.5 + +~~~~text +Copyright (c) 2017 quininer kel + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- signal-hook-registry 1.4.8 + +~~~~text +Copyright (c) 2017 tokio-jsonrpc developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- core_detect 1.0.0 + +~~~~text +Copyright (c) 2017-2020 The Rust Project Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- enumflags2 0.7.12 + +~~~~text +Copyright (c) 2017-2023 Maik Klein, Maja Kądziołka + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- digest 0.11.3 + +~~~~text +Copyright (c) 2017-2025 RustCrypto Developers +Copyright (c) 2017 Artyom Pavlov + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- aes 0.8.4 + +~~~~text +Copyright (c) 2018 Artyom Pavlov + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- bytes 1.12.1 + +~~~~text +Copyright (c) 2018 Carl Lerche + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- smallvec 1.16.1 + +~~~~text +Copyright (c) 2018 The Servo Project Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- want 0.3.1 + +~~~~text +Copyright (c) 2018-2019 Sean McArthur + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + + +~~~~ + +### MIT License + +Used by: + +- block-buffer 0.10.4 +- block-padding 0.3.3 + +~~~~text +Copyright (c) 2018-2019 The RustCrypto Project Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- cbc 0.1.2 + +~~~~text +Copyright (c) 2018-2022 RustCrypto Developers +Copyright (c) 2018 Artyom Pavlov + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- try-lock 0.2.5 + +~~~~text +Copyright (c) 2018-2023 Sean McArthur +Copyright (c) 2016 Alex Crichton + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + + +~~~~ + +### MIT License + +Used by: + +- block-buffer 0.12.1 + +~~~~text +Copyright (c) 2018-2025 The RustCrypto Project Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- getrandom 0.3.4 + +~~~~text +Copyright (c) 2018-2025 The rust-random Project Developers +Copyright (c) 2014 The Rust Project Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- signature 3.0.0 + +~~~~text +Copyright (c) 2018-2026 RustCrypto Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- rand_core 0.10.1 + +~~~~text +Copyright (c) 2018-2026 The Rand Project Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- zeroize 1.9.0 + +~~~~text +Copyright (c) 2018-2026 The RustCrypto Project Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- getrandom 0.4.3 + +~~~~text +Copyright (c) 2018-2026 The rust-random Project Developers +Copyright (c) 2014 The Rust Project Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- slab 0.4.12 + +~~~~text +Copyright (c) 2019 Carl Lerche + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- sharded-slab 0.1.7 + +~~~~text +Copyright (c) 2019 Eliza Weisman + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- matchers 0.2.0 + +~~~~text +Copyright (c) 2019 Eliza Weisman + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- futures-intrusive 0.5.0 + +~~~~text +Copyright (c) 2019 Matthias Einwag + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- bumpalo 3.20.3 + +~~~~text +Copyright (c) 2019 Nick Fitzgerald + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- tracing-attributes 0.1.31 +- tracing-core 0.1.36 +- tracing-log 0.2.0 +- tracing-serde 0.2.0 +- tracing-subscriber 0.3.23 +- tracing 0.1.44 + +~~~~text +Copyright (c) 2019 Tokio Contributors + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- tower-service 0.3.3 + +~~~~text +Copyright (c) 2019 Tower Contributors + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- http-body-util 0.1.5 +- http-body 1.1.0 + +~~~~text +Copyright (c) 2019-2026 Sean McArthur & Hyper Contributors + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- chacha20 0.10.2 +- zeroize_derive 1.5.0 + +~~~~text +Copyright (c) 2019-2026 The RustCrypto Project Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- iana-time-zone 0.1.65 + +~~~~text +Copyright (c) 2020 Andrew D. Straw + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- const-oid 0.9.6 +- der_derive 0.7.3 + +~~~~text +Copyright (c) 2020-2022 The RustCrypto Project Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- der 0.7.10 + +~~~~text +Copyright (c) 2020-2023 The RustCrypto Project Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- cpufeatures 0.2.17 + +~~~~text +Copyright (c) 2020-2025 The RustCrypto Project Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- const-oid 0.10.2 +- cpufeatures 0.3.1 +- der 0.8.2 +- pkcs8 0.11.0 + +~~~~text +Copyright (c) 2020-2026 The RustCrypto Project Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- crypto-common 0.1.7 + +~~~~text +Copyright (c) 2021 RustCrypto Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- x509-cert 0.2.5 + +~~~~text +Copyright (c) 2021 The RustCrypto Project Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- tiberius-macros 0.1.0 +- tiberius 0.13.0 + +~~~~text +Copyright (c) 2021 Tiberius Contributors + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- spki 0.7.3 + +~~~~text +Copyright (c) 2021-2023 The RustCrypto Project Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- pem-rfc7468 1.0.0 +- pkcs1 0.8.0-rc.4 + +~~~~text +Copyright (c) 2021-2025 The RustCrypto Project Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- crypto-common 0.2.2 + +~~~~text +Copyright (c) 2021-2026 RustCrypto Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- crypto-bigint 0.7.5 +- spki 0.8.0 + +~~~~text +Copyright (c) 2021-2026 The RustCrypto Project Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- inout 0.1.4 + +~~~~text +Copyright (c) 2022 The RustCrypto Project Developers +Copyright (c) 2022 Artyom Pavlov + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- cmov 0.5.4 +- hybrid-array 0.4.15 + +~~~~text +Copyright (c) 2022-2026 The RustCrypto Project Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- rustls-pki-types 1.15.1 + +~~~~text +Copyright (c) 2023 Dirkjan Ochtman + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- hyper-util 0.1.20 + +~~~~text +Copyright (c) 2023-2025 Sean McArthur + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- cpubits 0.1.1 + +~~~~text +Copyright (c) 2023-2026 The RustCrypto Project Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- zbus 5.19.0 +- zbus_macros 5.19.0 +- zbus_names 4.3.4 +- zvariant 5.15.0 +- zvariant_derive 5.15.0 + +~~~~text +Copyright (c) 2024 Zeeshan Ali Khan & zbus contributors + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- ctutils 0.4.2 + +~~~~text +Copyright (c) 2025-2026 The RustCrypto Project Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- zcheapstr 1.1.0 + +~~~~text +Copyright (c) 2026 Zeeshan Ali Khan & zcheapstr contributors + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- toml_datetime 1.1.1+spec-1.1.0 +- toml_edit 0.25.15+spec-1.1.0 +- toml_parser 1.1.3+spec-1.1.0 + +~~~~text +Copyright (c) Individual contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- libc 0.2.189 + +~~~~text +Copyright (c) The Rust Project Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- idna_adapter 1.2.2 + +~~~~text +Copyright (c) The rust-url developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- synstructure 0.13.2 + +~~~~text +Copyright 2016 Nika Layzell + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- bnum 0.13.0 + +~~~~text +Copyright 2018 Developers of the Rand project +Copyright (c) 2014 The Rust Project Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +~~~~ + +### MIT License + +Used by: + +- rand 0.10.2 +- rand 0.9.5 +- rand_core 0.9.5 + +~~~~text +Copyright 2018 Developers of the Rand project +Copyright (c) 2014 The Rust Project Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- multiversion-macros 0.9.0 +- multiversion 0.9.0 + +~~~~text +Copyright 2019 Caleb Zulawski + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- crypto-primes 0.7.2 + +~~~~text +Copyright 2022-* Bogdan Opanchuk + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- zerocopy-derive 0.8.57 +- zerocopy 0.8.57 + +~~~~text +Copyright 2023 The Fuchsia Authors + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + + +~~~~ + +### MIT License + +Used by: + +- encoding_rs 0.8.41 +- multiversion_no_op 1.0.0 +- utf8_iter 1.0.4 + +~~~~text +Copyright Mozilla Foundation + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- field-offset 0.3.6 + +~~~~text +MIT License + +Copyright (c) 2016-2021 Diggory Blake, and other contributors. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- atoi 2.0.0 + +~~~~text +MIT License + +Copyright (c) 2017 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- crc 3.4.0 + +~~~~text +MIT License + +Copyright (c) 2017 crc-rs Developers + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- pretty-hex 0.4.2 + +~~~~text +MIT License + +Copyright (c) 2018 Andrei Volnin + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- crc32fast 1.5.2 + +~~~~text +MIT License + +Copyright (c) 2018 Sam Rijs, Alex Crichton and contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- asynchronous-codec 0.7.0 + +~~~~text +MIT License + +Copyright (c) 2019 - 2020 Matt Hunzinger +Copyright (c) 2021 Max Inden + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- crc-catalog 2.5.0 + +~~~~text +MIT License + +Copyright (c) 2019 Akhil Velagapudi + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- tokio-postgres-rustls 0.14.0 + +~~~~text +MIT License + +Copyright (c) 2019 Jasper Hugo + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- tokio-macros 2.7.2 + +~~~~text +MIT License + +Copyright (c) 2019 Yoshua Wuyts +Copyright (c) Tokio Contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- secrecy 0.10.3 + +~~~~text +MIT License + +Copyright (c) 2019-2024 iqlusion + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- ashpd 0.13.13 + +~~~~text +MIT License + +Copyright (c) 2020 Bilal Elmoussaoui + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- rustls-platform-verifier 0.7.0 + +~~~~text +MIT License + +Copyright (c) 2022 1Password + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- oo7 0.6.0 + +~~~~text +MIT License + +Copyright (c) 2022 Bilal Elmoussaoui, Sophie Herold, Maximiliano Sandoval, Dhanuka Warusadura + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- bnum 0.13.0 + +~~~~text +MIT License + +Copyright (c) 2022 Isaac Holt + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +~~~~ + +### MIT License + +Used by: + +- tokio-stream 0.1.19 +- tokio-util 0.7.19 +- tokio 1.53.1 + +~~~~text +MIT License + +Copyright (c) Tokio Contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- simd-adler32 0.3.10 + +~~~~text +MIT License + +Copyright (c) [2021] [Marvin Countryman] + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- libgssapi-sys 0.3.4 +- libgssapi 0.11.0 + +~~~~text +MIT License + +Copyright (c) [year] [fullname] + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- rust_xlsxwriter 0.99.1 + +~~~~text +MIT License + +Copyright 2022-2026 John McNamara + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + + +~~~~ + +### MIT License + +Used by: + +- simdutf8 0.1.5 + +~~~~text +MIT License + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +~~~~ + +### MIT License + +Used by: + +- half 2.7.1 +- whoami 2.1.3 + +~~~~text +MIT License + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- async-recursion 1.1.1 +- flume 0.12.0 +- rsa 0.10.0-rc.18 + +~~~~text +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +~~~~ + +### MIT License + +Used by: + +- async-trait 0.1.92 +- atomic-waker 1.1.2 +- clickhouse-macros 0.3.0 +- clickhouse-types 0.1.3 +- clickhouse 0.15.2 +- displaydoc 0.2.7 +- endi 1.1.1 +- event-listener-strategy 0.5.4 +- event-listener 5.4.2 +- fastrand 2.5.0 +- futures-lite 2.6.1 +- itoa 1.0.18 +- linux-raw-sys 0.12.1 +- once_cell 1.21.4 +- ordered-stream 0.2.0 +- parking 2.2.1 +- pin-project-lite 0.2.17 +- proc-macro-crate 3.5.0 +- proc-macro2 1.0.107 +- quote 1.0.47 +- relm4-macros 0.11.0 +- relm4 0.11.0 +- rustix 1.1.4 +- rustversion 1.0.23 +- serde 1.0.229 +- serde_bytes 0.11.19 +- serde_core 1.0.229 +- serde_derive 1.0.229 +- serde_derive_internals 0.29.1 +- serde_json 1.0.151 +- serde_repr 0.1.21 +- sqlformat 0.5.0 +- syn 2.0.119 +- syn 3.0.5 +- thiserror-impl 2.0.20 +- thiserror 2.0.20 +- typed-path 0.12.3 +- unicode-ident 1.0.24 +- zmij 1.0.23 +- zvariant_utils 4.2.0 + +~~~~text +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- allocator-api2 0.2.21 + +~~~~text +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- winnow 0.7.15 +- winnow 1.0.4 + +~~~~text +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- siphasher 1.0.4 + +~~~~text +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- cairo-rs 0.22.9 +- cairo-sys-rs 0.22.9 +- gdk-pixbuf-sys 0.22.9 +- gdk-pixbuf 0.22.0 +- gdk4-sys 0.11.4 +- gdk4 0.11.4 +- gio-sys 0.22.9 +- gio-unix-sys 0.22.9 +- gio-unix 0.22.8 +- gio 0.22.9 +- glib-macros 0.22.9 +- glib-sys 0.22.9 +- glib 0.22.9 +- gobject-sys 0.22.9 +- graphene-rs 0.22.8 +- graphene-sys 0.22.9 +- gsk4-sys 0.11.4 +- gsk4 0.11.4 +- gtk4-macros 0.11.4 +- gtk4-sys 0.11.4 +- gtk4 0.11.4 +- libadwaita-sys 0.9.2 +- libadwaita 0.9.2 +- pango-sys 0.22.9 +- pango 0.22.9 +- sourceview5-sys 0.11.2 +- sourceview5 0.11.2 + +~~~~text +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + + +~~~~ + +### MIT License + +Used by: + +- tinyvec 1.13.3 + +~~~~text +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- spin 0.9.9 + +~~~~text +The MIT License (MIT) + +Copyright (c) 2014 Mathijs van de Nes + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +~~~~ + +### MIT License + +Used by: + +- tracing-core 0.1.36 +- zip 8.6.0 + +~~~~text +The MIT License (MIT) + +Copyright (c) 2014 Mathijs van de Nes + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- typenum 1.20.1 + +~~~~text +The MIT License (MIT) + +Copyright (c) 2014 Paho Lurie-Gregg + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- chrono 0.4.45 + +~~~~text +The MIT License (MIT) + +Copyright (c) 2014, Kang Seonghoon. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +~~~~ + +### MIT License + +Used by: + +- phf 0.13.1 +- phf_shared 0.13.1 + +~~~~text +The MIT License (MIT) + +Copyright (c) 2014-2022 Steven Fackler, Yuki Okushi + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- base64 0.22.1 + +~~~~text +The MIT License (MIT) + +Copyright (c) 2015 Alice Maz + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- aho-corasick 1.1.5 +- byteorder 1.5.0 +- csv-core 0.1.13 +- csv 1.4.0 +- memchr 2.8.3 + +~~~~text +The MIT License (MIT) + +Copyright (c) 2015 Andrew Gallant + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- gettext-rs 0.8.0 +- gettext-sys 0.26.0 + +~~~~text +The MIT License (MIT) + +Copyright (c) 2016 Konstantin V. Salikhov + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- postgres-protocol 0.6.12 +- postgres-types 0.2.14 +- tokio-postgres 0.7.18 + +~~~~text +The MIT License (MIT) + +Copyright (c) 2016 Steven Fackler + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + + +~~~~ + +### MIT License + +Used by: + +- locale_config 0.3.0 + +~~~~text +The MIT License (MIT) + +Copyright (c) 2016–2019 Jan Hudec +Copyright (c) 2016 A.J. Gardner +Copyright (c) 2019, Bastien Orivel +Copyright (c) 2019, Igor Gnatenko +Copyright (c) 2019, Sophie Tauchert <999eagle@999eagle.moe> + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- bstr 1.13.1 + +~~~~text +The MIT License (MIT) + +Copyright (c) 2018-2019 Andrew Gallant + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- deadpool-postgres 0.14.2 +- deadpool-runtime 0.3.1 +- deadpool 0.13.1 + +~~~~text +The MIT License (MIT) + +Copyright (c) 2019 Michael P. Jung + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + +~~~~ + +### MIT License + +Used by: + +- crossbeam-queue 0.3.14 +- crossbeam-utils 0.8.23 + +~~~~text +The MIT License (MIT) + +Copyright (c) 2019 The Crossbeam Project Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- async-broadcast 0.7.2 +- connection-string 0.2.0 + +~~~~text +The MIT License (MIT) + +Copyright (c) 2020 Yoshua Wuyts + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- nu-ansi-term 0.50.3 + +~~~~text +The MIT License (MIT) + +Copyright (c) 2014 Benjamin Sago +Copyright (c) 2021-2022 The Nushell Project Developers + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- generic-array 0.14.7 + +~~~~text +The MIT License (MIT) + +Copyright (c) 2015 Bartłomiej Kamiński + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +~~~~ + +### MIT License + +Used by: + +- hashlink 0.11.1 + +~~~~text +This work is derived in part from the `linked-hash-map` crate, Copyright (c) +2015 The Rust Project Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +~~~~ + +### MIT License + +Used by: + +- libm 0.2.16 + +~~~~text +rust-lang/libm as a whole is available for use under the MIT license: + +------------------------------------------------------------------------------ +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +------------------------------------------------------------------------------ + +As a contributor, you agree that your code can be used under either the MIT +license or the Apache-2.0 license: + +------------------------------------------------------------------------------ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +------------------------------------------------------------------------------ + +This Rust library contains the following copyrights: + + Copyright (c) 2018 Jorge Aparicio + +Portions of this software are derived from third-party works licensed under +terms compatible with the above MIT license: + +* musl libc https://www.musl-libc.org/. This library contains the following + copyright: + + Copyright © 2005-2020 Rich Felker, et al. + +* The CORE-MATH project https://core-math.gitlabpages.inria.fr/. CORE-MATH + routines are available under the MIT license on a per-file basis. + +The musl libc COPYRIGHT file also includes the following notice relevant to +math portions of the library: + +------------------------------------------------------------------------------ +Much of the math library code (src/math/* and src/complex/*) is +Copyright © 1993,2004 Sun Microsystems or +Copyright © 2003-2011 David Schultz or +Copyright © 2003-2009 Steven G. Kargl or +Copyright © 2003-2009 Bruce D. Evans or +Copyright © 2008 Stephen L. Moshier or +Copyright © 2017-2018 Arm Limited +and labelled as such in comments in the individual source files. All +have been licensed under extremely permissive terms. +------------------------------------------------------------------------------ + +Copyright notices are retained in src/* files where relevant. + +~~~~ + +### Unicode License v3 + +Used by: + +- unicode-ident 1.0.24 + +~~~~text +UNICODE LICENSE V3 + +COPYRIGHT AND PERMISSION NOTICE + +Copyright © 1991-2023 Unicode, Inc. + +NOTICE TO USER: Carefully read the following legal agreement. BY +DOWNLOADING, INSTALLING, COPYING OR OTHERWISE USING DATA FILES, AND/OR +SOFTWARE, YOU UNEQUIVOCALLY ACCEPT, AND AGREE TO BE BOUND BY, ALL OF THE +TERMS AND CONDITIONS OF THIS AGREEMENT. IF YOU DO NOT AGREE, DO NOT +DOWNLOAD, INSTALL, COPY, DISTRIBUTE OR USE THE DATA FILES OR SOFTWARE. + +Permission is hereby granted, free of charge, to any person obtaining a +copy of data files and any associated documentation (the "Data Files") or +software and any associated documentation (the "Software") to deal in the +Data Files or Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, and/or sell +copies of the Data Files or Software, and to permit persons to whom the +Data Files or Software are furnished to do so, provided that either (a) +this copyright and permission notice appear with all copies of the Data +Files or Software, or (b) this copyright and permission notice appear in +associated Documentation. + +THE DATA FILES AND SOFTWARE ARE PROVIDED "AS IS", WITHOUT WARRANTY OF ANY +KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF +THIRD PARTY RIGHTS. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER OR HOLDERS INCLUDED IN THIS NOTICE +BE LIABLE FOR ANY CLAIM, OR ANY SPECIAL INDIRECT OR CONSEQUENTIAL DAMAGES, +OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, +WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, +ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THE DATA +FILES OR SOFTWARE. + +Except as contained in this notice, the name of a copyright holder shall +not be used in advertising or otherwise to promote the sale, use or other +dealings in these Data Files or Software without prior written +authorization of the copyright holder. + +~~~~ + +### Unicode License v3 + +Used by: + +- fixed_decimal 0.7.2 +- icu_collections 2.3.0 +- icu_decimal 2.3.0 +- icu_decimal_data 2.3.0 +- icu_locale_core 2.3.0 +- icu_locale_fallback 2.3.0 +- icu_locale_fallback_data 2.3.0 +- icu_normalizer 2.3.0 +- icu_normalizer_data 2.3.0 +- icu_properties 2.3.0 +- icu_properties_data 2.3.0 +- icu_provider 2.3.1 +- litemap 0.8.3 +- potential_utf 0.1.6 +- tinystr 0.8.4 +- writeable 0.6.4 +- yoke-derive 0.8.2 +- yoke 0.8.3 +- zerofrom-derive 0.1.7 +- zerofrom 0.1.8 +- zerotrie 0.2.5 +- zerovec-derive 0.11.6 +- zerovec 0.11.8 + +~~~~text +UNICODE LICENSE V3 + +COPYRIGHT AND PERMISSION NOTICE + +Copyright © 2020-2024 Unicode, Inc. + +NOTICE TO USER: Carefully read the following legal agreement. BY +DOWNLOADING, INSTALLING, COPYING OR OTHERWISE USING DATA FILES, AND/OR +SOFTWARE, YOU UNEQUIVOCALLY ACCEPT, AND AGREE TO BE BOUND BY, ALL OF THE +TERMS AND CONDITIONS OF THIS AGREEMENT. IF YOU DO NOT AGREE, DO NOT +DOWNLOAD, INSTALL, COPY, DISTRIBUTE OR USE THE DATA FILES OR SOFTWARE. + +Permission is hereby granted, free of charge, to any person obtaining a +copy of data files and any associated documentation (the "Data Files") or +software and any associated documentation (the "Software") to deal in the +Data Files or Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, and/or sell +copies of the Data Files or Software, and to permit persons to whom the +Data Files or Software are furnished to do so, provided that either (a) +this copyright and permission notice appear with all copies of the Data +Files or Software, or (b) this copyright and permission notice appear in +associated Documentation. + +THE DATA FILES AND SOFTWARE ARE PROVIDED "AS IS", WITHOUT WARRANTY OF ANY +KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF +THIRD PARTY RIGHTS. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER OR HOLDERS INCLUDED IN THIS NOTICE +BE LIABLE FOR ANY CLAIM, OR ANY SPECIAL INDIRECT OR CONSEQUENTIAL DAMAGES, +OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, +WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, +ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THE DATA +FILES OR SOFTWARE. + +Except as contained in this notice, the name of a copyright holder shall +not be used in advertising or otherwise to promote the sale, use or other +dealings in these Data Files or Software without prior written +authorization of the copyright holder. + +SPDX-License-Identifier: Unicode-3.0 + +— + +Portions of ICU4X may have been adapted from ICU4C and/or ICU4J. +ICU 1.8.1 to ICU 57.1 © 1995-2016 International Business Machines Corporation and others. + +~~~~ + +### zlib License + +Used by: + +- zlib-rs 0.6.7 + +~~~~text +(C) 2024 Trifecta Tech Foundation + +This software is provided 'as-is', without any express or implied +warranty. In no event will the authors be held liable for any damages +arising from the use of this software. + +Permission is granted to anyone to use this software for any purpose, +including commercial applications, and to alter it and redistribute it +freely, subject to the following restrictions: + +1. The origin of this software must not be misrepresented; you must not + claim that you wrote the original software. If you use this software + in a product, an acknowledgment in the product documentation would be + appreciated but is not required. + +2. Altered source versions must be plainly marked as such, and must not be + misrepresented as being the original software. + +3. This notice may not be removed or altered from any source distribution. + +~~~~ + +### zlib License + +Used by: + +- foldhash 0.2.0 + +~~~~text +Copyright (c) 2024 Orson Peters + +This software is provided 'as-is', without any express or implied warranty. In +no event will the authors be held liable for any damages arising from the use of +this software. + +Permission is granted to anyone to use this software for any purpose, including +commercial applications, and to alter it and redistribute it freely, subject to +the following restrictions: + +1. The origin of this software must not be misrepresented; you must not claim + that you wrote the original software. If you use this software in a product, + an acknowledgment in the product documentation would be appreciated but is + not required. + +2. Altered source versions must be plainly marked as such, and must not be + misrepresented as being the original software. + +3. This notice may not be removed or altered from any source distribution. +~~~~ diff --git a/linux/data/licenses/third-party.json b/linux/data/licenses/third-party.json new file mode 100644 index 0000000000..c41fdddd2f --- /dev/null +++ b/linux/data/licenses/third-party.json @@ -0,0 +1,1481 @@ +{ + "licenses": [ + { + "id": "Apache-2.0", + "name": "Apache License 2.0", + "text": "\n Apache License\n Version 2.0, January 2004\n http://www.apache.org/licenses/\n\n TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n 1. Definitions.\n\n \"License\" shall mean the terms and conditions for use, reproduction,\n and distribution as defined by Sections 1 through 9 of this document.\n\n \"Licensor\" shall mean the copyright owner or entity authorized by\n the copyright owner that is granting the License.\n\n \"Legal Entity\" shall mean the union of the acting entity and all\n other entities that control, are controlled by, or are under common\n control with that entity. For the purposes of this definition,\n \"control\" means (i) the power, direct or indirect, to cause the\n direction or management of such entity, whether by contract or\n otherwise, or (ii) ownership of fifty percent (50%) or more of the\n outstanding shares, or (iii) beneficial ownership of such entity.\n\n \"You\" (or \"Your\") shall mean an individual or Legal Entity\n exercising permissions granted by this License.\n\n \"Source\" form shall mean the preferred form for making modifications,\n including but not limited to software source code, documentation\n source, and configuration files.\n\n \"Object\" form shall mean any form resulting from mechanical\n transformation or translation of a Source form, including but\n not limited to compiled object code, generated documentation,\n and conversions to other media types.\n\n \"Work\" shall mean the work of authorship, whether in Source or\n Object form, made available under the License, as indicated by a\n copyright notice that is included in or attached to the work\n (an example is provided in the Appendix below).\n\n \"Derivative Works\" shall mean any work, whether in Source or Object\n form, that is based on (or derived from) the Work and for which the\n editorial revisions, annotations, elaborations, or other modifications\n represent, as a whole, an original work of authorship. For the purposes\n of this License, Derivative Works shall not include works that remain\n separable from, or merely link (or bind by name) to the interfaces of,\n the Work and Derivative Works thereof.\n\n \"Contribution\" shall mean any work of authorship, including\n the original version of the Work and any modifications or additions\n to that Work or Derivative Works thereof, that is intentionally\n submitted to Licensor for inclusion in the Work by the copyright owner\n or by an individual or Legal Entity authorized to submit on behalf of\n the copyright owner. For the purposes of this definition, \"submitted\"\n means any form of electronic, verbal, or written communication sent\n to the Licensor or its representatives, including but not limited to\n communication on electronic mailing lists, source code control systems,\n and issue tracking systems that are managed by, or on behalf of, the\n Licensor for the purpose of discussing and improving the Work, but\n excluding communication that is conspicuously marked or otherwise\n designated in writing by the copyright owner as \"Not a Contribution.\"\n\n \"Contributor\" shall mean Licensor and any individual or Legal Entity\n on behalf of whom a Contribution has been received by Licensor and\n subsequently incorporated within the Work.\n\n 2. Grant of Copyright License. Subject to the terms and conditions of\n this License, each Contributor hereby grants to You a perpetual,\n worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n copyright license to reproduce, prepare Derivative Works of,\n publicly display, publicly perform, sublicense, and distribute the\n Work and such Derivative Works in Source or Object form.\n\n 3. Grant of Patent License. Subject to the terms and conditions of\n this License, each Contributor hereby grants to You a perpetual,\n worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n (except as stated in this section) patent license to make, have made,\n use, offer to sell, sell, import, and otherwise transfer the Work,\n where such license applies only to those patent claims licensable\n by such Contributor that are necessarily infringed by their\n Contribution(s) alone or by combination of their Contribution(s)\n with the Work to which such Contribution(s) was submitted. If You\n institute patent litigation against any entity (including a\n cross-claim or counterclaim in a lawsuit) alleging that the Work\n or a Contribution incorporated within the Work constitutes direct\n or contributory patent infringement, then any patent licenses\n granted to You under this License for that Work shall terminate\n as of the date such litigation is filed.\n\n 4. Redistribution. You may reproduce and distribute copies of the\n Work or Derivative Works thereof in any medium, with or without\n modifications, and in Source or Object form, provided that You\n meet the following conditions:\n\n (a) You must give any other recipients of the Work or\n Derivative Works a copy of this License; and\n\n (b) You must cause any modified files to carry prominent notices\n stating that You changed the files; and\n\n (c) You must retain, in the Source form of any Derivative Works\n that You distribute, all copyright, patent, trademark, and\n attribution notices from the Source form of the Work,\n excluding those notices that do not pertain to any part of\n the Derivative Works; and\n\n (d) If the Work includes a \"NOTICE\" text file as part of its\n distribution, then any Derivative Works that You distribute must\n include a readable copy of the attribution notices contained\n within such NOTICE file, excluding those notices that do not\n pertain to any part of the Derivative Works, in at least one\n of the following places: within a NOTICE text file distributed\n as part of the Derivative Works; within the Source form or\n documentation, if provided along with the Derivative Works; or,\n within a display generated by the Derivative Works, if and\n wherever such third-party notices normally appear. The contents\n of the NOTICE file are for informational purposes only and\n do not modify the License. You may add Your own attribution\n notices within Derivative Works that You distribute, alongside\n or as an addendum to the NOTICE text from the Work, provided\n that such additional attribution notices cannot be construed\n as modifying the License.\n\n You may add Your own copyright statement to Your modifications and\n may provide additional or different license terms and conditions\n for use, reproduction, or distribution of Your modifications, or\n for any such Derivative Works as a whole, provided Your use,\n reproduction, and distribution of the Work otherwise complies with\n the conditions stated in this License.\n\n 5. Submission of Contributions. Unless You explicitly state otherwise,\n any Contribution intentionally submitted for inclusion in the Work\n by You to the Licensor shall be under the terms and conditions of\n this License, without any additional terms or conditions.\n Notwithstanding the above, nothing herein shall supersede or modify\n the terms of any separate license agreement you may have executed\n with Licensor regarding such Contributions.\n\n 6. Trademarks. This License does not grant permission to use the trade\n names, trademarks, service marks, or product names of the Licensor,\n except as required for reasonable and customary use in describing the\n origin of the Work and reproducing the content of the NOTICE file.\n\n 7. Disclaimer of Warranty. Unless required by applicable law or\n agreed to in writing, Licensor provides the Work (and each\n Contributor provides its Contributions) on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or\n implied, including, without limitation, any warranties or conditions\n of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n PARTICULAR PURPOSE. You are solely responsible for determining the\n appropriateness of using or redistributing the Work and assume any\n risks associated with Your exercise of permissions under this License.\n\n 8. Limitation of Liability. In no event and under no legal theory,\n whether in tort (including negligence), contract, or otherwise,\n unless required by applicable law (such as deliberate and grossly\n negligent acts) or agreed to in writing, shall any Contributor be\n liable to You for damages, including any direct, indirect, special,\n incidental, or consequential damages of any character arising as a\n result of this License or out of the use or inability to use the\n Work (including but not limited to damages for loss of goodwill,\n work stoppage, computer failure or malfunction, or any and all\n other commercial damages or losses), even if such Contributor\n has been advised of the possibility of such damages.\n\n 9. Accepting Warranty or Additional Liability. While redistributing\n the Work or Derivative Works thereof, You may choose to offer,\n and charge a fee for, acceptance of support, warranty, indemnity,\n or other liability obligations and/or rights consistent with this\n License. However, in accepting such obligations, You may act only\n on Your own behalf and on Your sole responsibility, not on behalf\n of any other Contributor, and only if You agree to indemnify,\n defend, and hold each Contributor harmless for any liability\n incurred by, or claims asserted against, such Contributor by reason\n of your accepting any such warranty or additional liability.\n\n END OF TERMS AND CONDITIONS\n\n APPENDIX: How to apply the Apache License to your work.\n\n To apply the Apache License to your work, attach the following\n boilerplate notice, with the fields enclosed by brackets \"[]\"\n replaced with your own identifying information. (Don't include\n the brackets!) The text should be enclosed in the appropriate\n comment syntax for the file format. We also recommend that a\n file or class name and description of purpose be included on the\n same \"printed page\" as the copyright notice for easier\n identification within third-party archives.\n\n Copyright [yyyy] [name of copyright owner]\n\n Licensed under the Apache License, Version 2.0 (the \"License\");\n you may not use this file except in compliance with the License.\n You may obtain a copy of the License at\n\n http://www.apache.org/licenses/LICENSE-2.0\n\n Unless required by applicable law or agreed to in writing, software\n distributed under the License is distributed on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n See the License for the specific language governing permissions and\n limitations under the License.\n", + "used_by": [ + { "crate": { "name": "flagset", "version": "0.4.7" } }, + { "crate": { "name": "fragile", "version": "2.1.0" } } + ] + }, + { + "id": "Apache-2.0", + "name": "Apache License 2.0", + "text": " Apache License\n Version 2.0, January 2004\n http://www.apache.org/licenses/\n\n TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n 1. Definitions.\n\n \"License\" shall mean the terms and conditions for use, reproduction,\n and distribution as defined by Sections 1 through 9 of this document.\n\n \"Licensor\" shall mean the copyright owner or entity authorized by\n the copyright owner that is granting the License.\n\n \"Legal Entity\" shall mean the union of the acting entity and all\n other entities that control, are controlled by, or are under common\n control with that entity. For the purposes of this definition,\n \"control\" means (i) the power, direct or indirect, to cause the\n direction or management of such entity, whether by contract or\n otherwise, or (ii) ownership of fifty percent (50%) or more of the\n outstanding shares, or (iii) beneficial ownership of such entity.\n\n \"You\" (or \"Your\") shall mean an individual or Legal Entity\n exercising permissions granted by this License.\n\n \"Source\" form shall mean the preferred form for making modifications,\n including but not limited to software source code, documentation\n source, and configuration files.\n\n \"Object\" form shall mean any form resulting from mechanical\n transformation or translation of a Source form, including but\n not limited to compiled object code, generated documentation,\n and conversions to other media types.\n\n \"Work\" shall mean the work of authorship, whether in Source or\n Object form, made available under the License, as indicated by a\n copyright notice that is included in or attached to the work\n (an example is provided in the Appendix below).\n\n \"Derivative Works\" shall mean any work, whether in Source or Object\n form, that is based on (or derived from) the Work and for which the\n editorial revisions, annotations, elaborations, or other modifications\n represent, as a whole, an original work of authorship. For the purposes\n of this License, Derivative Works shall not include works that remain\n separable from, or merely link (or bind by name) to the interfaces of,\n the Work and Derivative Works thereof.\n\n \"Contribution\" shall mean any work of authorship, including\n the original version of the Work and any modifications or additions\n to that Work or Derivative Works thereof, that is intentionally\n submitted to Licensor for inclusion in the Work by the copyright owner\n or by an individual or Legal Entity authorized to submit on behalf of\n the copyright owner. For the purposes of this definition, \"submitted\"\n means any form of electronic, verbal, or written communication sent\n to the Licensor or its representatives, including but not limited to\n communication on electronic mailing lists, source code control systems,\n and issue tracking systems that are managed by, or on behalf of, the\n Licensor for the purpose of discussing and improving the Work, but\n excluding communication that is conspicuously marked or otherwise\n designated in writing by the copyright owner as \"Not a Contribution.\"\n\n \"Contributor\" shall mean Licensor and any individual or Legal Entity\n on behalf of whom a Contribution has been received by Licensor and\n subsequently incorporated within the Work.\n\n 2. Grant of Copyright License. Subject to the terms and conditions of\n this License, each Contributor hereby grants to You a perpetual,\n worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n copyright license to reproduce, prepare Derivative Works of,\n publicly display, publicly perform, sublicense, and distribute the\n Work and such Derivative Works in Source or Object form.\n\n 3. Grant of Patent License. Subject to the terms and conditions of\n this License, each Contributor hereby grants to You a perpetual,\n worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n (except as stated in this section) patent license to make, have made,\n use, offer to sell, sell, import, and otherwise transfer the Work,\n where such license applies only to those patent claims licensable\n by such Contributor that are necessarily infringed by their\n Contribution(s) alone or by combination of their Contribution(s)\n with the Work to which such Contribution(s) was submitted. If You\n institute patent litigation against any entity (including a\n cross-claim or counterclaim in a lawsuit) alleging that the Work\n or a Contribution incorporated within the Work constitutes direct\n or contributory patent infringement, then any patent licenses\n granted to You under this License for that Work shall terminate\n as of the date such litigation is filed.\n\n 4. Redistribution. You may reproduce and distribute copies of the\n Work or Derivative Works thereof in any medium, with or without\n modifications, and in Source or Object form, provided that You\n meet the following conditions:\n\n (a) You must give any other recipients of the Work or\n Derivative Works a copy of this License; and\n\n (b) You must cause any modified files to carry prominent notices\n stating that You changed the files; and\n\n (c) You must retain, in the Source form of any Derivative Works\n that You distribute, all copyright, patent, trademark, and\n attribution notices from the Source form of the Work,\n excluding those notices that do not pertain to any part of\n the Derivative Works; and\n\n (d) If the Work includes a \"NOTICE\" text file as part of its\n distribution, then any Derivative Works that You distribute must\n include a readable copy of the attribution notices contained\n within such NOTICE file, excluding those notices that do not\n pertain to any part of the Derivative Works, in at least one\n of the following places: within a NOTICE text file distributed\n as part of the Derivative Works; within the Source form or\n documentation, if provided along with the Derivative Works; or,\n within a display generated by the Derivative Works, if and\n wherever such third-party notices normally appear. The contents\n of the NOTICE file are for informational purposes only and\n do not modify the License. You may add Your own attribution\n notices within Derivative Works that You distribute, alongside\n or as an addendum to the NOTICE text from the Work, provided\n that such additional attribution notices cannot be construed\n as modifying the License.\n\n You may add Your own copyright statement to Your modifications and\n may provide additional or different license terms and conditions\n for use, reproduction, or distribution of Your modifications, or\n for any such Derivative Works as a whole, provided Your use,\n reproduction, and distribution of the Work otherwise complies with\n the conditions stated in this License.\n\n 5. Submission of Contributions. Unless You explicitly state otherwise,\n any Contribution intentionally submitted for inclusion in the Work\n by You to the Licensor shall be under the terms and conditions of\n this License, without any additional terms or conditions.\n Notwithstanding the above, nothing herein shall supersede or modify\n the terms of any separate license agreement you may have executed\n with Licensor regarding such Contributions.\n\n 6. Trademarks. This License does not grant permission to use the trade\n names, trademarks, service marks, or product names of the Licensor,\n except as required for reasonable and customary use in describing the\n origin of the Work and reproducing the content of the NOTICE file.\n\n 7. Disclaimer of Warranty. Unless required by applicable law or\n agreed to in writing, Licensor provides the Work (and each\n Contributor provides its Contributions) on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or\n implied, including, without limitation, any warranties or conditions\n of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n PARTICULAR PURPOSE. You are solely responsible for determining the\n appropriateness of using or redistributing the Work and assume any\n risks associated with Your exercise of permissions under this License.\n\n 8. Limitation of Liability. In no event and under no legal theory,\n whether in tort (including negligence), contract, or otherwise,\n unless required by applicable law (such as deliberate and grossly\n negligent acts) or agreed to in writing, shall any Contributor be\n liable to You for damages, including any direct, indirect, special,\n incidental, or consequential damages of any character arising as a\n result of this License or out of the use or inability to use the\n Work (including but not limited to damages for loss of goodwill,\n work stoppage, computer failure or malfunction, or any and all\n other commercial damages or losses), even if such Contributor\n has been advised of the possibility of such damages.\n\n 9. Accepting Warranty or Additional Liability. While redistributing\n the Work or Derivative Works thereof, You may choose to offer,\n and charge a fee for, acceptance of support, warranty, indemnity,\n or other liability obligations and/or rights consistent with this\n License. However, in accepting such obligations, You may act only\n on Your own behalf and on Your sole responsibility, not on behalf\n of any other Contributor, and only if You agree to indemnify,\n defend, and hold each Contributor harmless for any liability\n incurred by, or claims asserted against, such Contributor by reason\n of your accepting any such warranty or additional liability.\n\n END OF TERMS AND CONDITIONS\n\n APPENDIX: How to apply the Apache License to your work.\n\n To apply the Apache License to your work, attach the following\n boilerplate notice, with the fields enclosed by brackets \"[]\"\n replaced with your own identifying information. (Don't include\n the brackets!) The text should be enclosed in the appropriate\n comment syntax for the file format. We also recommend that a\n file or class name and description of purpose be included on the\n same \"printed page\" as the copyright notice for easier\n identification within third-party archives.\n\n Copyright 2011 Google Inc.\n\n Licensed under the Apache License, Version 2.0 (the \"License\");\n you may not use this file except in compliance with the License.\n You may obtain a copy of the License at\n\n http://www.apache.org/licenses/LICENSE-2.0\n\n Unless required by applicable law or agreed to in writing, software\n distributed under the License is distributed on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n See the License for the specific language governing permissions and\n limitations under the License.\n", + "used_by": [ + { "crate": { "name": "zopfli", "version": "0.8.3" } } + ] + }, + { + "id": "Apache-2.0", + "name": "Apache License 2.0", + "text": " Apache License\n Version 2.0, January 2004\n http://www.apache.org/licenses/\n\nTERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n1. Definitions.\n\n \"License\" shall mean the terms and conditions for use, reproduction,\n and distribution as defined by Sections 1 through 9 of this document.\n\n \"Licensor\" shall mean the copyright owner or entity authorized by\n the copyright owner that is granting the License.\n\n \"Legal Entity\" shall mean the union of the acting entity and all\n other entities that control, are controlled by, or are under common\n control with that entity. For the purposes of this definition,\n \"control\" means (i) the power, direct or indirect, to cause the\n direction or management of such entity, whether by contract or\n otherwise, or (ii) ownership of fifty percent (50%) or more of the\n outstanding shares, or (iii) beneficial ownership of such entity.\n\n \"You\" (or \"Your\") shall mean an individual or Legal Entity\n exercising permissions granted by this License.\n\n \"Source\" form shall mean the preferred form for making modifications,\n including but not limited to software source code, documentation\n source, and configuration files.\n\n \"Object\" form shall mean any form resulting from mechanical\n transformation or translation of a Source form, including but\n not limited to compiled object code, generated documentation,\n and conversions to other media types.\n\n \"Work\" shall mean the work of authorship, whether in Source or\n Object form, made available under the License, as indicated by a\n copyright notice that is included in or attached to the work\n (an example is provided in the Appendix below).\n\n \"Derivative Works\" shall mean any work, whether in Source or Object\n form, that is based on (or derived from) the Work and for which the\n editorial revisions, annotations, elaborations, or other modifications\n represent, as a whole, an original work of authorship. For the purposes\n of this License, Derivative Works shall not include works that remain\n separable from, or merely link (or bind by name) to the interfaces of,\n the Work and Derivative Works thereof.\n\n \"Contribution\" shall mean any work of authorship, including\n the original version of the Work and any modifications or additions\n to that Work or Derivative Works thereof, that is intentionally\n submitted to Licensor for inclusion in the Work by the copyright owner\n or by an individual or Legal Entity authorized to submit on behalf of\n the copyright owner. For the purposes of this definition, \"submitted\"\n means any form of electronic, verbal, or written communication sent\n to the Licensor or its representatives, including but not limited to\n communication on electronic mailing lists, source code control systems,\n and issue tracking systems that are managed by, or on behalf of, the\n Licensor for the purpose of discussing and improving the Work, but\n excluding communication that is conspicuously marked or otherwise\n designated in writing by the copyright owner as \"Not a Contribution.\"\n\n \"Contributor\" shall mean Licensor and any individual or Legal Entity\n on behalf of whom a Contribution has been received by Licensor and\n subsequently incorporated within the Work.\n\n2. Grant of Copyright License. Subject to the terms and conditions of\n this License, each Contributor hereby grants to You a perpetual,\n worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n copyright license to reproduce, prepare Derivative Works of,\n publicly display, publicly perform, sublicense, and distribute the\n Work and such Derivative Works in Source or Object form.\n\n3. Grant of Patent License. Subject to the terms and conditions of\n this License, each Contributor hereby grants to You a perpetual,\n worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n (except as stated in this section) patent license to make, have made,\n use, offer to sell, sell, import, and otherwise transfer the Work,\n where such license applies only to those patent claims licensable\n by such Contributor that are necessarily infringed by their\n Contribution(s) alone or by combination of their Contribution(s)\n with the Work to which such Contribution(s) was submitted. If You\n institute patent litigation against any entity (including a\n cross-claim or counterclaim in a lawsuit) alleging that the Work\n or a Contribution incorporated within the Work constitutes direct\n or contributory patent infringement, then any patent licenses\n granted to You under this License for that Work shall terminate\n as of the date such litigation is filed.\n\n4. Redistribution. You may reproduce and distribute copies of the\n Work or Derivative Works thereof in any medium, with or without\n modifications, and in Source or Object form, provided that You\n meet the following conditions:\n\n (a) You must give any other recipients of the Work or\n Derivative Works a copy of this License; and\n\n (b) You must cause any modified files to carry prominent notices\n stating that You changed the files; and\n\n (c) You must retain, in the Source form of any Derivative Works\n that You distribute, all copyright, patent, trademark, and\n attribution notices from the Source form of the Work,\n excluding those notices that do not pertain to any part of\n the Derivative Works; and\n\n (d) If the Work includes a \"NOTICE\" text file as part of its\n distribution, then any Derivative Works that You distribute must\n include a readable copy of the attribution notices contained\n within such NOTICE file, excluding those notices that do not\n pertain to any part of the Derivative Works, in at least one\n of the following places: within a NOTICE text file distributed\n as part of the Derivative Works; within the Source form or\n documentation, if provided along with the Derivative Works; or,\n within a display generated by the Derivative Works, if and\n wherever such third-party notices normally appear. The contents\n of the NOTICE file are for informational purposes only and\n do not modify the License. You may add Your own attribution\n notices within Derivative Works that You distribute, alongside\n or as an addendum to the NOTICE text from the Work, provided\n that such additional attribution notices cannot be construed\n as modifying the License.\n\n You may add Your own copyright statement to Your modifications and\n may provide additional or different license terms and conditions\n for use, reproduction, or distribution of Your modifications, or\n for any such Derivative Works as a whole, provided Your use,\n reproduction, and distribution of the Work otherwise complies with\n the conditions stated in this License.\n\n5. Submission of Contributions. Unless You explicitly state otherwise,\n any Contribution intentionally submitted for inclusion in the Work\n by You to the Licensor shall be under the terms and conditions of\n this License, without any additional terms or conditions.\n Notwithstanding the above, nothing herein shall supersede or modify\n the terms of any separate license agreement you may have executed\n with Licensor regarding such Contributions.\n\n6. Trademarks. This License does not grant permission to use the trade\n names, trademarks, service marks, or product names of the Licensor,\n except as required for reasonable and customary use in describing the\n origin of the Work and reproducing the content of the NOTICE file.\n\n7. Disclaimer of Warranty. Unless required by applicable law or\n agreed to in writing, Licensor provides the Work (and each\n Contributor provides its Contributions) on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or\n implied, including, without limitation, any warranties or conditions\n of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n PARTICULAR PURPOSE. You are solely responsible for determining the\n appropriateness of using or redistributing the Work and assume any\n risks associated with Your exercise of permissions under this License.\n\n8. Limitation of Liability. In no event and under no legal theory,\n whether in tort (including negligence), contract, or otherwise,\n unless required by applicable law (such as deliberate and grossly\n negligent acts) or agreed to in writing, shall any Contributor be\n liable to You for damages, including any direct, indirect, special,\n incidental, or consequential damages of any character arising as a\n result of this License or out of the use or inability to use the\n Work (including but not limited to damages for loss of goodwill,\n work stoppage, computer failure or malfunction, or any and all\n other commercial damages or losses), even if such Contributor\n has been advised of the possibility of such damages.\n\n9. Accepting Warranty or Additional Liability. While redistributing\n the Work or Derivative Works thereof, You may choose to offer,\n and charge a fee for, acceptance of support, warranty, indemnity,\n or other liability obligations and/or rights consistent with this\n License. However, in accepting such obligations, You may act only\n on Your own behalf and on Your sole responsibility, not on behalf\n of any other Contributor, and only if You agree to indemnify,\n defend, and hold each Contributor harmless for any liability\n incurred by, or claims asserted against, such Contributor by reason\n of your accepting any such warranty or additional liability.\n\nEND OF TERMS AND CONDITIONS\n", + "used_by": [ + { "crate": { "name": "ryu", "version": "1.0.23" } } + ] + }, + { + "id": "Apache-2.0", + "name": "Apache License 2.0", + "text": " Apache License\n Version 2.0, January 2004\n http://www.apache.org/licenses/\n\nTERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n1. Definitions.\n\n \"License\" shall mean the terms and conditions for use, reproduction,\n and distribution as defined by Sections 1 through 9 of this document.\n\n \"Licensor\" shall mean the copyright owner or entity authorized by\n the copyright owner that is granting the License.\n\n \"Legal Entity\" shall mean the union of the acting entity and all\n other entities that control, are controlled by, or are under common\n control with that entity. For the purposes of this definition,\n \"control\" means (i) the power, direct or indirect, to cause the\n direction or management of such entity, whether by contract or\n otherwise, or (ii) ownership of fifty percent (50%) or more of the\n outstanding shares, or (iii) beneficial ownership of such entity.\n\n \"You\" (or \"Your\") shall mean an individual or Legal Entity\n exercising permissions granted by this License.\n\n \"Source\" form shall mean the preferred form for making modifications,\n including but not limited to software source code, documentation\n source, and configuration files.\n\n \"Object\" form shall mean any form resulting from mechanical\n transformation or translation of a Source form, including but\n not limited to compiled object code, generated documentation,\n and conversions to other media types.\n\n \"Work\" shall mean the work of authorship, whether in Source or\n Object form, made available under the License, as indicated by a\n copyright notice that is included in or attached to the work\n (an example is provided in the Appendix below).\n\n \"Derivative Works\" shall mean any work, whether in Source or Object\n form, that is based on (or derived from) the Work and for which the\n editorial revisions, annotations, elaborations, or other modifications\n represent, as a whole, an original work of authorship. For the purposes\n of this License, Derivative Works shall not include works that remain\n separable from, or merely link (or bind by name) to the interfaces of,\n the Work and Derivative Works thereof.\n\n \"Contribution\" shall mean any work of authorship, including\n the original version of the Work and any modifications or additions\n to that Work or Derivative Works thereof, that is intentionally\n submitted to Licensor for inclusion in the Work by the copyright owner\n or by an individual or Legal Entity authorized to submit on behalf of\n the copyright owner. For the purposes of this definition, \"submitted\"\n means any form of electronic, verbal, or written communication sent\n to the Licensor or its representatives, including but not limited to\n communication on electronic mailing lists, source code control systems,\n and issue tracking systems that are managed by, or on behalf of, the\n Licensor for the purpose of discussing and improving the Work, but\n excluding communication that is conspicuously marked or otherwise\n designated in writing by the copyright owner as \"Not a Contribution.\"\n\n \"Contributor\" shall mean Licensor and any individual or Legal Entity\n on behalf of whom a Contribution has been received by Licensor and\n subsequently incorporated within the Work.\n\n2. Grant of Copyright License. Subject to the terms and conditions of\n this License, each Contributor hereby grants to You a perpetual,\n worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n copyright license to reproduce, prepare Derivative Works of,\n publicly display, publicly perform, sublicense, and distribute the\n Work and such Derivative Works in Source or Object form.\n\n3. Grant of Patent License. Subject to the terms and conditions of\n this License, each Contributor hereby grants to You a perpetual,\n worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n (except as stated in this section) patent license to make, have made,\n use, offer to sell, sell, import, and otherwise transfer the Work,\n where such license applies only to those patent claims licensable\n by such Contributor that are necessarily infringed by their\n Contribution(s) alone or by combination of their Contribution(s)\n with the Work to which such Contribution(s) was submitted. If You\n institute patent litigation against any entity (including a\n cross-claim or counterclaim in a lawsuit) alleging that the Work\n or a Contribution incorporated within the Work constitutes direct\n or contributory patent infringement, then any patent licenses\n granted to You under this License for that Work shall terminate\n as of the date such litigation is filed.\n\n4. Redistribution. You may reproduce and distribute copies of the\n Work or Derivative Works thereof in any medium, with or without\n modifications, and in Source or Object form, provided that You\n meet the following conditions:\n\n (a) You must give any other recipients of the Work or\n Derivative Works a copy of this License; and\n\n (b) You must cause any modified files to carry prominent notices\n stating that You changed the files; and\n\n (c) You must retain, in the Source form of any Derivative Works\n that You distribute, all copyright, patent, trademark, and\n attribution notices from the Source form of the Work,\n excluding those notices that do not pertain to any part of\n the Derivative Works; and\n\n (d) If the Work includes a \"NOTICE\" text file as part of its\n distribution, then any Derivative Works that You distribute must\n include a readable copy of the attribution notices contained\n within such NOTICE file, excluding those notices that do not\n pertain to any part of the Derivative Works, in at least one\n of the following places: within a NOTICE text file distributed\n as part of the Derivative Works; within the Source form or\n documentation, if provided along with the Derivative Works; or,\n within a display generated by the Derivative Works, if and\n wherever such third-party notices normally appear. The contents\n of the NOTICE file are for informational purposes only and\n do not modify the License. You may add Your own attribution\n notices within Derivative Works that You distribute, alongside\n or as an addendum to the NOTICE text from the Work, provided\n that such additional attribution notices cannot be construed\n as modifying the License.\n\n You may add Your own copyright statement to Your modifications and\n may provide additional or different license terms and conditions\n for use, reproduction, or distribution of Your modifications, or\n for any such Derivative Works as a whole, provided Your use,\n reproduction, and distribution of the Work otherwise complies with\n the conditions stated in this License.\n\n5. Submission of Contributions. Unless You explicitly state otherwise,\n any Contribution intentionally submitted for inclusion in the Work\n by You to the Licensor shall be under the terms and conditions of\n this License, without any additional terms or conditions.\n Notwithstanding the above, nothing herein shall supersede or modify\n the terms of any separate license agreement you may have executed\n with Licensor regarding such Contributions.\n\n6. Trademarks. This License does not grant permission to use the trade\n names, trademarks, service marks, or product names of the Licensor,\n except as required for reasonable and customary use in describing the\n origin of the Work and reproducing the content of the NOTICE file.\n\n7. Disclaimer of Warranty. Unless required by applicable law or\n agreed to in writing, Licensor provides the Work (and each\n Contributor provides its Contributions) on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or\n implied, including, without limitation, any warranties or conditions\n of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n PARTICULAR PURPOSE. You are solely responsible for determining the\n appropriateness of using or redistributing the Work and assume any\n risks associated with Your exercise of permissions under this License.\n\n8. Limitation of Liability. In no event and under no legal theory,\n whether in tort (including negligence), contract, or otherwise,\n unless required by applicable law (such as deliberate and grossly\n negligent acts) or agreed to in writing, shall any Contributor be\n liable to You for damages, including any direct, indirect, special,\n incidental, or consequential damages of any character arising as a\n result of this License or out of the use or inability to use the\n Work (including but not limited to damages for loss of goodwill,\n work stoppage, computer failure or malfunction, or any and all\n other commercial damages or losses), even if such Contributor\n has been advised of the possibility of such damages.\n\n9. Accepting Warranty or Additional Liability. While redistributing\n the Work or Derivative Works thereof, You may choose to offer,\n and charge a fee for, acceptance of support, warranty, indemnity,\n or other liability obligations and/or rights consistent with this\n License. However, in accepting such obligations, You may act only\n on Your own behalf and on Your sole responsibility, not on behalf\n of any other Contributor, and only if You agree to indemnify,\n defend, and hold each Contributor harmless for any liability\n incurred by, or claims asserted against, such Contributor by reason\n of your accepting any such warranty or additional liability.\n\nEND OF TERMS AND CONDITIONS\n\nAPPENDIX: How to apply the Apache License to your work.\n\n To apply the Apache License to your work, attach the following\n boilerplate notice, with the fields enclosed by brackets \"[]\"\n replaced with your own identifying information. (Don't include\n the brackets!) The text should be enclosed in the appropriate\n comment syntax for the file format. We also recommend that a\n file or class name and description of purpose be included on the\n same \"printed page\" as the copyright notice for easier\n identification within third-party archives.\n\nCopyright [yyyy] [name of copyright owner]\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n\thttp://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n", + "used_by": [ + { "crate": { "name": "sqlparser", "version": "0.63.0" } } + ] + }, + { + "id": "Apache-2.0", + "name": "Apache License 2.0", + "text": "AWS Libcrypto (AWS-LC)\n\nAWS-LC is a fork of BoringSSL, which is itself a fork of OpenSSL.\nContent from these and other sources retains their original licensing,\nas described below. New files from AWS-LC are made available under the Apache-2.0 license OR the ISC\nlicense. These licenses are reproduced at the bottom of this file.\n\n```\nCopyright Amazon.com, Inc. or its affiliates. All Rights Reserved.\nSPDX-License-Identifier: Apache-2.0 OR ISC\n```\n\n\n================================================================================\nBoringSSL\n================================================================================\n\nBoringSSL is a Google-maintained fork of OpenSSL. Historically, code\nauthored by Google for BoringSSL was licensed under the ISC License.\nBoringSSL has since relicensed upstream to Apache License 2.0. Existing\nAWS-LC code originating from BoringSSL retains its ISC license, while\nnewer code taken from BoringSSL is licensed under Apache License 2.0.\n\n```\nCopyright (c) 2014-2024 Google Inc.\nSPDX-License-Identifier: ISC\n```\n\nAdditional individual contributions to BoringSSL-derived code are\ncovered under the ISC license:\n\n - Brian Smith (Copyright 2016)\n - Robert Nagy (Copyright 2022)\n - Arm Ltd (Copyright 2020)\n\n```\nCopyright (c) 2025-2026 Google Inc.\nSPDX-License-Identifier: Apache-2.0\n```\n\n================================================================================\nOpenSSL\n================================================================================\n\nCode derived from the OpenSSL project is licensed under the\nApache License, Version 2.0.\n\n```\nCopyright (c) 1998-2011 The OpenSSL Project. All rights reserved.\nSPDX-License-Identifier: Apache-2.0\n```\n\nSome OpenSSL-derived files also carry the original SSLeay copyright:\n\n```\nCopyright (c) 1995-1998 Eric Young (eay@cryptsoft.com). All rights reserved.\nSPDX-License-Identifier: Apache-2.0\n```\n\nPortions of OpenSSL-derived code include contributions from:\n\n - Sun Microsystems, Inc. (Copyright 2002)\n - Nokia (Copyright 2005)\n - Intel Corporation (Copyright 2012-2021)\n\nThese contributions are covered under the Apache-2.0 license.\n\n================================================================================\nmlkem-native\n================================================================================\n\nCode from the mlkem-native project is licensed under Apache License 2.0 or MIT or ISC.\n\n```\nCopyright (c) The mlkem-native project authors.\nSPDX-License-Identifier: Apache-2.0 OR ISC OR MIT\n```\n\n================================================================================\nmldsa-native\n================================================================================\n\nCode from the mldsa-native project is licensed under Apache License 2.0 or MIT or ISC\n\n```\nCopyright (c) The mldsa-native project authors.\nSPDX-License-Identifier: Apache-2.0 OR ISC OR MIT\n```\n\n================================================================================\nThird-Party Libraries (compiled into libcrypto/libssl)\n================================================================================\n\nFiat Cryptography\n-----------------\nSynthesizing Correct-by-Construction Code for Cryptographic Primitives.\nSee third_party/fiat/LICENSE.\n\n```\nCopyright (c) 2015-2020 the fiat-crypto authors.\nSPDX-License-Identifier: MIT\n```\n\ns2n-bignum\n----------\nInteger arithmetic routines for cryptography.\nSee third_party/s2n-bignum/s2n-bignum-imported/LICENSE.\n\n```\nCopyright Amazon.com, Inc. or its affiliates. All Rights Reserved.\nSPDX-License-Identifier: Apache-2.0 OR ISC OR MIT-0\n```\n\nNote: ML-KEM/SHA3 code within s2n-bignum is licensed as\nApache-2.0 OR ISC OR MIT (with attribution), sourced from the\nmlkem-native project.\n\nJitter Entropy RNG\n-------------------\nCPU Jitter Random Number Generator Library.\nSee third_party/jitterentropy/jitterentropy-library/LICENSE.\n\nThe Jitter Entropy library is dual-licensed under a BSD-style license\nand the GNU General Public License Version 2. Amazon expressly elects\nto distribute the package under the 3-Clause BSD License and NOT under\nGNU General Public License Version 2.\n\n```\nCopyright (C) 2017 - 2025, Stephan Mueller .\nSPDX-License-Identifier: BSD-3-Clause\n```\n\nKeccak / AES Reference Implementations\n---------------------------------------\nPublic domain (CC0) contributions.\nhttps://creativecommons.org/public-domain/cc0\n\nCode from sources and by authors listed in comments on top of the respective files.\n\n\n================================================================================\nThird-Party Libraries (NOT compiled into libcrypto/libssl)\n================================================================================\n\nThe following are used for testing and build tooling only. Distributing\ncode linked against AWS-LC (libcrypto/libssl) does NOT trigger these\nlicense obligations.\n\nGoogle Test\n-----------\nSee third_party/googletest/LICENSE.\n\n```\nCopyright 2008 Google Inc.\nSPDX-License-Identifier: BSD-3-Clause\n```\n\nGo Standard Library\n-------------------\nCode in ssl/test/runner/ is derived from the Go standard library.\n\n```\nCopyright (c) The Go Authors. All rights reserved.\nSPDX-License-Identifier: BSD-3-Clause\n```\n\nWycheproof Test Vectors\n------------------------\nProject Wycheproof is a community managed repository of test vectors that can be used by cryptography library\ndevelopers to test against known attacks, specification inconsistencies, and other various implementation bugs.\n\nSee third_party/wycheproof_testvectors/LICENSE.\n\n```\nSPDX-License-Identifier: Apache-2.0\n```\n\n\n================================================================================\nFull License Texts\n================================================================================\n\nApache License 2.0\n------------------\nApache License\nVersion 2.0, January 2004\nhttp://www.apache.org/licenses/\n\nTERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n 1. Definitions.\n\n \"License\" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document.\n\n \"Licensor\" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License.\n\n \"Legal Entity\" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, \"control\" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity.\n\n \"You\" (or \"Your\") shall mean an individual or Legal Entity exercising permissions granted by this License.\n\n \"Source\" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files.\n\n \"Object\" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types.\n\n \"Work\" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below).\n\n \"Derivative Works\" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof.\n\n \"Contribution\" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, \"submitted\" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as \"Not a Contribution.\"\n\n \"Contributor\" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work.\n 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form.\n 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed.\n 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions:\n (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and\n (b) You must cause any modified files to carry prominent notices stating that You changed the files; and\n (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and\n (d) If the Work includes a \"NOTICE\" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License.\n\n You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License.\n 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions.\n 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file.\n 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an \"AS IS\" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License.\n 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages.\n 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability.\n\nEND OF TERMS AND CONDITIONS\n\nAPPENDIX: How to apply the Apache License to your work.\n\nTo apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets \"[]\" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same \"printed page\" as the copyright notice for easier identification within third-party archives.\n\nCopyright [yyyy] [name of copyright owner]\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\nhttp://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n\nISC License\n-----------\nPermission to use, copy, modify, and/or distribute this software for\nany purpose with or without fee is hereby granted, provided that the\nabove copyright notice and this permission notice appear in all copies.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\" AND THE AUTHOR DISCLAIMS ALL\nWARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED\nWARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE\nAUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL\nDAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR\nPROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER\nTORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR\nPERFORMANCE OF THIS SOFTWARE.\n\nMIT License\n-----------\nPermission is hereby granted, free of charge, to any person obtaining\na copy of this software and associated documentation files (the\n\"Software\"), to deal in the Software without restriction, including\nwithout limitation the rights to use, copy, modify, merge, publish,\ndistribute, sublicense, and/or sell copies of the Software, and to\npermit persons to whom the Software is furnished to do so, subject to\nthe following conditions:\n\nThe above copyright notice and this permission notice shall be\nincluded in all copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND,\nEXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF\nMERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.\nIN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,\nTORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE\nSOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.\n\nBSD 3-Clause License\n--------------------\nRedistribution and use in source and binary forms, with or without\nmodification, are permitted provided that the following conditions\nare met:\n\n1. Redistributions of source code must retain the above copyright\n notice, this list of conditions and the following disclaimer.\n\n2. Redistributions in binary form must reproduce the above copyright\n notice, this list of conditions and the following disclaimer in the\n documentation and/or other materials provided with the distribution.\n\n3. Neither the name of the copyright holder nor the names of its\n contributors may be used to endorse or promote products derived\n from this software without specific prior written permission.\n\nTHIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS\n\"AS IS\" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT\nLIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR\nA PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT\nHOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,\nSPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT\nLIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,\nDATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY\nTHEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT\n(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE\nOF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.\n\nMIT No Attribution (MIT-0)\n--------------------------\nPermission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the \"Software\"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "aws-lc-sys", "version": "0.45.0" } } + ] + }, + { + "id": "BSD-3-Clause", + "name": "BSD 3-Clause \"New\" or \"Revised\" License", + "text": "// Copyright © WHATWG (Apple, Google, Mozilla, Microsoft).\n//\n// Redistribution and use in source and binary forms, with or without\n// modification, are permitted provided that the following conditions are met:\n//\n// 1. Redistributions of source code must retain the above copyright notice, this\n// list of conditions and the following disclaimer.\n//\n// 2. Redistributions in binary form must reproduce the above copyright notice,\n// this list of conditions and the following disclaimer in the documentation\n// and/or other materials provided with the distribution.\n//\n// 3. Neither the name of the copyright holder nor the names of its\n// contributors may be used to endorse or promote products derived from\n// this software without specific prior written permission.\n//\n// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE\n// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE\n// FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL\n// DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR\n// SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER\n// CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,\n// OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE\n// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.\n\n/// The PUA code points special-cased in the GB18030 encoder.\npub(crate) static GB18030_2022_OVERRIDE_PUA: [u16; 18] = [\n 0xE78D, 0xE78E, 0xE78F, 0xE790, 0xE791, 0xE792, 0xE793, 0xE794, 0xE795, 0xE796, 0xE81E, 0xE826,\n 0xE82B, 0xE82C, 0xE832, 0xE843, 0xE854, 0xE864,\n];\n\n/// The bytes corresponding to the PUA code points special-cased in the GB18030 encoder.\npub(crate) static GB18030_2022_OVERRIDE_BYTES: [[u8; 2]; 18] = [\n [0xA6, 0xD9],\n [0xA6, 0xDA],\n [0xA6, 0xDB],\n [0xA6, 0xDC],\n [0xA6, 0xDD],\n [0xA6, 0xDE],\n [0xA6, 0xDF],\n [0xA6, 0xEC],\n [0xA6, 0xED],\n [0xA6, 0xF3],\n [0xFE, 0x59],\n [0xFE, 0x61],\n [0xFE, 0x66],\n [0xFE, 0x67],\n [0xFE, 0x6D],\n [0xFE, 0x7E],\n [0xFE, 0x90],\n [0xFE, 0xA0],\n];\n", + "used_by": [ + { "crate": { "name": "encoding_rs", "version": "0.8.41" } } + ] + }, + { + "id": "BSD-3-Clause", + "name": "BSD 3-Clause \"New\" or \"Revised\" License", + "text": "AWS Libcrypto (AWS-LC)\n\nAWS-LC is a fork of BoringSSL, which is itself a fork of OpenSSL.\nContent from these and other sources retains their original licensing,\nas described below. New files from AWS-LC are made available under the Apache-2.0 license OR the ISC\nlicense. These licenses are reproduced at the bottom of this file.\n\n```\nCopyright Amazon.com, Inc. or its affiliates. All Rights Reserved.\nSPDX-License-Identifier: Apache-2.0 OR ISC\n```\n\n\n================================================================================\nBoringSSL\n================================================================================\n\nBoringSSL is a Google-maintained fork of OpenSSL. Historically, code\nauthored by Google for BoringSSL was licensed under the ISC License.\nBoringSSL has since relicensed upstream to Apache License 2.0. Existing\nAWS-LC code originating from BoringSSL retains its ISC license, while\nnewer code taken from BoringSSL is licensed under Apache License 2.0.\n\n```\nCopyright (c) 2014-2024 Google Inc.\nSPDX-License-Identifier: ISC\n```\n\nAdditional individual contributions to BoringSSL-derived code are\ncovered under the ISC license:\n\n - Brian Smith (Copyright 2016)\n - Robert Nagy (Copyright 2022)\n - Arm Ltd (Copyright 2020)\n\n```\nCopyright (c) 2025-2026 Google Inc.\nSPDX-License-Identifier: Apache-2.0\n```\n\n================================================================================\nOpenSSL\n================================================================================\n\nCode derived from the OpenSSL project is licensed under the\nApache License, Version 2.0.\n\n```\nCopyright (c) 1998-2011 The OpenSSL Project. All rights reserved.\nSPDX-License-Identifier: Apache-2.0\n```\n\nSome OpenSSL-derived files also carry the original SSLeay copyright:\n\n```\nCopyright (c) 1995-1998 Eric Young (eay@cryptsoft.com). All rights reserved.\nSPDX-License-Identifier: Apache-2.0\n```\n\nPortions of OpenSSL-derived code include contributions from:\n\n - Sun Microsystems, Inc. (Copyright 2002)\n - Nokia (Copyright 2005)\n - Intel Corporation (Copyright 2012-2021)\n\nThese contributions are covered under the Apache-2.0 license.\n\n================================================================================\nmlkem-native\n================================================================================\n\nCode from the mlkem-native project is licensed under Apache License 2.0 or MIT or ISC.\n\n```\nCopyright (c) The mlkem-native project authors.\nSPDX-License-Identifier: Apache-2.0 OR ISC OR MIT\n```\n\n================================================================================\nmldsa-native\n================================================================================\n\nCode from the mldsa-native project is licensed under Apache License 2.0 or MIT or ISC\n\n```\nCopyright (c) The mldsa-native project authors.\nSPDX-License-Identifier: Apache-2.0 OR ISC OR MIT\n```\n\n================================================================================\nThird-Party Libraries (compiled into libcrypto/libssl)\n================================================================================\n\nFiat Cryptography\n-----------------\nSynthesizing Correct-by-Construction Code for Cryptographic Primitives.\nSee third_party/fiat/LICENSE.\n\n```\nCopyright (c) 2015-2020 the fiat-crypto authors.\nSPDX-License-Identifier: MIT\n```\n\ns2n-bignum\n----------\nInteger arithmetic routines for cryptography.\nSee third_party/s2n-bignum/s2n-bignum-imported/LICENSE.\n\n```\nCopyright Amazon.com, Inc. or its affiliates. All Rights Reserved.\nSPDX-License-Identifier: Apache-2.0 OR ISC OR MIT-0\n```\n\nNote: ML-KEM/SHA3 code within s2n-bignum is licensed as\nApache-2.0 OR ISC OR MIT (with attribution), sourced from the\nmlkem-native project.\n\nJitter Entropy RNG\n-------------------\nCPU Jitter Random Number Generator Library.\nSee third_party/jitterentropy/jitterentropy-library/LICENSE.\n\nThe Jitter Entropy library is dual-licensed under a BSD-style license\nand the GNU General Public License Version 2. Amazon expressly elects\nto distribute the package under the 3-Clause BSD License and NOT under\nGNU General Public License Version 2.\n\n```\nCopyright (C) 2017 - 2025, Stephan Mueller .\nSPDX-License-Identifier: BSD-3-Clause\n```\n\nKeccak / AES Reference Implementations\n---------------------------------------\nPublic domain (CC0) contributions.\nhttps://creativecommons.org/public-domain/cc0\n\nCode from sources and by authors listed in comments on top of the respective files.\n\n\n================================================================================\nThird-Party Libraries (NOT compiled into libcrypto/libssl)\n================================================================================\n\nThe following are used for testing and build tooling only. Distributing\ncode linked against AWS-LC (libcrypto/libssl) does NOT trigger these\nlicense obligations.\n\nGoogle Test\n-----------\nSee third_party/googletest/LICENSE.\n\n```\nCopyright 2008 Google Inc.\nSPDX-License-Identifier: BSD-3-Clause\n```\n\nGo Standard Library\n-------------------\nCode in ssl/test/runner/ is derived from the Go standard library.\n\n```\nCopyright (c) The Go Authors. All rights reserved.\nSPDX-License-Identifier: BSD-3-Clause\n```\n\nWycheproof Test Vectors\n------------------------\nProject Wycheproof is a community managed repository of test vectors that can be used by cryptography library\ndevelopers to test against known attacks, specification inconsistencies, and other various implementation bugs.\n\nSee third_party/wycheproof_testvectors/LICENSE.\n\n```\nSPDX-License-Identifier: Apache-2.0\n```\n\n\n================================================================================\nFull License Texts\n================================================================================\n\nApache License 2.0\n------------------\nApache License\nVersion 2.0, January 2004\nhttp://www.apache.org/licenses/\n\nTERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n 1. Definitions.\n\n \"License\" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document.\n\n \"Licensor\" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License.\n\n \"Legal Entity\" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, \"control\" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity.\n\n \"You\" (or \"Your\") shall mean an individual or Legal Entity exercising permissions granted by this License.\n\n \"Source\" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files.\n\n \"Object\" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types.\n\n \"Work\" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below).\n\n \"Derivative Works\" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof.\n\n \"Contribution\" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, \"submitted\" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as \"Not a Contribution.\"\n\n \"Contributor\" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work.\n 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form.\n 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed.\n 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions:\n (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and\n (b) You must cause any modified files to carry prominent notices stating that You changed the files; and\n (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and\n (d) If the Work includes a \"NOTICE\" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License.\n\n You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License.\n 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions.\n 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file.\n 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an \"AS IS\" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License.\n 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages.\n 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability.\n\nEND OF TERMS AND CONDITIONS\n\nAPPENDIX: How to apply the Apache License to your work.\n\nTo apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets \"[]\" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same \"printed page\" as the copyright notice for easier identification within third-party archives.\n\nCopyright [yyyy] [name of copyright owner]\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\nhttp://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n\nISC License\n-----------\nPermission to use, copy, modify, and/or distribute this software for\nany purpose with or without fee is hereby granted, provided that the\nabove copyright notice and this permission notice appear in all copies.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\" AND THE AUTHOR DISCLAIMS ALL\nWARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED\nWARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE\nAUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL\nDAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR\nPROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER\nTORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR\nPERFORMANCE OF THIS SOFTWARE.\n\nMIT License\n-----------\nPermission is hereby granted, free of charge, to any person obtaining\na copy of this software and associated documentation files (the\n\"Software\"), to deal in the Software without restriction, including\nwithout limitation the rights to use, copy, modify, merge, publish,\ndistribute, sublicense, and/or sell copies of the Software, and to\npermit persons to whom the Software is furnished to do so, subject to\nthe following conditions:\n\nThe above copyright notice and this permission notice shall be\nincluded in all copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND,\nEXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF\nMERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.\nIN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,\nTORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE\nSOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.\n\nBSD 3-Clause License\n--------------------\nRedistribution and use in source and binary forms, with or without\nmodification, are permitted provided that the following conditions\nare met:\n\n1. Redistributions of source code must retain the above copyright\n notice, this list of conditions and the following disclaimer.\n\n2. Redistributions in binary form must reproduce the above copyright\n notice, this list of conditions and the following disclaimer in the\n documentation and/or other materials provided with the distribution.\n\n3. Neither the name of the copyright holder nor the names of its\n contributors may be used to endorse or promote products derived\n from this software without specific prior written permission.\n\nTHIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS\n\"AS IS\" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT\nLIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR\nA PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT\nHOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,\nSPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT\nLIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,\nDATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY\nTHEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT\n(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE\nOF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.\n\nMIT No Attribution (MIT-0)\n--------------------------\nPermission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the \"Software\"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "aws-lc-sys", "version": "0.45.0" } } + ] + }, + { + "id": "BSD-3-Clause", + "name": "BSD 3-Clause \"New\" or \"Revised\" License", + "text": "Copyright (c) 2016-2017 Isis Agora Lovecruft, Henry de Valence. All rights reserved.\nCopyright (c) 2016-2024 Isis Agora Lovecruft. All rights reserved.\n\nRedistribution and use in source and binary forms, with or without\nmodification, are permitted provided that the following conditions are\nmet:\n\n1. Redistributions of source code must retain the above copyright\nnotice, this list of conditions and the following disclaimer.\n\n2. Redistributions in binary form must reproduce the above copyright\nnotice, this list of conditions and the following disclaimer in the\ndocumentation and/or other materials provided with the distribution.\n\n3. Neither the name of the copyright holder nor the names of its\ncontributors may be used to endorse or promote products derived from\nthis software without specific prior written permission.\n\nTHIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS\nIS\" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED\nTO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A\nPARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT\nHOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,\nSPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED\nTO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR\nPROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF\nLIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING\nNEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS\nSOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. \n", + "used_by": [ + { "crate": { "name": "subtle", "version": "2.6.1" } } + ] + }, + { + "id": "BSD-3-Clause", + "name": "BSD 3-Clause \"New\" or \"Revised\" License", + "text": "Copyright © WHATWG (Apple, Google, Mozilla, Microsoft).\n\nRedistribution and use in source and binary forms, with or without\nmodification, are permitted provided that the following conditions are met:\n\n1. Redistributions of source code must retain the above copyright notice, this\n list of conditions and the following disclaimer.\n\n2. Redistributions in binary form must reproduce the above copyright notice,\n this list of conditions and the following disclaimer in the documentation\n and/or other materials provided with the distribution.\n\n3. Neither the name of the copyright holder nor the names of its\n contributors may be used to endorse or promote products derived from\n this software without specific prior written permission.\n\nTHIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\nAND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\nIMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE\nDISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE\nFOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL\nDAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR\nSERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER\nCAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,\nOR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE\nOF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.\n", + "used_by": [ + { "crate": { "name": "encoding_rs", "version": "0.8.41" } } + ] + }, + { + "id": "CDLA-Permissive-2.0", + "name": "Community Data License Agreement Permissive 2.0", + "text": "# Community Data License Agreement - Permissive - Version 2.0\n\nThis is the Community Data License Agreement - Permissive, Version\n2.0 (the \"agreement\"). Data Provider(s) and Data Recipient(s) agree\nas follows:\n\n## 1. Provision of the Data\n\n1.1. A Data Recipient may use, modify, and share the Data made\navailable by Data Provider(s) under this agreement if that Data\nRecipient follows the terms of this agreement.\n\n1.2. This agreement does not impose any restriction on a Data\nRecipient's use, modification, or sharing of any portions of the\nData that are in the public domain or that may be used, modified,\nor shared under any other legal exception or limitation.\n\n## 2. Conditions for Sharing Data\n\n2.1. A Data Recipient may share Data, with or without modifications, so\nlong as the Data Recipient makes available the text of this agreement\nwith the shared Data.\n\n## 3. No Restrictions on Results\n\n3.1. This agreement does not impose any restriction or obligations\nwith respect to the use, modification, or sharing of Results.\n\n## 4. No Warranty; Limitation of Liability\n\n4.1. All Data Recipients receive the Data subject to the following\nterms:\n\nTHE DATA IS PROVIDED ON AN \"AS IS\" BASIS, WITHOUT REPRESENTATIONS,\nWARRANTIES OR CONDITIONS OF ANY KIND, EITHER EXPRESS OR IMPLIED\nINCLUDING, WITHOUT LIMITATION, ANY WARRANTIES OR CONDITIONS OF TITLE,\nNON-INFRINGEMENT, MERCHANTABILITY OR FITNESS FOR A PARTICULAR PURPOSE.\n\nNO DATA PROVIDER SHALL HAVE ANY LIABILITY FOR ANY DIRECT, INDIRECT,\nINCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING\nWITHOUT LIMITATION LOST PROFITS), HOWEVER CAUSED AND ON ANY THEORY OF\nLIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING\nNEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE DATA OR RESULTS,\nEVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.\n\n## 5. Definitions\n\n5.1. \"Data\" means the material received by a Data Recipient under\nthis agreement.\n\n5.2. \"Data Provider\" means any person who is the source of Data\nprovided under this agreement and in reliance on a Data Recipient's\nagreement to its terms.\n\n5.3. \"Data Recipient\" means any person who receives Data directly\nor indirectly from a Data Provider and agrees to the terms of this\nagreement.\n\n5.4. \"Results\" means any outcome obtained by computational analysis\nof Data, including for example machine learning models and models'\ninsights.\n", + "used_by": [ + { "crate": { "name": "webpki-roots", "version": "1.0.9" } } + ] + }, + { + "id": "ISC", + "name": "ISC License", + "text": "// Copyright 2015-2016 Brian Smith.\n//\n// Permission to use, copy, modify, and/or distribute this software for any\n// purpose with or without fee is hereby granted, provided that the above\n// copyright notice and this permission notice appear in all copies.\n//\n// THE SOFTWARE IS PROVIDED \"AS IS\" AND THE AUTHORS DISCLAIM ALL WARRANTIES\n// WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF\n// MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR\n// ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES\n// WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN\n// ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF\n// OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "untrusted", "version": "0.9.0" } } + ] + }, + { + "id": "ISC", + "name": "ISC License", + "text": "AWS Libcrypto (AWS-LC)\n\nAWS-LC is a fork of BoringSSL, which is itself a fork of OpenSSL.\nContent from these and other sources retains their original licensing,\nas described below. New files from AWS-LC are made available under the Apache-2.0 license OR the ISC\nlicense. These licenses are reproduced at the bottom of this file.\n\n```\nCopyright Amazon.com, Inc. or its affiliates. All Rights Reserved.\nSPDX-License-Identifier: Apache-2.0 OR ISC\n```\n\n\n================================================================================\nBoringSSL\n================================================================================\n\nBoringSSL is a Google-maintained fork of OpenSSL. Historically, code\nauthored by Google for BoringSSL was licensed under the ISC License.\nBoringSSL has since relicensed upstream to Apache License 2.0. Existing\nAWS-LC code originating from BoringSSL retains its ISC license, while\nnewer code taken from BoringSSL is licensed under Apache License 2.0.\n\n```\nCopyright (c) 2014-2024 Google Inc.\nSPDX-License-Identifier: ISC\n```\n\nAdditional individual contributions to BoringSSL-derived code are\ncovered under the ISC license:\n\n - Brian Smith (Copyright 2016)\n - Robert Nagy (Copyright 2022)\n - Arm Ltd (Copyright 2020)\n\n```\nCopyright (c) 2025-2026 Google Inc.\nSPDX-License-Identifier: Apache-2.0\n```\n\n================================================================================\nOpenSSL\n================================================================================\n\nCode derived from the OpenSSL project is licensed under the\nApache License, Version 2.0.\n\n```\nCopyright (c) 1998-2011 The OpenSSL Project. All rights reserved.\nSPDX-License-Identifier: Apache-2.0\n```\n\nSome OpenSSL-derived files also carry the original SSLeay copyright:\n\n```\nCopyright (c) 1995-1998 Eric Young (eay@cryptsoft.com). All rights reserved.\nSPDX-License-Identifier: Apache-2.0\n```\n\nPortions of OpenSSL-derived code include contributions from:\n\n - Sun Microsystems, Inc. (Copyright 2002)\n - Nokia (Copyright 2005)\n - Intel Corporation (Copyright 2012-2021)\n\nThese contributions are covered under the Apache-2.0 license.\n\n================================================================================\nmlkem-native\n================================================================================\n\nCode from the mlkem-native project is licensed under Apache License 2.0 or MIT or ISC.\n\n```\nCopyright (c) The mlkem-native project authors.\nSPDX-License-Identifier: Apache-2.0 OR ISC OR MIT\n```\n\n================================================================================\nmldsa-native\n================================================================================\n\nCode from the mldsa-native project is licensed under Apache License 2.0 or MIT or ISC\n\n```\nCopyright (c) The mldsa-native project authors.\nSPDX-License-Identifier: Apache-2.0 OR ISC OR MIT\n```\n\n================================================================================\nThird-Party Libraries (compiled into libcrypto/libssl)\n================================================================================\n\nFiat Cryptography\n-----------------\nSynthesizing Correct-by-Construction Code for Cryptographic Primitives.\nSee third_party/fiat/LICENSE.\n\n```\nCopyright (c) 2015-2020 the fiat-crypto authors.\nSPDX-License-Identifier: MIT\n```\n\ns2n-bignum\n----------\nInteger arithmetic routines for cryptography.\nSee third_party/s2n-bignum/s2n-bignum-imported/LICENSE.\n\n```\nCopyright Amazon.com, Inc. or its affiliates. All Rights Reserved.\nSPDX-License-Identifier: Apache-2.0 OR ISC OR MIT-0\n```\n\nNote: ML-KEM/SHA3 code within s2n-bignum is licensed as\nApache-2.0 OR ISC OR MIT (with attribution), sourced from the\nmlkem-native project.\n\nJitter Entropy RNG\n-------------------\nCPU Jitter Random Number Generator Library.\nSee third_party/jitterentropy/jitterentropy-library/LICENSE.\n\nThe Jitter Entropy library is dual-licensed under a BSD-style license\nand the GNU General Public License Version 2. Amazon expressly elects\nto distribute the package under the 3-Clause BSD License and NOT under\nGNU General Public License Version 2.\n\n```\nCopyright (C) 2017 - 2025, Stephan Mueller .\nSPDX-License-Identifier: BSD-3-Clause\n```\n\nKeccak / AES Reference Implementations\n---------------------------------------\nPublic domain (CC0) contributions.\nhttps://creativecommons.org/public-domain/cc0\n\nCode from sources and by authors listed in comments on top of the respective files.\n\n\n================================================================================\nThird-Party Libraries (NOT compiled into libcrypto/libssl)\n================================================================================\n\nThe following are used for testing and build tooling only. Distributing\ncode linked against AWS-LC (libcrypto/libssl) does NOT trigger these\nlicense obligations.\n\nGoogle Test\n-----------\nSee third_party/googletest/LICENSE.\n\n```\nCopyright 2008 Google Inc.\nSPDX-License-Identifier: BSD-3-Clause\n```\n\nGo Standard Library\n-------------------\nCode in ssl/test/runner/ is derived from the Go standard library.\n\n```\nCopyright (c) The Go Authors. All rights reserved.\nSPDX-License-Identifier: BSD-3-Clause\n```\n\nWycheproof Test Vectors\n------------------------\nProject Wycheproof is a community managed repository of test vectors that can be used by cryptography library\ndevelopers to test against known attacks, specification inconsistencies, and other various implementation bugs.\n\nSee third_party/wycheproof_testvectors/LICENSE.\n\n```\nSPDX-License-Identifier: Apache-2.0\n```\n\n\n================================================================================\nFull License Texts\n================================================================================\n\nApache License 2.0\n------------------\nApache License\nVersion 2.0, January 2004\nhttp://www.apache.org/licenses/\n\nTERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n 1. Definitions.\n\n \"License\" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document.\n\n \"Licensor\" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License.\n\n \"Legal Entity\" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, \"control\" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity.\n\n \"You\" (or \"Your\") shall mean an individual or Legal Entity exercising permissions granted by this License.\n\n \"Source\" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files.\n\n \"Object\" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types.\n\n \"Work\" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below).\n\n \"Derivative Works\" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof.\n\n \"Contribution\" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, \"submitted\" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as \"Not a Contribution.\"\n\n \"Contributor\" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work.\n 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form.\n 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed.\n 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions:\n (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and\n (b) You must cause any modified files to carry prominent notices stating that You changed the files; and\n (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and\n (d) If the Work includes a \"NOTICE\" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License.\n\n You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License.\n 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions.\n 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file.\n 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an \"AS IS\" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License.\n 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages.\n 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability.\n\nEND OF TERMS AND CONDITIONS\n\nAPPENDIX: How to apply the Apache License to your work.\n\nTo apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets \"[]\" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same \"printed page\" as the copyright notice for easier identification within third-party archives.\n\nCopyright [yyyy] [name of copyright owner]\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\nhttp://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n\nISC License\n-----------\nPermission to use, copy, modify, and/or distribute this software for\nany purpose with or without fee is hereby granted, provided that the\nabove copyright notice and this permission notice appear in all copies.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\" AND THE AUTHOR DISCLAIMS ALL\nWARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED\nWARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE\nAUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL\nDAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR\nPROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER\nTORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR\nPERFORMANCE OF THIS SOFTWARE.\n\nMIT License\n-----------\nPermission is hereby granted, free of charge, to any person obtaining\na copy of this software and associated documentation files (the\n\"Software\"), to deal in the Software without restriction, including\nwithout limitation the rights to use, copy, modify, merge, publish,\ndistribute, sublicense, and/or sell copies of the Software, and to\npermit persons to whom the Software is furnished to do so, subject to\nthe following conditions:\n\nThe above copyright notice and this permission notice shall be\nincluded in all copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND,\nEXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF\nMERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.\nIN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,\nTORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE\nSOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.\n\nBSD 3-Clause License\n--------------------\nRedistribution and use in source and binary forms, with or without\nmodification, are permitted provided that the following conditions\nare met:\n\n1. Redistributions of source code must retain the above copyright\n notice, this list of conditions and the following disclaimer.\n\n2. Redistributions in binary form must reproduce the above copyright\n notice, this list of conditions and the following disclaimer in the\n documentation and/or other materials provided with the distribution.\n\n3. Neither the name of the copyright holder nor the names of its\n contributors may be used to endorse or promote products derived\n from this software without specific prior written permission.\n\nTHIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS\n\"AS IS\" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT\nLIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR\nA PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT\nHOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,\nSPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT\nLIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,\nDATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY\nTHEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT\n(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE\nOF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.\n\nMIT No Attribution (MIT-0)\n--------------------------\nPermission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the \"Software\"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "aws-lc-sys", "version": "0.45.0" } } + ] + }, + { + "id": "ISC", + "name": "ISC License", + "text": "Except as otherwise noted, this project is licensed under the following\n(ISC-style) terms:\n\nCopyright 2015 Brian Smith.\n\nPermission to use, copy, modify, and/or distribute this software for any\npurpose with or without fee is hereby granted, provided that the above\ncopyright notice and this permission notice appear in all copies.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\" AND THE AUTHORS DISCLAIM ALL WARRANTIES\nWITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF\nMERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR\nANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES\nWHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN\nACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF\nOR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.\n\nThe files under third-party/chromium are licensed as described in\nthird-party/chromium/LICENSE.\n", + "used_by": [ + { "crate": { "name": "rustls-webpki", "version": "0.103.15" } } + ] + }, + { + "id": "ISC", + "name": "ISC License", + "text": "SPDX-License-Identifier: ISC AND (Apache-2.0 OR ISC)\n\n\nApache 2.0 license\n-------------------------------------\n\n\n Apache License\n Version 2.0, January 2004\n http://www.apache.org/licenses/\n\n TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n 1. Definitions.\n\n \"License\" shall mean the terms and conditions for use, reproduction,\n and distribution as defined by Sections 1 through 9 of this document.\n\n \"Licensor\" shall mean the copyright owner or entity authorized by\n the copyright owner that is granting the License.\n\n \"Legal Entity\" shall mean the union of the acting entity and all\n other entities that control, are controlled by, or are under common\n control with that entity. For the purposes of this definition,\n \"control\" means (i) the power, direct or indirect, to cause the\n direction or management of such entity, whether by contract or\n otherwise, or (ii) ownership of fifty percent (50%) or more of the\n outstanding shares, or (iii) beneficial ownership of such entity.\n\n \"You\" (or \"Your\") shall mean an individual or Legal Entity\n exercising permissions granted by this License.\n\n \"Source\" form shall mean the preferred form for making modifications,\n including but not limited to software source code, documentation\n source, and configuration files.\n\n \"Object\" form shall mean any form resulting from mechanical\n transformation or translation of a Source form, including but\n not limited to compiled object code, generated documentation,\n and conversions to other media types.\n\n \"Work\" shall mean the work of authorship, whether in Source or\n Object form, made available under the License, as indicated by a\n copyright notice that is included in or attached to the work\n (an example is provided in the Appendix below).\n\n \"Derivative Works\" shall mean any work, whether in Source or Object\n form, that is based on (or derived from) the Work and for which the\n editorial revisions, annotations, elaborations, or other modifications\n represent, as a whole, an original work of authorship. For the purposes\n of this License, Derivative Works shall not include works that remain\n separable from, or merely link (or bind by name) to the interfaces of,\n the Work and Derivative Works thereof.\n\n \"Contribution\" shall mean any work of authorship, including\n the original version of the Work and any modifications or additions\n to that Work or Derivative Works thereof, that is intentionally\n submitted to Licensor for inclusion in the Work by the copyright owner\n or by an individual or Legal Entity authorized to submit on behalf of\n the copyright owner. For the purposes of this definition, \"submitted\"\n means any form of electronic, verbal, or written communication sent\n to the Licensor or its representatives, including but not limited to\n communication on electronic mailing lists, source code control systems,\n and issue tracking systems that are managed by, or on behalf of, the\n Licensor for the purpose of discussing and improving the Work, but\n excluding communication that is conspicuously marked or otherwise\n designated in writing by the copyright owner as \"Not a Contribution.\"\n\n \"Contributor\" shall mean Licensor and any individual or Legal Entity\n on behalf of whom a Contribution has been received by Licensor and\n subsequently incorporated within the Work.\n\n 2. Grant of Copyright License. Subject to the terms and conditions of\n this License, each Contributor hereby grants to You a perpetual,\n worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n copyright license to reproduce, prepare Derivative Works of,\n publicly display, publicly perform, sublicense, and distribute the\n Work and such Derivative Works in Source or Object form.\n\n 3. Grant of Patent License. Subject to the terms and conditions of\n this License, each Contributor hereby grants to You a perpetual,\n worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n (except as stated in this section) patent license to make, have made,\n use, offer to sell, sell, import, and otherwise transfer the Work,\n where such license applies only to those patent claims licensable\n by such Contributor that are necessarily infringed by their\n Contribution(s) alone or by combination of their Contribution(s)\n with the Work to which such Contribution(s) was submitted. If You\n institute patent litigation against any entity (including a\n cross-claim or counterclaim in a lawsuit) alleging that the Work\n or a Contribution incorporated within the Work constitutes direct\n or contributory patent infringement, then any patent licenses\n granted to You under this License for that Work shall terminate\n as of the date such litigation is filed.\n\n 4. Redistribution. You may reproduce and distribute copies of the\n Work or Derivative Works thereof in any medium, with or without\n modifications, and in Source or Object form, provided that You\n meet the following conditions:\n\n (a) You must give any other recipients of the Work or\n Derivative Works a copy of this License; and\n\n (b) You must cause any modified files to carry prominent notices\n stating that You changed the files; and\n\n (c) You must retain, in the Source form of any Derivative Works\n that You distribute, all copyright, patent, trademark, and\n attribution notices from the Source form of the Work,\n excluding those notices that do not pertain to any part of\n the Derivative Works; and\n\n (d) If the Work includes a \"NOTICE\" text file as part of its\n distribution, then any Derivative Works that You distribute must\n include a readable copy of the attribution notices contained\n within such NOTICE file, excluding those notices that do not\n pertain to any part of the Derivative Works, in at least one\n of the following places: within a NOTICE text file distributed\n as part of the Derivative Works; within the Source form or\n documentation, if provided along with the Derivative Works; or,\n within a display generated by the Derivative Works, if and\n wherever such third-party notices normally appear. The contents\n of the NOTICE file are for informational purposes only and\n do not modify the License. You may add Your own attribution\n notices within Derivative Works that You distribute, alongside\n or as an addendum to the NOTICE text from the Work, provided\n that such additional attribution notices cannot be construed\n as modifying the License.\n\n You may add Your own copyright statement to Your modifications and\n may provide additional or different license terms and conditions\n for use, reproduction, or distribution of Your modifications, or\n for any such Derivative Works as a whole, provided Your use,\n reproduction, and distribution of the Work otherwise complies with\n the conditions stated in this License.\n\n 5. Submission of Contributions. Unless You explicitly state otherwise,\n any Contribution intentionally submitted for inclusion in the Work\n by You to the Licensor shall be under the terms and conditions of\n this License, without any additional terms or conditions.\n Notwithstanding the above, nothing herein shall supersede or modify\n the terms of any separate license agreement you may have executed\n with Licensor regarding such Contributions.\n\n 6. Trademarks. This License does not grant permission to use the trade\n names, trademarks, service marks, or product names of the Licensor,\n except as required for reasonable and customary use in describing the\n origin of the Work and reproducing the content of the NOTICE file.\n\n 7. Disclaimer of Warranty. Unless required by applicable law or\n agreed to in writing, Licensor provides the Work (and each\n Contributor provides its Contributions) on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or\n implied, including, without limitation, any warranties or conditions\n of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n PARTICULAR PURPOSE. You are solely responsible for determining the\n appropriateness of using or redistributing the Work and assume any\n risks associated with Your exercise of permissions under this License.\n\n 8. Limitation of Liability. In no event and under no legal theory,\n whether in tort (including negligence), contract, or otherwise,\n unless required by applicable law (such as deliberate and grossly\n negligent acts) or agreed to in writing, shall any Contributor be\n liable to You for damages, including any direct, indirect, special,\n incidental, or consequential damages of any character arising as a\n result of this License or out of the use or inability to use the\n Work (including but not limited to damages for loss of goodwill,\n work stoppage, computer failure or malfunction, or any and all\n other commercial damages or losses), even if such Contributor\n has been advised of the possibility of such damages.\n\n 9. Accepting Warranty or Additional Liability. While redistributing\n the Work or Derivative Works thereof, You may choose to offer,\n and charge a fee for, acceptance of support, warranty, indemnity,\n or other liability obligations and/or rights consistent with this\n License. However, in accepting such obligations, You may act only\n on Your own behalf and on Your sole responsibility, not on behalf\n of any other Contributor, and only if You agree to indemnify,\n defend, and hold each Contributor harmless for any liability\n incurred by, or claims asserted against, such Contributor by reason\n of your accepting any such warranty or additional liability.\n\n END OF TERMS AND CONDITIONS\n\n\nISC license\n-------------------------------------\n\n\nCopyright Amazon.com, Inc. or its affiliates.\n\nPermission to use, copy, modify, and/or distribute this software for any\npurpose with or without fee is hereby granted, provided that the above\ncopyright notice and this permission notice appear in all copies.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\" AND THE AUTHOR DISCLAIMS ALL WARRANTIES\nWITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF\nMERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR\nANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES\nWHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN\nACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF\nOR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "aws-lc-rs", "version": "1.18.1" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "# The MIT License (MIT)\n\nCopyright (c) 2014 Santiago Lapresta and contributors\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in\nall copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN\nTHE SOFTWARE.", + "used_by": [ + { "crate": { "name": "dotenvy", "version": "0.15.7" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "===============================================================================\n\nCopyright (c) 2016 Alex Crichton\nCopyright (c) 2017 The Tokio Authors\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n\thttp://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n\n===============================================================================\n\nCopyright (c) 2016 Alex Crichton\nCopyright (c) 2017 The Tokio Authors\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "atomic-waker", "version": "1.1.2" } }, + { "crate": { "name": "futures-lite", "version": "2.6.1" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "AWS Libcrypto (AWS-LC)\n\nAWS-LC is a fork of BoringSSL, which is itself a fork of OpenSSL.\nContent from these and other sources retains their original licensing,\nas described below. New files from AWS-LC are made available under the Apache-2.0 license OR the ISC\nlicense. These licenses are reproduced at the bottom of this file.\n\n```\nCopyright Amazon.com, Inc. or its affiliates. All Rights Reserved.\nSPDX-License-Identifier: Apache-2.0 OR ISC\n```\n\n\n================================================================================\nBoringSSL\n================================================================================\n\nBoringSSL is a Google-maintained fork of OpenSSL. Historically, code\nauthored by Google for BoringSSL was licensed under the ISC License.\nBoringSSL has since relicensed upstream to Apache License 2.0. Existing\nAWS-LC code originating from BoringSSL retains its ISC license, while\nnewer code taken from BoringSSL is licensed under Apache License 2.0.\n\n```\nCopyright (c) 2014-2024 Google Inc.\nSPDX-License-Identifier: ISC\n```\n\nAdditional individual contributions to BoringSSL-derived code are\ncovered under the ISC license:\n\n - Brian Smith (Copyright 2016)\n - Robert Nagy (Copyright 2022)\n - Arm Ltd (Copyright 2020)\n\n```\nCopyright (c) 2025-2026 Google Inc.\nSPDX-License-Identifier: Apache-2.0\n```\n\n================================================================================\nOpenSSL\n================================================================================\n\nCode derived from the OpenSSL project is licensed under the\nApache License, Version 2.0.\n\n```\nCopyright (c) 1998-2011 The OpenSSL Project. All rights reserved.\nSPDX-License-Identifier: Apache-2.0\n```\n\nSome OpenSSL-derived files also carry the original SSLeay copyright:\n\n```\nCopyright (c) 1995-1998 Eric Young (eay@cryptsoft.com). All rights reserved.\nSPDX-License-Identifier: Apache-2.0\n```\n\nPortions of OpenSSL-derived code include contributions from:\n\n - Sun Microsystems, Inc. (Copyright 2002)\n - Nokia (Copyright 2005)\n - Intel Corporation (Copyright 2012-2021)\n\nThese contributions are covered under the Apache-2.0 license.\n\n================================================================================\nmlkem-native\n================================================================================\n\nCode from the mlkem-native project is licensed under Apache License 2.0 or MIT or ISC.\n\n```\nCopyright (c) The mlkem-native project authors.\nSPDX-License-Identifier: Apache-2.0 OR ISC OR MIT\n```\n\n================================================================================\nmldsa-native\n================================================================================\n\nCode from the mldsa-native project is licensed under Apache License 2.0 or MIT or ISC\n\n```\nCopyright (c) The mldsa-native project authors.\nSPDX-License-Identifier: Apache-2.0 OR ISC OR MIT\n```\n\n================================================================================\nThird-Party Libraries (compiled into libcrypto/libssl)\n================================================================================\n\nFiat Cryptography\n-----------------\nSynthesizing Correct-by-Construction Code for Cryptographic Primitives.\nSee third_party/fiat/LICENSE.\n\n```\nCopyright (c) 2015-2020 the fiat-crypto authors.\nSPDX-License-Identifier: MIT\n```\n\ns2n-bignum\n----------\nInteger arithmetic routines for cryptography.\nSee third_party/s2n-bignum/s2n-bignum-imported/LICENSE.\n\n```\nCopyright Amazon.com, Inc. or its affiliates. All Rights Reserved.\nSPDX-License-Identifier: Apache-2.0 OR ISC OR MIT-0\n```\n\nNote: ML-KEM/SHA3 code within s2n-bignum is licensed as\nApache-2.0 OR ISC OR MIT (with attribution), sourced from the\nmlkem-native project.\n\nJitter Entropy RNG\n-------------------\nCPU Jitter Random Number Generator Library.\nSee third_party/jitterentropy/jitterentropy-library/LICENSE.\n\nThe Jitter Entropy library is dual-licensed under a BSD-style license\nand the GNU General Public License Version 2. Amazon expressly elects\nto distribute the package under the 3-Clause BSD License and NOT under\nGNU General Public License Version 2.\n\n```\nCopyright (C) 2017 - 2025, Stephan Mueller .\nSPDX-License-Identifier: BSD-3-Clause\n```\n\nKeccak / AES Reference Implementations\n---------------------------------------\nPublic domain (CC0) contributions.\nhttps://creativecommons.org/public-domain/cc0\n\nCode from sources and by authors listed in comments on top of the respective files.\n\n\n================================================================================\nThird-Party Libraries (NOT compiled into libcrypto/libssl)\n================================================================================\n\nThe following are used for testing and build tooling only. Distributing\ncode linked against AWS-LC (libcrypto/libssl) does NOT trigger these\nlicense obligations.\n\nGoogle Test\n-----------\nSee third_party/googletest/LICENSE.\n\n```\nCopyright 2008 Google Inc.\nSPDX-License-Identifier: BSD-3-Clause\n```\n\nGo Standard Library\n-------------------\nCode in ssl/test/runner/ is derived from the Go standard library.\n\n```\nCopyright (c) The Go Authors. All rights reserved.\nSPDX-License-Identifier: BSD-3-Clause\n```\n\nWycheproof Test Vectors\n------------------------\nProject Wycheproof is a community managed repository of test vectors that can be used by cryptography library\ndevelopers to test against known attacks, specification inconsistencies, and other various implementation bugs.\n\nSee third_party/wycheproof_testvectors/LICENSE.\n\n```\nSPDX-License-Identifier: Apache-2.0\n```\n\n\n================================================================================\nFull License Texts\n================================================================================\n\nApache License 2.0\n------------------\nApache License\nVersion 2.0, January 2004\nhttp://www.apache.org/licenses/\n\nTERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n 1. Definitions.\n\n \"License\" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document.\n\n \"Licensor\" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License.\n\n \"Legal Entity\" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, \"control\" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity.\n\n \"You\" (or \"Your\") shall mean an individual or Legal Entity exercising permissions granted by this License.\n\n \"Source\" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files.\n\n \"Object\" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types.\n\n \"Work\" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below).\n\n \"Derivative Works\" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof.\n\n \"Contribution\" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, \"submitted\" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as \"Not a Contribution.\"\n\n \"Contributor\" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work.\n 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form.\n 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed.\n 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions:\n (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and\n (b) You must cause any modified files to carry prominent notices stating that You changed the files; and\n (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and\n (d) If the Work includes a \"NOTICE\" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License.\n\n You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License.\n 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions.\n 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file.\n 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an \"AS IS\" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License.\n 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages.\n 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability.\n\nEND OF TERMS AND CONDITIONS\n\nAPPENDIX: How to apply the Apache License to your work.\n\nTo apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets \"[]\" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same \"printed page\" as the copyright notice for easier identification within third-party archives.\n\nCopyright [yyyy] [name of copyright owner]\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\nhttp://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n\nISC License\n-----------\nPermission to use, copy, modify, and/or distribute this software for\nany purpose with or without fee is hereby granted, provided that the\nabove copyright notice and this permission notice appear in all copies.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\" AND THE AUTHOR DISCLAIMS ALL\nWARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED\nWARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE\nAUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL\nDAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR\nPROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER\nTORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR\nPERFORMANCE OF THIS SOFTWARE.\n\nMIT License\n-----------\nPermission is hereby granted, free of charge, to any person obtaining\na copy of this software and associated documentation files (the\n\"Software\"), to deal in the Software without restriction, including\nwithout limitation the rights to use, copy, modify, merge, publish,\ndistribute, sublicense, and/or sell copies of the Software, and to\npermit persons to whom the Software is furnished to do so, subject to\nthe following conditions:\n\nThe above copyright notice and this permission notice shall be\nincluded in all copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND,\nEXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF\nMERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.\nIN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,\nTORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE\nSOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.\n\nBSD 3-Clause License\n--------------------\nRedistribution and use in source and binary forms, with or without\nmodification, are permitted provided that the following conditions\nare met:\n\n1. Redistributions of source code must retain the above copyright\n notice, this list of conditions and the following disclaimer.\n\n2. Redistributions in binary form must reproduce the above copyright\n notice, this list of conditions and the following disclaimer in the\n documentation and/or other materials provided with the distribution.\n\n3. Neither the name of the copyright holder nor the names of its\n contributors may be used to endorse or promote products derived\n from this software without specific prior written permission.\n\nTHIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS\n\"AS IS\" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT\nLIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR\nA PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT\nHOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,\nSPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT\nLIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,\nDATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY\nTHEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT\n(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE\nOF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.\n\nMIT No Attribution (MIT-0)\n--------------------------\nPermission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the \"Software\"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "aws-lc-sys", "version": "0.45.0" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (C) SQLx Contributors\nPortions of this work Copyright (C) LaunchBadge, LLC\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "sqlx-core", "version": "0.9.0" } }, + { "crate": { "name": "sqlx-macros-core", "version": "0.9.0" } }, + { "crate": { "name": "sqlx-macros", "version": "0.9.0" } }, + { "crate": { "name": "sqlx-mysql", "version": "0.9.0" } }, + { "crate": { "name": "sqlx-sqlite", "version": "0.9.0" } }, + { "crate": { "name": "sqlx", "version": "0.9.0" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2006-2009 Graydon Hoare\nCopyright (c) 2009-2013 Mozilla Foundation\nCopyright (c) 2016 Artyom Pavlov\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "md-5", "version": "0.10.6" } }, + { "crate": { "name": "sha2", "version": "0.10.9" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2010 The Rust Project Developers\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "lazy_static", "version": "1.5.0" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2013-2014 The Rust Project Developers.\nCopyright (c) 2015-2020 The rust-hex Developers\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n", + "used_by": [ + { "crate": { "name": "hex", "version": "0.4.3" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2013-2016 The rust-url developers\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "form_urlencoded", "version": "1.2.2" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2013-2025 The rust-url developers\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "idna", "version": "1.1.0" } }, + { "crate": { "name": "percent-encoding", "version": "2.3.2" } }, + { "crate": { "name": "url", "version": "2.5.8" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2014 Alex Crichton\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "cfg-if", "version": "1.0.4" } }, + { "crate": { "name": "futures-timer", "version": "3.0.4" } }, + { "crate": { "name": "openssl-probe", "version": "0.2.1" } }, + { "crate": { "name": "socket2", "version": "0.6.5" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2014 Carl Lerche and other MIO contributors\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in\nall copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN\nTHE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "mio", "version": "1.2.3" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2014 Chris Wong\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "errno", "version": "0.3.14" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2014 Steve \"Sc00bz\" Thomas (steve at tobtu dot com)\nCopyright (c) 2021-2025 The RustCrypto Project Developers\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "base64ct", "version": "1.8.3" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2014 The Rust Project Developers\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.", + "used_by": [ + { "crate": { "name": "bnum", "version": "0.13.0" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2014 The Rust Project Developers\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "bitflags", "version": "2.13.2" } }, + { "crate": { "name": "log", "version": "0.4.34" } }, + { "crate": { "name": "num-bigint-dig", "version": "0.9.1" } }, + { "crate": { "name": "num-bigint", "version": "0.4.8" } }, + { "crate": { "name": "num-complex", "version": "0.4.6" } }, + { "crate": { "name": "num-integer", "version": "0.1.47" } }, + { "crate": { "name": "num-iter", "version": "0.1.46" } }, + { "crate": { "name": "num-rational", "version": "0.4.2" } }, + { "crate": { "name": "num-traits", "version": "0.2.19" } }, + { "crate": { "name": "num", "version": "0.4.3" } }, + { "crate": { "name": "regex-automata", "version": "0.4.18" } }, + { "crate": { "name": "regex-syntax", "version": "0.8.11" } }, + { "crate": { "name": "regex", "version": "1.13.1" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2014 The Rust Project Developers\nCopyright (c) 2018 Ashley Mannix, Christopher Armstrong, Dylan DPC, Hunar Roop Kahlon\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "uuid", "version": "1.26.1" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2014 The rusqlite developers\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in\nall copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN\nTHE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "libsqlite3-sys", "version": "0.37.0" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2014-2026 Alex Crichton\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "flate2", "version": "1.1.10" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2014-2026 Sean McArthur\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in\nall copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN\nTHE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "hyper", "version": "1.11.1" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2015\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "either", "version": "1.18.0" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2015 Steven Allen\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "tempfile", "version": "3.27.0" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2015 The Rust Project Developers\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "heck", "version": "0.5.0" } }, + { "crate": { "name": "unicode-bidi", "version": "0.3.18" } }, + { "crate": { "name": "unicode-normalization", "version": "0.1.25" } }, + { "crate": { "name": "unicode-properties", "version": "0.1.4" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2015 The rust-openssl-verify Developers\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n", + "used_by": [ + { "crate": { "name": "fallible-iterator", "version": "0.2.0" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2015 The unicode-categories Developers\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n", + "used_by": [ + { "crate": { "name": "unicode_categories", "version": "0.1.1" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2015-2018 Vlad Filippov\nCopyright (c) 2018-2021 RustCrypto Developers\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "hkdf", "version": "0.12.4" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2015-2025 Sean McArthur\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in\nall copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN\nTHE SOFTWARE.\n\n", + "used_by": [ + { "crate": { "name": "httparse", "version": "1.10.1" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2016 Alex Crichton\nCopyright (c) 2017 The Tokio Authors\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "futures-channel", "version": "0.3.34" } }, + { "crate": { "name": "futures-core", "version": "0.3.34" } }, + { "crate": { "name": "futures-executor", "version": "0.3.34" } }, + { "crate": { "name": "futures-io", "version": "0.3.34" } }, + { "crate": { "name": "futures-macro", "version": "0.3.34" } }, + { "crate": { "name": "futures-sink", "version": "0.3.34" } }, + { "crate": { "name": "futures-task", "version": "0.3.34" } }, + { "crate": { "name": "futures-util", "version": "0.3.34" } }, + { "crate": { "name": "futures", "version": "0.3.34" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2016 Amanieu d'Antras\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "hashbrown", "version": "0.16.1" } }, + { "crate": { "name": "hashbrown", "version": "0.17.1" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2016 Joseph Birr-Pixton \n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "hyper-rustls", "version": "0.27.9" } }, + { "crate": { "name": "rustls-native-certs", "version": "0.8.4" } }, + { "crate": { "name": "rustls", "version": "0.23.46" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2016 The Rust Project Developers\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "lock_api", "version": "0.4.14" } }, + { "crate": { "name": "parking_lot", "version": "0.12.5" } }, + { "crate": { "name": "parking_lot_core", "version": "0.9.12" } }, + { "crate": { "name": "thread_local", "version": "1.1.10" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2016--2017\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "indexmap", "version": "2.14.2" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2016--2023\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "equivalent", "version": "1.0.2" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2016-2019 Ulrik Sverdrup \"bluss\" and scopeguard developers\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "scopeguard", "version": "1.2.0" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2016-2020 RustCrypto Developers\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "cipher", "version": "0.4.4" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2016-2026 The RustCrypto Project Developers\nCopyright (c) 2016 Artyom Pavlov\nCopyright (c) 2009-2013 Mozilla Foundation\nCopyright (c) 2006-2009 Graydon Hoare\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "md-5", "version": "0.11.0" } }, + { "crate": { "name": "sha1", "version": "0.11.0" } }, + { "crate": { "name": "sha2", "version": "0.11.0" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2017 Artyom Pavlov\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "digest", "version": "0.10.7" } }, + { "crate": { "name": "hmac", "version": "0.12.1" } }, + { "crate": { "name": "hmac", "version": "0.13.0" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2017 Artyom Pavlov\nCopyright (c) 2018-2023 The RustCrypto Project Developers\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "pbkdf2", "version": "0.12.2" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2017 Gilad Naaman\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.", + "used_by": [ + { "crate": { "name": "memoffset", "version": "0.9.1" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2017 Maik Klein\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "enumflags2_derive", "version": "0.7.12" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2017 Robert Grosse\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.", + "used_by": [ + { "crate": { "name": "stable_deref_trait", "version": "1.2.1" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2017 The rust-stringprep Developers\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n", + "used_by": [ + { "crate": { "name": "stringprep", "version": "0.1.5" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2017 http-rs authors\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "http", "version": "1.5.0" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2017 quininer kel\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "tokio-rustls", "version": "0.26.5" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2017 tokio-jsonrpc developers\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "signal-hook-registry", "version": "1.4.8" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2017-2020 The Rust Project Developers\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "core_detect", "version": "1.0.0" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2017-2023 Maik Klein, Maja Kądziołka\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "enumflags2", "version": "0.7.12" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2017-2025 RustCrypto Developers\nCopyright (c) 2017 Artyom Pavlov\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "digest", "version": "0.11.3" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2018 Artyom Pavlov\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "aes", "version": "0.8.4" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2018 Carl Lerche\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "bytes", "version": "1.12.1" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2018 The Servo Project Developers\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "smallvec", "version": "1.16.1" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2018-2019 Sean McArthur\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in\nall copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN\nTHE SOFTWARE.\n\n", + "used_by": [ + { "crate": { "name": "want", "version": "0.3.1" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2018-2019 The RustCrypto Project Developers\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "block-buffer", "version": "0.10.4" } }, + { "crate": { "name": "block-padding", "version": "0.3.3" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2018-2022 RustCrypto Developers\nCopyright (c) 2018 Artyom Pavlov\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "cbc", "version": "0.1.2" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2018-2023 Sean McArthur\nCopyright (c) 2016 Alex Crichton\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in\nall copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN\nTHE SOFTWARE.\n\n", + "used_by": [ + { "crate": { "name": "try-lock", "version": "0.2.5" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2018-2025 The RustCrypto Project Developers\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "block-buffer", "version": "0.12.1" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2018-2025 The rust-random Project Developers\nCopyright (c) 2014 The Rust Project Developers\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "getrandom", "version": "0.3.4" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2018-2026 RustCrypto Developers\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "signature", "version": "3.0.0" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2018-2026 The Rand Project Developers\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "rand_core", "version": "0.10.1" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2018-2026 The RustCrypto Project Developers\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "zeroize", "version": "1.9.0" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2018-2026 The rust-random Project Developers\nCopyright (c) 2014 The Rust Project Developers\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "getrandom", "version": "0.4.3" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2019 Carl Lerche\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "slab", "version": "0.4.12" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2019 Eliza Weisman\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in\nall copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN\nTHE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "sharded-slab", "version": "0.1.7" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2019 Eliza Weisman\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n", + "used_by": [ + { "crate": { "name": "matchers", "version": "0.2.0" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2019 Matthias Einwag\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "futures-intrusive", "version": "0.5.0" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2019 Nick Fitzgerald\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "bumpalo", "version": "3.20.3" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2019 Tokio Contributors\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "tracing-attributes", "version": "0.1.31" } }, + { "crate": { "name": "tracing-core", "version": "0.1.36" } }, + { "crate": { "name": "tracing-log", "version": "0.2.0" } }, + { "crate": { "name": "tracing-serde", "version": "0.2.0" } }, + { "crate": { "name": "tracing-subscriber", "version": "0.3.23" } }, + { "crate": { "name": "tracing", "version": "0.1.44" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2019 Tower Contributors\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "tower-service", "version": "0.3.3" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2019-2026 Sean McArthur & Hyper Contributors\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "http-body-util", "version": "0.1.5" } }, + { "crate": { "name": "http-body", "version": "1.1.0" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2019-2026 The RustCrypto Project Developers\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "chacha20", "version": "0.10.2" } }, + { "crate": { "name": "zeroize_derive", "version": "1.5.0" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2020 Andrew D. Straw\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "iana-time-zone", "version": "0.1.65" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2020-2022 The RustCrypto Project Developers\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "const-oid", "version": "0.9.6" } }, + { "crate": { "name": "der_derive", "version": "0.7.3" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2020-2023 The RustCrypto Project Developers\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "der", "version": "0.7.10" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2020-2025 The RustCrypto Project Developers\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "cpufeatures", "version": "0.2.17" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2020-2026 The RustCrypto Project Developers\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "const-oid", "version": "0.10.2" } }, + { "crate": { "name": "cpufeatures", "version": "0.3.1" } }, + { "crate": { "name": "der", "version": "0.8.2" } }, + { "crate": { "name": "pkcs8", "version": "0.11.0" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2021 RustCrypto Developers\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "crypto-common", "version": "0.1.7" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2021 The RustCrypto Project Developers\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "x509-cert", "version": "0.2.5" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2021 Tiberius Contributors\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "tiberius-macros", "version": "0.1.0" } }, + { "crate": { "name": "tiberius", "version": "0.13.0" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2021-2023 The RustCrypto Project Developers\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "spki", "version": "0.7.3" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2021-2025 The RustCrypto Project Developers\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "pem-rfc7468", "version": "1.0.0" } }, + { "crate": { "name": "pkcs1", "version": "0.8.0-rc.4" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2021-2026 RustCrypto Developers\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "crypto-common", "version": "0.2.2" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2021-2026 The RustCrypto Project Developers\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "crypto-bigint", "version": "0.7.5" } }, + { "crate": { "name": "spki", "version": "0.8.0" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2022 The RustCrypto Project Developers\nCopyright (c) 2022 Artyom Pavlov\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "inout", "version": "0.1.4" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2022-2026 The RustCrypto Project Developers\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "cmov", "version": "0.5.4" } }, + { "crate": { "name": "hybrid-array", "version": "0.4.15" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2023 Dirkjan Ochtman \n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "rustls-pki-types", "version": "1.15.1" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2023-2025 Sean McArthur\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in\nall copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN\nTHE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "hyper-util", "version": "0.1.20" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2023-2026 The RustCrypto Project Developers\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "cpubits", "version": "0.1.1" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2024 Zeeshan Ali Khan & zbus contributors\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "zbus", "version": "5.19.0" } }, + { "crate": { "name": "zbus_macros", "version": "5.19.0" } }, + { "crate": { "name": "zbus_names", "version": "4.3.4" } }, + { "crate": { "name": "zvariant", "version": "5.15.0" } }, + { "crate": { "name": "zvariant_derive", "version": "5.15.0" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2025-2026 The RustCrypto Project Developers\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "ctutils", "version": "0.4.2" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2026 Zeeshan Ali Khan & zcheapstr contributors\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "zcheapstr", "version": "1.1.0" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) Individual contributors\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n", + "used_by": [ + { "crate": { "name": "toml_datetime", "version": "1.1.1+spec-1.1.0" } }, + { "crate": { "name": "toml_edit", "version": "0.25.15+spec-1.1.0" } }, + { "crate": { "name": "toml_parser", "version": "1.1.3+spec-1.1.0" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) The Rust Project Developers\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "libc", "version": "0.2.189" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) The rust-url developers\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "idna_adapter", "version": "1.2.2" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright 2016 Nika Layzell\n\nPermission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the \"Software\"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "synstructure", "version": "0.13.2" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright 2018 Developers of the Rand project\nCopyright (c) 2014 The Rust Project Developers\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.", + "used_by": [ + { "crate": { "name": "bnum", "version": "0.13.0" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright 2018 Developers of the Rand project\nCopyright (c) 2014 The Rust Project Developers\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "rand", "version": "0.10.2" } }, + { "crate": { "name": "rand", "version": "0.9.5" } }, + { "crate": { "name": "rand_core", "version": "0.9.5" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright 2019 Caleb Zulawski\n\nPermission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the \"Software\"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "multiversion-macros", "version": "0.9.0" } }, + { "crate": { "name": "multiversion", "version": "0.9.0" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright 2022-* Bogdan Opanchuk\n\nPermission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "crypto-primes", "version": "0.7.2" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright 2023 The Fuchsia Authors\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n\n", + "used_by": [ + { "crate": { "name": "zerocopy-derive", "version": "0.8.57" } }, + { "crate": { "name": "zerocopy", "version": "0.8.57" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright Mozilla Foundation\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "encoding_rs", "version": "0.8.41" } }, + { "crate": { "name": "multiversion_no_op", "version": "1.0.0" } }, + { "crate": { "name": "utf8_iter", "version": "1.0.4" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "MIT License\n\nCopyright (c) 2016-2021 Diggory Blake, and other contributors.\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n", + "used_by": [ + { "crate": { "name": "field-offset", "version": "0.3.6" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "MIT License\n\nCopyright (c) 2017 \n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n", + "used_by": [ + { "crate": { "name": "atoi", "version": "2.0.0" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "MIT License\n\nCopyright (c) 2017 crc-rs Developers\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n", + "used_by": [ + { "crate": { "name": "crc", "version": "3.4.0" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "MIT License\n\nCopyright (c) 2018 Andrei Volnin\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n", + "used_by": [ + { "crate": { "name": "pretty-hex", "version": "0.4.2" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "MIT License\n\nCopyright (c) 2018 Sam Rijs, Alex Crichton and contributors\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n", + "used_by": [ + { "crate": { "name": "crc32fast", "version": "1.5.2" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "MIT License\n\nCopyright (c) 2019 - 2020 Matt Hunzinger\nCopyright (c) 2021 Max Inden\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n", + "used_by": [ + { "crate": { "name": "asynchronous-codec", "version": "0.7.0" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "MIT License\n\nCopyright (c) 2019 Akhil Velagapudi\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n", + "used_by": [ + { "crate": { "name": "crc-catalog", "version": "2.5.0" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "MIT License\n\nCopyright (c) 2019 Jasper Hugo\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n", + "used_by": [ + { "crate": { "name": "tokio-postgres-rustls", "version": "0.14.0" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "MIT License\n\nCopyright (c) 2019 Yoshua Wuyts\nCopyright (c) Tokio Contributors\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n", + "used_by": [ + { "crate": { "name": "tokio-macros", "version": "2.7.2" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "MIT License\n\nCopyright (c) 2019-2024 iqlusion\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n", + "used_by": [ + { "crate": { "name": "secrecy", "version": "0.10.3" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "MIT License\n\nCopyright (c) 2020 Bilal Elmoussaoui\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n", + "used_by": [ + { "crate": { "name": "ashpd", "version": "0.13.13" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "MIT License\n\nCopyright (c) 2022 1Password\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n", + "used_by": [ + { "crate": { "name": "rustls-platform-verifier", "version": "0.7.0" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "MIT License\n\nCopyright (c) 2022 Bilal Elmoussaoui, Sophie Herold, Maximiliano Sandoval, Dhanuka Warusadura\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n", + "used_by": [ + { "crate": { "name": "oo7", "version": "0.6.0" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "MIT License\n\nCopyright (c) 2022 Isaac Holt\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.", + "used_by": [ + { "crate": { "name": "bnum", "version": "0.13.0" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "MIT License\n\nCopyright (c) Tokio Contributors\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n", + "used_by": [ + { "crate": { "name": "tokio-stream", "version": "0.1.19" } }, + { "crate": { "name": "tokio-util", "version": "0.7.19" } }, + { "crate": { "name": "tokio", "version": "1.53.1" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "MIT License\n\nCopyright (c) [2021] [Marvin Countryman]\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n", + "used_by": [ + { "crate": { "name": "simd-adler32", "version": "0.3.10" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "MIT License\n\nCopyright (c) [year] [fullname]\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n", + "used_by": [ + { "crate": { "name": "libgssapi-sys", "version": "0.3.4" } }, + { "crate": { "name": "libgssapi", "version": "0.11.0" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "MIT License\n\nCopyright 2022-2026 John McNamara \n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n\n", + "used_by": [ + { "crate": { "name": "rust_xlsxwriter", "version": "0.99.1" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "MIT License\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.", + "used_by": [ + { "crate": { "name": "simdutf8", "version": "0.1.5" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "MIT License\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n", + "used_by": [ + { "crate": { "name": "half", "version": "2.7.1" } }, + { "crate": { "name": "whoami", "version": "2.1.3" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Permission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.", + "used_by": [ + { "crate": { "name": "async-recursion", "version": "1.1.1" } }, + { "crate": { "name": "flume", "version": "0.12.0" } }, + { "crate": { "name": "rsa", "version": "0.10.0-rc.18" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Permission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "async-trait", "version": "0.1.92" } }, + { "crate": { "name": "atomic-waker", "version": "1.1.2" } }, + { "crate": { "name": "clickhouse-macros", "version": "0.3.0" } }, + { "crate": { "name": "clickhouse-types", "version": "0.1.3" } }, + { "crate": { "name": "clickhouse", "version": "0.15.2" } }, + { "crate": { "name": "displaydoc", "version": "0.2.7" } }, + { "crate": { "name": "endi", "version": "1.1.1" } }, + { "crate": { "name": "event-listener-strategy", "version": "0.5.4" } }, + { "crate": { "name": "event-listener", "version": "5.4.2" } }, + { "crate": { "name": "fastrand", "version": "2.5.0" } }, + { "crate": { "name": "futures-lite", "version": "2.6.1" } }, + { "crate": { "name": "itoa", "version": "1.0.18" } }, + { "crate": { "name": "linux-raw-sys", "version": "0.12.1" } }, + { "crate": { "name": "once_cell", "version": "1.21.4" } }, + { "crate": { "name": "ordered-stream", "version": "0.2.0" } }, + { "crate": { "name": "parking", "version": "2.2.1" } }, + { "crate": { "name": "pin-project-lite", "version": "0.2.17" } }, + { "crate": { "name": "proc-macro-crate", "version": "3.5.0" } }, + { "crate": { "name": "proc-macro2", "version": "1.0.107" } }, + { "crate": { "name": "quote", "version": "1.0.47" } }, + { "crate": { "name": "relm4-macros", "version": "0.11.0" } }, + { "crate": { "name": "relm4", "version": "0.11.0" } }, + { "crate": { "name": "rustix", "version": "1.1.4" } }, + { "crate": { "name": "rustversion", "version": "1.0.23" } }, + { "crate": { "name": "serde", "version": "1.0.229" } }, + { "crate": { "name": "serde_bytes", "version": "0.11.19" } }, + { "crate": { "name": "serde_core", "version": "1.0.229" } }, + { "crate": { "name": "serde_derive", "version": "1.0.229" } }, + { "crate": { "name": "serde_derive_internals", "version": "0.29.1" } }, + { "crate": { "name": "serde_json", "version": "1.0.151" } }, + { "crate": { "name": "serde_repr", "version": "0.1.21" } }, + { "crate": { "name": "sqlformat", "version": "0.5.0" } }, + { "crate": { "name": "syn", "version": "2.0.119" } }, + { "crate": { "name": "syn", "version": "3.0.5" } }, + { "crate": { "name": "thiserror-impl", "version": "2.0.20" } }, + { "crate": { "name": "thiserror", "version": "2.0.20" } }, + { "crate": { "name": "typed-path", "version": "0.12.3" } }, + { "crate": { "name": "unicode-ident", "version": "1.0.24" } }, + { "crate": { "name": "zmij", "version": "1.0.23" } }, + { "crate": { "name": "zvariant_utils", "version": "4.2.0" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Permission is hereby granted, free of charge, to any\r\nperson obtaining a copy of this software and associated\r\ndocumentation files (the \"Software\"), to deal in the\r\nSoftware without restriction, including without\r\nlimitation the rights to use, copy, modify, merge,\r\npublish, distribute, sublicense, and/or sell copies of\r\nthe Software, and to permit persons to whom the Software\r\nis furnished to do so, subject to the following\r\nconditions:\r\n\r\nThe above copyright notice and this permission notice\r\nshall be included in all copies or substantial portions\r\nof the Software.\r\n\r\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\r\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\r\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\r\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\r\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\r\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\r\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\r\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\r\nDEALINGS IN THE SOFTWARE.\r\n", + "used_by": [ + { "crate": { "name": "allocator-api2", "version": "0.2.21" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Permission is hereby granted, free of charge, to any person obtaining\na copy of this software and associated documentation files (the\n\"Software\"), to deal in the Software without restriction, including\nwithout limitation the rights to use, copy, modify, merge, publish,\ndistribute, sublicense, and/or sell copies of the Software, and to\npermit persons to whom the Software is furnished to do so, subject to\nthe following conditions:\n\nThe above copyright notice and this permission notice shall be\nincluded in all copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND,\nEXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF\nMERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND\nNONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE\nLIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION\nWITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "winnow", "version": "0.7.15" } }, + { "crate": { "name": "winnow", "version": "1.0.4" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Permission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n", + "used_by": [ + { "crate": { "name": "siphasher", "version": "1.0.4" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Permission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n\n", + "used_by": [ + { "crate": { "name": "cairo-rs", "version": "0.22.9" } }, + { "crate": { "name": "cairo-sys-rs", "version": "0.22.9" } }, + { "crate": { "name": "gdk-pixbuf-sys", "version": "0.22.9" } }, + { "crate": { "name": "gdk-pixbuf", "version": "0.22.0" } }, + { "crate": { "name": "gdk4-sys", "version": "0.11.4" } }, + { "crate": { "name": "gdk4", "version": "0.11.4" } }, + { "crate": { "name": "gio-sys", "version": "0.22.9" } }, + { "crate": { "name": "gio-unix-sys", "version": "0.22.9" } }, + { "crate": { "name": "gio-unix", "version": "0.22.8" } }, + { "crate": { "name": "gio", "version": "0.22.9" } }, + { "crate": { "name": "glib-macros", "version": "0.22.9" } }, + { "crate": { "name": "glib-sys", "version": "0.22.9" } }, + { "crate": { "name": "glib", "version": "0.22.9" } }, + { "crate": { "name": "gobject-sys", "version": "0.22.9" } }, + { "crate": { "name": "graphene-rs", "version": "0.22.8" } }, + { "crate": { "name": "graphene-sys", "version": "0.22.9" } }, + { "crate": { "name": "gsk4-sys", "version": "0.11.4" } }, + { "crate": { "name": "gsk4", "version": "0.11.4" } }, + { "crate": { "name": "gtk4-macros", "version": "0.11.4" } }, + { "crate": { "name": "gtk4-sys", "version": "0.11.4" } }, + { "crate": { "name": "gtk4", "version": "0.11.4" } }, + { "crate": { "name": "libadwaita-sys", "version": "0.9.2" } }, + { "crate": { "name": "libadwaita", "version": "0.9.2" } }, + { "crate": { "name": "pango-sys", "version": "0.22.9" } }, + { "crate": { "name": "pango", "version": "0.22.9" } }, + { "crate": { "name": "sourceview5-sys", "version": "0.11.2" } }, + { "crate": { "name": "sourceview5", "version": "0.11.2" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the \"Software\"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "tinyvec", "version": "1.13.3" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "The MIT License (MIT)\n\nCopyright (c) 2014 Mathijs van de Nes\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.", + "used_by": [ + { "crate": { "name": "spin", "version": "0.9.9" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "The MIT License (MIT)\n\nCopyright (c) 2014 Mathijs van de Nes\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n", + "used_by": [ + { "crate": { "name": "tracing-core", "version": "0.1.36" } }, + { "crate": { "name": "zip", "version": "8.6.0" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "The MIT License (MIT)\n\nCopyright (c) 2014 Paho Lurie-Gregg\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n", + "used_by": [ + { "crate": { "name": "typenum", "version": "1.20.1" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "The MIT License (MIT)\n\nCopyright (c) 2014, Kang Seonghoon.\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in\nall copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN\nTHE SOFTWARE.", + "used_by": [ + { "crate": { "name": "chrono", "version": "0.4.45" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "The MIT License (MIT)\n\nCopyright (c) 2014-2022 Steven Fackler, Yuki Okushi\n\nPermission is hereby granted, free of charge, to any person obtaining a copy of\nthis software and associated documentation files (the \"Software\"), to deal in\nthe Software without restriction, including without limitation the rights to\nuse, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software is furnished to do so,\nsubject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS\nFOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR\nCOPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER\nIN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN\nCONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "phf", "version": "0.13.1" } }, + { "crate": { "name": "phf_shared", "version": "0.13.1" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "The MIT License (MIT)\n\nCopyright (c) 2015 Alice Maz\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in\nall copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN\nTHE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "base64", "version": "0.22.1" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "The MIT License (MIT)\n\nCopyright (c) 2015 Andrew Gallant\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in\nall copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN\nTHE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "aho-corasick", "version": "1.1.5" } }, + { "crate": { "name": "byteorder", "version": "1.5.0" } }, + { "crate": { "name": "csv-core", "version": "0.1.13" } }, + { "crate": { "name": "csv", "version": "1.4.0" } }, + { "crate": { "name": "memchr", "version": "2.8.3" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "The MIT License (MIT)\n\nCopyright (c) 2016 Konstantin V. Salikhov\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n", + "used_by": [ + { "crate": { "name": "gettext-rs", "version": "0.8.0" } }, + { "crate": { "name": "gettext-sys", "version": "0.26.0" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "The MIT License (MIT)\n\nCopyright (c) 2016 Steven Fackler\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n\n", + "used_by": [ + { "crate": { "name": "postgres-protocol", "version": "0.6.12" } }, + { "crate": { "name": "postgres-types", "version": "0.2.14" } }, + { "crate": { "name": "tokio-postgres", "version": "0.7.18" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "The MIT License (MIT)\n\nCopyright (c) 2016–2019 Jan Hudec \nCopyright (c) 2016 A.J. Gardner \nCopyright (c) 2019, Bastien Orivel \nCopyright (c) 2019, Igor Gnatenko \nCopyright (c) 2019, Sophie Tauchert <999eagle@999eagle.moe>\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in\nall copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN\nTHE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "locale_config", "version": "0.3.0" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "The MIT License (MIT)\n\nCopyright (c) 2018-2019 Andrew Gallant\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in\nall copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN\nTHE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "bstr", "version": "1.13.1" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "The MIT License (MIT)\n\nCopyright (c) 2019 Michael P. Jung\n\nPermission is hereby granted, free of charge, to any person obtaining a copy of\nthis software and associated documentation files (the \"Software\"), to deal in\nthe Software without restriction, including without limitation the rights to\nuse, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software is furnished to do so,\nsubject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS\nFOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR\nCOPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER\nIN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN\nCONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.\n\n", + "used_by": [ + { "crate": { "name": "deadpool-postgres", "version": "0.14.2" } }, + { "crate": { "name": "deadpool-runtime", "version": "0.3.1" } }, + { "crate": { "name": "deadpool", "version": "0.13.1" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "The MIT License (MIT)\n\nCopyright (c) 2019 The Crossbeam Project Developers\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "crossbeam-queue", "version": "0.3.14" } }, + { "crate": { "name": "crossbeam-utils", "version": "0.8.23" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "The MIT License (MIT)\n\nCopyright (c) 2020 Yoshua Wuyts\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n", + "used_by": [ + { "crate": { "name": "async-broadcast", "version": "0.7.2" } }, + { "crate": { "name": "connection-string", "version": "0.2.0" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "The MIT License (MIT)\r\n\r\nCopyright (c) 2014 Benjamin Sago\r\nCopyright (c) 2021-2022 The Nushell Project Developers\r\n\r\nPermission is hereby granted, free of charge, to any person obtaining a copy\r\nof this software and associated documentation files (the \"Software\"), to deal\r\nin the Software without restriction, including without limitation the rights\r\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\r\ncopies of the Software, and to permit persons to whom the Software is\r\nfurnished to do so, subject to the following conditions:\r\n\r\nThe above copyright notice and this permission notice shall be included in all\r\ncopies or substantial portions of the Software.\r\n\r\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\r\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\r\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\r\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\r\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\r\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\r\nSOFTWARE.\r\n", + "used_by": [ + { "crate": { "name": "nu-ansi-term", "version": "0.50.3" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "The MIT License (MIT)\r\n\r\nCopyright (c) 2015 Bartłomiej Kamiński\r\n\r\nPermission is hereby granted, free of charge, to any person obtaining a copy\r\nof this software and associated documentation files (the \"Software\"), to deal\r\nin the Software without restriction, including without limitation the rights\r\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\r\ncopies of the Software, and to permit persons to whom the Software is\r\nfurnished to do so, subject to the following conditions:\r\n\r\nThe above copyright notice and this permission notice shall be included in all\r\ncopies or substantial portions of the Software.\r\n\r\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\r\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\r\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\r\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\r\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\r\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\r\nSOFTWARE.", + "used_by": [ + { "crate": { "name": "generic-array", "version": "0.14.7" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "This work is derived in part from the `linked-hash-map` crate, Copyright (c)\n2015 The Rust Project Developers\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.", + "used_by": [ + { "crate": { "name": "hashlink", "version": "0.11.1" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "rust-lang/libm as a whole is available for use under the MIT license:\n\n------------------------------------------------------------------------------\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n------------------------------------------------------------------------------\n\nAs a contributor, you agree that your code can be used under either the MIT\nlicense or the Apache-2.0 license:\n\n------------------------------------------------------------------------------\n Apache License\n Version 2.0, January 2004\n http://www.apache.org/licenses/\n\n TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n 1. Definitions.\n\n \"License\" shall mean the terms and conditions for use, reproduction,\n and distribution as defined by Sections 1 through 9 of this document.\n\n \"Licensor\" shall mean the copyright owner or entity authorized by\n the copyright owner that is granting the License.\n\n \"Legal Entity\" shall mean the union of the acting entity and all\n other entities that control, are controlled by, or are under common\n control with that entity. For the purposes of this definition,\n \"control\" means (i) the power, direct or indirect, to cause the\n direction or management of such entity, whether by contract or\n otherwise, or (ii) ownership of fifty percent (50%) or more of the\n outstanding shares, or (iii) beneficial ownership of such entity.\n\n \"You\" (or \"Your\") shall mean an individual or Legal Entity\n exercising permissions granted by this License.\n\n \"Source\" form shall mean the preferred form for making modifications,\n including but not limited to software source code, documentation\n source, and configuration files.\n\n \"Object\" form shall mean any form resulting from mechanical\n transformation or translation of a Source form, including but\n not limited to compiled object code, generated documentation,\n and conversions to other media types.\n\n \"Work\" shall mean the work of authorship, whether in Source or\n Object form, made available under the License, as indicated by a\n copyright notice that is included in or attached to the work\n (an example is provided in the Appendix below).\n\n \"Derivative Works\" shall mean any work, whether in Source or Object\n form, that is based on (or derived from) the Work and for which the\n editorial revisions, annotations, elaborations, or other modifications\n represent, as a whole, an original work of authorship. For the purposes\n of this License, Derivative Works shall not include works that remain\n separable from, or merely link (or bind by name) to the interfaces of,\n the Work and Derivative Works thereof.\n\n \"Contribution\" shall mean any work of authorship, including\n the original version of the Work and any modifications or additions\n to that Work or Derivative Works thereof, that is intentionally\n submitted to Licensor for inclusion in the Work by the copyright owner\n or by an individual or Legal Entity authorized to submit on behalf of\n the copyright owner. For the purposes of this definition, \"submitted\"\n means any form of electronic, verbal, or written communication sent\n to the Licensor or its representatives, including but not limited to\n communication on electronic mailing lists, source code control systems,\n and issue tracking systems that are managed by, or on behalf of, the\n Licensor for the purpose of discussing and improving the Work, but\n excluding communication that is conspicuously marked or otherwise\n designated in writing by the copyright owner as \"Not a Contribution.\"\n\n \"Contributor\" shall mean Licensor and any individual or Legal Entity\n on behalf of whom a Contribution has been received by Licensor and\n subsequently incorporated within the Work.\n\n 2. Grant of Copyright License. Subject to the terms and conditions of\n this License, each Contributor hereby grants to You a perpetual,\n worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n copyright license to reproduce, prepare Derivative Works of,\n publicly display, publicly perform, sublicense, and distribute the\n Work and such Derivative Works in Source or Object form.\n\n 3. Grant of Patent License. Subject to the terms and conditions of\n this License, each Contributor hereby grants to You a perpetual,\n worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n (except as stated in this section) patent license to make, have made,\n use, offer to sell, sell, import, and otherwise transfer the Work,\n where such license applies only to those patent claims licensable\n by such Contributor that are necessarily infringed by their\n Contribution(s) alone or by combination of their Contribution(s)\n with the Work to which such Contribution(s) was submitted. If You\n institute patent litigation against any entity (including a\n cross-claim or counterclaim in a lawsuit) alleging that the Work\n or a Contribution incorporated within the Work constitutes direct\n or contributory patent infringement, then any patent licenses\n granted to You under this License for that Work shall terminate\n as of the date such litigation is filed.\n\n 4. Redistribution. You may reproduce and distribute copies of the\n Work or Derivative Works thereof in any medium, with or without\n modifications, and in Source or Object form, provided that You\n meet the following conditions:\n\n (a) You must give any other recipients of the Work or\n Derivative Works a copy of this License; and\n\n (b) You must cause any modified files to carry prominent notices\n stating that You changed the files; and\n\n (c) You must retain, in the Source form of any Derivative Works\n that You distribute, all copyright, patent, trademark, and\n attribution notices from the Source form of the Work,\n excluding those notices that do not pertain to any part of\n the Derivative Works; and\n\n (d) If the Work includes a \"NOTICE\" text file as part of its\n distribution, then any Derivative Works that You distribute must\n include a readable copy of the attribution notices contained\n within such NOTICE file, excluding those notices that do not\n pertain to any part of the Derivative Works, in at least one\n of the following places: within a NOTICE text file distributed\n as part of the Derivative Works; within the Source form or\n documentation, if provided along with the Derivative Works; or,\n within a display generated by the Derivative Works, if and\n wherever such third-party notices normally appear. The contents\n of the NOTICE file are for informational purposes only and\n do not modify the License. You may add Your own attribution\n notices within Derivative Works that You distribute, alongside\n or as an addendum to the NOTICE text from the Work, provided\n that such additional attribution notices cannot be construed\n as modifying the License.\n\n You may add Your own copyright statement to Your modifications and\n may provide additional or different license terms and conditions\n for use, reproduction, or distribution of Your modifications, or\n for any such Derivative Works as a whole, provided Your use,\n reproduction, and distribution of the Work otherwise complies with\n the conditions stated in this License.\n\n 5. Submission of Contributions. Unless You explicitly state otherwise,\n any Contribution intentionally submitted for inclusion in the Work\n by You to the Licensor shall be under the terms and conditions of\n this License, without any additional terms or conditions.\n Notwithstanding the above, nothing herein shall supersede or modify\n the terms of any separate license agreement you may have executed\n with Licensor regarding such Contributions.\n\n 6. Trademarks. This License does not grant permission to use the trade\n names, trademarks, service marks, or product names of the Licensor,\n except as required for reasonable and customary use in describing the\n origin of the Work and reproducing the content of the NOTICE file.\n\n 7. Disclaimer of Warranty. Unless required by applicable law or\n agreed to in writing, Licensor provides the Work (and each\n Contributor provides its Contributions) on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or\n implied, including, without limitation, any warranties or conditions\n of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n PARTICULAR PURPOSE. You are solely responsible for determining the\n appropriateness of using or redistributing the Work and assume any\n risks associated with Your exercise of permissions under this License.\n\n 8. Limitation of Liability. In no event and under no legal theory,\n whether in tort (including negligence), contract, or otherwise,\n unless required by applicable law (such as deliberate and grossly\n negligent acts) or agreed to in writing, shall any Contributor be\n liable to You for damages, including any direct, indirect, special,\n incidental, or consequential damages of any character arising as a\n result of this License or out of the use or inability to use the\n Work (including but not limited to damages for loss of goodwill,\n work stoppage, computer failure or malfunction, or any and all\n other commercial damages or losses), even if such Contributor\n has been advised of the possibility of such damages.\n\n 9. Accepting Warranty or Additional Liability. While redistributing\n the Work or Derivative Works thereof, You may choose to offer,\n and charge a fee for, acceptance of support, warranty, indemnity,\n or other liability obligations and/or rights consistent with this\n License. However, in accepting such obligations, You may act only\n on Your own behalf and on Your sole responsibility, not on behalf\n of any other Contributor, and only if You agree to indemnify,\n defend, and hold each Contributor harmless for any liability\n incurred by, or claims asserted against, such Contributor by reason\n of your accepting any such warranty or additional liability.\n\n END OF TERMS AND CONDITIONS\n\n APPENDIX: How to apply the Apache License to your work.\n\n To apply the Apache License to your work, attach the following\n boilerplate notice, with the fields enclosed by brackets \"[]\"\n replaced with your own identifying information. (Don't include\n the brackets!) The text should be enclosed in the appropriate\n comment syntax for the file format. We also recommend that a\n file or class name and description of purpose be included on the\n same \"printed page\" as the copyright notice for easier\n identification within third-party archives.\n\n Licensed under the Apache License, Version 2.0 (the \"License\");\n you may not use this file except in compliance with the License.\n You may obtain a copy of the License at\n\n http://www.apache.org/licenses/LICENSE-2.0\n\n Unless required by applicable law or agreed to in writing, software\n distributed under the License is distributed on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n See the License for the specific language governing permissions and\n limitations under the License.\n------------------------------------------------------------------------------\n\nThis Rust library contains the following copyrights:\n\n Copyright (c) 2018 Jorge Aparicio\n\nPortions of this software are derived from third-party works licensed under\nterms compatible with the above MIT license:\n\n* musl libc https://www.musl-libc.org/. This library contains the following\n copyright:\n\n Copyright © 2005-2020 Rich Felker, et al.\n\n* The CORE-MATH project https://core-math.gitlabpages.inria.fr/. CORE-MATH\n routines are available under the MIT license on a per-file basis.\n\nThe musl libc COPYRIGHT file also includes the following notice relevant to\nmath portions of the library:\n\n------------------------------------------------------------------------------\nMuch of the math library code (src/math/* and src/complex/*) is\nCopyright © 1993,2004 Sun Microsystems or\nCopyright © 2003-2011 David Schultz or\nCopyright © 2003-2009 Steven G. Kargl or\nCopyright © 2003-2009 Bruce D. Evans or\nCopyright © 2008 Stephen L. Moshier or\nCopyright © 2017-2018 Arm Limited\nand labelled as such in comments in the individual source files. All\nhave been licensed under extremely permissive terms.\n------------------------------------------------------------------------------\n\nCopyright notices are retained in src/* files where relevant.\n", + "used_by": [ + { "crate": { "name": "libm", "version": "0.2.16" } } + ] + }, + { + "id": "Unicode-3.0", + "name": "Unicode License v3", + "text": "UNICODE LICENSE V3\n\nCOPYRIGHT AND PERMISSION NOTICE\n\nCopyright © 1991-2023 Unicode, Inc.\n\nNOTICE TO USER: Carefully read the following legal agreement. BY\nDOWNLOADING, INSTALLING, COPYING OR OTHERWISE USING DATA FILES, AND/OR\nSOFTWARE, YOU UNEQUIVOCALLY ACCEPT, AND AGREE TO BE BOUND BY, ALL OF THE\nTERMS AND CONDITIONS OF THIS AGREEMENT. IF YOU DO NOT AGREE, DO NOT\nDOWNLOAD, INSTALL, COPY, DISTRIBUTE OR USE THE DATA FILES OR SOFTWARE.\n\nPermission is hereby granted, free of charge, to any person obtaining a\ncopy of data files and any associated documentation (the \"Data Files\") or\nsoftware and any associated documentation (the \"Software\") to deal in the\nData Files or Software without restriction, including without limitation\nthe rights to use, copy, modify, merge, publish, distribute, and/or sell\ncopies of the Data Files or Software, and to permit persons to whom the\nData Files or Software are furnished to do so, provided that either (a)\nthis copyright and permission notice appear with all copies of the Data\nFiles or Software, or (b) this copyright and permission notice appear in\nassociated Documentation.\n\nTHE DATA FILES AND SOFTWARE ARE PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY\nKIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF\nMERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF\nTHIRD PARTY RIGHTS.\n\nIN NO EVENT SHALL THE COPYRIGHT HOLDER OR HOLDERS INCLUDED IN THIS NOTICE\nBE LIABLE FOR ANY CLAIM, OR ANY SPECIAL INDIRECT OR CONSEQUENTIAL DAMAGES,\nOR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS,\nWHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION,\nARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THE DATA\nFILES OR SOFTWARE.\n\nExcept as contained in this notice, the name of a copyright holder shall\nnot be used in advertising or otherwise to promote the sale, use or other\ndealings in these Data Files or Software without prior written\nauthorization of the copyright holder.\n", + "used_by": [ + { "crate": { "name": "unicode-ident", "version": "1.0.24" } } + ] + }, + { + "id": "Unicode-3.0", + "name": "Unicode License v3", + "text": "UNICODE LICENSE V3\n\nCOPYRIGHT AND PERMISSION NOTICE\n\nCopyright © 2020-2024 Unicode, Inc.\n\nNOTICE TO USER: Carefully read the following legal agreement. BY\nDOWNLOADING, INSTALLING, COPYING OR OTHERWISE USING DATA FILES, AND/OR\nSOFTWARE, YOU UNEQUIVOCALLY ACCEPT, AND AGREE TO BE BOUND BY, ALL OF THE\nTERMS AND CONDITIONS OF THIS AGREEMENT. IF YOU DO NOT AGREE, DO NOT\nDOWNLOAD, INSTALL, COPY, DISTRIBUTE OR USE THE DATA FILES OR SOFTWARE.\n\nPermission is hereby granted, free of charge, to any person obtaining a\ncopy of data files and any associated documentation (the \"Data Files\") or\nsoftware and any associated documentation (the \"Software\") to deal in the\nData Files or Software without restriction, including without limitation\nthe rights to use, copy, modify, merge, publish, distribute, and/or sell\ncopies of the Data Files or Software, and to permit persons to whom the\nData Files or Software are furnished to do so, provided that either (a)\nthis copyright and permission notice appear with all copies of the Data\nFiles or Software, or (b) this copyright and permission notice appear in\nassociated Documentation.\n\nTHE DATA FILES AND SOFTWARE ARE PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY\nKIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF\nMERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF\nTHIRD PARTY RIGHTS.\n\nIN NO EVENT SHALL THE COPYRIGHT HOLDER OR HOLDERS INCLUDED IN THIS NOTICE\nBE LIABLE FOR ANY CLAIM, OR ANY SPECIAL INDIRECT OR CONSEQUENTIAL DAMAGES,\nOR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS,\nWHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION,\nARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THE DATA\nFILES OR SOFTWARE.\n\nExcept as contained in this notice, the name of a copyright holder shall\nnot be used in advertising or otherwise to promote the sale, use or other\ndealings in these Data Files or Software without prior written\nauthorization of the copyright holder.\n\nSPDX-License-Identifier: Unicode-3.0\n\n—\n\nPortions of ICU4X may have been adapted from ICU4C and/or ICU4J.\nICU 1.8.1 to ICU 57.1 © 1995-2016 International Business Machines Corporation and others.\n", + "used_by": [ + { "crate": { "name": "fixed_decimal", "version": "0.7.2" } }, + { "crate": { "name": "icu_collections", "version": "2.3.0" } }, + { "crate": { "name": "icu_decimal", "version": "2.3.0" } }, + { "crate": { "name": "icu_decimal_data", "version": "2.3.0" } }, + { "crate": { "name": "icu_locale_core", "version": "2.3.0" } }, + { "crate": { "name": "icu_locale_fallback", "version": "2.3.0" } }, + { "crate": { "name": "icu_locale_fallback_data", "version": "2.3.0" } }, + { "crate": { "name": "icu_normalizer", "version": "2.3.0" } }, + { "crate": { "name": "icu_normalizer_data", "version": "2.3.0" } }, + { "crate": { "name": "icu_properties", "version": "2.3.0" } }, + { "crate": { "name": "icu_properties_data", "version": "2.3.0" } }, + { "crate": { "name": "icu_provider", "version": "2.3.1" } }, + { "crate": { "name": "litemap", "version": "0.8.3" } }, + { "crate": { "name": "potential_utf", "version": "0.1.6" } }, + { "crate": { "name": "tinystr", "version": "0.8.4" } }, + { "crate": { "name": "writeable", "version": "0.6.4" } }, + { "crate": { "name": "yoke-derive", "version": "0.8.2" } }, + { "crate": { "name": "yoke", "version": "0.8.3" } }, + { "crate": { "name": "zerofrom-derive", "version": "0.1.7" } }, + { "crate": { "name": "zerofrom", "version": "0.1.8" } }, + { "crate": { "name": "zerotrie", "version": "0.2.5" } }, + { "crate": { "name": "zerovec-derive", "version": "0.11.6" } }, + { "crate": { "name": "zerovec", "version": "0.11.8" } } + ] + }, + { + "id": "Zlib", + "name": "zlib License", + "text": "(C) 2024 Trifecta Tech Foundation \n\nThis software is provided 'as-is', without any express or implied\nwarranty. In no event will the authors be held liable for any damages\narising from the use of this software.\n\nPermission is granted to anyone to use this software for any purpose,\nincluding commercial applications, and to alter it and redistribute it\nfreely, subject to the following restrictions:\n\n1. The origin of this software must not be misrepresented; you must not\n claim that you wrote the original software. If you use this software\n in a product, an acknowledgment in the product documentation would be\n appreciated but is not required.\n\n2. Altered source versions must be plainly marked as such, and must not be\n misrepresented as being the original software.\n\n3. This notice may not be removed or altered from any source distribution.\n", + "used_by": [ + { "crate": { "name": "zlib-rs", "version": "0.6.7" } } + ] + }, + { + "id": "Zlib", + "name": "zlib License", + "text": "Copyright (c) 2024 Orson Peters\n\nThis software is provided 'as-is', without any express or implied warranty. In\nno event will the authors be held liable for any damages arising from the use of\nthis software.\n\nPermission is granted to anyone to use this software for any purpose, including\ncommercial applications, and to alter it and redistribute it freely, subject to\nthe following restrictions:\n\n1. The origin of this software must not be misrepresented; you must not claim\n that you wrote the original software. If you use this software in a product,\n an acknowledgment in the product documentation would be appreciated but is\n not required.\n\n2. Altered source versions must be plainly marked as such, and must not be\n misrepresented as being the original software.\n\n3. This notice may not be removed or altered from any source distribution.", + "used_by": [ + { "crate": { "name": "foldhash", "version": "0.2.0" } } + ] + } + ] +} diff --git a/linux/data/meson.build b/linux/data/meson.build index 0d776c6b54..4bdc80dec8 100644 --- a/linux/data/meson.build +++ b/linux/data/meson.build @@ -68,6 +68,8 @@ install_data( install_data( meson.project_source_root() / 'LICENSE', + 'licenses' / 'THIRD-PARTY-NOTICE.md', + 'licenses' / 'third-party.json', install_dir: get_option('datadir') / 'licenses' / application_id, ) diff --git a/linux/data/resources/gtk/menus.ui b/linux/data/resources/gtk/menus.ui new file mode 100644 index 0000000000..ab0fccb5e9 --- /dev/null +++ b/linux/data/resources/gtk/menus.ui @@ -0,0 +1,66 @@ + + + + +
+ + New Window + app.new-window + +
+
+ + + Query History + win.show-history + action-missing + + + Saved Queries + win.show-saved-queries + action-missing + + + + Disconnect + win.disconnect + action-disabled + +
+
+ + Import Connections… + win.import-connections + + + Export Connections… + win.export-connections + +
+
+ + Preferences + app.preferences + + + Keyboard Shortcuts + app.shortcuts + + + About TablePro + app.about + +
+
+ +
+ + Reopen Closed Tab + win.reopen-closed-tab + +
+
+
diff --git a/linux/data/resources/icons/scalable/actions/document-modified-symbolic.svg b/linux/data/resources/icons/scalable/actions/document-modified-symbolic.svg new file mode 100644 index 0000000000..2ae9668f7b --- /dev/null +++ b/linux/data/resources/icons/scalable/actions/document-modified-symbolic.svg @@ -0,0 +1,4 @@ + + + + diff --git a/linux/data/resources/resources.gresource.xml b/linux/data/resources/resources.gresource.xml index 43f8172b18..d725974352 100644 --- a/linux/data/resources/resources.gresource.xml +++ b/linux/data/resources/resources.gresource.xml @@ -2,11 +2,21 @@ style.css + + metainfo.xml + + ../licenses/third-party.json shortcuts-dialog.ui + + gtk/menus.ui ui/browse-tab.ui ui/connect-dialog.ui ui/connect-failed-page.ui ui/connecting-page.ui + ui/export-operations-button.ui + ui/find-bar.ui + ui/history-dialog.ui + ui/history-row.ui ui/picker-page.ui ui/preferences-dialog.ui ui/results-pane.ui @@ -16,8 +26,10 @@ ui/ssh-section.ui ui/structure-tab.ui ui/tls-section.ui + ui/welcome-view.ui ui/workspace.ui icons/scalable/actions/table-relation-symbolic.svg icons/scalable/actions/statement-pending-symbolic.svg + icons/scalable/actions/document-modified-symbolic.svg diff --git a/linux/data/resources/shortcuts-dialog.ui b/linux/data/resources/shortcuts-dialog.ui index 4ebcc751ee..de2b28b7bf 100644 --- a/linux/data/resources/shortcuts-dialog.ui +++ b/linux/data/resources/shortcuts-dialog.ui @@ -2,66 +2,51 @@ + - + General - - Open SQL editor - <Primary>e - - - - - Refresh table - F5 - - - - + New window app.new-window - - Open Preferences + + Preferences app.preferences - - Open Query History - <Primary>h + + Query history + win.show-history - - Save pending changes - <Primary>s + + Show or hide the sidebar + win.show-sidebar - - Undo pending change - <Primary>z + + Keyboard shortcuts + app.shortcuts - - Redo pending change - <Primary>y - - - - - Show keyboard shortcuts - <Primary>question + + Close a dialog + Escape - + Quit app.quit @@ -69,260 +54,296 @@ - - Data grid + + Tabs - - Edit cell, or toggle a check box - F2 Return + + New SQL editor tab + win.new-editor-tab - - Toggle a check box - space + + Close the tab, or the window when no tab is open + win.close-current - - Finish editing - Return + + Reopen the last closed tab + win.reopen-closed-tab - - Cancel editing - Escape + + Next tab + <Primary>Tab <Primary>Page_Down - - Finish editing and edit the next cell - Tab + + Previous tab + <Primary><Shift>Tab <Primary>Page_Up - - Finish editing and edit the previous cell - <Shift>Tab + + Move the tab right + <Primary><Shift>Page_Down - - Move between cells - Up Down Left Right + + Move the tab left + <Primary><Shift>Page_Up + + + + + Browse Table - - Show cell menu - Menu <Shift>F10 + + Filter rows + <Primary>f - - Set cell to NULL - <Primary>BackSpace + + Read the table again + F5 - - Mark rows for deletion - Delete + + Save pending changes + <Primary>s - - Insert row - <Primary>n + + Undo the last change + <Primary>z - - - - - Browse table - - Filter rows - <Primary>f + + Redo the last change + <Primary><Shift>z <Primary>y + + + + + Previous page + <Alt>Page_Up - + + Next page + <Alt>Page_Down + + + + Select all rows <Primary>a - - Extend row selection to the clicked row + + Clear the selection + <Primary><Shift>a + + + + + First row of the page + <Primary>Home + + + + + Last row of the page + <Primary>End + + + + + Extend the selection to the clicked row <Shift>Pointer_Button1 - - Toggle the clicked row in the selection + + Add or remove the clicked row <Primary>Pointer_Button1 + + + + + Data Grid - - Clear the multi-row selection - Escape + + Move between cells + Up Down Left Right - - Copy selected rows as TSV - <Primary>c + + Edit cell, or toggle a check box + F2 Return - - Previous page - Page_Up + + Toggle a check box + space - - Next page - Page_Down + + Show the cell menu + Menu <Shift>F10 - - Jump to the first row of the page - <Primary>Home + + Set cell to NULL + <Primary>BackSpace - - Jump to the last row of the page - <Primary>End + + Finish editing + Return - - Save pending edits - <Primary>s + + Cancel editing + Escape - - Undo the last change - <Primary>z + + Finish editing and edit the next cell + Tab - - Redo the last change - <Primary><Shift>z + + Finish editing and edit the previous cell + <Shift>Tab - - - - - SQL editor - - Run query + + Save a multi-line value <Primary>Return - - Run the statement at the cursor - <Primary><Shift>Return + + Insert row + <Primary>n - - Cancel the running query - Escape + + Mark rows for deletion + Delete - - Toggle line comment - <Primary>slash + + Copy selected rows as TSV + <Primary>c - - New editor tab - <Primary>t + + Clear the multi-row selection + Escape + + + + + SQL Editor - - Close the current tab or window - <Primary>w + + Run the query, or the selected text + <Primary>Return - - Next editor tab - <Primary>Tab + + Run the statement at the cursor + <Primary><Shift>Return - - Previous editor tab - <Primary><Shift>Tab + + Cancel the running query + Escape - - Reopen the last closed tab - <Primary><Shift>t + + Format SQL + <Primary><Shift>f - - Format SQL - <Primary><Shift>f + + Toggle line comment + <Primary>slash - - Save the query under a name - <Primary><Shift>s + + Find + <Primary>f - - - - - Table structure - - Save pending DDL - <Primary>s + + Find and replace + <Primary>h - - Undo the DDL change - <Primary>z + + Next match + <Primary>g - - Redo the DDL change - <Primary><Shift>z + + Previous match + <Primary><Shift>g + + + + + Save the query under a name + win.save-query - - Dialogs + + Table Structure - - Close the dialog - Escape + + Save pending changes + <Primary>s diff --git a/linux/data/resources/ui/browse-tab.ui b/linux/data/resources/ui/browse-tab.ui index afd7c3c4de..cea219143f 100644 --- a/linux/data/resources/ui/browse-tab.ui +++ b/linux/data/resources/ui/browse-tab.ui @@ -9,6 +9,12 @@ would draw a separator above the grid even with every banner collapsed. --> flat + + + Couldn't refresh rows. Showing the last loaded page. + false + + Read-only connection. Editing disabled. @@ -56,7 +62,17 @@ dialog-error-symbolic - Couldn't load rows + Couldn't Load Rows + + + Try Again + center + + + @@ -68,8 +84,7 @@ list-add-symbolic - Insert row (Ctrl+N) - false + browse.insert-row @@ -78,8 +93,7 @@ list-remove-symbolic - tab.delete-rows - Delete selected rows (Delete) + browse.delete-rows @@ -95,28 +109,24 @@ go-first-symbolic - First page false go-previous-symbolic - Previous page (Page Up) false go-next-symbolic - Next page (Page Down) false go-last-symbolic - Last page false @@ -130,6 +140,31 @@ + + + + false + + + + 6 + + + false + + + + + Count Rows + + + + + + false @@ -144,7 +179,6 @@ document-save-symbolic - Export results @@ -155,8 +189,8 @@ filtering rows, and the search icons mean Ctrl+F's other use. --> - Filter rows (Ctrl+F) - win.open-filter + Filter rows + browse.filter @@ -221,7 +255,7 @@ Save - Save pending edits (Ctrl+S) + Save pending edits diff --git a/linux/data/resources/ui/connect-failed-page.ui b/linux/data/resources/ui/connect-failed-page.ui index 8a060c9fb6..e6e67482f4 100644 --- a/linux/data/resources/ui/connect-failed-page.ui +++ b/linux/data/resources/ui/connect-failed-page.ui @@ -4,66 +4,70 @@