From 0c8e0d47ba458c8892f0a4b3a6a6a40c24f2da20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Sun, 27 Sep 2026 09:05:35 -0400 Subject: [PATCH] =?UTF-8?q?DB=20&=20Dev=20Tools=20=C2=B7=20Databases:=20na?= =?UTF-8?q?rrow=20boxes=20scroll=20or=20wrap;=20the=20form's=20rules=20tes?= =?UTF-8?q?ted?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit QueryPlanViewer's step column takes the label width and the table scrolls sideways past its least width; TableStructureEditor's rows stretch to a least width that scrolls sideways; RedisKeyBrowser's columns wrap below each other inside 280px. The form's rules are pure, parse_port and ready, with unit tests; gpui tests cover saving, a new connection refilling the form, another engine bringing its port, and a press on a row's button leaving the row alone. --- TASKS.md | 1 + examples/gallery/pages/devtools/db.rs | 3 +- src/devtools/connection_form.rs | 71 +++++++++++++++++++--- src/devtools/queries.rs | 18 +++--- src/devtools/redis.rs | 4 +- src/devtools/structure.rs | 32 +++++++--- src/devtools/tests/mod.rs | 87 +++++++++++++++++++++++---- src/lists/tests.rs | 44 +++++++++++++- 8 files changed, 214 insertions(+), 46 deletions(-) diff --git a/TASKS.md b/TASKS.md index dee26a6..21ee5c7 100644 --- a/TASKS.md +++ b/TASKS.md @@ -384,3 +384,4 @@ Per-component lines live in `tasks/`. Tags there: | T32d | 1 | FAIL | Tab in a mind map's rename field added a topic; the replaced wire, the connector released on its own shape and a deleted shape's links went untested | | T32d | 2 | FAIL | Enter or Escape in a canvas field handed focus nowhere, so the map's and the layer's keys went dead until the next press | | T32d | 3 | PASS | | +| T33a | 1 | FAIL | the plan, the Redis browser and the structure editor drew past a 280px box; the form's rules and a row button's press went untested | diff --git a/examples/gallery/pages/devtools/db.rs b/examples/gallery/pages/devtools/db.rs index bb3f912..b7909af 100644 --- a/examples/gallery/pages/devtools/db.rs +++ b/examples/gallery/pages/devtools/db.rs @@ -270,8 +270,7 @@ pub fn structure(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> .flex_wrap() .gap_6() .child( - div().w(px(620.)).child( - TableStructureEditor::new("devtools-structure", fields) + div().w(px(660.)).child(TableStructureEditor::new("devtools-structure", fields) .on_change(move |next, _, cx| change(&edited, cx, |build| build.fields = next)), ), ) diff --git a/src/devtools/connection_form.rs b/src/devtools/connection_form.rs index 8afd89e..f0a243f 100644 --- a/src/devtools/connection_form.rs +++ b/src/devtools/connection_form.rs @@ -24,15 +24,23 @@ pub enum TestState { Failed(SharedString), } -/// The port a field names, from 1 to 65535. +/// The port `text` names, from 1 to 65535. +pub(crate) fn parse_port(text: &str) -> Option { + text.trim().parse().ok().filter(|port| *port > 0) +} + fn port(field: &Entity, cx: &App) -> Option { - field - .read(cx) - .text() - .trim() - .parse() - .ok() - .filter(|port| *port > 0) + parse_port(field.read(cx).text()) +} + +/// Whether a connection is filled enough to test or save: a name, and a file for SQLite, or a host and a port for the rest. +pub(crate) fn ready(name: &str, engine: Engine, host: &str, database: &str, port: &str) -> bool { + let filled = |text: &str| !text.trim().is_empty(); + filled(name) + && match engine { + Engine::Sqlite => filled(database), + _ => filled(host) && parse_port(port).is_some(), + } } /// The form's own fields, filled from the connection named by `seed`. @@ -162,9 +170,15 @@ impl RenderOnce for ConnectionForm { now.port.clone(), ); let file = engine == Engine::Sqlite; - let filled = |input: &Entity| !input.read(cx).text().trim().is_empty(); + let text = |input: &Entity| input.read(cx).text().to_string(); let good_port = file || port(&port_field, cx).is_some(); - let ready = filled(&name) && filled(if file { &database } else { &host }) && good_port; + let ready = ready( + &text(&name), + engine, + &text(&host), + &text(&database), + &text(&port_field), + ); let testing = self.test == Some(TestState::Testing); let (chosen, switched) = (fields.clone(), fields.clone()); let field = |key: &'static str, label: &'static str, control: gpui::AnyElement| { @@ -280,3 +294,40 @@ impl RenderOnce for ConnectionForm { ) } } + +#[cfg(test)] +mod tests { + use super::{parse_port, ready}; + use crate::devtools::Engine; + + #[test] + fn a_port_runs_from_one_to_65535() { + assert_eq!(parse_port("5433"), Some(5433)); + assert_eq!(parse_port(" 22 "), Some(22)); + assert_eq!(parse_port("0"), None); + assert_eq!(parse_port("70000"), None); + assert_eq!(parse_port("http"), None); + } + + #[test] + fn a_connection_waits_for_a_name_and_a_place() { + assert!(ready("Shop", Engine::Postgres, "localhost", "", "5432")); + assert!( + !ready("", Engine::Postgres, "localhost", "", "5432"), + "no name" + ); + assert!(!ready("Shop", Engine::Postgres, "", "", "5432"), "no host"); + assert!( + !ready("Shop", Engine::Postgres, "localhost", "", "0"), + "a bad port" + ); + assert!( + !ready("Notes", Engine::Sqlite, "localhost", "", "5432"), + "SQLite waits for a file" + ); + assert!( + ready("Notes", Engine::Sqlite, "", "notes.db", ""), + "and needs nothing else" + ); + } +} diff --git a/src/devtools/queries.rs b/src/devtools/queries.rs index 32396bf..909adcb 100644 --- a/src/devtools/queries.rs +++ b/src/devtools/queries.rs @@ -1,12 +1,14 @@ use std::rc::Rc; use gpui::{ - App, ElementId, IntoElement, ParentElement, RenderOnce, SharedString, Styled, Window, div, + App, ElementId, InteractiveElement, IntoElement, ParentElement, RenderOnce, SharedString, + StatefulInteractiveElement, Styled, Window, div, rems, }; use jiff::Timestamp; use crate::{ forms::{Input, TextInput}, + layout::on_axis, lists::{ListItem, SelectableList}, primitives::{Icon, IconName}, tables::{Cell, Column, TreeRow, TreeTable}, @@ -179,7 +181,7 @@ fn row(step: &PlanStep, whole: f64) -> TreeRow { .children(step.children.iter().map(|child| row(child, whole))) } -/// A query's plan as a tree of steps, each with its share of the whole cost drawn as a bar, the rows it yields and its time. Every step starts open. +/// A query's plan as a tree of steps, each with its share of the whole cost drawn as a bar, the rows it yields and its time. Every step starts open; a box narrower than its columns scrolls sideways. #[derive(IntoElement)] pub struct QueryPlanViewer { id: ElementId, @@ -203,20 +205,22 @@ fn keys(step: &PlanStep, out: &mut Vec) { impl RenderOnce for QueryPlanViewer { fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { - let step = gpui::rems(cx.theme().label_width().0 * 2.0); + let wide = cx.theme().label_width(); let mut open = Vec::new(); keys(&self.plan, &mut open); - TreeTable::new( - self.id, + let table = TreeTable::new( + (self.id.clone(), "table"), [ - Column::new("step", "Step").width(step), + Column::new("step", "Step").width(wide), Column::new("cost", "Cost").unsorted(), Column::new("rows", "Rows").end(), Column::new("ms", "Time").end().decimals(1).suffix(" ms"), ], [row(&self.plan, self.plan.cost)], ) - .open(open) + .open(open); + on_axis(div().id((self.id, "sideways")).overflow_x_scroll()) + .child(div().min_w(rems(wide.0 * 2.5)).child(table)) } } diff --git a/src/devtools/redis.rs b/src/devtools/redis.rs index 3528e5b..a20b73b 100644 --- a/src/devtools/redis.rs +++ b/src/devtools/redis.rs @@ -237,7 +237,7 @@ impl RenderOnce for RedisKeyBrowser { .child( div() .flex_1() - .min_w(theme.label_width() * 2.) + .min_w(theme.label_width()) .flex() .flex_col() .gap_2() @@ -248,7 +248,7 @@ impl RenderOnce for RedisKeyBrowser { .child(format!("{} of {} keys", found.len(), self.keys.len())) .child(list), ) - .child(div().flex_1().min_w(theme.label_width() * 2.).child(detail)) + .child(div().flex_1().min_w(theme.label_width()).child(detail)) } } diff --git a/src/devtools/structure.rs b/src/devtools/structure.rs index 0f293ee..dd6424d 100644 --- a/src/devtools/structure.rs +++ b/src/devtools/structure.rs @@ -1,8 +1,9 @@ use std::rc::Rc; use gpui::{ - App, AppContext as _, ElementId, Entity, FontWeight, IntoElement, ParentElement, RenderOnce, - SharedString, Styled, Window, div, relative, + App, AppContext as _, ElementId, Entity, FontWeight, InteractiveElement, IntoElement, + ParentElement, RenderOnce, SharedString, StatefulInteractiveElement, Styled, Window, div, + relative, rems, }; use super::schema::{Field, FieldKey}; @@ -11,6 +12,7 @@ use crate::{ canvas::{OnEdit, editing}, data_display::{Badge, Tone}, forms::{Checkbox, Choice, InlineEdit, Input, MultiSelect, Select, Switch, TextInput}, + layout::on_axis, primitives::IconName, theme::{ActiveTheme, TextSize}, typography::Ellipsis, @@ -34,9 +36,10 @@ const TYPES: [&str; 10] = [ const SHARES: [f32; 6] = [0.28, 0.24, 0.1, 0.1, 0.2, 0.08]; fn cells(parts: [gpui::AnyElement; 6]) -> gpui::Div { - parts.into_iter().zip(SHARES).fold( - div().flex().items_center().w_full(), - |row, (part, share)| { + parts + .into_iter() + .zip(SHARES) + .fold(div().flex().items_center(), |row, (part, share)| { row.child( div() .flex_none() @@ -45,11 +48,10 @@ fn cells(parts: [gpui::AnyElement; 6]) -> gpui::Div { .pr_2() .child(part), ) - }, - ) + }) } -/// A table's fields to edit in place: a name and a default rewritten on a press, a type from the common ones, whether it may be empty, and the primary key. A foreign key shows where it points. Minus drops a field and Add field appends one; each edit hands the owner every field. +/// A table's fields to edit in place, scrolling sideways in a box too narrow for their columns: a name and a default rewritten on a press, a type from the common ones, whether it may be empty, and the primary key. A foreign key shows where it points. Minus drops a field and Add field appends one; each edit hands the owner every field. #[derive(IntoElement)] pub struct TableStructureEditor { id: ElementId, @@ -180,12 +182,22 @@ impl RenderOnce for TableStructureEditor { all.push(Field::new(name, "text").nullable()); }, ); + let least = rems(theme.label_width().0 * 4.0); div() .flex() .flex_col() .gap_2() - .child(head) - .children(rows) + .child( + on_axis(div().id((id.clone(), "sideways")).overflow_x_scroll()).child( + div() + .min_w(least) + .flex() + .flex_col() + .gap_2() + .child(head) + .children(rows), + ), + ) .child( div().flex().child( Button::new((id, "add"), "Add field") diff --git a/src/devtools/tests/mod.rs b/src/devtools/tests/mod.rs index a4bff87..53c230c 100644 --- a/src/devtools/tests/mod.rs +++ b/src/devtools/tests/mod.rs @@ -1,20 +1,25 @@ use gpui::{ - AnyElement, Context, Entity, IntoElement, KeyBinding, ParentElement, Render, Styled, + AnyElement, App, Context, Entity, IntoElement, KeyBinding, ParentElement, Render, Styled, TestAppContext, VisualTestContext, Window, div, px, }; -use super::{Connection, ConnectionManager, Engine, Field, TableStructureEditor}; +use super::{Connection, ConnectionForm, ConnectionManager, Engine, Field, TableStructureEditor}; use crate::{forms, primitives::FocusNext, theme::Theme}; -/// A view that shows one tool and keeps the words it heard. +type Part = fn(&Bench, &mut Window, &mut App, Entity) -> AnyElement; + +/// A view that shows one tool, keeps the words it heard, and holds a choice among its part's cases. struct Bench { - part: fn(Entity) -> AnyElement, + part: Part, said: Vec, + choice: usize, } impl Render for Bench { - fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { - div().w(px(640.0)).child((self.part)(cx.entity())) + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let owner = cx.entity(); + let part = (self.part)(self, window, cx, owner); + div().w(px(640.0)).child(part) } } @@ -26,10 +31,7 @@ fn settle(cx: &mut VisualTestContext) { cx.run_until_parked(); } -fn bench( - part: fn(Entity) -> AnyElement, - cx: &mut TestAppContext, -) -> (Entity, &mut VisualTestContext) { +fn bench(part: Part, cx: &mut TestAppContext) -> (Entity, &mut VisualTestContext) { cx.update(|cx| { Theme::init(cx); Theme::update(cx, |theme| theme.reduced_motion = true); @@ -39,6 +41,7 @@ fn bench( let (host, cx) = cx.add_window_view(|_, _| Bench { part, said: Vec::new(), + choice: 0, }); settle(cx); (host, cx) @@ -71,7 +74,7 @@ fn tap(key: &str, cx: &mut VisualTestContext) { settle(cx); } -fn manager(owner: Entity) -> AnyElement { +fn manager(_: &Bench, _: &mut Window, _: &mut App, owner: Entity) -> AnyElement { let [opened, edited, picked] = [(); 3].map(|_| owner.clone()); ConnectionManager::new( "connections", @@ -97,7 +100,7 @@ fn enter_opens_a_connection_and_a_rows_button_leaves_the_row_alone(cx: &mut Test assert_eq!(said(&host, cx), ["open local", "edit local"]); } -fn structure(owner: Entity) -> AnyElement { +fn structure(_: &Bench, _: &mut Window, _: &mut App, owner: Entity) -> AnyElement { TableStructureEditor::new("structure", [Field::new("id", "int").primary()]) .on_change(move |fields, _, cx| { let names: Vec = fields @@ -131,3 +134,63 @@ fn an_address_names_the_host_or_the_file() { file.database = "app.db".into(); assert_eq!(file.address(), "app.db"); } + +fn form(bench: &Bench, _: &mut Window, _: &mut App, owner: Entity) -> AnyElement { + let connection = match bench.choice { + 0 => { + let mut shop = Connection::new("shop", "Shop", Engine::Postgres); + shop.database = "shop".into(); + shop + } + _ => Connection::new("cache", "Cache", Engine::Redis), + }; + ConnectionForm::new("form", connection) + .on_save(move |connection, _, cx| { + say( + &owner, + format!("save {} {}", connection.name, connection.address()), + cx, + ) + }) + .into_any_element() +} + +/// Save is the form's last stop, so a step back from nothing reaches it. +fn last(cx: &mut VisualTestContext) { + cx.update(|window, _| { + window.blur(); + window.focus_prev(); + }); + settle(cx); +} + +#[gpui::test] +fn save_takes_the_form_and_a_new_connection_refills_it(cx: &mut TestAppContext) { + let (host, cx) = bench(form, cx); + last(cx); + tap("space", cx); + host.update(cx, |bench, cx| { + bench.choice = 1; + cx.notify(); + }); + settle(cx); + last(cx); + tap("space", cx); + assert_eq!( + said(&host, cx), + ["save Shop localhost:5432/shop", "save Cache localhost:6379"] + ); +} + +/// Stops: the name, then the engine. +#[gpui::test] +fn another_engine_brings_its_port(cx: &mut TestAppContext) { + let (host, cx) = bench(form, cx); + tab(2, cx); + for key in ["down", "down", "enter"] { + tap(key, cx); + } + last(cx); + tap("space", cx); + assert_eq!(said(&host, cx), ["save Shop localhost:3306/shop"]); +} diff --git a/src/lists/tests.rs b/src/lists/tests.rs index 821922f..33ce455 100644 --- a/src/lists/tests.rs +++ b/src/lists/tests.rs @@ -4,9 +4,9 @@ use std::{ }; use gpui::{ - Context, Entity, IntoElement, Modifiers, ParentElement, Pixels, Point, Render, ScrollDelta, - ScrollWheelEvent, SharedString, Styled, TestAppContext, TouchPhase, VisualTestContext, Window, - div, point, px, + Context, Entity, InteractiveElement, IntoElement, Modifiers, ParentElement, Pixels, Point, + Render, ScrollDelta, ScrollWheelEvent, SharedString, StatefulInteractiveElement, Styled, + TestAppContext, TouchPhase, VisualTestContext, Window, div, point, px, }; use super::{ @@ -431,3 +431,41 @@ fn a_narrow_listing_keeps_room_for_the_name(cx: &mut TestAppContext) { }); assert!(name.size.width >= least, "{name:?} under {least:?}"); } + +/// A list whose row holds a button kept by `row_action`, which keeps what the list reports and what the button heard. +struct Held(Vec, Rc>); + +impl Render for Held { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let (view, pressed) = (cx.entity(), self.1.clone()); + let button = div() + .id("held-button") + .debug_selector(|| "held-button".into()) + .size(px(24.0)) + .on_click(move |_, _, _| pressed.set(pressed.get() + 1)); + SelectableList::new("held") + .row( + "a", + ListItem::new("a", "A").trailing(super::row_action(button)), + ) + .on_change(move |keys, _, cx| view.update(cx, |held, _| held.0 = keys.to_vec())) + .w(px(240.0)) + } +} + +#[gpui::test] +fn a_press_on_a_rows_button_leaves_the_row_alone(cx: &mut TestAppContext) { + setup(cx); + let pressed = Rc::new(Cell::new(0)); + let heard = pressed.clone(); + let (view, cx) = cx.add_window_view(move |_, _| Held(Vec::new(), heard)); + settle(cx); + let button = cx.debug_bounds("held-button").expect("the row's button"); + cx.simulate_click(button.center(), Modifiers::none()); + settle(cx); + assert_eq!(pressed.get(), 1, "the button heard its press"); + assert!( + view.read_with(cx, |held, _| held.0.is_empty()), + "the row stayed unselected" + ); +}