Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions linux/ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,12 +43,13 @@ The app runs on GNOME 50 ([ADR 0015](docs/decisions/0015-gnome-50-platform-floor
| Capability | Detail |
|---|---|
| Data grid | A virtualized `GtkColumnView` with keyboard-focusable cells, pages of 100 to 10,000 rows, and a pager with an exact or estimated row count; a table of about 100,000 rows or fewer is counted on its own, a larger one offers Count Rows |
| Filters | Per-column operators, a raw WHERE fragment checked as one expression, match any or all, kept per table |
| Filters | Per-column operators, a raw WHERE fragment checked as one expression, match any or all; binary columns compare to `0x` hex. Kept per table, and per tab across a restart or a reopen |
| Sorting | A header click sorts tables on the server and query results in the grid, one column at a time |
| Cell editing | Editors by column kind: inline text, a multi-line editor, a calendar for dates, a toggle for booleans, JSON checked before saving; values are edited from their exact text, never from what the cell displays. Table tabs only, on tables with a primary key; key, generated and auto-increment columns, and binary, spatial, array and composite values, are read-only |
| Rows | Insert, duplicate and delete; Set NULL and Empty, and DEFAULT on a new row; undo and redo; save or discard pending changes, and stop a save. On ClickHouse a save is not atomic, and Stop takes effect between statements |
| Bulk delete | Deleting 10 rows or more asks first, unless Confirm Before Deleting Rows is off in Preferences |
| Copy | Copy; Copy As rows, with headers, JSON, CSV with or without headers, Markdown or IN clause, and INSERT in an editable table tab; Copy Column Name; Show Row as JSON |
| Foreign keys | Open Referenced Row (Ctrl+J) on a key cell opens the table it points at in a new tab, filtered on every column of the key; Show Referencing Rows on a saved row opens a table whose key points at it. A tab already showing those rows comes to the front |
| Column widths | Kept per table; Widen Column and Narrow Column in the cell menu (Shift+Alt+Right and Shift+Alt+Left) change them from the keyboard |
| CSV import | Into an existing table, with a column mapping, in one write batch (one transaction where the engine has them), up to 64 MiB |
| Sidebar | Tables (partitioned ones included), views, materialized views and foreign tables by schema, with search and Refresh, read again after the editor runs DDL, Open in New Tab and Show CREATE TABLE; Edit Structure, Import CSV and Drop Table for tables |
Expand Down Expand Up @@ -134,7 +135,6 @@ Not built yet, in no order.
- Save the column order per table.
- Functions, procedures, sequences and triggers in the sidebar. The drivers already list the first three.
- The CREATE INDEX statement for one index. Show CREATE TABLE lists them after the table on PostgreSQL, SQL Server and SQLite (with the triggers on SQLite).
- Jump from a foreign key to the table it references.
- Reorder columns in the structure tab by dragging, with the ALTER previewed.
- An ER diagram.

Expand Down
1 change: 1 addition & 0 deletions linux/changelog.d/3797.added.1.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Filters compare a binary column to a 0x hex value.
1 change: 1 addition & 0 deletions linux/changelog.d/3797.added.2.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
A filter on a timestamp column takes a date as its midnight, and a date column takes a midnight.
1 change: 1 addition & 0 deletions linux/changelog.d/3797.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Open Referenced Row (Ctrl+J) and Show Referencing Rows in the cell menu open the rows a foreign key leads to.
1 change: 1 addition & 0 deletions linux/changelog.d/3797.fixed.1.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
On SQLite, a foreign key naming no columns of a missing table kept the table's structure tab from opening.
1 change: 1 addition & 0 deletions linux/changelog.d/3797.fixed.2.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Two tabs on one table came back with the same filter after a restart or Reopen Closed Tab.
1 change: 1 addition & 0 deletions linux/changelog.d/3797.fixed.3.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
On SQL Server, an equals, not equals or in filter on a text or ntext column failed.
1 change: 1 addition & 0 deletions linux/changelog.d/3797.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
On PostgreSQL, the structure tab listed each partition's copy of a foreign key as a key that could not be dropped.
1 change: 1 addition & 0 deletions linux/crates/app/src/application/accelerators.rs
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,7 @@ pub const ACCELERATORS: &[Accelerator] = &[
component(GridCell, "cell.toggle", &["space"], Listed(DataGrid)),
component(GridCell, "cell.menu", &["Menu", "<Shift>F10"], Listed(DataGrid)),
component(GridCell, "cell.set-null", &["<Primary>BackSpace"], Listed(DataGrid)),
component(GridCell, "cell.open-referenced-row", &["<Primary>j"], Listed(DataGrid)),
// GtkTreeView's keys for its focused header. A column view's header
// takes no focus, so they act on the focused cell's column.
component(GridCell, "cell.widen-column", &["<Shift><Alt>Right"], Listed(DataGrid)),
Expand Down
2 changes: 2 additions & 0 deletions linux/crates/app/src/ui/browse_tab/browse_command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ use tablepro_session::OperationError;

use super::page_reply::PageReply;
use crate::runtime::Stamped;
use crate::ui::grid::TableKeys;
use crate::ui::tab_load::LoadFailure;

/// The table's columns from the session's catalog, and how its rows are
Expand All @@ -24,4 +25,5 @@ pub(crate) enum BrowseCommand {
Page(Stamped<Result<PageReply, OperationError>>),
Estimate(Stamped<Result<TableEstimate, OperationError>>),
Count(Stamped<Result<u64, OperationError>>),
Keys(Stamped<Result<TableKeys, OperationError>>),
}
3 changes: 3 additions & 0 deletions linux/crates/app/src/ui/browse_tab/browse_request_kind.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,7 @@ pub(crate) enum BrowseRequestKind {
Estimate,
/// The exact total, which can scan the whole table.
Count,
/// The table's foreign keys both ways, which the grid follows. Read
/// with each read of the columns, and never in the way of the rows.
Keys,
}
1 change: 1 addition & 0 deletions linux/crates/app/src/ui/browse_tab/browse_tab_input.rs
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ impl From<GridMsg> for BrowseTabInput {
GridMsg::DeleteRows { rows } => EditInput::DeleteRows(rows).into(),
GridMsg::InsertRow => EditInput::InsertRow.into(),
GridMsg::DuplicateRows { rows } => EditInput::DuplicateRows(rows).into(),
GridMsg::FollowReference(reference) => ClipboardInput::GridFollowReference(reference).into(),
}
}
}
1 change: 1 addition & 0 deletions linux/crates/app/src/ui/browse_tab/clipboard_handlers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ impl BrowseModel {
}
ClipboardInput::GridShowRowJson(request) => fx.output(BrowseTabOutput::ShowRowJson(request)),
ClipboardInput::GridShowToast(message) => fx.output(BrowseTabOutput::ShowToast(message)),
ClipboardInput::GridFollowReference(reference) => fx.output(BrowseTabOutput::FollowReference(reference)),
}
}

Expand Down
3 changes: 3 additions & 0 deletions linux/crates/app/src/ui/browse_tab/clipboard_input.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
use tablepro_core::browse::RowReference;

use crate::ui::clipboard::{CopyRequest, InClauseRows};
use crate::ui::export::ExportSource;
use crate::ui::grid::{RowJsonRequest, RowObject};
Expand All @@ -16,4 +18,5 @@ pub(crate) enum ClipboardInput {
PasteNotSupported,
GridShowRowJson(RowJsonRequest),
GridShowToast(String),
GridFollowReference(RowReference),
}
21 changes: 21 additions & 0 deletions linux/crates/app/src/ui/browse_tab/command_handlers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ impl BrowseModel {
}
self.identity = Some(reply.identity);
self.on_columns_loaded(reply.columns.to_vec(), fx);
// A table whose columns changed may have other
// keys too.
self.after_columns.insert(BrowseRequestKind::Keys);
for kind in std::mem::take(&mut self.after_columns) {
self.fetch(kind, fx);
}
Expand Down Expand Up @@ -65,6 +68,24 @@ impl BrowseModel {
self.retry_if_link_moved(BrowseRequestKind::Estimate, fx);
}
}
// The keys only add to the menus, so a failure leaves the tab
// as it is, with the keys it had, and is asked again on the
// next connection.
BrowseCommand::Keys(Stamped { stamp, value }) => {
if !self.sequencer.accept(BrowseRequestKind::Keys, stamp) {
return;
}
self.failed.record(BrowseRequestKind::Keys, &value);
match value {
Ok(keys) => self.keys.set(keys),
Err(error) => {
if error != OperationError::Cancelled {
tracing::warn!(category = ?error.category(), "browse foreign keys failed");
}
self.retry_if_link_moved(BrowseRequestKind::Keys, fx);
}
}
}
// A failed count is not asked again on a reconnect: Try Again
// runs it, so a count that ran out of time does not run for
// another deadline, or under a filter applied since.
Expand Down
28 changes: 28 additions & 0 deletions linux/crates/app/src/ui/browse_tab/fetch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ use super::columns_fetch::{ColumnFreshness, load_columns};
use super::{BrowseCommand, BrowseModel, BrowseRequestKind, ColumnsReply, DataInput, Effects, PageReply};
use crate::runtime::{RequestTicket, Stamped};
use crate::ui::export::TableExport;
use crate::ui::grid::TableKeys;
use crate::ui::tab_load::LoadFailure;

/// What a page or a count reads, copied off the tab so the work owns
Expand Down Expand Up @@ -43,9 +44,36 @@ impl BrowseModel {
BrowseRequestKind::Page => self.fetch_page(fx),
BrowseRequestKind::Estimate => self.fetch_estimate(),
BrowseRequestKind::Count => self.start_count(self.total.estimate(), fx),
BrowseRequestKind::Keys => self.fetch_keys(),
}
}

/// Read the table's keys both ways in one operation, so a reconnect
/// between them cannot pair keys of two connections.
fn fetch_keys(&mut self) {
let ticket = self.issue(BrowseRequestKind::Keys);
let session = self.session.clone();
let table = self.table_ref();
let cancel = ticket.cancel.clone();
let work = async move {
session
.run_cancellable(OperationKind::Metadata, &cancel, move |lease, options| {
let table = table.clone();
async move {
let outbound = lease.fetch_foreign_keys(&table, options.clone()).await?;
let inbound = lease.fetch_inbound_foreign_keys(&table, options).await?;
Ok(TableKeys {
table,
outbound,
inbound,
})
}
})
.await
};
self.deliver(ticket, work, BrowseCommand::Keys, OperationError::from);
}

/// Read the columns, then the page built from them and the table's
/// estimate. No page is asked for before the columns land, since a
/// page is built from them, and a failure to read them fails the read
Expand Down
3 changes: 3 additions & 0 deletions linux/crates/app/src/ui/browse_tab/imp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,9 @@ impl ObjectImpl for BrowseTab {
.build(),
Signal::builder("stop-save-requested").build(),
Signal::builder("refresh-requested").build(),
Signal::builder("follow-reference")
.param_types([glib::BoxedAnyObject::static_type()])
.build(),
]
})
}
Expand Down
52 changes: 43 additions & 9 deletions linux/crates/app/src/ui/browse_tab/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ mod save_input;
mod selection_handlers;
mod selection_input;
mod selection_snapshot;
mod table_filter;
#[cfg(test)]
mod tests;

Expand All @@ -58,9 +59,10 @@ use gtk4::glib;
use gtk4::prelude::*;
use gtk4::subclass::prelude::*;
use libadwaita as adw;
use tablepro_core::ColumnInfo;
use tablepro_core::browse::RowReference;
use tablepro_core::dialect::SqlDialect;
use tablepro_core::meta::{RowIdentity, TableRef};
use tablepro_core::{ColumnInfo, FilterSet};
use tablepro_session::operation::DeadlinePolicy;
use tablepro_session::runtime::{Tasks, WatchForwarder};
use tablepro_session::{LinkGeneration, Session};
Expand Down Expand Up @@ -88,9 +90,10 @@ pub(crate) use self::row_total::RowTotal;
pub(crate) use self::save_input::SaveInput;
pub(crate) use self::selection_input::SelectionInput;
use self::selection_snapshot::SelectionSnapshot;
pub(crate) use self::table_filter::TableFilter;
use super::filter_strip::FilterStrip;
use super::grid::{
ColumnWidthBinding, GridRowIndex, GridSink, RowJsonRequest, RowObject, TabGridContext, present_row_json,
ColumnWidthBinding, GridRowIndex, GridSink, KeysHandle, RowJsonRequest, RowObject, TabGridContext, present_row_json,
};
use super::tab_load::{ContentLoad, FirstLoad, start_first_load};
use crate::runtime::{FailedRequests, RequestSequencer};
Expand Down Expand Up @@ -120,6 +123,7 @@ pub struct BrowseTabInit {
pub page_size: u64,
pub initial_offset: u64,
pub initial_sort: Option<(usize, bool)>,
pub filter: TableFilter,
pub column_widths: crate::persistence::ColumnWidthStore,
pub filter_settings: crate::persistence::FilterSettingsStore,
/// The session the tab reads through. The tab belongs to it and is
Expand All @@ -137,13 +141,12 @@ impl BrowseTab {
let tab: Self = glib::Object::new();
let imp = tab.imp();

// Restore the saved filter for this (connection, schema, table)
// up front so both the model and the inline strip start with the
// same FilterSet.
let initial_filter = init
.connection_id
.map(|id| init.filter_settings.filters(id, init.schema.as_deref(), &init.table))
.unwrap_or_default();
// The model and the inline strip start with the same FilterSet.
let (initial_filter, reveal_filter) = init.filter.opening(|| {
init.connection_id
.map(|id| init.filter_settings.filters(id, init.schema.as_deref(), &init.table))
.unwrap_or_default()
});
let widgets = layout::build(
&tab,
&layout::LayoutInit {
Expand All @@ -153,10 +156,14 @@ impl BrowseTab {
},
);
let filter_strip = layout::filter_strip(&tab, &initial_filter);
if reveal_filter {
filter_strip.set_revealed(true);
}
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();
let keys = KeysHandle::new(TableRef::new(init.schema.clone(), init.table.clone()));
let model = BrowseModel {
this: tab.downgrade(),
session: init.session,
Expand All @@ -169,6 +176,7 @@ impl BrowseTab {
link_generation,
identity: None,
dialect,
keys,
tracker: init.tracker,
schema: init.schema,
table: init.table,
Expand Down Expand Up @@ -312,6 +320,12 @@ impl BrowseTab {
self.read(|model| model.current_sort).flatten()
}

/// The filter the rows on screen were read under, not what the strip
/// holds before Apply.
pub fn applied_filter(&self) -> FilterSet {
self.read(|model| model.current_filter.clone()).unwrap_or_default()
}

/// A read of the tab's rows is running.
pub fn is_loading(&self) -> bool {
self.read(|model| model.tab_loading).unwrap_or(false)
Expand All @@ -331,6 +345,17 @@ impl BrowseTab {
)
}

/// The grid asked to open the rows a foreign key leads to.
pub(crate) fn connect_follow_reference<F: Fn(&Self, RowReference) + 'static>(&self, f: F) -> glib::SignalHandlerId {
self.connect_closure(
"follow-reference",
false,
glib::closure_local!(move |tab: &Self, payload: glib::BoxedAnyObject| {
f(tab, payload.borrow::<RowReference>().clone());
}),
)
}

pub fn connect_alert<F: Fn(&Self, String, String) + 'static>(&self, f: F) -> glib::SignalHandlerId {
self.connect_closure(
"alert",
Expand Down Expand Up @@ -412,6 +437,9 @@ impl BrowseTab {
BrowseTabOutput::StopSaveRequested => self.emit_by_name::<()>("stop-save-requested", &[]),
BrowseTabOutput::LoadingChanged(loading) => self.emit_by_name::<()>("loading-changed", &[&loading]),
BrowseTabOutput::RefreshRequested => self.emit_by_name::<()>("refresh-requested", &[]),
BrowseTabOutput::FollowReference(reference) => {
self.emit_by_name::<()>("follow-reference", &[&glib::BoxedAnyObject::new(reference)]);
}
}
}

Expand Down Expand Up @@ -480,6 +508,9 @@ pub(crate) struct BrowseModel {
/// The current connection's dialect, which says whether a filter can
/// be written for it. Read again after every reconnect.
dialect: Option<Arc<dyn SqlDialect>>,
/// The table's foreign keys, which every grid the tab builds reads
/// as its menu opens.
keys: KeysHandle,

tracker: TabTrackerHandle,
schema: Option<String>,
Expand Down Expand Up @@ -564,6 +595,8 @@ pub(crate) enum BrowseTabOutput {
LoadingChanged(bool),
/// F5 asked the window to read the tab again.
RefreshRequested,
/// The grid asked to open the rows a foreign key leads to.
FollowReference(RowReference),
}

impl BrowseModel {
Expand Down Expand Up @@ -596,6 +629,7 @@ impl BrowseModel {
schema: self.schema.clone(),
table: self.table.clone(),
}),
keys: Some(self.keys.clone()),
}
}

Expand Down
25 changes: 25 additions & 0 deletions linux/crates/app/src/ui/browse_tab/table_filter.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
use tablepro_core::FilterSet;

/// The filter a table tab opens under.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TableFilter {
/// The one last applied to the table, as the sidebar opens it.
Remembered,
/// The tab's own, as it was when the tab was saved or closed.
Kept(FilterSet),
/// A foreign key's, which the tab opens with its filter strip shown.
Followed(FilterSet),
}

impl TableFilter {
/// The set the tab opens under and whether its strip shows it. Only
/// a tab on the table's remembered filter reads it, so one with its
/// own leaves the table's alone until the user applies another.
pub(crate) fn opening(self, remembered: impl FnOnce() -> FilterSet) -> (FilterSet, bool) {
match self {
Self::Remembered => (remembered(), false),
Self::Kept(set) => (set, false),
Self::Followed(set) => (set, true),
}
}
}
8 changes: 4 additions & 4 deletions linux/crates/app/src/ui/browse_tab/tests/load.rs
Original file line number Diff line number Diff line change
Expand Up @@ -559,10 +559,10 @@ fn the_loading_page_names_a_table_with_an_ampersand() {
&fake.session,
&runtime,
&stores,
"R&D",
Default::default(),
Default::default(),
false,
super::Opening {
table: "R&D",
..super::Opening::users()
},
);
let shown = || crate::test_support::status_page_description(&tab.imp().loading_page);

Expand Down
Loading
Loading