The app crate's application, windows, workspace and tabs are gtk-rs subclasses (decisions/0012-window-per-session.md). A few views are still Relm4 components (decisions/0003-relm4-architecture.md): the saved-queries and CSV import dialogs. New views are subclasses. This page covers where state lives, the rules every view follows, and the patterns for each kind.
| State | Owner | Lives as long as |
|---|---|---|
Driver registry, connector, deadlines, settings, storage, Tasks, SessionRegistry, query history, the open-sessions record |
AppServices, built by run() and handed to every window |
the process |
| The claim on a session's key | the window's SessionPage |
the page |
| The connection | Session, shared by the window's page and its workspace |
the page, until the teardown closes it |
| Schemas, objects and columns | the session's SchemaCatalog |
the session |
| Tabs, pending edits, running saves | the Workspace: ChangeTrackerRegistry, StructureTrackerRegistry, WorkspaceHandle with its SaveLedger |
the workspace |
| Running exports | the workspace's ExportLedger |
the workspace |
| A tab's rows, filter and requests | the tab's model, in its imp struct |
the tab |
| An editor tab's own connection | the tab's EditorSessionSlot |
the tab, or until that connection is lost or the window's session is replaced |
| What the last run returned | the editor tab's RunLog, shown by its ResultsPane |
the next run |
| The history list, its search and the pages read | the HistoryDialog and its HistoryPaging |
the dialog |
Windows share nothing but AppServices. CI fails on a thread_local! under crates/app/src/services, since a registry shared that way would show one window's unsaved edits in every other window.
Background work goes through Tasks. Use crate::runtime::spawn_and_deliver to run work on the app runtime and get its result on the GTK thread, and spawn_held for work that has to finish even if its window goes, such as saving a named query or importing a connection list. A table or structure save goes through WorkspaceHandle::save, whose SaveLedger holds the process. crates/app/clippy.toml bans tokio::spawn, Handle::current, relm4::spawn and their siblings, so nothing reaches for whichever runtime happens to be ambient.
Input goes through DeliverInput::deliver. It works on an input queue's sender, a relm4::Sender, a ComponentSender and a FactorySender. A message to a view that has shut down is dropped and logged at debug under the component target, so a read or a save that comes back after its window closed changes nothing. ComponentSender::input and FactorySender::input are banned: relm4 0.11 calls expect on a closed channel, which would take every window down. Use try_deliver when the caller must act on a dropped message, as QuitSequence does when a window is already gone.
A watch loop is a WatchForwarder. The session publishes its health (Session::health), its link generation (link_changes) and each scope's objects (subscribe_objects) on tokio::sync::watch channels. A view keeps a WatchForwarder in a field: it delivers the current value, then every change, on the GTK thread, and stops when the field drops. A value published after the view went away is never delivered.
A view holds the session, never a connection. Every read and write goes through the session (Session::run, run_cancellable, the catalog reads), which leases the current link through its OperationRunner and carries the deadline to the driver. A view asks the session for the dialect (current_dialect) and asks again when the link changes, since a reconnect can reach another server version. A save plans with the dialect of the connection that runs it.
One request of each kind is current. A browse or structure tab keeps a RequestSequencer keyed by request kind. issue returns a RequestTicket with a stamp and a cancel token, and stops the request of that kind still running, so the server drops the statement. accept takes only the reply to the current request; a reply to a replaced one is dropped. Dropping the sequencer stops every request, which is how closing a tab stops its reads.
A failed read is asked again on the next link. FailedRequests holds the kinds whose last accepted reply failed. When link_changes reports a newer LinkGeneration, the tab asks those again on the new connection; a reply that failed after the link had already moved is asked again at once. A tab whose reads all landed keeps its rows. A browse tab's row count is not asked again: it can run for its whole deadline, so it runs only when the estimate or the user asks.
Content stays through a failed read. A browse or structure tab tracks its read with ContentLoad. The spinner shows only until the first read lands; a read of the content on screen keeps it there and spins the tab's page in the tab bar, and a failure then leaves it under a StaleBanner with Retry. The error page, with Try Again, is for a tab with nothing to show, or whose failed read asked for other rows than the ones on screen.
The catalog belongs to the session. SchemaCatalog (crates/session/src/session/schema_catalog.rs) holds the schemas, each scope's objects and each table's columns. Views read through Session::schemas, objects and columns, or refresh_objects and refresh_columns to fetch again. Callers that ask for the same thing at once share one fetch, a fetch runs as its own task so a view that closes does not stop it, and a failed refresh keeps the list it had. A schema change calls invalidate_table, which marks the table stale and keeps its data until the next fetch lands. The sidebar's Refresh, and an editor run that sent DDL or a commit (catalog_change::changes_catalog), cannot name the tables they touched: they call invalidate_columns, which does the same for every table, and read the object lists again. A failed read leaves the sidebar's list under a banner with Retry until a read lands.
Saves go through the workspace. WorkspaceHandle starts, stops and settles every save. Its SaveLedger records each running save with its tab, its kind, a cancel token and a process hold, and refuses a second save of the same kind for the same tab. Stop Saving, or a close that chose to stop, cancels the token, which reaches the driver through run_cancellable; the save settles when the driver answers.
A structure save is reviewed before it starts. WorkspaceHandle::save is the one route for a tab's Save and Ctrl+S and for a close that chose Save, so the review is never skipped. For a structure tab it plans the draft with the session's DdlDialect into a ChangeReview before it takes anything from the tracker or opens a SaveLedger entry:
- The review dialog asks first when the plan loses something (a dropped column, index or foreign key, a type change, a table rebuild), or when the engine applies DDL statement by statement (
EngineProfile, from the driver'sddl_writes) and the plan has more than one statement. Cancel is the default response. - After Apply the draft is planned again, and a plan that changed while the dialog was open is refused with
BuildDdlError::NotAsReviewed. The save itself runs only a plan thatChangeReview::matches, so a connection replaced in the meantime never runs statements the user did not see. - A tab has one review at a time. A second save while it is open gets
SaveRejection::InReview. - Cancel returns
SaveRejection::ReviewDeclined, which shows no alert.CloseGuardtakes it as Keep Open and starts none of the saves after it.
Closing asks through the window's prompts. CloseGuards runs one CloseGuard at a time per window, so a tab's close and the window's never ask about the same tab at once. A guard reads the workspace again after every answer and every settled save: it offers to save, waits for or stops running saves, asks about a save whose outcome is unknown, and confirms open transactions. Before any of that, a close of the whole window asks each CloseBlocker the workspace registered, such as its ExportLedger, and either keeps the window or stops that work and waits for it. A disconnect asks the same prompts before the teardown.
SessionWindow and Workspace each own an input queue from runtime::input_queue:
- Actions, signal handlers and background work send it a message, and
pump_inputshands the messages to the widget one at a time on the GTK thread, never inside the signal or action that sent one. - Shutdown aborts the pump, which closes the queue. A queued message that owns something, such as a connection that finished connecting, goes to the pump's leftover handler (
pump_inputs_with_leftovers) and is closed rather than dropped. - Window actions go on the window's own action map with
add_action_entries, which GTK exports aswin; CI fails on a group inserted aswin. The workspace removes the actions it added when it shuts down. - The workspace reports to the window through signals (
title-changed,dirty-changed,saves-in-flight-changed,work-completed,tabs-changed), and the window turns each into one of its own inputs.
A background read follows this shape, trimmed from SessionWindow::on_restore, which looks up the saved connection id names:
let (Some(state), Some(input)) = (self.state(), self.input()) else {
return;
};
let store = state.services.storage.connections().clone();
let reading = state.services.tasks.spawn_blocking_task(move || store.load_blocking());
glib::spawn_future_local(async move {
if let Ok(Ok(connections)) = reading.await {
let saved = connections.iter().find(|saved| saved.id == id).cloned().map(Box::new);
input.deliver(SessionWindowInput::RestoreSaved { id, saved });
}
});The closure holds only the sender, so a window that closed before the read ends is not kept alive, and the answer is dropped.
A browse or structure tab is a subclass whose model sits in its imp struct. send hands the model one input and handles it at once; an input a widget signal sets off while another is being handled waits in the tab's queue until that one finishes. The model's update returns effects: more inputs for the queue, and outputs the tab emits as signals. The workspace connects those signals, tags them with the tab's id and queues them as WorkspaceInput, so the tabs never call each other.
SqlEditor (crates/app/src/ui/editor/) runs SQL on a connection of its own and lists what each statement returned.
Each editor tab runs on an EditorSession, which keeps a transaction, temporary tables and session settings between runs. The tab's EditorSessionSlot holds it, shared with the run on the runtime:
- Empty until the first run, which opens one from the window's session with
Session::open_editor_session. - Open while it serves the window session it was opened from. One that is
Lost, or that belongs to a session the window has since replaced, is closed before the next run opens another. An editor session is never reopened behind the user's back: the failed run's line says the next run opens a new one, since the transaction went with the old one. - Closed once the tab goes (
EditorSessionSlot::close). A session that finishes opening after that is handed back and closed, never kept.
After a run that left its session Lost, the runner lets it go and asks the window's session to probe its own link, which may have gone too. A teardown takes every tab's session (SqlEditor::take_editor_session) and closes them together under one hold.
- Scope. Run sends the selection when there is one and the whole buffer otherwise (
run_scope::for_run). Run at Cursor selects the statement under the cursor and sends that.ScopedTextkeeps where the text starts in the buffer, in characters, so a server's error position becomes a buffer position. - Plan.
plan_batchesreads the text withScriptPlanand the grammar of the window session's dialect, or PostgreSQL's when there is none. EachRunBatchis one statement on PostgreSQL, MySQL, SQLite and ClickHouse, and oneGObatch on SQL Server, whichGO <count>repeats. psql, mysql and sqlite3 send a script the same way: no multi-statement text reaches the server, so an implicit transaction never rolls back statements that already reported success. On SQL Server the unit is theGObatch, as in sqlcmd: a batch reaches the server whole, whatever;it holds. The plan is built again at every run, never kept. - Warnings. A string, quoted name, comment or dollar quote that never closes adds a warning line naming its line. The text is still sent, so the server has the last word.
- Runs.
run_batchessends each batch as oneEditorSession::run, with the statement timeout from Preferences, the editor's row limit and a child of the run's cancel token. A run keeps at mostRUN_ROW_LIMIT(100,000) rows across all its results: once it has, a statement still runs but reads one row, which is dropped, and its result says the run's limit cut it. A run that getsEditorSessionBusy, because the last one is still settling, is shown as failed and not sent again. - Stopping. A failed statement stops the script, since the ones after it were written assuming it worked. SQL Server batches run on their own, as in sqlcmd, so a failed
GObatch goes on to the next (BatchErrorPolicy::ContinueNextBatch). A cancel, a timeout or a session error stops the script on every engine. Each batch never sent gets a Not Run line.
The editor plans with the grammar's defaults (LexicalSettings::default_for): backslash escapes on for MySQL and ClickHouse, off for the rest. It does not read the session's own settings. The PostgreSQL driver plans what each run sends again with the session's standard_conforming_strings, which it reads from the server's parameter status, and plans the rest of that text again after a statement that changes it.
Events cross to the GTK thread through a channel. BatchCollector turns one batch's ResultEvents into lines on the runtime, so only finished lines cross, and RunLog keeps the run's lines in order: a result set, a command with the rows it changed, a notice, a failure with the server's error position when it gives one, a warning, a stop, a timeout, and a Not Run line per batch never sent.
ResultsPane shows them as a list in an AdwOverlaySplitView beside the page of the selected line:
- One line needs no list. The list and its toggle appear with the second line, and the list opens by itself while the pane is wide. Below 560sp it folds over the result, and opening it is left to the toggle (
sidebar_policy). - A page is built when its line is first shown, and the 8 shown last keep theirs, so a script of thousands of statements builds a handful.
- When the run ends, the first failure or timeout is selected, or else the first line. A failure's page offers Go to Error when the server named a position.
- A result set's page is a read-only grid of the rows the run kept, under a
ResultLimitBannerwhen the row limit, or the run's, cut it short.
A finished run goes to HistoryService::record with the text that was run, DELIMITER and GO lines included, judged by the dialect of the session that ran it, not the window's current one (storage.md).
Open SQL File (Ctrl+O) and Save As (Ctrl+Shift+S) bind the tab to a .sql file, through the file chooser portal; Save (Ctrl+S) writes to it, or asks for one. The tab is named after the file, and the buffer's own modified flag says whether its text differs from the file's, so Undo back to the saved text clears it. The tab's record keeps the file's URI and that flag (EditorFileRecord), and its draft keeps the text, so a restore brings back both.
Closing the tab asks about unsaved file changes through the same Save Changes prompt as row and structure edits (DirtyKind::UnsavedFile), and its Save writes the file. Closing the window does not ask: the draft keeps the text for the next session.
Every RunSummary carries the session's TransactionState, and the last one a run reported decides whether closing the tab would lose work (transaction_dirty):
InTransactionandFailedcount, and the line above the results says so.Unknowncounts once something ran, because the engine cannot say what that left.- A lost session holds nothing any more, so it never counts.
The tab emits transaction-changed when that flips, and the workspace reads WorkspaceHandle::dirty_tabs again. Through TabDirectory::tabs it lists the tab as DirtyKind::OpenTransaction when the tab has nothing else pending, so the page shows the unsaved icon and the close prompts ask about it. A close asks once: the tabs the user agreed to lose are not asked about again in that close.
HistoryDialog reads the history a page of 100 at a time off the GTK thread, by the keyset storage.md describes.
HistoryPagingnumbers each refresh. A page that lands for an older search is dropped, a new search drops the old cursor, and one next-page read runs at a time.- A refresh keeps the rows on screen until its first page lands, so typing never blanks the list, and a refresh that fails keeps them.
- Pinning moves the row to where the history now lists it without reading again. A row that now sorts past the pages read comes back with its page.
- Delete happens at once and offers Undo (storage.md).
The components that remain follow these conventions until a rewrite turns them into subclasses:
- Input reaches them through
deliver. Their output reaches the subclass that hosts them throughconnect_receiver, whose closure delivers it to that subclass's queue. - Their commands (
sender.command,oneshot_command) run on Relm4's own runtime, not onTasks. The saved-queries dialog still loads that way; new work goes throughspawn_and_deliver. - They present their dialogs on the window they belong to.
ComponentBuilder::transient_foris banned, because Relm4 applies it only fromrelm4::RelmApp, which this app does not use.
<Thing>Input: every message a view handles, as an enum (SessionWindowInput,WorkspaceInput,BrowseTabInput).- A view with many inputs groups them by the state each one changes, one enum per group (
BrowseTabInput::Paging(PagingInput),StructureTabInput::Save(SaveInput)), and handles each group in its own*_handlersmodule. Senders pass the group's enum;sendtakes anything that converts into the view's input. <Thing>Output: only the messages a parent should react to.<Thing>Cmd: a Relm4 command's result.<Thing>Init: what the parent hands a view when it builds it (SessionWindowInit,WorkspaceInit).
AdwAlertDialogcloses itself before it emitsresponse. A response handler that callsclose()again finds the dialog gone, and libadwaita logs a critical when animations are off.- That close emits
closedbefore theresponsethe click chose. Aclosedhandler that answers at once turns every click into the close response.workspace::responddefers its close response to an idle callback, so the click's response comes first. - To wait for an answer, use
workspace::respond, which also answers when the dialog goes without a response.choose_futureis not used: its callback aborts the process in that case.
tokio::spawn,relm4::spawnorHandle::currentin app code. UseTasks.ComponentSender::inputorFactorySender::input. Usedeliver.- A hand-written loop over a watch channel. Use
WatchForwarder. - A view that keeps a
Connection, or a dialect past a link change. - A whole script handed to
EditorSession::run. Plan it and send one statement, or oneGObatch, per run. - A structure save that calls
SaveLedger::beginitself. Go throughWorkspaceHandle::save, which reviews first. - State in a
thread_local!or a static. It belongs to the workspace or toAppServices. - A
glib::clone!that captures a view strongly in a callback the view itself holds. Capture its input sender or a weak reference. - Blocking work on the GTK thread:
std::fs, a_blockingcall outsidespawn_blocking_task, orMainContext::block_on.
testing.md covers widget tests, the test windows and the application tests.