From f53fdc7615c849056edac403415919b2ebe66761 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 17 Oct 2026 16:33:32 +0700 Subject: [PATCH 01/14] fix(linux-browse): page past the edge row's key, not by offset A table with no key walks PostgreSQL heap block windows, so no page sorts the whole table. OFFSET stays under a user sort. An unsorted export of such a table no longer orders by ctid. --- .../app/src/ui/browse_tab/browse_pager.rs | 238 +++++++++- .../app/src/ui/browse_tab/command_handlers.rs | 3 + .../app/src/ui/browse_tab/data_handlers.rs | 10 +- linux/crates/app/src/ui/browse_tab/fetch.rs | 52 +-- .../app/src/ui/browse_tab/page_reply.rs | 29 +- .../app/src/ui/browse_tab/paging_handlers.rs | 12 +- .../crates/app/src/ui/browse_tab/row_count.rs | 4 +- linux/crates/app/src/ui/browse_tab/tests.rs | 71 +++ .../app/src/ui/browse_tab/total_tests.rs | 4 + .../core/src/browse/browse_sql_error.rs | 2 + linux/crates/core/src/browse/build_export.rs | 14 +- linux/crates/core/src/browse/build_page.rs | 54 ++- linux/crates/core/src/browse/mod.rs | 2 + linux/crates/core/src/browse/read_page.rs | 416 ++++++++++++++++++ linux/crates/core/src/browse/split_page.rs | 2 +- linux/crates/core/src/browse/tests.rs | 110 ++++- linux/crates/core/src/meta/mod.rs | 2 +- linux/crates/core/src/meta/page.rs | 31 +- linux/crates/core/src/meta/row_identity.rs | 16 + .../postgres/tests/integration/main.rs | 1 + .../postgres/tests/integration/paging.rs | 195 ++++++++ 21 files changed, 1150 insertions(+), 118 deletions(-) create mode 100644 linux/crates/core/src/browse/read_page.rs create mode 100644 linux/crates/drivers/postgres/tests/integration/paging.rs diff --git a/linux/crates/app/src/ui/browse_tab/browse_pager.rs b/linux/crates/app/src/ui/browse_tab/browse_pager.rs index 2264e4341d..e8f54cbb60 100644 --- a/linux/crates/app/src/ui/browse_tab/browse_pager.rs +++ b/linux/crates/app/src/ui/browse_tab/browse_pager.rs @@ -4,17 +4,54 @@ use super::row_total::RowTotal; /// Where the tab's page starts, and how many rows a page holds. Each /// move gives the position to read and the row the page then starts at. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +/// +/// A table that pages by key or heap address is read past the edge +/// row of the page on screen, so a page far into it costs what the +/// first one does. Only a page with no such edge, or a sort of the +/// user's, is read by offset. +#[derive(Debug, Clone, PartialEq)] pub(crate) struct BrowsePager { page_size: u64, start_ordinal: u64, + /// What a read of the page the pager is on asks for. + position: PagePosition, + /// How many rows that read asks for: a page, or for the last page + /// read from the end, what is left of the table. + limit: u64, + /// The way from the page on screen to the pages either side of it, + /// gone once the pager moves off that page. + edges: PageEdges, + /// The last page can be read backward from the table's end, which + /// the last page read said. + seeks: bool, +} + +/// How to reach the pages either side of a page by its edge rows. +#[derive(Debug, Clone, Default, PartialEq)] +pub(crate) struct PageEdges { + pub(crate) previous: Option, + pub(crate) next: Option, +} + +/// A page the pager can move to. +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct PageTarget { + pub(crate) position: PagePosition, + /// How many rows of the table come before the page. + pub(crate) start: u64, + pub(crate) limit: u64, } impl BrowsePager { pub(crate) fn new(page_size: u64, start_ordinal: u64) -> Self { + let page_size = page_size.max(1); Self { - page_size: page_size.max(1), + page_size, start_ordinal, + position: position_at(start_ordinal), + limit: page_size, + edges: PageEdges::default(), + seeks: false, } } @@ -28,55 +65,114 @@ impl BrowsePager { /// The position a read of the page the pager is on asks for. pub(crate) fn position(&self) -> PagePosition { - position_at(self.start_ordinal) + self.position.clone() + } + + pub(crate) fn limit(&self) -> u64 { + self.limit + } + + pub(crate) fn move_to(&mut self, target: PageTarget) { + self.position = target.position; + self.start_ordinal = target.start; + self.limit = target.limit; + self.edges = PageEdges::default(); } - pub(crate) fn go_to(&mut self, start_ordinal: u64) { - self.start_ordinal = start_ordinal; + /// Back to the first row, under a sort or filter that may page + /// another way, which the next page read says. + pub(crate) fn restart(&mut self) { + self.move_to(self.target(PagePosition::First, 0)); + self.seeks = false; + } + + /// The table's row identity changed, so a key the pager holds names + /// rows by columns the table may no longer key on. + pub(crate) fn rebase(&mut self) { + self.move_to(self.target(position_at(self.start_ordinal), self.start_ordinal)); + self.seeks = false; + } + + /// A page landed: where it starts, what it was read as, and the way + /// on from it. + pub(crate) fn land(&mut self, start: u64, position: PagePosition, edges: PageEdges, seeks: bool) { + if position == PagePosition::First { + self.limit = self.page_size; + } + self.position = position; + self.start_ordinal = start; + self.edges = edges; + self.seeks = seeks; } /// A new page size starts again from the first row. pub(crate) fn resize(&mut self, page_size: u64) { self.page_size = page_size.max(1); - self.start_ordinal = 0; + self.move_to(self.target(PagePosition::First, 0)); } - pub(crate) fn first(&self) -> Option<(PagePosition, u64)> { - (self.start_ordinal > 0).then_some((PagePosition::First, 0)) + pub(crate) fn first(&self) -> Option { + (self.start_ordinal > 0).then(|| self.target(PagePosition::First, 0)) } - pub(crate) fn prev(&self) -> Option<(PagePosition, u64)> { + pub(crate) fn prev(&self) -> Option { (self.start_ordinal > 0).then(|| { let start = self.start_ordinal.saturating_sub(self.page_size); - (position_at(start), start) + let position = match start { + 0 => PagePosition::First, + start => self.edges.previous.clone().unwrap_or(PagePosition::Offset(start)), + }; + self.target(position, start) }) } /// A page with fewer rows than a page holds was the last one. - pub(crate) fn next(&self, rows_on_page: u64) -> Option<(PagePosition, u64)> { + pub(crate) fn next(&self, rows_on_page: u64) -> Option { (rows_on_page >= self.page_size).then(|| { let start = self.start_ordinal.saturating_add(self.page_size); - (position_at(start), start) + let position = self.edges.next.clone().unwrap_or(PagePosition::Offset(start)); + self.target(position, start) }) } /// Only an exact total says where the last page starts. - pub(crate) fn last(&self, total: &RowTotal) -> Option<(PagePosition, u64)> { - let start = self.last_start(total.exact()?)?; - (start != self.start_ordinal).then(|| (position_at(start), start)) + pub(crate) fn last(&self, total: &RowTotal) -> Option { + let total = total.exact()?; + let start = self.last_start(total)?; + (start != self.start_ordinal).then(|| self.last_page(start, total)) } /// The last page, for a pager left past the end of a table that /// shrank since. - pub(crate) fn clamp(&self, total: u64) -> Option<(PagePosition, u64)> { + pub(crate) fn clamp(&self, total: u64) -> Option { let start = self.last_start(total)?; - (self.start_ordinal >= total).then(|| (position_at(start), start)) + (self.start_ordinal >= total).then(|| self.last_page(start, total)) + } + + fn last_page(&self, start: u64, total: u64) -> PageTarget { + match (start, self.seeks) { + (0, _) => self.target(PagePosition::First, 0), + (start, true) => PageTarget { + position: PagePosition::Last, + start, + limit: total - start, + }, + (start, false) => self.target(PagePosition::Offset(start), start), + } } fn last_start(&self, total: u64) -> Option { let last_row = total.checked_sub(1)?; Some(last_row / self.page_size * self.page_size) } + + fn target(&self, position: PagePosition, start: u64) -> PageTarget { + PageTarget { + position, + start, + limit: self.page_size, + } + } } fn position_at(start: u64) -> PagePosition { @@ -88,8 +184,25 @@ fn position_at(start: u64) -> PagePosition { #[cfg(test)] mod tests { + use tablepro_core::Value; + use super::*; + fn target(position: PagePosition, start: u64) -> Option { + Some(PageTarget { + position, + start, + limit: 100, + }) + } + + fn keyed_edges(first: i64, last: i64) -> PageEdges { + PageEdges { + previous: Some(PagePosition::BeforeKey(vec![Value::Int(first)])), + next: Some(PagePosition::AfterKey(vec![Value::Int(last)])), + } + } + #[test] fn browse_pager_last_requires_exact_total() { let pager = BrowsePager::new(100, 0); @@ -99,11 +212,11 @@ mod tests { assert_eq!(pager.last(&RowTotal::Counting { estimate: Some(1_000) }), None); assert_eq!( pager.last(&RowTotal::Exact(1_000)), - Some((PagePosition::Offset(900), 900)) + target(PagePosition::Offset(900), 900) ); assert_eq!( pager.last(&RowTotal::Exact(1_001)), - Some((PagePosition::Offset(1_000), 1_000)) + target(PagePosition::Offset(1_000), 1_000) ); assert_eq!(pager.last(&RowTotal::Exact(0)), None); assert_eq!(BrowsePager::new(100, 900).last(&RowTotal::Exact(1_000)), None); @@ -114,20 +227,96 @@ mod tests { let pager = BrowsePager::new(100, 200); assert_eq!(pager.position(), PagePosition::Offset(200)); - assert_eq!(pager.first(), Some((PagePosition::First, 0))); - assert_eq!(pager.prev(), Some((PagePosition::Offset(100), 100))); - assert_eq!(pager.next(100), Some((PagePosition::Offset(300), 300))); + assert_eq!(pager.first(), target(PagePosition::First, 0)); + assert_eq!(pager.prev(), target(PagePosition::Offset(100), 100)); + assert_eq!(pager.next(100), target(PagePosition::Offset(300), 300)); assert_eq!(pager.next(40), None, "a short page went on"); assert_eq!(BrowsePager::new(100, 0).first(), None); assert_eq!(BrowsePager::new(100, 0).prev(), None); - assert_eq!(BrowsePager::new(100, 50).prev(), Some((PagePosition::First, 0))); + assert_eq!(BrowsePager::new(100, 50).prev(), target(PagePosition::First, 0)); + } + + #[test] + fn a_keyed_page_pages_past_its_edge_rows_and_keeps_the_row_numbers() { + let mut pager = BrowsePager::new(100, 0); + pager.land(200, PagePosition::Offset(200), keyed_edges(201, 300), true); + + assert_eq!( + pager.next(100), + target(PagePosition::AfterKey(vec![Value::Int(300)]), 300) + ); + assert_eq!( + pager.prev(), + target(PagePosition::BeforeKey(vec![Value::Int(201)]), 100) + ); + let mut near_start = BrowsePager::new(100, 0); + near_start.land(100, PagePosition::Offset(100), keyed_edges(101, 200), true); + assert_eq!( + near_start.prev(), + target(PagePosition::First, 0), + "a page read backward from the second page would start mid-table" + ); + } + + #[test] + fn a_move_forgets_the_edges_of_the_page_it_left() { + let mut pager = BrowsePager::new(100, 0); + pager.land(0, PagePosition::First, keyed_edges(1, 100), true); + let next = pager.next(100).expect("a next page"); + + pager.move_to(next); + + assert_eq!( + pager.next(100), + target(PagePosition::Offset(200), 200), + "a page not yet read has no edge to read past" + ); + } + + #[test] + fn the_last_page_of_a_seekable_table_reads_what_is_left_from_its_end() { + let mut pager = BrowsePager::new(100, 0); + pager.land(0, PagePosition::First, keyed_edges(1, 100), true); + + assert_eq!( + pager.last(&RowTotal::Exact(1_042)), + Some(PageTarget { + position: PagePosition::Last, + start: 1_000, + limit: 42, + }) + ); + let mut past_end = BrowsePager::new(100, 0); + past_end.land(1_500, PagePosition::Offset(1_500), PageEdges::default(), true); + assert_eq!( + past_end.clamp(1_100), + Some(PageTarget { + position: PagePosition::Last, + start: 1_000, + limit: 100, + }) + ); + } + + #[test] + fn a_new_sort_pages_by_offset_until_its_first_page_lands() { + let mut pager = BrowsePager::new(100, 0); + pager.land(300, PagePosition::Offset(300), keyed_edges(301, 400), true); + + pager.restart(); + + assert_eq!((pager.position(), pager.start_ordinal()), (PagePosition::First, 0)); + assert_eq!( + pager.last(&RowTotal::Exact(1_042)), + target(PagePosition::Offset(1_000), 1_000) + ); } #[test] fn a_pager_past_the_end_clamps_to_the_last_page() { assert_eq!( BrowsePager::new(100, 500).clamp(250), - Some((PagePosition::Offset(200), 200)) + target(PagePosition::Offset(200), 200) ); assert_eq!(BrowsePager::new(100, 200).clamp(250), None); assert_eq!( @@ -145,6 +334,7 @@ mod tests { pager.resize(500); assert_eq!((pager.page_size(), pager.start_ordinal()), (500, 0)); + assert_eq!((pager.position(), pager.limit()), (PagePosition::First, 500)); assert_eq!(BrowsePager::new(0, 0).page_size(), 1); } } diff --git a/linux/crates/app/src/ui/browse_tab/command_handlers.rs b/linux/crates/app/src/ui/browse_tab/command_handlers.rs index 6f6a25b1a6..6e2d0adfc3 100644 --- a/linux/crates/app/src/ui/browse_tab/command_handlers.rs +++ b/linux/crates/app/src/ui/browse_tab/command_handlers.rs @@ -19,6 +19,9 @@ impl BrowseModel { self.failed.record(BrowseRequestKind::Columns, &value); match value { Ok(reply) => { + if self.identity.as_ref() != Some(&reply.identity) { + self.pager.rebase(); + } self.identity = Some(reply.identity); self.on_columns_loaded(reply.columns.to_vec(), fx); for kind in std::mem::take(&mut self.after_columns) { diff --git a/linux/crates/app/src/ui/browse_tab/data_handlers.rs b/linux/crates/app/src/ui/browse_tab/data_handlers.rs index ce57963fd1..af399df58f 100644 --- a/linux/crates/app/src/ui/browse_tab/data_handlers.rs +++ b/linux/crates/app/src/ui/browse_tab/data_handlers.rs @@ -141,8 +141,14 @@ impl BrowseModel { /// are dropped, so nothing past the visible columns reaches a copy /// or an export. The tab's drafts stay on top. pub(super) fn on_rows_loaded(&mut self, offset: u64, reply: PageReply, fx: &mut Effects) { - self.pager.go_to(offset); - let PageReply { result, layout, .. } = reply; + let PageReply { + position, + result, + layout, + edges, + seeks, + } = reply; + self.pager.land(offset, position, edges, seeks); let QueryResult { columns, rows, .. } = result; // The tracker saves a row by its primary key, so that is what // names it here too. diff --git a/linux/crates/app/src/ui/browse_tab/fetch.rs b/linux/crates/app/src/ui/browse_tab/fetch.rs index 84a5093a64..a3a87998ba 100644 --- a/linux/crates/app/src/ui/browse_tab/fetch.rs +++ b/linux/crates/app/src/ui/browse_tab/fetch.rs @@ -1,10 +1,10 @@ use std::future::Future; use std::sync::Arc; -use tablepro_core::browse::{BrowseSqlError, PageLayout, PageRequest, SortKey, build_page}; +use tablepro_core::browse::{PageRequest, SortKey, read_page}; use tablepro_core::meta::{RowIdentity, TableRef}; use tablepro_core::statement::StatementBuilder; -use tablepro_core::{BuildFilterError, ColumnInfo, FilterSet, QueryResult, RowLimit}; +use tablepro_core::{BuildFilterError, ColumnInfo, FilterSet}; use tablepro_session::{OperationError, OperationKind}; use super::columns_fetch::{ColumnFreshness, load_columns}; @@ -115,36 +115,25 @@ impl BrowseModel { let cancel = ticket.cancel.clone(); let position = self.pager.position(); let work = async move { - let reply_position = position.clone(); session .run_cancellable(OperationKind::Page, &cancel, move |lease, options| { let plan = plan.clone(); let position = position.clone(); async move { - let spec = build_page( - &*lease.dialect(), - &PageRequest { - table: &plan.table, - columns: &plan.columns, - filter: &plan.filter, - sort: &plan.sort, - identity: &plan.identity, - position: &position, - limit: plan.limit, - }, - )?; - let limit = RowLimit::new(plan.limit).unwrap_or(RowLimit::EDITOR_DEFAULT); - let result = lease.query_bound(&spec.statement, limit, options).await?; - check_shape(&result, &spec.layout)?; - Ok((result, spec.layout)) + let request = PageRequest { + table: &plan.table, + columns: &plan.columns, + filter: &plan.filter, + sort: &plan.sort, + identity: &plan.identity, + position: &position, + limit: plan.limit, + }; + read_page::(&*lease, &request, &options).await } }) .await - .map(|(result, layout)| PageReply { - position: reply_position, - result, - layout, - }) + .map(PageReply::from) }; self.deliver(ticket, work, BrowseCommand::Page, OperationError::from); } @@ -184,7 +173,7 @@ impl BrowseModel { .map(|(column, ascending)| SortKey { column, ascending }) .into_iter() .collect(), - limit: self.pager.page_size(), + limit: self.pager.limit(), }) } @@ -231,16 +220,3 @@ impl BrowseModel { pub(super) fn filter_failure(problem: BuildFilterError) -> LoadFailure { LoadFailure::Page(Arc::new(OperationError::Browse(problem.into()))) } - -/// Every row has the columns the page asked for, visible then identity, -/// so the split on the GTK thread takes each row apart at the same place. -fn check_shape(result: &QueryResult, layout: &PageLayout) -> Result<(), BrowseSqlError> { - let expected = layout.width(); - let found = std::iter::once(result.columns.len()) - .chain(result.rows.iter().map(Vec::len)) - .find(|width| *width != expected); - match found { - Some(found) => Err(BrowseSqlError::ShapeMismatch { expected, found }), - None => Ok(()), - } -} diff --git a/linux/crates/app/src/ui/browse_tab/page_reply.rs b/linux/crates/app/src/ui/browse_tab/page_reply.rs index 0630cd83d4..37d35c5a26 100644 --- a/linux/crates/app/src/ui/browse_tab/page_reply.rs +++ b/linux/crates/app/src/ui/browse_tab/page_reply.rs @@ -1,7 +1,9 @@ use tablepro_core::QueryResult; -use tablepro_core::browse::PageLayout; +use tablepro_core::browse::{PageLayout, PageRead, PagingMode}; use tablepro_core::meta::PagePosition; +use super::browse_pager::PageEdges; + /// A page as the server returned it, the row identity cells still after /// the visible ones. #[derive(Debug, Clone)] @@ -9,15 +11,36 @@ pub(crate) struct PageReply { pub position: PagePosition, pub result: QueryResult, pub layout: PageLayout, + pub edges: PageEdges, + /// The table pages by key or heap address rather than by offset. + pub seeks: bool, } impl PageReply { - /// `None` when the page starts at a key or a ctid window. + /// `None` when the page starts at a key or at the table's end. pub fn offset(&self) -> Option { match self.position { PagePosition::First => Some(0), PagePosition::Offset(offset) => Some(offset), - PagePosition::AfterKey(_) | PagePosition::BeforeKey(_) | PagePosition::CtidWindow { .. } => None, + PagePosition::Last + | PagePosition::AfterKey(_) + | PagePosition::BeforeKey(_) + | PagePosition::CtidWindow(_) => None, + } + } +} + +impl From for PageReply { + fn from(read: PageRead) -> Self { + Self { + position: read.position, + result: read.result, + layout: read.layout, + edges: PageEdges { + previous: read.previous, + next: read.next, + }, + seeks: read.mode != PagingMode::Offset, } } } diff --git a/linux/crates/app/src/ui/browse_tab/paging_handlers.rs b/linux/crates/app/src/ui/browse_tab/paging_handlers.rs index 9ad4c22b66..d20d1b380e 100644 --- a/linux/crates/app/src/ui/browse_tab/paging_handlers.rs +++ b/linux/crates/app/src/ui/browse_tab/paging_handlers.rs @@ -1,7 +1,7 @@ use gtk4::prelude::*; use tablepro_core::FilterSet; -use tablepro_core::meta::PagePosition; +use super::browse_pager::PageTarget; use super::columns_fetch::ColumnFreshness; use super::fetch::filter_failure; use super::{BrowseModel, BrowseRequestKind, BrowseTabOutput, Effects, PagingInput}; @@ -51,7 +51,7 @@ impl BrowseModel { .set_filters(conn_id, self.schema.as_deref(), &self.table, set.clone()); } // Filtered counts shift, so back to page 1. - self.pager.go_to(0); + self.pager.restart(); self.refresh_filter_chrome(); self.filter_strip.set_filter(&set); self.stop_count(false); @@ -67,7 +67,7 @@ impl BrowseModel { return; } self.current_sort = Some(next); - self.pager.go_to(0); + self.pager.restart(); self.capture_selection(); self.fetch(BrowseRequestKind::Page, fx); fx.output(BrowseTabOutput::StateChanged); @@ -84,11 +84,11 @@ impl BrowseModel { } /// Read the page a paginator move landed on, if it moved. - fn turn_page(&mut self, target: Option<(PagePosition, u64)>, fx: &mut Effects) { - let Some((_, start)) = target else { + fn turn_page(&mut self, target: Option, fx: &mut Effects) { + let Some(target) = target else { return; }; - self.pager.go_to(start); + self.pager.move_to(target); self.fetch(BrowseRequestKind::Page, fx); fx.output(BrowseTabOutput::StateChanged); } diff --git a/linux/crates/app/src/ui/browse_tab/row_count.rs b/linux/crates/app/src/ui/browse_tab/row_count.rs index d2585af83a..acc22eb584 100644 --- a/linux/crates/app/src/ui/browse_tab/row_count.rs +++ b/linux/crates/app/src/ui/browse_tab/row_count.rs @@ -132,10 +132,10 @@ impl BrowseModel { /// one moves a page left past the end back to the last page. Says /// whether it moved, for the caller to read that page. pub(super) fn clamp_to_total(&mut self, fx: &mut Effects) -> bool { - let Some((_, start)) = self.total.exact().and_then(|rows| self.pager.clamp(rows)) else { + let Some(target) = self.total.exact().and_then(|rows| self.pager.clamp(rows)) else { return false; }; - self.pager.go_to(start); + self.pager.move_to(target); fx.output(BrowseTabOutput::StateChanged); true } diff --git a/linux/crates/app/src/ui/browse_tab/tests.rs b/linux/crates/app/src/ui/browse_tab/tests.rs index 456142ca38..aa90c9c8d3 100644 --- a/linux/crates/app/src/ui/browse_tab/tests.rs +++ b/linux/crates/app/src/ui/browse_tab/tests.rs @@ -3007,3 +3007,74 @@ fn filter_button_tooltip_counts_the_active_rules() { wait_until(WAIT, || button.tooltip_text().as_deref() == Some("Filter Rows")) .expect("the tooltip still counted rules after they were cleared"); } + +/// The page queries a tab sent, in order. +fn page_queries(harness: &Harness) -> Vec { + harness + .connection + .observed_sql("query_bound") + .into_iter() + .flatten() + .collect() +} + +/// A table with a key pages past the edge row of the page on screen, +/// so a page deep into it costs what the first does. The row numbers +/// still count from the first page. +#[gtk4::test] +fn a_keyed_table_pages_by_the_edge_rows_key_not_by_offset() { + let mut script = serving_table(); + script.count_rows.always(FakeReply::ok(1_000)); + script.query_bound.always(FakeReply::ok(full_page(0))); + let harness = Harness::open(script, []); + wait_until(WAIT, || harness.shown().len() == 100).expect("the first page"); + let read = |count: usize| { + wait_until(WAIT, || { + page_queries(&harness).len() == count && !harness.tab().is_loading() + }) + .expect("the page read"); + }; + + harness.tab().send(PagingInput::NextPage); + read(2); + harness.tab().send(PagingInput::NextPage); + read(3); + harness.tab().send(PagingInput::PrevPage); + read(4); + + let queries = page_queries(&harness); + for query in &queries[1..] { + assert!(!query.contains("OFFSET"), "{query}"); + } + assert!(queries[1].contains(" > ("), "{}", queries[1]); + assert!( + queries[3].contains(" < (") && queries[3].contains(" DESC"), + "Previous reads back from the page's first row: {}", + queries[3] + ); + assert_eq!(harness.tab().current_offset(), 100); +} + +/// A sort of the user's has ties a key cannot resume from, so it pages +/// by offset. +#[gtk4::test] +fn a_sorted_table_pages_by_offset() { + let mut script = serving_table(); + script.query_bound.always(FakeReply::ok(full_page(0))); + let harness = Harness::open(script, []); + wait_until(WAIT, || harness.shown().len() == 100).expect("the first page"); + harness.tab().send(PagingInput::SortChanged { + col_idx: 1, + ascending: true, + }); + wait_until(WAIT, || { + page_queries(&harness).len() == 2 && !harness.tab().is_loading() + }) + .expect("the sorted page"); + + harness.tab().send(PagingInput::NextPage); + wait_until(WAIT, || page_queries(&harness).len() == 3).expect("the next page"); + + let next = page_queries(&harness).pop().unwrap_or_default(); + assert!(next.ends_with("LIMIT 100 OFFSET 100"), "{next}"); +} diff --git a/linux/crates/app/src/ui/browse_tab/total_tests.rs b/linux/crates/app/src/ui/browse_tab/total_tests.rs index 9a7a9cae20..5a8b7a8e45 100644 --- a/linux/crates/app/src/ui/browse_tab/total_tests.rs +++ b/linux/crates/app/src/ui/browse_tab/total_tests.rs @@ -259,6 +259,10 @@ fn closing_tab_cancels_running_count() { #[gtk4::test] fn last_page_waits_for_an_exact_total() { let mut script = paging(full_page(0), rows_from(900, &["Linus"])); + // The last page is read back from the table's end, so the count + // agrees with the one row it holds. + script.count_rows = Default::default(); + script.count_rows.always(FakeReply::ok(901)); script.table_estimate = Default::default(); script .table_estimate diff --git a/linux/crates/core/src/browse/browse_sql_error.rs b/linux/crates/core/src/browse/browse_sql_error.rs index cd27712a0c..96de0c9449 100644 --- a/linux/crates/core/src/browse/browse_sql_error.rs +++ b/linux/crates/core/src/browse/browse_sql_error.rs @@ -17,6 +17,8 @@ pub enum BrowseSqlError { UnsupportedPosition { mode: PagingMode }, #[error("the row to page from has a NULL key, which no row compares past")] NullAnchor, + #[error("the row to page from has no row address that can be read")] + UnreadableAnchor, #[error("the page returned {found} columns but the query asked for {expected}")] ShapeMismatch { expected: usize, found: usize }, #[error(transparent)] diff --git a/linux/crates/core/src/browse/build_export.rs b/linux/crates/core/src/browse/build_export.rs index a50093de2b..ff1ceca59b 100644 --- a/linux/crates/core/src/browse/build_export.rs +++ b/linux/crates/core/src/browse/build_export.rs @@ -1,5 +1,6 @@ use crate::dialect::SqlDialect; use crate::filter::build_filter; +use crate::meta::RowIdentity; use crate::statement::{Statement, StatementBuilder}; use super::select_parts::{ @@ -11,13 +12,20 @@ use super::{BrowseSqlError, ExportRequest}; /// /// The same projections and filter as a page, so an export holds what /// the grid showed. No engine row id is selected: it is never data the -/// user asked for. The identity still orders the rows, so two exports -/// of an unchanged table come out the same. +/// user asked for. A key still orders the rows, so two exports of an +/// unchanged table come out the same. A heap address orders them only +/// behind the user's sort: on its own it would sort the whole table +/// before the first row could be written. pub fn build_export(dialect: &dyn SqlDialect, request: &ExportRequest<'_>) -> Result { let projection = projection_list(dialect, request.columns)?; let components = identity_components(request.identity, request.columns)?; let user = user_order(dialect, request.table, request.columns, request.sort)?; - let order = with_tiebreak(user, &identity_order(dialect, request.table, &components)?); + let heap_order_only = + user.is_empty() && matches!(request.identity, RowIdentity::EngineRowId(id) if id.is_heap_address()); + let order = match heap_order_only { + true => Vec::new(), + false => with_tiebreak(user, &identity_order(dialect, request.table, &components)?), + }; let mut builder = StatementBuilder::new(); let mut conditions = Vec::new(); diff --git a/linux/crates/core/src/browse/build_page.rs b/linux/crates/core/src/browse/build_page.rs index 99f1e16aa7..9184bf6131 100644 --- a/linux/crates/core/src/browse/build_page.rs +++ b/linux/crates/core/src/browse/build_page.rs @@ -2,7 +2,7 @@ use crate::column::ColumnInfo; use crate::dialect::{BindTarget, KeysetDirection, SqlDialect}; use crate::dml::{BuildSqlError, KeyComponent}; use crate::filter::build_filter; -use crate::meta::{EngineRowIdPart, PageCost, PagePosition, PageStability, RowIdentity}; +use crate::meta::{CtidWindow, EngineRowIdPart, PageCost, PagePosition, PageStability, RowIdentity}; use crate::statement::StatementBuilder; use crate::value::Value; @@ -120,6 +120,7 @@ fn keyset_walk( }; let (anchor, keyset_direction) = match request.position { PagePosition::First => (None, KeysetDirection::After), + PagePosition::Last => (None, KeysetDirection::Before), PagePosition::Offset(offset) => { return Ok(Walk { condition: None, @@ -131,7 +132,7 @@ fn keyset_walk( } PagePosition::AfterKey(key) => (Some(key), KeysetDirection::After), PagePosition::BeforeKey(key) => (Some(key), KeysetDirection::Before), - PagePosition::CtidWindow { .. } => { + PagePosition::CtidWindow(_) => { return Err(BrowseSqlError::UnsupportedPosition { mode: PagingMode::Keyset, }); @@ -184,8 +185,8 @@ fn bind_anchor( Ok(markers) } -/// A range of heap blocks in ctid order. The bounds are bound as text -/// the dialect casts back to a row address. +/// A range of heap addresses in ctid order. The bounds are bound as +/// text the dialect casts back to a row address. fn ctid_walk( builder: &mut StatementBuilder, dialect: &dyn SqlDialect, @@ -194,19 +195,24 @@ fn ctid_walk( let ctid = dialect .engine_row_id_column(EngineRowIdPart::PostgresCtid) .ok_or(BuildSqlError::NoRowIdentity)?; - let order = vec![format!("{ctid} {}", direction(true))]; - let (after, end_block) = match position { - PagePosition::First => (None, None), + let whole_table = |backward| CtidWindow { + after: None, + before: None, + backward, + }; + let window = match position { + PagePosition::First => whole_table(false), + PagePosition::Last => whole_table(true), PagePosition::Offset(offset) => { return Ok(Walk { condition: None, - order, + order: vec![format!("{ctid} {}", direction(true))], offset: *offset, stability: PageStability::Stable, cost: PageCost::SortPerPage, }); } - PagePosition::CtidWindow { after, end_block } => (*after, Some(*end_block)), + PagePosition::CtidWindow(window) => *window, PagePosition::AfterKey(_) | PagePosition::BeforeKey(_) => { return Err(BrowseSqlError::UnsupportedPosition { mode: PagingMode::CtidWindow, @@ -216,24 +222,26 @@ fn ctid_walk( let target = BindTarget::EngineRowId(EngineRowIdPart::PostgresCtid); let mut bounds = Vec::new(); - if let Some((block, offset)) = after { - let marker = builder.bind(dialect, Value::Text(format!("({block},{offset})")), target)?; - bounds.push(format!("{ctid} > {marker}")); - } - // A block number past what a tid can hold means the window runs to - // the end of the table. - if let Some(end_block) = end_block.and_then(|end| u32::try_from(end).ok()) { - let marker = builder.bind(dialect, Value::Text(format!("({end_block},0)")), target)?; - bounds.push(format!("{ctid} < {marker}")); + for (bound, comparison) in [(window.after, ">"), (window.before, "<")] { + if let Some((block, offset)) = bound { + let marker = builder.bind(dialect, Value::Text(format!("({block},{offset})")), target)?; + bounds.push(format!("{ctid} {comparison} {marker}")); + } } + // Only the bound the read walks toward keeps the server to a range + // of blocks; without it the sort takes in the rest of the table. + let bounded = match window.backward { + true => window.after.is_some(), + false => window.before.is_some(), + }; Ok(Walk { condition: (!bounds.is_empty()).then(|| bounds.join(" AND ")), - order, + order: vec![format!("{ctid} {}", direction(!window.backward))], offset: 0, stability: PageStability::Stable, - cost: match end_block { - Some(_) => PageCost::BlockWindow, - None => PageCost::SortPerPage, + cost: match bounded { + true => PageCost::BlockWindow, + false => PageCost::SortPerPage, }, }) } @@ -242,7 +250,7 @@ fn offset_walk(position: &PagePosition, identity: &RowIdentity, order: Vec 0, PagePosition::Offset(offset) => *offset, - PagePosition::AfterKey(_) | PagePosition::BeforeKey(_) | PagePosition::CtidWindow { .. } => { + PagePosition::Last | PagePosition::AfterKey(_) | PagePosition::BeforeKey(_) | PagePosition::CtidWindow(_) => { return Err(BrowseSqlError::UnsupportedPosition { mode: PagingMode::Offset, }); diff --git a/linux/crates/core/src/browse/mod.rs b/linux/crates/core/src/browse/mod.rs index ee1a959b38..f0d84166cf 100644 --- a/linux/crates/core/src/browse/mod.rs +++ b/linux/crates/core/src/browse/mod.rs @@ -11,6 +11,7 @@ mod page_layout; mod page_request; mod page_spec; mod paging_mode; +mod read_page; mod row_key_layout; mod select_parts; mod sort_key; @@ -28,6 +29,7 @@ pub use page_layout::PageLayout; pub use page_request::PageRequest; pub use page_spec::PageSpec; pub use paging_mode::{PagingMode, paging_mode}; +pub use read_page::{PageRead, read_page}; pub use row_key_layout::RowKeyLayout; pub use sort_key::SortKey; pub use split_page::split_page; diff --git a/linux/crates/core/src/browse/read_page.rs b/linux/crates/core/src/browse/read_page.rs new file mode 100644 index 0000000000..ab3e652219 --- /dev/null +++ b/linux/crates/core/src/browse/read_page.rs @@ -0,0 +1,416 @@ +use crate::call_options::CallOptions; +use crate::connection::Connection; +use crate::error::DriverError; +use crate::meta::{CtidWindow, EngineRowIdPart, HeapAddress, PagePosition, RowIdentity, TableEstimate}; +use crate::query_result::QueryResult; +use crate::row_limit::RowLimit; +use crate::value::Value; + +use super::split_page::key_of; +use super::{BrowseSqlError, PageLayout, PageRequest, PageSpec, PagingMode, RowKeyLayout, build_page, paging_mode}; + +/// Rows per heap block assumed for a table the server never analysed. +/// Low on purpose: a window too wide costs a little sorting, one too +/// narrow costs another round trip. +const ASSUMED_ROWS_PER_BLOCK: u64 = 16; + +/// A page as the server returned it, its rows in the table's order. +#[derive(Debug, Clone)] +pub struct PageRead { + /// The visible columns, then the row identity's. + pub result: QueryResult, + pub layout: PageLayout, + /// What the rows are. A read backward that reached the start of the + /// table before it filled the page is the first page. + pub position: PagePosition, + pub mode: PagingMode, + /// The page before this one by its first row's key, and the page + /// after it by its last. `None` where only an offset reaches it: the + /// table pages by offset, or the edge row's key could not be read. + pub previous: Option, + pub next: Option, +} + +/// Read the page `request` names, by the mode `paging_mode` picks. +/// +/// A page of a keyless PostgreSQL table is read through block windows, +/// so no read sorts more of the table than a page's worth of blocks. +pub async fn read_page( + connection: &dyn Connection, + request: &PageRequest<'_>, + options: &CallOptions, +) -> Result +where + E: From + From, +{ + let mode = paging_mode(request.identity, request.sort); + let (mut result, mut spec) = read_at::(connection, request, request.position, options).await?; + let mut position = request.position.clone(); + let reached_start = reads_backward(request.position) && (result.rows.len() as u64) < request.limit; + if reached_start { + position = PagePosition::First; + // Rows past the anchor fill the rest of the first page. + if matches!(request.position, PagePosition::BeforeKey(_)) { + (result, spec) = read_at::(connection, request, &PagePosition::First, options).await?; + } + } + let (previous, next) = match mode { + PagingMode::Offset => (None, None), + PagingMode::Keyset | PagingMode::CtidWindow => ( + edge_key(result.rows.first(), &spec.row_key).map(PagePosition::BeforeKey), + edge_key(result.rows.last(), &spec.row_key).map(PagePosition::AfterKey), + ), + }; + Ok(PageRead { + result, + layout: spec.layout, + position, + mode, + previous, + next, + }) +} + +fn reads_backward(position: &PagePosition) -> bool { + match position { + PagePosition::Last | PagePosition::BeforeKey(_) => true, + PagePosition::CtidWindow(window) => window.backward, + PagePosition::First | PagePosition::Offset(_) | PagePosition::AfterKey(_) => false, + } +} + +/// A key a later page can be read past: whole, readable and not null. +fn edge_key(row: Option<&Vec>, layout: &RowKeyLayout) -> Option> { + key_of(row?, layout).filter(|key| key.iter().all(|value| !value.is_null())) +} + +async fn read_at( + connection: &dyn Connection, + request: &PageRequest<'_>, + position: &PagePosition, + options: &CallOptions, +) -> Result<(QueryResult, PageSpec), E> +where + E: From + From, +{ + let start = match paging_mode(request.identity, request.sort) { + PagingMode::CtidWindow => ctid_start(position, request.identity)?, + PagingMode::Keyset | PagingMode::Offset => None, + }; + let (mut result, spec) = match start { + Some(start) => walk_heap::(connection, request, start, options).await?, + None => query::(connection, request, position, request.limit, options).await?, + }; + if reads_backward(position) { + result.rows.reverse(); + } + Ok((result, spec)) +} + +/// Where a walk of the heap starts, for a position a single window +/// cannot answer. +fn ctid_start(position: &PagePosition, identity: &RowIdentity) -> Result, BrowseSqlError> { + Ok(match position { + PagePosition::First => Some(CtidStart::Forward(None)), + PagePosition::Last => Some(CtidStart::Backward(None)), + PagePosition::AfterKey(key) => Some(CtidStart::Forward(Some(heap_address(key, identity)?))), + PagePosition::BeforeKey(key) => Some(CtidStart::Backward(Some(heap_address(key, identity)?))), + PagePosition::Offset(_) | PagePosition::CtidWindow(_) => None, + }) +} + +/// The ctid in a row's key, which a page projects as text: `(block,line)`. +fn heap_address(key: &[Value], identity: &RowIdentity) -> Result { + let RowIdentity::EngineRowId(id) = identity else { + return Err(BrowseSqlError::UnreadableAnchor); + }; + let text = id + .parts() + .iter() + .position(|part| *part == EngineRowIdPart::PostgresCtid) + .and_then(|index| key.get(index)); + match text { + Some(Value::Text(text)) => parse_heap_address(text).ok_or(BrowseSqlError::UnreadableAnchor), + _ => Err(BrowseSqlError::UnreadableAnchor), + } +} + +fn parse_heap_address(text: &str) -> Option { + let (block, line) = text.strip_prefix('(')?.strip_suffix(')')?.split_once(',')?; + Some((block.trim().parse().ok()?, line.trim().parse().ok()?)) +} + +async fn query( + connection: &dyn Connection, + request: &PageRequest<'_>, + position: &PagePosition, + limit: u64, + options: &CallOptions, +) -> Result<(QueryResult, PageSpec), E> +where + E: From + From, +{ + let dialect = connection.dialect(); + let spec = build_page( + &*dialect, + &PageRequest { + position, + limit, + ..*request + }, + )?; + let row_limit = RowLimit::new(limit).unwrap_or(RowLimit::EDITOR_DEFAULT); + let result = connection + .query_bound(&spec.statement, row_limit, options.clone()) + .await?; + check_shape(&result, &spec.layout)?; + Ok((result, spec)) +} + +/// Fill a page from block windows, each read in ctid order, until it +/// is full or the walk passes the end of the table. Rows come back in +/// the order the walk read them. +async fn walk_heap( + connection: &dyn Connection, + request: &PageRequest<'_>, + start: CtidStart, + options: &CallOptions, +) -> Result<(QueryResult, PageSpec), E> +where + E: From + From, +{ + let estimate = connection.table_estimate(request.table, options.clone()).await?; + let mut walk = CtidWalk::new(start, &estimate, request.limit); + let mut rows = Vec::new(); + loop { + let wanted = request.limit.saturating_sub(rows.len() as u64).max(1); + let position = PagePosition::CtidWindow(walk.window()); + let (result, spec) = query::(connection, request, &position, wanted, options).await?; + let filled = result.rows.len() as u64 >= wanted; + rows.extend(result.rows); + if filled || !walk.advance() { + return Ok((QueryResult::new(result.columns, rows), spec)); + } + } +} + +/// Every row has the columns the page asked for, visible then identity, +/// so the split on the GTK thread takes each row apart at the same place. +fn check_shape(result: &QueryResult, layout: &PageLayout) -> Result<(), BrowseSqlError> { + let expected = layout.width(); + let found = std::iter::once(result.columns.len()) + .chain(result.rows.iter().map(Vec::len)) + .find(|width| *width != expected); + match found { + Some(found) => Err(BrowseSqlError::ShapeMismatch { expected, found }), + None => Ok(()), + } +} + +/// Which way a walk of the heap reads, and the row it reads past. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum CtidStart { + Forward(Option), + Backward(Option), +} + +/// The block windows a page of a keyless PostgreSQL table is read +/// through. A window that comes back short is followed by one twice as +/// wide, so a sparse table or a narrow filter still takes few reads. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct CtidWalk { + backward: bool, + /// The row the next window reads past, or the start of a block. + edge: Option, + /// The next window's first block, or going backward the block just + /// past its last. + block: u64, + span: u64, + heap_blocks: u64, +} + +impl CtidWalk { + pub(super) fn new(start: CtidStart, estimate: &TableEstimate, limit: u64) -> Self { + let heap_blocks = estimate.heap_blocks.unwrap_or(0); + let rows_per_block = match estimate.rows.value() { + Some(rows) if heap_blocks > 0 => (rows / heap_blocks).max(1), + _ => ASSUMED_ROWS_PER_BLOCK, + }; + let span = limit.div_ceil(rows_per_block).max(1); + let (backward, edge, block) = match start { + CtidStart::Forward(edge) => (false, edge, edge.map_or(0, |(block, _)| u64::from(block))), + CtidStart::Backward(edge) => (true, edge, edge.map_or(heap_blocks, |(block, _)| u64::from(block) + 1)), + }; + Self { + backward, + edge, + block, + span, + heap_blocks, + } + } + + pub(super) fn window(&self) -> CtidWindow { + match self.backward { + false => CtidWindow { + after: self.edge, + before: self.forward_end().map(block_start), + backward: false, + }, + true => CtidWindow { + after: self.backward_start().map(block_start), + before: self.edge, + backward: true, + }, + } + } + + /// Move on past a window that came back short. False when that + /// window already ran to the end of the table. + pub(super) fn advance(&mut self) -> bool { + let next = match self.backward { + false => self.forward_end(), + true => self.backward_start(), + }; + let Some(block) = next else { + return false; + }; + self.edge = Some(block_start(block)); + self.block = u64::from(block); + self.span = self.span.saturating_mul(2); + true + } + + /// The block a forward window stops before. `None` lets the last + /// window run on past the size read at the start, for rows added + /// since. + fn forward_end(&self) -> Option { + let end = self.block.saturating_add(self.span); + (end < self.heap_blocks).then(|| u32::try_from(end).ok()).flatten() + } + + /// The first block of a backward window, `None` once it reaches the + /// start of the table. + fn backward_start(&self) -> Option { + let start = self.block.saturating_sub(self.span); + (start > 0).then(|| u32::try_from(start).ok()).flatten() + } +} + +fn block_start(block: u32) -> HeapAddress { + (block, 0) +} + +#[cfg(test)] +mod tests { + use crate::meta::RowCountEstimate; + + use super::*; + + fn estimate(rows: u64, heap_blocks: u64) -> TableEstimate { + TableEstimate { + rows: RowCountEstimate::Approximate(rows), + heap_blocks: Some(heap_blocks), + } + } + + fn windows(mut walk: CtidWalk) -> Vec { + let mut windows = vec![walk.window()]; + while walk.advance() { + windows.push(walk.window()); + } + windows + } + + fn forward(after: Option, before: Option) -> CtidWindow { + CtidWindow { + after, + before, + backward: false, + } + } + + fn backward(after: Option, before: Option) -> CtidWindow { + CtidWindow { + after, + before, + backward: true, + } + } + + #[test] + fn a_window_holds_about_a_page_by_the_tables_own_density() { + // 100 rows a block, so a page of 1,000 needs 10 blocks. + let walk = CtidWalk::new(CtidStart::Forward(None), &estimate(100_000, 1_000), 1_000); + + assert_eq!(walk.window(), forward(None, Some((10, 0)))); + } + + #[test] + fn a_short_window_is_followed_by_one_twice_as_wide_up_to_the_end() { + let walk = CtidWalk::new(CtidStart::Forward(Some((5, 3))), &estimate(10_000, 100), 1_000); + + assert_eq!( + windows(walk), + [ + forward(Some((5, 3)), Some((15, 0))), + forward(Some((15, 0)), Some((35, 0))), + forward(Some((35, 0)), Some((75, 0))), + forward(Some((75, 0)), None), + ], + "the last window must run past the size read, for rows added since" + ); + } + + #[test] + fn a_backward_walk_ends_at_the_first_block() { + let walk = CtidWalk::new(CtidStart::Backward(Some((40, 2))), &estimate(10_000, 100), 1_000); + + assert_eq!( + windows(walk), + [ + backward(Some((31, 0)), Some((40, 2))), + backward(Some((11, 0)), Some((31, 0))), + backward(None, Some((11, 0))), + ] + ); + } + + #[test] + fn the_last_page_starts_from_the_tables_end_with_no_upper_bound() { + let walk = CtidWalk::new(CtidStart::Backward(None), &estimate(10_000, 100), 1_000); + + assert_eq!(walk.window(), backward(Some((90, 0)), None)); + } + + #[test] + fn a_table_never_analysed_or_empty_still_walks() { + let unknown = TableEstimate { + rows: RowCountEstimate::Unknown, + heap_blocks: Some(10_000), + }; + let walk = CtidWalk::new(CtidStart::Forward(None), &unknown, 1_000); + assert_eq!(walk.window(), forward(None, Some((63, 0)))); + + let empty = CtidWalk::new(CtidStart::Forward(None), &estimate(0, 0), 1_000); + assert_eq!(windows(empty), [forward(None, None)]); + } + + #[test] + fn a_heap_address_is_read_from_the_ctid_part_of_the_key() { + let ctid = RowIdentity::EngineRowId(crate::meta::EngineRowId::PostgresCtid); + let partitioned = RowIdentity::EngineRowId(crate::meta::EngineRowId::PostgresTableoidCtid); + let key = |parts: &[&str]| -> Vec { parts.iter().map(|part| Value::Text((*part).to_owned())).collect() }; + + assert_eq!(heap_address(&key(&["(4294967295,7)", "812"]), &ctid), Ok((u32::MAX, 7))); + assert_eq!(heap_address(&key(&["16384", "(0,1)", "812"]), &partitioned), Ok((0, 1))); + assert_eq!( + heap_address(&key(&["(4,x)", "812"]), &ctid), + Err(BrowseSqlError::UnreadableAnchor) + ); + assert_eq!( + heap_address(&[Value::Int(4)], &ctid), + Err(BrowseSqlError::UnreadableAnchor) + ); + } +} diff --git a/linux/crates/core/src/browse/split_page.rs b/linux/crates/core/src/browse/split_page.rs index b3149ad377..83fa9d7489 100644 --- a/linux/crates/core/src/browse/split_page.rs +++ b/linux/crates/core/src/browse/split_page.rs @@ -45,7 +45,7 @@ pub fn split_page(result: QueryResult, spec: &PageSpec) -> Result Option> { +pub(super) fn key_of(row: &[Value], layout: &RowKeyLayout) -> Option> { match layout { RowKeyLayout::ReadOnly => None, RowKeyLayout::Columns(positions) => positions diff --git a/linux/crates/core/src/browse/tests.rs b/linux/crates/core/src/browse/tests.rs index 0c93bb1eed..44b5bf3604 100644 --- a/linux/crates/core/src/browse/tests.rs +++ b/linux/crates/core/src/browse/tests.rs @@ -3,7 +3,9 @@ use crate::column::{ }; use crate::dialect::{RecordedTarget, TestDialect, TestMarkers}; use crate::filter::{Combinator, FilterOp, FilterRule, FilterSet, FilterValue}; -use crate::meta::{EngineRowId, EngineRowIdPart, PageCost, PagePosition, PageStability, RowIdentity, TableRef}; +use crate::meta::{ + CtidWindow, EngineRowId, EngineRowIdPart, PageCost, PagePosition, PageStability, RowIdentity, TableRef, +}; use crate::query_result::QueryResult; use crate::value::Value; @@ -212,10 +214,11 @@ fn a_sort_on_the_key_itself_is_not_repeated_as_its_tiebreak() { #[test] fn build_page_ctid_window_bounds() { let dialect = TestDialect::exact(); - let page = Page::of(RowIdentity::EngineRowId(EngineRowId::PostgresCtid)).at(PagePosition::CtidWindow { + let page = Page::of(RowIdentity::EngineRowId(EngineRowId::PostgresCtid)).at(PagePosition::CtidWindow(CtidWindow { after: Some((3, 7)), - end_block: 10, - }); + before: Some((10, 0)), + backward: false, + })); let spec = built(&page, &dialect); @@ -245,10 +248,11 @@ fn build_page_ctid_window_bounds() { #[test] fn the_first_ctid_window_has_only_its_upper_bound() { - let page = Page::of(RowIdentity::EngineRowId(EngineRowId::PostgresCtid)).at(PagePosition::CtidWindow { + let page = Page::of(RowIdentity::EngineRowId(EngineRowId::PostgresCtid)).at(PagePosition::CtidWindow(CtidWindow { after: None, - end_block: 4, - }); + before: Some((4, 0)), + backward: false, + })); let spec = built(&page, &TestDialect::exact()); @@ -260,6 +264,49 @@ fn the_first_ctid_window_has_only_its_upper_bound() { assert_eq!(spec.statement.params().len(), 1); } +#[test] +fn a_backward_ctid_window_reads_down_from_its_upper_bound() { + let page = Page::of(RowIdentity::EngineRowId(EngineRowId::PostgresCtid)).at(PagePosition::CtidWindow(CtidWindow { + after: Some((31, 0)), + before: Some((40, 2)), + backward: true, + })); + + let spec = built(&page, &TestDialect::exact()); + + assert!( + spec.statement + .sql() + .ends_with("WHERE ctid > $1 AND ctid < $2 ORDER BY ctid DESC LIMIT 50"), + "{}", + spec.statement.sql() + ); + assert_eq!(spec.cost, PageCost::BlockWindow); +} + +#[test] +fn a_ctid_window_open_on_the_side_it_reads_toward_sorts_the_rest_of_the_table() { + let open_end = + Page::of(RowIdentity::EngineRowId(EngineRowId::PostgresCtid)).at(PagePosition::CtidWindow(CtidWindow { + after: Some((75, 0)), + before: None, + backward: false, + })); + + assert_eq!(built(&open_end, &TestDialect::exact()).cost, PageCost::SortPerPage); +} + +#[test] +fn the_last_keyset_page_reads_the_key_backward_from_the_end() { + let spec = built(&Page::of(by_id()).at(PagePosition::Last), &TestDialect::exact()); + + assert_eq!( + spec.statement.sql(), + r#"SELECT "id", "name" FROM "public"."users" ORDER BY "users"."id" DESC LIMIT 50"# + ); + assert_eq!(spec.cost, PageCost::KeySeek); +} + #[test] fn build_count_reuses_filter_predicate() { let dialect = TestDialect::exact(); @@ -384,6 +431,19 @@ fn a_position_the_mode_cannot_read_is_an_error() { mode: PagingMode::Offset }) ); + let sorted_last = Page::of(by_id()) + .sorted(vec![SortKey { + column: 1, + ascending: true, + }]) + .at(PagePosition::Last); + assert_eq!( + sorted_last.build(&TestDialect::exact()), + Err(BrowseSqlError::UnsupportedPosition { + mode: PagingMode::Offset + }), + "a user sort with ties has no exact reverse" + ); } #[test] @@ -463,6 +523,42 @@ fn build_export_projects_only_visible_columns() { ); } +fn export_sql(identity: RowIdentity, sort: &[SortKey]) -> String { + let columns = columns(); + let statement = build_export( + &TestDialect::exact(), + &ExportRequest { + table: &table(), + columns: &columns, + filter: &FilterSet::default(), + sort, + identity: &identity, + }, + ) + .expect("the export"); + statement.sql().to_owned() +} + +#[test] +fn an_unsorted_export_of_a_heap_is_not_sorted_by_row_address() { + for id in [EngineRowId::PostgresCtid, EngineRowId::PostgresTableoidCtid] { + assert_eq!( + export_sql(RowIdentity::EngineRowId(id), &[]), + r#"SELECT "id", "name" FROM "public"."users""#, + "{id:?}" + ); + } + assert_eq!( + export_sql(RowIdentity::EngineRowId(EngineRowId::SqliteRowid), &[]), + r#"SELECT "id", "name" FROM "public"."users" ORDER BY rowid ASC"#, + "a rowid is the table's own key, so its order costs nothing" + ); + assert_eq!( + export_sql(by_id(), &[]), + r#"SELECT "id", "name" FROM "public"."users" ORDER BY "users"."id" ASC"# + ); +} + #[test] fn a_page_splits_into_keys_the_dml_can_bind_back() { let dialect = TestDialect::exact(); diff --git a/linux/crates/core/src/meta/mod.rs b/linux/crates/core/src/meta/mod.rs index 850e52e2e1..379e9a2c2f 100644 --- a/linux/crates/core/src/meta/mod.rs +++ b/linux/crates/core/src/meta/mod.rs @@ -11,7 +11,7 @@ mod table_ref; pub use index_info::{IndexInfo, IndexKey}; pub use object_info::{ObjectInfo, ObjectKind}; -pub use page::{PageCost, PagePosition, PageStability}; +pub use page::{CtidWindow, HeapAddress, PageCost, PagePosition, PageStability}; pub use row_identity::{EngineRowId, EngineRowIdPart, RowIdentity}; pub use schema_info::{ObjectScope, SchemaInfo}; pub use table_estimate::{RowCountEstimate, TableEstimate}; diff --git a/linux/crates/core/src/meta/page.rs b/linux/crates/core/src/meta/page.rs index 66e080b178..1719a51e70 100644 --- a/linux/crates/core/src/meta/page.rs +++ b/linux/crates/core/src/meta/page.rs @@ -4,17 +4,32 @@ use crate::value::Value; #[derive(Debug, Clone, PartialEq)] pub enum PagePosition { First, + /// The table's last rows, read backward from its end. The SQL + /// reverses the order and the reader puts the rows back. + Last, Offset(u64), - /// Keyset paging forward from the last row of the page before. + /// Forward from the last row of the page before, by its key. AfterKey(Vec), - /// Keyset paging backward. The SQL reverses the order and the - /// fetcher reverses the rows back. + /// Backward from the first row of the page after, by its key. The + /// SQL reverses the order and the reader puts the rows back. BeforeKey(Vec), - /// A block of the PostgreSQL heap, for a table with no key. - CtidWindow { - after: Option<(u32, u16)>, - end_block: u64, - }, + /// One range of PostgreSQL heap addresses, for a table with no key. + /// A page is read through as many of these as it takes to fill it. + CtidWindow(CtidWindow), +} + +/// A PostgreSQL heap address: block, then line pointer. Line pointers +/// start at 1, so `(block, 0)` sorts before every row in that block. +pub type HeapAddress = (u32, u16); + +/// The rows between two heap addresses. A bound left out runs to that +/// end of the table. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct CtidWindow { + pub after: Option, + pub before: Option, + /// Read from `before` down, for a page that ends there. + pub backward: bool, } /// Whether the same page read twice shows the same rows. diff --git a/linux/crates/core/src/meta/row_identity.rs b/linux/crates/core/src/meta/row_identity.rs index a12a83e0fa..5ee07e317c 100644 --- a/linux/crates/core/src/meta/row_identity.rs +++ b/linux/crates/core/src/meta/row_identity.rs @@ -76,6 +76,12 @@ impl EngineRowId { Self::SqliteRowid => &[EngineRowIdPart::SqliteRowid], } } + + /// Whether the id is a place in the heap rather than a key some + /// index keeps in order, so ordering by it sorts the whole table. + pub fn is_heap_address(self) -> bool { + matches!(self, Self::PostgresCtid | Self::PostgresTableoidCtid) + } } impl RowIdentity { @@ -123,6 +129,16 @@ mod tests { assert!(EngineRowIdPart::PostgresCtid.orders_rows()); } + #[test] + fn only_a_postgres_row_address_is_a_place_in_the_heap() { + assert!(EngineRowId::PostgresCtid.is_heap_address()); + assert!(EngineRowId::PostgresTableoidCtid.is_heap_address()); + assert!( + !EngineRowId::SqliteRowid.is_heap_address(), + "a rowid is the table's own key" + ); + } + #[test] fn unordered_rows_are_read_only() { assert!(!RowIdentity::Unordered.is_editable()); diff --git a/linux/crates/drivers/postgres/tests/integration/main.rs b/linux/crates/drivers/postgres/tests/integration/main.rs index 91981dcf07..c771185507 100644 --- a/linux/crates/drivers/postgres/tests/integration/main.rs +++ b/linux/crates/drivers/postgres/tests/integration/main.rs @@ -15,6 +15,7 @@ mod filter; mod flight; mod liveness; mod metadata; +mod paging; mod pgbouncer; mod read_only; mod slow_route; diff --git a/linux/crates/drivers/postgres/tests/integration/paging.rs b/linux/crates/drivers/postgres/tests/integration/paging.rs new file mode 100644 index 0000000000..c674e74002 --- /dev/null +++ b/linux/crates/drivers/postgres/tests/integration/paging.rs @@ -0,0 +1,195 @@ +//! Paging a table the way the browse tab does: past the edge row's key, +//! or for a table with no key through heap block windows, so no page +//! sorts the whole table. + +use tablepro_core::browse::{PageRead, PageRequest, PagingMode, build_page, read_page}; +use tablepro_core::filter::FilterSet; +use tablepro_core::meta::{CtidWindow, PagePosition, RowIdentity, TableRef}; +use tablepro_core::{ColumnInfo, Connection, RowLimit, Value}; + +use crate::support::{CALL, connect, setup, start, statement, within}; + +type Failure = Box; + +const PAGE: u64 = 1_000; + +struct Table { + table: TableRef, + columns: Vec, + identity: RowIdentity, +} + +impl Table { + async fn read(connection: &dyn Connection, name: &str) -> Self { + let table = TableRef::new(Some("public".to_owned()), name); + let columns = connection + .fetch_columns(&table, within(CALL)) + .await + .expect("the columns"); + let identity = connection + .row_identity(&table, within(CALL)) + .await + .expect("the identity"); + Self { + table, + columns, + identity, + } + } + + fn request<'a>(&'a self, filter: &'a FilterSet, position: &'a PagePosition) -> PageRequest<'a> { + PageRequest { + table: &self.table, + columns: &self.columns, + filter, + sort: &[], + identity: &self.identity, + position, + limit: PAGE, + } + } + + async fn page(&self, connection: &dyn Connection, position: &PagePosition) -> PageRead { + let filter = FilterSet::default(); + read_page::(connection, &self.request(&filter, position), &within(CALL)) + .await + .expect("a page") + } + + /// What the server plans for `position`'s statement. + async fn plan(&self, connection: &dyn Connection, position: &PagePosition) -> String { + let filter = FilterSet::default(); + let spec = build_page(&*connection.dialect(), &self.request(&filter, position)).expect("a page query"); + let explain = spec.statement.wrapped("EXPLAIN ", ""); + connection + .query_bound(&explain, RowLimit::EDITOR_DEFAULT, within(CALL)) + .await + .expect("the plan") + .rows + .iter() + .filter_map(|row| match row.first() { + Some(Value::Text(line)) => Some(line.clone()), + _ => None, + }) + .collect::>() + .join("\n") + } +} + +async fn everything(connection: &dyn Connection, sql: &str) -> Vec { + connection + .query_bound(&statement(sql), RowLimit::new(100_000).expect("a limit"), within(CALL)) + .await + .expect("the whole table") + .rows + .into_iter() + .filter_map(|row| row.into_iter().next()) + .collect() +} + +fn numbers(read: &PageRead) -> Vec { + read.result.rows.iter().filter_map(|row| row.first().cloned()).collect() +} + +/// Every page from the first, each read past the last row of the one +/// before. +async fn forward(table: &Table, connection: &dyn Connection) -> Vec { + let mut seen = Vec::new(); + let mut position = PagePosition::First; + loop { + let read = table.page(connection, &position).await; + seen.extend(numbers(&read)); + if (read.result.rows.len() as u64) < PAGE { + return seen; + } + position = read.next.expect("a full page names the page after it"); + } +} + +/// Every page from the last, each read back from the first row of the +/// one after, down to the first page. +async fn backward(table: &Table, connection: &dyn Connection, expected: &[Value]) { + let mut end = expected.len(); + let mut position = PagePosition::Last; + loop { + let read = table.page(connection, &position).await; + let rows = numbers(&read); + if read.position == PagePosition::First { + assert_eq!(rows, expected[..rows.len()], "the first page, reached going back"); + return; + } + assert_eq!( + rows, + expected[end - rows.len()..end], + "a page read backward ending at row {end}" + ); + end -= rows.len(); + position = read.previous.expect("a page names the page before it"); + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore = "requires docker"] +async fn a_table_with_no_key_pages_through_block_windows() { + let fixture = start().await; + let connection = connect(&fixture, false).await; + // Dead rows leave windows short of a page, so the walk widens them. + setup( + connection.as_ref(), + &[ + "CREATE TABLE heap (n int NOT NULL, note text)", + "INSERT INTO heap SELECT n, repeat('x', n % 90) FROM generate_series(1, 60000) AS n", + "DELETE FROM heap WHERE n % 7 = 0 OR n BETWEEN 20000 AND 31000", + "ANALYZE heap", + ], + ) + .await; + let table = Table::read(connection.as_ref(), "heap").await; + // Rows of varied size fill gaps left in earlier blocks, so the heap + // is not in insert order; sorting it whole is the order to match. + let expected = everything(connection.as_ref(), "SELECT n FROM heap ORDER BY ctid").await; + assert_eq!(expected.len(), 41_999); + + assert_eq!(forward(&table, connection.as_ref()).await, expected); + backward(&table, connection.as_ref(), &expected).await; + + let first = table.page(connection.as_ref(), &PagePosition::First).await; + assert_eq!(first.mode, PagingMode::CtidWindow); + let window = PagePosition::CtidWindow(CtidWindow { + after: Some((40, 3)), + before: Some((60, 0)), + backward: false, + }); + let plan = table.plan(connection.as_ref(), &window).await; + assert!(plan.contains("Tid Range Scan"), "{plan}"); + assert!(!plan.contains("Seq Scan"), "a window read the whole table:\n{plan}"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore = "requires docker"] +async fn a_keyed_table_pages_past_the_edge_key_to_its_last_page() { + let fixture = start().await; + let connection = connect(&fixture, false).await; + setup( + connection.as_ref(), + &[ + "CREATE TABLE keyed (n int PRIMARY KEY, note text)", + "INSERT INTO keyed SELECT n, 'row' FROM generate_series(1, 25500) AS n", + "ANALYZE keyed", + ], + ) + .await; + let table = Table::read(connection.as_ref(), "keyed").await; + let expected: Vec = (1..=25_500).map(Value::Int).collect(); + + assert_eq!(forward(&table, connection.as_ref()).await, expected); + backward(&table, connection.as_ref(), &expected).await; + + let last = table.page(connection.as_ref(), &PagePosition::Last).await; + let previous = last.previous.clone().expect("the page before the last"); + for position in [PagePosition::Last, previous] { + let plan = table.plan(connection.as_ref(), &position).await; + assert!(plan.contains("Index Scan Backward"), "{position:?}:\n{plan}"); + assert!(!plan.contains("Sort"), "{position:?} sorted the table:\n{plan}"); + } +} From 34438848fda7331b3edffc2101ed0538f3cbbe33 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 17 Oct 2026 16:33:35 +0700 Subject: [PATCH 02/14] fix(linux-browse): restore a selection in one pass over the page --- .../src/ui/browse_tab/selection_handlers.rs | 8 +- .../src/ui/browse_tab/selection_snapshot.rs | 94 ++++++++++++++++++- 2 files changed, 90 insertions(+), 12 deletions(-) diff --git a/linux/crates/app/src/ui/browse_tab/selection_handlers.rs b/linux/crates/app/src/ui/browse_tab/selection_handlers.rs index 1111056738..f6880e29e2 100644 --- a/linux/crates/app/src/ui/browse_tab/selection_handlers.rs +++ b/linux/crates/app/src/ui/browse_tab/selection_handlers.rs @@ -87,13 +87,7 @@ impl BrowseModel { ) else { return; }; - snapshot.restore( - &mut self.row_index, - &store, - &selection, - &column_view, - &self.view_columns, - ); + snapshot.restore(&store, &selection, &column_view, &self.view_columns); } } diff --git a/linux/crates/app/src/ui/browse_tab/selection_snapshot.rs b/linux/crates/app/src/ui/browse_tab/selection_snapshot.rs index aea5e1ccf8..83b6d21667 100644 --- a/linux/crates/app/src/ui/browse_tab/selection_snapshot.rs +++ b/linux/crates/app/src/ui/browse_tab/selection_snapshot.rs @@ -1,9 +1,11 @@ +use std::collections::{HashMap, HashSet}; + use gtk4 as gtk; use gtk4::gio; use gtk4::prelude::*; use crate::services::change_tracker::RowKey; -use crate::ui::grid::{GridCell, GridRowIndex, RowObject, selected_positions}; +use crate::ui::grid::{GridCell, RowObject, selected_positions}; /// The selected rows and the focused cell, by row key, so a page read /// again can put them back wherever the rows land in it. @@ -39,16 +41,16 @@ impl SelectionSnapshot { /// the header does not change. pub(crate) fn restore( &self, - index: &mut GridRowIndex, store: &gio::ListStore, selection: >k::MultiSelection, column_view: >k::ColumnView, columns: &[gtk::ColumnViewColumn], ) { + let positions = self.positions_in(store); let selected = gtk::Bitset::new_empty(); for key in &self.keys { - if let Some(position) = index.position_in(store, key) { - selected.add(position); + if let Some(position) = positions.get(key) { + selected.add(*position); } } let mask = gtk::Bitset::new_range(0, selection.n_items()); @@ -56,7 +58,7 @@ impl SelectionSnapshot { let focused = self .focus .as_ref() - .and_then(|(key, column)| Some((index.position_in(store, key)?, *column))); + .and_then(|(key, column)| Some((*positions.get(key)?, *column))); match focused { Some((position, column)) => { column_view.scroll_to(position, columns.get(column), gtk::ListScrollFlags::FOCUS, None); @@ -68,4 +70,86 @@ impl SelectionSnapshot { None => {} } } + + /// Where each captured row sits in `store` now, in one pass over it: + /// a lookup per row would scan the page once for every row of a + /// Select All. + fn positions_in(&self, store: &gio::ListStore) -> HashMap { + let wanted: HashSet<&RowKey> = self + .keys + .iter() + .chain(self.focus.as_ref().map(|(key, _)| key)) + .collect(); + (0..store.n_items()) + .filter_map(|position| { + let key = store.item(position).and_downcast::()?.id().tracker_key()?; + wanted.contains(&key).then_some((key, position)) + }) + .collect() + } +} + +#[cfg(test)] +mod tests { + use std::time::{Duration, Instant}; + + use gtk4 as gtk; + use gtk4::gio; + use tablepro_core::Value; + + use super::SelectionSnapshot; + use crate::services::change_tracker::RowKey; + use crate::ui::grid::{RowObject, selected_positions}; + + fn key(id: i64) -> RowKey { + RowKey::from_pk_values(&[Value::Int(id)]).expect("a key") + } + + fn page(ids: impl Iterator) -> gio::ListStore { + let store = gio::ListStore::new::(); + let rows: Vec = ids + .map(|id| RowObject::persisted(Some(key(id)), vec![Value::Int(id)].into())) + .collect(); + store.splice(0, 0, &rows); + store + } + + fn restored(snapshot: &SelectionSnapshot, store: &gio::ListStore) -> Vec { + let selection = gtk::MultiSelection::new(Some(store.clone())); + let view = gtk::ColumnView::new(Some(selection.clone())); + snapshot.restore(store, &selection, &view, &[]); + selected_positions(&selection) + } + + #[gtk4::test] + fn the_selection_follows_its_rows_to_where_they_landed() { + let snapshot = SelectionSnapshot { + keys: vec![key(3), key(9), key(1)], + focus: None, + }; + + assert_eq!(restored(&snapshot, &page([9, 5, 3, 7].into_iter())), [0, 2]); + } + + /// Select All on a large page, then a refresh: each row's place used + /// to be a scan of the whole page. + #[gtk4::test] + fn a_whole_large_page_selection_comes_back_in_one_pass() { + let rows = 20_000; + let snapshot = SelectionSnapshot { + keys: (0..rows).map(key).collect(), + focus: Some((key(rows - 1), 0)), + }; + let store = page((0..rows).rev()); + let started = Instant::now(); + + let selected = restored(&snapshot, &store); + + assert_eq!(selected.len(), 20_000); + assert!( + started.elapsed() < Duration::from_secs(2), + "restoring the selection took {:?}", + started.elapsed() + ); + } } From 86ac460250057c09f25e3dc07f3e423f4095f583 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 17 Oct 2026 16:33:40 +0700 Subject: [PATCH 03/14] fix(linux-grid): measure only the rows a store change adds A new page measures from scratch, a draft measures nothing, and the digits' width is laid out once per width, font and text scale. --- .../crates/app/src/ui/grid/column_factory.rs | 4 +- .../crates/app/src/ui/grid/column_figures.rs | 159 +++++++++++++++--- linux/crates/app/src/ui/grid/grid_columns.rs | 17 +- 3 files changed, 152 insertions(+), 28 deletions(-) diff --git a/linux/crates/app/src/ui/grid/column_factory.rs b/linux/crates/app/src/ui/grid/column_factory.rs index 31b5d8dd2c..dc18329692 100644 --- a/linux/crates/app/src/ui/grid/column_factory.rs +++ b/linux/crates/app/src/ui/grid/column_factory.rs @@ -51,10 +51,10 @@ pub(crate) fn build_column_view( store.splice(0, 0, &rows); let grid_columns = Rc::new(GridColumns::default()); // A browse page splices its rows into this store, so the columns - // measure them again before the view binds any of them. + // measure them before the view binds any of them. store.connect_items_changed({ let grid_columns = grid_columns.clone(); - move |store, _, _, _| grid_columns.measure(store) + move |store, position, _, added| grid_columns.measure_change(store, position, added) }); // Tab leaves the grid, and the arrow keys move between cells. let column_view = gtk::ColumnView::builder() diff --git a/linux/crates/app/src/ui/grid/column_figures.rs b/linux/crates/app/src/ui/grid/column_figures.rs index fc6f823011..2449d5b558 100644 --- a/linux/crates/app/src/ui/grid/column_figures.rs +++ b/linux/crates/app/src/ui/grid/column_figures.rs @@ -1,4 +1,4 @@ -use std::cell::Cell; +use std::cell::{Cell, RefCell}; use gtk4::prelude::*; use gtk4::{self as gtk, pango}; @@ -14,25 +14,83 @@ const TEXT_NAT_CHARS: u32 = 14; /// How a column of either grid lays out its text. A column of numbers /// lines them up on the last digit in tabular figures and asks for the /// width of its longest number, so the column view never cuts one. -#[derive(Debug, Default)] +#[derive(Debug)] pub struct ColumnFigures { numbers: Cell, /// The longest number the column has held, in characters. chars: Cell, + /// An untyped column has held a value, and every one was a number. + held_value: Cell, + all_numbers: Cell, + /// The last width asked of `digits_width`, which every numeric cell + /// of the column asks again as it is bound. + digits: RefCell>, +} + +impl Default for ColumnFigures { + fn default() -> Self { + Self { + numbers: Cell::new(false), + chars: Cell::new(0), + held_value: Cell::new(false), + all_numbers: Cell::new(true), + digits: RefCell::new(None), + } + } +} + +#[derive(Debug)] +struct DigitsWidth { + chars: u32, + font: Option, + dpi: i32, + width: i32, } impl ColumnFigures { /// Decide from the column's type, or from `rows` when the driver /// could not type it. pub fn measure(&self, column_type: &ColumnType, index: usize, rows: &[RowObject]) { - let values = || { - rows.iter() - .filter_map(|row| row.cell(index)) - .filter(|value| !value.is_null()) - }; - let numbers = holds_numbers(&column_type.kind, values()); - self.numbers.set(numbers); - self.chars.set(if numbers { widest(column_type, values()) } else { 0 }); + self.numbers.set(false); + self.chars.set(0); + self.held_value.set(false); + self.all_numbers.set(true); + self.extend(column_type, index, rows); + } + + /// Take in rows added to those already measured. A draft has no + /// cells, so it changes nothing. + pub fn extend(&self, column_type: &ColumnType, index: usize, rows: &[RowObject]) { + let values = rows + .iter() + .filter_map(|row| row.cell(index)) + .filter(|value| !value.is_null()); + if column_type.kind.is_numeric() { + self.numbers.set(true); + self.chars.set(self.chars.get().max(widest(column_type, values))); + return; + } + // PostgreSQL editor results carry no type (ADR 0016), so such a + // column aligns by what it holds, as a spreadsheet does. + if !matches!(column_type.kind, ColumnKind::Other | ColumnKind::Variant) { + return; + } + let mut scratch = String::new(); + for value in values { + if !self.all_numbers.get() { + break; + } + self.held_value.set(true); + match is_number(value) { + true => self.chars.set( + self.chars + .get() + .max(char_count(display_text(value, column_type, &mut scratch).text)), + ), + false => self.all_numbers.set(false), + } + } + self.numbers.set(self.held_value.get() && self.all_numbers.get()); } pub fn numbers(&self) -> bool { @@ -59,21 +117,31 @@ impl ColumnFigures { display.add_css_class("numeric"); // GtkInscription sizes by an average character, which a run of // digits outgrows, so the cell asks for the digits' own width. - display.set_width_request(digits_width(display, self.chars.get())); + display.set_width_request(self.digits_width(display, self.chars.get())); } -} -fn holds_numbers<'a>(kind: &ColumnKind, values: impl Iterator) -> bool { - if kind.is_numeric() { - return true; - } - // PostgreSQL editor results carry no type (ADR 0016), so such a - // column aligns by what it holds, as a spreadsheet does. - if !matches!(kind, ColumnKind::Other | ColumnKind::Variant) { - return false; + /// The width of `chars` digits, laid out once per width, font and + /// text scale rather than once per cell. + fn digits_width(&self, display: >k::Inscription, chars: u32) -> i32 { + let font = display.pango_context().font_description(); + let dpi = display.settings().gtk_xft_dpi(); + let mut cached = self.digits.borrow_mut(); + if let Some(digits) = cached.as_ref() + && digits.chars == chars + && digits.dpi == dpi + && digits.font == font + { + return digits.width; + } + let width = digits_width(display, chars); + *cached = Some(DigitsWidth { + chars, + font, + dpi, + width, + }); + width } - let mut values = values.peekable(); - values.peek().is_some() && values.all(is_number) } fn is_number(value: &Value) -> bool { @@ -180,6 +248,31 @@ mod tests { } } + #[test] + fn rows_added_later_widen_the_column_and_a_draft_changes_nothing() { + let integer = col("integer", false).column_type; + let figures = measured(&integer, &[Value::Int(12)]); + + figures.extend(&integer, 0, &[RowObject::draft(1)]); + assert_eq!(figures.chars.get(), 2); + figures.extend(&integer, 0, &rows(&[Value::Int(-1_234)])); + assert_eq!(figures.chars.get(), 5, "the widest row so far"); + figures.measure(&integer, 0, &rows(&[Value::Int(7)])); + assert_eq!(figures.chars.get(), 1, "a new page measures from scratch"); + } + + #[test] + fn an_untyped_column_stops_being_numbers_at_the_first_word_added() { + let untyped = ColumnType::untyped_text("text"); + let figures = measured(&untyped, &[text("6")]); + assert!(figures.numbers()); + + figures.extend(&untyped, 0, &rows(&[Value::Null, text("Coffee")])); + assert!(!figures.numbers()); + figures.extend(&untyped, 0, &rows(&[text("7")])); + assert!(!figures.numbers(), "one word in the column is enough"); + } + /// A browse page in a window too narrow for every column to get the /// width it asks for. struct Crowded { @@ -300,4 +393,26 @@ mod tests { }); assert!(wide.is_ok(), "the column waits for its widest row to scroll into view"); } + + #[gtk4::test] + fn adding_a_draft_keeps_the_width_the_page_measured() { + let columns = shop_columns(0); + let mut first: Vec> = (1..=20).map(|id| shop_row(id, "1.5", columns.len())).collect(); + if let Some(last) = first.last_mut() { + last[0] = Value::Int(100_000_000_001); + } + let grid = crowded(&columns, &first); + wait_until(WAIT, || !drawn(&grid.view, &[0]).is_empty()).expect("the page is drawn"); + + grid.store.insert(0, &RowObject::draft(1)); + + let wide = wait_until(WAIT, || { + let ids = drawn(&grid.view, &[0]); + !ids.is_empty() + && ids + .iter() + .all(|display| display.width() >= tabular_width(display, "100000000001")) + }); + assert!(wide.is_ok(), "the draft narrowed the column its rows had widened"); + } } diff --git a/linux/crates/app/src/ui/grid/grid_columns.rs b/linux/crates/app/src/ui/grid/grid_columns.rs index 8f5c12bf5a..80484d5952 100644 --- a/linux/crates/app/src/ui/grid/grid_columns.rs +++ b/linux/crates/app/src/ui/grid/grid_columns.rs @@ -55,14 +55,23 @@ impl GridColumns { }); } - /// Measure every column against the rows `store` holds now. - pub(super) fn measure(&self, store: &gio::ListStore) { - let rows: Vec = (0..store.n_items()) + /// Measure the rows a change to `store` brought in. A change that + /// replaced every row, a new page, measures from scratch; adding or + /// removing a draft, which has no cells, measures nothing. + pub(super) fn measure_change(&self, store: &gio::ListStore, position: u32, added: u32) { + let replaced_all = position == 0 && store.n_items() == added; + if !replaced_all && added == 0 { + return; + } + let rows: Vec = (position..position.saturating_add(added)) .filter_map(|position| store.item(position).and_downcast()) .collect(); for column in self.columns.borrow().iter() { let drawing = &column.drawing; - drawing.figures.measure(&drawing.column_type, column.index, &rows); + match replaced_all { + true => drawing.figures.measure(&drawing.column_type, column.index, &rows), + false => drawing.figures.extend(&drawing.column_type, column.index, &rows), + } } } From be43736dc5cd70b7b5a8b4d67438dd49f5ec1e91 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 17 Oct 2026 16:33:47 +0700 Subject: [PATCH 04/14] perf(linux-grid): cut a cell's text at 1,000 characters The cell shapes all the text it is given though one line shows, and the tooltip quotes 300 characters. --- linux/crates/app/src/ui/grid/display_text.rs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/linux/crates/app/src/ui/grid/display_text.rs b/linux/crates/app/src/ui/grid/display_text.rs index 0067f88776..2148a7821a 100644 --- a/linux/crates/app/src/ui/grid/display_text.rs +++ b/linux/crates/app/src/ui/grid/display_text.rs @@ -4,8 +4,10 @@ use tablepro_core::Value; use tablepro_core::column::ColumnType; /// A million-character value would hang the main thread in Pango -/// layout. The model keeps the whole value; only the cell is cut. -pub(super) const DISPLAY_MAX_CHARS: usize = 10_000; +/// layout, and the cell shapes all it is given though a line of it +/// shows. The model keeps the whole value; only the cell is cut, past +/// what its tooltip quotes. +pub(super) const DISPLAY_MAX_CHARS: usize = 1_000; /// What a cell shows for a value. `text` borrows the value where it /// can, so a text cell draws with no copy of its own. @@ -326,7 +328,7 @@ mod tests { fn a_huge_text_value_draws_bounded() { let display = shown(&Value::Text("x".repeat(1_000_000))); - assert!(display.len() < 11_000, "{}", display.len()); - assert!(display.ends_with("… (990000 more bytes)")); + assert!(display.len() < 1_100, "{}", display.len()); + assert!(display.ends_with("… (999000 more bytes)")); } } From 54c2b9d2a23a0a31a6e9b72764bac6610e7f8fed Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 17 Oct 2026 16:33:47 +0700 Subject: [PATCH 05/14] perf(linux-grid): tint a cell without copying or scanning edits Every bind of every cell scanned all pending edits for its row's state and copied its pending value, only to pick a class. --- linux/crates/app/src/services/cell_overlay.rs | 10 +++++++ .../crates/app/src/services/change_tracker.rs | 28 ++++++++++++++++++- .../app/src/services/tab_tracker_handle.rs | 6 +++- .../app/src/ui/grid/tab_grid_context.rs | 22 +++++++-------- 4 files changed, 53 insertions(+), 13 deletions(-) diff --git a/linux/crates/app/src/services/cell_overlay.rs b/linux/crates/app/src/services/cell_overlay.rs index ee2ec871a9..99cd7702c1 100644 --- a/linux/crates/app/src/services/cell_overlay.rs +++ b/linux/crates/app/src/services/cell_overlay.rs @@ -17,6 +17,16 @@ pub struct CellOverlay { pub error_flash: bool, } +/// What tints a cell, without its pending value: every bind asks this +/// of every cell, and copying a value or scanning every edit to tint +/// one would grow with the edits held. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct CellMarks { + pub row_deleted: bool, + pub state: CellState, + pub error_flash: bool, +} + #[cfg(test)] impl CellOverlay { /// The value the cell shows over `fetched`, a cell left to the diff --git a/linux/crates/app/src/services/change_tracker.rs b/linux/crates/app/src/services/change_tracker.rs index 59bcdf10b4..0bb2a1f109 100644 --- a/linux/crates/app/src/services/change_tracker.rs +++ b/linux/crates/app/src/services/change_tracker.rs @@ -11,7 +11,7 @@ use tablepro_core::meta::RowIdentity; use tablepro_core::save::{ChangeSet, RowChange, RowUpdate}; use tablepro_core::{ColumnInfo, Value}; -use super::cell_overlay::CellOverlay; +use super::cell_overlay::{CellMarks, CellOverlay}; use super::column_relayout::{ColumnRelayout, Misfit}; use super::save_batch::{SaveBatch, SaveTarget}; use super::touch::Touch; @@ -510,6 +510,14 @@ impl TabChangeTracker { } } + pub fn cell_marks(&self, row_key: &RowKey, col: usize) -> CellMarks { + CellMarks { + row_deleted: self.deletes.contains_key(row_key), + state: self.cell_state(row_key, col), + error_flash: self.is_error_row(row_key), + } + } + fn cell_state(&self, row_key: &RowKey, col: usize) -> CellState { if matches!(row_key, RowKey::Draft(_)) { return CellState::InsertDraft; @@ -1148,6 +1156,24 @@ mod tests { assert_eq!(t.pending_count(), 2); } + #[test] + fn cell_marks_tint_a_cell_by_its_own_edit_and_its_rows_delete() { + let mut t = TabChangeTracker::default(); + let (edited, deleted) = (rk(&[Value::Int(1)]), rk(&[Value::Int(2)])); + t.track_cell_edit(edited.clone(), 1, Value::Text("a".into()), Value::Text("b".into())); + delete(&mut t, deleted.clone(), vec![Value::Int(2), Value::Text("c".into())]); + let draft = RowKey::Draft(insert(&mut t, vec![Value::Null, Value::Null])); + + let marks = |key: &RowKey, col| { + let marks = t.cell_marks(key, col); + (marks.row_deleted, marks.state) + }; + assert_eq!(marks(&edited, 1), (false, CellState::Modified)); + assert_eq!(marks(&edited, 0), (false, CellState::Clean)); + assert_eq!(marks(&deleted, 0), (true, CellState::Clean)); + assert_eq!(marks(&draft, 0), (false, CellState::InsertDraft)); + } + #[test] fn delete_marks_row_pending() { let mut t = TabChangeTracker::default(); diff --git a/linux/crates/app/src/services/tab_tracker_handle.rs b/linux/crates/app/src/services/tab_tracker_handle.rs index 829267e033..5e8d923278 100644 --- a/linux/crates/app/src/services/tab_tracker_handle.rs +++ b/linux/crates/app/src/services/tab_tracker_handle.rs @@ -5,7 +5,7 @@ use tablepro_core::dml::BuildSqlError; use tablepro_core::edit::CellInput; use tablepro_core::{ColumnInfo, Value}; -use super::cell_overlay::CellOverlay; +use super::cell_overlay::{CellMarks, CellOverlay}; use super::change_tracker::{DraftRow, ErrorFlash, RowKey, RowState, TabChangeTracker, TrackedChanges}; use super::save_batch::{SaveBatch, SaveTarget}; use super::tracker_delta::TrackerDelta; @@ -23,6 +23,10 @@ impl TabTrackerHandle { self.0.borrow().cell_overlay(row_key, col) } + pub fn cell_marks(&self, row_key: &RowKey, col: usize) -> CellMarks { + self.0.borrow().cell_marks(row_key, col) + } + pub fn row_state(&self, row_key: &RowKey) -> RowState { self.0.borrow().row_state(row_key) } diff --git a/linux/crates/app/src/ui/grid/tab_grid_context.rs b/linux/crates/app/src/ui/grid/tab_grid_context.rs index 8913f0bc6e..1843d93f89 100644 --- a/linux/crates/app/src/ui/grid/tab_grid_context.rs +++ b/linux/crates/app/src/ui/grid/tab_grid_context.rs @@ -6,8 +6,8 @@ use tablepro_core::meta::RowIdentity; use super::row_state_icon::RowPendingState; use super::{ColumnWidthBinding, GridRowId, RowObject, RowSnapshot}; -use crate::services::cell_overlay::CellOverlay; -use crate::services::change_tracker::{CellState, RowKey, RowState}; +use crate::services::cell_overlay::CellMarks; +use crate::services::change_tracker::{CellState, RowKey}; use crate::services::tab_tracker_handle::TabTrackerHandle; /// `tracker` and `column_widths` are `None` for a grid nothing tracks, @@ -113,20 +113,20 @@ impl TabGridContext { let Some((tracker, key)) = self.tracked_key(row) else { return (Vec::new(), CellState::Clean); }; - let overlay = tracker.cell_overlay(&key, col); - (pending_classes(tracker.row_state(&key), &overlay, col), overlay.state) + let marks = tracker.cell_marks(&key, col); + (pending_classes(&marks, col), marks.state) } } -fn pending_classes(row_state: RowState, overlay: &CellOverlay, col: usize) -> Vec<&'static str> { +fn pending_classes(marks: &CellMarks, col: usize) -> Vec<&'static str> { let mut classes = Vec::new(); - match (row_state, overlay.state) { - (RowState::PendingDelete, _) => classes.push("tp-row-pending-delete"), - (RowState::InsertDraft, _) => classes.push("tp-row-pending-insert"), - (_, CellState::Modified) => classes.push("tp-cell-modified"), - _ => {} + match (marks.row_deleted, marks.state) { + (true, _) => classes.push("tp-row-pending-delete"), + (false, CellState::InsertDraft) => classes.push("tp-row-pending-insert"), + (false, CellState::Modified) => classes.push("tp-cell-modified"), + (false, CellState::Clean) => {} } - if col == 0 && overlay.error_flash { + if col == 0 && marks.error_flash { classes.push("tp-row-leftmost-error-flash"); } classes From 156f66cadaea94231fd0f90216c14fcf631c227b Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 17 Oct 2026 16:33:47 +0700 Subject: [PATCH 06/14] perf(linux-grid): sort a text column by the head of each value --- linux/crates/app/src/ui/grid/value_order.rs | 38 ++++++++++++++++++--- 1 file changed, 33 insertions(+), 5 deletions(-) diff --git a/linux/crates/app/src/ui/grid/value_order.rs b/linux/crates/app/src/ui/grid/value_order.rs index 191e2b6dd7..0fc932aaef 100644 --- a/linux/crates/app/src/ui/grid/value_order.rs +++ b/linux/crates/app/src/ui/grid/value_order.rs @@ -44,12 +44,29 @@ pub(crate) fn storage_class_order(a: &Value, b: &Value) -> Ordering { /// The text a collating sorter orders `value` by. `None` for a value /// that is not text, which some other sorter has already placed. pub(crate) fn sort_text(value: &Value) -> Option<&str> { - match value { - Value::Text(text) => Some(text), - Value::Json(json) => Some(json.as_str()), - Value::Other(other) => Some(&other.text), - _ => None, + let text = match value { + Value::Text(text) => text, + Value::Json(json) => json.as_str(), + Value::Other(other) => &other.text, + _ => return None, + }; + Some(sort_head(text)) +} + +/// A collation key is built from the whole text it is given, so a sort +/// of large documents would build keys as large as they are. The head +/// decides any order a person reads. +const SORT_TEXT_MAX_CHARS: usize = 4_096; + +/// A text of no more bytes than the cap has no more chars either, so it +/// is not walked. +fn sort_head(text: &str) -> &str { + if text.len() <= SORT_TEXT_MAX_CHARS { + return text; } + text.char_indices() + .nth(SORT_TEXT_MAX_CHARS) + .map_or(text, |(index, _)| &text[..index]) } #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] @@ -467,6 +484,17 @@ mod tests { ); } + #[test] + fn a_long_text_sorts_by_its_head() { + let long = format!("{}tail", "é".repeat(SORT_TEXT_MAX_CHARS)); + + assert_eq!( + sort_text(&Value::Text(long)), + Some("é".repeat(SORT_TEXT_MAX_CHARS).as_str()) + ); + assert_eq!(sort_text(&Value::Text("short".to_owned())), Some("short")); + } + #[test] fn sort_text_covers_text_kinds_only() { let json = tablepro_core::value::JsonText::parse("{\"a\":1}".to_owned()).expect("json"); From 8ff9c94c7468a162bc622ac8fabe17eb4b5e8bba Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 17 Oct 2026 16:34:13 +0700 Subject: [PATCH 07/14] fix(linux): ask before Discard drops a tab's pending changes Discard also clears the undo history, so nothing brought the changes back. The browse and structure tabs now confirm it, as F5 already does. --- linux/crates/app/src/ui/browse_tab/layout.rs | 2 +- .../app/src/ui/browse_tab/save_handlers.rs | 15 +++++++ .../app/src/ui/browse_tab/save_input.rs | 2 + linux/crates/app/src/ui/browse_tab/tests.rs | 43 +++++++++++++++++++ linux/crates/app/src/ui/discard_alert.rs | 32 ++++++++++++++ linux/crates/app/src/ui/mod.rs | 1 + .../crates/app/src/ui/structure_tab/layout.rs | 2 +- .../app/src/ui/structure_tab/save_handlers.rs | 11 +++++ .../app/src/ui/structure_tab/save_input.rs | 2 + .../crates/app/src/ui/structure_tab/tests.rs | 34 ++++++++++++++- linux/po/POTFILES.in | 1 + 11 files changed, 141 insertions(+), 4 deletions(-) create mode 100644 linux/crates/app/src/ui/discard_alert.rs diff --git a/linux/crates/app/src/ui/browse_tab/layout.rs b/linux/crates/app/src/ui/browse_tab/layout.rs index 50db8b0406..fa8b5a2b87 100644 --- a/linux/crates/app/src/ui/browse_tab/layout.rs +++ b/linux/crates/app/src/ui/browse_tab/layout.rs @@ -121,7 +121,7 @@ fn connect_controls(tab: &BrowseTab) { } for (request, send) in [ (PendingChangesRequest::Save, sender(tab, || SaveInput::CommitSave)), - (PendingChangesRequest::Discard, sender(tab, || SaveInput::DiscardAll)), + (PendingChangesRequest::Discard, sender(tab, || SaveInput::ConfirmDiscard)), (PendingChangesRequest::StopSaving, sender(tab, || SaveInput::StopSaving)), ] { imp.pending_bar.connect_request(request, move |_| send()); diff --git a/linux/crates/app/src/ui/browse_tab/save_handlers.rs b/linux/crates/app/src/ui/browse_tab/save_handlers.rs index 1f8000d853..6943465780 100644 --- a/linux/crates/app/src/ui/browse_tab/save_handlers.rs +++ b/linux/crates/app/src/ui/browse_tab/save_handlers.rs @@ -5,6 +5,7 @@ use gtk4::prelude::*; use super::{BrowseModel, BrowseRequestKind, BrowseTabOutput, Effects, SaveInput}; use crate::services::change_origins::ChangeOrigins; use crate::services::change_tracker::{RowKey, StatementSource}; +use crate::ui::discard_alert::confirm_discard; impl BrowseModel { pub(super) fn on_save(&mut self, input: SaveInput, fx: &mut Effects) { @@ -12,6 +13,7 @@ impl BrowseModel { SaveInput::CommitSave => fx.output(BrowseTabOutput::SaveRequested), SaveInput::SaveStarted => self.show_saving(true), SaveInput::StopSaving => fx.output(BrowseTabOutput::StopSaveRequested), + SaveInput::ConfirmDiscard => self.confirm_discard(), SaveInput::DiscardAll => { let delta = self.tracker.discard(); self.apply_tracker_delta(&delta, fx); @@ -46,6 +48,19 @@ impl BrowseModel { } } + fn confirm_discard(&self) { + let pending = self.tracker.pending_count(); + if pending == 0 { + return; + } + let tab = self.this.clone(); + confirm_discard(&self.widgets.inner_stack, pending, move || { + if let Some(tab) = tab.upgrade() { + tab.send(SaveInput::DiscardAll); + } + }); + } + fn save_committed(&mut self, origins: &ChangeOrigins, fx: &mut Effects) { self.show_saving(false); // The tracker already let go of what the save wrote. The diff --git a/linux/crates/app/src/ui/browse_tab/save_input.rs b/linux/crates/app/src/ui/browse_tab/save_input.rs index 29a2077a76..e7b11dbd71 100644 --- a/linux/crates/app/src/ui/browse_tab/save_input.rs +++ b/linux/crates/app/src/ui/browse_tab/save_input.rs @@ -9,6 +9,8 @@ pub(crate) enum SaveInput { /// The window started saving the tab's changes. SaveStarted, StopSaving, + /// Discard clicked: ask before the changes go. + ConfirmDiscard, DiscardAll, DiscardAndRefresh, /// The save landed, adding and removing these rows. Edits made diff --git a/linux/crates/app/src/ui/browse_tab/tests.rs b/linux/crates/app/src/ui/browse_tab/tests.rs index aa90c9c8d3..2437137883 100644 --- a/linux/crates/app/src/ui/browse_tab/tests.rs +++ b/linux/crates/app/src/ui/browse_tab/tests.rs @@ -3008,6 +3008,49 @@ fn filter_button_tooltip_counts_the_active_rules() { .expect("the tooltip still counted rules after they were cleared"); } +/// The pending bar's Discard drops the undo history with the edits, so +/// it asks first, as F5 does. +#[gtk4::test] +fn discard_asks_before_it_drops_the_pending_changes() { + use gtk4::subclass::prelude::ObjectSubclassIsExt; + use libadwaita::prelude::{AdwDialogExt, AdwWindowExt, AlertDialogExt}; + let mut script = serving_table(); + script.query_bound.always(FakeReply::ok(rows(&["Ada", "Grace"]))); + let harness = Harness::open(script, []); + wait_until(WAIT, || harness.shown().len() == 2).expect("the first page"); + let window = crate::test_support::OwnedWindow::presented(libadwaita::Window::new()); + window.set_content(Some(harness.tab())); + harness.tab().send(EditInput::InsertRow); + let discard = harness.tab().imp().pending_bar.imp().discard_button.get(); + let alert = || { + let mut shown = None; + wait_until(WAIT, || { + shown = window.visible_dialog().and_downcast::(); + shown.is_some() + }) + .expect("Discard asks first"); + shown.expect("the alert") + }; + + discard.emit_clicked(); + let first = alert(); + assert_eq!(harness.tracker.pending_count(), 1, "the edit went before the answer"); + assert_eq!(first.close_response(), "cancel"); + first.emit_by_name::<()>("response", &[&"cancel"]); + first.force_close(); + drain_main_context(); + assert_eq!(harness.tracker.pending_count(), 1, "Cancel dropped the edit"); + + discard.emit_clicked(); + let second = alert(); + second.emit_by_name::<()>("response", &[&"discard"]); + second.force_close(); + + wait_until(WAIT, || harness.tracker.pending_count() == 0).expect("the confirmed discard"); + assert_eq!(draft_rows(&harness.store()), 0); + window.set_content(None::<>k4::Widget>); +} + /// The page queries a tab sent, in order. fn page_queries(harness: &Harness) -> Vec { harness diff --git a/linux/crates/app/src/ui/discard_alert.rs b/linux/crates/app/src/ui/discard_alert.rs new file mode 100644 index 0000000000..54cd549382 --- /dev/null +++ b/linux/crates/app/src/ui/discard_alert.rs @@ -0,0 +1,32 @@ +use gtk4 as gtk; +use gtk4::prelude::*; +use libadwaita as adw; +use libadwaita::prelude::*; + +/// Ask before Discard drops a tab's pending changes. The undo history +/// goes with them, so the alert is the only chance to keep them. +pub(crate) fn confirm_discard( + parent: &impl IsA, + pending: usize, + discard: impl Fn() + 'static, +) -> adw::AlertDialog { + let body = crate::i18n::ngettext_f( + "{n} unsaved change on this tab will be lost, and Undo cannot bring it back.", + "{n} unsaved changes on this tab will be lost, and Undo cannot bring them back.", + u32::try_from(pending).unwrap_or(u32::MAX), + &[("n", &pending.to_string())], + ); + let dialog = adw::AlertDialog::new(Some(&crate::i18n::gettext("Discard Changes?")), Some(&body)); + dialog.add_response("cancel", &crate::i18n::gettext("Cancel")); + dialog.add_response("discard", &crate::i18n::gettext("Discard")); + dialog.set_response_appearance("discard", adw::ResponseAppearance::Destructive); + dialog.set_default_response(Some("cancel")); + dialog.set_close_response("cancel"); + dialog.connect_response(None, move |_, response| { + if response == "discard" { + discard(); + } + }); + dialog.present(Some(parent)); + dialog +} diff --git a/linux/crates/app/src/ui/mod.rs b/linux/crates/app/src/ui/mod.rs index 07fcfdd166..8c8254d17b 100644 --- a/linux/crates/app/src/ui/mod.rs +++ b/linux/crates/app/src/ui/mod.rs @@ -6,6 +6,7 @@ mod connect_dialog; mod connection_list_banner; pub(crate) mod credential_prompt; mod delete_connection_dialog; +mod discard_alert; mod editor; mod editor_font_provider; pub(crate) mod error_text; diff --git a/linux/crates/app/src/ui/structure_tab/layout.rs b/linux/crates/app/src/ui/structure_tab/layout.rs index ac5858448f..2e40246ea0 100644 --- a/linux/crates/app/src/ui/structure_tab/layout.rs +++ b/linux/crates/app/src/ui/structure_tab/layout.rs @@ -147,7 +147,7 @@ fn toolbar(tab: &StructureTab, mode: StructureMode, read_only: bool) { button.connect_clicked(move |_| sender.input(msg())); }; on_click(&imp.save_button, || SaveInput::Save.into()); - on_click(&imp.discard_button, || SaveInput::Discard.into()); + on_click(&imp.discard_button, || SaveInput::ConfirmDiscard.into()); on_click(&imp.drop_button, || TableInput::DropTableRequested.into()); on_click(&imp.stop_button, || SaveInput::StopSaving.into()); } diff --git a/linux/crates/app/src/ui/structure_tab/save_handlers.rs b/linux/crates/app/src/ui/structure_tab/save_handlers.rs index 771e7610ee..e7128228f1 100644 --- a/linux/crates/app/src/ui/structure_tab/save_handlers.rs +++ b/linux/crates/app/src/ui/structure_tab/save_handlers.rs @@ -6,7 +6,9 @@ use super::save_gate::SaveGate; use super::sql_preview::no_pending_changes; use super::{ ColumnsInput, SaveInput, StructureEffects, StructureMode, StructureModel, StructureTabInput, StructureTabOutput, + StructureTabSender, }; +use crate::ui::discard_alert::confirm_discard; use crate::ui::error_text::{build_error_text, save_blocker_text}; impl StructureModel { @@ -15,6 +17,15 @@ impl StructureModel { SaveInput::Save => self.save(fx), SaveInput::SaveStarted => self.show_saving(true), SaveInput::StopSaving => fx.output(StructureTabOutput::StopSaveRequested), + SaveInput::ConfirmDiscard => { + let pending = self.pending().change_count(); + if pending > 0 { + let sender = StructureTabSender { tab: self.this.clone() }; + confirm_discard(&self.widgets.inner_stack, pending, move || { + sender.input(SaveInput::Discard); + }); + } + } SaveInput::Reload => { // A table the save may have created has nothing to read // yet, so a new one is only cleared. diff --git a/linux/crates/app/src/ui/structure_tab/save_input.rs b/linux/crates/app/src/ui/structure_tab/save_input.rs index 0864810bcf..8d211c3350 100644 --- a/linux/crates/app/src/ui/structure_tab/save_input.rs +++ b/linux/crates/app/src/ui/structure_tab/save_input.rs @@ -6,6 +6,8 @@ pub(crate) enum SaveInput { /// The window started saving the tab's pending structure. SaveStarted, StopSaving, + /// Discard clicked: ask before the changes go. + ConfirmDiscard, Discard, /// Read the table again and drop the draft, for a save whose outcome /// nobody knows. diff --git a/linux/crates/app/src/ui/structure_tab/tests.rs b/linux/crates/app/src/ui/structure_tab/tests.rs index 8fd7788021..be2543f71e 100644 --- a/linux/crates/app/src/ui/structure_tab/tests.rs +++ b/linux/crates/app/src/ui/structure_tab/tests.rs @@ -4,7 +4,9 @@ use std::sync::Arc; use std::time::Duration; use gtk4::subclass::prelude::ObjectSubclassIsExt; -use libadwaita::prelude::{ActionRowExt, AdwWindowExt, ComboRowExt, ExpanderRowExt, PreferencesRowExt}; +use libadwaita::prelude::{ + ActionRowExt, AdwDialogExt, AdwWindowExt, AlertDialogExt, ComboRowExt, ExpanderRowExt, PreferencesRowExt, +}; use tablepro_core::column::{ColumnDefault, ColumnType}; use tablepro_core::ddl::{ ColumnAttributes, ColumnDefinition, ColumnKeyRoles, DraftColumn, IdentityStyle, InboundForeignKey, @@ -1211,6 +1213,7 @@ fn add_column_changes_one_item() { #[gtk4::test] fn discard_restores_without_phantom_ops() { let harness = open_edit(postgres(), keyed_users()); + let window = in_window(&harness); let tab = harness.tab(); let imp = tab.imp(); let dirty = Rc::new(RefCell::new(Vec::new())); @@ -1222,7 +1225,7 @@ fn discard_restores_without_phantom_ops() { assert_eq!(imp.pending_label.label(), "3 pending changes"); imp.discard_button.emit_clicked(); - drain_main_context(); + answer_discard(&window, "discard"); assert_eq!(harness.column_names(), ["id", "score"]); assert_eq!(harness.comments(), [None, None]); @@ -1237,6 +1240,33 @@ fn discard_restores_without_phantom_ops() { assert_eq!(imp.pending_label.label(), "1 pending change"); } +/// Discard drops the undo history with the changes, so it asks first, +/// as F5 does on a browse tab. +#[gtk4::test] +fn a_discard_not_confirmed_keeps_every_change() { + let harness = open_edit(postgres(), keyed_users()); + let window = in_window(&harness); + let imp = harness.tab().imp(); + entry(harness.tab(), 1, "col-name").set_text("points"); + + imp.discard_button.emit_clicked(); + answer_discard(&window, "cancel"); + + assert_eq!(harness.column_names(), ["id", "points"]); + assert_eq!(imp.pending_label.label(), "1 pending change"); +} + +/// Answer the alert Discard opens. +fn answer_discard(window: &adw::Window, response: &str) { + let alert = shown_form(window) + .downcast::() + .expect("Discard asks before it drops the changes"); + assert_eq!(alert.close_response(), "cancel"); + alert.emit_by_name::<()>("response", &[&response]); + alert.force_close(); + drain_main_context(); +} + #[gtk4::test] fn subtitle_updates_on_type_edit() { let harness = open_edit(postgres(), keyed_users()); diff --git a/linux/po/POTFILES.in b/linux/po/POTFILES.in index 66ad367755..ff266f5c48 100644 --- a/linux/po/POTFILES.in +++ b/linux/po/POTFILES.in @@ -186,6 +186,7 @@ crates/app/src/ui/credential_prompt/prompt_form.rs crates/app/src/ui/credential_prompt/prompt_text.rs crates/app/src/ui/credential_prompt/prompter.rs crates/app/src/ui/delete_connection_dialog.rs +crates/app/src/ui/discard_alert.rs crates/app/src/ui/editor/buffer_edit.rs crates/app/src/ui/editor/completion/catalog_read.rs crates/app/src/ui/editor/completion/completion_scope.rs From c2812b7197c865a6628443bba3bd7b6d87eb6f52 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 17 Oct 2026 16:34:32 +0700 Subject: [PATCH 08/14] fix(linux-browse): show a page size set in Preferences in the tab The Rows dropdown offered only its five sizes, so a tab reading 2,500 rows showed 1,000, and picking 1,000 did nothing. It now lists the tab's own size too. --- linux/crates/app/src/ui/browse_tab/layout.rs | 58 ++++++++++++++------ 1 file changed, 40 insertions(+), 18 deletions(-) diff --git a/linux/crates/app/src/ui/browse_tab/layout.rs b/linux/crates/app/src/ui/browse_tab/layout.rs index fa8b5a2b87..67ff7575b6 100644 --- a/linux/crates/app/src/ui/browse_tab/layout.rs +++ b/linux/crates/app/src/ui/browse_tab/layout.rs @@ -10,8 +10,7 @@ use super::browse_tab_widgets::BrowseTabWidgets; use super::pager_bar::PagerRequest; use super::pending_changes_bar::PendingChangesRequest; use super::{ - BrowseTab, BrowseTabInput, ClipboardInput, DEFAULT_PAGE_SIZE, PAGE_SIZE_OPTIONS, PagingInput, SaveInput, - loading_description, + BrowseTab, BrowseTabInput, ClipboardInput, PAGE_SIZE_OPTIONS, PagingInput, SaveInput, loading_description, }; use crate::ui::filter_strip::FilterStrip; use crate::ui::format::format_thousands; @@ -32,8 +31,8 @@ pub(super) fn build(tab: &BrowseTab, init: &LayoutInit<'_>) -> BrowseTabWidgets .set_description(Some(&loading_description(init.table_label))); imp.inner_stack.set_visible_child_name("loading"); imp.read_only_banner.set_revealed(init.read_only); - page_size_choices(&imp.page_size_dropdown, init.page_size); - connect_controls(tab); + let page_sizes = page_size_choices(&imp.page_size_dropdown, init.page_size); + connect_controls(tab, page_sizes); BrowseTabWidgets { inner_stack: imp.inner_stack.get(), grid_holder: imp.grid_holder.get(), @@ -82,20 +81,26 @@ pub(super) fn grid_sink(tab: &BrowseTab) -> GridSink { }) } -fn page_size_choices(dropdown: >k::DropDown, page_size: u64) { - let labels: Vec = PAGE_SIZE_OPTIONS.iter().map(|n| format_thousands(*n)).collect(); +/// The usual sizes, and the tab's own when Preferences or a saved tab +/// set another, so the dropdown always shows the size the tab reads. +/// Gives back the size at each position. +fn page_size_choices(dropdown: >k::DropDown, page_size: u64) -> Vec { + let sizes = page_sizes_with(page_size); + let labels: Vec = sizes.iter().map(|n| format_thousands(*n)).collect(); let strs: Vec<&str> = labels.iter().map(String::as_str).collect(); dropdown.set_model(Some(>k::StringList::new(&strs))); - let initial_idx = PAGE_SIZE_OPTIONS - .iter() - .position(|n| *n == page_size) - .unwrap_or_else(|| { - PAGE_SIZE_OPTIONS - .iter() - .position(|n| *n == DEFAULT_PAGE_SIZE) - .unwrap_or(2) - }); - dropdown.set_selected(u32::try_from(initial_idx).unwrap_or_default()); + let selected = sizes.iter().position(|n| *n == page_size).unwrap_or_default(); + dropdown.set_selected(u32::try_from(selected).unwrap_or_default()); + sizes +} + +fn page_sizes_with(page_size: u64) -> Vec { + let mut sizes = PAGE_SIZE_OPTIONS.to_vec(); + if !sizes.contains(&page_size) { + sizes.push(page_size); + sizes.sort_unstable(); + } + sizes } /// Holds the tab weakly, since the tab owns the control that holds this. @@ -108,7 +113,7 @@ fn sender + 'static>(tab: &BrowseTab, msg: fn() -> I) -> } } -fn connect_controls(tab: &BrowseTab) { +fn connect_controls(tab: &BrowseTab, page_sizes: Vec) { let imp = tab.imp(); for (request, send) in [ (PagerRequest::First, sender(tab, || PagingInput::FirstPage)), @@ -133,7 +138,7 @@ fn connect_controls(tab: &BrowseTab) { let tab = tab.downgrade(); imp.page_size_dropdown.connect_selected_notify(move |dropdown| { let index = usize::try_from(dropdown.selected()).unwrap_or(usize::MAX); - if let (Some(tab), Some(&size)) = (tab.upgrade(), PAGE_SIZE_OPTIONS.get(index)) { + if let (Some(tab), Some(&size)) = (tab.upgrade(), page_sizes.get(index)) { tab.send(PagingInput::PageSizeChanged(size)); } }); @@ -171,3 +176,20 @@ fn collapse_top_bars_with(tab: &BrowseTab, filter: &FilterStrip) { } filter.connect_revealed_notify(move |_| sync()); } + +#[cfg(test)] +mod tests { + use super::*; + + #[gtk4::test] + fn the_dropdown_shows_a_page_size_set_in_preferences() { + let dropdown = gtk::DropDown::default(); + + let sizes = page_size_choices(&dropdown, 2_500); + + assert_eq!(sizes, [100, 500, 1_000, 2_500, 5_000, 10_000]); + assert_eq!(sizes.get(dropdown.selected() as usize), Some(&2_500)); + assert_eq!(page_size_choices(&dropdown, 1_000), PAGE_SIZE_OPTIONS); + assert_eq!(sizes.len(), PAGE_SIZE_OPTIONS.len() + 1); + } +} From b861b7cc31b9279ad013ed8ae3ab055895f1171e Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 17 Oct 2026 16:36:47 +0700 Subject: [PATCH 09/14] fix(linux-browse): fit the bottom bar to the window's minimum width The tab is a breakpoint bin of its own: below 680sp Export, Filter, Count Rows and the page size move into a More menu, the pager label ellipsizes, and the filter strip's buttons wrap. The floor test now measures an open table tab. --- .../app/src/ui/browse_tab/browse_actions.rs | 21 +++- .../src/ui/browse_tab/browse_tab_widgets.rs | 3 + linux/crates/app/src/ui/browse_tab/imp.rs | 6 +- linux/crates/app/src/ui/browse_tab/layout.rs | 32 +++++- linux/crates/app/src/ui/browse_tab/mod.rs | 4 +- .../crates/app/src/ui/browse_tab/pager_bar.rs | 44 +++++++- .../app/src/ui/browse_tab/paging_handlers.rs | 12 ++ .../crates/app/src/ui/browse_tab/row_count.rs | 4 + linux/crates/app/src/ui/workspace/shell.rs | 104 +++++++++++++++++- linux/data/resources/ui/browse-tab.ui | 54 +++++++-- linux/data/resources/ui/filter-strip.ui | 63 ++++++----- linux/data/resources/ui/pager-bar.ui | 1 + 12 files changed, 297 insertions(+), 51 deletions(-) diff --git a/linux/crates/app/src/ui/browse_tab/browse_actions.rs b/linux/crates/app/src/ui/browse_tab/browse_actions.rs index 5bf547a2f7..3540753a2e 100644 --- a/linux/crates/app/src/ui/browse_tab/browse_actions.rs +++ b/linux/crates/app/src/ui/browse_tab/browse_actions.rs @@ -1,7 +1,7 @@ use gtk4 as gtk; -use gtk4::gio; use gtk4::prelude::*; use gtk4::subclass::prelude::*; +use gtk4::{gio, glib}; use super::{ BrowseModel, BrowseTab, BrowseTabInput, ClipboardInput, EditInput, PagingInput, SaveInput, SelectionInput, @@ -24,6 +24,10 @@ pub(super) struct BrowseActions { pub(super) delete_rows: gio::SimpleAction, pub(super) copy_rows: gio::SimpleAction, pub(super) clear_selection: gio::SimpleAction, + /// The More menu's Count Rows, on where the pager offers it. + pub(super) count_rows: gio::SimpleAction, + /// The More menu's page sizes, its state the size the tab reads. + pub(super) page_size: gio::SimpleAction, } impl BrowseActions { @@ -43,7 +47,7 @@ impl BrowseActions { /// The tab's `browse` group, the tab's own keys for it and the keys its /// buttons announce. The grid's keys go on each column view the tab /// builds, through `install_grid_shortcuts`. -pub(super) fn install_browse_actions(tab: &BrowseTab) -> BrowseActions { +pub(super) fn install_browse_actions(tab: &BrowseTab, page_size: u64) -> BrowseActions { let group = gio::SimpleActionGroup::new(); let add = |name: &str, input: fn() -> BrowseTabInput| { let action = gio::SimpleAction::new(name, None); @@ -57,6 +61,16 @@ pub(super) fn install_browse_actions(tab: &BrowseTab) -> BrowseActions { action }; add("filter", || PagingInput::ToggleFilterStrip.into()); + add("export", || ClipboardInput::Export(None).into()); + let page_size = + gio::SimpleAction::new_stateful("page-size", Some(glib::VariantTy::UINT64), &page_size.to_variant()); + let weak = tab.downgrade(); + page_size.connect_change_state(move |_, size| { + if let (Some(tab), Some(size)) = (weak.upgrade(), size.and_then(u64::from_variant)) { + tab.send(PagingInput::PageSizeChanged(size)); + } + }); + group.add_action(&page_size); add("refresh", || PagingInput::RequestRefresh.into()); add("paste-rows", || ClipboardInput::PasteNotSupported.into()); let previous = add("previous-page", || PagingInput::PrevPage.into()); @@ -80,6 +94,8 @@ pub(super) fn install_browse_actions(tab: &BrowseTab) -> BrowseActions { delete_rows, copy_rows: add("copy-rows", || ClipboardInput::CopySelectedRowsAsTsv.into()), clear_selection: add("clear-selection", || SelectionInput::ClearSelection.into()), + count_rows: add("count-rows", || PagingInput::CountRows.into()), + page_size, }; for action in [ &actions.save, @@ -89,6 +105,7 @@ pub(super) fn install_browse_actions(tab: &BrowseTab) -> BrowseActions { &actions.delete_rows, &actions.copy_rows, &actions.clear_selection, + &actions.count_rows, ] { action.set_enabled(false); } diff --git a/linux/crates/app/src/ui/browse_tab/browse_tab_widgets.rs b/linux/crates/app/src/ui/browse_tab/browse_tab_widgets.rs index 4a86af398a..808d02d6c1 100644 --- a/linux/crates/app/src/ui/browse_tab/browse_tab_widgets.rs +++ b/linux/crates/app/src/ui/browse_tab/browse_tab_widgets.rs @@ -22,4 +22,7 @@ pub(super) struct BrowseTabWidgets { pub(super) delete_button: gtk::Button, pub(super) pager_bar: PagerBar, pub(super) pending_bar: PendingChangesBar, + pub(super) page_size_dropdown: gtk::DropDown, + /// The size at each place in the dropdown and the More menu. + pub(super) page_sizes: Vec, } diff --git a/linux/crates/app/src/ui/browse_tab/imp.rs b/linux/crates/app/src/ui/browse_tab/imp.rs index 0695f2f1b3..4346df5147 100644 --- a/linux/crates/app/src/ui/browse_tab/imp.rs +++ b/linux/crates/app/src/ui/browse_tab/imp.rs @@ -45,6 +45,8 @@ pub struct BrowseTab { #[template_child] pub selection_label: TemplateChild, #[template_child] + pub more_button: TemplateChild, + #[template_child] pub export_button: TemplateChild, #[template_child] pub filter_button: TemplateChild, @@ -66,7 +68,7 @@ pub struct BrowseTab { impl ObjectSubclass for BrowseTab { const NAME: &'static str = "TableProBrowseTab"; type Type = super::BrowseTab; - type ParentType = adw::Bin; + type ParentType = adw::BreakpointBin; fn class_init(klass: &mut Self::Class) { PagerBar::ensure_type(); @@ -123,4 +125,4 @@ impl ObjectImpl for BrowseTab { } impl WidgetImpl for BrowseTab {} -impl BinImpl for BrowseTab {} +impl BreakpointBinImpl for BrowseTab {} diff --git a/linux/crates/app/src/ui/browse_tab/layout.rs b/linux/crates/app/src/ui/browse_tab/layout.rs index 67ff7575b6..37397c6c0b 100644 --- a/linux/crates/app/src/ui/browse_tab/layout.rs +++ b/linux/crates/app/src/ui/browse_tab/layout.rs @@ -1,9 +1,9 @@ use std::rc::Rc; use gtk4 as gtk; -use gtk4::glib; use gtk4::prelude::*; use gtk4::subclass::prelude::*; +use gtk4::{gio, glib}; use tablepro_core::FilterSet; use super::browse_tab_widgets::BrowseTabWidgets; @@ -12,6 +12,7 @@ use super::pending_changes_bar::PendingChangesRequest; use super::{ BrowseTab, BrowseTabInput, ClipboardInput, PAGE_SIZE_OPTIONS, PagingInput, SaveInput, loading_description, }; +use crate::i18n::gettext; use crate::ui::filter_strip::FilterStrip; use crate::ui::format::format_thousands; use crate::ui::grid::GridSink; @@ -32,7 +33,8 @@ pub(super) fn build(tab: &BrowseTab, init: &LayoutInit<'_>) -> BrowseTabWidgets imp.inner_stack.set_visible_child_name("loading"); imp.read_only_banner.set_revealed(init.read_only); let page_sizes = page_size_choices(&imp.page_size_dropdown, init.page_size); - connect_controls(tab, page_sizes); + imp.more_button.set_menu_model(Some(&more_menu(&page_sizes))); + connect_controls(tab, page_sizes.clone()); BrowseTabWidgets { inner_stack: imp.inner_stack.get(), grid_holder: imp.grid_holder.get(), @@ -48,9 +50,30 @@ pub(super) fn build(tab: &BrowseTab, init: &LayoutInit<'_>) -> BrowseTabWidgets delete_button: imp.delete_button.get(), pager_bar: imp.pager_bar.get(), pending_bar: imp.pending_bar.get(), + page_size_dropdown: imp.page_size_dropdown.get(), + page_sizes, } } +/// What the bottom bar holds past the tab's breakpoint, where it has no +/// room for it. +fn more_menu(page_sizes: &[u64]) -> gio::Menu { + let actions = gio::Menu::new(); + actions.append(Some(&gettext("Filter Rows")), Some("browse.filter")); + actions.append(Some(&gettext("Export Rows…")), Some("browse.export")); + actions.append(Some(&gettext("Count Rows")), Some("browse.count-rows")); + let sizes = gio::Menu::new(); + for size in page_sizes { + let item = gio::MenuItem::new(Some(&format_thousands(*size)), None); + item.set_action_and_target_value(Some("browse.page-size"), Some(&size.to_variant())); + sizes.append_item(&item); + } + let menu = gio::Menu::new(); + menu.append_section(None, &actions); + menu.append_section(Some(&gettext("Rows per Page")), &sizes); + menu +} + /// An inline editor that slides down above the grid. It lives in the /// tab so the user's in-progress rules survive a click into a cell or /// the SQL editor. @@ -126,7 +149,10 @@ fn connect_controls(tab: &BrowseTab, page_sizes: Vec) { } for (request, send) in [ (PendingChangesRequest::Save, sender(tab, || SaveInput::CommitSave)), - (PendingChangesRequest::Discard, sender(tab, || SaveInput::ConfirmDiscard)), + ( + PendingChangesRequest::Discard, + sender(tab, || SaveInput::ConfirmDiscard), + ), (PendingChangesRequest::StopSaving, sender(tab, || SaveInput::StopSaving)), ] { imp.pending_bar.connect_request(request, move |_| send()); diff --git a/linux/crates/app/src/ui/browse_tab/mod.rs b/linux/crates/app/src/ui/browse_tab/mod.rs index 017c5210cf..63c91f706d 100644 --- a/linux/crates/app/src/ui/browse_tab/mod.rs +++ b/linux/crates/app/src/ui/browse_tab/mod.rs @@ -111,7 +111,7 @@ pub(crate) const BULK_DELETE_CONFIRM_THRESHOLD: usize = 10; glib::wrapper! { pub struct BrowseTab(ObjectSubclass) - @extends adw::Bin, gtk::Widget, + @extends adw::BreakpointBin, gtk::Widget, @implements gtk::Accessible, gtk::Buildable, gtk::ConstraintTarget; } @@ -156,7 +156,7 @@ impl BrowseTab { }, ); let filter_strip = layout::filter_strip(&tab, &initial_filter); - let actions = browse_actions::install_browse_actions(&tab); + let actions = browse_actions::install_browse_actions(&tab, init.page_size); let link_generation = init.session.link_generation(); let dialect = init.session.current_dialect(); let link_changes = init.session.link_changes(); diff --git a/linux/crates/app/src/ui/browse_tab/pager_bar.rs b/linux/crates/app/src/ui/browse_tab/pager_bar.rs index 36c28ccbc3..8d00cf4926 100644 --- a/linux/crates/app/src/ui/browse_tab/pager_bar.rs +++ b/linux/crates/app/src/ui/browse_tab/pager_bar.rs @@ -1,3 +1,4 @@ +use std::cell::Cell; use std::sync::OnceLock; use glib::subclass::Signal; @@ -41,7 +42,8 @@ impl PagerRequest { mod imp { use super::*; - #[derive(Default, gtk::CompositeTemplate)] + #[derive(Default, gtk::CompositeTemplate, glib::Properties)] + #[properties(wrapper_type = super::PagerBar)] #[template(resource = "/app/tablepro/TablePro/ui/pager-bar.ui")] pub struct PagerBar { #[template_child] @@ -60,6 +62,28 @@ mod imp { pub count_spinner: TemplateChild, #[template_child] pub count_label: TemplateChild, + /// Below the tab's breakpoint, Count Rows is in the tab's More + /// menu instead. + #[property(get, set = Self::set_compact)] + compact: Cell, + count_offered: Cell, + } + + impl PagerBar { + fn set_compact(&self, compact: bool) { + self.compact.set(compact); + self.show_count_button(); + } + + pub(super) fn offer_count(&self, offered: bool) { + self.count_offered.set(offered); + self.show_count_button(); + } + + fn show_count_button(&self) { + self.count_button + .set_visible(self.count_offered.get() && !self.compact.get()); + } } #[glib::object_subclass] @@ -77,6 +101,7 @@ mod imp { } } + #[glib::derived_properties] impl ObjectImpl for PagerBar { fn constructed(&self) { self.parent_constructed(); @@ -146,7 +171,7 @@ impl PagerBar { imp.last_button.set_tooltip_text(Some(&last_tooltip)); let counting = matches!(total, RowTotal::Counting { .. }); - imp.count_button.set_visible(shown.is_some() && total.exact().is_none()); + imp.offer_count(shown.is_some() && total.exact().is_none()); imp.count_button.set_sensitive(!counting); imp.count_spinner.set_visible(counting); let (label, tooltip) = match total { @@ -323,6 +348,21 @@ mod tests { assert_eq!(counts.count(), 1); } + #[gtk4::test] + fn a_compact_bar_leaves_count_rows_to_the_menu() { + let bar = bar(); + let count = bar.imp().count_button.get(); + bar.show_total(&BrowsePager::new(100, 0), Some(&page(0, 100)), &RowTotal::Unknown); + + bar.set_compact(true); + assert!(!count.is_visible()); + bar.show_total(&BrowsePager::new(100, 0), Some(&page(0, 100)), &RowTotal::Unknown); + assert!(!count.is_visible(), "a new total put Count Rows back on a compact bar"); + + bar.set_compact(false); + assert!(count.is_visible()); + } + #[gtk4::test] fn the_range_label_waits_for_a_page_on_screen() { let bar = bar(); diff --git a/linux/crates/app/src/ui/browse_tab/paging_handlers.rs b/linux/crates/app/src/ui/browse_tab/paging_handlers.rs index d20d1b380e..c7f9bee9a4 100644 --- a/linux/crates/app/src/ui/browse_tab/paging_handlers.rs +++ b/linux/crates/app/src/ui/browse_tab/paging_handlers.rs @@ -23,6 +23,7 @@ impl BrowseModel { return; } self.pager.resize(size); + self.show_page_size(size); self.fetch(BrowseRequestKind::Page, fx); fx.output(BrowseTabOutput::StateChanged); } @@ -93,6 +94,17 @@ impl BrowseModel { fx.output(BrowseTabOutput::StateChanged); } + /// The dropdown and the More menu each pick a size, so each shows + /// the one the other picked. + fn show_page_size(&self, size: u64) { + if let Some(position) = self.widgets.page_sizes.iter().position(|n| *n == size) { + self.widgets + .page_size_dropdown + .set_selected(u32::try_from(position).unwrap_or_default()); + } + self.actions.page_size.set_state(&size.to_variant()); + } + pub(super) fn refresh_filter_chrome(&self) { let n = self.current_filter.len(); if n == 0 { diff --git a/linux/crates/app/src/ui/browse_tab/row_count.rs b/linux/crates/app/src/ui/browse_tab/row_count.rs index acc22eb584..589b8d2a6d 100644 --- a/linux/crates/app/src/ui/browse_tab/row_count.rs +++ b/linux/crates/app/src/ui/browse_tab/row_count.rs @@ -146,5 +146,9 @@ impl BrowseModel { self.widgets .pager_bar .show_total(&self.pager, self.shown_page(), &self.total); + let counting = matches!(self.total, RowTotal::Counting { .. }); + self.actions + .count_rows + .set_enabled(self.shown_page().is_some() && self.total.exact().is_none() && !counting); } } diff --git a/linux/crates/app/src/ui/workspace/shell.rs b/linux/crates/app/src/ui/workspace/shell.rs index ef8d8d70d0..86d8414006 100644 --- a/linux/crates/app/src/ui/workspace/shell.rs +++ b/linux/crates/app/src/ui/workspace/shell.rs @@ -119,7 +119,7 @@ fn follow_sidebar(split_view: &adw::OverlaySplitView, toggle: >k::ToggleButton } #[cfg(test)] -mod tests { +pub(super) mod tests { use std::time::Duration; use libadwaita::prelude::*; @@ -346,6 +346,108 @@ mod tests { fits_the_floor(&content()); } + /// A server with one keyed table, whose every page is the same + /// thousand rows. + pub(in crate::ui::workspace) fn served_table() -> tablepro_test_support::ConnectionScript { + use tablepro_core::column::{ColumnDefault, ColumnType}; + use tablepro_core::meta::{RowCountEstimate, RowIdentity, TableEstimate}; + use tablepro_core::{ColumnInfo, QueryResult, Value}; + use tablepro_test_support::FakeReply; + let column = |name: &str, primary_key: bool| ColumnInfo { + name: name.to_owned(), + column_type: ColumnType::untyped_text("text"), + nullable: !primary_key, + primary_key, + is_auto_increment: false, + is_generated: false, + default: ColumnDefault::None, + comment: None, + }; + let columns = vec![column("id", true), column("name", false)]; + let page = QueryResult::new( + columns.iter().map(ColumnInfo::result_column).collect::>(), + (0..1_000) + .map(|id| vec![Value::Int(id), Value::Text(format!("customer {id}"))]) + .collect(), + ); + let mut script = tablepro_test_support::ConnectionScript::default(); + script.fetch_columns.always(FakeReply::ok(columns)); + script.row_identity.always(FakeReply::ok(RowIdentity::UniqueKey { + columns: vec!["id".to_owned()], + })); + // Too many rows to count unasked, so Count Rows stays on the bar. + script.table_estimate.always(FakeReply::ok(TableEstimate { + rows: RowCountEstimate::Approximate(48_213_000), + heap_blocks: None, + })); + script.query_bound.always(FakeReply::ok(page)); + script + } + + /// The floor measured on the tab's own content, with its widest + /// bars up: the pager's longest label, pending changes and the + /// filter strip. + #[gtk4::test] + fn an_open_table_tab_fits_the_window_floor() { + use crate::ui::browse_tab::{BrowseTab, EditInput, PagingInput}; + let test = TestWindow::with_script( + "TableFloor", + Script { + connection: served_table(), + window_size: Some((360, 294)), + ..Script::default() + }, + ); + test.window + .send(SessionWindowInput::OpenSaved(Box::new(test.saved.clone()))); + test.wait_for_page("workspace"); + let workspace = test.workspace(); + let page = workspace + .with_model(|model| { + model.open_tab(super::super::tab_spec::TabSpec::Table { + table: tablepro_core::meta::TableRef::new(Some("public".to_owned()), "customers"), + page: super::super::tab_spec::TablePage::first(1_000), + }) + }) + .flatten() + .expect("a table tab"); + let tab = page.child().downcast::().expect("a browse tab"); + let wait = Duration::from_secs(10); + wait_until(wait, || { + descendants(&tab).iter().any(|widget| widget.is::()) + }) + .expect("the tab's rows"); + tab.send(EditInput::InsertRow); + let content = + crate::test_support::first_descendant_of_type::(&tab).expect("the tab's toolbar view"); + let needs = || { + let (width, ..) = content.measure(gtk::Orientation::Horizontal, -1); + if width > tab.width() { + return (width, i32::MAX); + } + (width, content.measure(gtk::Orientation::Vertical, tab.width()).0) + }; + + let fits = wait_until(wait, || { + let (width, height) = needs(); + tab.width() > 0 && width <= 360 && height <= tab.height() + }); + assert!( + fits.is_ok(), + "an open table tab needs {:?} px, the tab has {}x{}", + needs(), + tab.width(), + tab.height() + ); + + // Measured as it opens: at the floor's height the strip and the + // grid cannot both have room, so the strip is checked for width. + tab.send(PagingInput::ToggleFilterStrip); + let (with_filter, ..) = content.measure(gtk::Orientation::Horizontal, -1); + tab.send(PagingInput::ToggleFilterStrip); + assert!(with_filter <= 360, "the filter strip needs {with_filter} px"); + } + #[gtk4::test] fn overview_new_tab_uses_tab_factory() { let test = TestWindow::connected("OverviewNewTab"); diff --git a/linux/data/resources/ui/browse-tab.ui b/linux/data/resources/ui/browse-tab.ui index 6807b0e071..016728b6ef 100644 --- a/linux/data/resources/ui/browse-tab.ui +++ b/linux/data/resources/ui/browse-tab.ui @@ -2,7 +2,22 @@ -