Skip to content

Commit 97dcac8

Browse files
committed
refactor(linux): raw SQL becomes the primary filter input
Per user feedback: raw SQL is what developers reach for first; making them expand a section to type a WHERE clause is backwards. Inverted the strip's hierarchy so raw SQL is always-visible and the rule editor becomes the secondary, opt-in path. New layout: Header: Match [all ▾] of these rules: Clear all │ Apply │ ✕ ┌──────────────────────────────────────────────────────────────┐ │ WHERE [_______________________ raw SQL entry _______________│ ├──────────────────────────────────────────────────────────────┤ │ ▸ Or use the rule editor │ │ (when expanded:) │ │ [boxed-list of rule rows] │ │ [+ Add rule] │ └──────────────────────────────────────────────────────────────┘ Behaviour: - Raw entry is a monospace gtk::Entry with placeholder "e.g. created_at > now() - interval '1 day'". Always present at the top. - Pressing Enter inside the raw entry triggers Apply (matches every other DB client's "type → enter to run" muscle memory). - toggle() now grabs focus on the raw entry when opening so the user can type immediately. - Rule editor is wrapped in a gtk::Expander labelled "Or use the rule editor". Pre-expanded only when saved rules already exist (otherwise collapsed and out of the way). - Auto-seed of an empty rule is gone — the user only sees rule rows when they explicitly expand the rule editor. - Match combinator visibility now considers raw + rules together: shown when ≥2 rules exist OR (≥1 rule + raw SQL present), since those are the cases where AND/OR actually matters. - Bottom-of-strip "Advanced (raw SQL)" expander removed (raw is primary now; nowhere left to put a duplicate). - update_filter() also resets the raw entry's text so a FilterApplied that clears extra_sql doesn't leave stale text in the entry. 92 + 108 tests still pass; clippy clean.
1 parent 00507ab commit 97dcac8

1 file changed

Lines changed: 87 additions & 105 deletions

File tree

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

Lines changed: 87 additions & 105 deletions
Original file line numberDiff line numberDiff line change
@@ -296,6 +296,7 @@ pub struct FilterStrip {
296296
state: Rc<RefCell<FilterSet>>,
297297
columns: Rc<RefCell<Vec<ColumnInfo>>>,
298298
rebuild: RebuilderSlot,
299+
raw_entry: gtk::Entry,
299300
}
300301

301302
impl FilterStrip {
@@ -309,32 +310,13 @@ impl FilterStrip {
309310

310311
pub fn toggle(&self) {
311312
let opening = !self.is_revealed();
312-
if opening {
313-
// Re-seed an empty editor on every open so the user faces
314-
// a ready row instead of a blank list. Idempotent: if any
315-
// rule or raw SQL already exists, leave the state alone.
316-
self.seed_if_empty();
317-
}
318313
self.set_revealed(opening);
319-
}
320-
321-
fn seed_if_empty(&self) {
322-
let columns = self.columns.borrow();
323-
let mut state = self.state.borrow_mut();
324-
if state.rules.is_empty()
325-
&& extra_is_blank(state.extra_sql.as_deref())
326-
&& let Some(first) = columns.first()
327-
{
328-
state.rules.push(FilterRule {
329-
column: first.name.clone(),
330-
op: FilterOp::Eq,
331-
value: Some(FilterValue::Single(String::new())),
332-
});
333-
drop(state);
334-
drop(columns);
335-
if let Some(f) = self.rebuild.borrow().as_ref() {
336-
f();
337-
}
314+
if opening {
315+
// Drop the cursor in the raw SQL field so the user can
316+
// start typing immediately. Raw is the primary path; the
317+
// rule editor is one expander click away for the click-
318+
// driven case.
319+
self.raw_entry.grab_focus();
338320
}
339321
}
340322

@@ -353,7 +335,12 @@ impl FilterStrip {
353335
/// strip (e.g. saved-filter restore on tab open) so the editor
354336
/// reflects what's actually in effect.
355337
pub fn update_filter(&self, set: FilterSet) {
338+
let extra = set.extra_sql.clone().unwrap_or_default();
356339
*self.state.borrow_mut() = set;
340+
// Raw entry mirrors state too — without this the entry's
341+
// text still shows the previous raw fragment after a
342+
// FilterApplied that cleared it.
343+
self.raw_entry.set_text(&extra);
357344
if let Some(f) = self.rebuild.borrow().as_ref() {
358345
f();
359346
}
@@ -455,34 +442,81 @@ pub fn build(columns: Vec<ColumnInfo>, initial: FilterSet, on_apply: Rc<dyn Fn(F
455442
};
456443
});
457444

458-
// Rule list — boxed-list of rule rows, no section header.
459-
// Implicit title (the strip itself reads as "Filter") + the
460-
// header's Match dropdown is enough context. An empty list
461-
// gets one auto-seeded rule below so the user always faces a
462-
// ready-to-fill row instead of a blank Add Rule banner.
445+
// Rebuild closure — captured by every input-changed callback.
446+
// Drains the rules list, walks `state.rules`, builds a row per
447+
// rule. Re-entrancy guard: a CHANGED signal fired while we're
448+
// rebuilding (programmatic set_text on an EntryRow) would
449+
// re-enter and double-update state. The suppress flag
450+
// short-circuits during rebuild.
451+
let suppress: Rc<std::cell::Cell<bool>> = Rc::new(std::cell::Cell::new(false));
452+
let rebuild: RebuilderSlot = Rc::new(RefCell::new(None));
453+
454+
// Raw SQL input — primary, always-visible. The strip is aimed
455+
// at developers who already think in WHERE clauses; making them
456+
// expand a section to type SQL would be backwards. Structured
457+
// rules become the secondary affordance below.
458+
let raw_row = gtk::Box::builder()
459+
.orientation(gtk::Orientation::Horizontal)
460+
.spacing(8)
461+
.build();
462+
let where_label = gtk::Label::builder().label("WHERE").build();
463+
where_label.add_css_class("monospace");
464+
where_label.add_css_class("dim-label");
465+
let raw_entry = gtk::Entry::builder()
466+
.placeholder_text(crate::tr!("e.g. created_at > now() - interval '1 day'"))
467+
.hexpand(true)
468+
.build();
469+
raw_entry.add_css_class("monospace");
470+
raw_entry.set_text(state.borrow().extra_sql.as_deref().unwrap_or(""));
471+
let state_for_raw = state.clone();
472+
let rebuild_for_raw = rebuild.clone();
473+
raw_entry.connect_changed(move |e| {
474+
let text = e.text().to_string();
475+
let trimmed = text.trim();
476+
state_for_raw.borrow_mut().extra_sql = if trimmed.is_empty() { None } else { Some(text) };
477+
// Re-evaluate the Match revealer — combinator visibility
478+
// depends on whether raw + ≥1 rule are both present.
479+
if let Some(f) = rebuild_for_raw.borrow().as_ref() {
480+
f();
481+
}
482+
});
483+
let apply_btn_for_enter = apply_btn.clone();
484+
raw_entry.connect_activate(move |_| {
485+
apply_btn_for_enter.activate();
486+
});
487+
raw_row.append(&where_label);
488+
raw_row.append(&raw_entry);
489+
content.append(&raw_row);
490+
491+
// Structured rules — secondary, collapsed by default. Power
492+
// users who think in raw SQL never need to expand this; users
493+
// building filters by clicking get a dropdown-driven editor
494+
// when they reach for it. Pre-expanded only when the saved
495+
// FilterSet already has rules from a previous session.
496+
let rules_expander = gtk::Expander::builder()
497+
.label(crate::tr!("Or use the rule editor"))
498+
.expanded(!state.borrow().rules.is_empty())
499+
.build();
500+
let rules_body = gtk::Box::builder()
501+
.orientation(gtk::Orientation::Vertical)
502+
.spacing(8)
503+
.margin_top(8)
504+
.build();
505+
rules_expander.set_child(Some(&rules_body));
506+
content.append(&rules_expander);
507+
463508
let rules_list = gtk::ListBox::builder().selection_mode(gtk::SelectionMode::None).build();
464509
rules_list.add_css_class("boxed-list");
465-
content.append(&rules_list);
510+
rules_body.append(&rules_list);
466511

467-
// Inline "Add rule" — small, left-aligned, flat. Replaces the
468-
// earlier full-width AdwButtonRow banner that read like a
469-
// signpost rather than an action.
512+
// Inline "Add rule" button — small, left-aligned, flat.
470513
let add_rule_btn = gtk::Button::builder()
471514
.icon_name("list-add-symbolic")
472515
.label(crate::tr!("Add rule"))
473516
.halign(gtk::Align::Start)
474517
.build();
475518
add_rule_btn.add_css_class("flat");
476-
content.append(&add_rule_btn);
477-
478-
// Rebuild closure — captured by every input-changed callback.
479-
// Drains the list, walks `state.rules`, builds a row per rule,
480-
// appends "Add rule" at the end. Re-entrancy guard: a CHANGED
481-
// signal fired while we're rebuilding (programmatic set_text on
482-
// an EntryRow) would re-enter and double-update state. The
483-
// suppress flag short-circuits during rebuild.
484-
let suppress: Rc<std::cell::Cell<bool>> = Rc::new(std::cell::Cell::new(false));
485-
let rebuild: RebuilderSlot = Rc::new(RefCell::new(None));
519+
rules_body.append(&add_rule_btn);
486520

487521
{
488522
let rules_list = rules_list.clone();
@@ -508,9 +542,14 @@ pub fn build(columns: Vec<ColumnInfo>, initial: FilterSet, on_apply: Rc<dyn Fn(F
508542
);
509543
rules_list.append(&row);
510544
}
511-
// Match dropdown is meaningless until there are at least
512-
// two rules to combine; reveal it then, hide it otherwise.
513-
match_revealer.set_reveal_child(rules_snapshot.len() >= 2);
545+
// Match dropdown is meaningful when at least two clauses
546+
// need a combinator — that's ≥2 structured rules, or 1
547+
// structured rule combined with raw SQL. Hide it
548+
// otherwise so the user doesn't see a control that has
549+
// no effect on the resulting WHERE.
550+
let raw_present = !extra_is_blank(state.borrow().extra_sql.as_deref());
551+
let needs_combinator = rules_snapshot.len() >= 2 || (!rules_snapshot.is_empty() && raw_present);
552+
match_revealer.set_reveal_child(needs_combinator);
514553
// Visually mute the entire rules list when empty so the
515554
// strip reads as ready-for-input rather than already-
516555
// populated.
@@ -519,27 +558,6 @@ pub fn build(columns: Vec<ColumnInfo>, initial: FilterSet, on_apply: Rc<dyn Fn(F
519558
});
520559
*rebuild.borrow_mut() = Some(closure);
521560
}
522-
523-
// Auto-seed one rule when the user opens the strip on a new
524-
// empty filter — saves a click, makes the strip self-explanatory
525-
// (column ▾ op ▾ value entry visible from the moment it slides
526-
// in). Skipped when columns aren't loaded yet (the rule would
527-
// have nothing to bind to) or when the user already has rules
528-
// saved from a previous session.
529-
{
530-
let columns_borrow = columns.borrow();
531-
let mut state_mut = state.borrow_mut();
532-
if state_mut.rules.is_empty()
533-
&& extra_is_blank(state_mut.extra_sql.as_deref())
534-
&& let Some(first) = columns_borrow.first()
535-
{
536-
state_mut.rules.push(FilterRule {
537-
column: first.name.clone(),
538-
op: FilterOp::Eq,
539-
value: Some(FilterValue::Single(String::new())),
540-
});
541-
}
542-
}
543561
if let Some(f) = rebuild.borrow().as_ref() {
544562
f();
545563
}
@@ -563,43 +581,6 @@ pub fn build(columns: Vec<ColumnInfo>, initial: FilterSet, on_apply: Rc<dyn Fn(F
563581
}
564582
});
565583

566-
// Advanced (raw SQL) — collapsible at the bottom of the strip.
567-
// The user types arbitrary SQL appended to the structured rules
568-
// with the chosen combinator. No quoting or parameterisation;
569-
// identical security model to the SQL editor (the user already
570-
// owns the connection — a raw filter is a power feature, not an
571-
// injection vector). AdwExpanderRow keeps it out of the way for
572-
// the common case where structured rules are enough.
573-
// Advanced expander — collapsed by default unless the user has
574-
// a saved raw fragment. Dropping the long subtitle in favour of
575-
// a tooltip — the title alone is enough hint for the few users
576-
// who reach for raw SQL.
577-
let advanced_group = adw::PreferencesGroup::builder().build();
578-
let advanced_row = adw::ExpanderRow::builder()
579-
.title(crate::tr!("Advanced (raw SQL)"))
580-
.expanded(
581-
state
582-
.borrow()
583-
.extra_sql
584-
.as_deref()
585-
.is_some_and(|s| !s.trim().is_empty()),
586-
)
587-
.build();
588-
advanced_row.set_tooltip_text(Some(&crate::tr!(
589-
"Raw SQL fragment appended to the rules with the combinator above."
590-
)));
591-
let extra_entry = adw::EntryRow::builder().title(crate::tr!("Raw SQL")).build();
592-
extra_entry.set_text(state.borrow().extra_sql.as_deref().unwrap_or(""));
593-
let state_for_extra = state.clone();
594-
extra_entry.connect_changed(move |e| {
595-
let text = e.text().to_string();
596-
let trimmed = text.trim();
597-
state_for_extra.borrow_mut().extra_sql = if trimmed.is_empty() { None } else { Some(text) };
598-
});
599-
advanced_row.add_row(&extra_entry);
600-
advanced_group.add(&advanced_row);
601-
content.append(&advanced_group);
602-
603584
let scroller = gtk::ScrolledWindow::builder()
604585
.child(&content)
605586
.hscrollbar_policy(gtk::PolicyType::Never)
@@ -651,6 +632,7 @@ pub fn build(columns: Vec<ColumnInfo>, initial: FilterSet, on_apply: Rc<dyn Fn(F
651632
state,
652633
columns,
653634
rebuild,
635+
raw_entry,
654636
}
655637
}
656638

0 commit comments

Comments
 (0)