diff --git a/linux/ARCHITECTURE.md b/linux/ARCHITECTURE.md index cb638c315a..3d10d408c3 100644 --- a/linux/ARCHITECTURE.md +++ b/linux/ARCHITECTURE.md @@ -263,7 +263,7 @@ A window holds one `Session`. Every use of its connection goes through the `Oper The application tells each session what its connection cannot see. When `GNetworkMonitor` reports a change, and when a window adopts a session, it asks whether that session's server, or its first SSH hop, is reachable; a file, a socket and a loopback server are never asked about. A session that loses a server the network cannot reach waits offline, checking every 60 s, and reconnects as soon as the server is reachable again. While a session is open, a 5 s timer compares `CLOCK_BOOTTIME` with `CLOCK_MONOTONIC`, and after a suspend each server is checked again and a session that slept more than 30 s probes its connection. A session parked on an SSH host key keeps the error that parked it, and its banner's Review Key shows the fingerprint. Trusting it retries with ssh's question about that one key answered yes, so ssh still checks the key and writes known_hosts; a changed key is first removed with `ssh-keygen -R`, since ssh never accepts one. When the review cannot accept the key, because ssh's configuration takes keys only from known_hosts or the key is revoked, its dialog offers Retry for after the user fixes the key outside TablePro. -The session's `SchemaCatalog` holds what the window knows about the database: the schemas, each scope's objects and each table's columns. Callers that ask for the same thing at once share one fetch, a fetch runs as its own task so a view that closes does not stop it, and a failed refresh keeps the list it had. A reconnect commits the catalog the new connection read and marks the rest stale; `invalidate_table` does the same for one table after a schema change. The sidebar shows every schema's tables and views in one `GtkListView` that recycles its rows, a section per schema with the default one first and the engine's own last, and one menu whose items carry the table they act on. Its rows come from a `SidebarIndex`, sorted by collation key and given its search keys on a blocking thread, behind a `GListModel` and `GtkSectionModel` that makes a row's object only when the list view asks for it. It follows every schema's list through a watch the window drops before the session closes, and reads that list again when a reconnect commits the default schema's; a refresh that fails keeps the list and says so in a toast. The editor's completion reads the catalog when it opens: the default schema's objects, a named schema's objects after `schema.`, and the columns of the tables the statement names. Its candidates are built with that read on the runtime, and its `ProposalList` ranks them for the typed word and makes a proposal only for a row GtkSourceView shows. Either list thus costs the GTK thread the rows on screen, not the catalog, which the large-catalog case checks with 50,000 tables ([testing.md](docs/testing.md#large-catalog-case)). +The session's `SchemaCatalog` holds what the window knows about the database: the schemas, each scope's objects and each table's columns. Callers that ask for the same thing at once share one fetch, a fetch runs as its own task so a view that closes does not stop it, and a failed refresh keeps the list it had. A reconnect commits the catalog the new connection read and marks the rest stale; `invalidate_table` does the same for one table after a schema change. The sidebar shows every schema's tables and views in one `GtkListView` that recycles its rows, a section per schema with the default one first and the engine's own last, and one menu whose items carry the table they act on. Its rows come from a `SidebarIndex`, sorted by collation key and given its search keys on a blocking thread, behind a `GListModel` and `GtkSectionModel` that makes a row's object only when the list view asks for it. Each search, and each new list, picks the rows it keeps on a blocking thread too, and the list takes them in one change unless a newer search or list overtook them. It follows every schema's list through a watch the window drops before the session closes, and reads that list again when a reconnect commits the default schema's; a refresh that fails keeps the list and says so in a toast. The editor's completion reads the catalog when it opens: the default schema's objects, a named schema's objects after `schema.`, and the columns of the tables the statement names. Its candidates are built with that read on the runtime, and its `ProposalList` ranks them for the typed word and makes a proposal only for a row GtkSourceView shows. Either list thus costs the GTK thread the rows on screen, not the catalog, which the large-catalog case checks with 50,000 tables ([testing.md](docs/testing.md#large-catalog-case)). ## Workspace tab system diff --git a/linux/crates/app/src/ui/workspace/mod.rs b/linux/crates/app/src/ui/workspace/mod.rs index dc74a17647..51ba750807 100644 --- a/linux/crates/app/src/ui/workspace/mod.rs +++ b/linux/crates/app/src/ui/workspace/mod.rs @@ -564,7 +564,7 @@ impl Workspace { let persistence = workspace_persistence::WorkspacePersistence::new(storage.workspaces().clone()); let exports = workspace.export_ledger(&window, &services, &context, &input); let imports = import_ledger::ImportLedger::new(context.hold.clone(), window_inhibitor(&window)); - let sidebar = sidebar::build(&workspace, &input, sidebar::SidebarAffordances::for_session(&session)); + let sidebar = sidebar::build(&workspace, &input, &session, &tasks); let WorkspaceTabParts { tab_view, index, diff --git a/linux/crates/app/src/ui/workspace/sidebar/mod.rs b/linux/crates/app/src/ui/workspace/sidebar/mod.rs index a486fd4cec..8fd91f48cc 100644 --- a/linux/crates/app/src/ui/workspace/sidebar/mod.rs +++ b/linux/crates/app/src/ui/workspace/sidebar/mod.rs @@ -40,10 +40,14 @@ use sidebar_event::SidebarEvent; /// Build the sidebar into the workspace's template, sending what the /// user asks of it to `input`. -pub(super) fn build(workspace: &Workspace, input: &WorkspaceSender, affordances: SidebarAffordances) -> SidebarView { +pub(super) fn build(workspace: &Workspace, input: &WorkspaceSender, session: &Session, tasks: &Tasks) -> SidebarView { let imp = workspace.imp(); let events = input.clone(); - let view = SidebarView::new(move |event| events.deliver(workspace_input(event)), affordances); + let view = SidebarView::new( + move |event| events.deliver(workspace_input(event)), + SidebarAffordances::for_session(session), + Some(tasks.clone()), + ); imp.sidebar_slot.set_child(Some(view.widget())); view.follow_search(&imp.table_search); imp.table_search_bar.connect_entry(&*imp.table_search); @@ -207,6 +211,8 @@ impl WorkspaceModel { pub(super) fn on_sidebar_read(&mut self, read: u64, listing: SidebarListing) { if read != self.sidebar_read { + // A catalog's index frees a few strings per table. + drop(self.context.tasks.spawn_blocking_task(move || drop(listing))); return; } if let Some(schemas) = listing.schemas { diff --git a/linux/crates/app/src/ui/workspace/sidebar/sidebar_list.rs b/linux/crates/app/src/ui/workspace/sidebar/sidebar_list.rs index 68723a6d88..eb225d950b 100644 --- a/linux/crates/app/src/ui/workspace/sidebar/sidebar_list.rs +++ b/linux/crates/app/src/ui/workspace/sidebar/sidebar_list.rs @@ -1,4 +1,4 @@ -use std::rc::Rc; +use std::sync::Arc; use gtk4 as gtk; use gtk4::prelude::*; @@ -17,32 +17,35 @@ glib::wrapper! { @implements gio::ListModel, gtk::SectionModel; } +/// The entries of an index a search keeps, in list order, and each +/// section's rows among them. Worked out away from the GTK thread: a +/// search reads every table's key. +#[derive(Debug)] +pub(crate) struct SidebarRows { + index: Arc, + shown: Vec, + runs: Vec<(u32, u32)>, +} + +impl SidebarRows { + /// `index`'s entries matching `query` as `search_key` gave it, every + /// one when it is empty. + pub(crate) fn new(index: Arc, query: &str) -> Self { + let shown = matching(&index, query); + let runs = runs(&index, &shown); + Self { index, shown, runs } + } +} + impl SidebarList { pub(crate) fn new() -> Self { glib::Object::new() } - pub(crate) fn index(&self) -> Rc { + pub(crate) fn index(&self) -> Arc { self.imp().index.borrow().clone() } - /// Show `index`, its entries matching `query` as `search_key` gave - /// it, every one when it is empty. - pub(crate) fn replace(&self, index: SidebarIndex, query: &str) { - let index = Rc::new(index); - let shown = matching(&index, query); - let imp = self.imp(); - imp.objects.borrow_mut().clear(); - imp.index.replace(index); - self.show(shown, true); - } - - /// Show the entries matching `query`. - pub(crate) fn filter(&self, query: &str) { - let shown = matching(&self.index(), query); - self.show(shown, false); - } - /// How many rows were made since the index was last replaced. #[cfg(test)] pub(crate) fn made(&self) -> usize { @@ -58,18 +61,23 @@ impl SidebarList { .and_then(|position| u32::try_from(position).ok()) } - /// Take every row out, then put the new ones in. GtkListView looks - /// for the rows it tracks, its scroll anchor among them, through - /// every row a change adds in place of them, which would make an - /// object for each; a change that only removes or only adds has - /// nothing to look through. The list opens at its first row, as a - /// new list or a new search should. - fn show(&self, shown: Vec, replaced: bool) { + /// Show `rows`, taking every row out, then putting the new ones in. + /// GtkListView looks for the rows it tracks, its scroll anchor among + /// them, through every row a change adds in place of them, which + /// would make an object for each; a change that only removes or only + /// adds has nothing to look through. The list opens at its first row, + /// as a new list or a new search should. + pub(crate) fn show(&self, rows: SidebarRows) { + let SidebarRows { index, shown, runs } = rows; let imp = self.imp(); + let replaced = !Arc::ptr_eq(&imp.index.borrow(), &index); if !replaced && *imp.shown.borrow() == shown { return; } - let runs = runs(&self.index(), &shown); + if replaced { + imp.objects.borrow_mut().clear(); + imp.index.replace(index); + } let removed = u32::try_from(imp.shown.take().len()).unwrap_or(u32::MAX); imp.runs.take(); if removed > 0 { @@ -141,6 +149,14 @@ mod tests { SidebarIndex::build(&objects, &schemas) } + fn replace(list: &SidebarList, index: SidebarIndex, query: &str) { + list.show(SidebarRows::new(Arc::new(index), query)); + } + + fn filter(list: &SidebarList, query: &str) { + list.show(SidebarRows::new(list.index(), query)); + } + fn sections(list: &SidebarList) -> Vec<(u32, u32)> { let mut found = Vec::new(); let mut position = 0; @@ -164,7 +180,8 @@ mod tests { #[gtk4::test] fn sections_are_the_runs_of_one_schema() { let list = SidebarList::new(); - list.replace( + replace( + &list, index(&[ ("sales", "orders"), ("public", "users"), @@ -187,29 +204,30 @@ mod tests { #[gtk4::test] fn a_row_keeps_its_object_until_the_index_is_replaced() { let list = SidebarList::new(); - list.replace(index(&[("public", "users"), ("public", "orders")]), ""); + replace(&list, index(&[("public", "users"), ("public", "orders")]), ""); let first = list.item(0); - list.filter(&search_key("o")); - list.filter(""); + filter(&list, &search_key("o")); + filter(&list, ""); assert_eq!(list.item(0), first, "a search made the row anew"); - list.replace(index(&[("public", "users"), ("public", "orders")]), ""); + replace(&list, index(&[("public", "users"), ("public", "orders")]), ""); assert_ne!(list.item(0), first, "a new index kept an old row"); } #[gtk4::test] fn a_search_is_one_change_and_none_when_the_rows_stay() { let list = SidebarList::new(); - list.replace( + replace( + &list, index(&[("public", "users"), ("public", "orders"), ("sales", "user_log")]), "", ); let seen = changes(&list); - list.filter(&search_key("user")); - list.filter(&search_key("user")); - list.filter(&search_key("users")); + filter(&list, &search_key("user")); + filter(&list, &search_key("user")); + filter(&list, &search_key("users")); assert_eq!(*seen.borrow(), [(0, 3, 0), (0, 0, 2), (0, 2, 0), (0, 0, 1)]); assert_eq!(sections(&list), [(0, 1)]); @@ -222,7 +240,7 @@ mod tests { let list = SidebarList::new(); let seen = changes(&list); - list.replace(index(&[]), ""); + replace(&list, index(&[]), ""); assert!(seen.borrow().is_empty()); assert_eq!(list.section(0), (0, u32::MAX)); @@ -233,7 +251,8 @@ mod tests { let list = SidebarList::new(); let query = search_key("ord"); - list.replace( + replace( + &list, index(&[("public", "users"), ("public", "orders"), ("public", "carts")]), &query, ); diff --git a/linux/crates/app/src/ui/workspace/sidebar/sidebar_list_imp.rs b/linux/crates/app/src/ui/workspace/sidebar/sidebar_list_imp.rs index 8459f42e6a..9778055e9e 100644 --- a/linux/crates/app/src/ui/workspace/sidebar/sidebar_list_imp.rs +++ b/linux/crates/app/src/ui/workspace/sidebar/sidebar_list_imp.rs @@ -1,6 +1,6 @@ use std::cell::RefCell; use std::collections::HashMap; -use std::rc::Rc; +use std::sync::Arc; use gtk4 as gtk; use gtk4::prelude::*; @@ -12,7 +12,7 @@ use super::sidebar_object::SidebarObject; #[derive(Default)] pub struct SidebarList { - pub(super) index: RefCell>, + pub(super) index: RefCell>, /// The entries the search keeps, in list order. pub(super) shown: RefCell>, /// Each section's rows in `shown`, from the first to past the last. diff --git a/linux/crates/app/src/ui/workspace/sidebar/sidebar_model.rs b/linux/crates/app/src/ui/workspace/sidebar/sidebar_model.rs index fc41516280..dddf6aa77d 100644 --- a/linux/crates/app/src/ui/workspace/sidebar/sidebar_model.rs +++ b/linux/crates/app/src/ui/workspace/sidebar/sidebar_model.rs @@ -1,22 +1,50 @@ -use std::cell::RefCell; +use std::cell::{Cell, RefCell}; +use std::sync::Arc; use gtk4 as gtk; use gtk4::prelude::*; use tablepro_core::meta::{ObjectKind, TableRef}; use super::sidebar_index::{SidebarIndex, search_key}; -use super::sidebar_list::SidebarList; +use super::sidebar_list::{SidebarList, SidebarRows}; use super::sidebar_object::SidebarObject; /// The sidebar's objects in schema sections, filtered by the search. A -/// new index replaces the list in one change, and a search reads the -/// index's search keys, so neither builds a widget or an object per -/// table. +/// new index or search is worked out into rows apart from the list, and +/// the list takes them in one change, so neither builds a widget or an +/// object per table. pub(crate) struct SidebarModel { list: SidebarList, selection: gtk::SingleSelection, + /// The index last given, which the list shows once its rows land. + index: RefCell>, /// The search as `search_key` gave it, empty for none. query: RefCell, + /// Counts the indexes and searches given, so rows worked out for an + /// older one never replace a newer one's. + asked: Cell, +} + +/// The rows to work out for the index and search last given. +pub(crate) struct RowsAsked { + index: Arc, + query: String, + asked: u64, +} + +impl RowsAsked { + pub(crate) fn work_out(self) -> RowsWorkedOut { + RowsWorkedOut { + rows: SidebarRows::new(self.index, &self.query), + asked: self.asked, + } + } +} + +#[derive(Debug)] +pub(crate) struct RowsWorkedOut { + rows: SidebarRows, + asked: u64, } impl SidebarModel { @@ -26,30 +54,52 @@ impl SidebarModel { selection.set_autoselect(false); selection.set_can_unselect(true); Self { + index: RefCell::new(list.index()), list, selection, query: RefCell::new(String::new()), + asked: Cell::new(0), } } - pub(crate) fn replace(&self, index: SidebarIndex) { - self.unselect(); - self.list.replace(index, &self.query.borrow()); + pub(crate) fn replace(&self, index: SidebarIndex) -> RowsAsked { + self.index.replace(Arc::new(index)); + self.ask() } - pub(crate) fn set_query(&self, query: &str) { + /// None when the search is the one already given. + pub(crate) fn set_query(&self, query: &str) -> Option { let query = query.trim(); let prepared = if query.is_empty() { String::new() } else { search_key(query) }; - let previous = self.query.replace(prepared.clone()); - if previous == prepared { - return; + if self.query.replace(prepared.clone()) == prepared { + return None; + } + Some(self.ask()) + } + + fn ask(&self) -> RowsAsked { + let asked = self.asked.get() + 1; + self.asked.set(asked); + RowsAsked { + index: self.index.borrow().clone(), + query: self.query.borrow().clone(), + asked, + } + } + + /// Show rows worked out for the index and search last given. Rows a + /// later one overtook change nothing and come back. + pub(crate) fn show(&self, worked_out: RowsWorkedOut) -> Result<(), RowsWorkedOut> { + if worked_out.asked != self.asked.get() { + return Err(worked_out); } self.unselect(); - self.list.filter(&prepared); + self.list.show(worked_out.rows); + Ok(()) } /// GtkSingleSelection looks for the selected row among every row a @@ -59,6 +109,11 @@ impl SidebarModel { self.selection.set_selected(gtk::INVALID_LIST_POSITION); } + /// The index the list shows now. + pub(crate) fn shown_index(&self) -> Arc { + self.list.index() + } + pub(crate) fn has_query(&self) -> bool { !self.query.borrow().is_empty() } @@ -131,7 +186,14 @@ mod tests { } fn replace(model: &SidebarModel, objects: &[ObjectInfo], schemas: &[SchemaInfo]) { - model.replace(SidebarIndex::build(objects, schemas)); + let asked = model.replace(SidebarIndex::build(objects, schemas)); + model.show(asked.work_out()).expect("the newest list's rows"); + } + + fn search(model: &SidebarModel, query: &str) { + if let Some(asked) = model.set_query(query) { + model.show(asked.work_out()).expect("the newest search's rows"); + } } /// Each section's title and its rows' names, in list order. @@ -232,7 +294,7 @@ mod tests { ); let index = model.list().index(); - model.set_query("USE"); + search(&model, "USE"); let names = |model: &SidebarModel| -> Vec { (0..model.shown()) .filter_map(|at| model.object_at(at)) @@ -240,15 +302,52 @@ mod tests { .collect() }; assert_eq!(names(&model), ["Users"]); - model.set_query("auth.TOK"); + search(&model, "auth.TOK"); assert_eq!(names(&model), ["tokens"]); - model.set_query("auth"); + search(&model, "auth"); assert_eq!(names(&model), ["tokens"], "the schema matches too"); - model.set_query(" "); + search(&model, " "); assert!(!model.has_query(), "blank is no search"); assert_eq!(model.shown(), 3); - assert!(Rc::ptr_eq(&index, &model.list().index()), "a search rebuilt the index"); + assert!(Arc::ptr_eq(&index, &model.list().index()), "a search rebuilt the index"); + } + + #[gtk4::test] + fn rows_worked_out_for_an_older_list_or_search_are_dropped() { + let model = SidebarModel::new(); + replace(&model, &[object("public", "orders")], &[]); + let names = |model: &SidebarModel| -> Vec { + (0..model.shown()) + .filter_map(|at| model.object_at(at)) + .map(|object| object.name()) + .collect() + }; + + let listing = model.replace(SidebarIndex::build( + &[object("public", "orders"), object("public", "users")], + &[], + )); + let older = model.set_query("ord").expect("a new search"); + let newer = model.set_query("us").expect("a new search"); + + assert!( + model.show(newer.work_out()).is_ok(), + "the last search's rows were dropped" + ); + assert!( + model.show(older.work_out()).is_err(), + "an older search's rows replaced the last" + ); + assert!( + model.show(listing.work_out()).is_err(), + "a list's rows replaced a later search's" + ); + assert_eq!(names(&model), ["users"], "the search did not filter the new list"); + assert!( + model.set_query(" US ").is_none(), + "the same search was worked out again" + ); } #[gtk4::test] @@ -285,8 +384,8 @@ mod tests { model.select(Some(&last)); replace(&model, &objects, &[]); - model.set_query("t0"); - model.set_query("t"); + search(&model, "t0"); + search(&model, "t"); model.select(Some(&last)); assert_eq!(model.selection().selected(), 4_999); @@ -306,7 +405,7 @@ mod tests { model.select(Some(&users)); assert_eq!(model.selection().selected(), 1); - model.set_query("ord"); + search(&model, "ord"); model.select(Some(&users)); assert_eq!(model.selection().selected(), gtk::INVALID_LIST_POSITION); assert_eq!( @@ -315,7 +414,7 @@ mod tests { "a hidden table is still listed" ); - model.set_query(""); + search(&model, ""); model.select(Some(&users)); model.select(None); assert_eq!(model.selection().selected(), gtk::INVALID_LIST_POSITION); diff --git a/linux/crates/app/src/ui/workspace/sidebar/sidebar_view.rs b/linux/crates/app/src/ui/workspace/sidebar/sidebar_view.rs index ed8942fba8..94006d9994 100644 --- a/linux/crates/app/src/ui/workspace/sidebar/sidebar_view.rs +++ b/linux/crates/app/src/ui/workspace/sidebar/sidebar_view.rs @@ -1,11 +1,13 @@ use std::cell::{Cell, RefCell}; use std::rc::{Rc, Weak}; +use std::sync::Arc; use gtk4 as gtk; use gtk4::prelude::*; use gtk4::{gdk, gio, glib}; use libadwaita as adw; use tablepro_core::meta::TableRef; +use tablepro_session::runtime::Tasks; use super::object_key::ObjectKey; use super::sidebar_affordances::SidebarAffordances; @@ -16,7 +18,7 @@ use super::sidebar_menu::{ CONTEXT_MENU, DROP_TABLE, EDIT_STRUCTURE, GROUP, IMPORT_CSV, NEW_TABLE, OPEN_IN_NEW_TAB, SHOW_CREATE_TABLE, object_menu, offers, }; -use super::sidebar_model::SidebarModel; +use super::sidebar_model::{RowsAsked, RowsWorkedOut, SidebarModel}; use super::sidebar_row_factory::row_factory; use crate::application::{ShortcutOwner, local_shortcut_controller}; @@ -39,6 +41,9 @@ pub(crate) struct SidebarInner { empty: adw::StatusPage, events: Box, affordances: Cell, + /// Where a new index or search is worked out into rows. Without it, + /// on the GTK thread. + tasks: Option, /// Whether a list was shown yet. Until then the sidebar is blank /// rather than saying there are no tables. listed: Cell, @@ -56,7 +61,11 @@ pub(crate) struct SidebarInner { } impl SidebarView { - pub(crate) fn new(events: impl Fn(SidebarEvent) + 'static, affordances: SidebarAffordances) -> Self { + pub(crate) fn new( + events: impl Fn(SidebarEvent) + 'static, + affordances: SidebarAffordances, + tasks: Option, + ) -> Self { let model = SidebarModel::new(); let list_view = gtk::ListView::new(Some(model.selection().clone()), None::); list_view.add_css_class("navigation-sidebar"); @@ -90,6 +99,7 @@ impl SidebarView { empty, events: Box::new(events), affordances: Cell::new(affordances), + tasks, listed: Cell::new(false), header_factory: RefCell::new(None), active: RefCell::new(None), @@ -115,15 +125,13 @@ impl SidebarView { &self.inner.root } - /// Show a new object list. The selection goes back to the tab in - /// front's table, which the new list holds as a new object. + /// Show a new object list under the search, once its rows are worked + /// out. The selection goes back to the tab in front's table, which the + /// new list holds as a new object. pub(crate) fn show(&self, index: SidebarIndex) { - self.inner.model.replace(index); self.inner.listed.set(true); - self.inner.reselect(); - // An empty list replacing an empty list changes no item, so the - // list's signal never says the placeholder is out of date. - self.inner.refresh_placeholder(); + let asked = self.inner.model.replace(index); + work_out_rows(&self.inner, asked); } /// `show`, indexing on the GTK thread, for lists a test writes out. @@ -137,9 +145,9 @@ impl SidebarView { } pub(crate) fn set_query(&self, query: &str) { - self.inner.model.set_query(query); - self.inner.reselect(); - self.inner.refresh_placeholder(); + if let Some(asked) = self.inner.model.set_query(query) { + work_out_rows(&self.inner, asked); + } } /// Select the row of the table the tab in front shows, or none for @@ -238,7 +246,11 @@ impl SidebarInner { pub(super) fn track_row(&self, item: >k::ListItem) { let mut rows = self.rows.borrow_mut(); - rows.retain(|row| row.upgrade().is_some()); + // A search makes two hundred rows at once, so the rows gone are + // dropped only when the list would grow, not on every row. + if rows.len() == rows.capacity() { + rows.retain(|row| row.upgrade().is_some()); + } rows.push(item.downgrade()); } @@ -385,6 +397,30 @@ impl SidebarInner { (self.events)(SidebarEvent::NewTable(schema.filter(|schema| !schema.is_empty()))); } + fn show_rows(&self, worked_out: RowsWorkedOut) { + let previous = self.model.shown_index(); + if let Err(overtaken) = self.model.show(worked_out) { + self.release(overtaken); + return; + } + self.reselect(); + // An empty list replacing an empty list changes no item, so the + // list's signal never says the placeholder is out of date. + self.refresh_placeholder(); + if !Arc::ptr_eq(&previous, &self.model.shown_index()) { + self.release(previous); + } + } + + /// Let go of what may hold the last reference to an index on the + /// blocking threads: an index frees a few strings per table, which + /// takes a catalog of 50,000 longer than a frame. + fn release(&self, unused: impl Send + 'static) { + if let Some(tasks) = self.tasks.as_ref() { + drop(tasks.spawn_blocking_task(move || drop(unused))); + } + } + fn reselect(&self) { self.model.select(self.active.borrow().as_ref()); } @@ -414,6 +450,27 @@ impl SidebarInner { } } +/// Work out the rows on the blocking threads, then show them unless a +/// newer index or search overtook them. +fn work_out_rows(inner: &Rc, asked: RowsAsked) { + let Some(tasks) = inner.tasks.as_ref() else { + inner.show_rows(asked.work_out()); + return; + }; + let working = tasks.spawn_blocking_task(move || asked.work_out()); + let view = Rc::downgrade(inner); + glib::spawn_future_local(async move { + match working.await { + Ok(worked_out) => { + if let Some(view) = view.upgrade() { + view.show_rows(worked_out); + } + } + Err(failure) => tracing::debug!(%failure, "the sidebar's rows were not worked out"), + } + }); +} + fn install_actions(inner: &Rc) { for name in [ OPEN_IN_NEW_TAB, diff --git a/linux/crates/app/src/ui/workspace/sidebar/tests.rs b/linux/crates/app/src/ui/workspace/sidebar/tests.rs index a1c1929e89..a99de37cfe 100644 --- a/linux/crates/app/src/ui/workspace/sidebar/tests.rs +++ b/linux/crates/app/src/ui/workspace/sidebar/tests.rs @@ -14,7 +14,7 @@ use tablepro_test_support::FakeReply; use super::object_key::ObjectKey; use super::sidebar_event::SidebarEvent; use super::*; -use crate::test_support::{descendants, drain_main_context, wait_until}; +use crate::test_support::{descendants, drain_main_context, test_runtime, wait_until}; use crate::ui::session_window::tests::{TestWindow, WAIT}; use crate::ui::workspace::WorkspaceTab; use crate::ui::workspace::tab_spec::{TabSpec, TablePage}; @@ -65,7 +65,7 @@ impl Shown { adw::init().expect("libadwaita initialises under the test backend"); let events = Events::default(); let sink = events.clone(); - let view = SidebarView::new(move |event| sink.borrow_mut().push(event), affordances); + let view = SidebarView::new(move |event| sink.borrow_mut().push(event), affordances, None); let objects: Vec = names.iter().map(|name| object("public", name)).collect(); view.apply_objects(&objects, &public()); let window = gtk::Window::new(); @@ -213,6 +213,31 @@ fn a_search_makes_only_the_rows_it_shows() { ); } +#[gtk4::test] +fn a_list_and_its_searches_are_worked_out_off_the_gtk_thread() { + adw::init().expect("libadwaita initialises under the test backend"); + let runtime = test_runtime(); + let view = SidebarView::new(|_| {}, every_affordance(), Some(runtime.tasks())); + let objects: Vec = names(20_000).iter().map(|name| object("public", name)).collect(); + + view.show(SidebarIndex::build(&objects, &public())); + assert_eq!(view.shown(), 0, "the GTK thread worked out the list's rows"); + wait_until(WAIT, || view.shown() == 20_000).expect("the list's rows"); + view.sync_selection(Some(&table("t19999"))); + for query in ["t1", "t19", "t19999"] { + view.set_query(query); + } + + assert_eq!(view.shown(), 20_000, "the GTK thread worked out a search's rows"); + wait_until(WAIT, || view.shown() == 1).expect("the last search's rows"); + assert_eq!( + view.selected_table(), + Some(table("t19999")), + "the selection did not come back" + ); + view.shutdown(); +} + #[gtk4::test] fn single_click_activation_opens_table() { let shown = Shown::new(&["a", "b", "c", "d", "e"], every_affordance()); diff --git a/linux/crates/app/src/ui/workspace/tests/large_catalog.rs b/linux/crates/app/src/ui/workspace/tests/large_catalog.rs index 01492a97c5..8b9baf9458 100644 --- a/linux/crates/app/src/ui/workspace/tests/large_catalog.rs +++ b/linux/crates/app/src/ui/workspace/tests/large_catalog.rs @@ -662,7 +662,7 @@ struct Screen { impl Screen { fn new(session: &Arc, tasks: &Tasks) -> Self { - let sidebar = SidebarView::new(|_| {}, SidebarAffordances::for_session(session)); + let sidebar = SidebarView::new(|_| {}, SidebarAffordances::for_session(session), Some(tasks.clone())); let panes = gtk4::Paned::new(gtk4::Orientation::Horizontal); panes.set_start_child(Some(sidebar.widget())); panes.set_position(300);