Skip to content

Commit 19828f0

Browse files
committed
refactor(linux): filter goes inline + raw SQL escape hatch
UX shape change: AdwDialog → inline GtkRevealer slid in above the grid. The user filters data they can see; obscuring the grid with a modal added a round-trip every rule tweak. Strip stays open while they edit, applies on demand, collapses with Esc / Close (×). Matches GtkSearchBar's slide-in pattern (the native idiom for "transient editor above content"), not AdwDialog's "form-with-validate-then-apply" flow. Layout (replaces the dialog): Filter rows Clear all │ Apply │ × Combine rules with: [ All ▾ ] ┌─ boxed-list ──────────────────────────────────────┐ │ [Column ▾] [Op ▾] [Value …] [✕] │ │ [+ Add rule] │ └────────────────────────────────────────────────────┘ ▸ Advanced (raw SQL) Module rename: filter_dialog.rs → filter_strip.rs. Public surface swaps `present(parent, ...)` for `build(columns, initial, on_apply) -> FilterStrip` returning a holder with the revealer + state + methods to toggle / update_columns / update_filter so BrowseTab can react to ColumnsLoaded and external FilterApplied without rebuilding from scratch. Raw SQL escape hatch: - FilterSet gains extra_sql: Option<String>. Appended to the structured rules with the chosen combinator, wrapped in parens so user OR / NOT precedence stays scoped to their fragment. - Emitted verbatim — no quoting, no parameterisation. Same security model as the SQL editor: the user owns the connection. Power feature, not an injection vector. - Whitespace-only extra_sql treated as None so blank entries don't emit `WHERE ()`. - 5 new core tests covering extra_sql alone / combined with rules / with OR combinator / blank handling / is_empty + len semantics. BrowseTab integration: - Owns Option<FilterStrip>. Built once at init, added as a top bar above grid_search_bar. - ToggleFilterStrip input wired to win.open-filter / Ctrl+R. - FilterApplied still persists + refetches; now also calls strip.update_filter(set) so the editor reflects the committed state. - ColumnsLoaded calls strip.update_columns so operator allowlists re-narrow on schema changes. - Esc inside the strip collapses without applying (Local-scope shortcut on the strip's outer Box; doesn't compete with cell-editor / search-bar Esc handlers). Test counts: app 92 (unchanged), core 108 (was 103, +5 extra_sql). Clippy clean.
1 parent 0109ffb commit 19828f0

6 files changed

Lines changed: 370 additions & 111 deletions

File tree

linux/crates/app/src/services/filter_settings.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,7 @@ mod tests {
117117
op: FilterOp::Eq,
118118
value: Some(FilterValue::Single("alice".into())),
119119
}],
120+
extra_sql: None,
120121
}
121122
}
122123

linux/crates/app/src/ui/app/browse.rs

Lines changed: 4 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -327,39 +327,15 @@ impl App {
327327
}
328328
}
329329

330-
/// Ctrl+R / Filter button — present the filter editor for the
331-
/// active Browse tab. Hands the dialog the tab's columns +
332-
/// current FilterSet; the dialog calls back via `on_apply` with
333-
/// either the new set or `FilterSet::default()` for "Clear all".
334-
/// Either way it routes through `BrowseTabInput::FilterApplied`
335-
/// so persistence + chrome update + refetch all run in one place.
330+
/// Ctrl+R / Filter button — toggle the inline filter strip on
331+
/// the active Browse tab. Strip lives inside the tab (always
332+
/// constructed at init), so this is just a reveal flip.
336333
pub(super) fn on_show_filter_dialog(&self) {
337334
let Some(id) = self.selected_browse_tab_id() else {
338335
self.show_toast(&crate::tr!("Open a table to filter rows."));
339336
return;
340337
};
341-
let (columns, current_filter) = {
342-
let tabs = self.workspace_tabs.borrow();
343-
let Some(controller) = tabs.get(&id).and_then(|t| t.browse_controller()) else {
344-
return;
345-
};
346-
let model = controller.model();
347-
(model.columns().to_vec(), model.current_filter().clone())
348-
};
349-
if columns.is_empty() {
350-
// ColumnsLoaded hasn't fired yet — opening the dialog with
351-
// no columns would just show an empty Add Rule list.
352-
self.show_toast(&crate::tr!("Loading columns… try again in a moment."));
353-
return;
354-
}
355-
let tab_id = id;
356-
let workspace_tabs = self.workspace_tabs.clone();
357-
let on_apply: std::rc::Rc<dyn Fn(tablepro_core::FilterSet)> = std::rc::Rc::new(move |set| {
358-
if let Some(controller) = workspace_tabs.borrow().get(&tab_id).and_then(|t| t.browse_controller()) {
359-
let _ = controller.sender().send(BrowseTabInput::FilterApplied(set));
360-
}
361-
});
362-
crate::ui::filter_dialog::present(&self.window, columns, current_filter, on_apply);
338+
self.dispatch_to_tab(id, BrowseTabInput::ToggleFilterStrip);
363339
}
364340

365341
pub(super) fn on_refresh_active_tab(&self) {

linux/crates/app/src/ui/browse_tab.rs

Lines changed: 49 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -98,11 +98,15 @@ pub struct BrowseTab {
9898
prev_button: gtk::Button,
9999
next_button: gtk::Button,
100100
last_button: gtk::Button,
101-
/// Toolbar button that opens the filter dialog. Carries an
101+
/// Toolbar button that toggles the filter strip. Carries an
102102
/// `.accent` CSS class + tooltip suffix when the current filter
103103
/// has any rules; visually flat otherwise so the user spots an
104104
/// active filter at a glance.
105105
filter_button: gtk::Button,
106+
/// Inline filter editor — slides in above the grid when
107+
/// revealed. Owned per-tab so the rules editor doesn't lose
108+
/// in-progress state if the user accidentally clicks outside it.
109+
filter_strip: Option<crate::ui::filter_strip::FilterStrip>,
106110
insert_button: gtk::Button,
107111
delete_button: gtk::Button,
108112
save_button: gtk::Button,
@@ -142,10 +146,13 @@ pub enum BrowseTabInput {
142146
/// selections are intentionally preserved — unselecting the
143147
/// only-row would strand the keyboard focus indicator.
144148
ClearSelection,
145-
/// User confirmed a new filter set in the filter dialog (or
146-
/// hit "Clear all" — that's an empty FilterSet). BrowseTab
147-
/// persists, resets pagination to offset 0, refreshes chrome,
148-
/// and re-fetches.
149+
/// Toggle the inline filter strip's reveal state. Wired to the
150+
/// Filter button + Ctrl+R action.
151+
ToggleFilterStrip,
152+
/// User confirmed a new filter set in the filter strip (or hit
153+
/// "Clear all" — that's an empty FilterSet). BrowseTab persists,
154+
/// resets pagination to offset 0, refreshes chrome, and
155+
/// re-fetches.
149156
FilterApplied(tablepro_core::FilterSet),
150157
/// User clicked First page (offset → 0).
151158
FirstPage,
@@ -1262,6 +1269,14 @@ impl SimpleComponent for BrowseTab {
12621269
// removed. Idempotent — calling twice is a no-op.
12631270
crate::services::change_tracker::open_tab(init.tab_id);
12641271

1272+
// Restore the saved filter for this (connection, schema,
1273+
// table) up front so both the model field and the inline
1274+
// strip start with the same FilterSet.
1275+
let initial_filter = init
1276+
.connection_id
1277+
.map(|id| crate::services::filter_settings::load(id, init.schema.as_deref(), &init.table))
1278+
.unwrap_or_default();
1279+
12651280
let suppress_combo_emit = Rc::new(std::cell::Cell::new(true));
12661281
let grid_holder = gtk::Box::builder()
12671282
.orientation(gtk::Orientation::Vertical)
@@ -1327,6 +1342,17 @@ impl SimpleComponent for BrowseTab {
13271342

13281343
root.add_top_bar(&read_only_banner);
13291344
root.add_top_bar(&no_pk_banner);
1345+
// Filter strip — inline editor that slides down above the
1346+
// grid when revealed. Ownership stays inside this BrowseTab
1347+
// so the user's in-progress rule edits survive a click into
1348+
// a cell or the SQL editor.
1349+
let filter_set_for_strip = initial_filter.clone();
1350+
let sender_for_strip = sender.clone();
1351+
let on_apply_filter: std::rc::Rc<dyn Fn(tablepro_core::FilterSet)> = std::rc::Rc::new(move |set| {
1352+
sender_for_strip.input(BrowseTabInput::FilterApplied(set));
1353+
});
1354+
let filter_strip = crate::ui::filter_strip::build(Vec::new(), filter_set_for_strip, on_apply_filter);
1355+
root.add_top_bar(&filter_strip.widget);
13301356
root.add_top_bar(&grid_search_bar);
13311357
root.set_content(Some(&inner_stack));
13321358
// Mutations bar sits below the paginator (call order = stack
@@ -1558,15 +1584,6 @@ impl SimpleComponent for BrowseTab {
15581584
GridMsg::DuplicateRow { row_position } => BrowseTabInput::DuplicateRow { row_position },
15591585
}));
15601586

1561-
// Restore the saved filter for this (connection, schema,
1562-
// table) before moving init.schema / init.table into the
1563-
// struct. If none was saved, FilterSet::default() means
1564-
// "no WHERE clause" which is the same as today's path.
1565-
let initial_filter = init
1566-
.connection_id
1567-
.map(|id| crate::services::filter_settings::load(id, init.schema.as_deref(), &init.table))
1568-
.unwrap_or_default();
1569-
15701587
let model = BrowseTab {
15711588
tab_id: init.tab_id,
15721589
schema: init.schema,
@@ -1600,6 +1617,7 @@ impl SimpleComponent for BrowseTab {
16001617
next_button: paginator.next_button,
16011618
last_button: paginator.last_button,
16021619
filter_button: paginator.filter_button,
1620+
filter_strip: Some(filter_strip),
16031621
insert_button: mutations.insert_button,
16041622
delete_button: mutations.delete_button,
16051623
save_button: mutations.save_button,
@@ -1659,8 +1677,15 @@ impl SimpleComponent for BrowseTab {
16591677
}
16601678
BrowseTabInput::ColumnsLoaded(columns) => {
16611679
let words: Vec<String> = columns.iter().map(|c| c.name.clone()).collect();
1662-
self.current_columns = columns;
1680+
self.current_columns = columns.clone();
16631681
self.refresh_crud_buttons();
1682+
// Filter strip rebuilds against the new schema —
1683+
// operator allowlists narrow per type, so a column
1684+
// that switched from text to int needs its operator
1685+
// dropdown refreshed.
1686+
if let Some(strip) = self.filter_strip.as_ref() {
1687+
strip.update_columns(columns);
1688+
}
16641689
let _ = sender.output(BrowseTabOutput::SchemaWordsChanged(words));
16651690
// If rows are already cached, render now with the proper
16661691
// editability map. Otherwise wait for RowsLoaded.
@@ -1729,6 +1754,11 @@ impl SimpleComponent for BrowseTab {
17291754
}
17301755
sel.unselect_all();
17311756
}
1757+
BrowseTabInput::ToggleFilterStrip => {
1758+
if let Some(strip) = self.filter_strip.as_ref() {
1759+
strip.toggle();
1760+
}
1761+
}
17321762
BrowseTabInput::FilterApplied(set) => {
17331763
// No change to the rule list → don't churn the disk
17341764
// or refetch. Re-fetch on identical filter would just
@@ -1739,13 +1769,16 @@ impl SimpleComponent for BrowseTab {
17391769
}
17401770
self.current_filter = set.clone();
17411771
if let Some(conn_id) = self.connection_id {
1742-
crate::services::filter_settings::save(conn_id, self.schema.as_deref(), &self.table, set);
1772+
crate::services::filter_settings::save(conn_id, self.schema.as_deref(), &self.table, set.clone());
17431773
}
17441774
// Filtered counts shift; jump back to page 1 so the
17451775
// user isn't stranded on offset N where N might be
17461776
// beyond the new filtered total.
17471777
self.current_offset = 0;
17481778
self.refresh_filter_chrome();
1779+
if let Some(strip) = self.filter_strip.as_ref() {
1780+
strip.update_filter(set);
1781+
}
17491782
let _ = sender.output(BrowseTabOutput::FetchPage);
17501783
let _ = sender.output(BrowseTabOutput::FetchRowCount);
17511784
let _ = sender.output(BrowseTabOutput::StateChanged);

0 commit comments

Comments
 (0)