From b77db213d1d6f6ab20f483836ece12c7234ad87f Mon Sep 17 00:00:00 2001 From: sedax90 <5001801+sedax90@users.noreply.github.com> Date: Fri, 30 Oct 2026 09:37:04 +0100 Subject: [PATCH 01/53] feat(linux-editor): name the tab's database and schema in the editor bar --- linux/crates/app/src/ui/editor/imp.rs | 4 + linux/crates/app/src/ui/editor/mod.rs | 2 + linux/crates/app/src/ui/editor/tab_scope.rs | 100 ++++++++++++++++++++ linux/crates/app/src/ui/editor/tests.rs | 53 +++++++++++ linux/data/resources/ui/sql-editor.ui | 25 +++++ linux/po/POTFILES.in | 1 + 6 files changed, 185 insertions(+) create mode 100644 linux/crates/app/src/ui/editor/tab_scope.rs diff --git a/linux/crates/app/src/ui/editor/imp.rs b/linux/crates/app/src/ui/editor/imp.rs index bc9ccfddf1..c89247faa3 100644 --- a/linux/crates/app/src/ui/editor/imp.rs +++ b/linux/crates/app/src/ui/editor/imp.rs @@ -29,6 +29,10 @@ pub struct RunningScript { #[derive(Default, gtk4::CompositeTemplate)] #[template(resource = "/app/tablepro/TablePro/ui/sql-editor.ui")] pub struct SqlEditor { + #[template_child] + pub scope_box: TemplateChild, + #[template_child] + pub scope_label: TemplateChild, #[template_child] pub cursor_info: TemplateChild, #[template_child] diff --git a/linux/crates/app/src/ui/editor/mod.rs b/linux/crates/app/src/ui/editor/mod.rs index 25bfae0b0b..15c3a498fc 100644 --- a/linux/crates/app/src/ui/editor/mod.rs +++ b/linux/crates/app/src/ui/editor/mod.rs @@ -26,6 +26,7 @@ mod sidebar_policy; mod significant_tokens; mod sql_file; mod statement_cursor; +mod tab_scope; #[cfg(test)] mod tests; mod transaction_dirty; @@ -152,6 +153,7 @@ impl SqlEditor { editor.install_drop_target(); editor.connect_buttons(); editor.connect_grid(); + editor.show_scope(); editor } diff --git a/linux/crates/app/src/ui/editor/tab_scope.rs b/linux/crates/app/src/ui/editor/tab_scope.rs new file mode 100644 index 0000000000..752b9c22d3 --- /dev/null +++ b/linux/crates/app/src/ui/editor/tab_scope.rs @@ -0,0 +1,100 @@ +//! The database the tab works on, named in the editor bar as the macOS +//! editor names it (`QueryContainerPicker`): the database, then the schema +//! an unqualified name resolves to where the engine nests schemas in a +//! database. A file's name is already in the window title and the sidebar. + +use gtk4::glib; +use gtk4::prelude::*; +use gtk4::subclass::prelude::*; + +use super::SqlEditor; +use crate::i18n::gettext_f; + +/// The bar's text, dot-qualified as SQL and the sidebar spell a qualified +/// object, and what a screen reader says for it. `None` with no database. +pub(super) fn scope_text(database: Option<&str>, default_schema: Option<&str>) -> Option<(String, String)> { + let database = database.filter(|database| !database.is_empty())?; + // Where the engine's schemas are its databases, as on MySQL, the + // default one is the database itself and adds nothing. + match default_schema.filter(|schema| !schema.is_empty() && *schema != database) { + Some(schema) => Some(( + format!("{database}.{schema}"), + gettext_f( + "{database}, schema {schema}", + &[("database", database), ("schema", schema)], + ), + )), + None => Some((database.to_owned(), database.to_owned())), + } +} + +impl SqlEditor { + /// Name the tab's database now, and its schema once the catalog has + /// listed the schemas. + pub(super) fn show_scope(&self) { + let Some((session, tasks)) = self + .deps() + .and_then(|deps| deps.session.get().map(|session| (session, deps.tasks.clone()))) + else { + self.set_scope(None); + return; + }; + let database = session.database().map(str::to_owned); + self.set_scope(scope_text(database.as_deref(), None)); + if database.is_none() { + return; + } + // The catalog reads on the runtime, as completion's reads do. + let reading = tasks.spawn_task(async move { + session.schemas().await.ok().and_then(|schemas| { + schemas + .iter() + .find(|schema| schema.is_default) + .map(|schema| schema.name.clone()) + }) + }); + let editor = self.downgrade(); + glib::spawn_future_local(async move { + let default = reading.await.ok().flatten(); + if let Some(editor) = editor.upgrade() { + editor.set_scope(scope_text(database.as_deref(), default.as_deref())); + } + }); + } + + fn set_scope(&self, scope: Option<(String, String)>) { + let imp = self.imp(); + let shown = scope.is_some(); + if let Some((text, spoken)) = scope { + imp.scope_label.set_label(&text); + imp.scope_box + .update_property(&[gtk4::accessible::Property::Label(&spoken)]); + imp.scope_box.set_tooltip_text(Some(&spoken)); + } + imp.scope_box.set_visible(shown); + } +} + +#[cfg(test)] +mod tests { + use super::scope_text; + + #[test] + fn the_scope_names_the_database_then_a_schema_of_its_own() { + assert_eq!( + scope_text(Some("shop"), Some("public")), + Some(("shop.public".to_owned(), "shop, schema public".to_owned())) + ); + assert_eq!( + scope_text(Some("shop"), Some("shop")), + Some(("shop".to_owned(), "shop".to_owned())), + "a schema that is the database" + ); + assert_eq!( + scope_text(Some("shop"), None), + Some(("shop".to_owned(), "shop".to_owned())) + ); + assert_eq!(scope_text(None, Some("main")), None, "a file"); + assert_eq!(scope_text(Some(""), None), None); + } +} diff --git a/linux/crates/app/src/ui/editor/tests.rs b/linux/crates/app/src/ui/editor/tests.rs index 6e4ffb6e2e..ff881962d7 100644 --- a/linux/crates/app/src/ui/editor/tests.rs +++ b/linux/crates/app/src/ui/editor/tests.rs @@ -2618,3 +2618,56 @@ fn a_command_editor_has_none_of_the_sql_tools() { "the document's words and the engine's own snippets complete" ); } + +/// The bar names the tab's database and its default schema, as the +/// macOS editor bar does. +#[gtk4::test] +fn the_bar_names_the_tabs_database_and_schema() { + crate::register_test_resources(); + let runtime = crate::test_support::test_runtime(); + let fake = Arc::new(FakeEditorSession::new(answering())); + let mut request = FakeConnector::network_request("db.example", 5432).expect("a request"); + if let tablepro_session::connect::RequestTarget::Network { database, .. } = &mut request.target { + *database = Some("shop".to_owned()); + } + let mut script = ConnectionScript::default(); + let editor_session: Arc = fake; + script.open_editor_session.always(FakeReply::ok(editor_session)); + let (pending, _) = FakeConnector::pending_session(request, script); + let session = Session::start( + pending, + tablepro_session::SessionDeps { + connector: Arc::new(FakeConnector::new([])), + credentials: Arc::new(FakeCredentialSource::new()), + deadlines: Arc::new(DefaultDeadlines::new(tablepro_core::LivenessPolicy::DESKTOP)), + tasks: runtime.tasks(), + prompter: None, + }, + ); + let schema = |name: &str, is_default: bool| tablepro_core::meta::SchemaInfo { + name: name.to_owned(), + is_default, + is_system: false, + }; + session + .catalog() + .commit(vec![schema("public", true), schema("audit", false)], Vec::new()); + let active = ActiveSession::new(); + active.replace(session); + let settings = MemorySettings::new(); + let editor = SqlEditor::new(SqlEditorInit { + initial_query: None, + settings: settings.get().clone(), + history: None, + tasks: runtime.tasks(), + session: active, + hold: Rc::new(tablepro_session::runtime::CountingHold::new()), + close_timeout: Duration::from_secs(5), + snippets: crate::test_support::builtin_snippets_only(), + }); + + let label = editor.imp().scope_label.get(); + crate::test_support::wait_until(Duration::from_secs(5), || label.label() == "shop.public") + .unwrap_or_else(|_| panic!("the bar says {:?}", label.label())); + assert!(editor.imp().scope_box.is_visible()); +} diff --git a/linux/data/resources/ui/sql-editor.ui b/linux/data/resources/ui/sql-editor.ui index 17eba7d890..b343a5e633 100644 --- a/linux/data/resources/ui/sql-editor.ui +++ b/linux/data/resources/ui/sql-editor.ui @@ -92,6 +92,31 @@ 8 8 8 + + + + false + 6 + 4 + label + + + + drive-harddisk-symbolic + presentation + + + + + middle + presentation + + + + true diff --git a/linux/po/POTFILES.in b/linux/po/POTFILES.in index ba56cfe121..9b169697bc 100644 --- a/linux/po/POTFILES.in +++ b/linux/po/POTFILES.in @@ -352,6 +352,7 @@ crates/app/src/ui/editor/sidebar_policy.rs crates/app/src/ui/editor/significant_tokens.rs crates/app/src/ui/editor/sql_file.rs crates/app/src/ui/editor/statement_cursor.rs +crates/app/src/ui/editor/tab_scope.rs crates/app/src/ui/editor/tests.rs crates/app/src/ui/editor/transaction_dirty.rs crates/app/src/ui/editor/vim_mode.rs From f5c2d42c06e53564504bdf5c6affdc673e5eaf4c Mon Sep 17 00:00:00 2001 From: sedax90 <5001801+sedax90@users.noreply.github.com> Date: Fri, 30 Oct 2026 09:37:14 +0100 Subject: [PATCH 02/53] docs(linux-editor): add the changelog entry --- linux/changelog.d/3896.added.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 linux/changelog.d/3896.added.md diff --git a/linux/changelog.d/3896.added.md b/linux/changelog.d/3896.added.md new file mode 100644 index 0000000000..207d0fefd8 --- /dev/null +++ b/linux/changelog.d/3896.added.md @@ -0,0 +1 @@ +The SQL editor's bar names the database and schema the tab works on. From 48a216de82312dae297d063769ae990a371cde6c Mon Sep 17 00:00:00 2001 From: sedax90 <5001801+sedax90@users.noreply.github.com> Date: Fri, 30 Oct 2026 09:54:22 +0100 Subject: [PATCH 03/53] feat(linux-editor): switch a query tab to another MySQL or MariaDB database from the editor bar --- .../app/src/services/connection_deletion.rs | 6 +- .../app/src/ui/assistant/schema_reader.rs | 1 + .../src/ui/editor/completion/catalog_read.rs | 11 +- .../completion/sql_completion_provider.rs | 9 +- .../completion/sql_completion_provider_imp.rs | 2 + .../app/src/ui/editor/editor_session_slot.rs | 98 ++++++- linux/crates/app/src/ui/editor/imp.rs | 6 + linux/crates/app/src/ui/editor/tab_scope.rs | 257 ++++++++++++++++-- linux/crates/app/src/ui/editor/tests.rs | 72 +++++ .../app/src/ui/error_text/unsupported.rs | 2 + .../app/src/ui/workspace/tab_factory.rs | 9 +- linux/crates/app/src/ui/workspace/tab_spec.rs | 2 + .../app/src/ui/workspace/tests/assistant.rs | 1 + .../app/src/ui/workspace/tests/close_tab.rs | 2 + .../app/src/ui/workspace/tests/tab_title.rs | 2 + .../src/ui/workspace/workspace_persistence.rs | 2 + .../app/src/ui/workspace/workspace_tabs.rs | 5 +- linux/crates/app/src/workspace/tab_record.rs | 6 +- .../app/src/workspace/workspace_record.rs | 4 + .../src/workspace/workspace_record_builder.rs | 1 + .../app/src/workspace/workspace_store.rs | 2 + linux/crates/core/src/connection.rs | 9 + linux/crates/core/src/unsupported_feature.rs | 3 + linux/crates/drivers/mysql/src/connection.rs | 8 + linux/crates/drivers/mysql/src/metadata.rs | 35 +++ .../mysql/tests/integration/metadata.rs | 36 ++- .../crates/test-support/src/call_counters.rs | 1 + .../test-support/src/connection_script.rs | 2 + .../test-support/src/fake_connection.rs | 13 + linux/data/resources/ui/sql-editor.ui | 60 +++- 30 files changed, 616 insertions(+), 51 deletions(-) diff --git a/linux/crates/app/src/services/connection_deletion.rs b/linux/crates/app/src/services/connection_deletion.rs index 8eaa79c90f..8344ae13eb 100644 --- a/linux/crates/app/src/services/connection_deletion.rs +++ b/linux/crates/app/src/services/connection_deletion.rs @@ -248,7 +248,11 @@ mod tests { &key, WorkspaceRecord { active_tab: Some(0), - tabs: vec![TabRecord::Editor { draft, file: None }], + tabs: vec![TabRecord::Editor { + draft, + file: None, + database: None, + }], }, ) .expect("scheduled"); diff --git a/linux/crates/app/src/ui/assistant/schema_reader.rs b/linux/crates/app/src/ui/assistant/schema_reader.rs index c694bc6dfd..3096018fc5 100644 --- a/linux/crates/app/src/ui/assistant/schema_reader.rs +++ b/linux/crates/app/src/ui/assistant/schema_reader.rs @@ -39,6 +39,7 @@ pub(crate) async fn read(session: Arc, engine: String, text: String) -> CompletionScope::General { tables_in_statement: named, }, + None, ) .await; let schema = session.schemas().await.ok().and_then(|schemas| { diff --git a/linux/crates/app/src/ui/editor/completion/catalog_read.rs b/linux/crates/app/src/ui/editor/completion/catalog_read.rs index 1c2eede515..2153a4adea 100644 --- a/linux/crates/app/src/ui/editor/completion/catalog_read.rs +++ b/linux/crates/app/src/ui/editor/completion/catalog_read.rs @@ -17,8 +17,15 @@ pub struct CatalogAnswer { /// Read what `scope` offers. The catalog shares each read with every /// other view and keeps what it read, so typing does not reach the /// server again; a list that cannot be read is left out. -pub async fn read(session: Arc, scope: CompletionScope) -> CatalogAnswer { - let defaults = listed(session.objects(ObjectScope::DefaultSchema).await); +/// +/// `home` is the database the tab was moved to from the editor bar, where +/// an unqualified name now resolves; `None` keeps the connection's own. +pub async fn read(session: Arc, scope: CompletionScope, home: Option) -> CatalogAnswer { + let home = match home { + Some(database) => ObjectScope::Schema(database), + None => ObjectScope::DefaultSchema, + }; + let defaults = listed(session.objects(home).await); match scope { CompletionScope::Objects { schema: None } => CatalogAnswer { schemas: Some(listed(session.schemas().await)), diff --git a/linux/crates/app/src/ui/editor/completion/sql_completion_provider.rs b/linux/crates/app/src/ui/editor/completion/sql_completion_provider.rs index 9daab4c91b..0d17dd37bc 100644 --- a/linux/crates/app/src/ui/editor/completion/sql_completion_provider.rs +++ b/linux/crates/app/src/ui/editor/completion/sql_completion_provider.rs @@ -44,6 +44,12 @@ pub struct ProviderDeps { } impl SqlCompletionProvider { + /// Resolve unqualified names in `database`, the tab's own when the + /// editor bar moved it off the connection's. + pub fn set_home(&self, database: Option) { + self.imp().home.replace(database); + } + pub fn new(session: ActiveSession, tasks: Tasks, snippets: Rc) -> Self { let provider: Self = glib::Object::new(); let _ = provider.imp().deps.set(ProviderDeps { @@ -67,11 +73,12 @@ impl SqlCompletionProvider { let ranking = match (request.session.clone(), tasks.clone()) { (Some(session), Some(tasks)) if request.grammar.is_sql() => { let fallback = request.clone(); + let home = self.imp().home.borrow().clone(); let ranked_for = needle.clone(); let blocking = tasks.clone(); tasks .spawn_task(async move { - let answer = catalog_read::read(session, request.scope.clone()).await; + let answer = catalog_read::read(session, request.scope.clone(), home).await; blocking .spawn_blocking_task(move || Ranking::new(request.candidates(&answer).into(), &ranked_for)) .await diff --git a/linux/crates/app/src/ui/editor/completion/sql_completion_provider_imp.rs b/linux/crates/app/src/ui/editor/completion/sql_completion_provider_imp.rs index 9ceb7307b7..ea5265cdcd 100644 --- a/linux/crates/app/src/ui/editor/completion/sql_completion_provider_imp.rs +++ b/linux/crates/app/src/ui/editor/completion/sql_completion_provider_imp.rs @@ -19,6 +19,8 @@ const PRIORITY: i32 = 100; #[derive(Default)] pub struct SqlCompletionProvider { pub deps: OnceCell, + /// The database the tab was moved to, where unqualified names resolve. + pub home: std::cell::RefCell>, } #[glib::object_subclass] diff --git a/linux/crates/app/src/ui/editor/editor_session_slot.rs b/linux/crates/app/src/ui/editor/editor_session_slot.rs index 5e18a149e8..361edfb694 100644 --- a/linux/crates/app/src/ui/editor/editor_session_slot.rs +++ b/linux/crates/app/src/ui/editor/editor_session_slot.rs @@ -1,7 +1,11 @@ use std::sync::{Arc, Mutex, MutexGuard, PoisonError, Weak}; use std::time::Duration; -use tablepro_core::{EditorSession, EditorSessionOptions, EditorSessionState, TransactionState}; +use async_trait::async_trait; +use tablepro_core::{ + EditorSession, EditorSessionOptions, EditorSessionState, ResultEvent, ResultSink, RunOptions, SinkFlow, + TransactionState, UserSql, +}; use tablepro_session::{OperationError, Session}; use tokio_util::sync::CancellationToken; @@ -28,6 +32,15 @@ struct SlotState { /// told yet. rolled_back: bool, closed: bool, + /// The database the tab was moved to from the editor bar. Every + /// session the tab opens or holds is moved there before anything else. + database: Option, +} + +/// A database a tab was moved to. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TabDatabase { + pub name: String, } struct OpenEditor { @@ -37,6 +50,8 @@ struct OpenEditor { editor: Arc, /// Where the transaction stood after the last run that reported it. transaction: Option, + /// The tab's database this session was moved to, if any. + moved_to: Option, } enum Lookup { @@ -80,7 +95,7 @@ impl EditorSessionSlot { cancel: &CancellationToken, ) -> Result, AcquireError> { let stale = match self.lookup(session) { - Lookup::Ready(editor) => return Ok(editor), + Lookup::Ready(editor) => return self.move_to_database(session, editor, cancel).await, Lookup::Closed => return Err(AcquireError::Cancelled), Lookup::RolledBack { stale } => { if let Some(stale) = stale { @@ -96,7 +111,7 @@ impl EditorSessionSlot { let options = EditorSessionOptions { statement_timeout }; match session.open_editor_session(options, cancel).await { Ok(editor) => match self.adopt(session, editor.clone()) { - Ok(()) => Ok(editor), + Ok(()) => self.move_to_database(session, editor, cancel).await, Err(orphan) => { close_quietly(orphan, close_timeout).await; Err(AcquireError::Cancelled) @@ -110,6 +125,72 @@ impl EditorSessionSlot { } } + /// Move the tab's sessions to `database` from the next run on. + pub fn set_database(&self, database: Option) { + lock(&self.state).database = database; + } + + /// The tab's database, when it was moved off the connection's own. + pub fn database(&self) -> Option { + lock(&self.state).database.clone() + } + + /// Run the engine's `USE` for the tab's database on `editor` unless it + /// ran there already. Built now, when the session's dialect is known: + /// a tab restored before the connection came up named only the name. + async fn move_to_database( + &self, + session: &Arc, + editor: Arc, + cancel: &CancellationToken, + ) -> Result, AcquireError> { + let wanted = { + let state = lock(&self.state); + let moved = state + .open + .as_ref() + .filter(|open| Arc::ptr_eq(&open.editor, &editor)) + .and_then(|open| open.moved_to.clone()); + state + .database + .clone() + .filter(|database| moved.as_ref() != Some(&database.name)) + }; + let Some(database) = wanted else { + return Ok(editor); + }; + let Some(dialect) = session.current_dialect() else { + return Err(AcquireError::Failed(crate::i18n::gettext_f( + "Could not switch this tab to the database “{database}”", + &[("database", &database.name)], + ))); + }; + let statement = format!("USE {}", dialect.quote_identifier(&database.name)); + let ran = editor + .run( + UserSql::new(statement.as_str()), + RunOptions::new(cancel.clone()), + Box::new(Discard), + ) + .await; + match ran { + Ok(summary) if summary.succeeded() => { + if let Some(open) = lock(&self.state) + .open + .as_mut() + .filter(|open| Arc::ptr_eq(&open.editor, &editor)) + { + open.moved_to = Some(database.name); + } + Ok(editor) + } + _ => Err(AcquireError::Failed(crate::i18n::gettext_f( + "Could not switch this tab to the database “{database}”", + &[("database", &database.name)], + ))), + } + } + fn lookup(&self, owner: &Arc) -> Lookup { let mut state = lock(&self.state); if state.closed { @@ -168,6 +249,7 @@ impl EditorSessionSlot { owner: Arc::downgrade(owner), editor, transaction: None, + moved_to: None, }); Ok(()) } @@ -229,6 +311,16 @@ fn lock(mutex: &Mutex) -> MutexGuard<'_, T> { mutex.lock().unwrap_or_else(PoisonError::into_inner) } +/// Takes what a `USE` sends back, which is nothing the tab shows. +struct Discard; + +#[async_trait] +impl ResultSink for Discard { + async fn accept(&mut self, _event: ResultEvent) -> SinkFlow { + SinkFlow::Continue + } +} + #[cfg(test)] pub(super) mod tests { use tablepro_test_support::{FakeEditorSession, FakeRun, Replies}; diff --git a/linux/crates/app/src/ui/editor/imp.rs b/linux/crates/app/src/ui/editor/imp.rs index c89247faa3..19a4382fc7 100644 --- a/linux/crates/app/src/ui/editor/imp.rs +++ b/linux/crates/app/src/ui/editor/imp.rs @@ -34,6 +34,10 @@ pub struct SqlEditor { #[template_child] pub scope_label: TemplateChild, #[template_child] + pub scope_button: TemplateChild, + #[template_child] + pub scope_button_label: TemplateChild, + #[template_child] pub cursor_info: TemplateChild, #[template_child] pub menu_button: TemplateChild, @@ -60,6 +64,8 @@ pub struct SqlEditor { /// This tab's own connection, which keeps its transaction and /// temporary tables between runs. pub slot: Arc, + /// The tab's own actions: the database menu's. + pub tab_actions: RefCell>, pub running: RefCell>, pub log: RefCell>, /// Whether the last run left a transaction open, as last reported. diff --git a/linux/crates/app/src/ui/editor/tab_scope.rs b/linux/crates/app/src/ui/editor/tab_scope.rs index 752b9c22d3..9993e8ef33 100644 --- a/linux/crates/app/src/ui/editor/tab_scope.rs +++ b/linux/crates/app/src/ui/editor/tab_scope.rs @@ -1,14 +1,27 @@ //! The database the tab works on, named in the editor bar as the macOS //! editor names it (`QueryContainerPicker`): the database, then the schema //! an unqualified name resolves to where the engine nests schemas in a -//! database. A file's name is already in the window title and the sidebar. +//! database. Where the server has other databases and the connection may +//! write, the name is a menu of them, the user's first and the engine's own +//! after, and picking one moves the tab there. A file's name is already in +//! the window title and the sidebar. + +use std::sync::Arc; -use gtk4::glib; use gtk4::prelude::*; use gtk4::subclass::prelude::*; +use gtk4::{gio, glib}; +use tablepro_core::meta::SchemaInfo; +use tablepro_session::Session; +use tablepro_session::operation::OperationKind; +use tablepro_session::runtime::Tasks; use super::SqlEditor; -use crate::i18n::gettext_f; +use super::editor_session_slot::TabDatabase; +use crate::i18n::{gettext, gettext_f}; + +/// The action a database menu item names, with the database as target. +const SWITCH_ACTION: &str = "database"; /// The bar's text, dot-qualified as SQL and the sidebar spell a qualified /// object, and what a screen reader says for it. `None` with no database. @@ -28,10 +41,33 @@ pub(super) fn scope_text(database: Option<&str>, default_schema: Option<&str>) - } } +/// The menu's sections as macOS orders them: the user's databases, then +/// the engine's own after a separator. `None` when there is no other +/// database to move to. +pub(super) fn database_menu(databases: &[SchemaInfo]) -> Option { + if databases.len() < 2 { + return None; + } + let menu = gio::Menu::new(); + for system in [false, true] { + let section = gio::Menu::new(); + for database in databases.iter().filter(|database| database.is_system == system) { + let item = gio::MenuItem::new(Some(&database.name), None); + item.set_action_and_target_value(Some(&format!("tab.{SWITCH_ACTION}")), Some(&database.name.to_variant())); + section.append_item(&item); + } + if section.n_items() > 0 { + menu.append_section(None, §ion); + } + } + Some(menu) +} + impl SqlEditor { - /// Name the tab's database now, and its schema once the catalog has - /// listed the schemas. + /// Name the tab's database now, its schema once the catalog has listed + /// the schemas, and offer the server's others once they are listed. pub(super) fn show_scope(&self) { + self.install_database_action(); let Some((session, tasks)) = self .deps() .and_then(|deps| deps.session.get().map(|session| (session, deps.tasks.clone()))) @@ -39,45 +75,180 @@ impl SqlEditor { self.set_scope(None); return; }; + if let Some(moved) = self.imp().slot.database() { + self.set_scope(scope_text(Some(&moved.name), None)); + self.list_databases(&session, &tasks, &moved.name); + return; + } let database = session.database().map(str::to_owned); self.set_scope(scope_text(database.as_deref(), None)); - if database.is_none() { + let Some(database) = database else { return; - } + }; + self.list_databases(&session, &tasks, &database); // The catalog reads on the runtime, as completion's reads do. - let reading = tasks.spawn_task(async move { - session.schemas().await.ok().and_then(|schemas| { - schemas - .iter() - .find(|schema| schema.is_default) - .map(|schema| schema.name.clone()) + let reading = { + let session = session.clone(); + tasks.spawn_task(async move { + session.schemas().await.ok().and_then(|schemas| { + schemas + .iter() + .find(|schema| schema.is_default) + .map(|schema| schema.name.clone()) + }) }) - }); + }; let editor = self.downgrade(); glib::spawn_future_local(async move { let default = reading.await.ok().flatten(); + if let Some(editor) = editor.upgrade() + && editor.imp().slot.database().is_none() + { + editor.set_scope(scope_text(Some(&database), default.as_deref())); + } + }); + } + + /// Ask the server for its databases. A connection that may not write + /// keeps the label, as on macOS, and so does an engine that cannot + /// list them. + fn list_databases(&self, session: &Arc, tasks: &Tasks, current: &str) { + if session.read_only() { + return; + } + let listing = { + let session = session.clone(); + tasks.spawn_task(async move { + session + .run(OperationKind::Metadata, |lease, options| async move { + Ok(lease.list_databases(options).await?) + }) + .await + }) + }; + let editor = self.downgrade(); + let current = current.to_owned(); + glib::spawn_future_local(async move { + let databases = match listing.await { + Ok(Ok(databases)) => databases, + Ok(Err(error)) => { + tracing::debug!(%error, "the server's databases could not be listed for the editor bar"); + return; + } + Err(failure) => { + tracing::debug!(%failure, "listing the server's databases failed"); + return; + } + }; if let Some(editor) = editor.upgrade() { - editor.set_scope(scope_text(database.as_deref(), default.as_deref())); + editor.offer_databases(&databases, ¤t); } }); } - fn set_scope(&self, scope: Option<(String, String)>) { + fn offer_databases(&self, databases: &[SchemaInfo], current: &str) { + let Some(menu) = database_menu(databases) else { + return; + }; let imp = self.imp(); - let shown = scope.is_some(); - if let Some((text, spoken)) = scope { - imp.scope_label.set_label(&text); - imp.scope_box - .update_property(&[gtk4::accessible::Property::Label(&spoken)]); - imp.scope_box.set_tooltip_text(Some(&spoken)); + imp.scope_button.set_menu_model(Some(&menu)); + let current = imp + .slot + .database() + .map_or_else(|| current.to_owned(), |moved| moved.name); + if let Some(action) = self.database_action() { + action.set_state(¤t.to_variant()); } - imp.scope_box.set_visible(shown); + imp.scope_box.set_visible(false); + imp.scope_button.set_visible(true); + } + + fn install_database_action(&self) { + if self.database_action().is_some() { + return; + } + let action = gio::SimpleAction::new_stateful(SWITCH_ACTION, Some(glib::VariantTy::STRING), &"".to_variant()); + let editor = self.downgrade(); + action.connect_change_state(move |action, value| { + let (Some(editor), Some(name)) = (editor.upgrade(), value.and_then(|value| value.str())) else { + return; + }; + if action.state().as_ref().and_then(|state| state.str()) == Some(name) { + return; + } + action.set_state(&name.to_variant()); + editor.move_to_database(name); + }); + let group = gio::SimpleActionGroup::new(); + group.add_action(&action); + self.insert_action_group("tab", Some(&group)); + self.imp().tab_actions.replace(Some(group)); + } + + fn database_action(&self) -> Option { + self.imp() + .tab_actions + .borrow() + .as_ref()? + .lookup_action(SWITCH_ACTION) + .and_downcast() + } + + /// Point the tab at `name`: its sessions run the engine's `USE` before + /// the next statement, completion resolves unqualified names there, + /// and the bar names it. The schema goes with the old database, since + /// a schema only means something inside the database that holds it. + pub(crate) fn move_to_database(&self, name: &str) { + self.imp() + .slot + .set_database(Some(TabDatabase { name: name.to_owned() })); + if let Some(action) = self.database_action() { + action.set_state(&name.to_variant()); + } + for provider in self.imp().completion_providers.borrow().iter() { + if let Some(catalog) = provider.downcast_ref::() { + catalog.set_home(Some(name.to_owned())); + } + } + self.set_scope(scope_text(Some(name), None)); + } + + /// The database the editor bar moved the tab to, which a restore + /// moves it to again. + pub(crate) fn tab_database(&self) -> Option { + self.imp().slot.database().map(|database| database.name) + } + + fn set_scope(&self, scope: Option<(String, String)>) { + let imp = self.imp(); + let switchable = imp.scope_button.menu_model().is_some(); + let Some((text, spoken)) = scope else { + imp.scope_box.set_visible(false); + imp.scope_button.set_visible(false); + return; + }; + imp.scope_label.set_label(&text); + imp.scope_button_label.set_label(&text); + imp.scope_box + .update_property(&[gtk4::accessible::Property::Label(&spoken)]); + imp.scope_box.set_tooltip_text(Some(&spoken)); + imp.scope_button + .update_property(&[gtk4::accessible::Property::Label(&spoken)]); + imp.scope_button + .update_property(&[gtk4::accessible::Property::Description(&gettext("Switch Database"))]); + imp.scope_button.set_tooltip_text(Some(&gettext("Switch Database"))); + imp.scope_box.set_visible(!switchable); + imp.scope_button.set_visible(switchable); } } #[cfg(test)] mod tests { - use super::scope_text; + use gtk4::gio; + use gtk4::prelude::*; + use tablepro_core::meta::SchemaInfo; + + use super::{database_menu, scope_text}; #[test] fn the_scope_names_the_database_then_a_schema_of_its_own() { @@ -97,4 +268,42 @@ mod tests { assert_eq!(scope_text(None, Some("main")), None, "a file"); assert_eq!(scope_text(Some(""), None), None); } + + fn database(name: &str, is_system: bool) -> SchemaInfo { + SchemaInfo { + name: name.to_owned(), + is_default: false, + is_system, + } + } + + fn labels(menu: &gio::Menu, section: i32) -> Vec { + let section = menu.item_link(section, "section").expect("a section"); + (0..section.n_items()) + .filter_map(|item| { + section + .item_attribute_value(item, "label", None) + .and_then(|label| label.str().map(str::to_owned)) + }) + .collect() + } + + #[test] + fn the_menu_lists_the_users_databases_then_the_engines_own() { + let menu = database_menu(&[ + database("mysql", true), + database("shop", false), + database("analytics", false), + database("information_schema", true), + ]) + .expect("a menu"); + + assert_eq!(menu.n_items(), 2, "two sections"); + assert_eq!(labels(&menu, 0), ["shop", "analytics"]); + assert_eq!(labels(&menu, 1), ["mysql", "information_schema"]); + assert!( + database_menu(&[database("shop", false)]).is_none(), + "nothing to move to" + ); + } } diff --git a/linux/crates/app/src/ui/editor/tests.rs b/linux/crates/app/src/ui/editor/tests.rs index ff881962d7..1a42688a49 100644 --- a/linux/crates/app/src/ui/editor/tests.rs +++ b/linux/crates/app/src/ui/editor/tests.rs @@ -2671,3 +2671,75 @@ fn the_bar_names_the_tabs_database_and_schema() { .unwrap_or_else(|_| panic!("the bar says {:?}", label.label())); assert!(editor.imp().scope_box.is_visible()); } + +/// The bar is a menu of the server's databases; picking one moves the +/// tab there, so the next run sends the engine's `USE` before the query. +#[gtk4::test] +fn picking_a_database_moves_the_tab_and_its_next_run() { + crate::register_test_resources(); + let runtime = crate::test_support::test_runtime(); + let fake = Arc::new(FakeEditorSession::new(answering())); + let mut request = FakeConnector::network_request("db.example", 3306).expect("a request"); + if let tablepro_session::connect::RequestTarget::Network { database, .. } = &mut request.target { + *database = Some("shop".to_owned()); + } + let database = |name: &str, is_default: bool, is_system: bool| tablepro_core::meta::SchemaInfo { + name: name.to_owned(), + is_default, + is_system, + }; + let mut script = ConnectionScript::default(); + script.list_databases.always(FakeReply::ok(vec![ + database("analytics", false, false), + database("shop", true, false), + database("mysql", false, true), + ])); + let editor_session: Arc = fake.clone(); + script.open_editor_session.always(FakeReply::ok(editor_session)); + let (pending, _) = FakeConnector::pending_session(request, script); + let session = Session::start( + pending, + tablepro_session::SessionDeps { + connector: Arc::new(FakeConnector::new([])), + credentials: Arc::new(FakeCredentialSource::new()), + deadlines: Arc::new(DefaultDeadlines::new(tablepro_core::LivenessPolicy::DESKTOP)), + tasks: runtime.tasks(), + prompter: None, + }, + ); + let active = ActiveSession::new(); + active.replace(session); + let settings = MemorySettings::new(); + let editor = SqlEditor::new(SqlEditorInit { + initial_query: Some("SELECT 1".to_owned()), + settings: settings.get().clone(), + history: None, + tasks: runtime.tasks(), + session: active.clone(), + hold: Rc::new(tablepro_session::runtime::CountingHold::new()), + close_timeout: Duration::from_secs(5), + snippets: crate::test_support::builtin_snippets_only(), + }); + let button = editor.imp().scope_button.get(); + crate::test_support::wait_until(Duration::from_secs(5), || button.is_visible()).expect("the database menu"); + + WidgetExt::activate_action(&editor, "tab.database", Some(&"analytics".to_variant())).expect("tab.database"); + assert_eq!(editor.imp().scope_button_label.label(), "analytics"); + + let harness = Harness { + editor, + fake: fake.clone(), + history: None, + active, + settings, + runtime, + }; + run_and_wait(&harness); + let sent = sent(&fake); + assert!( + sent.first() + .is_some_and(|first| first.starts_with("USE ") && first.contains("analytics")), + "{sent:?}" + ); + assert_eq!(sent.last().map(String::as_str), Some("SELECT 1")); +} diff --git a/linux/crates/app/src/ui/error_text/unsupported.rs b/linux/crates/app/src/ui/error_text/unsupported.rs index cd7aae5416..c61ae79ef1 100644 --- a/linux/crates/app/src/ui/error_text/unsupported.rs +++ b/linux/crates/app/src/ui/error_text/unsupported.rs @@ -47,6 +47,7 @@ pub(super) fn unsupported_message(feature: UnsupportedFeature) -> String { UnsupportedFeature::Tables => gettext("This database holds keys, not tables."), UnsupportedFeature::Keys => gettext("This database holds tables, not keys."), UnsupportedFeature::CharacterSets => gettext("This database has no character sets to pick from."), + UnsupportedFeature::DatabaseSwitching => gettext("This connection cannot switch to another database."), UnsupportedFeature::EditorCommand { command } => gettext_f( "{command} cannot run in the editor: it turns the connection into a stream the editor cannot show.", &[("command", command)], @@ -84,6 +85,7 @@ mod tests { UnsupportedFeature::Tables, UnsupportedFeature::Keys, UnsupportedFeature::CharacterSets, + UnsupportedFeature::DatabaseSwitching, UnsupportedFeature::EditorCommand { command: "MONITOR" }, ]; diff --git a/linux/crates/app/src/ui/workspace/tab_factory.rs b/linux/crates/app/src/ui/workspace/tab_factory.rs index 021e835596..533ae82559 100644 --- a/linux/crates/app/src/ui/workspace/tab_factory.rs +++ b/linux/crates/app/src/ui/workspace/tab_factory.rs @@ -100,7 +100,8 @@ impl TabFactory { initial_query, draft, file, - } => self.editor(id, initial_query, draft, file), + database, + } => self.editor(id, initial_query, draft, file, database), TabSpec::Table { table, page, filter } => self.table( id, TableOpening { @@ -128,7 +129,7 @@ impl TabFactory { /// 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, None); + let slot = self.editor(id, None, None, None, None); self.insert(id, slot) } @@ -167,6 +168,7 @@ impl TabFactory { initial_query: Option, draft: Option, file: Option, + database: Option, ) -> WorkspaceTab { let parts = &self.parts; // Such a tab shows the editor's sample query, which names nothing. @@ -181,6 +183,9 @@ impl TabFactory { close_timeout: parts.context.close_timeout(), snippets: parts.snippets.clone(), }); + if let Some(database) = database { + editor.move_to_database(&database); + } connect_editor(&editor, id, &parts.input, &parts.toasts); editor.attach_assistant(parts.assistant.clone()); // After the signals are connected, so the workspace marks a tab diff --git a/linux/crates/app/src/ui/workspace/tab_spec.rs b/linux/crates/app/src/ui/workspace/tab_spec.rs index 3872f9995c..ad6bdbf233 100644 --- a/linux/crates/app/src/ui/workspace/tab_spec.rs +++ b/linux/crates/app/src/ui/workspace/tab_spec.rs @@ -16,6 +16,8 @@ pub(crate) enum TabSpec { draft: Option, /// The `.sql` file a restored or reopened tab is saved to. file: Option, + /// The database the editor bar moved a restored tab to. + database: Option, }, Table { table: TableRef, diff --git a/linux/crates/app/src/ui/workspace/tests/assistant.rs b/linux/crates/app/src/ui/workspace/tests/assistant.rs index 49da7d47d9..10c9a07f6a 100644 --- a/linux/crates/app/src/ui/workspace/tests/assistant.rs +++ b/linux/crates/app/src/ui/workspace/tests/assistant.rs @@ -93,6 +93,7 @@ fn insert_goes_to_the_editor_in_front_or_a_new_one() { initial_query: Some("-- ".to_owned()), draft: None, file: None, + database: None, }) }); input.deliver(WorkspaceInput::InsertFromAssistant("SELECT 2;".to_owned())); diff --git a/linux/crates/app/src/ui/workspace/tests/close_tab.rs b/linux/crates/app/src/ui/workspace/tests/close_tab.rs index d26cb97d06..39bb3ad8a1 100644 --- a/linux/crates/app/src/ui/workspace/tests/close_tab.rs +++ b/linux/crates/app/src/ui/workspace/tests/close_tab.rs @@ -189,6 +189,7 @@ fn editor_on_file(test: &TestWindow, path: &std::path::Path) -> (Uuid, crate::ui uri: gtk::gio::File::for_path(path).uri().to_string(), modified: true, }), + database: None, }) }); let id = workspace.selected_tab().expect("the editor in front"); @@ -287,6 +288,7 @@ fn an_editor_asking_to_close_closes_its_own_tab_not_the_one_in_front() { initial_query: Some(query.to_owned()), draft: None, file: None, + database: None, }) }); let page = shell_of(&workspace) diff --git a/linux/crates/app/src/ui/workspace/tests/tab_title.rs b/linux/crates/app/src/ui/workspace/tests/tab_title.rs index ee27913289..92cd1ed168 100644 --- a/linux/crates/app/src/ui/workspace/tests/tab_title.rs +++ b/linux/crates/app/src/ui/workspace/tests/tab_title.rs @@ -184,6 +184,7 @@ fn restored_workspace_selects_saved_active_tab() { TabRecord::Editor { draft: DraftId::new(), file: None, + database: None, }, orders, ], @@ -229,6 +230,7 @@ fn a_restored_editor_tab_keeps_its_file_and_its_unsaved_changes() { uri: gio::File::for_path(&path).uri().to_string(), modified: true, }), + database: None, }], }, ) diff --git a/linux/crates/app/src/ui/workspace/workspace_persistence.rs b/linux/crates/app/src/ui/workspace/workspace_persistence.rs index 5eabf1fddf..ec2000f63e 100644 --- a/linux/crates/app/src/ui/workspace/workspace_persistence.rs +++ b/linux/crates/app/src/ui/workspace/workspace_persistence.rs @@ -191,6 +191,7 @@ fn tab_record(tab: &WorkspaceTab) -> Option { WorkspaceTab::Editor(slot) => Some(TabRecord::Editor { draft: slot.draft, file: super::workspace_tabs::editor_file_record(&slot.editor), + database: slot.editor.tab_database(), }), WorkspaceTab::Structure(slot) => match slot.mode { // A draft for a table that does not exist yet has nothing to @@ -504,6 +505,7 @@ mod tests { TabRecord::Editor { draft: DraftId::new(), file: None, + database: None, }, ], }; diff --git a/linux/crates/app/src/ui/workspace/workspace_tabs.rs b/linux/crates/app/src/ui/workspace/workspace_tabs.rs index cf2de5f6e5..6fa5140a74 100644 --- a/linux/crates/app/src/ui/workspace/workspace_tabs.rs +++ b/linux/crates/app/src/ui/workspace/workspace_tabs.rs @@ -131,10 +131,11 @@ impl WorkspaceModel { TabRecord::Diagram { schema, places } => TabSpec::Diagram { schema, places }, // Its text is on disk already, or there is none to keep, so // nothing is written until the user types. - TabRecord::Editor { draft, file } => TabSpec::Editor { + TabRecord::Editor { draft, file, database } => TabSpec::Editor { initial_query: Some(drafts.remove(&draft).unwrap_or_default()), draft: Some(draft), file, + database, }, TabRecord::Key { key } => TabSpec::Key { key }, }; @@ -188,6 +189,7 @@ impl WorkspaceModel { initial_query, draft: None, file: None, + database: None, }); } @@ -699,6 +701,7 @@ impl WorkspaceModel { initial_query: initial, draft: None, file, + database: None, }); } ClosedTabDescriptor::Table { diff --git a/linux/crates/app/src/workspace/tab_record.rs b/linux/crates/app/src/workspace/tab_record.rs index fa4b714d69..6717b8b47c 100644 --- a/linux/crates/app/src/workspace/tab_record.rs +++ b/linux/crates/app/src/workspace/tab_record.rs @@ -44,6 +44,10 @@ pub enum TabRecord { draft: DraftId, #[serde(default, skip_serializing_if = "Option::is_none")] file: Option, + /// The database the editor bar moved the tab to. `None` keeps the + /// connection's own, as in a record written before tabs kept one. + #[serde(default, skip_serializing_if = "Option::is_none")] + database: Option, }, /// A key-value engine's key, by the name the sidebar lists it as. Key { @@ -106,7 +110,7 @@ impl TabRecord { } Self::Structure { schema, table } => (!table.is_empty()).then_some(Self::Structure { schema, table }), Self::Diagram { schema, places } => Some(Self::Diagram { schema, places }), - Self::Editor { draft, file } => Some(Self::Editor { draft, file }), + Self::Editor { draft, file, database } => Some(Self::Editor { draft, file, database }), // A key's name can be empty, as Redis lets one be. Self::Key { key } => Some(Self::Key { key }), } diff --git a/linux/crates/app/src/workspace/workspace_record.rs b/linux/crates/app/src/workspace/workspace_record.rs index a433588b51..06f994c9e6 100644 --- a/linux/crates/app/src/workspace/workspace_record.rs +++ b/linux/crates/app/src/workspace/workspace_record.rs @@ -120,6 +120,7 @@ mod tests { TabRecord::Editor { draft: DraftId::new(), file: None, + database: None, }, TabRecord::Diagram { schema: Some("sales".to_owned()), @@ -182,6 +183,7 @@ mod tests { TabRecord::Editor { draft: DraftId::from_uuid(draft), file: None, + database: None, }, TabRecord::Structure { schema: Some("sales".to_owned()), @@ -231,6 +233,7 @@ mod tests { TabRecord::Editor { draft: DraftId::from_uuid(draft), file: None, + database: None, }, TabRecord::Structure { schema: Some("sales".to_owned()), @@ -262,6 +265,7 @@ mod tests { 0 => TabRecord::Editor { draft: DraftId::new(), file: None, + database: None, }, _ => table(&format!("t{index}")), }) diff --git a/linux/crates/app/src/workspace/workspace_record_builder.rs b/linux/crates/app/src/workspace/workspace_record_builder.rs index 7e81155c91..0fa4922ef7 100644 --- a/linux/crates/app/src/workspace/workspace_record_builder.rs +++ b/linux/crates/app/src/workspace/workspace_record_builder.rs @@ -67,6 +67,7 @@ mod tests { page(Some(TabRecord::Editor { draft: DraftId::new(), file: None, + database: None, })), page(None), ]; diff --git a/linux/crates/app/src/workspace/workspace_store.rs b/linux/crates/app/src/workspace/workspace_store.rs index fd01fedd2a..954e1e52ba 100644 --- a/linux/crates/app/src/workspace/workspace_store.rs +++ b/linux/crates/app/src/workspace/workspace_store.rs @@ -536,6 +536,7 @@ mod tests { tabs: vec![TabRecord::Editor { draft: DraftId::new(), file: None, + database: None, }], }; @@ -684,6 +685,7 @@ mod tests { .map(|_| TabRecord::Editor { draft: DraftId::new(), file: None, + database: None, }) .collect(), }; diff --git a/linux/crates/core/src/connection.rs b/linux/crates/core/src/connection.rs index 32b3f44884..c16484fab6 100644 --- a/linux/crates/core/src/connection.rs +++ b/linux/crates/core/src/connection.rs @@ -103,6 +103,15 @@ pub trait Connection: Send + Sync { }) } + /// The server's databases a query tab can switch to, the connection's + /// own marked as the default, and the engine's own marked as system. + /// An engine whose connection is to one database keeps this default. + async fn list_databases(&self, _options: CallOptions) -> Result, DriverError> { + Err(DriverError::Unsupported { + feature: UnsupportedFeature::DatabaseSwitching, + }) + } + async fn row_identity(&self, table: &TableRef, options: CallOptions) -> Result; async fn table_estimate(&self, table: &TableRef, options: CallOptions) -> Result; diff --git a/linux/crates/core/src/unsupported_feature.rs b/linux/crates/core/src/unsupported_feature.rs index 6eff4bed47..d42e81a6b2 100644 --- a/linux/crates/core/src/unsupported_feature.rs +++ b/linux/crates/core/src/unsupported_feature.rs @@ -48,6 +48,8 @@ pub enum UnsupportedFeature { /// A list of character sets and collations to pick a column's or a /// table's from. CharacterSets, + /// Another of the server's databases for a query tab. + DatabaseSwitching, /// A command the editor cannot run, because it stops the one reply /// per command the editor reads, such as `SUBSCRIBE`. EditorCommand { @@ -79,6 +81,7 @@ impl fmt::Display for UnsupportedFeature { Self::Tables => f.write_str("tables"), Self::Keys => f.write_str("keys"), Self::CharacterSets => f.write_str("character sets"), + Self::DatabaseSwitching => f.write_str("database switching"), Self::EditorCommand { command } => write!(f, "{command} in the editor"), } } diff --git a/linux/crates/drivers/mysql/src/connection.rs b/linux/crates/drivers/mysql/src/connection.rs index 5137000983..3a83c808f0 100644 --- a/linux/crates/drivers/mysql/src/connection.rs +++ b/linux/crates/drivers/mysql/src/connection.rs @@ -271,6 +271,14 @@ impl Connection for MysqlConnection { .await } + async fn list_databases(&self, options: CallOptions) -> Result, DriverError> { + self.read( + options, + read_op(|connection, flavour| Box::pin(metadata::list_databases(connection, flavour))), + ) + .await + } + async fn list_objects(&self, scope: &ObjectScope, options: CallOptions) -> Result, DriverError> { let scope = scope.clone(); self.read( diff --git a/linux/crates/drivers/mysql/src/metadata.rs b/linux/crates/drivers/mysql/src/metadata.rs index 2392af170e..0899312eb0 100644 --- a/linux/crates/drivers/mysql/src/metadata.rs +++ b/linux/crates/drivers/mysql/src/metadata.rs @@ -85,6 +85,41 @@ pub(crate) async fn list_schemas( .collect()) } +/// The databases MySQL and MariaDB keep for themselves. +const SYSTEM_DATABASES: [&str; 4] = ["information_schema", "mysql", "performance_schema", "sys"]; + +/// Every database the login can see, the connection's own the default. +pub(crate) async fn list_databases( + connection: &mut MySqlConnection, + flavour: MySqlFlavour, +) -> Result, DriverError> { + let current = list_schemas(connection, flavour) + .await? + .into_iter() + .next() + .map(|schema| schema.name); + let rows = catalog_rows( + connection, + flavour, + "SELECT SCHEMA_NAME FROM information_schema.SCHEMATA ORDER BY SCHEMA_NAME", + "SCHEMATA", + &[], + ) + .await?; + let mut databases = Vec::with_capacity(rows.len()); + for row in rows { + let Some(name) = row.text(0)? else { + continue; + }; + databases.push(SchemaInfo { + is_default: current.as_deref() == Some(name.as_str()), + is_system: SYSTEM_DATABASES.iter().any(|system| name.eq_ignore_ascii_case(system)), + name, + }); + } + Ok(databases) +} + const TABLES_IN_SCHEMA_SQL: &str = "\ SELECT TABLE_SCHEMA, TABLE_NAME, TABLE_TYPE FROM information_schema.TABLES WHERE TABLE_SCHEMA = COALESCE(?, DATABASE()) ORDER BY TABLE_NAME"; diff --git a/linux/crates/drivers/mysql/tests/integration/metadata.rs b/linux/crates/drivers/mysql/tests/integration/metadata.rs index e5c0b7123c..41581b0d72 100644 --- a/linux/crates/drivers/mysql/tests/integration/metadata.rs +++ b/linux/crates/drivers/mysql/tests/integration/metadata.rs @@ -8,7 +8,7 @@ use tablepro_core::statement::StatementBuilder; use tablepro_core::{Connection, RowLimit, Value}; use tablepro_test_fixtures::MysqlFlavour; -use crate::support::{CALL, FLAVOURS, connect, rows, setup, start, table, within}; +use crate::support::{CALL, FLAVOURS, connect, connect_root, rows, setup, start, table, within}; #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[ignore = "requires docker"] @@ -496,3 +496,37 @@ async fn insensitive_filter_matches_mixed_case() { assert_eq!(insensitive, ["Alpha"]); connection.close().await.expect("a close"); } + +/// The databases a query tab can move to: the login's own marked as the +/// default, another one it can see, and the server's own marked as +/// system, which the editor bar lists after the user's. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore = "requires docker"] +async fn the_servers_databases_are_listed_for_a_query_tab() { + for flavour in FLAVOURS { + let fixture = start(flavour).await; + let root = connect_root(&fixture).await; + let grant = format!( + "GRANT ALL ON analytics.* TO '{}'@'%'", + fixture.password_credentials().username + ); + setup( + root.as_ref(), + &["CREATE DATABASE IF NOT EXISTS analytics", grant.as_str()], + ) + .await; + root.close().await.expect("a close"); + let connection = connect(&fixture, false).await; + + let databases = connection.list_databases(within(CALL)).await.expect("the databases"); + + let find = |name: &str| databases.iter().find(|database| database.name == name); + let own = find("tablepro").expect("the login's database"); + assert!(own.is_default && !own.is_system, "{flavour:?}: {own:?}"); + let other = find("analytics").expect("another database"); + assert!(!other.is_default && !other.is_system, "{flavour:?}: {other:?}"); + let system = find("information_schema").expect("information_schema"); + assert!(system.is_system, "{flavour:?}: {system:?}"); + connection.close().await.expect("a close"); + } +} diff --git a/linux/crates/test-support/src/call_counters.rs b/linux/crates/test-support/src/call_counters.rs index 9bf60773b7..9c8683e484 100644 --- a/linux/crates/test-support/src/call_counters.rs +++ b/linux/crates/test-support/src/call_counters.rs @@ -7,6 +7,7 @@ use std::sync::atomic::{AtomicUsize, Ordering}; pub struct CallCounters { pub probe: Arc, pub list_schemas: Arc, + pub list_databases: Arc, pub list_objects: Arc, pub fetch_columns: Arc, pub fetch_indexes: Arc, diff --git a/linux/crates/test-support/src/connection_script.rs b/linux/crates/test-support/src/connection_script.rs index c47947a703..920bc05b24 100644 --- a/linux/crates/test-support/src/connection_script.rs +++ b/linux/crates/test-support/src/connection_script.rs @@ -25,6 +25,7 @@ pub struct ConnectionScript { pub close: CloseBehaviour, pub probe: Replies>, pub list_schemas: Replies>>, + pub list_databases: Replies>>, pub list_objects: Replies>>, pub fetch_columns: Replies>>, pub fetch_indexes: Replies>>, @@ -59,6 +60,7 @@ impl Default for ConnectionScript { close: CloseBehaviour::Immediate, probe: Replies::default(), list_schemas: Replies::default(), + list_databases: Replies::default(), list_objects: Replies::default(), fetch_columns: Replies::default(), fetch_indexes: Replies::default(), diff --git a/linux/crates/test-support/src/fake_connection.rs b/linux/crates/test-support/src/fake_connection.rs index dcb16a3637..22f2ebee53 100644 --- a/linux/crates/test-support/src/fake_connection.rs +++ b/linux/crates/test-support/src/fake_connection.rs @@ -189,6 +189,19 @@ impl Connection for FakeConnection { .await } + async fn list_databases(&self, options: CallOptions) -> Result, DriverError> { + let counter = &self.counters.list_databases; + self.answer( + "list_databases", + counter, + |s| &mut s.list_databases, + None, + &options, + TimeoutPhase::Statement, + ) + .await + } + async fn list_schemas(&self, options: CallOptions) -> Result, DriverError> { let counter = &self.counters.list_schemas; self.answer( diff --git a/linux/data/resources/ui/sql-editor.ui b/linux/data/resources/ui/sql-editor.ui index b343a5e633..8268c1403a 100644 --- a/linux/data/resources/ui/sql-editor.ui +++ b/linux/data/resources/ui/sql-editor.ui @@ -94,25 +94,55 @@ 8 - - false - 6 - 4 - label - + names it: a label where there is nothing to switch to, + else a menu of the server's databases. --> + - - drive-harddisk-symbolic - presentation + + false + 6 + 4 + label + + + + drive-harddisk-symbolic + presentation + + + + + middle + presentation + + - - middle - presentation + + false + + + + 6 + + + drive-harddisk-symbolic + presentation + + + + + middle + presentation + + + + From 7dd1bc72998afdb1580186da8d117c3ed8fad3e8 Mon Sep 17 00:00:00 2001 From: sedax90 <5001801+sedax90@users.noreply.github.com> Date: Fri, 30 Oct 2026 09:54:49 +0100 Subject: [PATCH 04/53] docs(linux-editor): add the changelog entry --- linux/changelog.d/3897.added.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 linux/changelog.d/3897.added.md diff --git a/linux/changelog.d/3897.added.md b/linux/changelog.d/3897.added.md new file mode 100644 index 0000000000..07ce4b367e --- /dev/null +++ b/linux/changelog.d/3897.added.md @@ -0,0 +1 @@ +On MySQL and MariaDB, the SQL editor's database name is a menu that moves the tab to another of the server's databases. From 35be6ed24ea9c18a9f59810bde7ecabd9aa864e1 Mon Sep 17 00:00:00 2001 From: sedax90 <5001801+sedax90@users.noreply.github.com> Date: Fri, 30 Oct 2026 10:02:20 +0100 Subject: [PATCH 05/53] feat(linux-editor): switch a query tab's database on SQL Server, ClickHouse, Cassandra and MongoDB --- .../app/src/ui/editor/editor_session_slot.rs | 2 +- linux/crates/core/src/dialect/sql_dialect.rs | 6 +++++ .../drivers/cassandra/src/connection.rs | 6 +++++ .../drivers/clickhouse/src/connection.rs | 6 +++++ .../crates/drivers/mongodb/src/connection.rs | 6 +++++ linux/crates/drivers/mongodb/src/dialect.rs | 10 ++++++++ linux/crates/drivers/mssql/src/connection.rs | 8 +++++++ linux/crates/drivers/mssql/src/dialect.rs | 5 ++++ linux/crates/drivers/mssql/src/metadata.rs | 23 ++++++++++++++++++ .../mssql/tests/integration/metadata.rs | 24 +++++++++++++++++++ 10 files changed, 95 insertions(+), 1 deletion(-) diff --git a/linux/crates/app/src/ui/editor/editor_session_slot.rs b/linux/crates/app/src/ui/editor/editor_session_slot.rs index 361edfb694..47c9098207 100644 --- a/linux/crates/app/src/ui/editor/editor_session_slot.rs +++ b/linux/crates/app/src/ui/editor/editor_session_slot.rs @@ -165,7 +165,7 @@ impl EditorSessionSlot { &[("database", &database.name)], ))); }; - let statement = format!("USE {}", dialect.quote_identifier(&database.name)); + let statement = dialect.use_database(&database.name); let ran = editor .run( UserSql::new(statement.as_str()), diff --git a/linux/crates/core/src/dialect/sql_dialect.rs b/linux/crates/core/src/dialect/sql_dialect.rs index 79b7e00948..6ffacbd454 100644 --- a/linux/crates/core/src/dialect/sql_dialect.rs +++ b/linux/crates/core/src/dialect/sql_dialect.rs @@ -31,6 +31,12 @@ pub trait SqlDialect: Send + Sync + std::fmt::Debug { /// whatever it contains. fn quote_identifier(&self, name: &str) -> String; + /// The statement that moves an editor session to the server's database + /// `name`, which a query tab runs before its next statement. + fn use_database(&self, name: &str) -> String { + format!("USE {}", self.quote_identifier(name)) + } + /// Bind one value, giving back the SQL that names it. /// /// `ordinal` is the number of parameters the statement already diff --git a/linux/crates/drivers/cassandra/src/connection.rs b/linux/crates/drivers/cassandra/src/connection.rs index 1d1fe242df..599e39f40d 100644 --- a/linux/crates/drivers/cassandra/src/connection.rs +++ b/linux/crates/drivers/cassandra/src/connection.rs @@ -243,6 +243,12 @@ impl Connection for CqlConnection { outcome } + /// The schemas this engine lists are the server's databases, which a + /// query tab can move between. + async fn list_databases(&self, options: CallOptions) -> Result, DriverError> { + self.list_schemas(options).await + } + async fn list_schemas(&self, options: CallOptions) -> Result, DriverError> { let default = self.endpoint.keyspace().map(str::to_owned); self.read(options, move |link, _| async move { diff --git a/linux/crates/drivers/clickhouse/src/connection.rs b/linux/crates/drivers/clickhouse/src/connection.rs index a80b148d3e..abe7c291e4 100644 --- a/linux/crates/drivers/clickhouse/src/connection.rs +++ b/linux/crates/drivers/clickhouse/src/connection.rs @@ -261,6 +261,12 @@ impl Connection for ClickHouseConnection { outcome } + /// The schemas this engine lists are the server's databases, which a + /// query tab can move between. + async fn list_databases(&self, options: CallOptions) -> Result, DriverError> { + self.list_schemas(options).await + } + async fn list_schemas(&self, options: CallOptions) -> Result, DriverError> { self.workspace_read(options, |calls| async move { metadata::list_schemas(&calls).await }) .await diff --git a/linux/crates/drivers/mongodb/src/connection.rs b/linux/crates/drivers/mongodb/src/connection.rs index 170848f1d5..a5d55fccf7 100644 --- a/linux/crates/drivers/mongodb/src/connection.rs +++ b/linux/crates/drivers/mongodb/src/connection.rs @@ -282,6 +282,12 @@ impl Connection for MongoConnection { outcome } + /// The schemas this engine lists are the server's databases, which a + /// query tab can move between. + async fn list_databases(&self, options: CallOptions) -> Result, DriverError> { + self.list_schemas(options).await + } + async fn list_schemas(&self, options: CallOptions) -> Result, DriverError> { let default = self.database.clone(); self.read(options, move |context, session| { diff --git a/linux/crates/drivers/mongodb/src/dialect.rs b/linux/crates/drivers/mongodb/src/dialect.rs index 8bcb36fda9..ece3facbed 100644 --- a/linux/crates/drivers/mongodb/src/dialect.rs +++ b/linux/crates/drivers/mongodb/src/dialect.rs @@ -17,6 +17,11 @@ use tablepro_core::value::Value; pub struct MongoDialect; impl SqlDialect for MongoDialect { + /// mongosh's own: `use` takes the name as it is, never quoted. + fn use_database(&self, name: &str) -> String { + format!("use {name}") + } + /// A write reports how many documents it matched, and `_id` is /// unique, so a count proves a save touched the document it meant to, /// and an insert with an `_id` already taken fails with E11000. @@ -106,6 +111,11 @@ mod tests { use super::*; + #[test] + fn a_tab_moves_with_the_shells_own_use() { + assert_eq!(MongoDialect.use_database("analytics"), "use analytics"); + } + #[test] fn an_import_writes_a_document_a_step() { assert_eq!(MongoDialect.insert_batch_limits().rows, 1); diff --git a/linux/crates/drivers/mssql/src/connection.rs b/linux/crates/drivers/mssql/src/connection.rs index 2aebc5c9b4..d88cd13e86 100644 --- a/linux/crates/drivers/mssql/src/connection.rs +++ b/linux/crates/drivers/mssql/src/connection.rs @@ -254,6 +254,14 @@ impl Connection for MssqlConnection { .await } + async fn list_databases(&self, options: CallOptions) -> Result, DriverError> { + self.read( + options, + read_op(|client, watch| Box::pin(metadata::list_databases(client, watch))), + ) + .await + } + async fn list_objects(&self, scope: &ObjectScope, options: CallOptions) -> Result, DriverError> { let scope = scope.clone(); self.read( diff --git a/linux/crates/drivers/mssql/src/dialect.rs b/linux/crates/drivers/mssql/src/dialect.rs index 7b470b9519..c6c8a26284 100644 --- a/linux/crates/drivers/mssql/src/dialect.rs +++ b/linux/crates/drivers/mssql/src/dialect.rs @@ -480,6 +480,11 @@ mod tests { MssqlDialect::new(16) } + #[test] + fn a_tab_moves_with_use_and_a_bracketed_name() { + assert_eq!(dialect().use_database("sales ]2026"), "USE [sales ]]2026]"); + } + fn column(declared: &str) -> ColumnInfo { ColumnInfo { name: "c".to_owned(), diff --git a/linux/crates/drivers/mssql/src/metadata.rs b/linux/crates/drivers/mssql/src/metadata.rs index c93ec84c41..08d452420d 100644 --- a/linux/crates/drivers/mssql/src/metadata.rs +++ b/linux/crates/drivers/mssql/src/metadata.rs @@ -24,6 +24,14 @@ const SCHEMAS_SQL: &str = "SELECT s.name, \ THEN 1 ELSE 0 END AS int) \ FROM sys.schemas s ORDER BY s.name"; +/// The databases the login may open, the connection's own the default +/// and the four the server keeps for itself (`database_id` 1 to 4) as +/// system ones. +const DATABASES_SQL: &str = "SELECT d.name, \ + CAST(CASE WHEN d.name = DB_NAME() THEN 1 ELSE 0 END AS int), \ + CAST(CASE WHEN d.database_id <= 4 THEN 1 ELSE 0 END AS int) \ + FROM sys.databases d WHERE HAS_DBACCESS(d.name) = 1 ORDER BY d.name"; + const OBJECTS_SQL: &str = "SELECT SCHEMA_NAME(o.schema_id), o.name, RTRIM(o.type) \ FROM sys.objects o \ WHERE o.type IN ('U', 'V', 'SO', 'FN', 'IF', 'TF', 'FS', 'FT', 'P', 'PC') AND o.is_ms_shipped = 0"; @@ -80,6 +88,21 @@ pub(crate) async fn list_schemas(client: &mut MssqlClient, watch: &Watch) -> Res .map_err(Interrupt::Failed) } +pub(crate) async fn list_databases(client: &mut MssqlClient, watch: &Watch) -> Result, Interrupt> { + let rows = catalogue_rows(client, watch, DATABASES_SQL, &[]).await?; + rows.iter() + .map(|row| { + let cells = Cells::new(row, "sys.databases"); + Ok(SchemaInfo { + name: cells.required_text(0)?, + is_default: cells.flag(1), + is_system: cells.flag(2), + }) + }) + .collect::>() + .map_err(Interrupt::Failed) +} + pub(crate) async fn list_objects( client: &mut MssqlClient, watch: &Watch, diff --git a/linux/crates/drivers/mssql/tests/integration/metadata.rs b/linux/crates/drivers/mssql/tests/integration/metadata.rs index c1f3baf099..9ccc4402d0 100644 --- a/linux/crates/drivers/mssql/tests/integration/metadata.rs +++ b/linux/crates/drivers/mssql/tests/integration/metadata.rs @@ -114,6 +114,30 @@ async fn lists_objects_and_names_rows_by_key() { assert!(schemas.iter().any(|schema| schema.name == "sys" && schema.is_system)); } +/// The databases a query tab can move to: the login's own marked as the +/// default and the server's four as system ones. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore = "requires docker"] +async fn the_servers_databases_are_listed_for_a_query_tab() { + let fixture = start().await; + let connection = connect(&fixture, false).await; + + let databases = connection.list_databases(within(CALL)).await.expect("the databases"); + + let find = |name: &str| databases.iter().find(|database| database.name == name); + assert!( + databases.iter().filter(|database| database.is_default).count() == 1, + "{databases:?}" + ); + let master = find("master").expect("master"); + assert!(master.is_system, "{master:?}"); + let own = databases + .iter() + .find(|database| database.is_default) + .expect("the login's database"); + assert!(!own.is_system || own.name == "master", "{own:?}"); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[ignore = "requires docker"] async fn fetch_table_definition_reports_pk_name_collation_comment() { From c47fc2884fa7a92fbfbc1865b1206ecbb6b80726 Mon Sep 17 00:00:00 2001 From: sedax90 <5001801+sedax90@users.noreply.github.com> Date: Fri, 30 Oct 2026 10:02:44 +0100 Subject: [PATCH 06/53] docs(linux-editor): add the changelog entry --- linux/changelog.d/3898.added.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 linux/changelog.d/3898.added.md diff --git a/linux/changelog.d/3898.added.md b/linux/changelog.d/3898.added.md new file mode 100644 index 0000000000..80506f53f1 --- /dev/null +++ b/linux/changelog.d/3898.added.md @@ -0,0 +1 @@ +The SQL editor's database menu also moves a tab on SQL Server, ClickHouse, Cassandra, ScyllaDB and MongoDB. From d4cc649280a9c9b8273d3617a4de8a22a31612c0 Mon Sep 17 00:00:00 2001 From: sedax90 <5001801+sedax90@users.noreply.github.com> Date: Fri, 30 Oct 2026 10:06:04 +0100 Subject: [PATCH 07/53] fix(linux-editor): refuse a MongoDB database name a use statement cannot carry --- .../app/src/ui/editor/editor_session_slot.rs | 6 ++++-- linux/crates/core/src/dialect/sql_dialect.rs | 7 ++++--- linux/crates/drivers/mongodb/src/dialect.rs | 21 +++++++++++++++---- linux/crates/drivers/mssql/src/dialect.rs | 5 ++++- 4 files changed, 29 insertions(+), 10 deletions(-) diff --git a/linux/crates/app/src/ui/editor/editor_session_slot.rs b/linux/crates/app/src/ui/editor/editor_session_slot.rs index 47c9098207..ee119ff50b 100644 --- a/linux/crates/app/src/ui/editor/editor_session_slot.rs +++ b/linux/crates/app/src/ui/editor/editor_session_slot.rs @@ -159,13 +159,15 @@ impl EditorSessionSlot { let Some(database) = wanted else { return Ok(editor); }; - let Some(dialect) = session.current_dialect() else { + let Some(statement) = session + .current_dialect() + .and_then(|dialect| dialect.use_database(&database.name)) + else { return Err(AcquireError::Failed(crate::i18n::gettext_f( "Could not switch this tab to the database “{database}”", &[("database", &database.name)], ))); }; - let statement = dialect.use_database(&database.name); let ran = editor .run( UserSql::new(statement.as_str()), diff --git a/linux/crates/core/src/dialect/sql_dialect.rs b/linux/crates/core/src/dialect/sql_dialect.rs index 6ffacbd454..741cbbb8b7 100644 --- a/linux/crates/core/src/dialect/sql_dialect.rs +++ b/linux/crates/core/src/dialect/sql_dialect.rs @@ -32,9 +32,10 @@ pub trait SqlDialect: Send + Sync + std::fmt::Debug { fn quote_identifier(&self, name: &str) -> String; /// The statement that moves an editor session to the server's database - /// `name`, which a query tab runs before its next statement. - fn use_database(&self, name: &str) -> String { - format!("USE {}", self.quote_identifier(name)) + /// `name`, which a query tab runs before its next statement. `None` + /// for a name the engine's statement cannot carry whole. + fn use_database(&self, name: &str) -> Option { + Some(format!("USE {}", self.quote_identifier(name))) } /// Bind one value, giving back the SQL that names it. diff --git a/linux/crates/drivers/mongodb/src/dialect.rs b/linux/crates/drivers/mongodb/src/dialect.rs index ece3facbed..9ecd13b520 100644 --- a/linux/crates/drivers/mongodb/src/dialect.rs +++ b/linux/crates/drivers/mongodb/src/dialect.rs @@ -17,9 +17,16 @@ use tablepro_core::value::Value; pub struct MongoDialect; impl SqlDialect for MongoDialect { - /// mongosh's own: `use` takes the name as it is, never quoted. - fn use_database(&self, name: &str) -> String { - format!("use {name}") + /// mongosh's own `use`, the name quoted. Its parser takes a quoted + /// name as it stands, escapes and all, and the splitter ends a + /// statement at `;`, so a name holding a quote, a backslash, `;` or a + /// space is refused rather than sent as more than one statement. + fn use_database(&self, name: &str) -> Option { + let carried = !name.is_empty() + && !name + .chars() + .any(|c| matches!(c, '"' | '\'' | '\\' | ';') || c.is_whitespace() || c.is_control()); + carried.then(|| format!("use \"{name}\"")) } /// A write reports how many documents it matched, and `_id` is @@ -113,7 +120,13 @@ mod tests { #[test] fn a_tab_moves_with_the_shells_own_use() { - assert_eq!(MongoDialect.use_database("analytics"), "use analytics"); + assert_eq!( + MongoDialect.use_database("analytics").as_deref(), + Some("use \"analytics\"") + ); + for name in ["x; db.dropDatabase()", "a\"b", "a\\b", "two words", "line\nbreak", ""] { + assert_eq!(MongoDialect.use_database(name), None, "{name:?}"); + } } #[test] diff --git a/linux/crates/drivers/mssql/src/dialect.rs b/linux/crates/drivers/mssql/src/dialect.rs index c6c8a26284..4af1d8aeac 100644 --- a/linux/crates/drivers/mssql/src/dialect.rs +++ b/linux/crates/drivers/mssql/src/dialect.rs @@ -482,7 +482,10 @@ mod tests { #[test] fn a_tab_moves_with_use_and_a_bracketed_name() { - assert_eq!(dialect().use_database("sales ]2026"), "USE [sales ]]2026]"); + assert_eq!( + dialect().use_database("sales ]2026").as_deref(), + Some("USE [sales ]]2026]") + ); } fn column(declared: &str) -> ColumnInfo { From d2549cb420866dea7ae05ea82bc429e08e2fba33 Mon Sep 17 00:00:00 2001 From: sedax90 <5001801+sedax90@users.noreply.github.com> Date: Fri, 30 Oct 2026 10:14:49 +0100 Subject: [PATCH 08/53] feat(linux-editor): move a PostgreSQL query tab to another database by opening its session there --- .../app/src/ui/editor/editor_session_slot.rs | 111 +++++++++++++++--- linux/crates/core/src/dialect/mod.rs | 2 +- linux/crates/core/src/dialect/sql_dialect.rs | 21 +++- linux/crates/core/src/editor_session.rs | 4 + linux/crates/drivers/mongodb/src/dialect.rs | 17 +-- linux/crates/drivers/mssql/src/dialect.rs | 5 +- .../crates/drivers/postgres/src/connection.rs | 13 +- linux/crates/drivers/postgres/src/dialect.rs | 14 ++- .../drivers/postgres/src/metadata/mod.rs | 2 +- .../drivers/postgres/src/metadata/objects.rs | 26 ++++ linux/crates/drivers/postgres/src/physical.rs | 12 ++ .../tests/integration/editor_session.rs | 38 ++++++ linux/crates/mcp/src/query_tools.rs | 1 + .../src/conformance/cases/document.rs | 1 + .../src/conformance/cases/keyspace.rs | 1 + 15 files changed, 234 insertions(+), 34 deletions(-) diff --git a/linux/crates/app/src/ui/editor/editor_session_slot.rs b/linux/crates/app/src/ui/editor/editor_session_slot.rs index ee119ff50b..85da618948 100644 --- a/linux/crates/app/src/ui/editor/editor_session_slot.rs +++ b/linux/crates/app/src/ui/editor/editor_session_slot.rs @@ -2,6 +2,7 @@ use std::sync::{Arc, Mutex, MutexGuard, PoisonError, Weak}; use std::time::Duration; use async_trait::async_trait; +use tablepro_core::dialect::DatabaseMove; use tablepro_core::{ EditorSession, EditorSessionOptions, EditorSessionState, ResultEvent, ResultSink, RunOptions, SinkFlow, TransactionState, UserSql, @@ -54,6 +55,21 @@ struct OpenEditor { moved_to: Option, } +/// What the open session does for a tab that has to be in a database it +/// can reach only by opening a session there. +enum Leave { + Stay, + Go, + Refused(String), +} + +fn could_not_switch(database: &str) -> String { + crate::i18n::gettext_f( + "Could not switch this tab to the database “{database}”", + &[("database", database)], + ) +} + enum Lookup { Ready(Arc), /// None usable; `stale` is one to close before opening another. @@ -94,8 +110,16 @@ impl EditorSessionSlot { close_timeout: Duration, cancel: &CancellationToken, ) -> Result, AcquireError> { + // A session in another database than the tab's, on an engine whose + // connection cannot leave its database, gives way to one opened in + // the tab's. + let reopen_in = self.reopen_in(session); let stale = match self.lookup(session) { - Lookup::Ready(editor) => return self.move_to_database(session, editor, cancel).await, + Lookup::Ready(editor) => match self.leave_for(&editor, reopen_in.as_deref()) { + Leave::Stay => return self.move_to_database(session, editor, cancel).await, + Leave::Refused(reason) => return Err(AcquireError::Failed(reason)), + Leave::Go => Some(editor), + }, Lookup::Closed => return Err(AcquireError::Cancelled), Lookup::RolledBack { stale } => { if let Some(stale) = stale { @@ -108,9 +132,12 @@ impl EditorSessionSlot { if let Some(stale) = stale { close_quietly(stale, close_timeout).await; } - let options = EditorSessionOptions { statement_timeout }; + let options = EditorSessionOptions { + statement_timeout, + database: reopen_in.clone(), + }; match session.open_editor_session(options, cancel).await { - Ok(editor) => match self.adopt(session, editor.clone()) { + Ok(editor) => match self.adopt(session, editor.clone(), reopen_in) { Ok(()) => self.move_to_database(session, editor, cancel).await, Err(orphan) => { close_quietly(orphan, close_timeout).await; @@ -159,14 +186,11 @@ impl EditorSessionSlot { let Some(database) = wanted else { return Ok(editor); }; - let Some(statement) = session + let Some(DatabaseMove::Use(statement)) = session .current_dialect() - .and_then(|dialect| dialect.use_database(&database.name)) + .map(|dialect| dialect.database_move(&database.name)) else { - return Err(AcquireError::Failed(crate::i18n::gettext_f( - "Could not switch this tab to the database “{database}”", - &[("database", &database.name)], - ))); + return Err(AcquireError::Failed(could_not_switch(&database.name))); }; let ran = editor .run( @@ -186,11 +210,40 @@ impl EditorSessionSlot { } Ok(editor) } - _ => Err(AcquireError::Failed(crate::i18n::gettext_f( - "Could not switch this tab to the database “{database}”", - &[("database", &database.name)], - ))), + _ => Err(AcquireError::Failed(could_not_switch(&database.name))), + } + } + + /// The tab's database, when the engine reaches it only by opening a + /// session there. + fn reopen_in(&self, session: &Arc) -> Option { + let database = lock(&self.state).database.clone()?; + let dialect = session.current_dialect()?; + (dialect.database_move(&database.name) == DatabaseMove::Reconnect).then_some(database.name) + } + + /// Whether the open session can serve a tab that has to be in + /// `reopen_in`. One holding a transaction stays: closing it would roll + /// the transaction back behind the user's back. + fn leave_for(&self, editor: &Arc, reopen_in: Option<&str>) -> Leave { + let Some(wanted) = reopen_in else { + return Leave::Stay; + }; + let mut state = lock(&self.state); + let Some(open) = state.open.as_ref().filter(|open| Arc::ptr_eq(&open.editor, editor)) else { + return Leave::Stay; + }; + if open.moved_to.as_deref() == Some(wanted) { + return Leave::Stay; } + if open.held_transaction() { + return Leave::Refused(crate::i18n::gettext_f( + "Commit or roll back the transaction before this tab moves to the database “{database}”", + &[("database", wanted)], + )); + } + state.open = None; + Leave::Go } fn lookup(&self, owner: &Arc) -> Lookup { @@ -242,7 +295,12 @@ impl EditorSessionSlot { /// Keep a newly opened session, or hand it back when the tab closed /// while it was opening. - fn adopt(&self, owner: &Arc, editor: Arc) -> Result<(), Arc> { + fn adopt( + &self, + owner: &Arc, + editor: Arc, + opened_in: Option, + ) -> Result<(), Arc> { let mut state = lock(&self.state); if state.closed { return Err(editor); @@ -251,7 +309,7 @@ impl EditorSessionSlot { owner: Arc::downgrade(owner), editor, transaction: None, - moved_to: None, + moved_to: opened_in, }); Ok(()) } @@ -362,6 +420,27 @@ pub(super) mod tests { const CLOSE: Duration = Duration::from_secs(10); + /// A tab moved to a database its engine reaches only by opening a + /// session there leaves a session in another one, keeps one already + /// there, and keeps one holding a transaction, with the reason. + #[tokio::test] + async fn a_session_in_another_database_gives_way_unless_it_holds_a_transaction() { + let (session, _) = session_opening(Vec::new()); + let slot = EditorSessionSlot::new(); + let editor: Arc = answering(); + + assert!(slot.adopt(&session, editor.clone(), Some("shop".to_owned())).is_ok()); + assert!(matches!(slot.leave_for(&editor, Some("shop")), Leave::Stay)); + assert!(matches!(slot.leave_for(&editor, None), Leave::Stay)); + + slot.note_transaction(&editor, Some(TransactionState::InTransaction)); + assert!(matches!(slot.leave_for(&editor, Some("analytics")), Leave::Refused(_))); + + slot.note_transaction(&editor, Some(TransactionState::Idle)); + assert!(matches!(slot.leave_for(&editor, Some("analytics")), Leave::Go)); + assert!(lock(&slot.state).open.is_none(), "the session in shop stayed"); + } + #[tokio::test] async fn a_tab_opens_its_editor_session_once_and_reuses_it() { let editor = answering(); @@ -410,7 +489,7 @@ pub(super) mod tests { assert!(slot.close().is_none()); - assert!(slot.adopt(&session, editor).is_err()); + assert!(slot.adopt(&session, editor, None).is_err()); assert!(matches!( slot.acquire(&session, None, CLOSE, &CancellationToken::new()).await, Err(AcquireError::Cancelled) diff --git a/linux/crates/core/src/dialect/mod.rs b/linux/crates/core/src/dialect/mod.rs index 08c8769e7b..80cb26ef5c 100644 --- a/linux/crates/core/src/dialect/mod.rs +++ b/linux/crates/core/src/dialect/mod.rs @@ -22,7 +22,7 @@ pub use keyset_direction::KeysetDirection; pub use like_form::{LikeCase, LikeForm}; pub use literal_error::LiteralError; pub use placeholder::Placeholder; -pub use sql_dialect::SqlDialect; +pub use sql_dialect::{DatabaseMove, SqlDialect}; pub use table_paging::TablePaging; #[cfg(test)] pub(crate) use test_dialect::{RecordedTarget, TestDialect, TestMarkers}; diff --git a/linux/crates/core/src/dialect/sql_dialect.rs b/linux/crates/core/src/dialect/sql_dialect.rs index 741cbbb8b7..a33948593b 100644 --- a/linux/crates/core/src/dialect/sql_dialect.rs +++ b/linux/crates/core/src/dialect/sql_dialect.rs @@ -10,6 +10,18 @@ use super::{ Placeholder, TablePaging, }; +/// How an editor session gets to another of the server's databases. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum DatabaseMove { + /// Run this statement on the session, before anything else. + Use(String), + /// A connection stays in the database it opened in, as on PostgreSQL: + /// open a session there instead. + Reconnect, + /// The engine's statement cannot carry the name whole. + Refused, +} + /// How one engine spells SQL. /// /// Every builder in core goes through this rather than matching on a @@ -31,11 +43,10 @@ pub trait SqlDialect: Send + Sync + std::fmt::Debug { /// whatever it contains. fn quote_identifier(&self, name: &str) -> String; - /// The statement that moves an editor session to the server's database - /// `name`, which a query tab runs before its next statement. `None` - /// for a name the engine's statement cannot carry whole. - fn use_database(&self, name: &str) -> Option { - Some(format!("USE {}", self.quote_identifier(name))) + /// How an editor session gets to the server's database `name`, for a + /// query tab moved there from the editor bar. + fn database_move(&self, name: &str) -> DatabaseMove { + DatabaseMove::Use(format!("USE {}", self.quote_identifier(name))) } /// Bind one value, giving back the SQL that names it. diff --git a/linux/crates/core/src/editor_session.rs b/linux/crates/core/src/editor_session.rs index 20660f092d..4e94e5eb6b 100644 --- a/linux/crates/core/src/editor_session.rs +++ b/linux/crates/core/src/editor_session.rs @@ -43,6 +43,10 @@ pub enum EditorSessionState { #[derive(Debug, Clone, Default)] pub struct EditorSessionOptions { pub statement_timeout: Option, + /// Another of the server's databases to open the session in, for an + /// engine whose connection stays in the database it opened in. + /// `None` opens it in the connection's own. + pub database: Option, } /// One editor tab's own connection to the server. diff --git a/linux/crates/drivers/mongodb/src/dialect.rs b/linux/crates/drivers/mongodb/src/dialect.rs index 9ecd13b520..c6deae1291 100644 --- a/linux/crates/drivers/mongodb/src/dialect.rs +++ b/linux/crates/drivers/mongodb/src/dialect.rs @@ -1,7 +1,7 @@ use tablepro_core::column::{ColumnType, SqlExpression}; use tablepro_core::dialect::{ - BindError, BindTarget, DialectCapabilities, InsertBatchLimits, InsertConflict, KeysetDirection, LiteralError, - Placeholder, SqlDialect, + BindError, BindTarget, DatabaseMove, DialectCapabilities, InsertBatchLimits, InsertConflict, KeysetDirection, + LiteralError, Placeholder, SqlDialect, }; use tablepro_core::meta::{EngineRowId, EngineRowIdPart}; use tablepro_core::sql_syntax::{ExpressionSyntaxError, HistoryRecording, SqlGrammar, TypeSyntaxError}; @@ -21,12 +21,15 @@ impl SqlDialect for MongoDialect { /// name as it stands, escapes and all, and the splitter ends a /// statement at `;`, so a name holding a quote, a backslash, `;` or a /// space is refused rather than sent as more than one statement. - fn use_database(&self, name: &str) -> Option { + fn database_move(&self, name: &str) -> DatabaseMove { let carried = !name.is_empty() && !name .chars() .any(|c| matches!(c, '"' | '\'' | '\\' | ';') || c.is_whitespace() || c.is_control()); - carried.then(|| format!("use \"{name}\"")) + match carried { + true => DatabaseMove::Use(format!("use \"{name}\"")), + false => DatabaseMove::Refused, + } } /// A write reports how many documents it matched, and `_id` is @@ -121,11 +124,11 @@ mod tests { #[test] fn a_tab_moves_with_the_shells_own_use() { assert_eq!( - MongoDialect.use_database("analytics").as_deref(), - Some("use \"analytics\"") + MongoDialect.database_move("analytics"), + DatabaseMove::Use("use \"analytics\"".to_owned()) ); for name in ["x; db.dropDatabase()", "a\"b", "a\\b", "two words", "line\nbreak", ""] { - assert_eq!(MongoDialect.use_database(name), None, "{name:?}"); + assert_eq!(MongoDialect.database_move(name), DatabaseMove::Refused, "{name:?}"); } } diff --git a/linux/crates/drivers/mssql/src/dialect.rs b/linux/crates/drivers/mssql/src/dialect.rs index 4af1d8aeac..ef88c7d4ab 100644 --- a/linux/crates/drivers/mssql/src/dialect.rs +++ b/linux/crates/drivers/mssql/src/dialect.rs @@ -472,6 +472,7 @@ const CREDENTIAL_RULES: CredentialRules = CredentialRules { mod tests { use chrono::{DateTime, NaiveDate}; use tablepro_core::column::{ColumnDefault, SqlTypeExpr}; + use tablepro_core::dialect::DatabaseMove; use tablepro_core::value::OffsetTimestamp; use super::*; @@ -483,8 +484,8 @@ mod tests { #[test] fn a_tab_moves_with_use_and_a_bracketed_name() { assert_eq!( - dialect().use_database("sales ]2026").as_deref(), - Some("USE [sales ]]2026]") + dialect().database_move("sales ]2026"), + DatabaseMove::Use("USE [sales ]]2026]".to_owned()) ); } diff --git a/linux/crates/drivers/postgres/src/connection.rs b/linux/crates/drivers/postgres/src/connection.rs index 44045d4b30..015d920bf8 100644 --- a/linux/crates/drivers/postgres/src/connection.rs +++ b/linux/crates/drivers/postgres/src/connection.rs @@ -254,6 +254,11 @@ impl Connection for PgConnection { outcome } + async fn list_databases(&self, options: CallOptions) -> Result, DriverError> { + self.read(options, read_op(|client| Box::pin(metadata::list_databases(client)))) + .await + } + async fn list_schemas(&self, options: CallOptions) -> Result, DriverError> { self.read(options, read_op(|client| Box::pin(metadata::list_schemas(client)))) .await @@ -462,7 +467,13 @@ impl Connection for PgConnection { cancel: CancellationToken, ) -> Result, DriverError> { self.ensure_open()?; - let session = PgEditorSession::open(self.endpoint.clone(), self.settings.read_only, options, cancel).await?; + // A connection stays in the database it opened in, so a tab moved + // to another one gets a session opened there. + let endpoint = match options.database.as_deref() { + Some(database) => Arc::new(self.endpoint.in_database(database)), + None => self.endpoint.clone(), + }; + let session = PgEditorSession::open(endpoint, self.settings.read_only, options, cancel).await?; Ok(session) } diff --git a/linux/crates/drivers/postgres/src/dialect.rs b/linux/crates/drivers/postgres/src/dialect.rs index f9dfaabd0d..fbe3806285 100644 --- a/linux/crates/drivers/postgres/src/dialect.rs +++ b/linux/crates/drivers/postgres/src/dialect.rs @@ -6,7 +6,8 @@ use tablepro_core::column::{ }; use tablepro_core::ddl::DdlDialect; use tablepro_core::dialect::{ - BindError, BindTarget, DialectCapabilities, InsertConflict, KeysetDirection, LiteralError, Placeholder, SqlDialect, + BindError, BindTarget, DatabaseMove, DialectCapabilities, InsertConflict, KeysetDirection, LiteralError, + Placeholder, SqlDialect, }; use tablepro_core::export::value_to_text; use tablepro_core::meta::{EngineRowId, EngineRowIdPart}; @@ -41,6 +42,12 @@ impl PgDialect { } impl SqlDialect for PgDialect { + /// A PostgreSQL connection stays in the database it opened in: there + /// is no `USE`, so a tab moves by opening a session there. + fn database_move(&self, _name: &str) -> DatabaseMove { + DatabaseMove::Reconnect + } + fn capabilities(&self) -> DialectCapabilities { DialectCapabilities { exact_row_counts: true, @@ -476,6 +483,11 @@ mod tests { ColumnKind::Integer(IntegerKind { width, signed: true }) } + #[test] + fn a_tab_moves_to_another_database_by_reconnecting() { + assert_eq!(dialect().database_move("analytics"), DatabaseMove::Reconnect); + } + #[test] fn a_null_binds_as_the_columns_own_type() { let cases = [ diff --git a/linux/crates/drivers/postgres/src/metadata/mod.rs b/linux/crates/drivers/postgres/src/metadata/mod.rs index 930ef17be2..5ce4e6569d 100644 --- a/linux/crates/drivers/postgres/src/metadata/mod.rs +++ b/linux/crates/drivers/postgres/src/metadata/mod.rs @@ -12,7 +12,7 @@ pub(crate) use estimate::table_estimate; pub(crate) use foreign_keys::{fetch_foreign_keys, fetch_outbound_foreign_keys}; pub(crate) use identity::row_identity; pub(crate) use indexes::fetch_indexes; -pub(crate) use objects::{list_objects, list_schemas}; +pub(crate) use objects::{list_databases, list_objects, list_schemas}; use futures_util::TryStreamExt; use postgres_types::{ToSql, Type}; diff --git a/linux/crates/drivers/postgres/src/metadata/objects.rs b/linux/crates/drivers/postgres/src/metadata/objects.rs index 39e2a8e26a..91380aa494 100644 --- a/linux/crates/drivers/postgres/src/metadata/objects.rs +++ b/linux/crates/drivers/postgres/src/metadata/objects.rs @@ -50,6 +50,32 @@ pub(crate) async fn list_schemas(client: &Client) -> Result, Dri .collect() } +/// The databases the login may connect to, the connection's own the +/// default. `template0` takes no connections; `template1` and `postgres` +/// are the server's own. +const DATABASES_SQL: &str = "\ +SELECT d.datname::text, + d.datname = current_database(), + d.datistemplate OR d.datname = 'postgres' +FROM pg_catalog.pg_database d +WHERE d.datallowconn AND pg_catalog.has_database_privilege(d.datname, 'CONNECT') +ORDER BY d.datname"; + +pub(crate) async fn list_databases(client: &Client) -> Result, DriverError> { + let field = decode_error("pg_database"); + text_rows(client, DATABASES_SQL, &[]) + .await? + .iter() + .map(|row| { + Ok(SchemaInfo { + name: row.try_get(0).map_err(&field)?, + is_default: row.try_get(1).map_err(&field)?, + is_system: row.try_get(2).map_err(&field)?, + }) + }) + .collect() +} + pub(crate) async fn list_objects(client: &Client, scope: &ObjectScope) -> Result, DriverError> { let (mode, schema) = match scope { ObjectScope::DefaultSchema => ("default", ""), diff --git a/linux/crates/drivers/postgres/src/physical.rs b/linux/crates/drivers/postgres/src/physical.rs index 0a03010a40..255849e8bf 100644 --- a/linux/crates/drivers/postgres/src/physical.rs +++ b/linux/crates/drivers/postgres/src/physical.rs @@ -56,6 +56,18 @@ impl PgEndpoint { }) } + /// The same server, login and TLS, opening connections in `database`. + pub(crate) fn in_database(&self, database: &str) -> Self { + let mut config = self.config.clone(); + config.dbname(database); + Self { + transport: self.transport.clone(), + config, + tls: self.tls.clone(), + liveness: self.liveness, + } + } + pub(crate) fn liveness(&self) -> &LivenessPolicy { &self.liveness } diff --git a/linux/crates/drivers/postgres/tests/integration/editor_session.rs b/linux/crates/drivers/postgres/tests/integration/editor_session.rs index 058eca484d..1a103a2be1 100644 --- a/linux/crates/drivers/postgres/tests/integration/editor_session.rs +++ b/linux/crates/drivers/postgres/tests/integration/editor_session.rs @@ -526,3 +526,41 @@ async fn editor_deadline_times_out_with_the_server_acknowledging() { assert_eq!(session.state(), EditorSessionState::Ready); run_ok(session.as_ref(), "SELECT 1").await; } + +/// A tab moved to another database: the server lists it, and a session +/// opened there runs in it, since a connection never leaves the database +/// it opened in. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore = "requires docker"] +async fn a_session_opened_in_another_database_runs_there() { + let fixture = start().await; + let connection = connect(&fixture, false).await; + + let databases = connection.list_databases(within(CALL)).await.expect("the databases"); + let own = databases + .iter() + .find(|database| database.is_default) + .expect("the connection's database"); + assert!(!own.is_system, "{own:?}"); + let postgres = databases + .iter() + .find(|database| database.name == "postgres") + .expect("postgres"); + assert!(postgres.is_system, "{postgres:?}"); + assert!( + databases.iter().all(|database| database.name != "template0"), + "{databases:?}" + ); + + let options = tablepro_core::EditorSessionOptions { + statement_timeout: None, + database: Some("postgres".to_owned()), + }; + let elsewhere = connection + .open_editor_session(options, CancellationToken::new()) + .await + .expect("a session in postgres"); + let (_, events) = run(elsewhere.as_ref(), "SELECT current_database()").await; + assert_eq!(texts(&events), ["postgres"]); + elsewhere.close().await; +} diff --git a/linux/crates/mcp/src/query_tools.rs b/linux/crates/mcp/src/query_tools.rs index 784fac6a4f..bf77d7503b 100644 --- a/linux/crates/mcp/src/query_tools.rs +++ b/linux/crates/mcp/src/query_tools.rs @@ -184,6 +184,7 @@ async fn run_read(session: &Session, text: &str, limit: usize, timeout: Duration async fn run_write(session: &Session, text: &str, limit: usize, timeout: Duration, cancel: &CancellationToken) -> Ran { let options = EditorSessionOptions { statement_timeout: Some(timeout), + database: None, }; let editor = session .open_editor_session(options, cancel) diff --git a/linux/crates/test-support/src/conformance/cases/document.rs b/linux/crates/test-support/src/conformance/cases/document.rs index eec2476dc2..a38fab090b 100644 --- a/linux/crates/test-support/src/conformance/cases/document.rs +++ b/linux/crates/test-support/src/conformance/cases/document.rs @@ -605,6 +605,7 @@ pub async fn a_timed_out_statement_is_stopped_by_the_server( let table = filled(TIMEOUT_CASE, &engine, connection.as_ref(), 50).await?; let options = EditorSessionOptions { statement_timeout: Some(SETTLE), + database: None, }; let session = connection .open_editor_session(options, CancellationToken::new()) diff --git a/linux/crates/test-support/src/conformance/cases/keyspace.rs b/linux/crates/test-support/src/conformance/cases/keyspace.rs index ea14571853..f0edded84d 100644 --- a/linux/crates/test-support/src/conformance/cases/keyspace.rs +++ b/linux/crates/test-support/src/conformance/cases/keyspace.rs @@ -340,6 +340,7 @@ pub async fn a_timed_out_command_is_stopped_by_the_server( let connection = connect(TIMEOUT_CASE, &engine, writable()).await?; let options = EditorSessionOptions { statement_timeout: Some(SETTLE), + database: None, }; let session = editor(connection.as_ref(), options).await?; From b56bb373d1656642a59bd973ba84ff54fb694c2e Mon Sep 17 00:00:00 2001 From: sedax90 <5001801+sedax90@users.noreply.github.com> Date: Fri, 30 Oct 2026 10:15:16 +0100 Subject: [PATCH 09/53] docs(linux-editor): add the changelog entry --- linux/changelog.d/3899.added.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 linux/changelog.d/3899.added.md diff --git a/linux/changelog.d/3899.added.md b/linux/changelog.d/3899.added.md new file mode 100644 index 0000000000..2a9eb85047 --- /dev/null +++ b/linux/changelog.d/3899.added.md @@ -0,0 +1 @@ +On PostgreSQL, the SQL editor's database menu moves a tab to another of the server's databases by opening its connection there. From 03ba56d18743359bcabdb986c5f521203c1a30ca Mon Sep 17 00:00:00 2001 From: sedax90 <5001801+sedax90@users.noreply.github.com> Date: Fri, 30 Oct 2026 10:26:07 +0100 Subject: [PATCH 10/53] feat(linux-session): open a connection's session in another of the server's databases --- .../session/src/connect/connect_request.rs | 12 +++ linux/crates/session/src/session/session.rs | 100 +++++++++++++++++- 2 files changed, 109 insertions(+), 3 deletions(-) diff --git a/linux/crates/session/src/connect/connect_request.rs b/linux/crates/session/src/connect/connect_request.rs index 6063d1a2da..d2ca81631f 100644 --- a/linux/crates/session/src/connect/connect_request.rs +++ b/linux/crates/session/src/connect/connect_request.rs @@ -25,6 +25,18 @@ impl ConnectRequest { self.workspace_settings.read_only = true; } + /// The same server, user and settings opening another of the server's + /// databases, for a window that follows a query tab there. `None` for + /// a file, which is the one database it opens. + pub fn in_database(&self, database: &str) -> Option { + let mut request = self.for_reconnect(); + match &mut request.target { + RequestTarget::Network { database: opened, .. } => *opened = Some(database.to_owned()), + RequestTarget::File(_) => return None, + } + Some(request) + } + /// The request a reconnect sends: the same server, user and /// settings, never a retarget. pub fn for_reconnect(&self) -> Self { diff --git a/linux/crates/session/src/session/session.rs b/linux/crates/session/src/session/session.rs index 8c53bbef2b..520c6162b4 100644 --- a/linux/crates/session/src/session/session.rs +++ b/linux/crates/session/src/session/session.rs @@ -2,18 +2,21 @@ use std::future::Future; use std::sync::{Arc, Mutex, PoisonError}; use std::time::Duration; +use tablepro_core::credentials::CredentialInteraction; use tablepro_core::dialect::SqlDialect; use tablepro_core::meta::{ObjectInfo, ObjectScope, SchemaInfo}; use tablepro_core::{ - CallOptions, ColumnInfo, EditorSession, EditorSessionOptions, SafeModeLevel, TableRef, WorkspaceSettings, + CallOptions, ColumnInfo, DriverError, EditorSession, EditorSessionOptions, SafeModeLevel, TableRef, + UnsupportedFeature, WorkspaceSettings, }; +use tablepro_storage::CredentialDemand; use tokio::sync::watch; use tokio_util::sync::{CancellationToken, DropGuard}; use tokio_util::task::AbortOnDropHandle; use uuid::Uuid; use super::{ObjectListSnapshot, SchemaCatalog, SessionDeps}; -use crate::connect::{ConnectError, ConnectionMetadata, PendingSession, RequestTarget}; +use crate::connect::{ConnectError, ConnectRequest, ConnectionMetadata, PendingSession, RequestTarget}; use crate::health::{ LinkGeneration, MonitorContext, MonitorMailbox, MonitorSignal, ReachabilityTarget, ReconnectPlan, ReviewedHostKey, SessionHealth, run_monitor, @@ -41,6 +44,9 @@ pub struct Session { /// The database the connection was asked to open, as the user named /// it. database: Option, + /// What opens this connection's sessions in the server's other + /// databases: its request and the app's parts, as a reconnect has them. + origin: SessionOrigin, runner: OperationRunner, mailbox: Arc, health: watch::Receiver, @@ -53,9 +59,20 @@ pub struct Session { _monitor: AbortOnDropHandle<()>, } +/// What a session was opened with, kept to open its siblings in the +/// server's other databases. +struct SessionOrigin { + request: ConnectRequest, + deps: SessionDeps, +} + impl Session { pub fn start(pending: PendingSession, deps: SessionDeps) -> Arc { let parts = pending.into_parts(); + let origin = SessionOrigin { + request: parts.request.for_reconnect(), + deps: deps.clone(), + }; let close_timeout = deps .deadlines .deadline(OperationKind::Close) @@ -116,6 +133,7 @@ impl Session { trusted_host_key, ssh, database, + origin, runner, mailbox, health, @@ -263,6 +281,37 @@ impl Session { self.database.as_deref() } + /// A session of this connection in another of the server's databases, + /// for a window that follows a query tab there. Opened as a reconnect + /// is: the stored secret, never a prompt. A file has no other + /// database. + pub async fn open_in_database( + &self, + database: &str, + cancel: CancellationToken, + ) -> Result, ConnectError> { + let request = + self.origin + .request + .in_database(database) + .ok_or(ConnectError::Driver(DriverError::Unsupported { + feature: UnsupportedFeature::NetworkConnection, + }))?; + let refreshed = self + .origin + .deps + .credentials + .refresh(request, CredentialInteraction::Unattended, CredentialDemand::default()) + .await?; + let pending = self + .origin + .deps + .connector + .establish(refreshed.request, CredentialInteraction::Unattended, cancel) + .await?; + Ok(Session::start(pending, self.origin.deps.clone())) + } + /// The SSH tunnel the connection was made through, if any. pub fn ssh_config(&self) -> Option<&tablepro_ssh::SshConfig> { self.ssh.as_ref() @@ -369,7 +418,7 @@ mod tests { }; use super::*; - use crate::fakes::{FakeConnector, FakeCredentialSource}; + use crate::fakes::{FakeConnector, FakeCredentialSource, FakeEstablish}; use crate::operation::DefaultDeadlines; use crate::test_support::paused_tasks; @@ -468,6 +517,51 @@ mod tests { assert_eq!(count, 7); } + /// A window following a query tab to another database opens a sibling + /// session there: the same server and user, the other database, the + /// stored secret and no prompt. + #[tokio::test(start_paused = true)] + async fn a_sibling_session_opens_in_another_database() { + let connector = FakeConnector::new([]).always(FakeEstablish::Connect(ConnectionScript::default())); + let mut request = FakeConnector::network_request("db.example", 5432).unwrap(); + if let RequestTarget::Network { database, .. } = &mut request.target { + *database = Some("shop".to_owned()); + } + let (pending, _) = FakeConnector::pending_session(request, ConnectionScript::default()); + let session = Session::start( + pending, + SessionDeps { + connector: Arc::new(connector.clone()), + ..deps(Arc::new(FakeCredentialSource::new())) + }, + ); + + let sibling = session + .open_in_database("analytics", CancellationToken::new()) + .await + .expect("a session in analytics"); + + assert_eq!(session.database(), Some("shop")); + assert_eq!(sibling.database(), Some("analytics")); + let call = connector.calls().pop().expect("an attempt"); + assert!(!call.attended, "the sibling asked for a secret"); + assert_eq!(call.request.metadata.id, session.id(), "another connection's request"); + } + + #[tokio::test(start_paused = true)] + async fn a_file_has_no_other_database_to_open() { + let (session, _) = start(ConnectionScript::default()); + + let refused = session.open_in_database("analytics", CancellationToken::new()).await; + + assert!(matches!( + refused, + Err(ConnectError::Driver(DriverError::Unsupported { + feature: UnsupportedFeature::NetworkConnection + })) + )); + } + #[tokio::test(start_paused = true)] async fn read_only_and_dialect_follow_the_current_connection() { let (session, _) = start(ConnectionScript { From 0f869416445865d7758b988ddb33181f87e2ca47 Mon Sep 17 00:00:00 2001 From: sedax90 <5001801+sedax90@users.noreply.github.com> Date: Fri, 30 Oct 2026 10:30:26 +0100 Subject: [PATCH 11/53] docs(linux): propose a session per open database of a connection --- ...0030-a-database-is-a-session-of-its-own.md | 44 +++++++++++++++++++ linux/docs/decisions/README.md | 1 + 2 files changed, 45 insertions(+) create mode 100644 linux/docs/decisions/0030-a-database-is-a-session-of-its-own.md diff --git a/linux/docs/decisions/0030-a-database-is-a-session-of-its-own.md b/linux/docs/decisions/0030-a-database-is-a-session-of-its-own.md new file mode 100644 index 0000000000..ab14c55e33 --- /dev/null +++ b/linux/docs/decisions/0030-a-database-is-a-session-of-its-own.md @@ -0,0 +1,44 @@ +# 0030: Another database of a connection is a session of its own in the window + +- **Status**: Accepted +- **Date**: 2026-10-30 + +## Context + +A session is one connection to one database ([0028](0028-sessions-share-a-window.md)). The editor bar lets a query tab move its own editor session to another of the server's databases. The window's sidebar, its catalog and completion still read the database the connection opened, so after a move the tab's tables are missing from both. On SQL Server and PostgreSQL, where a database holds schemas, completion offers nothing. + +The macOS app keeps a connection per database where the engine needs one and points the window at the database of the tab in front: its sidebar shows that database, and a tab bound to another one is hidden until the window returns there (`BrowseCursorCoordinator`, `QueryTabManager.reconcileVisibleContainer`). + +[0012](0012-window-per-session.md) rejected a session that changes in place under its parts, and 0028 keeps a tab on the session it was built with. + +## Decision + +Each other database of a connection the user opens is a sibling session in the same window, with a workspace of its own, made with `Session::open_in_database`. The sidebar shows each open database of a connection as a branch under it. A query tab moved to a database from the editor bar opens or reaches that database's session, and the window follows it there. + +## Rationale + +- **No session changes under its parts.** A sibling is another session, so what 0012 and 0028 protect holds: a workspace never changes database, and a tab never changes session. A table opened from a database's branch is built on that database's session. +- **The catalog is the database's own.** Each sibling's catalog lists its database, so the sidebar, completion and Show CREATE TABLE are right on every engine, the ones whose databases hold schemas included. +- **One way in.** A sibling joins the window as any session does, so its tabs, its unsaved edits, its banners and its teardown follow what a connection's already do. +- **The same secret and no prompt.** A sibling opens as a reconnect does, through the window's connector and the stored secret (#3900). + +## Consequences + +- The registry keeps a window's key per saved connection; a sibling is keyed by the connection and its database, so the same database of a connection never opens twice. +- The sidebar's connection row gets Open Database…, from the server's databases, and each open database is a branch the user can close. +- A tab names its database beside its connection, as "orders (prod, analytics)", while another database of the connection has a tab in the bar. +- The restore record keeps each window's siblings with their tabs. +- The editor bar's move runs on the tab's own editor session as before; the window following it is the sidebar selecting that database's branch and completion reading its catalog. +- A file connection has no other database. + +## Alternatives considered + +**One session that follows the tab in front, as macOS's window does.** Every part reading the session would have to be reset at each tab switch, which 0012 rejected, and a table tab built for one database would run on another. + +**Hide the tabs of the other databases, as macOS does.** 0028 chose tabs of several sessions side by side over a strip that hides them; a database is a session here, so its tabs show beside the others with its name in the title. + +**Only completion follows the tab.** Smaller, but the sidebar and every other tab stay on the connection's database, which is the gap this closes. + +## Plan + +In order, each its own pull request: the sibling session (#3900); the registry key and a sibling joining the window; Open Database… and the database branches in the sidebar; the editor bar's move reaching the database's session, with completion on its catalog; tab titles; the restore record. diff --git a/linux/docs/decisions/README.md b/linux/docs/decisions/README.md index 2c26060651..5fd248db51 100644 --- a/linux/docs/decisions/README.md +++ b/linux/docs/decisions/README.md @@ -77,6 +77,7 @@ A fact the repository keeps elsewhere, such as a workflow trigger, a branch, a v | [0027](0027-one-data-grid.md) | One data grid on GtkColumnView, with rows and a focused cell | Accepted | Every surface shows rows in one `DataGrid`: row selection plus a focused cell, stable row slots, a row number column, values as text and a row detail pane. | | [0028](0028-sessions-share-a-window.md) | A window holds several sessions in one tab view | Accepted | A `WorkspaceShell` per window shares one tab view among its sessions, each with its own workspace; the sidebar is a tree of the saved connections with each open session's schemas and tables. | | [0029](0029-images-decode-in-glycin.md) | Images from the database decode in glycin's sandbox | Accepted | Row Details decodes a value's picture with the glycin crate 3.x, in a sandboxed loader process, never with GTK's in-process loaders. | +| [0030](0030-a-database-is-a-session-of-its-own.md) | Another database of a connection is a session of its own in the window | Accepted | A database the user opens beside the connection's own is a sibling session with its own workspace and sidebar branch; a query tab moved to it from the editor bar reaches that session, and the window follows it. | 0011 is not used. It was reserved for the DDL decision, which was recorded as 0010, and docs cite ADRs by number, so the later ones keep theirs. From fb4e69852419b1195ad17a5aa215e155481fff5b Mon Sep 17 00:00:00 2001 From: sedax90 <5001801+sedax90@users.noreply.github.com> Date: Fri, 30 Oct 2026 10:43:21 +0100 Subject: [PATCH 12/53] wip: saved-in key --- linux/crates/app/src/ui/session_target.rs | 16 +++++++++++++++- .../app/src/ui/session_window/connections.rs | 2 +- .../app/src/ui/session_window/session_restore.rs | 3 +++ linux/crates/session/src/registry/session_key.rs | 6 ++++++ 4 files changed, 25 insertions(+), 2 deletions(-) diff --git a/linux/crates/app/src/ui/session_target.rs b/linux/crates/app/src/ui/session_target.rs index ec039278bd..11fe1afc59 100644 --- a/linux/crates/app/src/ui/session_target.rs +++ b/linux/crates/app/src/ui/session_target.rs @@ -7,12 +7,15 @@ use tablepro_session::SessionKey; use uuid::Uuid; const SAVED: &str = "saved:"; +const SAVED_IN: &str = "saved-in:"; const FILE: &str = "file:"; -/// `saved:` or `file:`. +/// `saved:`, `saved-in:/` or `file:`. A uuid +/// holds no `/`, so the database after it may hold any. pub(crate) fn to_variant(key: &SessionKey) -> glib::Variant { match key { SessionKey::Saved(id) => format!("{SAVED}{id}"), + SessionKey::SavedIn { id, database } => format!("{SAVED_IN}{id}/{database}"), SessionKey::File(uri) => format!("{FILE}{uri}"), } .to_variant() @@ -24,6 +27,13 @@ pub(crate) fn from_variant(value: &glib::Variant) -> Option { if let Some(id) = text.strip_prefix(SAVED) { return Uuid::parse_str(id).ok().map(SessionKey::Saved); } + if let Some(rest) = text.strip_prefix(SAVED_IN) { + let (id, database) = rest.split_once('/')?; + return Uuid::parse_str(id).ok().map(|id| SessionKey::SavedIn { + id, + database: database.to_owned(), + }); + } text.strip_prefix(FILE) .filter(|uri| glib::Uri::peek_scheme(uri).is_some()) .map(|uri| SessionKey::File(uri.to_owned())) @@ -37,6 +47,10 @@ mod tests { fn a_key_round_trips() { let keys = [ SessionKey::Saved(Uuid::new_v4()), + SessionKey::SavedIn { + id: Uuid::new_v4(), + database: "sales/2026".to_owned(), + }, SessionKey::File("file:///home/u/R%26D/sales.db".to_owned()), SessionKey::File("file:///srv/caf%C3%A9.sqlite3".to_owned()), ]; diff --git a/linux/crates/app/src/ui/session_window/connections.rs b/linux/crates/app/src/ui/session_window/connections.rs index ef070d1abd..33bf6f7470 100644 --- a/linux/crates/app/src/ui/session_window/connections.rs +++ b/linux/crates/app/src/ui/session_window/connections.rs @@ -492,7 +492,7 @@ impl SessionWindow { return; }; let saved = match key { - SessionKey::Saved(id) => self.stored_connection(*id), + SessionKey::Saved(id) | SessionKey::SavedIn { id, .. } => self.stored_connection(*id), SessionKey::File(_) => None, }; // The saved level can differ from the session's after a reconnect diff --git a/linux/crates/app/src/ui/session_window/session_restore.rs b/linux/crates/app/src/ui/session_window/session_restore.rs index a762f94bc1..e34f652d26 100644 --- a/linux/crates/app/src/ui/session_window/session_restore.rs +++ b/linux/crates/app/src/ui/session_window/session_restore.rs @@ -22,6 +22,8 @@ impl SessionWindow { return; } SessionKey::Saved(id) => id, + // Not recorded yet: a sibling reopens with its connection. + SessionKey::SavedIn { .. } => return, }; let (Some(state), Some(input)) = (self.state(), self.input()) else { return; @@ -76,6 +78,7 @@ impl SessionWindow { let restorable = match key { SessionKey::File(_) => true, SessionKey::Saved(_) => saved.is_some(), + SessionKey::SavedIn { .. } => false, }; if restorable && let Some(state) = self.state() { state.services.open_sessions().add(key.clone(), self.id()); diff --git a/linux/crates/session/src/registry/session_key.rs b/linux/crates/session/src/registry/session_key.rs index b35b426ae0..6637a44f11 100644 --- a/linux/crates/session/src/registry/session_key.rs +++ b/linux/crates/session/src/registry/session_key.rs @@ -13,6 +13,12 @@ use super::FileIdentity; #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub enum SessionKey { Saved(Uuid), + /// Another of a saved connection's databases, opened in the window + /// that holds the connection ([0030]). Its sibling sessions share the + /// window and never another's. + /// + /// [0030]: ../../../docs/decisions/0030-a-database-is-a-session-of-its-own.md + SavedIn { id: Uuid, database: String }, /// The canonical file URI when there is one, else the URI the file /// arrived as. File(String), From 9f6c1d7bf4eb337eda61d2ec18ae9cbcfc8b16cf Mon Sep 17 00:00:00 2001 From: sedax90 <5001801+sedax90@users.noreply.github.com> Date: Fri, 30 Oct 2026 10:58:30 +0100 Subject: [PATCH 13/53] feat(linux-workspace): open another of a connection's databases beside it in the window --- .../crates/app/src/application/mcp_bridge.rs | 4 +- .../app/src/services/file_resolution.rs | 2 +- linux/crates/app/src/ui/session_window/mod.rs | 26 ++- .../ui/session_window/session_window_input.rs | 3 + .../src/ui/session_window/sibling_sessions.rs | 212 ++++++++++++++++++ .../src/ui/session_window/tests/sessions.rs | 56 +++++ .../src/ui/session_window/window_actions.rs | 3 +- .../crates/app/src/ui/workspace/assistant.rs | 4 +- linux/crates/app/src/ui/workspace/mod.rs | 4 +- .../ui/workspace/sidebar/connection_tree.rs | 53 ++++- .../app/src/ui/workspace/sidebar/tree_menu.rs | 5 + .../crates/app/src/ui/workspace/tab_title.rs | 8 + .../app/src/workspace/session_key_record.rs | 10 + .../app/src/workspace/workspace_file_name.rs | 7 + .../session/src/registry/session_key.rs | 5 +- linux/po/POTFILES.in | 1 + 16 files changed, 388 insertions(+), 15 deletions(-) create mode 100644 linux/crates/app/src/ui/session_window/sibling_sessions.rs diff --git a/linux/crates/app/src/application/mcp_bridge.rs b/linux/crates/app/src/application/mcp_bridge.rs index fc35e500c5..f4fd0e552f 100644 --- a/linux/crates/app/src/application/mcp_bridge.rs +++ b/linux/crates/app/src/application/mcp_bridge.rs @@ -138,7 +138,9 @@ fn answer(app: &TableProApplication, request: McpRequest) { .into_iter() .filter_map(|(key, _)| match key { SessionKey::Saved(id) => Some(id), - SessionKey::File(_) => None, + // Another database of an open connection is no other + // connection. + SessionKey::SavedIn { .. } | SessionKey::File(_) => None, }) .collect(); let _ = reply.send(open); diff --git a/linux/crates/app/src/services/file_resolution.rs b/linux/crates/app/src/services/file_resolution.rs index 698ddbd161..4a9120ff42 100644 --- a/linux/crates/app/src/services/file_resolution.rs +++ b/linux/crates/app/src/services/file_resolution.rs @@ -77,7 +77,7 @@ pub fn resolve_file_blocking( Some(connection) => FileTarget::Saved(Box::new(connection)), None => FileTarget::File(identity), }, - SessionKey::File(_) => FileTarget::File(identity), + SessionKey::SavedIn { .. } | SessionKey::File(_) => FileTarget::File(identity), } }); ResolvedFile { uri, target } diff --git a/linux/crates/app/src/ui/session_window/mod.rs b/linux/crates/app/src/ui/session_window/mod.rs index 1f7865151c..ec0386114c 100644 --- a/linux/crates/app/src/ui/session_window/mod.rs +++ b/linux/crates/app/src/ui/session_window/mod.rs @@ -29,6 +29,7 @@ mod session_restore; mod session_teardown; mod session_window_command; mod session_window_input; +mod sibling_sessions; #[cfg(test)] pub(crate) mod tests; mod window_actions; @@ -465,6 +466,7 @@ impl SessionWindow { SessionWindowInput::ConnectionSaved(id) => self.on_connection_saved(id), SessionWindowInput::Disconnect => self.on_disconnect_front(), SessionWindowInput::DisconnectSession(key) => self.on_disconnect(key), + SessionWindowInput::ChooseDatabaseIn(key) => self.choose_database_in(key), SessionWindowInput::SessionTeardownApproved(key) => self.on_session_teardown_approved(&key), SessionWindowInput::ShowSession(key) => self.show_session(&key), SessionWindowInput::NewQueryIn(key) => self.deliver_to(&key, WorkspaceInput::NewEditorTab), @@ -758,14 +760,13 @@ impl SessionWindow { warnings, session_only, } = connected; - let (Some(state), Some(guards), Some(input)) = (self.state(), self.close_guards(), self.input()) else { + let (Some(state), Some(_), Some(_)) = (self.state(), self.close_guards(), self.input()) else { self.discard_pending(pending); return; }; let key = claim.key().clone(); let credentials = state.context.session_credentials(saved.as_ref(), &session_only); let session = Session::start(pending, state.context.session_deps(credentials)); - claim.attach(&session); self.remember_session(&key, saved.as_ref()); if let ConnectSource::Intent(intent) = &source && let Err(error) = state @@ -775,6 +776,18 @@ impl SessionWindow { { tracing::warn!(%error, "could not remember the connect dialog driver"); } + self.join(claim, session, &warnings); + } + + /// Build the workspace of a session that started, in the window's + /// shell, which the first session builds. A connect and a sibling of + /// another database both join this way. + pub(super) fn join(&self, claim: SessionClaim, session: Arc, warnings: &[String]) { + let (Some(state), Some(guards), Some(input)) = (self.state(), self.close_guards(), self.input()) else { + return; + }; + let key = claim.key().clone(); + claim.attach(&session); let shell = self.shell().unwrap_or_else(|| self.new_shell(state, &input)); let workspace = Workspace::new(WorkspaceInit { key: key.clone(), @@ -796,7 +809,7 @@ impl SessionWindow { workspace.start(); self.sync_actions(); for warning in warnings { - self.show_toast(&warning); + self.show_toast(warning); } if let SessionKey::Saved(id) = key { self.touch_last_opened(id); @@ -1263,6 +1276,13 @@ impl SessionWindow { /// Close the session under `key` through its own prompts, then tear /// it down. The window's other sessions and connects go on. fn on_disconnect(&self, key: SessionKey) { + // A connection's other databases go with it, each through its own + // prompts (0030). + if let SessionKey::Saved(id) = &key { + for sibling in self.siblings_of(*id) { + self.on_disconnect(sibling); + } + } let Some(workspace) = self.session_workspace(&key) else { return; }; 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 ea95bba177..78346ef6d2 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 @@ -69,6 +69,9 @@ pub(crate) enum SessionWindowInput { Disconnect, /// Close the session open under the key through its prompts. DisconnectSession(SessionKey), + /// Open Database… on a connection's row: pick another of its server's + /// databases to open beside it. + ChooseDatabaseIn(SessionKey), /// The prompts approved closing the session under the key. SessionTeardownApproved(SessionKey), /// Bring the session under the key to the front. diff --git a/linux/crates/app/src/ui/session_window/sibling_sessions.rs b/linux/crates/app/src/ui/session_window/sibling_sessions.rs new file mode 100644 index 0000000000..d769117bc2 --- /dev/null +++ b/linux/crates/app/src/ui/session_window/sibling_sessions.rs @@ -0,0 +1,212 @@ +//! Another of a saved connection's databases, opened beside it in the +//! window that holds the connection ([0030]): a sibling session with a +//! workspace and a sidebar branch of its own, as the connection has. +//! +//! [0030]: ../../../../../docs/decisions/0030-a-database-is-a-session-of-its-own.md + +use std::sync::Arc; + +use gtk4::prelude::*; +use gtk4::subclass::prelude::*; +use gtk4::{self as gtk, glib}; +use libadwaita as adw; +use libadwaita::prelude::*; +use tablepro_core::meta::SchemaInfo; +use tablepro_session::operation::OperationKind; +use tablepro_session::{Session, SessionKey}; +use tokio_util::sync::CancellationToken; +use uuid::Uuid; + +use super::SessionWindow; +use crate::i18n::{gettext, gettext_f}; +use crate::ui::error_text::{connect_error_message, operation_error_message}; + +/// The response of the chooser that opens the database picked. +const OPEN: &str = "open"; + +/// The databases to offer: neither the connection's own nor one open +/// already, the user's first and the server's own after, as the editor +/// bar's menu lists them. +pub(super) fn offered(databases: &[SchemaInfo], open: &[String]) -> Vec { + let mut offered: Vec<&SchemaInfo> = databases + .iter() + .filter(|database| !database.is_default && !open.contains(&database.name)) + .collect(); + offered.sort_by_key(|database| database.is_system); + offered.into_iter().map(|database| database.name.clone()).collect() +} + +impl SessionWindow { + /// Open the connection `id`'s database `database` beside it. One open + /// already is left as it is; so is a connection this window does not + /// hold, whose window opens its own. + pub(crate) fn open_database(&self, id: Uuid, database: String) { + let key = SessionKey::SavedIn { + id, + database: database.clone(), + }; + if self.session_workspace(&key).is_some() { + return; + } + let (Some(origin), Some(state)) = (self.held_session(&SessionKey::Saved(id)), self.state()) else { + return; + }; + if origin.database() == Some(database.as_str()) { + return; + } + let Some(claim) = self.claim(key) else { + return; + }; + let opening = { + let database = database.clone(); + state + .services + .tasks + .spawn_task(async move { origin.open_in_database(&database, CancellationToken::new()).await }) + }; + let window = self.downgrade(); + glib::spawn_future_local(async move { + let opened = opening.await; + let Some(window) = window.upgrade() else { + return; + }; + match opened { + Ok(Ok(session)) => window.join(claim, session, &[]), + Ok(Err(error)) => window.show_toast(&gettext_f( + "Could not open the database “{database}”: {error}", + &[("database", &database), ("error", &connect_error_message(&error))], + )), + Err(failure) => { + tracing::warn!(%failure, "opening another database failed"); + } + } + }); + } + + /// Open Database…: list the server's databases, then ask which to + /// open beside the connection under `key`. + pub(super) fn choose_database_in(&self, key: SessionKey) { + let SessionKey::Saved(id) = key else { + return; + }; + let (Some(session), Some(state)) = (self.held_session(&key), self.state()) else { + return; + }; + let listing = { + let session = session.clone(); + state.services.tasks.spawn_task(async move { + session + .run(OperationKind::Metadata, |lease, options| async move { + Ok(lease.list_databases(options).await?) + }) + .await + }) + }; + let window = self.downgrade(); + glib::spawn_future_local(async move { + let listed = listing.await; + let Some(window) = window.upgrade() else { + return; + }; + match listed { + Ok(Ok(databases)) => window.ask_database(id, &databases), + Ok(Err(error)) => window.show_toast(&operation_error_message(&error)), + Err(failure) => tracing::warn!(%failure, "listing the server's databases failed"), + } + }); + } + + fn ask_database(&self, id: Uuid, databases: &[SchemaInfo]) { + let open: Vec = self + .imp() + .sessions + .borrow() + .iter() + .filter_map(|open| match open.claim.key() { + SessionKey::SavedIn { id: of, database } if *of == id => Some(database.clone()), + _ => None, + }) + .collect(); + let names = offered(databases, &open); + if names.is_empty() { + self.show_toast(&gettext("No other database of this server to open")); + return; + } + let strings: Vec<&str> = names.iter().map(String::as_str).collect(); + let choice = gtk::DropDown::from_strings(&strings); + choice.update_property(&[gtk::accessible::Property::Label(&gettext("Database"))]); + let dialog = adw::AlertDialog::builder() + .heading(gettext("Open Database")) + .body(gettext( + "It opens beside the connection, with tables and tabs of its own.", + )) + .extra_child(&choice) + .default_response(OPEN) + .close_response("cancel") + .build(); + dialog.add_responses(&[("cancel", &gettext("Cancel")), (OPEN, &gettext("Open"))]); + dialog.set_response_appearance(OPEN, adw::ResponseAppearance::Suggested); + let window = self.downgrade(); + dialog.connect_response(None, move |_, response| { + let picked = usize::try_from(choice.selected()) + .ok() + .and_then(|at| names.get(at)) + .cloned(); + if let (OPEN, Some(window), Some(database)) = (response, window.upgrade(), picked) { + window.open_database(id, database); + } + }); + dialog.present(Some(self)); + } + + /// The keys of the connection `id`'s other open databases. + pub(super) fn siblings_of(&self, id: Uuid) -> Vec { + self.imp() + .sessions + .borrow() + .iter() + .map(|open| open.claim.key().clone()) + .filter(|key| matches!(key, SessionKey::SavedIn { id: of, .. } if *of == id)) + .collect() + } + + /// The session the window holds under `key`. + pub(super) fn held_session(&self, key: &SessionKey) -> Option> { + let sessions = self.imp().sessions.try_borrow().ok()?; + sessions + .iter() + .find(|open| open.claim.key() == key) + .map(|open| open.session.clone()) + } +} + +#[cfg(test)] +mod tests { + use tablepro_core::meta::SchemaInfo; + + use super::offered; + + fn database(name: &str, is_default: bool, is_system: bool) -> SchemaInfo { + SchemaInfo { + name: name.to_owned(), + is_default, + is_system, + } + } + + #[test] + fn the_chooser_offers_the_other_databases_the_users_first() { + let databases = [ + database("mysql", false, true), + database("shop", true, false), + database("analytics", false, false), + database("archive", false, false), + ]; + + assert_eq!( + offered(&databases, &["archive".to_owned()]), + ["analytics", "mysql"], + "neither the connection's own nor one open already" + ); + } +} diff --git a/linux/crates/app/src/ui/session_window/tests/sessions.rs b/linux/crates/app/src/ui/session_window/tests/sessions.rs index 42eeb2bee9..7a76cbd25f 100644 --- a/linux/crates/app/src/ui/session_window/tests/sessions.rs +++ b/linux/crates/app/src/ui/session_window/tests/sessions.rs @@ -620,3 +620,59 @@ fn a_tab_dropped_on_another_window_goes_back_to_its_own() { wait_until(WAIT, || holds_page(&home, &page) && !holds_page(&away, &page)) .expect("the tab to go back to its session's window"); } + +/// Another database of the connection opens beside it as a session of +/// its own, with a row of its own under the connection's, and goes when +/// the connection is disconnected (0030). +#[gtk4::test] +fn another_database_opens_beside_the_connection_and_goes_with_it() { + let test = TestWindow::with_script( + "Sibling", + Script { + network: true, + ..Script::default() + }, + ); + let (vault, id) = (test.vault.clone(), test.saved.id); + crate::test_support::finish_on_main(WAIT, async move { + tablepro_core::credentials::SecretVault::store( + &*vault, + id, + tablepro_core::credentials::SecretKind::DatabasePassword, + &secrecy::SecretString::from("s3cret"), + "label", + ) + .await + }) + .expect("the store to end") + .expect("the password stored"); + test.window + .send(SessionWindowInput::OpenSaved(Box::new(test.saved.clone()))); + test.wait_for_page("workspace"); + let sibling = tablepro_session::SessionKey::SavedIn { + id: test.saved.id, + database: "analytics".to_owned(), + }; + + test.window.open_database(test.saved.id, "analytics".to_owned()); + + wait_until(WAIT, || test.window.held_session(&sibling).is_some()).expect("the database to open"); + let session = test.window.held_session(&sibling).expect("the sibling"); + assert_eq!(session.database(), Some("analytics")); + let calls = test.connector.calls(); + let opened = calls.last().expect("a connect for the database"); + assert!(!opened.attended, "opening the database asked for a secret"); + + test.window.open_database(test.saved.id, "analytics".to_owned()); + drain_main_context(); + assert_eq!( + test.connector.calls().len(), + calls.len(), + "the open database opened again" + ); + + test.window.send(SessionWindowInput::DisconnectSession( + tablepro_session::SessionKey::Saved(test.saved.id), + )); + wait_until(WAIT, || test.window.held_session(&sibling).is_none()).expect("the database to go with it"); +} 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 e8f47f3a36..e9c02cf709 100644 --- a/linux/crates/app/src/ui/session_window/window_actions.rs +++ b/linux/crates/app/src/ui/session_window/window_actions.rs @@ -44,12 +44,13 @@ const ACTIONS: [(&str, ActionInput); 11] = [ /// The actions a connection tree row or a toast fires for its own /// session, which take the session as their target (`session_target`). -const SESSION_ACTIONS: [(&str, SessionInput); 7] = [ +const SESSION_ACTIONS: [(&str, SessionInput); 8] = [ ("show-session", SessionWindowInput::ShowSession), ("disconnect-session", SessionWindowInput::DisconnectSession), ("stop-connecting", SessionWindowInput::StopConnecting), ("new-query-in", SessionWindowInput::NewQueryIn), ("refresh-in", SessionWindowInput::RefreshIn), + ("open-database-in", SessionWindowInput::ChooseDatabaseIn), ("show-connect-failure", SessionWindowInput::ShowConnectFailure), ("forget-connect-failure", SessionWindowInput::ForgetConnectFailure), ]; diff --git a/linux/crates/app/src/ui/workspace/assistant.rs b/linux/crates/app/src/ui/workspace/assistant.rs index a4c788ba03..9bff22c1e6 100644 --- a/linux/crates/app/src/ui/workspace/assistant.rs +++ b/linux/crates/app/src/ui/workspace/assistant.rs @@ -39,7 +39,9 @@ pub(super) fn access( let saved = services.saved_connections.clone(); let key = key.clone(); AssistantAccess::new(services.ai.clone(), session, engine, move || match &key { - SessionKey::Saved(id) => saved.get(*id).map_or_else(AiPolicy::default, |saved| saved.ai_policy), + SessionKey::Saved(id) | SessionKey::SavedIn { id, .. } => { + saved.get(*id).map_or_else(AiPolicy::default, |saved| saved.ai_policy) + } SessionKey::File(_) => AiPolicy::default(), }) } diff --git a/linux/crates/app/src/ui/workspace/mod.rs b/linux/crates/app/src/ui/workspace/mod.rs index 200a2f17ad..517b92836f 100644 --- a/linux/crates/app/src/ui/workspace/mod.rs +++ b/linux/crates/app/src/ui/workspace/mod.rs @@ -1097,7 +1097,9 @@ impl WorkspaceModel { workspace.notify_connection_name(); } let icon = match &self.key { - SessionKey::Saved(id) => self.saved_connections.get(*id).and_then(|saved| saved.icon), + SessionKey::Saved(id) | SessionKey::SavedIn { id, .. } => { + self.saved_connections.get(*id).and_then(|saved| saved.icon) + } SessionKey::File(_) => None, } .map(|icon| crate::ui::icons::connection_icon(icon).to_owned()); diff --git a/linux/crates/app/src/ui/workspace/sidebar/connection_tree.rs b/linux/crates/app/src/ui/workspace/sidebar/connection_tree.rs index e761590ba1..b64d04b4e4 100644 --- a/linux/crates/app/src/ui/workspace/sidebar/connection_tree.rs +++ b/linux/crates/app/src/ui/workspace/sidebar/connection_tree.rs @@ -425,10 +425,28 @@ impl TreeInner { fn saved_name(&self, key: &SessionKey) -> Option { match key { SessionKey::Saved(id) => self.saved.get(*id).map(|saved| saved.name), + SessionKey::SavedIn { id, database } => { + self.saved.get(*id).map(|saved| sibling_title(&saved.name, database)) + } SessionKey::File(_) => None, } } + /// The open databases of the connection `id` beside its own, by name. + fn siblings_of(&self, id: uuid::Uuid) -> Vec<(SessionKey, String)> { + let mut siblings: Vec<(SessionKey, String)> = self + .branches + .borrow() + .keys() + .filter_map(|key| match key { + SessionKey::SavedIn { id: of, database } if *of == id => Some((key.clone(), database.clone())), + _ => None, + }) + .collect(); + siblings.sort_by_cached_key(|(_, database)| (database.to_lowercase(), database.clone())); + siblings + } + /// The node of the connection under `key`, made under `title` for a /// key the tree has not seen. fn node_for(&self, key: &SessionKey, title: &str) -> TreeNode { @@ -485,11 +503,25 @@ impl TreeInner { saved.color.map_or("", crate::ui::connection_appearance::color_class), ); kept.insert(key); + // The connection's other open databases come right after it, + // in its group, by name ([0030]). + let mut rows = vec![node]; + for (sibling, database) in self.siblings_of(saved.id) { + let title = sibling_title(&saved.name, &database); + let node = self.node_for(&sibling, &title); + node.update( + &title, + crate::ui::icons::DRIVE_HARDDISK, + saved.color.map_or("", crate::ui::connection_appearance::color_class), + ); + kept.insert(sibling); + rows.push(node); + } match &saved.group { - None => ungrouped.push(node), + None => ungrouped.extend(rows), Some(group) => match grouped.last_mut() { - Some((name, members)) if *name == group.as_str() => members.push(node), - _ => grouped.push((group.as_str().to_owned(), vec![node])), + Some((name, members)) if *name == group.as_str() => members.extend(rows), + _ => grouped.push((group.as_str().to_owned(), rows)), }, } } @@ -787,7 +819,7 @@ impl TreeInner { Some(saved) => search.matches(&saved, &self.saved.tags()), None => search.matches_name(&node.title()), }, - SessionKey::File(_) => search.matches_name(&node.title()), + SessionKey::SavedIn { .. } | SessionKey::File(_) => search.matches_name(&node.title()), } } @@ -1076,7 +1108,8 @@ impl TreeInner { fn stored(&self, node: &TreeNode) -> Option { match node.key()? { SessionKey::Saved(id) => self.saved.get(id).map(|_| id), - SessionKey::File(_) => None, + // A database's row is not the connection's: it has no editing. + SessionKey::SavedIn { .. } | SessionKey::File(_) => None, } } @@ -1097,7 +1130,7 @@ impl TreeInner { let lead = session_section(&key, state); let saved = match &key { SessionKey::Saved(id) => self.saved.get(*id), - SessionKey::File(_) => None, + SessionKey::SavedIn { .. } | SessionKey::File(_) => None, }; let Some(saved) = saved else { if lead.n_items() == 0 { @@ -1505,3 +1538,11 @@ fn index_in(model: &impl IsA, object: &impl IsA) - let object = object.upcast_ref::(); (0..model.n_items()).find(|&at| model.item(at).as_ref() == Some(object)) } + +/// A sibling database's row: its connection and the database, apart. +fn sibling_title(connection: &str, database: &str) -> String { + crate::i18n::gettext_f( + "{connection} · {database}", + &[("connection", connection), ("database", database)], + ) +} diff --git a/linux/crates/app/src/ui/workspace/sidebar/tree_menu.rs b/linux/crates/app/src/ui/workspace/sidebar/tree_menu.rs index ee1cf92e37..27db2c3563 100644 --- a/linux/crates/app/src/ui/workspace/sidebar/tree_menu.rs +++ b/linux/crates/app/src/ui/workspace/sidebar/tree_menu.rs @@ -6,6 +6,7 @@ use tablepro_session::SessionKey; pub(super) const SHOW_SESSION: &str = "win.show-session"; const NEW_QUERY_IN: &str = "win.new-query-in"; const REFRESH_IN: &str = "win.refresh-in"; +const OPEN_DATABASE_IN: &str = "win.open-database-in"; pub(super) const DISCONNECT_SESSION: &str = "win.disconnect-session"; const STOP_CONNECTING: &str = "win.stop-connecting"; pub(super) const SHOW_CONNECT_FAILURE: &str = "win.show-connect-failure"; @@ -39,6 +40,10 @@ pub(super) fn session_section(key: &SessionKey, state: InWindow) -> gio::Menu { InWindow::Open => { add(crate::i18n::gettext("New Query"), NEW_QUERY_IN); add(crate::i18n::gettext("Refresh"), REFRESH_IN); + // Another of the server's databases, beside this one (0030). + if let SessionKey::Saved(_) = key { + add(crate::i18n::gettext("Open Database…"), OPEN_DATABASE_IN); + } add(crate::i18n::gettext("Disconnect"), DISCONNECT_SESSION); } // A connect can wait for a host that never answers. diff --git a/linux/crates/app/src/ui/workspace/tab_title.rs b/linux/crates/app/src/ui/workspace/tab_title.rs index 12f4ea8c75..8be27e9d79 100644 --- a/linux/crates/app/src/ui/workspace/tab_title.rs +++ b/linux/crates/app/src/ui/workspace/tab_title.rs @@ -45,6 +45,14 @@ impl ConnectionLabel { pub(crate) fn name(&self) -> String { let saved = match &self.key { SessionKey::Saved(id) => self.saved.get(*id).map(|saved| saved.name), + // Another database of the connection names both, as + // "orders (prod, analytics)" (0030). + SessionKey::SavedIn { id, database } => self.saved.get(*id).map(|saved| { + crate::i18n::gettext_f( + "{connection}, {database}", + &[("connection", &saved.name), ("database", database)], + ) + }), SessionKey::File(_) => None, }; saved diff --git a/linux/crates/app/src/workspace/session_key_record.rs b/linux/crates/app/src/workspace/session_key_record.rs index e9ccb5fcdb..6ed97082c1 100644 --- a/linux/crates/app/src/workspace/session_key_record.rs +++ b/linux/crates/app/src/workspace/session_key_record.rs @@ -7,6 +7,7 @@ use uuid::Uuid; #[serde(tag = "kind", rename_all = "snake_case")] pub enum SessionKeyRecord { Saved { id: Uuid }, + SavedIn { id: Uuid, database: String }, File { uri: String }, } @@ -14,6 +15,10 @@ impl From<&SessionKey> for SessionKeyRecord { fn from(key: &SessionKey) -> Self { match key { SessionKey::Saved(id) => Self::Saved { id: *id }, + SessionKey::SavedIn { id, database } => Self::SavedIn { + id: *id, + database: database.clone(), + }, SessionKey::File(uri) => Self::File { uri: uri.clone() }, } } @@ -23,6 +28,7 @@ impl SessionKeyRecord { pub fn into_key(self) -> SessionKey { match self { Self::Saved { id } => SessionKey::Saved(id), + Self::SavedIn { id, database } => SessionKey::SavedIn { id, database }, Self::File { uri } => SessionKey::File(uri), } } @@ -36,6 +42,10 @@ mod tests { fn each_key_comes_back_as_it_went_in() { for key in [ SessionKey::Saved(Uuid::new_v4()), + SessionKey::SavedIn { + id: Uuid::new_v4(), + database: "analytics".to_owned(), + }, SessionKey::File("file:///srv/app.db".to_owned()), ] { assert_eq!(SessionKeyRecord::from(&key).into_key(), key); diff --git a/linux/crates/app/src/workspace/workspace_file_name.rs b/linux/crates/app/src/workspace/workspace_file_name.rs index 79127d7327..43219ae0c4 100644 --- a/linux/crates/app/src/workspace/workspace_file_name.rs +++ b/linux/crates/app/src/workspace/workspace_file_name.rs @@ -15,6 +15,13 @@ impl WorkspaceFileName { pub fn for_key(key: &SessionKey) -> Self { let stem = match key { SessionKey::Saved(id) => id.to_string(), + // A database name can hold a slash, so it goes in as a digest. + SessionKey::SavedIn { id, database } => { + let digest = glib::compute_checksum_for_string(glib::ChecksumType::Sha256, database.as_str()) + .map(|digest| digest.to_string()) + .unwrap_or_default(); + format!("{id}-database-{digest}") + } SessionKey::File(uri) => { // GLib returns no digest only for a checksum type it // does not know, which SHA-256 is not. diff --git a/linux/crates/session/src/registry/session_key.rs b/linux/crates/session/src/registry/session_key.rs index 6637a44f11..0576d05b70 100644 --- a/linux/crates/session/src/registry/session_key.rs +++ b/linux/crates/session/src/registry/session_key.rs @@ -18,7 +18,10 @@ pub enum SessionKey { /// window and never another's. /// /// [0030]: ../../../docs/decisions/0030-a-database-is-a-session-of-its-own.md - SavedIn { id: Uuid, database: String }, + SavedIn { + id: Uuid, + database: String, + }, /// The canonical file URI when there is one, else the URI the file /// arrived as. File(String), diff --git a/linux/po/POTFILES.in b/linux/po/POTFILES.in index f07321e92c..ec4e606349 100644 --- a/linux/po/POTFILES.in +++ b/linux/po/POTFILES.in @@ -482,6 +482,7 @@ crates/app/src/ui/session_window/session_restore.rs crates/app/src/ui/session_window/session_teardown.rs crates/app/src/ui/session_window/session_window_command.rs crates/app/src/ui/session_window/session_window_input.rs +crates/app/src/ui/session_window/sibling_sessions.rs crates/app/src/ui/session_window/tests/mod.rs crates/app/src/ui/session_window/tests/notification.rs crates/app/src/ui/session_window/tests/open_file.rs From e08f5180618b815780a39dd35198b4a991417327 Mon Sep 17 00:00:00 2001 From: sedax90 <5001801+sedax90@users.noreply.github.com> Date: Fri, 30 Oct 2026 10:58:55 +0100 Subject: [PATCH 14/53] docs(linux-workspace): add the changelog entry --- linux/changelog.d/3902.added.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 linux/changelog.d/3902.added.md diff --git a/linux/changelog.d/3902.added.md b/linux/changelog.d/3902.added.md new file mode 100644 index 0000000000..84a39c3c09 --- /dev/null +++ b/linux/changelog.d/3902.added.md @@ -0,0 +1 @@ +Open Database… on a connection's row opens another of the server's databases beside it in the window, with its own tables and tabs. From 0def6aabbd47e69e280ef7c8cdd3a08c8f965893 Mon Sep 17 00:00:00 2001 From: sedax90 <5001801+sedax90@users.noreply.github.com> Date: Fri, 30 Oct 2026 11:06:41 +0100 Subject: [PATCH 15/53] feat(linux-editor): take a query tab moved to another database to that database's session --- .../completion/sql_completion_provider.rs | 16 ++++- .../completion/sql_completion_provider_imp.rs | 3 + linux/crates/app/src/ui/editor/imp.rs | 5 ++ linux/crates/app/src/ui/editor/mod.rs | 8 +++ linux/crates/app/src/ui/editor/tab_scope.rs | 11 ++++ linux/crates/app/src/ui/editor/tests.rs | 4 ++ linux/crates/app/src/ui/session_window/mod.rs | 3 + .../ui/session_window/session_window_input.rs | 7 +++ .../src/ui/session_window/sibling_sessions.rs | 59 ++++++++++++++++++- .../src/ui/session_window/tests/sessions.rs | 55 +++++++++++++++++ linux/crates/app/src/ui/workspace/mod.rs | 34 +++++++++++ .../app/src/ui/workspace/tab_factory.rs | 4 ++ 12 files changed, 206 insertions(+), 3 deletions(-) diff --git a/linux/crates/app/src/ui/editor/completion/sql_completion_provider.rs b/linux/crates/app/src/ui/editor/completion/sql_completion_provider.rs index 0d17dd37bc..9329bf5814 100644 --- a/linux/crates/app/src/ui/editor/completion/sql_completion_provider.rs +++ b/linux/crates/app/src/ui/editor/completion/sql_completion_provider.rs @@ -44,6 +44,12 @@ pub struct ProviderDeps { } impl SqlCompletionProvider { + /// Read the catalog of `session`, the tab's database's, rather than + /// the connection's. Names resolve in that session's default schema. + pub fn set_catalog_session(&self, session: Option>) { + self.imp().catalog_session.replace(session); + } + /// Resolve unqualified names in `database`, the tab's own when the /// editor bar moved it off the connection's. pub fn set_home(&self, database: Option) { @@ -73,7 +79,15 @@ impl SqlCompletionProvider { let ranking = match (request.session.clone(), tasks.clone()) { (Some(session), Some(tasks)) if request.grammar.is_sql() => { let fallback = request.clone(); - let home = self.imp().home.borrow().clone(); + // The tab's database's own session resolves its names in + // its default schema; until it opens, the home database + // stands in on the connection's catalog. + let followed = self.imp().catalog_session.borrow().clone(); + let home = match followed { + Some(_) => None, + None => self.imp().home.borrow().clone(), + }; + let session = followed.unwrap_or(session); let ranked_for = needle.clone(); let blocking = tasks.clone(); tasks diff --git a/linux/crates/app/src/ui/editor/completion/sql_completion_provider_imp.rs b/linux/crates/app/src/ui/editor/completion/sql_completion_provider_imp.rs index ea5265cdcd..a9b125f424 100644 --- a/linux/crates/app/src/ui/editor/completion/sql_completion_provider_imp.rs +++ b/linux/crates/app/src/ui/editor/completion/sql_completion_provider_imp.rs @@ -21,6 +21,9 @@ pub struct SqlCompletionProvider { pub deps: OnceCell, /// The database the tab was moved to, where unqualified names resolve. pub home: std::cell::RefCell>, + /// The session of the tab's database, whose catalog completion reads + /// instead of the window's connection's. + pub catalog_session: std::cell::RefCell>>, } #[glib::object_subclass] diff --git a/linux/crates/app/src/ui/editor/imp.rs b/linux/crates/app/src/ui/editor/imp.rs index 19a4382fc7..e5057bd42b 100644 --- a/linux/crates/app/src/ui/editor/imp.rs +++ b/linux/crates/app/src/ui/editor/imp.rs @@ -142,6 +142,11 @@ impl ObjectImpl for SqlEditor { .param_types([bool::static_type()]) .build(), Signal::builder("file-changed").build(), + // The editor bar moved the tab to another database, named + // here, or back to the connection's own with "". + Signal::builder("database-moved") + .param_types([String::static_type()]) + .build(), // Vim's :q, which closes the tab through its close prompts. Signal::builder("close-requested").build(), Signal::builder("assistant-requested") diff --git a/linux/crates/app/src/ui/editor/mod.rs b/linux/crates/app/src/ui/editor/mod.rs index 15c3a498fc..6e6870cc2c 100644 --- a/linux/crates/app/src/ui/editor/mod.rs +++ b/linux/crates/app/src/ui/editor/mod.rs @@ -359,6 +359,14 @@ impl SqlEditor { ) } + pub fn connect_database_moved(&self, f: F) -> glib::SignalHandlerId { + self.connect_closure( + "database-moved", + false, + glib::closure_local!(move |editor: &Self, database: String| f(editor, database)), + ) + } + pub fn connect_toast(&self, f: F) -> glib::SignalHandlerId { self.connect_closure( "toast", diff --git a/linux/crates/app/src/ui/editor/tab_scope.rs b/linux/crates/app/src/ui/editor/tab_scope.rs index 9993e8ef33..ca014ff2bc 100644 --- a/linux/crates/app/src/ui/editor/tab_scope.rs +++ b/linux/crates/app/src/ui/editor/tab_scope.rs @@ -211,6 +211,17 @@ impl SqlEditor { } } self.set_scope(scope_text(Some(name), None)); + self.emit_by_name::<()>("database-moved", &[&name.to_owned()]); + } + + /// Completion reads `session`'s catalog: the session the window holds + /// for the tab's database, once it has opened (0030). + pub(crate) fn follow_catalog(&self, session: Option>) { + for provider in self.imp().completion_providers.borrow().iter() { + if let Some(catalog) = provider.downcast_ref::() { + catalog.set_catalog_session(session.clone()); + } + } } /// The database the editor bar moved the tab to, which a restore diff --git a/linux/crates/app/src/ui/editor/tests.rs b/linux/crates/app/src/ui/editor/tests.rs index 1a42688a49..368bc05a4d 100644 --- a/linux/crates/app/src/ui/editor/tests.rs +++ b/linux/crates/app/src/ui/editor/tests.rs @@ -2723,8 +2723,12 @@ fn picking_a_database_moves_the_tab_and_its_next_run() { let button = editor.imp().scope_button.get(); crate::test_support::wait_until(Duration::from_secs(5), || button.is_visible()).expect("the database menu"); + let moved = Rc::new(std::cell::RefCell::new(Vec::new())); + let heard = moved.clone(); + editor.connect_database_moved(move |_, database| heard.borrow_mut().push(database)); WidgetExt::activate_action(&editor, "tab.database", Some(&"analytics".to_variant())).expect("tab.database"); assert_eq!(editor.imp().scope_button_label.label(), "analytics"); + assert_eq!(*moved.borrow(), ["analytics"], "the window was not told"); let harness = Harness { editor, diff --git a/linux/crates/app/src/ui/session_window/mod.rs b/linux/crates/app/src/ui/session_window/mod.rs index ec0386114c..cfc568ee5e 100644 --- a/linux/crates/app/src/ui/session_window/mod.rs +++ b/linux/crates/app/src/ui/session_window/mod.rs @@ -467,6 +467,9 @@ impl SessionWindow { SessionWindowInput::Disconnect => self.on_disconnect_front(), SessionWindowInput::DisconnectSession(key) => self.on_disconnect(key), SessionWindowInput::ChooseDatabaseIn(key) => self.choose_database_in(key), + SessionWindowInput::FollowDatabase { key, tab_id, database } => { + self.follow_database(&key, tab_id, database); + } SessionWindowInput::SessionTeardownApproved(key) => self.on_session_teardown_approved(&key), SessionWindowInput::ShowSession(key) => self.show_session(&key), SessionWindowInput::NewQueryIn(key) => self.deliver_to(&key, WorkspaceInput::NewEditorTab), 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 78346ef6d2..681cac993f 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 @@ -72,6 +72,13 @@ pub(crate) enum SessionWindowInput { /// Open Database… on a connection's row: pick another of its server's /// databases to open beside it. ChooseDatabaseIn(SessionKey), + /// An editor tab of the session under `key` moved to `database`, or + /// back to the connection's own with "" (0030). + FollowDatabase { + key: SessionKey, + tab_id: uuid::Uuid, + database: String, + }, /// The prompts approved closing the session under the key. SessionTeardownApproved(SessionKey), /// Bring the session under the key to the front. diff --git a/linux/crates/app/src/ui/session_window/sibling_sessions.rs b/linux/crates/app/src/ui/session_window/sibling_sessions.rs index d769117bc2..44fb175d2f 100644 --- a/linux/crates/app/src/ui/session_window/sibling_sessions.rs +++ b/linux/crates/app/src/ui/session_window/sibling_sessions.rs @@ -19,7 +19,9 @@ use uuid::Uuid; use super::SessionWindow; use crate::i18n::{gettext, gettext_f}; +use crate::runtime::DeliverInput; use crate::ui::error_text::{connect_error_message, operation_error_message}; +use crate::ui::workspace::WorkspaceInput; /// The response of the chooser that opens the database picked. const OPEN: &str = "open"; @@ -41,11 +43,23 @@ impl SessionWindow { /// already is left as it is; so is a connection this window does not /// hold, whose window opens its own. pub(crate) fn open_database(&self, id: Uuid, database: String) { + self.open_database_then(id, database, |_, _| {}); + } + + /// Open the database as `open_database` does, then call `then` with + /// its session, at once when it is open already. + fn open_database_then( + &self, + id: Uuid, + database: String, + then: impl FnOnce(&SessionWindow, Arc) + 'static, + ) { let key = SessionKey::SavedIn { id, database: database.clone(), }; - if self.session_workspace(&key).is_some() { + if let Some(session) = self.held_session(&key) { + then(self, session); return; } let (Some(origin), Some(state)) = (self.held_session(&SessionKey::Saved(id)), self.state()) else { @@ -71,7 +85,10 @@ impl SessionWindow { return; }; match opened { - Ok(Ok(session)) => window.join(claim, session, &[]), + Ok(Ok(session)) => { + window.join(claim, session.clone(), &[]); + then(&window, session); + } Ok(Err(error)) => window.show_toast(&gettext_f( "Could not open the database “{database}”: {error}", &[("database", &database), ("error", &connect_error_message(&error))], @@ -159,6 +176,44 @@ impl SessionWindow { dialog.present(Some(self)); } + /// An editor tab of the session under `key` moved to `database`: open + /// or reach that database's session, hand it to the tab's completion, + /// and select its row, as the macOS window follows the tab (0030). Back + /// on the connection's own database the tab reads the connection's. + pub(super) fn follow_database(&self, key: &SessionKey, tab_id: Uuid, database: String) { + let SessionKey::Saved(id) = *key else { + return; + }; + let Some(origin) = self.held_session(key) else { + return; + }; + if database.is_empty() || origin.database() == Some(database.as_str()) { + self.hand_over(id, tab_id, None, key); + return; + } + let row = SessionKey::SavedIn { + id, + database: database.clone(), + }; + self.open_database_then(id, database, move |window, session| { + window.hand_over(id, tab_id, Some(session), &row); + }); + } + + /// Give the connection `id`'s editor tab `tab_id` the catalog of + /// `session`, and select `row` in the sidebar. + fn hand_over(&self, id: Uuid, tab_id: Uuid, session: Option>, row: &SessionKey) { + if let Some(sender) = self + .session_workspace(&SessionKey::Saved(id)) + .and_then(|workspace| workspace.sender()) + { + sender.deliver(WorkspaceInput::FollowCatalog { tab_id, session }); + } + if let Some(tree) = self.shell().and_then(|shell| shell.tree()) { + tree.select(Some(row), None); + } + } + /// The keys of the connection `id`'s other open databases. pub(super) fn siblings_of(&self, id: Uuid) -> Vec { self.imp() diff --git a/linux/crates/app/src/ui/session_window/tests/sessions.rs b/linux/crates/app/src/ui/session_window/tests/sessions.rs index 7a76cbd25f..772a69463a 100644 --- a/linux/crates/app/src/ui/session_window/tests/sessions.rs +++ b/linux/crates/app/src/ui/session_window/tests/sessions.rs @@ -676,3 +676,58 @@ fn another_database_opens_beside_the_connection_and_goes_with_it() { )); wait_until(WAIT, || test.window.held_session(&sibling).is_none()).expect("the database to go with it"); } + +/// An editor tab moved to another database brings that database's session +/// into the window for the tab to read its catalog; one moved back to the +/// connection's own opens nothing (0030). +#[gtk4::test] +fn an_editor_tab_moved_to_a_database_opens_its_session() { + let test = TestWindow::with_script( + "FollowDatabase", + Script { + network: true, + ..Script::default() + }, + ); + let (vault, id) = (test.vault.clone(), test.saved.id); + crate::test_support::finish_on_main(WAIT, async move { + tablepro_core::credentials::SecretVault::store( + &*vault, + id, + tablepro_core::credentials::SecretKind::DatabasePassword, + &secrecy::SecretString::from("s3cret"), + "label", + ) + .await + }) + .expect("the store to end") + .expect("the password stored"); + test.window + .send(SessionWindowInput::OpenSaved(Box::new(test.saved.clone()))); + test.wait_for_page("workspace"); + let connection = SessionKey::Saved(test.saved.id); + let before = test.connector.calls().len(); + + test.window.send(SessionWindowInput::FollowDatabase { + key: connection.clone(), + tab_id: uuid::Uuid::new_v4(), + database: String::new(), + }); + drain_main_context(); + assert_eq!( + test.connector.calls().len(), + before, + "the connection's own database opened again" + ); + + test.window.send(SessionWindowInput::FollowDatabase { + key: connection, + tab_id: uuid::Uuid::new_v4(), + database: "analytics".to_owned(), + }); + let sibling = SessionKey::SavedIn { + id: test.saved.id, + database: "analytics".to_owned(), + }; + wait_until(WAIT, || test.window.held_session(&sibling).is_some()).expect("the database's session"); +} diff --git a/linux/crates/app/src/ui/workspace/mod.rs b/linux/crates/app/src/ui/workspace/mod.rs index 517b92836f..61fd935a90 100644 --- a/linux/crates/app/src/ui/workspace/mod.rs +++ b/linux/crates/app/src/ui/workspace/mod.rs @@ -73,6 +73,7 @@ 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::ui::SessionWindowInput; use crate::workspace::{ApplicationInhibit, CloseBlocker, CloseGuards, WindowInhibitor, WorkspaceHandle}; glib::wrapper! { @@ -567,6 +568,18 @@ pub(crate) enum WorkspaceInput { body: String, }, ShowToast(String), + /// An editor tab's bar moved it to `database`, or back to the + /// connection's own with "": the window follows it there (0030). + EditorDatabaseMoved { + tab_id: Uuid, + database: String, + }, + /// Completion in the editor tab reads `session`'s catalog, the tab's + /// database's, or the connection's again with `None`. + FollowCatalog { + tab_id: Uuid, + session: Option>, + }, /// A table or structure tab gained or lost unsaved changes. TabDirtyChanged, NewTableTab { @@ -1039,6 +1052,21 @@ fn replace_text(cell: &RefCell, text: &str) -> bool { } impl WorkspaceModel { + /// Ask the window to reach the session of the database an editor tab + /// moved to, and to hand it back for the tab's completion. + fn follow_editor_database(&self, tab_id: Uuid, database: String) { + let Some(shell) = self.shell.upgrade() else { + return; + }; + if let Some(sender) = shell.imp().sender.get() { + sender.deliver(SessionWindowInput::FollowDatabase { + key: self.key.clone(), + tab_id, + database, + }); + } + } + /// The window this workspace is in, for dialogs to sit over. pub(super) fn window(&self) -> Option { self.window.upgrade() @@ -1242,6 +1270,12 @@ impl WorkspaceModel { 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::EditorDatabaseMoved { tab_id, database } => self.follow_editor_database(tab_id, database), + WorkspaceInput::FollowCatalog { tab_id, session } => { + if let Some(WorkspaceTab::Editor(slot)) = self.workspace_tabs.borrow().get(&tab_id) { + slot.editor.follow_catalog(session); + } + } WorkspaceInput::TabDirtyChanged => self.refresh_unsaved(), WorkspaceInput::NewTableTab { schema } => self.on_new_table_tab(schema), WorkspaceInput::ShowDiagram { schema } => self.show_diagram(schema, Vec::new()), diff --git a/linux/crates/app/src/ui/workspace/tab_factory.rs b/linux/crates/app/src/ui/workspace/tab_factory.rs index 533ae82559..21c6d930b6 100644 --- a/linux/crates/app/src/ui/workspace/tab_factory.rs +++ b/linux/crates/app/src/ui/workspace/tab_factory.rs @@ -500,6 +500,10 @@ pub(super) fn connect_editor( editor.connect_transaction_changed(move |_, _| transaction.deliver(WorkspaceInput::EditorTransactionChanged)); let toast = input.clone(); editor.connect_toast(move |_, text| toast.deliver(WorkspaceInput::ShowToast(text))); + let moved = input.clone(); + editor.connect_database_moved(move |_, database| { + moved.deliver(WorkspaceInput::EditorDatabaseMoved { tab_id, database }); + }); let close = input.clone(); editor.connect_close_requested(move |_| close.deliver(WorkspaceInput::CloseWorkspaceTab(tab_id))); let assistant = input.clone(); From 2c528e7550affa5e344cc34ad3ad228a01f96bf6 Mon Sep 17 00:00:00 2001 From: sedax90 <5001801+sedax90@users.noreply.github.com> Date: Fri, 30 Oct 2026 11:07:20 +0100 Subject: [PATCH 16/53] docs(linux-editor): add the changelog entry --- linux/changelog.d/3903.added.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 linux/changelog.d/3903.added.md diff --git a/linux/changelog.d/3903.added.md b/linux/changelog.d/3903.added.md new file mode 100644 index 0000000000..e195e088d8 --- /dev/null +++ b/linux/changelog.d/3903.added.md @@ -0,0 +1 @@ +A query tab moved to another database completes from that database's tables, which opens beside the connection and is selected in the sidebar. From 0c8f034f779c9a68838370c80435cc1c2857c517 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 30 Oct 2026 17:15:54 +0700 Subject: [PATCH 17/53] fix(linux-editor): name the database a tab's session is in, and only where a connection reaches several --- linux/changelog.d/3896.added.md | 2 +- .../app/src/ui/editor/editor_session_slot.rs | 5 + linux/crates/app/src/ui/editor/mod.rs | 1 + linux/crates/app/src/ui/editor/tab_scope.rs | 101 ++++++++++-------- linux/crates/app/src/ui/editor/tests.rs | 100 +++++++++++++---- .../core/src/dialect/database_layout.rs | 13 +++ linux/crates/core/src/dialect/mod.rs | 2 + linux/crates/core/src/dialect/sql_dialect.rs | 8 +- linux/crates/core/src/editor_session.rs | 7 ++ .../core/src/sql_syntax/catalog_change.rs | 28 +++++ linux/crates/core/src/sql_syntax/mod.rs | 2 +- linux/crates/drivers/cassandra/src/dialect.rs | 8 +- linux/crates/drivers/cassandra/src/editor.rs | 6 ++ .../crates/drivers/clickhouse/src/dialect.rs | 8 +- .../drivers/clickhouse/src/editor_session.rs | 6 ++ linux/crates/drivers/mongodb/src/dialect.rs | 8 +- .../drivers/mongodb/src/editor_session.rs | 4 + linux/crates/drivers/mssql/src/dialect.rs | 8 +- linux/crates/drivers/mssql/src/editor_run.rs | 10 ++ .../drivers/mssql/src/editor_session.rs | 26 ++++- .../mssql/tests/integration/sessions.rs | 5 + linux/crates/drivers/mysql/src/dialect.rs | 8 +- .../drivers/mysql/src/editor_session.rs | 34 +++++- .../mysql/tests/integration/editor_session.rs | 29 +++++ linux/crates/drivers/postgres/src/dialect.rs | 7 +- .../test-support/src/fake_editor_session.rs | 11 ++ linux/data/resources/ui/sql-editor.ui | 2 - 27 files changed, 363 insertions(+), 86 deletions(-) create mode 100644 linux/crates/core/src/dialect/database_layout.rs diff --git a/linux/changelog.d/3896.added.md b/linux/changelog.d/3896.added.md index 207d0fefd8..325194bc2f 100644 --- a/linux/changelog.d/3896.added.md +++ b/linux/changelog.d/3896.added.md @@ -1 +1 @@ -The SQL editor's bar names the database and schema the tab works on. +The SQL editor's bar names the database the tab's statements run in, and its schema where a database holds schemas. diff --git a/linux/crates/app/src/ui/editor/editor_session_slot.rs b/linux/crates/app/src/ui/editor/editor_session_slot.rs index 5e18a149e8..58d5019343 100644 --- a/linux/crates/app/src/ui/editor/editor_session_slot.rs +++ b/linux/crates/app/src/ui/editor/editor_session_slot.rs @@ -139,6 +139,11 @@ impl EditorSessionSlot { } } + /// Where the tab's session says it is, once it says. + pub fn database(&self) -> Option { + lock(&self.state).open.as_ref().and_then(|open| open.editor.database()) + } + /// Where the transaction of `editor` stood after its last run. pub fn transaction(&self, editor: &Arc) -> Option { let state = lock(&self.state); diff --git a/linux/crates/app/src/ui/editor/mod.rs b/linux/crates/app/src/ui/editor/mod.rs index 15c3a498fc..d994114567 100644 --- a/linux/crates/app/src/ui/editor/mod.rs +++ b/linux/crates/app/src/ui/editor/mod.rs @@ -691,6 +691,7 @@ impl SqlEditor { self.emit_by_name::<()>("transaction-changed", &[&dirty]); } self.record_history(entry, dialect); + self.show_scope(); self.emit_by_name::<()>("run-finished", &[&outcome]); self.emit_by_name::<()>("run-state-changed", &[&false]); } diff --git a/linux/crates/app/src/ui/editor/tab_scope.rs b/linux/crates/app/src/ui/editor/tab_scope.rs index 752b9c22d3..e4789ef638 100644 --- a/linux/crates/app/src/ui/editor/tab_scope.rs +++ b/linux/crates/app/src/ui/editor/tab_scope.rs @@ -1,36 +1,32 @@ -//! The database the tab works on, named in the editor bar as the macOS -//! editor names it (`QueryContainerPicker`): the database, then the schema -//! an unqualified name resolves to where the engine nests schemas in a -//! database. A file's name is already in the window title and the sidebar. +//! The database the tab works on, named in the editor bar. use gtk4::glib; use gtk4::prelude::*; use gtk4::subclass::prelude::*; +use tablepro_core::dialect::DatabaseLayout; +use tablepro_session::Session; use super::SqlEditor; use crate::i18n::gettext_f; -/// The bar's text, dot-qualified as SQL and the sidebar spell a qualified -/// object, and what a screen reader says for it. `None` with no database. -pub(super) fn scope_text(database: Option<&str>, default_schema: Option<&str>) -> Option<(String, String)> { - let database = database.filter(|database| !database.is_empty())?; - // Where the engine's schemas are its databases, as on MySQL, the - // default one is the database itself and adds nothing. - match default_schema.filter(|schema| !schema.is_empty() && *schema != database) { - Some(schema) => Some(( +/// The bar's text, dot-qualified as SQL spells a qualified name, and what +/// a screen reader says for it. +pub(super) fn scope_text(database: &str, schema: Option<&str>) -> (String, String) { + match schema.filter(|schema| !schema.is_empty()) { + Some(schema) => ( format!("{database}.{schema}"), gettext_f( "{database}, schema {schema}", &[("database", database), ("schema", schema)], ), - )), - None => Some((database.to_owned(), database.to_owned())), + ), + None => (database.to_owned(), database.to_owned()), } } impl SqlEditor { - /// Name the tab's database now, and its schema once the catalog has - /// listed the schemas. + /// Name where the tab's next statement runs and, on an engine whose + /// databases hold schemas, the schema completion resolves names in. pub(super) fn show_scope(&self) { let Some((session, tasks)) = self .deps() @@ -39,29 +35,55 @@ impl SqlEditor { self.set_scope(None); return; }; - let database = session.database().map(str::to_owned); - self.set_scope(scope_text(database.as_deref(), None)); - if database.is_none() { + let Some((database, layout)) = self.scope_database(&session) else { + self.set_scope(None); + return; + }; + self.set_scope(Some(scope_text(&database, None))); + // The catalog describes the connection's own database only. + if layout != DatabaseLayout::WithSchemas || session.database() != Some(database.as_str()) { return; } - // The catalog reads on the runtime, as completion's reads do. - let reading = tasks.spawn_task(async move { - session.schemas().await.ok().and_then(|schemas| { - schemas - .iter() - .find(|schema| schema.is_default) - .map(|schema| schema.name.clone()) + let reading = { + let session = session.clone(); + tasks.spawn_task(async move { + session.schemas().await.ok().and_then(|schemas| { + schemas + .iter() + .find(|schema| schema.is_default) + .map(|schema| schema.name.clone()) + }) }) - }); + }; let editor = self.downgrade(); glib::spawn_future_local(async move { - let default = reading.await.ok().flatten(); - if let Some(editor) = editor.upgrade() { - editor.set_scope(scope_text(database.as_deref(), default.as_deref())); + let Some(schema) = reading.await.ok().flatten() else { + return; + }; + if let Some(editor) = editor.upgrade() + && editor.scope_database(&session).is_some_and(|(now, _)| now == database) + { + editor.set_scope(Some(scope_text(&database, Some(&schema)))); } }); } + /// The database the tab's session last reported, else the + /// connection's. `None` where a connection reaches one database. + fn scope_database(&self, session: &Session) -> Option<(String, DatabaseLayout)> { + let layout = session.current_dialect()?.database_layout(); + if layout == DatabaseLayout::Single { + return None; + } + let database = self + .imp() + .slot + .database() + .or_else(|| session.database().map(str::to_owned)) + .filter(|name| !name.is_empty())?; + Some((database, layout)) + } + fn set_scope(&self, scope: Option<(String, String)>) { let imp = self.imp(); let shown = scope.is_some(); @@ -80,21 +102,12 @@ mod tests { use super::scope_text; #[test] - fn the_scope_names_the_database_then_a_schema_of_its_own() { - assert_eq!( - scope_text(Some("shop"), Some("public")), - Some(("shop.public".to_owned(), "shop, schema public".to_owned())) - ); - assert_eq!( - scope_text(Some("shop"), Some("shop")), - Some(("shop".to_owned(), "shop".to_owned())), - "a schema that is the database" - ); + fn the_scope_names_the_database_then_its_schema() { assert_eq!( - scope_text(Some("shop"), None), - Some(("shop".to_owned(), "shop".to_owned())) + scope_text("shop", Some("public")), + ("shop.public".to_owned(), "shop, schema public".to_owned()) ); - assert_eq!(scope_text(None, Some("main")), None, "a file"); - assert_eq!(scope_text(Some(""), None), None); + assert_eq!(scope_text("shop", None), ("shop".to_owned(), "shop".to_owned())); + assert_eq!(scope_text("shop", Some("")), ("shop".to_owned(), "shop".to_owned())); } } diff --git a/linux/crates/app/src/ui/editor/tests.rs b/linux/crates/app/src/ui/editor/tests.rs index ff881962d7..f0a2d0dec2 100644 --- a/linux/crates/app/src/ui/editor/tests.rs +++ b/linux/crates/app/src/ui/editor/tests.rs @@ -2619,10 +2619,9 @@ fn a_command_editor_has_none_of_the_sql_tools() { ); } -/// The bar names the tab's database and its default schema, as the -/// macOS editor bar does. -#[gtk4::test] -fn the_bar_names_the_tabs_database_and_schema() { +/// An editor on a connection to `shop`, whose catalog lists `schemas` +/// with the first as the default. +fn scoped_editor(dialect: Arc, schemas: &[&str]) -> Harness { crate::register_test_resources(); let runtime = crate::test_support::test_runtime(); let fake = Arc::new(FakeEditorSession::new(answering())); @@ -2630,8 +2629,11 @@ fn the_bar_names_the_tabs_database_and_schema() { if let tablepro_session::connect::RequestTarget::Network { database, .. } = &mut request.target { *database = Some("shop".to_owned()); } - let mut script = ConnectionScript::default(); - let editor_session: Arc = fake; + let mut script = ConnectionScript { + dialect, + ..ConnectionScript::default() + }; + let editor_session: Arc = fake.clone(); script.open_editor_session.always(FakeReply::ok(editor_session)); let (pending, _) = FakeConnector::pending_session(request, script); let session = Session::start( @@ -2644,30 +2646,88 @@ fn the_bar_names_the_tabs_database_and_schema() { prompter: None, }, ); - let schema = |name: &str, is_default: bool| tablepro_core::meta::SchemaInfo { - name: name.to_owned(), - is_default, - is_system: false, - }; - session - .catalog() - .commit(vec![schema("public", true), schema("audit", false)], Vec::new()); + let listed = schemas + .iter() + .enumerate() + .map(|(index, name)| tablepro_core::meta::SchemaInfo { + name: (*name).to_owned(), + is_default: index == 0, + is_system: false, + }) + .collect(); + session.catalog().commit(listed, Vec::new()); let active = ActiveSession::new(); active.replace(session); let settings = MemorySettings::new(); let editor = SqlEditor::new(SqlEditorInit { - initial_query: None, + initial_query: Some("SELECT 1".to_owned()), settings: settings.get().clone(), history: None, tasks: runtime.tasks(), - session: active, + session: active.clone(), hold: Rc::new(tablepro_session::runtime::CountingHold::new()), close_timeout: Duration::from_secs(5), snippets: crate::test_support::builtin_snippets_only(), }); + Harness { + editor, + fake, + history: None, + active, + settings, + runtime, + } +} + +fn postgres_dialect() -> Arc { + Arc::new(tablepro_driver_postgres::PgDialect::new( + &tablepro_core::ServerInfo::new("PostgreSQL", "18.0", 18, 0, 0), + )) +} + +fn scope_shown(harness: &Harness) -> Option { + let imp = harness.editor.imp(); + imp.scope_box.is_visible().then(|| imp.scope_label.label().to_string()) +} + +#[gtk4::test] +fn the_bar_names_the_database_and_the_schema_names_resolve_in() { + let harness = scoped_editor(postgres_dialect(), &["public", "audit"]); + + wait_until(WAIT, || scope_shown(&harness).as_deref() == Some("shop.public")) + .unwrap_or_else(|_| panic!("the bar says {:?}", scope_shown(&harness))); + assert_eq!( + harness.editor.imp().scope_box.tooltip_text().as_deref(), + Some("shop, schema public") + ); +} + +/// Where the server's databases are the catalog's schemas, the default +/// schema is the database and adds nothing. +#[gtk4::test] +fn the_bar_names_a_database_that_is_its_own_schema_once() { + let harness = scoped_editor(Arc::new(tablepro_driver_mongodb::MongoDialect), &["shop", "analytics"]); + + crate::test_support::drain_main_context(); + assert_eq!(scope_shown(&harness).as_deref(), Some("shop")); +} + +#[gtk4::test] +fn an_engine_of_one_database_names_none() { + let harness = scoped_editor(Arc::new(tablepro_test_support::FakeDialect), &["main"]); + + crate::test_support::drain_main_context(); + assert_eq!(scope_shown(&harness), None); +} + +/// A `USE` in the script moves the session, and the bar goes with it. +#[gtk4::test] +fn the_bar_follows_a_use_the_tab_ran() { + let harness = scoped_editor(postgres_dialect(), &["public"]); + wait_until(WAIT, || scope_shown(&harness).as_deref() == Some("shop.public")).expect("the scope"); + harness.fake.set_database(Some("analytics")); + + run_and_wait(&harness); - let label = editor.imp().scope_label.get(); - crate::test_support::wait_until(Duration::from_secs(5), || label.label() == "shop.public") - .unwrap_or_else(|_| panic!("the bar says {:?}", label.label())); - assert!(editor.imp().scope_box.is_visible()); + assert_eq!(scope_shown(&harness).as_deref(), Some("analytics")); } diff --git a/linux/crates/core/src/dialect/database_layout.rs b/linux/crates/core/src/dialect/database_layout.rs new file mode 100644 index 0000000000..33067c5843 --- /dev/null +++ b/linux/crates/core/src/dialect/database_layout.rs @@ -0,0 +1,13 @@ +/// How the server's databases stand to the catalog a connection reads. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DatabaseLayout { + /// A connection reaches one database: a file, or numbered databases + /// of keys. + Single, + /// The server's databases are the schemas the catalog lists, so one + /// connection reads every one of them. + AsSchemas, + /// Each database holds schemas of its own, and a connection's catalog + /// describes only the database it opened. + WithSchemas, +} diff --git a/linux/crates/core/src/dialect/mod.rs b/linux/crates/core/src/dialect/mod.rs index 08c8769e7b..92eed0f063 100644 --- a/linux/crates/core/src/dialect/mod.rs +++ b/linux/crates/core/src/dialect/mod.rs @@ -3,6 +3,7 @@ mod bind_error; mod bind_target; +mod database_layout; mod dialect_capabilities; mod insert_batch_limits; mod keyset_direction; @@ -16,6 +17,7 @@ mod test_dialect; pub use bind_error::BindError; pub use bind_target::BindTarget; +pub use database_layout::DatabaseLayout; pub use dialect_capabilities::{DialectCapabilities, InsertConflict}; pub use insert_batch_limits::InsertBatchLimits; pub use keyset_direction::KeysetDirection; diff --git a/linux/crates/core/src/dialect/sql_dialect.rs b/linux/crates/core/src/dialect/sql_dialect.rs index 79b7e00948..b02f232434 100644 --- a/linux/crates/core/src/dialect/sql_dialect.rs +++ b/linux/crates/core/src/dialect/sql_dialect.rs @@ -6,8 +6,8 @@ use crate::sql_syntax::{ExpressionSyntaxError, HistoryRecording, SqlGrammar, Typ use crate::value::Value; use super::{ - BindError, BindTarget, DialectCapabilities, InsertBatchLimits, KeysetDirection, LikeCase, LikeForm, LiteralError, - Placeholder, TablePaging, + BindError, BindTarget, DatabaseLayout, DialectCapabilities, InsertBatchLimits, KeysetDirection, LikeCase, LikeForm, + LiteralError, Placeholder, TablePaging, }; /// How one engine spells SQL. @@ -31,6 +31,10 @@ pub trait SqlDialect: Send + Sync + std::fmt::Debug { /// whatever it contains. fn quote_identifier(&self, name: &str) -> String; + fn database_layout(&self) -> DatabaseLayout { + DatabaseLayout::Single + } + /// Bind one value, giving back the SQL that names it. /// /// `ordinal` is the number of parameters the statement already diff --git a/linux/crates/core/src/editor_session.rs b/linux/crates/core/src/editor_session.rs index 20660f092d..5e29af3596 100644 --- a/linux/crates/core/src/editor_session.rs +++ b/linux/crates/core/src/editor_session.rs @@ -71,5 +71,12 @@ pub trait EditorSession: Send + Sync { async fn transaction_state(&self, options: CallOptions) -> Result; + /// The database an unqualified name resolves to now, as the server + /// last said. `None` where the driver does not follow it: the session + /// is still in the database it opened in. + fn database(&self) -> Option { + None + } + async fn close(&self); } diff --git a/linux/crates/core/src/sql_syntax/catalog_change.rs b/linux/crates/core/src/sql_syntax/catalog_change.rs index 3a7806743b..2fc62739eb 100644 --- a/linux/crates/core/src/sql_syntax/catalog_change.rs +++ b/linux/crates/core/src/sql_syntax/catalog_change.rs @@ -1,5 +1,6 @@ use super::SqlGrammar; use super::script::{ScriptPlan, ScriptTokenKind}; +use super::statement_tokens::lex_statements; /// Words that add, rename or remove what the sidebar lists. They count /// anywhere in a statement: a SQL Server batch needs no `;` between its @@ -27,6 +28,17 @@ pub fn changes_catalog(text: &str, grammar: SqlGrammar) -> bool { ScriptPlan::server_readings(text, grammar).is_none_or(|mut plans| plans.any(|plan| plan_changes(text, &plan))) } +/// Whether running `text` can leave the session in another database. A +/// `USE` anywhere counts, since a SQL Server batch needs no `;` between +/// its statements; an index hint's `USE` only costs a question. +pub fn may_change_database(text: &str, grammar: SqlGrammar) -> bool { + lex_statements(text, grammar) + .statements + .iter() + .flatten() + .any(|token| token.is_word("USE")) +} + fn plan_changes(text: &str, plan: &ScriptPlan) -> bool { let is = |word: &str, set: &[&str]| set.iter().any(|verb| word.eq_ignore_ascii_case(verb)); plan.statements().iter().any(|statement| { @@ -48,6 +60,22 @@ fn plan_changes(text: &str, plan: &ScriptPlan) -> bool { mod tests { use super::*; + #[test] + fn a_use_can_change_the_database() { + for grammar in SqlGrammar::SQL { + for sql in [ + "USE analytics", + "SELECT 1; use `a b`", + "SELECT 1 USE analytics SELECT 2", + ] { + assert!(may_change_database(sql, grammar), "{grammar:?}: {sql}"); + } + for sql in ["SELECT 'USE x'", "-- USE x\nSELECT 1", "SELECT used FROM t"] { + assert!(!may_change_database(sql, grammar), "{grammar:?}: {sql}"); + } + } + } + #[test] fn ddl_changes_the_catalog_in_every_grammar() { for grammar in SqlGrammar::SQL { diff --git a/linux/crates/core/src/sql_syntax/mod.rs b/linux/crates/core/src/sql_syntax/mod.rs index 36acc3d9a3..2952c3fd57 100644 --- a/linux/crates/core/src/sql_syntax/mod.rs +++ b/linux/crates/core/src/sql_syntax/mod.rs @@ -16,7 +16,7 @@ mod statement_access; mod statement_tokens; pub use builtin_snippet::{BuiltinSnippet, builtin_snippets}; -pub use catalog_change::changes_catalog; +pub use catalog_change::{changes_catalog, may_change_database}; pub use credential_rules::CredentialRules; pub use errors::{ExpressionSyntaxError, TypeSyntaxError}; pub use grammar::SqlGrammar; diff --git a/linux/crates/drivers/cassandra/src/dialect.rs b/linux/crates/drivers/cassandra/src/dialect.rs index 7a4850f713..336fd673db 100644 --- a/linux/crates/drivers/cassandra/src/dialect.rs +++ b/linux/crates/drivers/cassandra/src/dialect.rs @@ -1,8 +1,8 @@ use tablepro_core::column::{ColumnInfo, ColumnType, SqlExpression}; use tablepro_core::ddl::DdlDialect; use tablepro_core::dialect::{ - BindError, BindTarget, DialectCapabilities, InsertBatchLimits, InsertConflict, KeysetDirection, LikeForm, - LiteralError, Placeholder, SqlDialect, TablePaging, + BindError, BindTarget, DatabaseLayout, DialectCapabilities, InsertBatchLimits, InsertConflict, KeysetDirection, + LikeForm, LiteralError, Placeholder, SqlDialect, TablePaging, }; use tablepro_core::filter::{Combinator, FilterOp}; use tablepro_core::meta::{EngineRowId, EngineRowIdPart, TableRef}; @@ -141,6 +141,10 @@ const DURATION: &[FilterOp] = &[FilterOp::Eq]; const CASSANDRA_IN_OUTSIDE_KEY: (u32, u32) = (5, 0); impl SqlDialect for CqlDialect { + fn database_layout(&self) -> DatabaseLayout { + DatabaseLayout::AsSchemas + } + fn capabilities(&self) -> DialectCapabilities { match self.conditional_writes { true => DialectCapabilities { diff --git a/linux/crates/drivers/cassandra/src/editor.rs b/linux/crates/drivers/cassandra/src/editor.rs index 5292545157..97da762cd6 100644 --- a/linux/crates/drivers/cassandra/src/editor.rs +++ b/linux/crates/drivers/cassandra/src/editor.rs @@ -74,6 +74,12 @@ impl EditorSession for CqlEditorSession { self.id } + /// The driver moves the session itself when a `USE` answers. + fn database(&self) -> Option { + let link = self.link.get().ok()?; + link.session().get_keyspace().map(|keyspace| keyspace.to_string()) + } + fn state(&self) -> EditorSessionState { match self.state.load(Ordering::SeqCst) { READY => EditorSessionState::Ready, diff --git a/linux/crates/drivers/clickhouse/src/dialect.rs b/linux/crates/drivers/clickhouse/src/dialect.rs index fc978cd8ab..01cacf8445 100644 --- a/linux/crates/drivers/clickhouse/src/dialect.rs +++ b/linux/crates/drivers/clickhouse/src/dialect.rs @@ -4,8 +4,8 @@ use tablepro_core::column::{ }; use tablepro_core::ddl::DdlDialect; use tablepro_core::dialect::{ - BindError, BindTarget, DialectCapabilities, InsertBatchLimits, InsertConflict, KeysetDirection, LikeCase, LikeForm, - LiteralError, Placeholder, SqlDialect, + BindError, BindTarget, DatabaseLayout, DialectCapabilities, InsertBatchLimits, InsertConflict, KeysetDirection, + LikeCase, LikeForm, LiteralError, Placeholder, SqlDialect, }; use tablepro_core::export::value_to_text; use tablepro_core::meta::{EngineRowId, EngineRowIdPart, TableRef}; @@ -53,6 +53,10 @@ impl ClickHouseDialect { } impl SqlDialect for ClickHouseDialect { + fn database_layout(&self) -> DatabaseLayout { + DatabaseLayout::AsSchemas + } + fn capabilities(&self) -> DialectCapabilities { // A mutation reports nothing useful about how many rows it // touched, so a write proves itself with a probe instead. diff --git a/linux/crates/drivers/clickhouse/src/editor_session.rs b/linux/crates/drivers/clickhouse/src/editor_session.rs index 0a3b2ccf21..4d00aacd94 100644 --- a/linux/crates/drivers/clickhouse/src/editor_session.rs +++ b/linux/crates/drivers/clickhouse/src/editor_session.rs @@ -76,6 +76,12 @@ impl EditorSession for ClickHouseEditorSession { self.id } + /// Every request names the database, which a `USE` in the session + /// does not override. + fn database(&self) -> Option { + self.session.database().map(str::to_owned) + } + fn state(&self) -> EditorSessionState { self.status.state() } diff --git a/linux/crates/drivers/mongodb/src/dialect.rs b/linux/crates/drivers/mongodb/src/dialect.rs index 8bcb36fda9..9fa2d50bd4 100644 --- a/linux/crates/drivers/mongodb/src/dialect.rs +++ b/linux/crates/drivers/mongodb/src/dialect.rs @@ -1,7 +1,7 @@ use tablepro_core::column::{ColumnType, SqlExpression}; use tablepro_core::dialect::{ - BindError, BindTarget, DialectCapabilities, InsertBatchLimits, InsertConflict, KeysetDirection, LiteralError, - Placeholder, SqlDialect, + BindError, BindTarget, DatabaseLayout, DialectCapabilities, InsertBatchLimits, InsertConflict, KeysetDirection, + LiteralError, Placeholder, SqlDialect, }; use tablepro_core::meta::{EngineRowId, EngineRowIdPart}; use tablepro_core::sql_syntax::{ExpressionSyntaxError, HistoryRecording, SqlGrammar, TypeSyntaxError}; @@ -17,6 +17,10 @@ use tablepro_core::value::Value; pub struct MongoDialect; impl SqlDialect for MongoDialect { + fn database_layout(&self) -> DatabaseLayout { + DatabaseLayout::AsSchemas + } + /// A write reports how many documents it matched, and `_id` is /// unique, so a count proves a save touched the document it meant to, /// and an insert with an `_id` already taken fails with E11000. diff --git a/linux/crates/drivers/mongodb/src/editor_session.rs b/linux/crates/drivers/mongodb/src/editor_session.rs index 96f6946fd0..be9f35e515 100644 --- a/linux/crates/drivers/mongodb/src/editor_session.rs +++ b/linux/crates/drivers/mongodb/src/editor_session.rs @@ -64,6 +64,10 @@ impl EditorSession for MongoEditorSession { self.id } + fn database(&self) -> Option { + Some(self.database.lock().unwrap_or_else(PoisonError::into_inner).clone()) + } + fn state(&self) -> EditorSessionState { self.status.state() } diff --git a/linux/crates/drivers/mssql/src/dialect.rs b/linux/crates/drivers/mssql/src/dialect.rs index 7b470b9519..7aaa8972a4 100644 --- a/linux/crates/drivers/mssql/src/dialect.rs +++ b/linux/crates/drivers/mssql/src/dialect.rs @@ -4,8 +4,8 @@ use tablepro_core::column::{ }; use tablepro_core::ddl::DdlDialect; use tablepro_core::dialect::{ - BindError, BindTarget, DialectCapabilities, InsertBatchLimits, InsertConflict, KeysetDirection, LikeCase, LikeForm, - LiteralError, Placeholder, SqlDialect, + BindError, BindTarget, DatabaseLayout, DialectCapabilities, InsertBatchLimits, InsertConflict, KeysetDirection, + LikeCase, LikeForm, LiteralError, Placeholder, SqlDialect, }; use tablepro_core::meta::{EngineRowId, EngineRowIdPart}; use tablepro_core::sql_syntax::{ @@ -42,6 +42,10 @@ impl MssqlDialect { } impl SqlDialect for MssqlDialect { + fn database_layout(&self) -> DatabaseLayout { + DatabaseLayout::WithSchemas + } + /// SQL Server adds the rows its triggers touch to a statement's /// count, so a write proves itself with a probe instead. fn capabilities(&self) -> DialectCapabilities { diff --git a/linux/crates/drivers/mssql/src/editor_run.rs b/linux/crates/drivers/mssql/src/editor_run.rs index 4223ab45a0..cbcaf2f7c1 100644 --- a/linux/crates/drivers/mssql/src/editor_run.rs +++ b/linux/crates/drivers/mssql/src/editor_run.rs @@ -247,6 +247,16 @@ pub(crate) async fn transaction_state(client: &mut MssqlClient) -> Result Result, DriverError> { + let stream = client.simple_query("SELECT DB_NAME()").await.map_err(map_error)?; + let row = stream.into_row().await.map_err(map_error)?; + Ok(row.and_then(|row| match decode_row(&row).into_iter().next() { + Some(Value::Text(name)) => Some(name), + _ => None, + })) +} + /// XACT_STATE is -1 when an error has doomed the transaction: it can /// only be rolled back. fn state_of(transaction_count: Option, xact_state: Option) -> TransactionState { diff --git a/linux/crates/drivers/mssql/src/editor_session.rs b/linux/crates/drivers/mssql/src/editor_session.rs index 4ec3c2850e..372ffaffd9 100644 --- a/linux/crates/drivers/mssql/src/editor_session.rs +++ b/linux/crates/drivers/mssql/src/editor_session.rs @@ -3,6 +3,7 @@ use std::sync::{Arc, Mutex, PoisonError}; use std::time::Duration; use async_trait::async_trait; +use tablepro_core::sql_syntax::{SqlGrammar, may_change_database}; use tablepro_core::{ CallOptions, DriverError, EditorSession, EditorSessionId, EditorSessionOptions, EditorSessionState, LossPhase, ResultSink, RunEnd, RunOptions, RunSummary, StopReason, StopRequest, TimeoutPhase, TransactionState, UserSql, @@ -11,7 +12,7 @@ use tokio::sync::OwnedMutexGuard; use tokio::time::Instant; use tokio_util::sync::CancellationToken; -use crate::editor_run::{EditorRun, ScriptOutcome, transaction_state}; +use crate::editor_run::{EditorRun, ScriptOutcome, current_database, transaction_state}; use crate::endpoint::MssqlEndpoint; use crate::errors::ends_connection; use crate::in_band_request::StopPolicy; @@ -38,6 +39,8 @@ struct Shared { lost: AtomicBool, /// The running statement's stop, so closing the tab stops it. current: Mutex>, + /// Where a `USE` left the session, once one ran. + database: Mutex>, statement_timeout: Option, policy: StopPolicy, cancel_grace: Duration, @@ -59,6 +62,7 @@ impl MssqlEditorSession { running: AtomicBool::new(false), lost: AtomicBool::new(false), current: Mutex::new(None), + database: Mutex::new(None), statement_timeout: options.statement_timeout, policy: StopPolicy::within(liveness.cancel_grace), cancel_grace: liveness.cancel_grace, @@ -103,7 +107,8 @@ impl Shared { let supervised = self.supervise(link, &request, &sql, &options, sink.as_mut()).await; let (end, transaction_state, keep) = match supervised { Some(Ok(outcome)) if outcome.reusable => { - let (state, keep) = self.state_after_run(link).await; + let follow = may_change_database(sql.as_str(), SqlGrammar::MsSql); + let (state, keep) = self.state_after_run(link, follow).await; (Ok(outcome.end), state, keep) } // The run ended, but somewhere the connection cannot go on @@ -175,7 +180,14 @@ impl Shared { /// Where the transaction stands after a run, within the cancel grace, /// and unknown rather than guessed when the answer does not come. - async fn state_after_run(&self, link: &mut MssqlLink) -> (TransactionState, bool) { + async fn state_after_run(&self, link: &mut MssqlLink, follow_database: bool) -> (TransactionState, bool) { + if follow_database { + match tokio::time::timeout(self.cancel_grace, current_database(&mut link.client)).await { + Ok(Ok(current)) => *self.database.lock().unwrap_or_else(PoisonError::into_inner) = current, + Ok(Err(error)) => return (TransactionState::Unknown, !ends_connection(&error)), + Err(_) => return (TransactionState::Unknown, false), + } + } match tokio::time::timeout(self.cancel_grace, transaction_state(&mut link.client)).await { Ok(Ok(state)) => (state, true), Ok(Err(error)) => (TransactionState::Unknown, !ends_connection(&error)), @@ -198,6 +210,14 @@ impl EditorSession for MssqlEditorSession { self.id } + fn database(&self) -> Option { + self.shared + .database + .lock() + .unwrap_or_else(PoisonError::into_inner) + .clone() + } + fn state(&self) -> EditorSessionState { self.shared.state() } diff --git a/linux/crates/drivers/mssql/tests/integration/sessions.rs b/linux/crates/drivers/mssql/tests/integration/sessions.rs index 01874bd9ae..ae1e0008a0 100644 --- a/linux/crates/drivers/mssql/tests/integration/sessions.rs +++ b/linux/crates/drivers/mssql/tests/integration/sessions.rs @@ -17,6 +17,11 @@ async fn use_database_persists_across_runs() { let database = run_ok(session.as_ref(), "SELECT DB_NAME()").await.rows(); assert_eq!(database, vec![vec![Value::Text("tempdb".to_owned())]]); + assert_eq!( + session.database().as_deref(), + Some("tempdb"), + "where the session says it is" + ); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] diff --git a/linux/crates/drivers/mysql/src/dialect.rs b/linux/crates/drivers/mysql/src/dialect.rs index dedd423ca4..df5d173259 100644 --- a/linux/crates/drivers/mysql/src/dialect.rs +++ b/linux/crates/drivers/mysql/src/dialect.rs @@ -5,8 +5,8 @@ use tablepro_core::column::{ }; use tablepro_core::ddl::DdlDialect; use tablepro_core::dialect::{ - BindError, BindTarget, DialectCapabilities, InsertConflict, KeysetDirection, LikeCase, LikeForm, LiteralError, - Placeholder, SqlDialect, + BindError, BindTarget, DatabaseLayout, DialectCapabilities, InsertConflict, KeysetDirection, LikeCase, LikeForm, + LiteralError, Placeholder, SqlDialect, }; use tablepro_core::export::value_to_text; use tablepro_core::meta::{EngineRowId, EngineRowIdPart}; @@ -61,6 +61,10 @@ impl MySqlDialect { } impl SqlDialect for MySqlDialect { + fn database_layout(&self) -> DatabaseLayout { + DatabaseLayout::AsSchemas + } + fn capabilities(&self) -> DialectCapabilities { // CLIENT_FOUND_ROWS is on, so an UPDATE reports the rows it // matched, including ones it left unchanged. diff --git a/linux/crates/drivers/mysql/src/editor_session.rs b/linux/crates/drivers/mysql/src/editor_session.rs index 7907f61351..13d86f79cd 100644 --- a/linux/crates/drivers/mysql/src/editor_session.rs +++ b/linux/crates/drivers/mysql/src/editor_session.rs @@ -1,22 +1,26 @@ -use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex, PoisonError}; use std::time::Duration; use async_trait::async_trait; use sqlx::mysql::MySqlConnection; use sqlx::{AssertSqlSafe, Connection as _, Executor as _}; +use tablepro_core::sql_syntax::{SqlGrammar, may_change_database}; use tablepro_core::{ CallOptions, DriverError, EditorSession, EditorSessionId, EditorSessionOptions, EditorSessionState, LivenessPolicy, - LossPhase, ResultSink, RowLimit, RunEnd, RunOptions, RunSummary, TimeoutPhase, TransactionState, UserSql, + LossPhase, ResultSink, RowLimit, RunEnd, RunOptions, RunSummary, TimeoutPhase, TransactionState, UserSql, Value, }; use tokio::sync::OwnedMutexGuard; use tokio::time::Instant; use tokio_util::sync::CancellationToken; use crate::cancel::ControlLink; +use crate::decode::decode_row; use crate::editor_run::{RunOutcome, run_script}; use crate::errors::map_error; use crate::flavour::MySqlFlavour; +use crate::mysql_timestamp_mode::MySqlTimestampMode; +use crate::mysql_wire_format::MySqlWireFormat; use crate::physical::{MysqlEndpoint, SessionKind}; use crate::session_statements::{SessionLimits, select_limit}; use crate::transaction_probe::TransactionProbe; @@ -47,6 +51,8 @@ struct Shared { flavour: MySqlFlavour, control: ControlLink, probe: TransactionProbe, + /// Where a `USE` left the session, once one ran. + database: Mutex>, running: AtomicBool, lost: AtomicBool, /// Cancelled when the session closes, so a running statement stops. @@ -85,6 +91,7 @@ impl MysqlEditorSession { flavour: physical.flavour, control: ControlLink::new(endpoint), probe: TransactionProbe::new(physical.flavour), + database: Mutex::new(None), running: AtomicBool::new(false), lost: AtomicBool::new(false), closing: CancellationToken::new(), @@ -126,6 +133,9 @@ impl Shared { let supervised = self.supervise(held, &sql, &options, sink.as_mut()).await; let transaction_state = match &supervised { Ok((_, true)) => { + if may_change_database(sql.as_str(), SqlGrammar::MySql) { + self.follow_database(&mut held.connection).await; + } let asking = self.probe.state(&mut held.connection); tokio::time::timeout(self.liveness.cancel_grace, asking) .await @@ -154,6 +164,18 @@ impl Shared { }) } + async fn follow_database(&self, connection: &mut MySqlConnection) { + let asking = connection.fetch_one(sqlx::raw_sql("SELECT DATABASE()")); + let Ok(Ok(row)) = tokio::time::timeout(self.liveness.cancel_grace, asking).await else { + return; + }; + let current = match decode_row(&row, MySqlWireFormat::Text, MySqlTimestampMode::SessionLocal).first() { + Some(Value::Text(name)) => Some(name.clone()), + _ => None, + }; + *self.database.lock().unwrap_or_else(PoisonError::into_inner) = current; + } + /// Run the script, stopping it on a cancel, at the deadline, when /// the session closes or when the sink stops reading. A stop is KILL /// QUERY on a connection of the session's own; the statement then @@ -317,6 +339,14 @@ impl EditorSession for MysqlEditorSession { outcome } + fn database(&self) -> Option { + self.shared + .database + .lock() + .unwrap_or_else(PoisonError::into_inner) + .clone() + } + async fn transaction_state(&self, options: CallOptions) -> Result { let shared = &self.shared; let asking = async { diff --git a/linux/crates/drivers/mysql/tests/integration/editor_session.rs b/linux/crates/drivers/mysql/tests/integration/editor_session.rs index 236d599ca2..6701d7132d 100644 --- a/linux/crates/drivers/mysql/tests/integration/editor_session.rs +++ b/linux/crates/drivers/mysql/tests/integration/editor_session.rs @@ -259,3 +259,32 @@ async fn a_deadline_reaches_a_run_still_setting_its_row_limit() { editor.close().await; connection.close().await.expect("a close"); } + +/// The session says where a `USE` left it, so the editor bar can follow. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore = "requires docker"] +async fn a_use_moves_the_session_and_it_says_where() { + for flavour in FLAVOURS { + let fixture = start(flavour).await; + let root = connect_root(&fixture).await; + let grant = format!( + "GRANT ALL ON analytics.* TO '{}'@'%'", + fixture.password_credentials().username + ); + setup( + root.as_ref(), + &["CREATE DATABASE IF NOT EXISTS analytics", grant.as_str()], + ) + .await; + root.close().await.expect("a close"); + let connection = connect(&fixture, false).await; + let editor = session(connection.as_ref()).await; + + run_ok(editor.as_ref(), "SELECT 1").await; + assert_eq!(editor.database(), None, "{flavour:?}: no USE ran"); + run_ok(editor.as_ref(), "USE analytics").await; + assert_eq!(editor.database().as_deref(), Some("analytics"), "{flavour:?}"); + editor.close().await; + connection.close().await.expect("a close"); + } +} diff --git a/linux/crates/drivers/postgres/src/dialect.rs b/linux/crates/drivers/postgres/src/dialect.rs index f9dfaabd0d..c7693e6ea3 100644 --- a/linux/crates/drivers/postgres/src/dialect.rs +++ b/linux/crates/drivers/postgres/src/dialect.rs @@ -6,7 +6,8 @@ use tablepro_core::column::{ }; use tablepro_core::ddl::DdlDialect; use tablepro_core::dialect::{ - BindError, BindTarget, DialectCapabilities, InsertConflict, KeysetDirection, LiteralError, Placeholder, SqlDialect, + BindError, BindTarget, DatabaseLayout, DialectCapabilities, InsertConflict, KeysetDirection, LiteralError, + Placeholder, SqlDialect, }; use tablepro_core::export::value_to_text; use tablepro_core::meta::{EngineRowId, EngineRowIdPart}; @@ -41,6 +42,10 @@ impl PgDialect { } impl SqlDialect for PgDialect { + fn database_layout(&self) -> DatabaseLayout { + DatabaseLayout::WithSchemas + } + fn capabilities(&self) -> DialectCapabilities { DialectCapabilities { exact_row_counts: true, diff --git a/linux/crates/test-support/src/fake_editor_session.rs b/linux/crates/test-support/src/fake_editor_session.rs index b53d4666f8..1dc4ddfee8 100644 --- a/linux/crates/test-support/src/fake_editor_session.rs +++ b/linux/crates/test-support/src/fake_editor_session.rs @@ -25,6 +25,7 @@ pub struct FakeEditorSession { texts: Mutex>, state: Mutex, transaction_state: Mutex, + database: Mutex>, close_behaviour: CloseBehaviour, closes: AtomicUsize, closed: AtomicBool, @@ -44,6 +45,7 @@ impl FakeEditorSession { texts: Mutex::new(Vec::new()), state: Mutex::new(EditorSessionState::Ready), transaction_state: Mutex::new(TransactionState::Idle), + database: Mutex::new(None), close_behaviour, closes: AtomicUsize::new(0), closed: AtomicBool::new(false), @@ -75,6 +77,11 @@ impl FakeEditorSession { *lock(&self.transaction_state) = state; } + /// Where the session says it is, as after a `USE`. + pub fn set_database(&self, database: Option<&str>) { + *lock(&self.database) = database.map(str::to_owned); + } + /// The connection goes while no run is on it, as when the server /// ends a session left idle in a transaction. pub fn lose(&self) { @@ -188,6 +195,10 @@ impl EditorSession for FakeEditorSession { Ok(*lock(&self.transaction_state)) } + fn database(&self) -> Option { + lock(&self.database).clone() + } + async fn close(&self) { let first = self.closes.fetch_add(1, Ordering::SeqCst) == 0; self.closing.cancel(); diff --git a/linux/data/resources/ui/sql-editor.ui b/linux/data/resources/ui/sql-editor.ui index b343a5e633..f520d2d01d 100644 --- a/linux/data/resources/ui/sql-editor.ui +++ b/linux/data/resources/ui/sql-editor.ui @@ -93,8 +93,6 @@ 8 8 - false 6 From e9448f1bc9745b661bcd25ace61abff96f434b03 Mon Sep 17 00:00:00 2001 From: sedax90 <5001801+sedax90@users.noreply.github.com> Date: Fri, 30 Oct 2026 11:19:24 +0100 Subject: [PATCH 18/53] feat(linux-workspace): name a tab's database in its title when not the connection's own --- .../src/ui/session_window/tests/sessions.rs | 30 +++-- linux/crates/app/src/ui/workspace/mod.rs | 11 +- .../app/src/ui/workspace/tab_factory.rs | 1 + .../crates/app/src/ui/workspace/tab_title.rs | 113 ++++++++++++++---- 4 files changed, 126 insertions(+), 29 deletions(-) diff --git a/linux/crates/app/src/ui/session_window/tests/sessions.rs b/linux/crates/app/src/ui/session_window/tests/sessions.rs index 772a69463a..db9346b854 100644 --- a/linux/crates/app/src/ui/session_window/tests/sessions.rs +++ b/linux/crates/app/src/ui/session_window/tests/sessions.rs @@ -678,8 +678,8 @@ fn another_database_opens_beside_the_connection_and_goes_with_it() { } /// An editor tab moved to another database brings that database's session -/// into the window for the tab to read its catalog; one moved back to the -/// connection's own opens nothing (0030). +/// into the window for the tab to read its catalog, and names it in its +/// title; one moved back to the connection's own opens nothing (0030). #[gtk4::test] fn an_editor_tab_moved_to_a_database_opens_its_session() { let test = TestWindow::with_script( @@ -720,14 +720,30 @@ fn an_editor_tab_moved_to_a_database_opens_its_session() { "the connection's own database opened again" ); - test.window.send(SessionWindowInput::FollowDatabase { - key: connection, - tab_id: uuid::Uuid::new_v4(), - database: "analytics".to_owned(), - }); + let shell = test.window.shell().expect("the shell"); + WidgetExt::activate_action( + &test.window, + "win.new-query-in", + Some(&crate::ui::session_target::to_variant(&connection)), + ) + .expect("win.new-query-in"); + wait_until(WAIT, || shell.tab_view().n_pages() == 1).expect("a new tab"); + let page = shell.tab_view().nth_page(0); + let child = page.child(); + let editor = crate::test_support::descendants(&child) + .into_iter() + .chain([child]) + .find_map(|widget| widget.downcast::().ok()) + .expect("the editor"); + WidgetExt::activate_action(&editor, "tab.database", Some(&"analytics".to_variant())).expect("tab.database"); let sibling = SessionKey::SavedIn { id: test.saved.id, database: "analytics".to_owned(), }; wait_until(WAIT, || test.window.held_session(&sibling).is_some()).expect("the database's session"); + assert_eq!( + page.title(), + "Query 1 (analytics)", + "the tab does not name its database" + ); } diff --git a/linux/crates/app/src/ui/workspace/mod.rs b/linux/crates/app/src/ui/workspace/mod.rs index 61fd935a90..face8df591 100644 --- a/linux/crates/app/src/ui/workspace/mod.rs +++ b/linux/crates/app/src/ui/workspace/mod.rs @@ -412,6 +412,14 @@ impl WorkspaceTab { } } + /// The database the editor bar moved the tab to. + pub(crate) fn moved_to(&self) -> Option { + match self { + WorkspaceTab::Editor(slot) => slot.editor.tab_database(), + _ => None, + } + } + /// What the tab's title is made from. pub(crate) fn identity(&self) -> tab_identity::TabIdentity { use tab_identity::TabIdentity; @@ -1055,6 +1063,7 @@ impl WorkspaceModel { /// Ask the window to reach the session of the database an editor tab /// moved to, and to hand it back for the tab's completion. fn follow_editor_database(&self, tab_id: Uuid, database: String) { + self.retitle_tab(tab_id); let Some(shell) = self.shell.upgrade() else { return; }; @@ -1149,7 +1158,7 @@ impl WorkspaceModel { return; }; if let Some(tab) = self.workspace_tabs.borrow().get(&id) { - tab_title::apply_tab_title(tab.page(), &title, &self.label); + tab_title::apply_tab_title(tab.page(), &title, &self.label, tab.moved_to().as_deref()); } if self.index.last_selected() == Some(id) { self.refresh_title(); diff --git a/linux/crates/app/src/ui/workspace/tab_factory.rs b/linux/crates/app/src/ui/workspace/tab_factory.rs index 21c6d930b6..4813643370 100644 --- a/linux/crates/app/src/ui/workspace/tab_factory.rs +++ b/linux/crates/app/src/ui/workspace/tab_factory.rs @@ -139,6 +139,7 @@ impl TabFactory { &page, &resolve_tab_title(&slot.identity(), self.parts.naming.get()), &self.parts.label, + slot.moved_to().as_deref(), ); self.parts.tabs.borrow_mut().insert(id, slot); self.parts.input.deliver(WorkspaceInput::WorkspaceTabsChanged); diff --git a/linux/crates/app/src/ui/workspace/tab_title.rs b/linux/crates/app/src/ui/workspace/tab_title.rs index 8be27e9d79..76cd2abf46 100644 --- a/linux/crates/app/src/ui/workspace/tab_title.rs +++ b/linux/crates/app/src/ui/workspace/tab_title.rs @@ -43,24 +43,43 @@ impl ConnectionLabel { } pub(crate) fn name(&self) -> String { + let connection = self.connection_name().unwrap_or_default(); + match &self.key { + SessionKey::SavedIn { database, .. } => in_database(&connection, database), + _ => connection, + } + } + + /// The connection's name alone, without a database. + fn connection_name(&self) -> Option { let saved = match &self.key { - SessionKey::Saved(id) => self.saved.get(*id).map(|saved| saved.name), - // Another database of the connection names both, as - // "orders (prod, analytics)" (0030). - SessionKey::SavedIn { id, database } => self.saved.get(*id).map(|saved| { - crate::i18n::gettext_f( - "{connection}, {database}", - &[("connection", &saved.name), ("database", database)], - ) - }), + SessionKey::Saved(id) | SessionKey::SavedIn { id, .. } => self.saved.get(*id).map(|saved| saved.name), SessionKey::File(_) => None, }; - saved - .or_else(|| self.session.metadata().map(|metadata| metadata.name)) - .unwrap_or_default() + saved.or_else(|| self.session.metadata().map(|metadata| metadata.name)) + } + + /// The database a tab names in its title: the one the editor bar moved + /// it to, unless that is its session's own, else its sibling session's. + /// The connection's own database goes unnamed (0030). + fn database(&self, moved_to: Option<&str>) -> Option { + let own = self.session.get(); + let sibling = match &self.key { + SessionKey::SavedIn { database, .. } => Some(database.as_str()), + _ => None, + }; + named_database(moved_to, own.as_ref().and_then(|session| session.database()), sibling) } - pub(crate) fn shares_bar(&self) -> bool { + /// What a tab names after its title: the connection while the bar is + /// shared, the database when it is not the connection's own, as + /// "orders (prod, analytics)" or "Query 1 (analytics)". + fn suffix(&self, moved_to: Option<&str>) -> Option { + let connection = self.shares_bar().then(|| self.connection_name().unwrap_or_default()); + suffix(connection, self.database(moved_to)) + } + + fn shares_bar(&self) -> bool { self.shares_bar.get() } @@ -70,18 +89,44 @@ impl ConnectionLabel { } } +fn named_database(moved_to: Option<&str>, own: Option<&str>, sibling: Option<&str>) -> Option { + moved_to + .filter(|database| Some(*database) != own) + .or(sibling) + .map(str::to_owned) +} + +fn suffix(connection: Option, database: Option) -> Option { + match (connection, database) { + (Some(connection), Some(database)) => Some(in_database(&connection, &database)), + (connection, database) => connection.or(database), + } +} + +fn in_database(connection: &str, database: &str) -> String { + // Translators: a connection and one of its databases, after a tab's title. + crate::i18n::gettext_f( + "{connection}, {database}", + &[("connection", connection), ("database", database)], + ) +} + /// Give `page` its title, tooltip and keyword. libadwaita treats an /// empty tooltip as none, which clears the last one. The tooltip says /// what the unsaved icon shows, since a screen reader reads a tab's /// tooltip and not its icon, and it is escaped: AdwTab shows it as Pango /// markup. -pub(crate) fn apply_tab_title(page: &adw::TabPage, title: &TabTitle, connection: &ConnectionLabel) { - let name = connection.name(); +pub(crate) fn apply_tab_title( + page: &adw::TabPage, + title: &TabTitle, + connection: &ConnectionLabel, + moved_to: Option<&str>, +) { // The overview's search finds a connection's tabs by its name. - page.set_keyword(&name); - let title = match connection.shares_bar() { - true => name_connection(title.clone(), &name), - false => title.clone(), + page.set_keyword(&connection.name()); + let title = match connection.suffix(moved_to) { + Some(suffix) => name_connection(title.clone(), &suffix), + None => title.clone(), }; page.set_title(&title.label); let unsaved = page.icon().is_some().then(|| crate::i18n::gettext("Unsaved changes")); @@ -92,8 +137,9 @@ pub(crate) fn apply_tab_title(page: &adw::TabPage, title: &TabTitle, connection: page.set_tooltip(&glib::markup_escape_text(&lines.join("\n"))); } -/// A tab beside another connection's names its own after its title, -/// which stays the useful part once ellipsized. The tooltip names it on a +/// A tab beside another connection's names its own after its title, and +/// its database when not the connection's own, since the title stays the +/// useful part once ellipsized. The tooltip names it on a /// line of its own, which a cut label loses. fn name_connection(title: TabTitle, connection: &str) -> TabTitle { let tooltip = [title.tooltip.as_deref().unwrap_or(&title.label), connection].join("\n"); @@ -292,6 +338,31 @@ mod tests { assert!(!title.label.contains('·')); } + #[test] + fn a_tab_names_its_database_when_not_the_connections_own() { + assert_eq!( + named_database(Some("analytics"), Some("shop"), None).as_deref(), + Some("analytics") + ); + assert_eq!( + named_database(Some("shop"), Some("shop"), None), + None, + "the connection's own" + ); + assert_eq!(named_database(None, Some("shop"), None), None); + assert_eq!( + named_database(None, Some("analytics"), Some("analytics")).as_deref(), + Some("analytics") + ); + assert_eq!(suffix(None, Some("analytics".to_owned())).as_deref(), Some("analytics")); + assert_eq!( + suffix(Some("Shop".to_owned()), Some("analytics".to_owned())).as_deref(), + Some("Shop, analytics") + ); + assert_eq!(suffix(Some("Shop".to_owned()), None).as_deref(), Some("Shop")); + assert_eq!(suffix(None, None), None); + } + #[test] fn a_script_its_label_shows_whole_has_no_tooltip() { let title = resolve_tab_title( From d4787ae5bf757328f6780080019f7b1f40d8abb2 Mon Sep 17 00:00:00 2001 From: sedax90 <5001801+sedax90@users.noreply.github.com> Date: Fri, 30 Oct 2026 11:19:46 +0100 Subject: [PATCH 19/53] docs(linux-workspace): add the changelog entry --- linux/changelog.d/3904.added.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 linux/changelog.d/3904.added.md diff --git a/linux/changelog.d/3904.added.md b/linux/changelog.d/3904.added.md new file mode 100644 index 0000000000..71d759adee --- /dev/null +++ b/linux/changelog.d/3904.added.md @@ -0,0 +1 @@ +A query tab in another of the connection's databases names it in its title, as "Query 1 (analytics)". From a771366247fd6119741c7ae2d8be3a6c9bbfc4bd Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 30 Oct 2026 17:22:42 +0700 Subject: [PATCH 20/53] fix(linux-editor): move a tab's own session with the driver, and complete only where the catalog reaches --- .../app/src/ui/assistant/schema_reader.rs | 2 +- .../src/ui/editor/completion/catalog_read.rs | 110 +++++++++--- .../app/src/ui/editor/completion/mod.rs | 2 +- .../completion/sql_completion_provider.rs | 9 +- .../completion/sql_completion_provider_imp.rs | 3 +- .../app/src/ui/editor/editor_actions.rs | 16 +- .../app/src/ui/editor/editor_session_slot.rs | 132 ++++++-------- linux/crates/app/src/ui/editor/imp.rs | 4 +- linux/crates/app/src/ui/editor/mod.rs | 6 +- .../crates/app/src/ui/editor/script_runner.rs | 9 + linux/crates/app/src/ui/editor/tests.rs | 161 +++++++++++------- linux/crates/app/src/ui/workspace/mod.rs | 4 + .../app/src/ui/workspace/tab_factory.rs | 2 + .../app/src/ui/workspace/workspace_tabs.rs | 5 +- linux/crates/core/src/connection.rs | 5 +- linux/crates/core/src/editor_session.rs | 9 + linux/crates/drivers/mysql/src/dialect.rs | 6 +- .../drivers/mysql/src/editor_session.rs | 46 +++++ linux/crates/drivers/mysql/src/metadata.rs | 2 +- .../test-support/src/fake_editor_session.rs | 23 +++ 20 files changed, 368 insertions(+), 188 deletions(-) diff --git a/linux/crates/app/src/ui/assistant/schema_reader.rs b/linux/crates/app/src/ui/assistant/schema_reader.rs index 3096018fc5..aff96ad98c 100644 --- a/linux/crates/app/src/ui/assistant/schema_reader.rs +++ b/linux/crates/app/src/ui/assistant/schema_reader.rs @@ -39,7 +39,7 @@ pub(crate) async fn read(session: Arc, engine: String, text: String) -> CompletionScope::General { tables_in_statement: named, }, - None, + crate::ui::editor::CatalogHome::Connection, ) .await; let schema = session.schemas().await.ok().and_then(|schemas| { diff --git a/linux/crates/app/src/ui/editor/completion/catalog_read.rs b/linux/crates/app/src/ui/editor/completion/catalog_read.rs index 2153a4adea..24f9febb8c 100644 --- a/linux/crates/app/src/ui/editor/completion/catalog_read.rs +++ b/linux/crates/app/src/ui/editor/completion/catalog_read.rs @@ -14,58 +14,93 @@ pub struct CatalogAnswer { pub columns: Vec<(TableRef, Arc<[ColumnInfo]>)>, } +/// Where an unqualified name resolves for the tab. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub enum CatalogHome { + /// The connection's default schema. + #[default] + Connection, + /// Another of the server's databases, which the catalog lists as a + /// schema. + Schema(String), + /// A database the catalog does not describe, which nothing offered + /// from it would be right for. + Elsewhere, +} + /// Read what `scope` offers. The catalog shares each read with every /// other view and keeps what it read, so typing does not reach the /// server again; a list that cannot be read is left out. -/// -/// `home` is the database the tab was moved to from the editor bar, where -/// an unqualified name now resolves; `None` keeps the connection's own. -pub async fn read(session: Arc, scope: CompletionScope, home: Option) -> CatalogAnswer { - let home = match home { - Some(database) => ObjectScope::Schema(database), - None => ObjectScope::DefaultSchema, +pub async fn read(session: Arc, scope: CompletionScope, home: CatalogHome) -> CatalogAnswer { + let defaults = match &home { + CatalogHome::Connection => listed(session.objects(ObjectScope::DefaultSchema).await), + CatalogHome::Schema(name) => listed(session.objects(ObjectScope::Schema(name.clone())).await), + CatalogHome::Elsewhere => return CatalogAnswer::default(), }; - let defaults = listed(session.objects(home).await); + let home = Home { home, defaults }; match scope { CompletionScope::Objects { schema: None } => CatalogAnswer { schemas: Some(listed(session.schemas().await)), - objects: Some(defaults), + objects: Some(home.defaults.clone()), columns: Vec::new(), }, CompletionScope::Objects { schema: Some(name) } => { // `users.` typed before the FROM that names users. - let table = relation_named(&defaults, &name); + let table = relation_named(&home.defaults, &name); CatalogAnswer { schemas: None, - objects: schema_objects(&session, &name).await, + objects: schema_objects(&session, &home, &name).await, columns: columns_of(&session, table.into_iter().collect()).await, } } CompletionScope::Qualified { qualifier, name } => { let table = TableRef::new(Some(qualifier), name.clone()); - match known_table(&session, &defaults, &table).await { + match known_table(&session, &home, &table).await { Some(table) => CatalogAnswer { columns: columns_of(&session, vec![table]).await, ..CatalogAnswer::default() }, None => CatalogAnswer { - objects: schema_objects(&session, &name).await, + objects: schema_objects(&session, &home, &name).await, ..CatalogAnswer::default() }, } } CompletionScope::Columns { tables } => CatalogAnswer { - columns: columns_of(&session, known_tables(&session, &defaults, &tables).await).await, + columns: columns_of(&session, known_tables(&session, &home, &tables).await).await, ..CatalogAnswer::default() }, CompletionScope::General { tables_in_statement } => CatalogAnswer { schemas: None, - columns: columns_of(&session, known_tables(&session, &defaults, &tables_in_statement).await).await, - objects: Some(defaults), + columns: columns_of(&session, known_tables(&session, &home, &tables_in_statement).await).await, + objects: Some(home.defaults), }, } } +/// The schema unqualified names resolve in and what it holds. +struct Home { + home: CatalogHome, + defaults: Arc<[ObjectInfo]>, +} + +impl Home { + /// Whether `schema` names the home schema, whose objects are read. + fn is(&self, schema: &SchemaInfo) -> bool { + match &self.home { + CatalogHome::Connection => schema.is_default, + CatalogHome::Schema(name) => schema.name == *name, + CatalogHome::Elsewhere => false, + } + } + + /// A name for the home database the catalog does not list as a + /// schema, as MySQL lists only the connection's. + fn named(&self, name: &str) -> bool { + matches!(&self.home, CatalogHome::Schema(home) if home.eq_ignore_ascii_case(name)) + } +} + async fn columns_of(session: &Session, tables: Vec) -> Vec<(TableRef, Arc<[ColumnInfo]>)> { let reads = tables.into_iter().map(|table| async move { let columns = listed(session.columns(table.clone()).await); @@ -83,7 +118,10 @@ fn listed(read: Result, Arc>) -> Arc<[T]> { /// The objects of the schema called `name`, or `None` when the catalog /// has no such schema. -async fn schema_objects(session: &Session, name: &str) -> Option> { +async fn schema_objects(session: &Session, home: &Home, name: &str) -> Option> { + if home.named(name) { + return Some(home.defaults.clone()); + } let schemas = listed(session.schemas().await); let schema = schema_named(&schemas, name)?; Some(listed(session.objects(ObjectScope::Schema(schema.name.clone())).await)) @@ -92,10 +130,10 @@ async fn schema_objects(session: &Session, name: &str) -> Option Vec { +async fn known_tables(session: &Session, home: &Home, tables: &[TableRef]) -> Vec { let mut known = Vec::new(); for table in tables { - if let Some(table) = known_table(session, defaults, table).await + if let Some(table) = known_table(session, home, table).await && !known.contains(&table) { known.push(table); @@ -106,14 +144,17 @@ async fn known_tables(session: &Session, defaults: &[ObjectInfo], tables: &[Tabl /// The catalog's own name for `table`, so the read is the one the /// table's other views share. -async fn known_table(session: &Session, defaults: &[ObjectInfo], table: &TableRef) -> Option { +async fn known_table(session: &Session, home: &Home, table: &TableRef) -> Option { let Some(schema) = &table.schema else { - return relation_named(defaults, &table.name); + return relation_named(&home.defaults, &table.name); }; + if home.named(schema) { + return relation_named(&home.defaults, &table.name); + } let schemas = listed(session.schemas().await); let schema = schema_named(&schemas, schema)?; - if schema.is_default { - return relation_named(defaults, &table.name); + if home.is(schema) { + return relation_named(&home.defaults, &table.name); } let objects = listed(session.objects(ObjectScope::Schema(schema.name.clone())).await); relation_named(&objects, &table.name) @@ -156,6 +197,29 @@ mod tests { } } + /// A tab moved to `analytics` reads `shop.orders` from `shop`, the + /// connection's default schema, not from its own. + #[test] + fn only_the_tabs_own_database_is_read_from_its_home() { + let shop = SchemaInfo { + name: "shop".to_owned(), + is_default: true, + is_system: false, + }; + let moved = Home { + home: CatalogHome::Schema("analytics".to_owned()), + defaults: Arc::from([]), + }; + assert!(!moved.is(&shop)); + assert!(moved.named("analytics")); + let staying = Home { + home: CatalogHome::Connection, + defaults: Arc::from([]), + }; + assert!(staying.is(&shop)); + assert!(!staying.named("shop"), "the catalog lists the connection's own"); + } + #[test] fn an_unqualified_table_takes_the_catalogs_schema() { let defaults = [object("users", ObjectKind::Table), object("Orders", ObjectKind::Table)]; diff --git a/linux/crates/app/src/ui/editor/completion/mod.rs b/linux/crates/app/src/ui/editor/completion/mod.rs index f5e2c4c98d..a131338771 100644 --- a/linux/crates/app/src/ui/editor/completion/mod.rs +++ b/linux/crates/app/src/ui/editor/completion/mod.rs @@ -17,6 +17,6 @@ mod tests; #[cfg(test)] pub(super) use tests::accept_builtin_snippet; -pub(crate) use catalog_read::read as read_catalog; +pub(crate) use catalog_read::{CatalogHome, read as read_catalog}; pub(crate) use completion_scope::{CompletionScope, tables_named}; pub use sql_completion_provider::SqlCompletionProvider; diff --git a/linux/crates/app/src/ui/editor/completion/sql_completion_provider.rs b/linux/crates/app/src/ui/editor/completion/sql_completion_provider.rs index 0d17dd37bc..bebf5a2564 100644 --- a/linux/crates/app/src/ui/editor/completion/sql_completion_provider.rs +++ b/linux/crates/app/src/ui/editor/completion/sql_completion_provider.rs @@ -14,7 +14,7 @@ use tablepro_core::sql_syntax::script::{LexicalSettings, text_offsets}; use tablepro_session::Session; use tablepro_session::runtime::Tasks; -use super::catalog_read::{self, CatalogAnswer}; +use super::catalog_read::{self, CatalogAnswer, CatalogHome}; use super::completion_scope::{self, CompletionScope}; use super::fuzzy; use super::proposal_kind::ProposalKind; @@ -44,10 +44,9 @@ pub struct ProviderDeps { } impl SqlCompletionProvider { - /// Resolve unqualified names in `database`, the tab's own when the - /// editor bar moved it off the connection's. - pub fn set_home(&self, database: Option) { - self.imp().home.replace(database); + /// Resolve unqualified names in `home`, the tab's database. + pub fn set_home(&self, home: CatalogHome) { + self.imp().home.replace(home); } pub fn new(session: ActiveSession, tasks: Tasks, snippets: Rc) -> Self { diff --git a/linux/crates/app/src/ui/editor/completion/sql_completion_provider_imp.rs b/linux/crates/app/src/ui/editor/completion/sql_completion_provider_imp.rs index ea5265cdcd..710220ea4c 100644 --- a/linux/crates/app/src/ui/editor/completion/sql_completion_provider_imp.rs +++ b/linux/crates/app/src/ui/editor/completion/sql_completion_provider_imp.rs @@ -19,8 +19,7 @@ const PRIORITY: i32 = 100; #[derive(Default)] pub struct SqlCompletionProvider { pub deps: OnceCell, - /// The database the tab was moved to, where unqualified names resolve. - pub home: std::cell::RefCell>, + pub home: std::cell::RefCell, } #[glib::object_subclass] diff --git a/linux/crates/app/src/ui/editor/editor_actions.rs b/linux/crates/app/src/ui/editor/editor_actions.rs index a32aaa051d..fecf7ccd06 100644 --- a/linux/crates/app/src/ui/editor/editor_actions.rs +++ b/linux/crates/app/src/ui/editor/editor_actions.rs @@ -1,6 +1,6 @@ -use gtk4::gio; use gtk4::prelude::*; use gtk4::subclass::prelude::*; +use gtk4::{gio, glib}; use libadwaita as adw; use libadwaita::prelude::*; @@ -68,6 +68,18 @@ impl SqlEditor { // Hidden until a workspace attaches an assistant that may send. set_enabled(&actions, name, false); } + let editor = self.downgrade(); + let database = gio::SimpleAction::new_stateful( + super::tab_scope::MOVE_ACTION, + Some(glib::VariantTy::STRING), + &"".to_variant(), + ); + database.connect_change_state(move |_, value| { + if let (Some(editor), Some(name)) = (editor.upgrade(), value.and_then(|value| value.str())) { + editor.move_to_database(name); + } + }); + actions.add_action(&database); sync_run_actions(&actions, false, None); sync_caret_actions(&actions, &self.imp().source_view); // Commands have no comments, and no layout to format them into. @@ -238,6 +250,8 @@ fn close_view_popups(view: &impl IsA) -> bool { /// its completion popup. The session takes one run at a time, so a /// commit or rollback waits for the run in flight. fn sync_run_actions(actions: &gio::SimpleActionGroup, running: bool, open: Option) { + // A tab moves between runs, and never away from its transaction. + set_enabled(actions, super::tab_scope::MOVE_ACTION, !running && open.is_none()); set_enabled(actions, "cancel", running); set_enabled(actions, "roll-back", !running && open.is_some()); set_enabled(actions, "commit", !running && open == Some(OpenTransaction::Open)); diff --git a/linux/crates/app/src/ui/editor/editor_session_slot.rs b/linux/crates/app/src/ui/editor/editor_session_slot.rs index 3791fdc468..e0be235a77 100644 --- a/linux/crates/app/src/ui/editor/editor_session_slot.rs +++ b/linux/crates/app/src/ui/editor/editor_session_slot.rs @@ -1,11 +1,7 @@ use std::sync::{Arc, Mutex, MutexGuard, PoisonError, Weak}; use std::time::Duration; -use async_trait::async_trait; -use tablepro_core::{ - EditorSession, EditorSessionOptions, EditorSessionState, ResultEvent, ResultSink, RunOptions, SinkFlow, - TransactionState, UserSql, -}; +use tablepro_core::{CallOptions, EditorSession, EditorSessionOptions, EditorSessionState, TransactionState}; use tablepro_session::{OperationError, Session}; use tokio_util::sync::CancellationToken; @@ -32,15 +28,9 @@ struct SlotState { /// told yet. rolled_back: bool, closed: bool, - /// The database the tab was moved to from the editor bar. Every - /// session the tab opens or holds is moved there before anything else. - database: Option, -} - -/// A database a tab was moved to. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct TabDatabase { - pub name: String, + /// The database the tab works in once it left the connection's: picked + /// from the editor bar, restored, or where a `USE` left its session. + database: Option, } struct OpenEditor { @@ -50,8 +40,6 @@ struct OpenEditor { editor: Arc, /// Where the transaction stood after the last run that reported it. transaction: Option, - /// The tab's database this session was moved to, if any. - moved_to: Option, } enum Lookup { @@ -76,6 +64,12 @@ pub enum AcquireError { /// The session's connection was lost with a transaction open, which /// the server rolled back. Nothing was opened in its place. TransactionRolledBack, + /// The tab could not get to its database, so it stays where its + /// session is. + Moved { + database: String, + reason: String, + }, Failed(String), } @@ -94,8 +88,9 @@ impl EditorSessionSlot { close_timeout: Duration, cancel: &CancellationToken, ) -> Result, AcquireError> { + let deadline = statement_timeout.map(|timeout| tokio::time::Instant::now() + timeout); let stale = match self.lookup(session) { - Lookup::Ready(editor) => return self.move_to_database(session, editor, cancel).await, + Lookup::Ready(editor) => return self.reach_database(session, editor, deadline, cancel).await, Lookup::Closed => return Err(AcquireError::Cancelled), Lookup::RolledBack { stale } => { if let Some(stale) = stale { @@ -111,7 +106,7 @@ impl EditorSessionSlot { let options = EditorSessionOptions { statement_timeout }; match session.open_editor_session(options, cancel).await { Ok(editor) => match self.adopt(session, editor.clone()) { - Ok(()) => self.move_to_database(session, editor, cancel).await, + Ok(()) => self.reach_database(session, editor, deadline, cancel).await, Err(orphan) => { close_quietly(orphan, close_timeout).await; Err(AcquireError::Cancelled) @@ -125,69 +120,54 @@ impl EditorSessionSlot { } } - /// Move the tab's sessions to `database` from the next run on. - pub fn set_database(&self, database: Option) { - lock(&self.state).database = database; + /// Move the tab to `database` from its next run on. + pub fn set_database(&self, database: String) { + lock(&self.state).database = Some(database); } - /// The tab's database, when it was moved off the connection's own. - pub fn database(&self) -> Option { + /// The tab's database, `None` while it is the connection's. + pub fn database(&self) -> Option { lock(&self.state).database.clone() } - /// Run the engine's `USE` for the tab's database on `editor` unless it - /// ran there already. Built now, when the session's dialect is known: - /// a tab restored before the connection came up named only the name. - async fn move_to_database( + /// Take where a run left `editor`, which a `USE` in it may have moved. + pub fn follow(&self, editor: &Arc) { + let mut state = lock(&self.state); + let held = state.open.as_ref().is_some_and(|open| open.editor.id() == editor.id()); + if let Some(database) = editor.database().filter(|_| held) { + state.database = Some(database); + } + } + + /// Put `editor` in the tab's database unless it is there. A session + /// that cannot get there leaves the tab where the session is. + async fn reach_database( &self, session: &Arc, editor: Arc, + deadline: Option, cancel: &CancellationToken, ) -> Result, AcquireError> { - let wanted = { - let state = lock(&self.state); - let moved = state - .open - .as_ref() - .filter(|open| Arc::ptr_eq(&open.editor, &editor)) - .and_then(|open| open.moved_to.clone()); - state - .database - .clone() - .filter(|database| moved.as_ref() != Some(&database.name)) - }; - let Some(database) = wanted else { + let Some(wanted) = self.database() else { return Ok(editor); }; - let Some(dialect) = session.current_dialect() else { - return Err(AcquireError::Failed(crate::i18n::gettext_f( - "Could not switch this tab to the database “{database}”", - &[("database", &database.name)], - ))); - }; - let statement = format!("USE {}", dialect.quote_identifier(&database.name)); - let ran = editor - .run( - UserSql::new(statement.as_str()), - RunOptions::new(cancel.clone()), - Box::new(Discard), - ) - .await; - match ran { - Ok(summary) if summary.succeeded() => { - if let Some(open) = lock(&self.state) - .open - .as_mut() - .filter(|open| Arc::ptr_eq(&open.editor, &editor)) - { - open.moved_to = Some(database.name); - } - Ok(editor) + let here = editor.database().or_else(|| session.database().map(str::to_owned)); + if here.as_deref() == Some(wanted.as_str()) { + return Ok(editor); + } + match editor + .use_database(&wanted, CallOptions::new(deadline, cancel.clone())) + .await + { + Ok(()) => Ok(editor), + Err(tablepro_core::DriverError::Cancelled) => Err(AcquireError::Cancelled), + Err(error) => { + lock(&self.state).database = here; + Err(AcquireError::Moved { + database: wanted, + reason: crate::ui::error_text::driver_message(&error), + }) } - _ => Err(AcquireError::Failed(crate::i18n::gettext_f( - "Could not switch this tab to the database “{database}”", - &[("database", &database.name)], - ))), } } @@ -220,11 +200,6 @@ impl EditorSessionSlot { } } - /// Where the tab's session says it is, once it says. - pub fn database(&self) -> Option { - lock(&self.state).open.as_ref().and_then(|open| open.editor.database()) - } - /// Where the transaction of `editor` stood after its last run. pub fn transaction(&self, editor: &Arc) -> Option { let state = lock(&self.state); @@ -254,7 +229,6 @@ impl EditorSessionSlot { owner: Arc::downgrade(owner), editor, transaction: None, - moved_to: None, }); Ok(()) } @@ -316,16 +290,6 @@ fn lock(mutex: &Mutex) -> MutexGuard<'_, T> { mutex.lock().unwrap_or_else(PoisonError::into_inner) } -/// Takes what a `USE` sends back, which is nothing the tab shows. -struct Discard; - -#[async_trait] -impl ResultSink for Discard { - async fn accept(&mut self, _event: ResultEvent) -> SinkFlow { - SinkFlow::Continue - } -} - #[cfg(test)] pub(super) mod tests { use tablepro_test_support::{FakeEditorSession, FakeRun, Replies}; diff --git a/linux/crates/app/src/ui/editor/imp.rs b/linux/crates/app/src/ui/editor/imp.rs index 19a4382fc7..c43482b723 100644 --- a/linux/crates/app/src/ui/editor/imp.rs +++ b/linux/crates/app/src/ui/editor/imp.rs @@ -64,8 +64,6 @@ pub struct SqlEditor { /// This tab's own connection, which keeps its transaction and /// temporary tables between runs. pub slot: Arc, - /// The tab's own actions: the database menu's. - pub tab_actions: RefCell>, pub running: RefCell>, pub log: RefCell>, /// Whether the last run left a transaction open, as last reported. @@ -142,6 +140,8 @@ impl ObjectImpl for SqlEditor { .param_types([bool::static_type()]) .build(), Signal::builder("file-changed").build(), + // The tab moved to another database. + Signal::builder("database-changed").build(), // Vim's :q, which closes the tab through its close prompts. Signal::builder("close-requested").build(), Signal::builder("assistant-requested") diff --git a/linux/crates/app/src/ui/editor/mod.rs b/linux/crates/app/src/ui/editor/mod.rs index d994114567..e1c3b7a61a 100644 --- a/linux/crates/app/src/ui/editor/mod.rs +++ b/linux/crates/app/src/ui/editor/mod.rs @@ -49,7 +49,7 @@ use tablepro_session::runtime::{ProcessHold, Tasks}; use tokio_util::sync::CancellationToken; use self::buffer_edit::{CursorAnchor, replace_all_undoable}; -pub(crate) use self::completion::{CompletionScope, read_catalog, tables_named}; +pub(crate) use self::completion::{CatalogHome, CompletionScope, read_catalog, tables_named}; pub(crate) use self::editor_file::file_name; use self::editor_view::EditorView; use self::imp::RunningScript; @@ -154,6 +154,7 @@ impl SqlEditor { editor.connect_buttons(); editor.connect_grid(); editor.show_scope(); + editor.list_databases(); editor } @@ -692,6 +693,9 @@ impl SqlEditor { } self.record_history(entry, dialect); self.show_scope(); + if imp.changes_catalog.get() { + self.list_databases(); + } self.emit_by_name::<()>("run-finished", &[&outcome]); self.emit_by_name::<()>("run-state-changed", &[&false]); } diff --git a/linux/crates/app/src/ui/editor/script_runner.rs b/linux/crates/app/src/ui/editor/script_runner.rs index 922d106e62..d3901a8c16 100644 --- a/linux/crates/app/src/ui/editor/script_runner.rs +++ b/linux/crates/app/src/ui/editor/script_runner.rs @@ -243,6 +243,14 @@ pub async fn run_in_tab( refuse(&events, message, true).await; return; } + Err(AcquireError::Moved { database, reason }) => { + let message = crate::i18n::gettext_f( + "Could not move this tab to the database “{database}”: {reason}", + &[("database", &database), ("reason", &reason)], + ); + refuse(&events, message, false).await; + return; + } Err(AcquireError::Failed(message)) => { refuse(&events, message, false).await; return; @@ -250,6 +258,7 @@ pub async fn run_in_tab( }; let before = slot.transaction(&editor); let after = run_batches(&editor, &run, &cancel, &events, before).await; + slot.follow(&editor); if editor.state() != EditorSessionState::Lost { slot.note_transaction(&editor, after.transaction); return; diff --git a/linux/crates/app/src/ui/editor/tests.rs b/linux/crates/app/src/ui/editor/tests.rs index 79b9198c4e..5ae932f317 100644 --- a/linux/crates/app/src/ui/editor/tests.rs +++ b/linux/crates/app/src/ui/editor/tests.rs @@ -2622,9 +2622,18 @@ fn a_command_editor_has_none_of_the_sql_tools() { /// An editor on a connection to `shop`, whose catalog lists `schemas` /// with the first as the default. fn scoped_editor(dialect: Arc, schemas: &[&str]) -> Harness { + scoped_editor_with(dialect, schemas, answering(), |_, _| {}) +} + +fn scoped_editor_with( + dialect: Arc, + schemas: &[&str], + runs: Replies, + setup: impl FnOnce(&mut ConnectionScript, &mut tablepro_session::connect::ConnectRequest), +) -> Harness { crate::register_test_resources(); let runtime = crate::test_support::test_runtime(); - let fake = Arc::new(FakeEditorSession::new(answering())); + let fake = Arc::new(FakeEditorSession::new(runs)); let mut request = FakeConnector::network_request("db.example", 5432).expect("a request"); if let tablepro_session::connect::RequestTarget::Network { database, .. } = &mut request.target { *database = Some("shop".to_owned()); @@ -2633,6 +2642,7 @@ fn scoped_editor(dialect: Arc, schemas: dialect, ..ConnectionScript::default() }; + setup(&mut script, &mut request); let editor_session: Arc = fake.clone(); script.open_editor_session.always(FakeReply::ok(editor_session)); let (pending, _) = FakeConnector::pending_session(request, script); @@ -2732,74 +2742,103 @@ fn the_bar_follows_a_use_the_tab_ran() { assert_eq!(scope_shown(&harness).as_deref(), Some("analytics")); } -/// The bar is a menu of the server's databases; picking one moves the -/// tab there, so the next run sends the engine's `USE` before the query. -#[gtk4::test] -fn picking_a_database_moves_the_tab_and_its_next_run() { - crate::register_test_resources(); - let runtime = crate::test_support::test_runtime(); - let fake = Arc::new(FakeEditorSession::new(answering())); - let mut request = FakeConnector::network_request("db.example", 3306).expect("a request"); - if let tablepro_session::connect::RequestTarget::Network { database, .. } = &mut request.target { - *database = Some("shop".to_owned()); - } +fn mysql_dialect() -> Arc { + Arc::new(tablepro_driver_mysql::MySqlDialect::new( + tablepro_driver_mysql::MySqlFlavour::MySql, + &tablepro_core::ServerInfo::new("MySQL", "8.4.0", 8, 4, 0), + )) +} + +/// A MySQL connection to `shop` on a server that also holds `analytics`. +fn databases_editor(runs: Replies, read_only: bool) -> Harness { let database = |name: &str, is_default: bool, is_system: bool| tablepro_core::meta::SchemaInfo { name: name.to_owned(), is_default, is_system, }; - let mut script = ConnectionScript::default(); - script.list_databases.always(FakeReply::ok(vec![ - database("analytics", false, false), - database("shop", true, false), - database("mysql", false, true), - ])); - let editor_session: Arc = fake.clone(); - script.open_editor_session.always(FakeReply::ok(editor_session)); - let (pending, _) = FakeConnector::pending_session(request, script); - let session = Session::start( - pending, - tablepro_session::SessionDeps { - connector: Arc::new(FakeConnector::new([])), - credentials: Arc::new(FakeCredentialSource::new()), - deadlines: Arc::new(DefaultDeadlines::new(tablepro_core::LivenessPolicy::DESKTOP)), - tasks: runtime.tasks(), - prompter: None, - }, - ); - let active = ActiveSession::new(); - active.replace(session); - let settings = MemorySettings::new(); - let editor = SqlEditor::new(SqlEditorInit { - initial_query: Some("SELECT 1".to_owned()), - settings: settings.get().clone(), - history: None, - tasks: runtime.tasks(), - session: active.clone(), - hold: Rc::new(tablepro_session::runtime::CountingHold::new()), - close_timeout: Duration::from_secs(5), - snippets: crate::test_support::builtin_snippets_only(), + let harness = scoped_editor_with(mysql_dialect(), &["shop"], runs, |script, _| { + script.list_databases.always(FakeReply::ok(vec![ + database("analytics", false, false), + database("shop", true, false), + database("mysql", false, true), + ])); + script.read_only = read_only; }); - let button = editor.imp().scope_button.get(); - crate::test_support::wait_until(Duration::from_secs(5), || button.is_visible()).expect("the database menu"); + let button = harness.editor.imp().scope_button.get(); + wait_until(WAIT, || button.is_visible()).expect("the database menu"); + harness +} - WidgetExt::activate_action(&editor, "tab.database", Some(&"analytics".to_variant())).expect("tab.database"); - assert_eq!(editor.imp().scope_button_label.label(), "analytics"); +fn pick_database(harness: &Harness, name: &str) { + WidgetExt::activate_action(&harness.editor, "editor.database", Some(&name.to_variant())).expect("editor.database"); +} + +/// Picking a database moves the tab: the bar names it at once, and the +/// next run moves the session there before its statements. +#[gtk4::test] +fn picking_a_database_moves_the_tab_and_its_next_run() { + let harness = databases_editor(answering(), false); + let changed = SignalLog::connect(&harness.editor, "database-changed", |_| ()); + + pick_database(&harness, "analytics"); + assert_eq!(harness.editor.imp().scope_button_label.label(), "analytics"); + assert_eq!(changed.count(), 1, "the workspace keeps where the tab is"); - let harness = Harness { - editor, - fake: fake.clone(), - history: None, - active, - settings, - runtime, - }; run_and_wait(&harness); - let sent = sent(&fake); - assert!( - sent.first() - .is_some_and(|first| first.starts_with("USE ") && first.contains("analytics")), - "{sent:?}" + assert_eq!(harness.fake.moves(), ["analytics"]); + assert_eq!( + sent(&harness.fake), + ["SELECT 1"], + "the move is the session's, not a statement" + ); + assert_eq!(harness.editor.tab_database().as_deref(), Some("analytics")); + + pick_database(&harness, "shop"); + run_and_wait(&harness); + assert_eq!( + harness.fake.moves(), + ["analytics", "shop"], + "back to the connection's own" ); - assert_eq!(sent.last().map(String::as_str), Some("SELECT 1")); + assert_eq!(harness.editor.tab_database(), None); +} + +/// A database the server will not move to leaves the tab where its +/// session is, and the run says why. +#[gtk4::test] +fn a_refused_move_leaves_the_tab_where_its_session_is() { + let harness = databases_editor(answering(), false); + harness + .fake + .refuse_moves(DriverError::server("Access denied to database 'analytics'")); + + pick_database(&harness, "analytics"); + run_and_wait(&harness); + + assert!(sent(&harness.fake).is_empty(), "the script ran in the wrong database"); + assert_eq!(harness.editor.imp().scope_button_label.label(), "shop"); + let log = harness.editor.imp().log.borrow(); + let said = log.as_ref().and_then(|log| log.first_failure()).unwrap_or_default(); + assert!(said.contains("analytics") && said.contains("Access denied"), "{said}"); +} + +/// A `USE` is no write, so a read-only connection moves too. +#[gtk4::test] +fn a_read_only_connection_offers_its_databases() { + let harness = databases_editor(answering(), true); + + pick_database(&harness, "analytics"); + run_and_wait(&harness); + + assert_eq!(harness.fake.moves(), ["analytics"]); +} + +/// A tab never leaves its transaction behind in another database. +#[gtk4::test] +fn a_tab_holding_a_transaction_stays_where_it_is() { + let harness = databases_editor(transaction_runs(), false); + run_and_wait(&harness); + let action = imp_action(&harness.editor, "database").expect("the action"); + + assert!(!action.is_enabled(), "the tab could leave its transaction"); } diff --git a/linux/crates/app/src/ui/workspace/mod.rs b/linux/crates/app/src/ui/workspace/mod.rs index 200a2f17ad..0bfa005a70 100644 --- a/linux/crates/app/src/ui/workspace/mod.rs +++ b/linux/crates/app/src/ui/workspace/mod.rs @@ -283,6 +283,7 @@ pub enum ClosedTabDescriptor { Editor { query: String, file: Option, + database: Option, }, Table { schema: Option, @@ -499,6 +500,8 @@ pub(crate) enum WorkspaceInput { /// An editor tab was saved, opened a file, or its text came to /// differ from its file's or match it again. EditorTabFileChanged(Uuid), + /// An editor tab moved to another database, which its record keeps. + EditorTabDatabaseChanged, /// The session's saved tabs and their editor text, read off the GTK /// thread. WorkspaceLoaded(workspace_tabs::LoadedWorkspace), @@ -1263,6 +1266,7 @@ impl WorkspaceModel { } WorkspaceInput::EditorTabContentChanged(id) => self.on_editor_tab_content_changed(id), WorkspaceInput::EditorTabFileChanged(id) => self.on_editor_tab_file_changed(id), + WorkspaceInput::EditorTabDatabaseChanged => self.persist_workspace_state(), WorkspaceInput::WorkspaceLoaded(loaded) => self.on_workspace_loaded(loaded), WorkspaceInput::ResetWorkspaceLayout => self.on_reset_workspace_layout(), WorkspaceInput::SafeModeChanged => self.refresh_title(), diff --git a/linux/crates/app/src/ui/workspace/tab_factory.rs b/linux/crates/app/src/ui/workspace/tab_factory.rs index 533ae82559..49a33e49fb 100644 --- a/linux/crates/app/src/ui/workspace/tab_factory.rs +++ b/linux/crates/app/src/ui/workspace/tab_factory.rs @@ -496,6 +496,8 @@ pub(super) fn connect_editor( editor.connect_content_changed(move |_| content.deliver(WorkspaceInput::EditorTabContentChanged(tab_id))); let file = input.clone(); editor.connect_file_changed(move |_| file.deliver(WorkspaceInput::EditorTabFileChanged(tab_id))); + let database = input.clone(); + editor.connect_database_changed(move |_| database.deliver(WorkspaceInput::EditorTabDatabaseChanged)); let transaction = input.clone(); editor.connect_transaction_changed(move |_, _| transaction.deliver(WorkspaceInput::EditorTransactionChanged)); let toast = input.clone(); diff --git a/linux/crates/app/src/ui/workspace/workspace_tabs.rs b/linux/crates/app/src/ui/workspace/workspace_tabs.rs index 6fa5140a74..4b2f884211 100644 --- a/linux/crates/app/src/ui/workspace/workspace_tabs.rs +++ b/linux/crates/app/src/ui/workspace/workspace_tabs.rs @@ -664,6 +664,7 @@ fn describe_closed_tab(slot: &WorkspaceTab) -> Option { WorkspaceTab::Editor(s) => Some(ClosedTabDescriptor::Editor { query: whole_text(&s.editor.buffer()), file: editor_file_record(&s.editor), + database: s.editor.tab_database(), }), WorkspaceTab::Table(s) => Some(ClosedTabDescriptor::Table { schema: s.schema.clone(), @@ -695,13 +696,13 @@ impl WorkspaceModel { /// Open again a tab this session closed, which the shell kept. pub(super) fn on_reopen_closed_tab(&mut self, descriptor: ClosedTabDescriptor) { match descriptor { - ClosedTabDescriptor::Editor { query, file } => { + ClosedTabDescriptor::Editor { query, file, database } => { let initial = if query.is_empty() { None } else { Some(query) }; self.open_editor(TabSpec::Editor { initial_query: initial, draft: None, file, - database: None, + database, }); } ClosedTabDescriptor::Table { diff --git a/linux/crates/core/src/connection.rs b/linux/crates/core/src/connection.rs index c16484fab6..43ea64fd27 100644 --- a/linux/crates/core/src/connection.rs +++ b/linux/crates/core/src/connection.rs @@ -103,9 +103,8 @@ pub trait Connection: Send + Sync { }) } - /// The server's databases a query tab can switch to, the connection's - /// own marked as the default, and the engine's own marked as system. - /// An engine whose connection is to one database keeps this default. + /// The server's databases the login can open, the connection's own + /// marked as the default and the engine's own as system. async fn list_databases(&self, _options: CallOptions) -> Result, DriverError> { Err(DriverError::Unsupported { feature: UnsupportedFeature::DatabaseSwitching, diff --git a/linux/crates/core/src/editor_session.rs b/linux/crates/core/src/editor_session.rs index 5e29af3596..ea2d6d1662 100644 --- a/linux/crates/core/src/editor_session.rs +++ b/linux/crates/core/src/editor_session.rs @@ -78,5 +78,14 @@ pub trait EditorSession: Send + Sync { None } + /// Move the session to the server's database `database`, keeping + /// what it holds. Unsupported where the engine binds a session's + /// database when it connects. + async fn use_database(&self, _database: &str, _options: CallOptions) -> Result<(), DriverError> { + Err(DriverError::Unsupported { + feature: crate::UnsupportedFeature::DatabaseSwitching, + }) + } + async fn close(&self); } diff --git a/linux/crates/drivers/mysql/src/dialect.rs b/linux/crates/drivers/mysql/src/dialect.rs index df5d173259..57f67107ee 100644 --- a/linux/crates/drivers/mysql/src/dialect.rs +++ b/linux/crates/drivers/mysql/src/dialect.rs @@ -60,6 +60,10 @@ impl MySqlDialect { } } +pub(crate) fn quote(name: &str) -> String { + format!("`{}`", name.replace('`', "``")) +} + impl SqlDialect for MySqlDialect { fn database_layout(&self) -> DatabaseLayout { DatabaseLayout::AsSchemas @@ -79,7 +83,7 @@ impl SqlDialect for MySqlDialect { } fn quote_identifier(&self, name: &str) -> String { - format!("`{}`", name.replace('`', "``")) + quote(name) } fn placeholder(&self, _ordinal: usize, value: Value, target: BindTarget<'_>) -> Result { diff --git a/linux/crates/drivers/mysql/src/editor_session.rs b/linux/crates/drivers/mysql/src/editor_session.rs index 13d86f79cd..293c7c2d38 100644 --- a/linux/crates/drivers/mysql/src/editor_session.rs +++ b/linux/crates/drivers/mysql/src/editor_session.rs @@ -16,6 +16,7 @@ use tokio_util::sync::CancellationToken; use crate::cancel::ControlLink; use crate::decode::decode_row; +use crate::dialect::quote; use crate::editor_run::{RunOutcome, run_script}; use crate::errors::map_error; use crate::flavour::MySqlFlavour; @@ -347,6 +348,51 @@ impl EditorSession for MysqlEditorSession { .clone() } + /// `USE` carries any name: a backtick in it is doubled. + async fn use_database(&self, database: &str, options: CallOptions) -> Result<(), DriverError> { + let shared = &self.shared; + let stopped = || match options.cancel().is_cancelled() { + true => DriverError::Cancelled, + false => DriverError::Timeout { + phase: TimeoutPhase::Statement, + server_cancelled: false, + }, + }; + let mut guard = tokio::select! { + biased; + () = options.cancel().cancelled() => return Err(stopped()), + () = elapsed(options.deadline()) => return Err(stopped()), + guard = shared.held.lock() => guard, + }; + let Some(held) = guard.as_mut().filter(|_| !shared.lost.load(Ordering::SeqCst)) else { + return Err(DriverError::ConnectionLost { + during: LossPhase::Idle, + }); + }; + let statement = format!("USE {}", quote(database)); + let using = held.connection.execute(sqlx::raw_sql(AssertSqlSafe(statement))); + let used = tokio::select! { + biased; + () = options.cancel().cancelled() => None, + () = elapsed(options.deadline()) => None, + used = using => Some(used), + }; + match used { + Some(Ok(_)) => { + *shared.database.lock().unwrap_or_else(PoisonError::into_inner) = Some(database.to_owned()); + Ok(()) + } + Some(Err(error)) => Err(map_error(&error, shared.flavour)), + // The answer is still on the wire, so the connection is in no + // state anything knows. + None => { + shared.lost.store(true, Ordering::SeqCst); + *guard = None; + Err(stopped()) + } + } + } + async fn transaction_state(&self, options: CallOptions) -> Result { let shared = &self.shared; let asking = async { diff --git a/linux/crates/drivers/mysql/src/metadata.rs b/linux/crates/drivers/mysql/src/metadata.rs index 0899312eb0..ca91863308 100644 --- a/linux/crates/drivers/mysql/src/metadata.rs +++ b/linux/crates/drivers/mysql/src/metadata.rs @@ -113,7 +113,7 @@ pub(crate) async fn list_databases( }; databases.push(SchemaInfo { is_default: current.as_deref() == Some(name.as_str()), - is_system: SYSTEM_DATABASES.iter().any(|system| name.eq_ignore_ascii_case(system)), + is_system: SYSTEM_DATABASES.contains(&name.as_str()), name, }); } diff --git a/linux/crates/test-support/src/fake_editor_session.rs b/linux/crates/test-support/src/fake_editor_session.rs index 1dc4ddfee8..9f0235f719 100644 --- a/linux/crates/test-support/src/fake_editor_session.rs +++ b/linux/crates/test-support/src/fake_editor_session.rs @@ -26,6 +26,8 @@ pub struct FakeEditorSession { state: Mutex, transaction_state: Mutex, database: Mutex>, + moves: Mutex>, + refused_moves: Mutex>, close_behaviour: CloseBehaviour, closes: AtomicUsize, closed: AtomicBool, @@ -46,6 +48,8 @@ impl FakeEditorSession { state: Mutex::new(EditorSessionState::Ready), transaction_state: Mutex::new(TransactionState::Idle), database: Mutex::new(None), + moves: Mutex::new(Vec::new()), + refused_moves: Mutex::new(None), close_behaviour, closes: AtomicUsize::new(0), closed: AtomicBool::new(false), @@ -82,6 +86,16 @@ impl FakeEditorSession { *lock(&self.database) = database.map(str::to_owned); } + /// The databases `use_database` moved the session to, in order. + pub fn moves(&self) -> Vec { + lock(&self.moves).clone() + } + + /// Every later `use_database` fails with `error`. + pub fn refuse_moves(&self, error: DriverError) { + *lock(&self.refused_moves) = Some(error); + } + /// The connection goes while no run is on it, as when the server /// ends a session left idle in a transaction. pub fn lose(&self) { @@ -199,6 +213,15 @@ impl EditorSession for FakeEditorSession { lock(&self.database).clone() } + async fn use_database(&self, database: &str, _options: CallOptions) -> Result<(), DriverError> { + if let Some(error) = lock(&self.refused_moves).clone() { + return Err(error); + } + lock(&self.moves).push(database.to_owned()); + *lock(&self.database) = Some(database.to_owned()); + Ok(()) + } + async fn close(&self) { let first = self.closes.fetch_add(1, Ordering::SeqCst) == 0; self.closing.cancel(); From c86f693e50cfaac2579ed52882eb4279c7814f14 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 30 Oct 2026 17:30:24 +0700 Subject: [PATCH 21/53] fix(linux-editor): tell the workspace when a USE moves the tab --- linux/crates/app/src/ui/editor/imp.rs | 5 ++++- linux/crates/app/src/ui/editor/tab_scope.rs | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/linux/crates/app/src/ui/editor/imp.rs b/linux/crates/app/src/ui/editor/imp.rs index c43482b723..00a1988c56 100644 --- a/linux/crates/app/src/ui/editor/imp.rs +++ b/linux/crates/app/src/ui/editor/imp.rs @@ -64,6 +64,8 @@ pub struct SqlEditor { /// This tab's own connection, which keeps its transaction and /// temporary tables between runs. pub slot: Arc, + /// The database the bar names, to tell the workspace when it changes. + pub named_database: RefCell>, pub running: RefCell>, pub log: RefCell>, /// Whether the last run left a transaction open, as last reported. @@ -140,7 +142,8 @@ impl ObjectImpl for SqlEditor { .param_types([bool::static_type()]) .build(), Signal::builder("file-changed").build(), - // The tab moved to another database. + // The tab is in another database: picked from the bar, or + // where a `USE` took its session. Signal::builder("database-changed").build(), // Vim's :q, which closes the tab through its close prompts. Signal::builder("close-requested").build(), diff --git a/linux/crates/app/src/ui/editor/tab_scope.rs b/linux/crates/app/src/ui/editor/tab_scope.rs index 1e6b70c3f5..83c9bbf88b 100644 --- a/linux/crates/app/src/ui/editor/tab_scope.rs +++ b/linux/crates/app/src/ui/editor/tab_scope.rs @@ -82,6 +82,10 @@ impl SqlEditor { }; self.set_scope(Some(scope_text(&database, None))); self.follow_in_completion(catalog_home(layout, session.database(), &database)); + let moved = self.imp().named_database.replace(Some(database.clone())) != Some(database.clone()); + if moved { + self.emit_by_name::<()>("database-changed", &[]); + } if let Some(action) = super::imp_action(self, MOVE_ACTION) { action.set_state(&database.to_variant()); } @@ -177,7 +181,6 @@ impl SqlEditor { } self.imp().slot.set_database(database.to_owned()); self.show_scope(); - self.emit_by_name::<()>("database-changed", &[]); } /// The database a restore puts the tab back in: `None` while it is From 0c0e7c268fa1171b4cbf0d61d84d452fcf6a3f31 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 30 Oct 2026 17:34:35 +0700 Subject: [PATCH 22/53] fix(linux-editor): move SQL Server, ClickHouse, Cassandra and MongoDB tabs with each driver's own means --- linux/changelog.d/3898.fixed.md | 1 + .../core/src/sql_syntax/catalog_change.rs | 26 ++++ linux/crates/core/src/sql_syntax/mod.rs | 2 +- linux/crates/drivers/cassandra/src/editor.rs | 25 +++- linux/crates/drivers/cassandra/src/errors.rs | 2 +- .../drivers/clickhouse/src/editor_session.rs | 115 ++++++++++++++++-- .../drivers/mongodb/src/editor_session.rs | 11 ++ linux/crates/drivers/mssql/src/dialect.rs | 6 +- linux/crates/drivers/mssql/src/editor_run.rs | 9 ++ .../drivers/mssql/src/editor_session.rs | 51 +++++++- linux/crates/drivers/mssql/src/metadata.rs | 8 +- .../mssql/tests/integration/sessions.rs | 26 ++++ 12 files changed, 264 insertions(+), 18 deletions(-) create mode 100644 linux/changelog.d/3898.fixed.md diff --git a/linux/changelog.d/3898.fixed.md b/linux/changelog.d/3898.fixed.md new file mode 100644 index 0000000000..bdbc531689 --- /dev/null +++ b/linux/changelog.d/3898.fixed.md @@ -0,0 +1 @@ +On ClickHouse, a USE in the SQL editor now applies to the statements that follow it. diff --git a/linux/crates/core/src/sql_syntax/catalog_change.rs b/linux/crates/core/src/sql_syntax/catalog_change.rs index 2fc62739eb..8154324b4c 100644 --- a/linux/crates/core/src/sql_syntax/catalog_change.rs +++ b/linux/crates/core/src/sql_syntax/catalog_change.rs @@ -39,6 +39,22 @@ pub fn may_change_database(text: &str, grammar: SqlGrammar) -> bool { .any(|token| token.is_word("USE")) } +/// The database the last `USE ` in `text` names, for an engine +/// whose client, not its server, keeps the database a request runs in. A +/// name that would need unescaping is left out. +pub fn used_database(text: &str, grammar: SqlGrammar) -> Option { + lex_statements(text, grammar) + .statements + .iter() + .rev() + .find_map(|statement| match statement.as_slice() { + [first, name] if first.is_word("USE") => name.name(), + _ => None, + }) + .filter(|name| !name.is_empty() && !name.contains(['`', '"', '\\'])) + .map(str::to_owned) +} + fn plan_changes(text: &str, plan: &ScriptPlan) -> bool { let is = |word: &str, set: &[&str]| set.iter().any(|verb| word.eq_ignore_ascii_case(verb)); plan.statements().iter().any(|statement| { @@ -60,6 +76,16 @@ fn plan_changes(text: &str, plan: &ScriptPlan) -> bool { mod tests { use super::*; + #[test] + fn a_use_names_its_database() { + let grammar = SqlGrammar::ClickHouse; + assert_eq!(used_database("USE analytics", grammar).as_deref(), Some("analytics")); + assert_eq!(used_database("use `my db`;", grammar).as_deref(), Some("my db")); + assert_eq!(used_database("USE a; USE b", grammar).as_deref(), Some("b")); + assert_eq!(used_database("SELECT 1", grammar), None); + assert_eq!(used_database("USE `a``b`", grammar), None, "a name to unescape"); + } + #[test] fn a_use_can_change_the_database() { for grammar in SqlGrammar::SQL { diff --git a/linux/crates/core/src/sql_syntax/mod.rs b/linux/crates/core/src/sql_syntax/mod.rs index 2952c3fd57..42693f6f0c 100644 --- a/linux/crates/core/src/sql_syntax/mod.rs +++ b/linux/crates/core/src/sql_syntax/mod.rs @@ -16,7 +16,7 @@ mod statement_access; mod statement_tokens; pub use builtin_snippet::{BuiltinSnippet, builtin_snippets}; -pub use catalog_change::{changes_catalog, may_change_database}; +pub use catalog_change::{changes_catalog, may_change_database, used_database}; pub use credential_rules::CredentialRules; pub use errors::{ExpressionSyntaxError, TypeSyntaxError}; pub use grammar::SqlGrammar; diff --git a/linux/crates/drivers/cassandra/src/editor.rs b/linux/crates/drivers/cassandra/src/editor.rs index 97da762cd6..a35c20c3d1 100644 --- a/linux/crates/drivers/cassandra/src/editor.rs +++ b/linux/crates/drivers/cassandra/src/editor.rs @@ -21,7 +21,7 @@ use tablepro_core::{ use tokio::time::Instant; use tokio_util::sync::CancellationToken; -use crate::errors::execution_error; +use crate::errors::{execution_error, use_keyspace_error}; use crate::rows::{ColumnsRead, MAX_PAGE_ROWS, next_state, page_size}; use crate::session::{CqlLink, LinkSlot}; @@ -80,6 +80,22 @@ impl EditorSession for CqlEditorSession { link.session().get_keyspace().map(|keyspace| keyspace.to_string()) } + /// The driver checks the name, which CQL takes as letters, digits and + /// `_` alone, and moves every connection of the session. + async fn use_database(&self, database: &str, options: CallOptions) -> Result<(), DriverError> { + let link = self.link.get()?; + let using = link.session().use_keyspace(database, true); + tokio::select! { + biased; + () = options.cancel().cancelled() => Err(DriverError::Cancelled), + () = until(options.deadline()) => Err(DriverError::Timeout { + phase: tablepro_core::TimeoutPhase::Statement, + server_cancelled: false, + }), + used = using => used.map_err(|error| use_keyspace_error(error, LossPhase::Statement)), + } + } + fn state(&self) -> EditorSessionState { match self.state.load(Ordering::SeqCst) { READY => EditorSessionState::Ready, @@ -375,3 +391,10 @@ fn lost(error: &DriverError) -> bool { ErrorCategory::Network | ErrorCategory::ConnectionLost | ErrorCategory::Tls ) } + +async fn until(deadline: Option) { + match deadline { + Some(at) => tokio::time::sleep_until(at).await, + None => std::future::pending().await, + } +} diff --git a/linux/crates/drivers/cassandra/src/errors.rs b/linux/crates/drivers/cassandra/src/errors.rs index 0750d4f0b6..1098400562 100644 --- a/linux/crates/drivers/cassandra/src/errors.rs +++ b/linux/crates/drivers/cassandra/src/errors.rs @@ -88,7 +88,7 @@ fn pool_error(error: ConnectionPoolError, during: LossPhase) -> DriverError { } } -fn use_keyspace_error(error: UseKeyspaceError, during: LossPhase) -> DriverError { +pub(crate) fn use_keyspace_error(error: UseKeyspaceError, during: LossPhase) -> DriverError { match error { UseKeyspaceError::RequestError(attempt) => attempt_error(attempt, during), other => DriverError::server(other.to_string()), diff --git a/linux/crates/drivers/clickhouse/src/editor_session.rs b/linux/crates/drivers/clickhouse/src/editor_session.rs index 4d00aacd94..8a566e9c29 100644 --- a/linux/crates/drivers/clickhouse/src/editor_session.rs +++ b/linux/crates/drivers/clickhouse/src/editor_session.rs @@ -1,10 +1,10 @@ -use std::sync::Arc; +use std::sync::{Arc, Mutex, PoisonError}; use std::time::Duration; use async_trait::async_trait; use tablepro_core::{ CallOptions, DriverError, EditorSession, EditorSessionId, EditorSessionOptions, EditorSessionState, LossPhase, - ResultSink, RunOptions, RunSummary, TimeoutPhase, TransactionState, UserSql, + ResultSink, RunEnd, RunOptions, RunSummary, TimeoutPhase, TransactionState, UserSql, }; use tokio_util::sync::CancellationToken; use uuid::Uuid; @@ -24,7 +24,9 @@ use crate::session_status::SessionStatus; /// takes no scripts. pub(crate) struct ClickHouseEditorSession { id: EditorSessionId, - session: clickhouse::Client, + /// Every request names its database, which overrides a `USE` the + /// session ran, so the tab's database lives in the client. + session: Arc>, control: clickhouse::Client, statement_timeout: Option, grace: Duration, @@ -34,6 +36,10 @@ pub(crate) struct ClickHouseEditorSession { } impl ClickHouseEditorSession { + fn client(&self) -> clickhouse::Client { + self.session.lock().unwrap_or_else(PoisonError::into_inner).clone() + } + /// Open the session with one trivial question, so a session the /// server will not create fails here rather than on the first run. pub(crate) async fn open( @@ -59,7 +65,7 @@ impl ClickHouseEditorSession { id: EditorSessionId::new(), // Without the check, a run on a session the server let expire // quietly starts a new, empty one under the same id. - session: session.with_setting("session_check", "1"), + session: Arc::new(Mutex::new(session.with_setting("session_check", "1"))), control: endpoint.control().clone(), statement_timeout: options.statement_timeout, grace: liveness.cancel_grace, @@ -76,10 +82,26 @@ impl EditorSession for ClickHouseEditorSession { self.id } - /// Every request names the database, which a `USE` in the session - /// does not override. fn database(&self) -> Option { - self.session.database().map(str::to_owned) + self.client().database().map(str::to_owned) + } + + /// The next request names `database`, once a request there answers. + async fn use_database(&self, database: &str, options: CallOptions) -> Result<(), DriverError> { + let moved = self.client().with_database(database); + let asking = drain(moved.query_raw("SELECT 1")); + let answered = tokio::select! { + biased; + () = options.cancel().cancelled() => return Err(DriverError::Cancelled), + () = until(options.deadline()) => return Err(DriverError::Timeout { + phase: TimeoutPhase::Statement, + server_cancelled: false, + }), + answered = asking => answered, + }; + answered?; + *self.session.lock().unwrap_or_else(PoisonError::into_inner) = moved; + Ok(()) } fn state(&self) -> EditorSessionState { @@ -97,17 +119,27 @@ impl EditorSession for ClickHouseEditorSession { ) -> Result { self.status.begin()?; let guard = options.cancel.clone().drop_guard(); + let used = + tablepro_core::sql_syntax::used_database(sql.as_str(), tablepro_core::sql_syntax::SqlGrammar::ClickHouse); let run = EditorRun { - session: self.session.clone(), + session: self.client(), control: self.control.clone(), statement_timeout: self.statement_timeout, grace: self.grace, shutdown: self.shutdown.clone(), lingering: self.lingering.clone(), }; - let status = self.status.clone(); + let (status, client) = (self.status.clone(), self.session.clone()); let running = tokio::spawn(async move { let outcome = run.run(sql, options, sink).await; + // The server took the `USE`; the requests after it name that + // database, as the session would have. + if let (Ok(summary), Some(database)) = (&outcome, used) + && summary.end == RunEnd::Completed + { + let mut client = client.lock().unwrap_or_else(PoisonError::into_inner); + *client = client.clone().with_database(database); + } match &outcome { // The server still held the session for a stopped query: // the session itself is fine. @@ -143,6 +175,13 @@ impl EditorSession for ClickHouseEditorSession { } } +async fn until(deadline: Option) { + match deadline { + Some(at) => tokio::time::sleep_until(at).await, + None => std::future::pending().await, + } +} + #[cfg(test)] mod tests { use async_trait::async_trait; @@ -171,6 +210,64 @@ mod tests { .await } + struct Collect(Arc>>>); + + #[async_trait] + impl ResultSink for Collect { + async fn accept(&mut self, event: ResultEvent) -> SinkFlow { + if let ResultEvent::Rows(rows) = event { + self.0.lock().unwrap_or_else(PoisonError::into_inner).extend(rows); + } + SinkFlow::Continue + } + } + + async fn current_database(session: &ClickHouseEditorSession) -> Vec> { + let rows = Arc::new(Mutex::new(Vec::new())); + session + .run( + UserSql::new("SELECT currentDatabase()"), + RunOptions::new(CancellationToken::new()), + Box::new(Collect(rows.clone())), + ) + .await + .expect("a run"); + let rows = rows.lock().unwrap_or_else(PoisonError::into_inner).clone(); + rows + } + + /// Every request names its database, so a `USE` lasts only because + /// the session's next requests name the database it chose. + #[tokio::test] + #[ignore = "requires docker"] + async fn a_session_runs_in_the_database_it_moved_to() { + let fixture = ClickHouseFixture::start().await.expect("a ClickHouse container"); + let endpoint = ClickHouseEndpoint::for_fixture(&fixture, test_liveness()); + let session = + ClickHouseEditorSession::open(&endpoint, EditorSessionOptions::default(), CancellationToken::new()) + .await + .expect("an editor session"); + let text = |name: &str| vec![vec![tablepro_core::Value::Text(name.to_owned())]]; + + session + .use_database("system", CallOptions::new(None, CancellationToken::new())) + .await + .expect("a move to system"); + assert_eq!(current_database(&session).await, text("system")); + assert_eq!(session.database().as_deref(), Some("system")); + + let used = run(&session, "USE default").await; + assert_eq!(used.map(|summary| summary.end), Ok(RunEnd::Completed)); + assert_eq!(current_database(&session).await, text("default")); + assert_eq!(session.database().as_deref(), Some("default")); + + let refused = session + .use_database("no_such_database", CallOptions::new(None, CancellationToken::new())) + .await; + assert!(refused.is_err(), "{refused:?}"); + assert_eq!(session.database().as_deref(), Some("default"), "a refused move moved"); + } + #[tokio::test] #[ignore = "requires docker"] async fn a_session_the_server_let_expire_is_lost_not_replaced() { diff --git a/linux/crates/drivers/mongodb/src/editor_session.rs b/linux/crates/drivers/mongodb/src/editor_session.rs index be9f35e515..121b1f6026 100644 --- a/linux/crates/drivers/mongodb/src/editor_session.rs +++ b/linux/crates/drivers/mongodb/src/editor_session.rs @@ -68,6 +68,17 @@ impl EditorSession for MongoEditorSession { Some(self.database.lock().unwrap_or_else(PoisonError::into_inner).clone()) } + /// As the shell's `use`: the next statements name the database. + async fn use_database(&self, database: &str, _options: CallOptions) -> Result<(), DriverError> { + if self.status.state() == EditorSessionState::Lost { + return Err(DriverError::ConnectionLost { + during: LossPhase::Idle, + }); + } + *self.database.lock().unwrap_or_else(PoisonError::into_inner) = database.to_owned(); + Ok(()) + } + fn state(&self) -> EditorSessionState { self.status.state() } diff --git a/linux/crates/drivers/mssql/src/dialect.rs b/linux/crates/drivers/mssql/src/dialect.rs index b36f450c87..2e2b2a4660 100644 --- a/linux/crates/drivers/mssql/src/dialect.rs +++ b/linux/crates/drivers/mssql/src/dialect.rs @@ -41,6 +41,10 @@ impl MssqlDialect { } } +pub(crate) fn quote(name: &str) -> String { + format!("[{}]", name.replace(']', "]]")) +} + impl SqlDialect for MssqlDialect { fn database_layout(&self) -> DatabaseLayout { DatabaseLayout::WithSchemas @@ -60,7 +64,7 @@ impl SqlDialect for MssqlDialect { } fn quote_identifier(&self, name: &str) -> String { - format!("[{}]", name.replace(']', "]]")) + quote(name) } fn placeholder(&self, ordinal: usize, value: Value, target: BindTarget<'_>) -> Result { diff --git a/linux/crates/drivers/mssql/src/editor_run.rs b/linux/crates/drivers/mssql/src/editor_run.rs index cbcaf2f7c1..6e7a9df7de 100644 --- a/linux/crates/drivers/mssql/src/editor_run.rs +++ b/linux/crates/drivers/mssql/src/editor_run.rs @@ -247,6 +247,15 @@ pub(crate) async fn transaction_state(client: &mut MssqlClient) -> Result Result<(), DriverError> { + let statement = format!("USE {}", crate::dialect::quote(database)); + let stream = client.simple_query(statement).await.map_err(map_error)?; + stream.into_results().await.map_err(map_error)?; + Ok(()) +} + /// The database the session is in, from the server itself. pub(crate) async fn current_database(client: &mut MssqlClient) -> Result, DriverError> { let stream = client.simple_query("SELECT DB_NAME()").await.map_err(map_error)?; diff --git a/linux/crates/drivers/mssql/src/editor_session.rs b/linux/crates/drivers/mssql/src/editor_session.rs index 372ffaffd9..0e6f63ffe7 100644 --- a/linux/crates/drivers/mssql/src/editor_session.rs +++ b/linux/crates/drivers/mssql/src/editor_session.rs @@ -12,7 +12,7 @@ use tokio::sync::OwnedMutexGuard; use tokio::time::Instant; use tokio_util::sync::CancellationToken; -use crate::editor_run::{EditorRun, ScriptOutcome, current_database, transaction_state}; +use crate::editor_run::{EditorRun, ScriptOutcome, current_database, transaction_state, use_database}; use crate::endpoint::MssqlEndpoint; use crate::errors::ends_connection; use crate::in_band_request::StopPolicy; @@ -247,6 +247,55 @@ impl EditorSession for MssqlEditorSession { outcome } + async fn use_database(&self, database: &str, options: CallOptions) -> Result<(), DriverError> { + let shared = &self.shared; + let mut held = tokio::select! { + biased; + () = options.cancel().cancelled() => return Err(DriverError::Cancelled), + () = elapsed(options.deadline()) => { + return Err(DriverError::Timeout { phase: TimeoutPhase::Statement, server_cancelled: false }); + } + held = shared.link.lock() => held, + }; + let Some(link) = held.as_mut().filter(|_| !shared.lost.load(Ordering::SeqCst)) else { + return Err(DriverError::ConnectionLost { + during: LossPhase::Idle, + }); + }; + let using = use_database(&mut link.client, database); + let used = tokio::select! { + biased; + () = options.cancel().cancelled() => None, + () = elapsed(options.deadline()) => None, + used = using => Some(used), + }; + match used { + Some(Ok(())) => { + *shared.database.lock().unwrap_or_else(PoisonError::into_inner) = Some(database.to_owned()); + Ok(()) + } + Some(Err(error)) => { + if ends_connection(&error) { + shared.lost.store(true, Ordering::SeqCst); + *held = None; + } + Err(error) + } + // Cut off mid-request, the connection is out of step. + None => { + shared.lost.store(true, Ordering::SeqCst); + *held = None; + Err(match options.cancel().is_cancelled() { + true => DriverError::Cancelled, + false => DriverError::Timeout { + phase: TimeoutPhase::Statement, + server_cancelled: false, + }, + }) + } + } + } + async fn transaction_state(&self, options: CallOptions) -> Result { let shared = &self.shared; let mut held = tokio::select! { diff --git a/linux/crates/drivers/mssql/src/metadata.rs b/linux/crates/drivers/mssql/src/metadata.rs index 08d452420d..f098234f89 100644 --- a/linux/crates/drivers/mssql/src/metadata.rs +++ b/linux/crates/drivers/mssql/src/metadata.rs @@ -24,13 +24,13 @@ const SCHEMAS_SQL: &str = "SELECT s.name, \ THEN 1 ELSE 0 END AS int) \ FROM sys.schemas s ORDER BY s.name"; -/// The databases the login may open, the connection's own the default -/// and the four the server keeps for itself (`database_id` 1 to 4) as -/// system ones. +/// The online databases the login may open, the connection's own the +/// default and the four the server keeps for itself (`database_id` 1 to +/// 4) as system ones. const DATABASES_SQL: &str = "SELECT d.name, \ CAST(CASE WHEN d.name = DB_NAME() THEN 1 ELSE 0 END AS int), \ CAST(CASE WHEN d.database_id <= 4 THEN 1 ELSE 0 END AS int) \ - FROM sys.databases d WHERE HAS_DBACCESS(d.name) = 1 ORDER BY d.name"; + FROM sys.databases d WHERE d.state = 0 AND HAS_DBACCESS(d.name) = 1 ORDER BY d.name"; const OBJECTS_SQL: &str = "SELECT SCHEMA_NAME(o.schema_id), o.name, RTRIM(o.type) \ FROM sys.objects o \ diff --git a/linux/crates/drivers/mssql/tests/integration/sessions.rs b/linux/crates/drivers/mssql/tests/integration/sessions.rs index ae1e0008a0..4364a74859 100644 --- a/linux/crates/drivers/mssql/tests/integration/sessions.rs +++ b/linux/crates/drivers/mssql/tests/integration/sessions.rs @@ -24,6 +24,32 @@ async fn use_database_persists_across_runs() { ); } +/// The editor bar's move: a bracketed name carries a `]`, and a database +/// the server refuses leaves the session where it was. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore = "requires docker"] +async fn a_session_moves_to_a_database_by_its_name() { + let fixture = start().await; + let connection = connect(&fixture, false).await; + setup( + connection.as_ref(), + &["IF DB_ID(N'odd]name') IS NULL CREATE DATABASE [odd]]name]"], + ) + .await; + let session = session(connection.as_ref()).await; + let options = || tablepro_core::CallOptions::new(None, CancellationToken::new()); + + session.use_database("odd]name", options()).await.expect("a move"); + let database = run_ok(session.as_ref(), "SELECT DB_NAME()").await.rows(); + assert_eq!(database, vec![vec![Value::Text("odd]name".to_owned())]]); + assert_eq!(session.database().as_deref(), Some("odd]name")); + + let refused = session.use_database("no_such_database", options()).await; + assert!(refused.is_err(), "{refused:?}"); + let database = run_ok(session.as_ref(), "SELECT DB_NAME()").await.rows(); + assert_eq!(database, vec![vec![Value::Text("odd]name".to_owned())]]); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[ignore = "requires docker"] async fn begin_tran_insert_big_select_commit_persists() { From 2bb98f99ae8242db992c4a8db3212bbdd5759277 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 30 Oct 2026 17:42:08 +0700 Subject: [PATCH 23/53] fix(linux-editor): open a PostgreSQL tab's session in its database through the slot's one move --- linux/crates/core/src/editor_session.rs | 6 +++--- .../crates/drivers/postgres/src/editor/editor_session.rs | 8 ++++++++ linux/crates/drivers/postgres/src/physical.rs | 4 ++++ .../drivers/postgres/tests/integration/editor_session.rs | 6 ++++++ 4 files changed, 21 insertions(+), 3 deletions(-) diff --git a/linux/crates/core/src/editor_session.rs b/linux/crates/core/src/editor_session.rs index 96ca851bb0..670d05dabb 100644 --- a/linux/crates/core/src/editor_session.rs +++ b/linux/crates/core/src/editor_session.rs @@ -43,9 +43,9 @@ pub enum EditorSessionState { #[derive(Debug, Clone, Default)] pub struct EditorSessionOptions { pub statement_timeout: Option, - /// Another of the server's databases to open the session in, for an - /// engine whose connection stays in the database it opened in. - /// `None` opens it in the connection's own. + /// The database to open the session in, where the engine binds a + /// session's database when it connects. Elsewhere the session opens in + /// the connection's and `EditorSession::use_database` moves it. pub database: Option, } diff --git a/linux/crates/drivers/postgres/src/editor/editor_session.rs b/linux/crates/drivers/postgres/src/editor/editor_session.rs index 44afb469f1..7df7283131 100644 --- a/linux/crates/drivers/postgres/src/editor/editor_session.rs +++ b/linux/crates/drivers/postgres/src/editor/editor_session.rs @@ -24,6 +24,8 @@ use crate::physical::PgEndpoint; /// are enforced with cancel requests. pub(crate) struct PgEditorSession { id: EditorSessionId, + /// A connection stays in the database it opened in. + database: Option, commands: mpsc::Sender, status: Arc, shutdown: CancellationToken, @@ -39,6 +41,7 @@ impl PgEditorSession { cancel: CancellationToken, ) -> Result, DriverError> { let (client, connection) = endpoint.open(&cancel).await?; + let database = endpoint.database().map(str::to_owned); let close_timeout = endpoint.liveness().close_timeout; let (notice_sender, notices) = mpsc::unbounded_channel(); // PostgreSQL has defaulted to on since 9.1; the server reports @@ -68,6 +71,7 @@ impl PgEditorSession { )); Ok(Arc::new(Self { id: EditorSessionId::new(), + database, commands, status, shutdown, @@ -87,6 +91,10 @@ impl EditorSession for PgEditorSession { self.status.state() } + fn database(&self) -> Option { + self.database.clone() + } + async fn run( &self, sql: UserSql, diff --git a/linux/crates/drivers/postgres/src/physical.rs b/linux/crates/drivers/postgres/src/physical.rs index 255849e8bf..216443a778 100644 --- a/linux/crates/drivers/postgres/src/physical.rs +++ b/linux/crates/drivers/postgres/src/physical.rs @@ -68,6 +68,10 @@ impl PgEndpoint { } } + pub(crate) fn database(&self) -> Option<&str> { + self.config.get_dbname() + } + pub(crate) fn liveness(&self) -> &LivenessPolicy { &self.liveness } diff --git a/linux/crates/drivers/postgres/tests/integration/editor_session.rs b/linux/crates/drivers/postgres/tests/integration/editor_session.rs index 1a103a2be1..63eb936270 100644 --- a/linux/crates/drivers/postgres/tests/integration/editor_session.rs +++ b/linux/crates/drivers/postgres/tests/integration/editor_session.rs @@ -562,5 +562,11 @@ async fn a_session_opened_in_another_database_runs_there() { .expect("a session in postgres"); let (_, events) = run(elsewhere.as_ref(), "SELECT current_database()").await; assert_eq!(texts(&events), ["postgres"]); + assert_eq!(elsewhere.database().as_deref(), Some("postgres")); + let moved = elsewhere.use_database("tablepro", within(CALL)).await; + assert!( + matches!(moved, Err(tablepro_core::DriverError::Unsupported { .. })), + "a live session moved: {moved:?}" + ); elsewhere.close().await; } From ef2d68c0b9966a308c9cebb0d47e3b688e4e5759 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 30 Oct 2026 17:45:03 +0700 Subject: [PATCH 24/53] fix(linux-session): open a sibling session at the connection's Safe Mode level --- linux/crates/session/src/session/session.rs | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/linux/crates/session/src/session/session.rs b/linux/crates/session/src/session/session.rs index 520c6162b4..6de16ac416 100644 --- a/linux/crates/session/src/session/session.rs +++ b/linux/crates/session/src/session/session.rs @@ -309,7 +309,11 @@ impl Session { .connector .establish(refreshed.request, CredentialInteraction::Unattended, cancel) .await?; - Ok(Session::start(pending, self.origin.deps.clone())) + let sibling = Session::start(pending, self.origin.deps.clone()); + // The level the user chose since the connection opened, which a + // read-only connection's sibling shares by opening read-only too. + sibling.set_safe_mode(self.safe_mode()); + Ok(sibling) } /// The SSH tunnel the connection was made through, if any. @@ -536,11 +540,18 @@ mod tests { }, ); + assert!(session.set_safe_mode(SafeModeLevel::ConfirmWrites)); + let sibling = session .open_in_database("analytics", CancellationToken::new()) .await .expect("a session in analytics"); + assert_eq!( + sibling.safe_mode(), + SafeModeLevel::ConfirmWrites, + "the sibling dropped the level" + ); assert_eq!(session.database(), Some("shop")); assert_eq!(sibling.database(), Some("analytics")); let call = connector.calls().pop().expect("an attempt"); From a283924800059f9653d7ccfe3c8e1a9fd25c35dd Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 30 Oct 2026 17:45:28 +0700 Subject: [PATCH 25/53] docs(linux): record the design the database switch is built on --- ...0030-a-database-is-a-session-of-its-own.md | 38 ++++++++----------- 1 file changed, 16 insertions(+), 22 deletions(-) diff --git a/linux/docs/decisions/0030-a-database-is-a-session-of-its-own.md b/linux/docs/decisions/0030-a-database-is-a-session-of-its-own.md index ab14c55e33..f2a1d5d0b0 100644 --- a/linux/docs/decisions/0030-a-database-is-a-session-of-its-own.md +++ b/linux/docs/decisions/0030-a-database-is-a-session-of-its-own.md @@ -1,44 +1,38 @@ -# 0030: Another database of a connection is a session of its own in the window +# 0030: A query tab's database, and another database as a session of its own - **Status**: Accepted - **Date**: 2026-10-30 ## Context -A session is one connection to one database ([0028](0028-sessions-share-a-window.md)). The editor bar lets a query tab move its own editor session to another of the server's databases. The window's sidebar, its catalog and completion still read the database the connection opened, so after a move the tab's tables are missing from both. On SQL Server and PostgreSQL, where a database holds schemas, completion offers nothing. - -The macOS app keeps a connection per database where the engine needs one and points the window at the database of the tab in front: its sidebar shows that database, and a tab bound to another one is hidden until the window returns there (`BrowseCursorCoordinator`, `QueryTabManager.reconcileVisibleContainer`). +A session is one connection to one database ([0028](0028-sessions-share-a-window.md)). A query tab can run in another of the server's databases, through the editor bar or a `USE` the user types. Engines get there in different ways: MySQL, MariaDB and SQL Server move a live session with `USE`, ClickHouse names the database in every request, Cassandra moves its keyspace, MongoDB's shell keeps a current database, and PostgreSQL binds the database when the connection opens. The window's sidebar and the completion catalog read the database the connection opened. [0012](0012-window-per-session.md) rejected a session that changes in place under its parts, and 0028 keeps a tab on the session it was built with. ## Decision -Each other database of a connection the user opens is a sibling session in the same window, with a workspace of its own, made with `Session::open_in_database`. The sidebar shows each open database of a connection as a branch under it. A query tab moved to a database from the editor bar opens or reaches that database's session, and the window follows it there. +A query tab keeps its database, and its own editor session goes there. `EditorSession::use_database` moves a live session by the engine's own means; where the engine binds the database at connect, the driver refuses it and the tab gets an editor session opened in that database (`EditorSessionOptions::database`). A session reports the database a `USE` left it in, and the tab follows. The tab never changes session: its workspace, Safe Mode and teardown stay its connection's. + +What the window shows of another database is a sibling session, `Session::open_in_database`: the same server and login, opened as a reconnect is. It joins the window with a workspace and a sidebar row of its own, from Open Database… on a connection's row, or when a tab moves to that database. Completion in a moved tab reads the sibling's catalog. ## Rationale -- **No session changes under its parts.** A sibling is another session, so what 0012 and 0028 protect holds: a workspace never changes database, and a tab never changes session. A table opened from a database's branch is built on that database's session. -- **The catalog is the database's own.** Each sibling's catalog lists its database, so the sidebar, completion and Show CREATE TABLE are right on every engine, the ones whose databases hold schemas included. -- **One way in.** A sibling joins the window as any session does, so its tabs, its unsaved edits, its banners and its teardown follow what a connection's already do. -- **The same secret and no prompt.** A sibling opens as a reconnect does, through the window's connector and the stored secret (#3900). +- **A tab keeps what it built.** A `USE` keeps the tab's temporary tables and settings. The database menu waits while the tab holds a transaction, so a move never rolls one back. +- **The tab's session does not wait on the window's.** A PostgreSQL tab opens in its database over its own connection, with the secret the connection already holds, so a restored tab or a lost connection reopens there without a sibling. +- **The catalog is the database's own.** A sibling lists its own database, so the sidebar and completion are right on engines whose databases hold schemas. Until it opens, completion reads the connection's catalog where the server's databases are its schemas (MySQL, ClickHouse, Cassandra, MongoDB) and offers no tables where they are not. +- **No session changes under its parts.** A sibling is a session like any other, so 0012 and 0028 hold. ## Consequences -- The registry keeps a window's key per saved connection; a sibling is keyed by the connection and its database, so the same database of a connection never opens twice. -- The sidebar's connection row gets Open Database…, from the server's databases, and each open database is a branch the user can close. -- A tab names its database beside its connection, as "orders (prod, analytics)", while another database of the connection has a tab in the bar. -- The restore record keeps each window's siblings with their tabs. -- The editor bar's move runs on the tab's own editor session as before; the window following it is the sidebar selecting that database's branch and completion reading its catalog. -- A file connection has no other database. +- A sibling opens with the stored secret and never prompts; one that cannot open says so in a toast and the tab still runs in its database. +- The registry keys a sibling by its connection and database, so a database opens once per window. It goes when its connection disconnects. +- A tab names its database in its title when it is not the connection's. +- The workspace record keeps a tab's database, and a restore reopens a connection's open databases beside it once it has joined. ## Alternatives considered -**One session that follows the tab in front, as macOS's window does.** Every part reading the session would have to be reset at each tab switch, which 0012 rejected, and a table tab built for one database would run on another. - -**Hide the tabs of the other databases, as macOS does.** 0028 chose tabs of several sessions side by side over a strip that hides them; a database is a session here, so its tabs show beside the others with its name in the title. - -**Only completion follows the tab.** Smaller, but the sidebar and every other tab stay on the connection's database, which is the gap this closes. +**One session that follows the tab in front.** Every part reading the session would reset at each tab switch, which 0012 rejected. -## Plan +**Every move opens a new editor session.** One path for every engine, but a `USE` engine would lose its temporary tables and settings at each move. -In order, each its own pull request: the sibling session (#3900); the registry key and a sibling joining the window; Open Database… and the database branches in the sidebar; the editor bar's move reaching the database's session, with completion on its catalog; tab titles; the restore record. +**A PostgreSQL tab runs on its sibling's connection.** The tab would depend on a window session the user can close, and a password typed only for the connection could not open it. From b2dc9793419488dfe6b96a9ebeea08590619fd05 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 30 Oct 2026 17:45:33 +0700 Subject: [PATCH 26/53] docs(linux): name the ADR's decision in the index --- linux/docs/decisions/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/linux/docs/decisions/README.md b/linux/docs/decisions/README.md index 5fd248db51..734ee04abe 100644 --- a/linux/docs/decisions/README.md +++ b/linux/docs/decisions/README.md @@ -77,7 +77,7 @@ A fact the repository keeps elsewhere, such as a workflow trigger, a branch, a v | [0027](0027-one-data-grid.md) | One data grid on GtkColumnView, with rows and a focused cell | Accepted | Every surface shows rows in one `DataGrid`: row selection plus a focused cell, stable row slots, a row number column, values as text and a row detail pane. | | [0028](0028-sessions-share-a-window.md) | A window holds several sessions in one tab view | Accepted | A `WorkspaceShell` per window shares one tab view among its sessions, each with its own workspace; the sidebar is a tree of the saved connections with each open session's schemas and tables. | | [0029](0029-images-decode-in-glycin.md) | Images from the database decode in glycin's sandbox | Accepted | Row Details decodes a value's picture with the glycin crate 3.x, in a sandboxed loader process, never with GTK's in-process loaders. | -| [0030](0030-a-database-is-a-session-of-its-own.md) | Another database of a connection is a session of its own in the window | Accepted | A database the user opens beside the connection's own is a sibling session with its own workspace and sidebar branch; a query tab moved to it from the editor bar reaches that session, and the window follows it. | +| [0030](0030-a-database-is-a-session-of-its-own.md) | A query tab's database, and another database as a session of its own | Accepted | A query tab's own editor session moves to its database through the driver, or opens there where the engine binds it at connect; another database the window shows is a sibling session with its own workspace and sidebar row. | 0011 is not used. It was reserved for the DDL decision, which was recorded as 0010, and docs cite ADRs by number, so the later ones keep theirs. From b59e7c6f600414c75686d1be7f47ad23f049b48a Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 30 Oct 2026 17:49:40 +0700 Subject: [PATCH 27/53] fix(linux-workspace): close a database that opened after its connection went --- .../app/src/ui/session_window/sibling_sessions.rs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/linux/crates/app/src/ui/session_window/sibling_sessions.rs b/linux/crates/app/src/ui/session_window/sibling_sessions.rs index d769117bc2..564c3fd53e 100644 --- a/linux/crates/app/src/ui/session_window/sibling_sessions.rs +++ b/linux/crates/app/src/ui/session_window/sibling_sessions.rs @@ -67,7 +67,15 @@ impl SessionWindow { let window = self.downgrade(); glib::spawn_future_local(async move { let opened = opening.await; - let Some(window) = window.upgrade() else { + // A database goes with its connection, which may have gone + // while it opened. + let window = window + .upgrade() + .filter(|window| window.held_session(&SessionKey::Saved(id)).is_some()); + let Some(window) = window else { + if let Ok(Ok(session)) = opened { + drop(session.begin_shutdown()); + } return; }; match opened { From cbe0a1105f705f82a932b4f56046fea11214f5a3 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 30 Oct 2026 17:54:49 +0700 Subject: [PATCH 28/53] fix(linux-editor): end a session whose database after a USE the server did not say --- .../drivers/mssql/src/editor_session.rs | 5 ++-- .../drivers/mysql/src/editor_session.rs | 24 ++++++++++++------- 2 files changed, 19 insertions(+), 10 deletions(-) diff --git a/linux/crates/drivers/mssql/src/editor_session.rs b/linux/crates/drivers/mssql/src/editor_session.rs index 372ffaffd9..d7145794de 100644 --- a/linux/crates/drivers/mssql/src/editor_session.rs +++ b/linux/crates/drivers/mssql/src/editor_session.rs @@ -184,8 +184,9 @@ impl Shared { if follow_database { match tokio::time::timeout(self.cancel_grace, current_database(&mut link.client)).await { Ok(Ok(current)) => *self.database.lock().unwrap_or_else(PoisonError::into_inner) = current, - Ok(Err(error)) => return (TransactionState::Unknown, !ends_connection(&error)), - Err(_) => return (TransactionState::Unknown, false), + // A session in a database nothing knows ends rather than + // run the next script in the wrong one. + Ok(Err(_)) | Err(_) => return (TransactionState::Unknown, false), } } match tokio::time::timeout(self.cancel_grace, transaction_state(&mut link.client)).await { diff --git a/linux/crates/drivers/mysql/src/editor_session.rs b/linux/crates/drivers/mysql/src/editor_session.rs index 13d86f79cd..d2a5587a7a 100644 --- a/linux/crates/drivers/mysql/src/editor_session.rs +++ b/linux/crates/drivers/mysql/src/editor_session.rs @@ -131,11 +131,16 @@ impl Shared { let started = Instant::now(); self.running.store(true, Ordering::SeqCst); let supervised = self.supervise(held, &sql, &options, sink.as_mut()).await; + // A session in a database nothing knows would run the next script + // in the wrong one, so it ends instead. + let placed = match &supervised { + Ok((_, true)) if may_change_database(sql.as_str(), SqlGrammar::MySql) => { + self.follow_database(&mut held.connection).await + } + _ => true, + }; let transaction_state = match &supervised { - Ok((_, true)) => { - if may_change_database(sql.as_str(), SqlGrammar::MySql) { - self.follow_database(&mut held.connection).await; - } + Ok((_, true)) if placed => { let asking = self.probe.state(&mut held.connection); tokio::time::timeout(self.liveness.cancel_grace, asking) .await @@ -145,8 +150,8 @@ impl Shared { }; self.running.store(false, Ordering::SeqCst); let end = match supervised { - Ok((end, true)) => end, - Ok((end, false)) => { + Ok((end, true)) if placed => end, + Ok((end, _)) => { self.lost.store(true, Ordering::SeqCst); *guard = None; end @@ -164,16 +169,19 @@ impl Shared { }) } - async fn follow_database(&self, connection: &mut MySqlConnection) { + /// Ask where a `USE` left the session. False when the answer did not + /// come. + async fn follow_database(&self, connection: &mut MySqlConnection) -> bool { let asking = connection.fetch_one(sqlx::raw_sql("SELECT DATABASE()")); let Ok(Ok(row)) = tokio::time::timeout(self.liveness.cancel_grace, asking).await else { - return; + return false; }; let current = match decode_row(&row, MySqlWireFormat::Text, MySqlTimestampMode::SessionLocal).first() { Some(Value::Text(name)) => Some(name.clone()), _ => None, }; *self.database.lock().unwrap_or_else(PoisonError::into_inner) = current; + true } /// Run the script, stopping it on a cancel, at the deadline, when From 3738fe25385f90250e67763af2ec16f11fe700a3 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 30 Oct 2026 17:55:17 +0700 Subject: [PATCH 29/53] fix(linux-editor): end a run only once the tab knows the database it left the session in --- linux/crates/app/src/ui/editor/script_runner.rs | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/linux/crates/app/src/ui/editor/script_runner.rs b/linux/crates/app/src/ui/editor/script_runner.rs index d3901a8c16..4d0d1356cd 100644 --- a/linux/crates/app/src/ui/editor/script_runner.rs +++ b/linux/crates/app/src/ui/editor/script_runner.rs @@ -126,7 +126,8 @@ pub struct AfterRun { } /// Send each batch in turn, reporting each one as it ends. `transaction` -/// is where the session's transaction stood before the run. +/// is where the session's transaction stood before the run. The caller +/// says `Finished` once the tab knows where the run left it. pub async fn run_batches( editor: &Arc, run: &ScriptRun, @@ -201,7 +202,6 @@ pub async fn run_batches( } } } - let _ = events.send(RunEvent::Finished).await; after } @@ -261,12 +261,15 @@ pub async fn run_in_tab( slot.follow(&editor); if editor.state() != EditorSessionState::Lost { slot.note_transaction(&editor, after.transaction); + let _ = events.send(RunEvent::Finished).await; return; } let generation = *session.link_changes().borrow(); session.signal(MonitorSignal::ProbeRequested { generation }); let refuse_next = transaction_open(after.transaction) && !after.rollback_told; - if slot.forget(&editor, refuse_next) { + let forgotten = slot.forget(&editor, refuse_next); + let _ = events.send(RunEvent::Finished).await; + if forgotten { close_quietly(editor, close_timeout).await; } } @@ -399,7 +402,11 @@ mod tests { } log }; - let (_, log) = tokio::join!(run_batches(&session, run, cancel, &events, None), collect); + let running = async { + run_batches(&session, run, cancel, &events, None).await; + let _ = events.send(RunEvent::Finished).await; + }; + let (_, log) = tokio::join!(running, collect); log } From 755a489653abc749aab162439b09906843c71b14 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 30 Oct 2026 17:56:17 +0700 Subject: [PATCH 30/53] fix(linux-editor): report only the keyspace Cassandra confirmed, and end a session whose move did not finish --- linux/crates/drivers/cassandra/src/editor.rs | 37 ++++++++++++++++---- 1 file changed, 31 insertions(+), 6 deletions(-) diff --git a/linux/crates/drivers/cassandra/src/editor.rs b/linux/crates/drivers/cassandra/src/editor.rs index a35c20c3d1..84c6351594 100644 --- a/linux/crates/drivers/cassandra/src/editor.rs +++ b/linux/crates/drivers/cassandra/src/editor.rs @@ -4,8 +4,8 @@ //! only that tab, as cqlsh moves only its own connection. CQL keeps no //! other state on a connection: no transaction, no temporary table. -use std::sync::Arc; use std::sync::atomic::{AtomicU8, Ordering}; +use std::sync::{Arc, Mutex, PoisonError}; use std::time::Duration; use async_trait::async_trait; @@ -40,12 +40,17 @@ pub(crate) struct CqlEditorSession { statement_timeout: Option, state: Arc, shutdown: CancellationToken, + /// The keyspace the server confirmed. The driver records one it is + /// still moving to, or failed to. + keyspace: Arc>>, } impl CqlEditorSession { pub(crate) fn new(link: CqlLink, read_only: bool, statement_timeout: Option) -> Self { + let keyspace = link.session().get_keyspace().map(|keyspace| keyspace.to_string()); Self { id: EditorSessionId::new(), + keyspace: Arc::new(Mutex::new(keyspace)), link: LinkSlot::new(link), read_only, statement_timeout, @@ -74,18 +79,19 @@ impl EditorSession for CqlEditorSession { self.id } - /// The driver moves the session itself when a `USE` answers. fn database(&self) -> Option { - let link = self.link.get().ok()?; - link.session().get_keyspace().map(|keyspace| keyspace.to_string()) + self.keyspace.lock().unwrap_or_else(PoisonError::into_inner).clone() } /// The driver checks the name, which CQL takes as letters, digits and - /// `_` alone, and moves every connection of the session. + /// `_` alone, and moves every connection of the session. A move that + /// does not finish leaves them in keyspaces nothing knows, so the + /// session ends. async fn use_database(&self, database: &str, options: CallOptions) -> Result<(), DriverError> { let link = self.link.get()?; + self.begin()?; let using = link.session().use_keyspace(database, true); - tokio::select! { + let used = tokio::select! { biased; () = options.cancel().cancelled() => Err(DriverError::Cancelled), () = until(options.deadline()) => Err(DriverError::Timeout { @@ -93,6 +99,18 @@ impl EditorSession for CqlEditorSession { server_cancelled: false, }), used = using => used.map_err(|error| use_keyspace_error(error, LossPhase::Statement)), + }; + match used { + Ok(()) => { + *self.keyspace.lock().unwrap_or_else(PoisonError::into_inner) = Some(database.to_owned()); + self.state.store(READY, Ordering::SeqCst); + Ok(()) + } + Err(error) => { + self.state.store(LOST, Ordering::SeqCst); + self.link.release(); + Err(error) + } } } @@ -122,8 +140,15 @@ impl EditorSession for CqlEditorSession { shutdown: self.shutdown.clone(), }; let state = self.state.clone(); + let (keyspace, link) = (self.keyspace.clone(), run.link.clone()); + let follows = tablepro_core::sql_syntax::may_change_database(sql.as_str(), SqlGrammar::Cql); let running = tokio::spawn(async move { let outcome = run.run(sql, options, sink).await; + // The driver records a keyspace once the server confirms it. + if outcome.is_ok() && follows { + *keyspace.lock().unwrap_or_else(PoisonError::into_inner) = + link.session().get_keyspace().map(|keyspace| keyspace.to_string()); + } let next = match &outcome { Ok(_) => READY, Err(_) => LOST, From a250df6177350f898b86dc8b9af17cfef15bde1e Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 30 Oct 2026 18:10:44 +0700 Subject: [PATCH 31/53] fix(linux-workspace): close a connection's databases with it under one question, and only for the connection they opened from --- .../app/src/ui/session_window/connections.rs | 16 +++--- linux/crates/app/src/ui/session_window/mod.rs | 56 +++++++++++++------ .../src/ui/session_window/sibling_sessions.rs | 14 +++-- .../crates/app/src/workspace/close_guards.rs | 21 ++++--- 4 files changed, 67 insertions(+), 40 deletions(-) diff --git a/linux/crates/app/src/ui/session_window/connections.rs b/linux/crates/app/src/ui/session_window/connections.rs index 33bf6f7470..edb05af33b 100644 --- a/linux/crates/app/src/ui/session_window/connections.rs +++ b/linux/crates/app/src/ui/session_window/connections.rs @@ -571,20 +571,19 @@ impl SessionWindow { /// Ask the tabs, as Disconnect does, then connect again. fn reopen_after_teardown(&self, id: Uuid) { - let Some(workspace) = self.session_workspace(&SessionKey::Saved(id)) else { - return; - }; - let (Some(handle), Some(guards), Some(input)) = (workspace.prompt_handle(), self.close_guards(), self.input()) - else { + // The connection's other databases go with it, since they took + // its level when they opened (0030). + let keys = self.with_siblings(&SessionKey::Saved(id)); + let (Some((handles, guards)), Some(input)) = (self.teardown_handles(&keys), self.input()) else { return; }; guards.begin_teardown(); - if guards.may_tear_down(&handle) { + if guards.may_tear_down(&handles) { input.deliver(SessionWindowInput::ReopenApproved(id)); return; } let window = self.downgrade(); - guards.ask_tear_down(self.upcast_ref(), &handle, move |decision| match decision { + guards.ask_tear_down(self.upcast_ref(), &handles, move |decision| match decision { CloseDecision::Approved => input.deliver(SessionWindowInput::ReopenApproved(id)), _ => { if let Some(window) = window.upgrade() { @@ -599,6 +598,9 @@ impl SessionWindow { /// sessions and connects go on. pub(super) fn on_reopen_approved(&self, id: Uuid) { let stored = self.stored_connection(id); + for sibling in self.siblings_of(id) { + self.tear_down_session(&sibling); + } if !self.tear_down_session(&SessionKey::Saved(id)) { return; } diff --git a/linux/crates/app/src/ui/session_window/mod.rs b/linux/crates/app/src/ui/session_window/mod.rs index ec0386114c..e4f2ffeffa 100644 --- a/linux/crates/app/src/ui/session_window/mod.rs +++ b/linux/crates/app/src/ui/session_window/mod.rs @@ -1274,37 +1274,61 @@ impl SessionWindow { } /// Close the session under `key` through its own prompts, then tear - /// it down. The window's other sessions and connects go on. + /// it down. A connection's other databases go with it, under one + /// question for all of them (0030). The window's other sessions and + /// connects go on. fn on_disconnect(&self, key: SessionKey) { - // A connection's other databases go with it, each through its own - // prompts (0030). - if let SessionKey::Saved(id) = &key { - for sibling in self.siblings_of(*id) { - self.on_disconnect(sibling); - } - } - let Some(workspace) = self.session_workspace(&key) else { + let keys = self.with_siblings(&key); + let Some((handles, guards)) = self.teardown_handles(&keys) else { return; }; - let (Some(handle), Some(guards)) = (workspace.prompt_handle(), self.close_guards()) else { - return; + let approved = move || { + keys.iter() + .cloned() + .map(SessionWindowInput::SessionTeardownApproved) + .collect::>() }; - let approved = move || SessionWindowInput::SessionTeardownApproved(key.clone()); guards.begin_teardown(); - if guards.may_tear_down(&handle) { - self.send(approved()); + if guards.may_tear_down(&handles) { + for input in approved() { + self.send(input); + } return; } let input = self.input(); - guards.ask_tear_down(self.upcast_ref(), &handle, move |decision| { + guards.ask_tear_down(self.upcast_ref(), &handles, move |decision| { if decision == CloseDecision::Approved && let Some(input) = input { - input.deliver(approved()); + for approved in approved() { + input.deliver(approved); + } } }); } + /// `key`, after the other databases of its connection. + pub(super) fn with_siblings(&self, key: &SessionKey) -> Vec { + let mut keys = match key { + SessionKey::Saved(id) => self.siblings_of(*id), + _ => Vec::new(), + }; + keys.push(key.clone()); + keys + } + + /// The prompt handles of the workspaces open under `keys`. `None` + /// when the last of them, the one asked about, is not open. + pub(super) fn teardown_handles(&self, keys: &[SessionKey]) -> Option<(Vec, Rc)> { + let guards = self.close_guards()?.clone(); + self.session_workspace(keys.last()?)?; + let handles = keys + .iter() + .filter_map(|key| self.session_workspace(key)?.prompt_handle()) + .collect(); + Some((handles, guards)) + } + /// Ctrl+W: the tab on screen closes through its session's prompts, /// and with no tab on screen the window closes through its own. fn on_close_current(&self) { diff --git a/linux/crates/app/src/ui/session_window/sibling_sessions.rs b/linux/crates/app/src/ui/session_window/sibling_sessions.rs index 564c3fd53e..af1908f1ed 100644 --- a/linux/crates/app/src/ui/session_window/sibling_sessions.rs +++ b/linux/crates/app/src/ui/session_window/sibling_sessions.rs @@ -58,7 +58,7 @@ impl SessionWindow { return; }; let opening = { - let database = database.clone(); + let (database, origin) = (database.clone(), origin.clone()); state .services .tasks @@ -67,11 +67,13 @@ impl SessionWindow { let window = self.downgrade(); glib::spawn_future_local(async move { let opened = opening.await; - // A database goes with its connection, which may have gone - // while it opened. - let window = window - .upgrade() - .filter(|window| window.held_session(&SessionKey::Saved(id)).is_some()); + // A database goes with the connection it opened from, which may + // have gone, or been opened again, while it opened. + let window = window.upgrade().filter(|window| { + window + .held_session(&SessionKey::Saved(id)) + .is_some_and(|now| Arc::ptr_eq(&now, &origin)) + }); let Some(window) = window else { if let Ok(Ok(session)) = opened { drop(session.begin_shutdown()); diff --git a/linux/crates/app/src/workspace/close_guards.rs b/linux/crates/app/src/workspace/close_guards.rs index eae7124e5b..a8344893b1 100644 --- a/linux/crates/app/src/workspace/close_guards.rs +++ b/linux/crates/app/src/workspace/close_guards.rs @@ -174,9 +174,13 @@ impl CloseGuards { self.teardown_acknowledged.borrow_mut().clear(); } - /// Whether every tab may go with the connection without asking. - pub(crate) fn may_tear_down(&self, handle: &WorkspaceHandle) -> bool { - handle.close_step(CloseScope::Window, &self.teardown_acknowledged.borrow()) == CloseStep::Approve + /// Whether every tab of `handles` may go with their connection + /// without asking. + pub(crate) fn may_tear_down(&self, handles: &[WorkspaceHandle]) -> bool { + let acknowledged = self.teardown_acknowledged.borrow(); + handles + .iter() + .all(|handle| handle.close_step(CloseScope::Window, &acknowledged) == CloseStep::Approve) } /// Ask whether every tab may go with the connection; `decided` hears @@ -184,19 +188,14 @@ impl CloseGuards { pub(crate) fn ask_tear_down( self: &Rc, parent: >k::Widget, - handle: &WorkspaceHandle, + handles: &[WorkspaceHandle], decided: impl FnOnce(CloseDecision) + 'static, ) { - let (guards, handle, parent) = (self.clone(), handle.clone(), parent.clone()); + let (guards, handles, parent) = (self.clone(), handles.to_vec(), parent.clone()); glib::spawn_future_local(async move { let mut acknowledged = guards.teardown_acknowledged.borrow().clone(); let decision = guards - .run( - &parent, - std::slice::from_ref(&handle), - CloseScope::Window, - &mut acknowledged, - ) + .run(&parent, &handles, CloseScope::Window, &mut acknowledged) .await; if decision == CloseDecision::Approved { guards.teardown_acknowledged.replace(acknowledged); From 2d368b52c9f0c847b0a3d111de489be890691276 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 30 Oct 2026 18:11:45 +0700 Subject: [PATCH 32/53] fix(linux-editor): hand a database's session to every tab waiting for it, from any of the connection's workspaces --- linux/crates/app/src/ui/editor/tab_scope.rs | 11 +++ linux/crates/app/src/ui/session_window/imp.rs | 3 + .../src/ui/session_window/sibling_sessions.rs | 73 ++++++++++++------- .../src/ui/session_window/tests/sessions.rs | 67 +++++++++++++++++ 4 files changed, 128 insertions(+), 26 deletions(-) diff --git a/linux/crates/app/src/ui/editor/tab_scope.rs b/linux/crates/app/src/ui/editor/tab_scope.rs index 13a899b050..dbb531efd5 100644 --- a/linux/crates/app/src/ui/editor/tab_scope.rs +++ b/linux/crates/app/src/ui/editor/tab_scope.rs @@ -220,6 +220,17 @@ impl SqlEditor { } } + /// The database of the session whose catalog completion reads, when + /// the window handed one over. + #[cfg(test)] + pub(crate) fn followed_catalog(&self) -> Option { + self.imp().completion_providers.borrow().iter().find_map(|provider| { + let provider = provider.downcast_ref::()?; + let session = provider.imp().catalog_session.borrow().clone()?; + session.database().map(str::to_owned) + }) + } + fn follow_in_completion(&self, home: CatalogHome) { for provider in self.imp().completion_providers.borrow().iter() { if let Some(provider) = provider.downcast_ref::() { diff --git a/linux/crates/app/src/ui/session_window/imp.rs b/linux/crates/app/src/ui/session_window/imp.rs index 42d4bd21d8..41718d3cb8 100644 --- a/linux/crates/app/src/ui/session_window/imp.rs +++ b/linux/crates/app/src/ui/session_window/imp.rs @@ -63,6 +63,9 @@ pub struct SessionWindow { /// The recorded sessions the window was opened to restore, until /// each saved connection is looked up or each file resolved. pub(super) restoring: RefCell>, + /// Another database opening beside its connection, and what waits for + /// its session: each tab moved there while it opened. + pub(super) opening_databases: RefCell>>, /// One close prompt at a time, for a tab, the window or a /// disconnect. pub(super) close_guards: OnceCell>, diff --git a/linux/crates/app/src/ui/session_window/sibling_sessions.rs b/linux/crates/app/src/ui/session_window/sibling_sessions.rs index 61a4dc9967..65e0bdab5f 100644 --- a/linux/crates/app/src/ui/session_window/sibling_sessions.rs +++ b/linux/crates/app/src/ui/session_window/sibling_sessions.rs @@ -23,6 +23,9 @@ use crate::runtime::DeliverInput; use crate::ui::error_text::{connect_error_message, operation_error_message}; use crate::ui::workspace::WorkspaceInput; +/// What waits for a database's session while it opens. +pub(super) type Waiter = Box)>; + /// The response of the chooser that opens the database picked. const OPEN: &str = "open"; @@ -62,15 +65,23 @@ impl SessionWindow { then(self, session); return; } + if let Some(waiting) = self.imp().opening_databases.borrow_mut().get_mut(&key) { + waiting.push(Box::new(then)); + return; + } let (Some(origin), Some(state)) = (self.held_session(&SessionKey::Saved(id)), self.state()) else { return; }; if origin.database() == Some(database.as_str()) { return; } - let Some(claim) = self.claim(key) else { + let Some(claim) = self.claim(key.clone()) else { return; }; + self.imp() + .opening_databases + .borrow_mut() + .insert(key.clone(), vec![Box::new(then)]); let opening = { let (database, origin) = (database.clone(), origin.clone()); state @@ -83,6 +94,10 @@ impl SessionWindow { let opened = opening.await; // A database goes with the connection it opened from, which may // have gone, or been opened again, while it opened. + let waiting = window + .upgrade() + .and_then(|window| window.imp().opening_databases.borrow_mut().remove(&key)) + .unwrap_or_default(); let window = window.upgrade().filter(|window| { window .held_session(&SessionKey::Saved(id)) @@ -97,7 +112,9 @@ impl SessionWindow { match opened { Ok(Ok(session)) => { window.join(claim, session.clone(), &[]); - then(&window, session); + for waiter in waiting { + waiter(&window, session.clone()); + } } Ok(Err(error)) => window.show_toast(&gettext_f( "Could not open the database “{database}”: {error}", @@ -186,37 +203,41 @@ impl SessionWindow { dialog.present(Some(self)); } - /// An editor tab of the session under `key` moved to `database`: open - /// or reach that database's session, hand it to the tab's completion, - /// and select its row, as the macOS window follows the tab (0030). Back - /// on the connection's own database the tab reads the connection's. + /// An editor tab of the session under `key` moved to `database`: reach + /// that database's session, the connection's or one opened beside it, + /// hand it to the tab's completion, and select its row (0030). Back on + /// its own session's database the tab reads that session's catalog. pub(super) fn follow_database(&self, key: &SessionKey, tab_id: Uuid, database: String) { - let SessionKey::Saved(id) = *key else { - return; + let id = match key { + SessionKey::Saved(id) | SessionKey::SavedIn { id, .. } => *id, + SessionKey::File(_) => return, }; - let Some(origin) = self.held_session(key) else { + let connection = SessionKey::Saved(id); + let Some(parent) = self.held_session(&connection) else { return; }; - if database.is_empty() || origin.database() == Some(database.as_str()) { - self.hand_over(id, tab_id, None, key); - return; + if database.is_empty() { + self.hand_over(key, tab_id, None, key); + } else if parent.database() == Some(database.as_str()) { + self.hand_over(key, tab_id, Some(parent), &connection); + } else { + let (from, row) = ( + key.clone(), + SessionKey::SavedIn { + id, + database: database.clone(), + }, + ); + self.open_database_then(id, database, move |window, session| { + window.hand_over(&from, tab_id, Some(session), &row); + }); } - let row = SessionKey::SavedIn { - id, - database: database.clone(), - }; - self.open_database_then(id, database, move |window, session| { - window.hand_over(id, tab_id, Some(session), &row); - }); } - /// Give the connection `id`'s editor tab `tab_id` the catalog of - /// `session`, and select `row` in the sidebar. - fn hand_over(&self, id: Uuid, tab_id: Uuid, session: Option>, row: &SessionKey) { - if let Some(sender) = self - .session_workspace(&SessionKey::Saved(id)) - .and_then(|workspace| workspace.sender()) - { + /// Give the editor tab `tab_id` of the session under `from` the + /// catalog of `session`, and select `row` in the sidebar. + fn hand_over(&self, from: &SessionKey, tab_id: Uuid, session: Option>, row: &SessionKey) { + if let Some(sender) = self.session_workspace(from).and_then(|workspace| workspace.sender()) { sender.deliver(WorkspaceInput::FollowCatalog { tab_id, session }); } if let Some(tree) = self.shell().and_then(|shell| shell.tree()) { diff --git a/linux/crates/app/src/ui/session_window/tests/sessions.rs b/linux/crates/app/src/ui/session_window/tests/sessions.rs index 772a69463a..73966bf644 100644 --- a/linux/crates/app/src/ui/session_window/tests/sessions.rs +++ b/linux/crates/app/src/ui/session_window/tests/sessions.rs @@ -731,3 +731,70 @@ fn an_editor_tab_moved_to_a_database_opens_its_session() { }; wait_until(WAIT, || test.window.held_session(&sibling).is_some()).expect("the database's session"); } + +fn editors_of(shell: &crate::ui::workspace::WorkspaceShell) -> Vec { + let pages = shell.tab_view().n_pages(); + (0..pages) + .filter_map(|index| { + let child = shell.tab_view().nth_page(index).child(); + crate::test_support::descendants(&child) + .into_iter() + .chain([child]) + .find_map(|widget| widget.downcast::().ok()) + }) + .collect() +} + +/// Two tabs moved to a database while its session opens both read its +/// catalog once it has (0030). +#[gtk4::test] +fn tabs_moved_while_a_database_opens_all_read_its_catalog() { + let test = TestWindow::with_script( + "FollowWhileOpening", + Script { + network: true, + connection: tablepro_test_support::ConnectionScript { + dialect: std::sync::Arc::new(tablepro_driver_mysql::MySqlDialect::new( + tablepro_driver_mysql::MySqlFlavour::MySql, + &tablepro_core::ServerInfo::new("MySQL", "8.4.0", 8, 4, 0), + )), + ..tablepro_test_support::ConnectionScript::default() + }, + ..Script::default() + }, + ); + let (vault, id) = (test.vault.clone(), test.saved.id); + crate::test_support::finish_on_main(WAIT, async move { + tablepro_core::credentials::SecretVault::store( + &*vault, + id, + tablepro_core::credentials::SecretKind::DatabasePassword, + &secrecy::SecretString::from("s3cret"), + "label", + ) + .await + }) + .expect("the store to end") + .expect("the password stored"); + test.window + .send(SessionWindowInput::OpenSaved(Box::new(test.saved.clone()))); + test.wait_for_page("workspace"); + let shell = test.window.shell().expect("the shell"); + let connection = crate::ui::session_target::to_variant(&SessionKey::Saved(test.saved.id)); + for _ in 0..2 { + WidgetExt::activate_action(&test.window, "win.new-query-in", Some(&connection)).expect("win.new-query-in"); + } + wait_until(WAIT, || editors_of(&shell).len() == 2).expect("two tabs"); + + for editor in editors_of(&shell) { + WidgetExt::activate_action(&editor, "editor.database", Some(&"analytics".to_variant())) + .expect("editor.database"); + } + + wait_until(WAIT, || { + editors_of(&shell) + .iter() + .all(|editor| editor.followed_catalog().as_deref() == Some("analytics")) + }) + .expect("both tabs to read the database's catalog"); +} From b35a1b07fa38a0961e668ebb6dc553985ce3e622 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 30 Oct 2026 18:38:02 +0700 Subject: [PATCH 33/53] test(linux-session): count the session's own hold on its credentials --- linux/crates/session/src/session/session.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/linux/crates/session/src/session/session.rs b/linux/crates/session/src/session/session.rs index 6de16ac416..a2edaf40f1 100644 --- a/linux/crates/session/src/session/session.rs +++ b/linux/crates/session/src/session/session.rs @@ -482,7 +482,8 @@ mod tests { let session = Session::start(pending, deps(credentials.clone())); let weak: Weak = Arc::downgrade(&session); let mut health = session.health(); - assert_eq!(Arc::strong_count(&credentials), 2, "the monitor holds no plan"); + // The test's, the monitor's, and the session's own for its siblings. + assert_eq!(Arc::strong_count(&credentials), 3, "the monitor holds no plan"); drop(session); for _ in 0..10 { From ad372fe357d29c8b270bc01811abcf0ffbc4cc3f Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 30 Oct 2026 18:59:51 +0700 Subject: [PATCH 34/53] test(linux-mssql): pin the bracket quoting a move's USE uses --- linux/crates/drivers/mssql/src/dialect.rs | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/linux/crates/drivers/mssql/src/dialect.rs b/linux/crates/drivers/mssql/src/dialect.rs index 2e2b2a4660..e48961435a 100644 --- a/linux/crates/drivers/mssql/src/dialect.rs +++ b/linux/crates/drivers/mssql/src/dialect.rs @@ -489,11 +489,8 @@ mod tests { } #[test] - fn a_tab_moves_with_use_and_a_bracketed_name() { - assert_eq!( - dialect().use_database("sales ]2026").as_deref(), - Some("USE [sales ]]2026]") - ); + fn a_bracket_in_a_name_is_doubled() { + assert_eq!(quote("sales ]2026"), "[sales ]]2026]"); } fn column(declared: &str) -> ColumnInfo { From 93ababb56047ae02882269e27300cf88cb09b624 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 30 Oct 2026 19:05:51 +0700 Subject: [PATCH 35/53] fix(linux-editor): offer the databases of a connection opened without a name, and drop a MySQL session a USE lost --- linux/crates/app/src/ui/editor/imp.rs | 3 ++ linux/crates/app/src/ui/editor/tab_scope.rs | 35 ++++++++++++++----- .../drivers/mysql/src/editor_session.rs | 9 ++++- 3 files changed, 37 insertions(+), 10 deletions(-) diff --git a/linux/crates/app/src/ui/editor/imp.rs b/linux/crates/app/src/ui/editor/imp.rs index 00a1988c56..3f0170cd70 100644 --- a/linux/crates/app/src/ui/editor/imp.rs +++ b/linux/crates/app/src/ui/editor/imp.rs @@ -66,6 +66,9 @@ pub struct SqlEditor { pub slot: Arc, /// The database the bar names, to tell the workspace when it changes. pub named_database: RefCell>, + /// The server's name for the connection's database, for one opened + /// without a name. + pub listed_own_database: RefCell>, pub running: RefCell>, pub log: RefCell>, /// Whether the last run left a transaction open, as last reported. diff --git a/linux/crates/app/src/ui/editor/tab_scope.rs b/linux/crates/app/src/ui/editor/tab_scope.rs index 83c9bbf88b..81958e23ce 100644 --- a/linux/crates/app/src/ui/editor/tab_scope.rs +++ b/linux/crates/app/src/ui/editor/tab_scope.rs @@ -81,7 +81,8 @@ impl SqlEditor { return; }; self.set_scope(Some(scope_text(&database, None))); - self.follow_in_completion(catalog_home(layout, session.database(), &database)); + let own = self.own_database(&session); + self.follow_in_completion(catalog_home(layout, own.as_deref(), &database)); let moved = self.imp().named_database.replace(Some(database.clone())) != Some(database.clone()); if moved { self.emit_by_name::<()>("database-changed", &[]); @@ -90,7 +91,7 @@ impl SqlEditor { action.set_state(&database.to_variant()); } // The catalog describes the connection's own database only. - if layout != DatabaseLayout::WithSchemas || session.database() != Some(database.as_str()) { + if layout != DatabaseLayout::WithSchemas || own.as_deref() != Some(database.as_str()) { return; } let reading = { @@ -128,11 +129,21 @@ impl SqlEditor { .imp() .slot .database() - .or_else(|| session.database().map(str::to_owned)) + .or_else(|| self.own_database(session)) .filter(|name| !name.is_empty())?; Some((database, layout)) } + /// The database the connection opened in: the name it was given, else + /// the one the server listed as its own. + fn own_database(&self, session: &Session) -> Option { + session + .database() + .filter(|name| !name.is_empty()) + .map(str::to_owned) + .or_else(|| self.imp().listed_own_database.borrow().clone()) + } + /// Ask the server for its databases, for the bar's menu. A server /// that cannot list them leaves the bar a label. pub(super) fn list_databases(&self) { @@ -142,7 +153,10 @@ impl SqlEditor { else { return; }; - if self.scope_database(&session).is_none() { + let several = session + .current_dialect() + .is_some_and(|dialect| dialect.database_layout() != DatabaseLayout::Single); + if !several { return; } let listing = tasks.spawn_task(async move { @@ -167,6 +181,9 @@ impl SqlEditor { }; if let Some(editor) = editor.upgrade() { let imp = editor.imp(); + let own = databases.iter().find(|database| database.is_default); + imp.listed_own_database + .replace(own.map(|database| database.name.clone())); imp.scope_button.set_menu_model(database_menu(&databases).as_ref()); editor.show_scope(); } @@ -187,11 +204,11 @@ impl SqlEditor { /// the connection's own. pub(crate) fn tab_database(&self) -> Option { let database = self.imp().slot.database()?; - let own = self.deps().and_then(|deps| deps.session.get()); - match own { - Some(session) if session.database() == Some(database.as_str()) => None, - _ => Some(database), - } + let own = self + .deps() + .and_then(|deps| deps.session.get()) + .and_then(|session| self.own_database(&session)); + (own.as_deref() != Some(database.as_str())).then_some(database) } pub(crate) fn connect_database_changed(&self, f: F) -> glib::SignalHandlerId { diff --git a/linux/crates/drivers/mysql/src/editor_session.rs b/linux/crates/drivers/mysql/src/editor_session.rs index 9fa8db8b85..681243cc05 100644 --- a/linux/crates/drivers/mysql/src/editor_session.rs +++ b/linux/crates/drivers/mysql/src/editor_session.rs @@ -390,7 +390,14 @@ impl EditorSession for MysqlEditorSession { *shared.database.lock().unwrap_or_else(PoisonError::into_inner) = Some(database.to_owned()); Ok(()) } - Some(Err(error)) => Err(map_error(&error, shared.flavour)), + Some(Err(error)) => { + let error = map_error(&error, shared.flavour); + if error.category() == tablepro_core::ErrorCategory::ConnectionLost { + shared.lost.store(true, Ordering::SeqCst); + *guard = None; + } + Err(error) + } // The answer is still on the wire, so the connection is in no // state anything knows. None => { From 615302c5b64171c9c913953807b324d57c17a6d3 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 30 Oct 2026 19:06:02 +0700 Subject: [PATCH 36/53] test(linux-editor): a connection without a database name names the server's --- linux/crates/app/src/ui/editor/tests.rs | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/linux/crates/app/src/ui/editor/tests.rs b/linux/crates/app/src/ui/editor/tests.rs index 5ae932f317..1ed38eba05 100644 --- a/linux/crates/app/src/ui/editor/tests.rs +++ b/linux/crates/app/src/ui/editor/tests.rs @@ -2803,6 +2803,31 @@ fn picking_a_database_moves_the_tab_and_its_next_run() { assert_eq!(harness.editor.tab_database(), None); } +/// A connection opened without a database name is in the one the server +/// calls its own, which the bar names and the menu checks. +#[gtk4::test] +fn a_connection_without_a_database_name_names_the_servers() { + let database = |name: &str, is_default: bool| tablepro_core::meta::SchemaInfo { + name: name.to_owned(), + is_default, + is_system: false, + }; + let harness = scoped_editor_with(mysql_dialect(), &[], answering(), |script, request| { + if let tablepro_session::connect::RequestTarget::Network { database, .. } = &mut request.target { + *database = None; + } + script + .list_databases + .always(FakeReply::ok(vec![database("analytics", false), database("app", true)])); + }); + let button = harness.editor.imp().scope_button.get(); + wait_until(WAIT, || button.is_visible()).expect("the database menu"); + + assert_eq!(harness.editor.imp().scope_button_label.label(), "app"); + pick_database(&harness, "app"); + assert_eq!(harness.editor.tab_database(), None, "the connection's own"); +} + /// A database the server will not move to leaves the tab where its /// session is, and the run says why. #[gtk4::test] From d7ef12e009d6707d5a59c48f89ffef6dc2991878 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 30 Oct 2026 19:06:31 +0700 Subject: [PATCH 37/53] fix(linux-editor): keep a PostgreSQL tab's session until one opens in the database it moves to --- .../app/src/ui/editor/editor_session_slot.rs | 79 +++++++++++++------ 1 file changed, 54 insertions(+), 25 deletions(-) diff --git a/linux/crates/app/src/ui/editor/editor_session_slot.rs b/linux/crates/app/src/ui/editor/editor_session_slot.rs index 27f44a24d4..941cc6ef90 100644 --- a/linux/crates/app/src/ui/editor/editor_session_slot.rs +++ b/linux/crates/app/src/ui/editor/editor_session_slot.rs @@ -202,17 +202,10 @@ impl EditorSessionSlot { (statement_timeout, close_timeout): (Option, Duration), cancel: &CancellationToken, ) -> Result, AcquireError> { - let holds_transaction = { - let mut state = lock(&self.state); - let held = state - .open - .as_ref() - .is_some_and(|open| open.editor.id() == editor.id() && open.held_transaction()); - if !held { - state.open = None; - } - held - }; + let holds_transaction = lock(&self.state) + .open + .as_ref() + .is_some_and(|open| open.editor.id() == editor.id() && open.held_transaction()); if holds_transaction { lock(&self.state).database = here; return Err(AcquireError::Moved { @@ -220,26 +213,33 @@ impl EditorSessionSlot { reason: crate::i18n::gettext("Commit or roll back the transaction first."), }); } - close_quietly(editor, close_timeout).await; + // The session in the old database stays until one opens in the + // new, so a move that fails keeps what the tab built there. let options = EditorSessionOptions { statement_timeout, database: Some(wanted.clone()), }; - match session.open_editor_session(options, cancel).await { - Ok(opened) => match self.adopt(session, opened.clone()) { - Ok(()) => Ok(opened), - Err(orphan) => { - close_quietly(orphan, close_timeout).await; - Err(AcquireError::Cancelled) - } - }, - Err(OperationError::Cancelled) => Err(AcquireError::Cancelled), + let opened = match session.open_editor_session(options, cancel).await { + Ok(opened) => opened, Err(error) => { lock(&self.state).database = here; - Err(AcquireError::Moved { - database: wanted, - reason: crate::ui::error_text::operation_error_message(&error), - }) + return Err(match error { + OperationError::Cancelled => AcquireError::Cancelled, + error => AcquireError::Moved { + database: wanted, + reason: crate::ui::error_text::operation_error_message(&error), + }, + }); + } + }; + match self.adopt(session, opened.clone()) { + Ok(()) => { + close_quietly(editor, close_timeout).await; + Ok(opened) + } + Err(orphan) => { + close_quietly(orphan, close_timeout).await; + Err(AcquireError::Cancelled) } } } @@ -413,6 +413,35 @@ pub(super) mod tests { editor } + /// A session that cannot open in the new database leaves the tab in its + /// own, on the session it had and what it built there. + #[tokio::test] + async fn a_move_that_cannot_open_keeps_the_session_it_had() { + let shop = bound_to("shop"); + let (session, _) = session_opening(vec![shop.clone()]); + let slot = EditorSessionSlot::new(); + let first = slot + .acquire(&session, None, CLOSE, &CancellationToken::new()) + .await + .expect("a session in shop"); + + slot.set_database("analytics".to_owned()); + let refused = slot.acquire(&session, None, CLOSE, &CancellationToken::new()).await; + assert!( + matches!(refused, Err(AcquireError::Moved { .. })), + "{:?}", + refused.err() + ); + assert_eq!(shop.close_count(), 0, "the session in shop closed"); + assert_eq!(slot.database().as_deref(), Some("shop")); + + let again = slot + .acquire(&session, None, CLOSE, &CancellationToken::new()) + .await + .expect("the session in shop"); + assert_eq!(again.id(), first.id()); + } + /// Where a live session cannot move, the tab gets a session opened in /// its database, unless the one it has holds a transaction. #[tokio::test] From 42efe7d2409b467c53fa303adb4d50b7d795faac Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 30 Oct 2026 19:14:01 +0700 Subject: [PATCH 38/53] fix(linux-editor): refresh a moved tab's database after its DDL, keep its catalog over a late one, and stop openings with their connection --- linux/crates/app/src/ui/editor/tab_scope.rs | 10 ++++++++-- linux/crates/app/src/ui/editor/tests.rs | 7 +++++++ linux/crates/app/src/ui/session_window/imp.rs | 2 +- .../src/ui/session_window/session_teardown.rs | 11 ++++++++++ .../src/ui/session_window/sibling_sessions.rs | 17 ++++++++++++---- linux/crates/app/src/ui/workspace/mod.rs | 19 ++++++++++++++++++ .../app/src/ui/workspace/sidebar/mod.rs | 20 +++++++++++++------ 7 files changed, 73 insertions(+), 13 deletions(-) diff --git a/linux/crates/app/src/ui/editor/tab_scope.rs b/linux/crates/app/src/ui/editor/tab_scope.rs index 5c93870046..483c045545 100644 --- a/linux/crates/app/src/ui/editor/tab_scope.rs +++ b/linux/crates/app/src/ui/editor/tab_scope.rs @@ -227,9 +227,15 @@ impl SqlEditor { /// Completion reads `session`'s catalog: the session the window holds /// for the tab's database, or `None` back on the connection's own. pub(crate) fn follow_catalog(&self, session: Option>) { - // A session opened late for a database the tab has since left. + // A session opened late for a database the tab has since left + // changes nothing; the one it is in may have answered first. let current = self.imp().named_database.borrow().clone(); - let session = session.filter(|session| session.database() == current.as_deref()); + if session + .as_ref() + .is_some_and(|session| session.database() != current.as_deref()) + { + return; + } for provider in self.imp().completion_providers.borrow().iter() { if let Some(provider) = provider.downcast_ref::() { provider.set_catalog_session(session.clone()); diff --git a/linux/crates/app/src/ui/editor/tests.rs b/linux/crates/app/src/ui/editor/tests.rs index b08b4e4870..d631503a97 100644 --- a/linux/crates/app/src/ui/editor/tests.rs +++ b/linux/crates/app/src/ui/editor/tests.rs @@ -2912,6 +2912,13 @@ fn completion_reads_only_the_session_of_the_tabs_database() { pick_database(&harness, "analytics"); harness.editor.follow_catalog(Some(analytics)); assert_eq!(followed_catalog(&harness).as_deref(), Some("analytics")); + let archive = session_in("archive", harness.runtime.tasks()); + harness.editor.follow_catalog(Some(archive)); + assert_eq!( + followed_catalog(&harness).as_deref(), + Some("analytics"), + "a late session for a database the tab left replaced the one it is in" + ); pick_database(&harness, "shop"); assert_eq!(followed_catalog(&harness), None, "the last database's catalog stayed"); diff --git a/linux/crates/app/src/ui/session_window/imp.rs b/linux/crates/app/src/ui/session_window/imp.rs index 41718d3cb8..04e0843889 100644 --- a/linux/crates/app/src/ui/session_window/imp.rs +++ b/linux/crates/app/src/ui/session_window/imp.rs @@ -65,7 +65,7 @@ pub struct SessionWindow { pub(super) restoring: RefCell>, /// Another database opening beside its connection, and what waits for /// its session: each tab moved there while it opened. - pub(super) opening_databases: RefCell>>, + pub(super) opening_databases: RefCell>, /// One close prompt at a time, for a tab, the window or a /// disconnect. pub(super) close_guards: OnceCell>, 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 b6bb35396c..6bca7bd811 100644 --- a/linux/crates/app/src/ui/session_window/session_teardown.rs +++ b/linux/crates/app/src/ui/session_window/session_teardown.rs @@ -61,6 +61,17 @@ impl SessionWindow { /// False when the window holds no such session. pub(super) fn tear_down_session(&self, key: &SessionKey) -> bool { let imp = self.imp(); + // A database still opening from this connection opens for nobody; + // one asked of the next connection opens anew. + if let SessionKey::Saved(id) = key { + imp.opening_databases.borrow_mut().retain(|opening, (cancel, _)| { + let goes = matches!(opening, SessionKey::SavedIn { id: of, .. } if of == id); + if goes { + cancel.cancel(); + } + !goes + }); + } let open = { let Ok(mut sessions) = imp.sessions.try_borrow_mut() else { tracing::warn!("a session was torn down while the sessions were read"); diff --git a/linux/crates/app/src/ui/session_window/sibling_sessions.rs b/linux/crates/app/src/ui/session_window/sibling_sessions.rs index 65e0bdab5f..5a57d14ae3 100644 --- a/linux/crates/app/src/ui/session_window/sibling_sessions.rs +++ b/linux/crates/app/src/ui/session_window/sibling_sessions.rs @@ -26,6 +26,10 @@ use crate::ui::workspace::WorkspaceInput; /// What waits for a database's session while it opens. pub(super) type Waiter = Box)>; +/// A database opening beside its connection: the stop for its open, and +/// what waits for it. +pub(super) type Opening = (CancellationToken, Vec); + /// The response of the chooser that opens the database picked. const OPEN: &str = "open"; @@ -65,7 +69,7 @@ impl SessionWindow { then(self, session); return; } - if let Some(waiting) = self.imp().opening_databases.borrow_mut().get_mut(&key) { + if let Some((_, waiting)) = self.imp().opening_databases.borrow_mut().get_mut(&key) { waiting.push(Box::new(then)); return; } @@ -78,25 +82,30 @@ impl SessionWindow { let Some(claim) = self.claim(key.clone()) else { return; }; + let cancel = CancellationToken::new(); self.imp() .opening_databases .borrow_mut() - .insert(key.clone(), vec![Box::new(then)]); + .insert(key.clone(), (cancel.clone(), vec![Box::new(then)])); let opening = { - let (database, origin) = (database.clone(), origin.clone()); + let (database, origin, cancel) = (database.clone(), origin.clone(), cancel.clone()); state .services .tasks - .spawn_task(async move { origin.open_in_database(&database, CancellationToken::new()).await }) + .spawn_task(async move { origin.open_in_database(&database, cancel).await }) }; let window = self.downgrade(); glib::spawn_future_local(async move { let opened = opening.await; // A database goes with the connection it opened from, which may // have gone, or been opened again, while it opened. + // One stopped with its connection was taken off the list then, + // and one under the same name since is another's. let waiting = window .upgrade() + .filter(|_| !cancel.is_cancelled()) .and_then(|window| window.imp().opening_databases.borrow_mut().remove(&key)) + .map(|(_, waiting)| waiting) .unwrap_or_default(); let window = window.upgrade().filter(|window| { window diff --git a/linux/crates/app/src/ui/workspace/mod.rs b/linux/crates/app/src/ui/workspace/mod.rs index 0ac15f1692..fa90c6c58d 100644 --- a/linux/crates/app/src/ui/workspace/mod.rs +++ b/linux/crates/app/src/ui/workspace/mod.rs @@ -1069,6 +1069,25 @@ impl WorkspaceModel { } } + /// Ask the window to read the catalog of this connection's database + /// `database` again, where a tab's statement changed it. + fn refresh_database(&self, database: String) { + let id = match &self.key { + SessionKey::Saved(id) | SessionKey::SavedIn { id, .. } => *id, + SessionKey::File(_) => return, + }; + let Some(shell) = self.shell.upgrade() else { + return; + }; + if let Some(sender) = shell.imp().sender.get() { + // The connection's own database has no row of its own beside it. + if matches!(self.key, SessionKey::SavedIn { .. }) { + sender.deliver(SessionWindowInput::RefreshIn(SessionKey::Saved(id))); + } + sender.deliver(SessionWindowInput::RefreshIn(SessionKey::SavedIn { id, database })); + } + } + /// The window this workspace is in, for dialogs to sit over. pub(super) fn window(&self) -> Option { self.window.upgrade() diff --git a/linux/crates/app/src/ui/workspace/sidebar/mod.rs b/linux/crates/app/src/ui/workspace/sidebar/mod.rs index 8b95752d96..cc0b5e5f04 100644 --- a/linux/crates/app/src/ui/workspace/sidebar/mod.rs +++ b/linux/crates/app/src/ui/workspace/sidebar/mod.rs @@ -273,12 +273,20 @@ impl WorkspaceModel { /// A run that sent DDL, or a commit that may publish it, changes what /// the sidebar and the completion list. pub(super) fn on_editor_run_finished(&mut self, id: uuid::Uuid) { - let changed = matches!( - self.workspace_tabs.borrow().get(&id), - Some(super::WorkspaceTab::Editor(slot)) if slot.editor.last_run_changes_catalog() - ); - if changed { - self.refresh_catalog(); + let changed = match self.workspace_tabs.borrow().get(&id) { + Some(super::WorkspaceTab::Editor(slot)) if slot.editor.last_run_changes_catalog() => { + Some(slot.editor.tab_database()) + } + _ => None, + }; + let Some(moved_to) = changed else { + return; + }; + self.refresh_catalog(); + // The statement ran in the tab's database, whose own session the + // window holds beside this one (0030). + if let Some(database) = moved_to { + self.refresh_database(database); } } From 3d592b9023fa260ea6779d9064e2f048e98ebcbc Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 30 Oct 2026 19:14:09 +0700 Subject: [PATCH 39/53] fix(linux-workspace): keep another database's table state apart, and delete its tabs with its connection --- .../app/src/services/connection_deletion.rs | 59 +++++++++++++++++++ .../app/src/ui/workspace/tab_factory.rs | 10 +++- .../crates/app/src/ui/workspace/tab_title.rs | 4 ++ .../app/src/workspace/workspace_file_name.rs | 7 ++- .../app/src/workspace/workspace_store.rs | 28 +++++++++ 5 files changed, 106 insertions(+), 2 deletions(-) diff --git a/linux/crates/app/src/services/connection_deletion.rs b/linux/crates/app/src/services/connection_deletion.rs index 8344ae13eb..3d03997c60 100644 --- a/linux/crates/app/src/services/connection_deletion.rs +++ b/linux/crates/app/src/services/connection_deletion.rs @@ -61,6 +61,9 @@ impl ConnectionDeletion { if let Err(error) = self.workspaces.purge(&SessionKey::Saved(id)).await { report.purge_failures.push(PurgeFailure::Workspace(error)); } + if let Err(error) = self.workspaces.purge_databases_of(id).await { + report.purge_failures.push(PurgeFailure::Workspace(error)); + } let Some(history) = self.history.clone() else { let unavailable = || "the query history did not open".to_owned(); report.purge_failures.push(PurgeFailure::SavedQueries(unavailable())); @@ -357,6 +360,62 @@ mod tests { } } + /// Another database opened beside the connection keeps its tabs and + /// drafts under a name of its own, which goes with the connection. + #[tokio::test] + async fn the_tabs_of_its_other_databases_go_with_the_connection() { + let fixture = fixture().await; + let (database, other_database) = ( + SessionKey::SavedIn { + id: fixture.id, + database: "analytics".to_owned(), + }, + SessionKey::SavedIn { + id: fixture.other, + database: "analytics".to_owned(), + }, + ); + let names = [&database, &other_database].map(|key| WorkspaceFileName::for_key(key)); + for (key, name) in [&database, &other_database].into_iter().zip(&names) { + let draft = DraftId::new(); + fixture + .drafts + .write_blocking(&name.draft_scope(), draft, "SELECT 1") + .expect("a draft"); + fixture + .workspaces + .schedule( + key, + WorkspaceRecord { + active_tab: Some(0), + tabs: vec![TabRecord::Editor { + draft, + file: None, + database: None, + }], + }, + ) + .expect("scheduled"); + fixture.workspaces.flush(key).await.expect("the saved tabs"); + } + let workspaces_dir = fixture.paths.workspaces_dir(); + let drafts_dir = |name: &WorkspaceFileName| fixture.drafts.root().join(name.draft_scope().as_str()); + + fixture + .deletion() + .run(fixture.id, DeletionOptions { delete_history: false }) + .await + .expect("the delete"); + + assert!(!workspaces_dir.join(names[0].file_name()).exists(), "its tabs stayed"); + assert!(!drafts_dir(&names[0]).exists(), "its drafts stayed"); + assert!( + workspaces_dir.join(names[1].file_name()).exists(), + "another connection's went" + ); + assert!(drafts_dir(&names[1]).exists()); + } + #[tokio::test] async fn secret_failure_keeps_entry_and_skips_purge() { let fixture = fixture().await; diff --git a/linux/crates/app/src/ui/workspace/tab_factory.rs b/linux/crates/app/src/ui/workspace/tab_factory.rs index 49a33e49fb..e59a2431d7 100644 --- a/linux/crates/app/src/ui/workspace/tab_factory.rs +++ b/linux/crates/app/src/ui/workspace/tab_factory.rs @@ -7,6 +7,7 @@ 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; @@ -230,7 +231,14 @@ impl TabFactory { tracker: parts.change_trackers.register(id), schema: schema.clone(), table: name.clone(), - connection_id: parts.context.active.metadata().map(|metadata| metadata.id), + // Column widths and filters are kept per connection and table; + // another database's table of the same name is not that one. + connection_id: match parts.label.key() { + SessionKey::SavedIn { .. } => None, + SessionKey::Saved(_) | SessionKey::File(_) => { + parts.context.active.metadata().map(|metadata| metadata.id) + } + }, read_only: parts.read_only, page_size: page.page_size, initial_offset: page.offset, diff --git a/linux/crates/app/src/ui/workspace/tab_title.rs b/linux/crates/app/src/ui/workspace/tab_title.rs index 8be27e9d79..b6b10efc07 100644 --- a/linux/crates/app/src/ui/workspace/tab_title.rs +++ b/linux/crates/app/src/ui/workspace/tab_title.rs @@ -33,6 +33,10 @@ pub(crate) struct ConnectionLabel { } impl ConnectionLabel { + pub(crate) fn key(&self) -> &SessionKey { + &self.key + } + pub(crate) fn new(key: SessionKey, saved: Rc, session: ActiveSession) -> Self { Self { key, diff --git a/linux/crates/app/src/workspace/workspace_file_name.rs b/linux/crates/app/src/workspace/workspace_file_name.rs index 43219ae0c4..c0d37a4ae4 100644 --- a/linux/crates/app/src/workspace/workspace_file_name.rs +++ b/linux/crates/app/src/workspace/workspace_file_name.rs @@ -20,7 +20,7 @@ impl WorkspaceFileName { let digest = glib::compute_checksum_for_string(glib::ChecksumType::Sha256, database.as_str()) .map(|digest| digest.to_string()) .unwrap_or_default(); - format!("{id}-database-{digest}") + format!("{}{digest}", Self::databases_prefix(*id)) } SessionKey::File(uri) => { // GLib returns no digest only for a checksum type it @@ -34,6 +34,11 @@ impl WorkspaceFileName { Self { stem } } + /// What the names of the connection `id`'s other databases start with. + pub fn databases_prefix(id: uuid::Uuid) -> String { + format!("{id}-database-") + } + pub fn file_name(&self) -> String { format!("{}.json", self.stem) } diff --git a/linux/crates/app/src/workspace/workspace_store.rs b/linux/crates/app/src/workspace/workspace_store.rs index 954e1e52ba..fe195b28da 100644 --- a/linux/crates/app/src/workspace/workspace_store.rs +++ b/linux/crates/app/src/workspace/workspace_store.rs @@ -206,6 +206,34 @@ impl WorkspaceStore { async move { purging.await.unwrap_or(Err(WorkspaceSaveError::WriterStopped)) } } + /// Remove the saved tabs and drafts of every other database of the + /// connection `id` that was ever open beside it. Their names hold a + /// digest of the database, so they are found by the connection's part. + pub fn purge_databases_of(&self, id: uuid::Uuid) -> impl Future> + use<> { + let prefix = WorkspaceFileName::databases_prefix(id); + let stems: Vec = std::fs::read_dir(&self.inner.dir) + .into_iter() + .flatten() + .filter_map(|entry| entry.ok()?.file_name().into_string().ok()) + .filter_map(|name| name.strip_suffix(".json").map(str::to_owned)) + .filter(|stem| stem.starts_with(&prefix)) + .collect(); + let purges: Vec<_> = stems + .into_iter() + .filter_map(|stem| { + let drafts = tablepro_storage::DraftScope::new(&stem).ok()?; + let path = self.inner.dir.join(format!("{stem}.json")); + Some(queue_purge(&self.inner.writer, path, drafts)) + }) + .collect(); + async move { + for purge in purges { + purge.await.unwrap_or(Err(WorkspaceSaveError::WriterStopped))?; + } + Ok(()) + } + } + /// Queue `record` as the sessions the windows show. A newer one /// replaces it if the writer has not reached it yet. pub fn schedule_open_sessions(&self, record: OpenSessionsRecord) -> Result<(), WorkspaceSaveError> { From 139e75fccf148a04ee229465b9e3cc8bd7b40686 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 30 Oct 2026 19:14:33 +0700 Subject: [PATCH 40/53] test(linux-clickhouse): read the collected rows without a binding --- linux/crates/drivers/clickhouse/src/editor_session.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/linux/crates/drivers/clickhouse/src/editor_session.rs b/linux/crates/drivers/clickhouse/src/editor_session.rs index 8a566e9c29..894506f6f3 100644 --- a/linux/crates/drivers/clickhouse/src/editor_session.rs +++ b/linux/crates/drivers/clickhouse/src/editor_session.rs @@ -232,8 +232,7 @@ mod tests { ) .await .expect("a run"); - let rows = rows.lock().unwrap_or_else(PoisonError::into_inner).clone(); - rows + rows.lock().unwrap_or_else(PoisonError::into_inner).clone() } /// Every request names its database, so a `USE` lasts only because From 2b50233e164ff5c10eb1943e5b235ef519c7a1e8 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 30 Oct 2026 19:15:04 +0700 Subject: [PATCH 41/53] fix(linux-editor): show a database name's underscores in the menu --- linux/crates/app/src/ui/editor/tab_scope.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/linux/crates/app/src/ui/editor/tab_scope.rs b/linux/crates/app/src/ui/editor/tab_scope.rs index 81958e23ce..fd95971a58 100644 --- a/linux/crates/app/src/ui/editor/tab_scope.rs +++ b/linux/crates/app/src/ui/editor/tab_scope.rs @@ -42,7 +42,9 @@ pub(super) fn database_menu(databases: &[SchemaInfo]) -> Option { for system in [false, true] { let section = gio::Menu::new(); for database in databases.iter().filter(|database| database.is_system == system) { - let item = gio::MenuItem::new(Some(&database.name), None); + // A popover menu reads `_` as a mnemonic mark, and database names + // such as `information_schema` hold one. + let item = gio::MenuItem::new(Some(&database.name.replace('_', "__")), None); item.set_action_and_target_value( Some(&format!("editor.{MOVE_ACTION}")), Some(&database.name.to_variant()), @@ -297,7 +299,11 @@ mod tests { assert_eq!(menu.n_items(), 2, "two sections"); assert_eq!(labels(&menu, 0), ["shop", "analytics"]); - assert_eq!(labels(&menu, 1), ["mysql", "information_schema"]); + assert_eq!( + labels(&menu, 1), + ["mysql", "information__schema"], + "an underscore is no mnemonic" + ); assert!( database_menu(&[database("shop", false)]).is_none(), "nothing to move to" From cfd96f77790b3f7609fc143ed7f79dbb84411c4d Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 30 Oct 2026 19:15:15 +0700 Subject: [PATCH 42/53] refactor(linux-editor): reuse the menu label helper for database names --- linux/crates/app/src/ui/editor/tab_scope.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/linux/crates/app/src/ui/editor/tab_scope.rs b/linux/crates/app/src/ui/editor/tab_scope.rs index fd95971a58..c06d90c9cb 100644 --- a/linux/crates/app/src/ui/editor/tab_scope.rs +++ b/linux/crates/app/src/ui/editor/tab_scope.rs @@ -42,9 +42,7 @@ pub(super) fn database_menu(databases: &[SchemaInfo]) -> Option { for system in [false, true] { let section = gio::Menu::new(); for database in databases.iter().filter(|database| database.is_system == system) { - // A popover menu reads `_` as a mnemonic mark, and database names - // such as `information_schema` hold one. - let item = gio::MenuItem::new(Some(&database.name.replace('_', "__")), None); + let item = gio::MenuItem::new(Some(&crate::ui::menu_label(&database.name)), None); item.set_action_and_target_value( Some(&format!("editor.{MOVE_ACTION}")), Some(&database.name.to_variant()), From eb2be503be29e7222eed1d95086fbb58b011e202 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 30 Oct 2026 19:29:27 +0700 Subject: [PATCH 43/53] test(linux-workspace): ask the tear-down question over a list of workspaces --- .../crates/app/src/workspace/close_guards.rs | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/linux/crates/app/src/workspace/close_guards.rs b/linux/crates/app/src/workspace/close_guards.rs index a8344893b1..0addee6892 100644 --- a/linux/crates/app/src/workspace/close_guards.rs +++ b/linux/crates/app/src/workspace/close_guards.rs @@ -464,21 +464,21 @@ mod tests { let guards = CloseGuards::new(); let window = window(); let parent = window.clone().upcast::(); - assert!(!guards.may_tear_down(&workspace.handle)); + assert!(!guards.may_tear_down(std::slice::from_ref(&workspace.handle))); let (kept, sink) = decision_sink(); - guards.ask_tear_down(&parent, &workspace.handle, sink); + guards.ask_tear_down(&parent, std::slice::from_ref(&workspace.handle), sink); let first = answer_next(&window, "Save Changes?", "cancel", None); wait_until(WAIT, || kept.get().is_some()).expect("a decision"); assert_eq!(kept.get(), Some(CloseDecision::Cancelled)); - assert!(!guards.may_tear_down(&workspace.handle)); + assert!(!guards.may_tear_down(std::slice::from_ref(&workspace.handle))); let (approved, sink) = decision_sink(); - guards.ask_tear_down(&parent, &workspace.handle, sink); + guards.ask_tear_down(&parent, std::slice::from_ref(&workspace.handle), sink); answer_next(&window, "Save Changes?", "discard", Some(&first)); wait_until(WAIT, || approved.get().is_some()).expect("a decision"); assert_eq!(approved.get(), Some(CloseDecision::Approved)); - assert!(guards.may_tear_down(&workspace.handle)); + assert!(guards.may_tear_down(std::slice::from_ref(&workspace.handle))); } /// A switch asks once before it connects and again before the new @@ -493,26 +493,26 @@ mod tests { guards.begin_teardown(); let (approved, sink) = decision_sink(); - guards.ask_tear_down(&parent, &workspace.handle, sink); + guards.ask_tear_down(&parent, std::slice::from_ref(&workspace.handle), sink); answer_next(&window, "Close With Open Transactions?", "close", None); let first = answer_next(&window, "Save Changes?", "discard", None); wait_until(WAIT, || approved.get().is_some()).expect("a decision"); - assert!(guards.may_tear_down(&workspace.handle)); + assert!(guards.may_tear_down(std::slice::from_ref(&workspace.handle))); edit_again(&workspace); assert!( - !guards.may_tear_down(&workspace.handle), + !guards.may_tear_down(std::slice::from_ref(&workspace.handle)), "an edit made while connecting went unasked" ); let (again, sink) = decision_sink(); - guards.ask_tear_down(&parent, &workspace.handle, sink); + guards.ask_tear_down(&parent, std::slice::from_ref(&workspace.handle), sink); answer_next(&window, "Save Changes?", "discard", Some(&first)); wait_until(WAIT, || again.get().is_some()).expect("a decision"); assert_eq!(again.get(), Some(CloseDecision::Approved)); guards.begin_teardown(); assert!( - !guards.may_tear_down(&workspace.handle), + !guards.may_tear_down(std::slice::from_ref(&workspace.handle)), "a new switch skipped the transaction" ); } From cde4cfefb4f1dacc8f6c775deef123f6e387d21d Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 30 Oct 2026 19:49:32 +0700 Subject: [PATCH 44/53] test(linux-workspace): an open connection's menu offers Open Database --- linux/crates/app/src/ui/workspace/sidebar/tests.rs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/linux/crates/app/src/ui/workspace/sidebar/tests.rs b/linux/crates/app/src/ui/workspace/sidebar/tests.rs index 6ce2dcabe0..c19049e641 100644 --- a/linux/crates/app/src/ui/workspace/sidebar/tests.rs +++ b/linux/crates/app/src/ui/workspace/sidebar/tests.rs @@ -1270,8 +1270,13 @@ fn the_connection_menu_holds_the_pickers_items() { ) ); assert_eq!( - open[..3], - ["win.new-query-in", "win.refresh-in", "win.disconnect-session"], + open[..4], + [ + "win.new-query-in", + "win.refresh-in", + "win.open-database-in", + "win.disconnect-session" + ], "{open:?}" ); assert!(!open.contains(&"picker.connect".to_owned())); From 9cc3de044e0e3ba2ab7b321d120faa9f1ff51206 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 30 Oct 2026 19:49:45 +0700 Subject: [PATCH 45/53] refactor(linux-workspace): keep the input handler short by naming the database steps --- linux/crates/app/src/ui/workspace/mod.rs | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/linux/crates/app/src/ui/workspace/mod.rs b/linux/crates/app/src/ui/workspace/mod.rs index fa90c6c58d..db64bd36b9 100644 --- a/linux/crates/app/src/ui/workspace/mod.rs +++ b/linux/crates/app/src/ui/workspace/mod.rs @@ -1050,6 +1050,18 @@ fn replace_text(cell: &RefCell, text: &str) -> bool { } impl WorkspaceModel { + /// The record keeps the tab's database, and the window follows it. + fn on_editor_tab_database_changed(&self, tab_id: Uuid) { + self.persist_workspace_state(); + self.follow_editor_database(tab_id); + } + + fn follow_catalog(&self, tab_id: Uuid, session: Option>) { + if let Some(WorkspaceTab::Editor(slot)) = self.workspace_tabs.borrow().get(&tab_id) { + slot.editor.follow_catalog(session); + } + } + /// Ask the window to reach the session of the database an editor tab /// is in, and to hand it back for the tab's completion. fn follow_editor_database(&self, tab_id: Uuid) { @@ -1291,11 +1303,7 @@ impl WorkspaceModel { 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::FollowCatalog { tab_id, session } => { - if let Some(WorkspaceTab::Editor(slot)) = self.workspace_tabs.borrow().get(&tab_id) { - slot.editor.follow_catalog(session); - } - } + WorkspaceInput::FollowCatalog { tab_id, session } => self.follow_catalog(tab_id, session), WorkspaceInput::TabDirtyChanged => self.refresh_unsaved(), WorkspaceInput::NewTableTab { schema } => self.on_new_table_tab(schema), WorkspaceInput::ShowDiagram { schema } => self.show_diagram(schema, Vec::new()), @@ -1319,10 +1327,7 @@ impl WorkspaceModel { } WorkspaceInput::EditorTabContentChanged(id) => self.on_editor_tab_content_changed(id), WorkspaceInput::EditorTabFileChanged(id) => self.on_editor_tab_file_changed(id), - WorkspaceInput::EditorTabDatabaseChanged(id) => { - self.persist_workspace_state(); - self.follow_editor_database(id); - } + WorkspaceInput::EditorTabDatabaseChanged(id) => self.on_editor_tab_database_changed(id), WorkspaceInput::WorkspaceLoaded(loaded) => self.on_workspace_loaded(loaded), WorkspaceInput::ResetWorkspaceLayout => self.on_reset_workspace_layout(), WorkspaceInput::SafeModeChanged => self.refresh_title(), From 7fa1d4fe8dd30eb7747f9e23adfe0ca0912b4f46 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 30 Oct 2026 20:27:12 +0700 Subject: [PATCH 46/53] test(linux-workspace): name the file names without a closure --- linux/crates/app/src/services/connection_deletion.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/linux/crates/app/src/services/connection_deletion.rs b/linux/crates/app/src/services/connection_deletion.rs index 3d03997c60..723ca09658 100644 --- a/linux/crates/app/src/services/connection_deletion.rs +++ b/linux/crates/app/src/services/connection_deletion.rs @@ -375,7 +375,7 @@ mod tests { database: "analytics".to_owned(), }, ); - let names = [&database, &other_database].map(|key| WorkspaceFileName::for_key(key)); + let names = [&database, &other_database].map(WorkspaceFileName::for_key); for (key, name) in [&database, &other_database].into_iter().zip(&names) { let draft = DraftId::new(); fixture From 8b911bd0bb4bf5fe53df2ebd95e1498c071eda8e Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 30 Oct 2026 21:00:30 +0700 Subject: [PATCH 47/53] fix(linux-workspace): close a database that joined during its connection's disconnect, and purge one only after its drafts land --- linux/crates/app/src/ui/session_window/mod.rs | 7 +++ .../app/src/workspace/workspace_store.rs | 44 ++++++++++++------- .../app/src/workspace/writes_in_flight.rs | 10 +++++ 3 files changed, 44 insertions(+), 17 deletions(-) diff --git a/linux/crates/app/src/ui/session_window/mod.rs b/linux/crates/app/src/ui/session_window/mod.rs index e4f2ffeffa..ad061aae96 100644 --- a/linux/crates/app/src/ui/session_window/mod.rs +++ b/linux/crates/app/src/ui/session_window/mod.rs @@ -1268,6 +1268,13 @@ impl SessionWindow { } fn on_session_teardown_approved(&self, key: &SessionKey) { + // A database that joined while the question was up has nothing + // unsaved yet, and goes with its connection too. + if let SessionKey::Saved(id) = key { + for sibling in self.siblings_of(*id) { + self.tear_down_session(&sibling); + } + } if self.tear_down_session(key) { tracing::info!("disconnected"); } diff --git a/linux/crates/app/src/workspace/workspace_store.rs b/linux/crates/app/src/workspace/workspace_store.rs index fe195b28da..0108faeda7 100644 --- a/linux/crates/app/src/workspace/workspace_store.rs +++ b/linux/crates/app/src/workspace/workspace_store.rs @@ -210,25 +210,35 @@ impl WorkspaceStore { /// connection `id` that was ever open beside it. Their names hold a /// digest of the database, so they are found by the connection's part. pub fn purge_databases_of(&self, id: uuid::Uuid) -> impl Future> + use<> { - let prefix = WorkspaceFileName::databases_prefix(id); - let stems: Vec = std::fs::read_dir(&self.inner.dir) - .into_iter() - .flatten() - .filter_map(|entry| entry.ok()?.file_name().into_string().ok()) - .filter_map(|name| name.strip_suffix(".json").map(str::to_owned)) - .filter(|stem| stem.starts_with(&prefix)) - .collect(); - let purges: Vec<_> = stems - .into_iter() - .filter_map(|stem| { - let drafts = tablepro_storage::DraftScope::new(&stem).ok()?; - let path = self.inner.dir.join(format!("{stem}.json")); - Some(queue_purge(&self.inner.writer, path, drafts)) - }) + // A database closed a moment ago may still be writing its drafts, + // which would come back after the purge. + let landings: Vec<_> = self + .inner + .outside + .keys_in_flight(|key| matches!(key, SessionKey::SavedIn { id: of, .. } if *of == id)) + .iter() + .filter_map(|key| self.inner.outside.landing(key)) .collect(); + let (dir, writer) = (self.inner.dir.clone(), self.inner.writer.clone()); async move { - for purge in purges { - purge.await.unwrap_or(Err(WorkspaceSaveError::WriterStopped))?; + for landing in landings { + let _ = landing.await; + } + let prefix = WorkspaceFileName::databases_prefix(id); + let stems: Vec = std::fs::read_dir(&dir) + .into_iter() + .flatten() + .filter_map(|entry| entry.ok()?.file_name().into_string().ok()) + .filter_map(|name| name.strip_suffix(".json").map(str::to_owned)) + .filter(|stem| stem.starts_with(&prefix)) + .collect(); + for stem in stems { + let Ok(drafts) = tablepro_storage::DraftScope::new(&stem) else { + continue; + }; + queue_purge(&writer, dir.join(format!("{stem}.json")), drafts) + .await + .unwrap_or(Err(WorkspaceSaveError::WriterStopped))?; } Ok(()) } diff --git a/linux/crates/app/src/workspace/writes_in_flight.rs b/linux/crates/app/src/workspace/writes_in_flight.rs index 4984e59b35..29de1d7d01 100644 --- a/linux/crates/app/src/workspace/writes_in_flight.rs +++ b/linux/crates/app/src/workspace/writes_in_flight.rs @@ -27,6 +27,16 @@ impl OutsideWrites { } } + /// The sessions with writes still in flight that `keep` picks. + pub(super) fn keys_in_flight(&self, keep: impl Fn(&SessionKey) -> bool) -> Vec { + self.sessions + .borrow() + .iter() + .filter(|(key, landing)| landing.writes > 0 && keep(key)) + .map(|(key, _)| key.clone()) + .collect() + } + /// Resolves once every write for `key` has landed, or `None` when /// none is in flight. pub(super) fn landing(&self, key: &SessionKey) -> Option> { From 19378be4023d26eb3b5317089ca0b306608381ba Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 30 Oct 2026 21:01:20 +0700 Subject: [PATCH 48/53] fix(linux-editor): hand a database's session to its tabs whenever it joins, and leave the sidebar's selection to the tab in front --- linux/crates/app/src/ui/session_window/imp.rs | 6 +- .../src/ui/session_window/session_teardown.rs | 2 +- .../src/ui/session_window/sibling_sessions.rs | 109 ++++++++---------- linux/crates/app/src/ui/workspace/mod.rs | 13 +++ 4 files changed, 64 insertions(+), 66 deletions(-) diff --git a/linux/crates/app/src/ui/session_window/imp.rs b/linux/crates/app/src/ui/session_window/imp.rs index 04e0843889..136becbcf3 100644 --- a/linux/crates/app/src/ui/session_window/imp.rs +++ b/linux/crates/app/src/ui/session_window/imp.rs @@ -63,9 +63,9 @@ pub struct SessionWindow { /// The recorded sessions the window was opened to restore, until /// each saved connection is looked up or each file resolved. pub(super) restoring: RefCell>, - /// Another database opening beside its connection, and what waits for - /// its session: each tab moved there while it opened. - pub(super) opening_databases: RefCell>, + /// Another database opening beside its connection, with the stop for + /// its open. + pub(super) opening_databases: RefCell>, /// One close prompt at a time, for a tab, the window or a /// disconnect. pub(super) close_guards: OnceCell>, 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 6bca7bd811..bb4d37ec04 100644 --- a/linux/crates/app/src/ui/session_window/session_teardown.rs +++ b/linux/crates/app/src/ui/session_window/session_teardown.rs @@ -64,7 +64,7 @@ impl SessionWindow { // A database still opening from this connection opens for nobody; // one asked of the next connection opens anew. if let SessionKey::Saved(id) = key { - imp.opening_databases.borrow_mut().retain(|opening, (cancel, _)| { + imp.opening_databases.borrow_mut().retain(|opening, cancel| { let goes = matches!(opening, SessionKey::SavedIn { id: of, .. } if of == id); if goes { cancel.cancel(); diff --git a/linux/crates/app/src/ui/session_window/sibling_sessions.rs b/linux/crates/app/src/ui/session_window/sibling_sessions.rs index 5a57d14ae3..e888a8553c 100644 --- a/linux/crates/app/src/ui/session_window/sibling_sessions.rs +++ b/linux/crates/app/src/ui/session_window/sibling_sessions.rs @@ -23,13 +23,6 @@ use crate::runtime::DeliverInput; use crate::ui::error_text::{connect_error_message, operation_error_message}; use crate::ui::workspace::WorkspaceInput; -/// What waits for a database's session while it opens. -pub(super) type Waiter = Box)>; - -/// A database opening beside its connection: the stop for its open, and -/// what waits for it. -pub(super) type Opening = (CancellationToken, Vec); - /// The response of the chooser that opens the database picked. const OPEN: &str = "open"; @@ -47,30 +40,14 @@ pub(super) fn offered(databases: &[SchemaInfo], open: &[String]) -> Vec impl SessionWindow { /// Open the connection `id`'s database `database` beside it. One open - /// already is left as it is; so is a connection this window does not - /// hold, whose window opens its own. + /// or opening already is left as it is; so is a connection this window + /// does not hold, whose window opens its own. pub(crate) fn open_database(&self, id: Uuid, database: String) { - self.open_database_then(id, database, |_, _| {}); - } - - /// Open the database as `open_database` does, then call `then` with - /// its session, at once when it is open already. - fn open_database_then( - &self, - id: Uuid, - database: String, - then: impl FnOnce(&SessionWindow, Arc) + 'static, - ) { let key = SessionKey::SavedIn { id, database: database.clone(), }; - if let Some(session) = self.held_session(&key) { - then(self, session); - return; - } - if let Some((_, waiting)) = self.imp().opening_databases.borrow_mut().get_mut(&key) { - waiting.push(Box::new(then)); + if self.held_session(&key).is_some() || self.imp().opening_databases.borrow().contains_key(&key) { return; } let (Some(origin), Some(state)) = (self.held_session(&SessionKey::Saved(id)), self.state()) else { @@ -86,7 +63,7 @@ impl SessionWindow { self.imp() .opening_databases .borrow_mut() - .insert(key.clone(), (cancel.clone(), vec![Box::new(then)])); + .insert(key.clone(), cancel.clone()); let opening = { let (database, origin, cancel) = (database.clone(), origin.clone(), cancel.clone()); state @@ -97,16 +74,15 @@ impl SessionWindow { let window = self.downgrade(); glib::spawn_future_local(async move { let opened = opening.await; - // A database goes with the connection it opened from, which may - // have gone, or been opened again, while it opened. // One stopped with its connection was taken off the list then, // and one under the same name since is another's. - let waiting = window - .upgrade() - .filter(|_| !cancel.is_cancelled()) - .and_then(|window| window.imp().opening_databases.borrow_mut().remove(&key)) - .map(|(_, waiting)| waiting) - .unwrap_or_default(); + if !cancel.is_cancelled() + && let Some(window) = window.upgrade() + { + window.imp().opening_databases.borrow_mut().remove(&key); + } + // A database goes with the connection it opened from, which may + // have gone, or been opened again, while it opened. let window = window.upgrade().filter(|window| { window .held_session(&SessionKey::Saved(id)) @@ -121,9 +97,7 @@ impl SessionWindow { match opened { Ok(Ok(session)) => { window.join(claim, session.clone(), &[]); - for waiter in waiting { - waiter(&window, session.clone()); - } + window.hand_out(id, &session); } Ok(Err(error)) => window.show_toast(&gettext_f( "Could not open the database “{database}”: {error}", @@ -136,6 +110,25 @@ impl SessionWindow { }); } + /// Give the connection `id`'s query tabs in `session`'s database its + /// catalog, a tab moved there while it opened or before it reopened + /// included. + fn hand_out(&self, id: Uuid, session: &Arc) { + let workspaces: Vec<_> = self + .imp() + .sessions + .borrow() + .iter() + .filter(|open| matches!(open.claim.key(), SessionKey::Saved(of) | SessionKey::SavedIn { id: of, .. } if *of == id)) + .map(|open| open.workspace.clone()) + .collect(); + for workspace in workspaces { + if let Some(sender) = workspace.sender() { + sender.deliver(WorkspaceInput::DatabaseOpened(session.clone())); + } + } + } + /// Open Database…: list the server's databases, then ask which to /// open beside the connection under `key`. pub(super) fn choose_database_in(&self, key: SessionKey) { @@ -212,46 +205,38 @@ impl SessionWindow { dialog.present(Some(self)); } - /// An editor tab of the session under `key` moved to `database`: reach - /// that database's session, the connection's or one opened beside it, - /// hand it to the tab's completion, and select its row (0030). Back on - /// its own session's database the tab reads that session's catalog. + /// An editor tab of the session under `key` moved to `database`: hand + /// its completion that database's session, the connection's or one + /// beside it, opening that one first (0030). Back on its own session's + /// database the tab reads that session's catalog. pub(super) fn follow_database(&self, key: &SessionKey, tab_id: Uuid, database: String) { let id = match key { SessionKey::Saved(id) | SessionKey::SavedIn { id, .. } => *id, SessionKey::File(_) => return, }; - let connection = SessionKey::Saved(id); - let Some(parent) = self.held_session(&connection) else { + let Some(parent) = self.held_session(&SessionKey::Saved(id)) else { return; }; + let sibling = SessionKey::SavedIn { + id, + database: database.clone(), + }; if database.is_empty() { - self.hand_over(key, tab_id, None, key); + self.hand_over(key, tab_id, None); } else if parent.database() == Some(database.as_str()) { - self.hand_over(key, tab_id, Some(parent), &connection); + self.hand_over(key, tab_id, Some(parent)); + } else if let Some(session) = self.held_session(&sibling) { + self.hand_over(key, tab_id, Some(session)); } else { - let (from, row) = ( - key.clone(), - SessionKey::SavedIn { - id, - database: database.clone(), - }, - ); - self.open_database_then(id, database, move |window, session| { - window.hand_over(&from, tab_id, Some(session), &row); - }); + // Its session hands itself to the tab once it joins. + self.open_database(id, database); } } - /// Give the editor tab `tab_id` of the session under `from` the - /// catalog of `session`, and select `row` in the sidebar. - fn hand_over(&self, from: &SessionKey, tab_id: Uuid, session: Option>, row: &SessionKey) { + fn hand_over(&self, from: &SessionKey, tab_id: Uuid, session: Option>) { if let Some(sender) = self.session_workspace(from).and_then(|workspace| workspace.sender()) { sender.deliver(WorkspaceInput::FollowCatalog { tab_id, session }); } - if let Some(tree) = self.shell().and_then(|shell| shell.tree()) { - tree.select(Some(row), None); - } } /// The keys of the connection `id`'s other open databases. diff --git a/linux/crates/app/src/ui/workspace/mod.rs b/linux/crates/app/src/ui/workspace/mod.rs index db64bd36b9..994762578f 100644 --- a/linux/crates/app/src/ui/workspace/mod.rs +++ b/linux/crates/app/src/ui/workspace/mod.rs @@ -572,6 +572,9 @@ pub(crate) enum WorkspaceInput { body: String, }, ShowToast(String), + /// Another database of the connection opened: its query tabs read + /// its catalog. + DatabaseOpened(Arc), /// Completion in the editor tab reads `session`'s catalog, the tab's /// database's, or the connection's again with `None`. FollowCatalog { @@ -1056,6 +1059,15 @@ impl WorkspaceModel { self.follow_editor_database(tab_id); } + fn on_database_opened(&self, session: &Arc) { + for tab in self.workspace_tabs.borrow().values() { + // A tab in another database lets it pass. + if let WorkspaceTab::Editor(slot) = tab { + slot.editor.follow_catalog(Some(session.clone())); + } + } + } + fn follow_catalog(&self, tab_id: Uuid, session: Option>) { if let Some(WorkspaceTab::Editor(slot)) = self.workspace_tabs.borrow().get(&tab_id) { slot.editor.follow_catalog(session); @@ -1304,6 +1316,7 @@ impl WorkspaceModel { WorkspaceInput::ShowAlert { title, body } => self.show_error_alert(&title, &body), WorkspaceInput::ShowToast(msg) => self.show_toast(&msg), WorkspaceInput::FollowCatalog { tab_id, session } => self.follow_catalog(tab_id, session), + WorkspaceInput::DatabaseOpened(session) => self.on_database_opened(&session), WorkspaceInput::TabDirtyChanged => self.refresh_unsaved(), WorkspaceInput::NewTableTab { schema } => self.on_new_table_tab(schema), WorkspaceInput::ShowDiagram { schema } => self.show_diagram(schema, Vec::new()), From e50f1756928b68d7836032112d6fc0060301dcce Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 30 Oct 2026 21:10:30 +0700 Subject: [PATCH 49/53] fix(linux-editor): export, describe and complete a moved tab in its own database, and offer one when none is open --- .../app/src/ui/assistant/assistant_access.rs | 17 ++++- .../app/src/ui/assistant/schema_reader.rs | 41 ++++++++---- .../src/ui/editor/completion/catalog_read.rs | 11 ++-- linux/crates/app/src/ui/editor/imp.rs | 3 + linux/crates/app/src/ui/editor/mod.rs | 10 ++- linux/crates/app/src/ui/editor/tab_scope.rs | 24 +++++-- linux/crates/app/src/ui/editor/tests.rs | 62 +++++++++++++++++++ .../crates/app/src/ui/workspace/assistant.rs | 18 ++++++ 8 files changed, 161 insertions(+), 25 deletions(-) diff --git a/linux/crates/app/src/ui/assistant/assistant_access.rs b/linux/crates/app/src/ui/assistant/assistant_access.rs index 67f888c59c..9e79516cda 100644 --- a/linux/crates/app/src/ui/assistant/assistant_access.rs +++ b/linux/crates/app/src/ui/assistant/assistant_access.rs @@ -14,6 +14,7 @@ use tablepro_core::{AiPolicy, AiProvider}; use crate::services::active_session::ActiveSession; use crate::services::ai_service::AiService; +use crate::ui::editor::CatalogHome; const CANCEL: &str = "cancel"; const ALLOW: &str = "allow"; @@ -54,6 +55,9 @@ pub(crate) struct AssistantAccess { /// Each Time. granted: RefCell>, asking: Cell, + /// Where the editor tab in front resolves names: its database, when + /// it moved off the connection's. + front: RefCell CatalogHome>>>, } impl AssistantAccess { @@ -72,9 +76,15 @@ impl AssistantAccess { policy: Box::new(policy), granted: RefCell::new(None), asking: Cell::new(false), + front: RefCell::new(None), }) } + /// Describe the database of the editor tab `front` says is in front. + pub(crate) fn follow_front(&self, front: impl Fn() -> CatalogHome + 'static) { + self.front.replace(Some(Box::new(front))); + } + pub(crate) fn ai(&self) -> &AiService { &self.ai } @@ -196,10 +206,15 @@ impl AssistantAccess { }; }; let engine = self.engine.clone(); + let home = self + .front + .borrow() + .as_ref() + .map_or_else(CatalogHome::default, |front| front()); let read = self .ai .tasks() - .spawn_task(async move { super::schema_reader::read(session, engine, text).await }) + .spawn_task(async move { super::schema_reader::read(session, engine, text, home).await }) .await; read.unwrap_or_else(|failure| { tracing::warn!(%failure, "the schema for the assistant could not be read"); diff --git a/linux/crates/app/src/ui/assistant/schema_reader.rs b/linux/crates/app/src/ui/assistant/schema_reader.rs index aff96ad98c..186927125c 100644 --- a/linux/crates/app/src/ui/assistant/schema_reader.rs +++ b/linux/crates/app/src/ui/assistant/schema_reader.rs @@ -10,12 +10,12 @@ use tablepro_core::sql_syntax::script::LexicalSettings; use tablepro_core::{ColumnInfo, TableRef}; use tablepro_session::Session; -use crate::ui::editor::{CompletionScope, read_catalog, tables_named}; +use crate::ui::editor::{CatalogHome, CompletionScope, read_catalog, tables_named}; /// The schema the user is in, its tables, and the structure of the /// tables `text` names. Shares the catalog's reads with the sidebar and /// completion, so it rarely reaches the server. -pub(crate) async fn read(session: Arc, engine: String, text: String) -> SchemaContext { +pub(crate) async fn read(session: Arc, engine: String, text: String, home: CatalogHome) -> SchemaContext { let grammar = session .current_dialect() .map_or(SqlGrammar::PostgreSql, |dialect| dialect.grammar()); @@ -24,9 +24,22 @@ pub(crate) async fn read(session: Arc, engine: String, text: String) -> } else { QueryLanguage::Commands }; + // Nothing the connection's catalog holds is in that database. + if let CatalogHome::Elsewhere(database) = home { + return SchemaContext { + engine, + language, + database: Some(database), + ..SchemaContext::default() + }; + } let mut named = tables_named(&text, grammar, LexicalSettings::default_for(grammar)); // A question in words names tables as words, which no SQL parse sees. - if let Ok(objects) = session.objects(ObjectScope::DefaultSchema).await { + let listed = match &home { + CatalogHome::Schema(database) => ObjectScope::Schema(database.clone()), + CatalogHome::Connection | CatalogHome::Elsewhere(_) => ObjectScope::DefaultSchema, + }; + if let Ok(objects) = session.objects(listed).await { for table in named_in_words(&text, &objects) { if !named.contains(&table) { named.push(table); @@ -39,15 +52,18 @@ pub(crate) async fn read(session: Arc, engine: String, text: String) -> CompletionScope::General { tables_in_statement: named, }, - crate::ui::editor::CatalogHome::Connection, + home.clone(), ) .await; - let schema = session.schemas().await.ok().and_then(|schemas| { - schemas - .iter() - .find(|schema| schema.is_default) - .map(|schema| schema.name.clone()) - }); + let schema = match &home { + CatalogHome::Schema(database) => Some(database.clone()), + CatalogHome::Connection | CatalogHome::Elsewhere(_) => session.schemas().await.ok().and_then(|schemas| { + schemas + .iter() + .find(|schema| schema.is_default) + .map(|schema| schema.name.clone()) + }), + }; let tables = answer .objects .map(|objects| { @@ -67,7 +83,10 @@ pub(crate) async fn read(session: Arc, engine: String, text: String) -> SchemaContext { engine, language, - database: session.database().map(str::to_owned), + database: match home { + CatalogHome::Schema(database) => Some(database), + CatalogHome::Connection | CatalogHome::Elsewhere(_) => session.database().map(str::to_owned), + }, schema, tables, described, diff --git a/linux/crates/app/src/ui/editor/completion/catalog_read.rs b/linux/crates/app/src/ui/editor/completion/catalog_read.rs index 24f9febb8c..a0e91a4d00 100644 --- a/linux/crates/app/src/ui/editor/completion/catalog_read.rs +++ b/linux/crates/app/src/ui/editor/completion/catalog_read.rs @@ -25,7 +25,7 @@ pub enum CatalogHome { Schema(String), /// A database the catalog does not describe, which nothing offered /// from it would be right for. - Elsewhere, + Elsewhere(String), } /// Read what `scope` offers. The catalog shares each read with every @@ -35,7 +35,7 @@ pub async fn read(session: Arc, scope: CompletionScope, home: CatalogHo let defaults = match &home { CatalogHome::Connection => listed(session.objects(ObjectScope::DefaultSchema).await), CatalogHome::Schema(name) => listed(session.objects(ObjectScope::Schema(name.clone())).await), - CatalogHome::Elsewhere => return CatalogAnswer::default(), + CatalogHome::Elsewhere(_) => return CatalogAnswer::default(), }; let home = Home { home, defaults }; match scope { @@ -90,14 +90,15 @@ impl Home { match &self.home { CatalogHome::Connection => schema.is_default, CatalogHome::Schema(name) => schema.name == *name, - CatalogHome::Elsewhere => false, + CatalogHome::Elsewhere(_) => false, } } /// A name for the home database the catalog does not list as a - /// schema, as MySQL lists only the connection's. + /// schema, as MySQL lists only the connection's. Exact, since + /// `Analytics` and `analytics` can be two databases. fn named(&self, name: &str) -> bool { - matches!(&self.home, CatalogHome::Schema(home) if home.eq_ignore_ascii_case(name)) + matches!(&self.home, CatalogHome::Schema(home) if home == name) } } diff --git a/linux/crates/app/src/ui/editor/imp.rs b/linux/crates/app/src/ui/editor/imp.rs index 3f0170cd70..7cf2bfe202 100644 --- a/linux/crates/app/src/ui/editor/imp.rs +++ b/linux/crates/app/src/ui/editor/imp.rs @@ -66,6 +66,9 @@ pub struct SqlEditor { pub slot: Arc, /// The database the bar names, to tell the workspace when it changes. pub named_database: RefCell>, + /// Where completion resolves unqualified names, which the assistant + /// describes too. + pub catalog_home: RefCell, /// The server's name for the connection's database, for one opened /// without a name. pub listed_own_database: RefCell>, diff --git a/linux/crates/app/src/ui/editor/mod.rs b/linux/crates/app/src/ui/editor/mod.rs index e1c3b7a61a..457426700f 100644 --- a/linux/crates/app/src/ui/editor/mod.rs +++ b/linux/crates/app/src/ui/editor/mod.rs @@ -871,8 +871,11 @@ impl SqlEditor { let EntryKind::ResultSet(result) = &entry.kind else { return None; }; - Some(ExportOffer { - origin: ExportOrigin::Editor { + // All Rows runs the statement again on the connection, which is in + // another database than this tab. + let origin = match self.tab_database() { + Some(_) => ExportOrigin::Shown { rows: shown }, + None => ExportOrigin::Editor { shown, query: ExportQuery::Editor { sql: result.statement.clone(), @@ -880,6 +883,9 @@ impl SqlEditor { shown_rows: result.row_count() as u64, truncated: result.summary.is_truncated(), }, + }; + Some(ExportOffer { + origin, session: self.deps().and_then(|deps| deps.session.get()), name: export_name_for_query(&entry.preview), target: None, diff --git a/linux/crates/app/src/ui/editor/tab_scope.rs b/linux/crates/app/src/ui/editor/tab_scope.rs index c06d90c9cb..d0e7eb8835 100644 --- a/linux/crates/app/src/ui/editor/tab_scope.rs +++ b/linux/crates/app/src/ui/editor/tab_scope.rs @@ -11,7 +11,7 @@ use tablepro_session::operation::OperationKind; use super::SqlEditor; use super::completion::{CatalogHome, SqlCompletionProvider}; -use crate::i18n::gettext_f; +use crate::i18n::{gettext, gettext_f}; /// The `editor` action a database menu item names, with the database as /// its target. @@ -33,9 +33,9 @@ pub(super) fn scope_text(database: &str, schema: Option<&str>) -> (String, Strin } /// The user's databases, then the engine's own after a separator. `None` -/// when there is no other database to move to. +/// when there is no database to move to but the one the tab is in. pub(super) fn database_menu(databases: &[SchemaInfo]) -> Option { - if databases.len() < 2 { + if !databases.iter().any(|database| !database.is_default) { return None; } let menu = gio::Menu::new(); @@ -61,7 +61,7 @@ pub(super) fn catalog_home(layout: DatabaseLayout, own: Option<&str>, database: match layout { _ if own == Some(database) => CatalogHome::Connection, DatabaseLayout::AsSchemas => CatalogHome::Schema(database.to_owned()), - DatabaseLayout::WithSchemas | DatabaseLayout::Single => CatalogHome::Elsewhere, + DatabaseLayout::WithSchemas | DatabaseLayout::Single => CatalogHome::Elsewhere(database.to_owned()), } } @@ -77,7 +77,12 @@ impl SqlEditor { return; }; let Some((database, layout)) = self.scope_database(&session) else { - self.set_scope(None); + // A connection in no database yet can still pick one. + let pick = self.imp().scope_button.menu_model().is_some().then(|| { + let none = gettext("No Database"); + (none.clone(), none) + }); + self.set_scope(pick); return; }; self.set_scope(Some(scope_text(&database, None))); @@ -219,7 +224,14 @@ impl SqlEditor { ) } + /// Where unqualified names in this tab resolve, for what the assistant + /// says about it. + pub(crate) fn catalog_home(&self) -> CatalogHome { + self.imp().catalog_home.borrow().clone() + } + fn follow_in_completion(&self, home: CatalogHome) { + self.imp().catalog_home.replace(home.clone()); for provider in self.imp().completion_providers.borrow().iter() { if let Some(provider) = provider.downcast_ref::() { provider.set_home(home.clone()); @@ -322,7 +334,7 @@ mod tests { ); assert_eq!( catalog_home(DatabaseLayout::WithSchemas, Some("shop"), "analytics"), - CatalogHome::Elsewhere + CatalogHome::Elsewhere("analytics".to_owned()) ); } } diff --git a/linux/crates/app/src/ui/editor/tests.rs b/linux/crates/app/src/ui/editor/tests.rs index 1ed38eba05..a960c9fe1e 100644 --- a/linux/crates/app/src/ui/editor/tests.rs +++ b/linux/crates/app/src/ui/editor/tests.rs @@ -2828,6 +2828,68 @@ fn a_connection_without_a_database_name_names_the_servers() { assert_eq!(harness.editor.tab_database(), None, "the connection's own"); } +/// A connection in no database yet still offers the server's. +#[gtk4::test] +fn a_connection_in_no_database_offers_one() { + let database = |name: &str| tablepro_core::meta::SchemaInfo { + name: name.to_owned(), + is_default: false, + is_system: false, + }; + let harness = scoped_editor_with(mysql_dialect(), &[], answering(), |script, request| { + if let tablepro_session::connect::RequestTarget::Network { database, .. } = &mut request.target { + *database = None; + } + script.list_databases.always(FakeReply::ok(vec![database("analytics")])); + }); + let button = harness.editor.imp().scope_button.get(); + wait_until(WAIT, || button.is_visible()).expect("the database menu"); + + assert_eq!(harness.editor.imp().scope_button_label.label(), "No Database"); + pick_database(&harness, "analytics"); + assert_eq!(harness.editor.imp().scope_button_label.label(), "analytics"); +} + +/// All Rows runs the statement again on the connection, in another +/// database than a moved tab's, so only the rows shown are offered. +#[gtk4::test] +fn a_moved_tab_exports_the_rows_it_shows_only() { + let mut runs = Replies::default(); + runs.always(result_set(3, Some(40), Truncation::ReadPastLimit)); + let harness = databases_editor(runs, false); + let offers = SignalLog::connect(&harness.editor, "export-results", |values| { + values[1] + .get::() + .expect("an offer") + .borrow::() + .clone() + }); + pick_database(&harness, "analytics"); + run_and_wait(&harness); + + shown_banner(&harness).emit_by_name::<()>("button-clicked", &[]); + + let offers = offers.take(); + let [offer] = offers.as_slice() else { + panic!("one offer, not {}", offers.len()); + }; + assert!(matches!(offer.origin, ExportOrigin::Shown { .. }), "{:?}", offer.origin); +} + +/// The assistant describes the database the tab in front is in. +#[gtk4::test] +fn the_assistant_reads_the_tabs_database() { + let harness = databases_editor(answering(), false); + assert_eq!(harness.editor.catalog_home(), CatalogHome::Connection); + + pick_database(&harness, "analytics"); + + assert_eq!( + harness.editor.catalog_home(), + CatalogHome::Schema("analytics".to_owned()) + ); +} + /// A database the server will not move to leaves the tab where its /// session is, and the run says why. #[gtk4::test] diff --git a/linux/crates/app/src/ui/workspace/assistant.rs b/linux/crates/app/src/ui/workspace/assistant.rs index a4c788ba03..9bbcc91590 100644 --- a/linux/crates/app/src/ui/workspace/assistant.rs +++ b/linux/crates/app/src/ui/workspace/assistant.rs @@ -19,6 +19,7 @@ use crate::application::AppServices; use crate::runtime::DeliverInput; use crate::services::active_session::ActiveSession; use crate::ui::assistant::{AssistantAccess, AssistantPane, AssistantRequest, Availability, PaneHooks}; +use crate::ui::editor::CatalogHome; use crate::ui::handler_guard::HandlerGuard; pub(super) struct AssistantColumn { @@ -52,6 +53,10 @@ pub(super) fn install(parts: &WorkspaceTabParts, input: &WorkspaceSender) -> Ass parts.index.clone(), parts.tab_view.downgrade(), ); + { + let (tabs, index, view) = (tabs.clone(), index.clone(), view.clone()); + access.follow_front(move || active_editor_home(&tabs, &index, &view)); + } let pane: AssistantPane = glib::Object::new(); pane.bind(PaneHooks { access: access.clone(), @@ -81,6 +86,19 @@ pub(super) fn install(parts: &WorkspaceTabParts, input: &WorkspaceSender) -> Ass } } +/// Where this session's editor tab on screen resolves names: its +/// database, when it moved off the connection's. +fn active_editor_home(tabs: &WorkspaceTabs, index: &TabIndex, view: &glib::WeakRef) -> CatalogHome { + let id = view.upgrade().and_then(|view| index.id_of(&view.selected_page()?)); + match id.and_then(|id| match tabs.borrow().get(&id) { + Some(WorkspaceTab::Editor(slot)) => Some(slot.editor.catalog_home()), + _ => None, + }) { + Some(home) => home, + None => CatalogHome::Connection, + } +} + /// The text of this session's editor tab while it is the one on screen. fn active_editor_text(tabs: &WorkspaceTabs, index: &TabIndex, view: &glib::WeakRef) -> Option { let id = index.id_of(&view.upgrade()?.selected_page()?)?; From 94cea80bf746655f568d3c3099dd1c81638564f4 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 30 Oct 2026 21:51:56 +0700 Subject: [PATCH 50/53] test(linux-mssql): create the test's database outside a transaction --- linux/crates/drivers/mssql/tests/integration/sessions.rs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/linux/crates/drivers/mssql/tests/integration/sessions.rs b/linux/crates/drivers/mssql/tests/integration/sessions.rs index 4364a74859..4df5fae92b 100644 --- a/linux/crates/drivers/mssql/tests/integration/sessions.rs +++ b/linux/crates/drivers/mssql/tests/integration/sessions.rs @@ -31,12 +31,13 @@ async fn use_database_persists_across_runs() { async fn a_session_moves_to_a_database_by_its_name() { let fixture = start().await; let connection = connect(&fixture, false).await; - setup( - connection.as_ref(), - &["IF DB_ID(N'odd]name') IS NULL CREATE DATABASE [odd]]name]"], + let session = session(connection.as_ref()).await; + // CREATE DATABASE runs outside a transaction, as the editor runs it. + run_ok( + session.as_ref(), + "IF DB_ID(N'odd]name') IS NULL CREATE DATABASE [odd]]name]", ) .await; - let session = session(connection.as_ref()).await; let options = || tablepro_core::CallOptions::new(None, CancellationToken::new()); session.use_database("odd]name", options()).await.expect("a move"); From f942f9032ec2a97888f94201e698dd41da00f7a5 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 30 Oct 2026 22:18:35 +0700 Subject: [PATCH 51/53] test(linux-editor): a menu for the one database a connection in none can pick --- linux/crates/app/src/ui/editor/tab_scope.rs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/linux/crates/app/src/ui/editor/tab_scope.rs b/linux/crates/app/src/ui/editor/tab_scope.rs index d0e7eb8835..c3db3f867c 100644 --- a/linux/crates/app/src/ui/editor/tab_scope.rs +++ b/linux/crates/app/src/ui/editor/tab_scope.rs @@ -314,9 +314,14 @@ mod tests { ["mysql", "information__schema"], "an underscore is no mnemonic" ); + let own = SchemaInfo { + is_default: true, + ..database("shop", false) + }; + assert!(database_menu(&[own]).is_none(), "nothing to move to"); assert!( - database_menu(&[database("shop", false)]).is_none(), - "nothing to move to" + database_menu(&[database("shop", false)]).is_some(), + "a connection in no database can pick its one" ); } From fde9a3f4c7bcc32e158f702d3595d19560a71d3a Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 31 Oct 2026 12:13:46 +0700 Subject: [PATCH 52/53] fix(linux-editor): retitle a tab whose database the server then names the connection's own --- linux/crates/app/src/ui/editor/tab_scope.rs | 13 ++++++-- linux/crates/app/src/ui/editor/tests.rs | 35 +++++++++++++++++++++ 2 files changed, 45 insertions(+), 3 deletions(-) diff --git a/linux/crates/app/src/ui/editor/tab_scope.rs b/linux/crates/app/src/ui/editor/tab_scope.rs index c3db3f867c..3a796c89bc 100644 --- a/linux/crates/app/src/ui/editor/tab_scope.rs +++ b/linux/crates/app/src/ui/editor/tab_scope.rs @@ -186,11 +186,18 @@ impl SqlEditor { }; if let Some(editor) = editor.upgrade() { let imp = editor.imp(); - let own = databases.iter().find(|database| database.is_default); - imp.listed_own_database - .replace(own.map(|database| database.name.clone())); + let own = databases + .iter() + .find(|database| database.is_default) + .map(|database| database.name.clone()); + let own_changed = imp.listed_own_database.replace(own.clone()) != own; imp.scope_button.set_menu_model(database_menu(&databases).as_ref()); editor.show_scope(); + // A tab already in the database the server now calls the + // connection's own is no longer a moved one. + if own_changed { + editor.emit_by_name::<()>("database-changed", &[]); + } } }); } diff --git a/linux/crates/app/src/ui/editor/tests.rs b/linux/crates/app/src/ui/editor/tests.rs index a960c9fe1e..7217fc22f4 100644 --- a/linux/crates/app/src/ui/editor/tests.rs +++ b/linux/crates/app/src/ui/editor/tests.rs @@ -2850,6 +2850,41 @@ fn a_connection_in_no_database_offers_one() { assert_eq!(harness.editor.imp().scope_button_label.label(), "analytics"); } +/// A tab in the database the server names as the connection's own once +/// it lists them again is no longer a moved one, for its title and its +/// record. +#[gtk4::test] +fn a_tab_in_the_database_the_server_then_calls_its_own_is_not_moved() { + let database = |name: &str, is_default: bool| tablepro_core::meta::SchemaInfo { + name: name.to_owned(), + is_default, + is_system: false, + }; + let harness = scoped_editor_with(mysql_dialect(), &[], answering(), |script, request| { + if let tablepro_session::connect::RequestTarget::Network { database, .. } = &mut request.target { + *database = None; + } + script + .list_databases + .once(FakeReply::ok(vec![ + database("analytics", false), + database("app", false), + ])) + .always(FakeReply::ok(vec![database("analytics", false), database("app", true)])); + }); + let button = harness.editor.imp().scope_button.get(); + wait_until(WAIT, || button.is_visible()).expect("the database menu"); + let changed = SignalLog::connect(&harness.editor, "database-changed", |_| ()); + pick_database(&harness, "app"); + assert_eq!(harness.editor.tab_database().as_deref(), Some("app")); + + harness.editor.buffer().set_text("CREATE DATABASE scratch"); + run_and_wait(&harness); + + wait_until(WAIT, || changed.count() == 2).expect("the workspace to hear the tab is in its own database"); + assert_eq!(harness.editor.tab_database(), None); +} + /// All Rows runs the statement again on the connection, in another /// database than a moved tab's, so only the rows shown are offered. #[gtk4::test] From a34a9052f20454b3264fedced8a1b17d79073b78 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 31 Oct 2026 12:14:32 +0700 Subject: [PATCH 53/53] refactor(linux-workspace): handle a tab's database change beside its file change --- linux/crates/app/src/ui/workspace/mod.rs | 5 +---- linux/crates/app/src/ui/workspace/workspace_tabs.rs | 7 +++++++ 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/linux/crates/app/src/ui/workspace/mod.rs b/linux/crates/app/src/ui/workspace/mod.rs index 7566b3cbc8..b2fcf23602 100644 --- a/linux/crates/app/src/ui/workspace/mod.rs +++ b/linux/crates/app/src/ui/workspace/mod.rs @@ -1275,10 +1275,7 @@ impl WorkspaceModel { } WorkspaceInput::EditorTabContentChanged(id) => self.on_editor_tab_content_changed(id), WorkspaceInput::EditorTabFileChanged(id) => self.on_editor_tab_file_changed(id), - WorkspaceInput::EditorTabDatabaseChanged(id) => { - self.persist_workspace_state(); - self.retitle_tab(id); - } + WorkspaceInput::EditorTabDatabaseChanged(id) => self.on_editor_tab_database_changed(id), WorkspaceInput::WorkspaceLoaded(loaded) => self.on_workspace_loaded(loaded), WorkspaceInput::ResetWorkspaceLayout => self.on_reset_workspace_layout(), WorkspaceInput::SafeModeChanged => self.refresh_title(), diff --git a/linux/crates/app/src/ui/workspace/workspace_tabs.rs b/linux/crates/app/src/ui/workspace/workspace_tabs.rs index 4b2f884211..2775d02249 100644 --- a/linux/crates/app/src/ui/workspace/workspace_tabs.rs +++ b/linux/crates/app/src/ui/workspace/workspace_tabs.rs @@ -530,6 +530,13 @@ impl WorkspaceModel { self.persist_workspace_state(); } + /// A tab that moved to another database is restored there and names + /// it in its title. + pub(super) fn on_editor_tab_database_changed(&self, id: Uuid) { + self.retitle_tab(id); + self.persist_workspace_state(); + } + pub(super) fn on_editor_tab_run_state_changed(&self, id: Uuid, running: bool) { if let Some(WorkspaceTab::Editor(slot)) = self.workspace_tabs.borrow().get(&id) { slot.page.set_loading(running);