diff --git a/AGENTS.md b/AGENTS.md index 074a148..21bae52 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -170,6 +170,7 @@ Ely GPUI Component. A component library for GPUI, in light and dark. - A ScrubInput shows its number in about 140px beside a short label. In a narrower column a pair hides it, so a long label takes a row of its own (`canvas::TypographyPanel`). - Design panels edit a whole value and hand the owner a changed copy through `canvas::panel::editing`. A swatch that opens a picker is `canvas::panel::color_well`. - A press that stops propagation also stops the ancestors' focus on press. A layer that takes keys focuses itself in its own press handler (`canvas::ToolLayer`, `canvas::MindMap`), and a field over it stops its own presses. +- A button inside a pressable row goes through `lists::row_action`, which keeps its press and its Space or Enter from the row (`devtools::ConnectionManager`). - A field opened over a component hands focus back to that component on Enter and Escape, through `forms::Editing`'s `returning` and `give_back` (`canvas::writing`); a blur keeps the words and leaves focus where it went. - A focus ring on a box whose children measure themselves goes on an overlay child: a border insets the children and shifts what they measure (`canvas::ToolLayer`). - A menu of times goes through `mail::times`: each row names its day and hour, a moment comes once (tomorrow is next Monday on a Sunday), and "Pick a time…" opens one dialog that waits for a time ahead of now (`ScheduleSend`, `SnoozePicker`). diff --git a/examples/gallery/pages/devtools/data.rs b/examples/gallery/pages/devtools/data.rs new file mode 100644 index 0000000..913a093 --- /dev/null +++ b/examples/gallery/pages/devtools/data.rs @@ -0,0 +1,223 @@ +use std::rc::Rc; + +use ely_gpui_component::{ + devtools::{ + Outcome, PlanStep, QueryHistory, QueryPlanViewer, QueryRun, RedisKey, RedisKeyBrowser, + RedisValue, + }, + editor::CodeEditor, + forms::TextInput, + tables::DataGrid, + theme::{ActiveTheme, Radius}, +}; +use gpui::{ + App, AppContext as _, IntoElement, ParentElement, SharedString, Styled, Window, div, px, +}; +use jiff::Timestamp; + +use crate::ui::{change, keep, section}; + +const SQL: &str = "SELECT u.email, count(o.id) AS orders, sum(o.total) AS spent +FROM users u +JOIN orders o ON o.user_id = u.id +WHERE o.placed_at > now() - interval '30 days' +GROUP BY u.email +ORDER BY spent DESC +LIMIT 5;"; + +fn runs(now: Timestamp) -> Vec { + let ago = |minutes: i64| now - jiff::SignedDuration::from_mins(minutes); + let run = |key: &str, sql: &str, minutes, took_ms, outcome| QueryRun { + key: key.to_string().into(), + sql: sql.to_string().into(), + at: ago(minutes), + took_ms, + outcome, + }; + vec![ + run( + "r4", + "SELECT u.email, count(o.id) AS orders", + 2, + 18, + Outcome::Rows(5), + ), + run( + "r3", + "UPDATE orders SET total = total * 1.1 WHERE id = 42", + 9, + 4, + Outcome::Rows(1), + ), + run( + "r2", + "SELECT * FROM order_item", + 14, + 2, + Outcome::Failed("relation \"order_item\" does not exist".into()), + ), + run("r1", "SELECT count(*) FROM users", 61, 7, Outcome::Rows(1)), + ] +} + +fn plan() -> PlanStep { + PlanStep::new("limit", "Limit", 1284.0, 5, 12.4).child( + PlanStep::new("sort", "Sort", 1283.0, 212, 12.1).child( + PlanStep::new("group", "Group", 1270.0, 212, 11.2).child( + PlanStep::new("join", "Hash Join", 1150.0, 3480, 9.8) + .child(PlanStep::new("orders", "Seq Scan", 920.0, 3480, 7.6).on("orders")) + .child(PlanStep::new("users", "Index Scan", 180.0, 1200, 1.4).on("users_pkey")), + ), + ), + ) +} + +/// The query demo's editor, result, history search and clock. +struct Console { + editor: gpui::Entity, + rows: Rc>>, + search: gpui::Entity, + now: Timestamp, + runs: Vec, +} + +pub fn queries(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let console = window.use_keyed_state("devtools-console", cx, |window, cx| { + let now: Timestamp = "2026-09-27T12:00:00Z".parse().expect("a time"); + let rows = [ + ["ada@example.com", "7", "1,284.00"], + ["grace@example.com", "5", "962.50"], + ["linus@example.com", "4", "711.20"], + ["margaret@example.com", "4", "640.00"], + ["alan@example.com", "3", "388.90"], + ]; + Console { + editor: cx.new(|cx| CodeEditor::new(SQL, window, cx).language("SQL")), + rows: Rc::new( + rows.iter() + .map(|row| row.iter().map(|cell| SharedString::from(*cell)).collect()) + .collect(), + ), + search: cx.new(|cx| TextInput::new(window, cx).placeholder("Find a query")), + now, + runs: runs(now), + } + }); + let now = console.read(cx); + let (editor, rows, search, clock, history) = ( + now.editor.clone(), + now.rows.clone(), + now.search.clone(), + now.now, + now.runs.clone(), + ); + let again = console.clone(); + let theme = cx.theme(); + let boxed = |width: f32, height: f32| { + div() + .w(px(width)) + .h(px(height)) + .rounded(theme.radius(Radius::Lg)) + .border_1() + .border_color(theme.colors.border) + .overflow_hidden() + }; + section( + "QueryEditor → editor::CodeEditor · QueryResultGrid → tables::DataGrid · QueryHistory · QueryPlanViewer", + "A query in the code editor over its result in the data grid; the queries that ran, found by their text, each with how it ended, and one run again on Enter; and the plan, each step with its share of the cost.", + cx, + ) + .child( + div() + .flex() + .flex_col() + .gap_4() + .child(boxed(760.0, 170.0).child(editor)) + .child( + DataGrid::new("devtools-result", ["email", "orders", "spent"], rows) + .w(px(760.)) + .h(px(170.)), + ) + .child( + div() + .flex() + .flex_wrap() + .gap_6() + .child( + div().w(px(340.)).child( + QueryHistory::new("devtools-history", history, &search, clock).on_run(move |key, _, cx| { + change(&again, cx, |console| { + let run = console.runs.iter().find(|run| run.key == *key).expect("a listed run").clone(); + console.runs.insert(0, QueryRun { key: format!("r{}", console.runs.len() + 1).into(), at: console.now, ..run }); + }) + }), + ), + ) + .child(div().w(px(520.)).child(QueryPlanViewer::new("devtools-plan", plan()))), + ), + ) +} + +pub fn redis(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let pattern = window.use_keyed_state("devtools-redis-pattern", cx, |window, cx| { + TextInput::new(window, cx).placeholder("Pattern, such as user:*") + }); + let picked = keep( + "devtools-redis-picked", + || Some(SharedString::from("user:42")), + window, + cx, + ); + let selected = picked.read(cx).clone(); + let keys = vec![ + RedisKey { + key: "user:42".into(), + value: RedisValue::Hash(vec![ + ("name".into(), "Ada".into()), + ("plan".into(), "team".into()), + ("seats".into(), "12".into()), + ]), + ttl: None, + }, + RedisKey { + key: "user:42:session".into(), + value: RedisValue::Text("f3a9c1d2-77e0-4b1a".into()), + ttl: Some(1800), + }, + RedisKey { + key: "queue:mail".into(), + value: RedisValue::List(vec![ + "welcome:42".into(), + "receipt:1009".into(), + "digest:7".into(), + ]), + ttl: None, + }, + RedisKey { + key: "tags:popular".into(), + value: RedisValue::Set(vec!["rust".into(), "gpui".into(), "design".into()]), + ttl: None, + }, + RedisKey { + key: "board:weekly".into(), + value: RedisValue::Sorted(vec![ + ("ada".into(), 1284.0), + ("grace".into(), 962.5), + ("alan".into(), 388.9), + ]), + ttl: Some(86_400), + }, + ]; + section( + "RedisKeyBrowser", + "Keys found by a glob, each with its type and the time it has left; the key picked shows what it holds, laid out for its type.", + cx, + ) + .child( + div().w(px(760.)).child( + RedisKeyBrowser::new("devtools-redis", keys, &pattern) + .selected(selected) + .on_select(move |key, _, cx| change(&picked, cx, |picked| *picked = Some(key.clone()))), + ), + ) +} diff --git a/examples/gallery/pages/devtools/db.rs b/examples/gallery/pages/devtools/db.rs new file mode 100644 index 0000000..bb3f912 --- /dev/null +++ b/examples/gallery/pages/devtools/db.rs @@ -0,0 +1,286 @@ +use ely_gpui_component::{ + canvas::Viewport, + devtools::{ + Connection, ConnectionForm, ConnectionManager, ConnectionState, DbTable, ERDiagram, Engine, + Field, Index, IndexManager, Schema, SchemaTree, TableStructureEditor, TestState, + }, + theme::{ActiveTheme, Radius}, +}; +use gpui::{App, IntoElement, ParentElement, SharedString, Styled, Window, div, px}; + +use crate::ui::{change, keep, section}; + +pub(super) fn tables() -> Vec { + vec![ + DbTable::new( + "users", + [ + Field::new("id", "int").primary(), + Field::new("email", "text"), + Field::new("name", "text").nullable(), + Field::new("created_at", "timestamptz").default_value("now()"), + ], + ), + DbTable::new( + "orders", + [ + Field::new("id", "int").primary(), + Field::new("user_id", "int").refers("users", "id"), + Field::new("total", "numeric"), + Field::new("placed_at", "timestamptz"), + ], + ), + DbTable::new( + "order_items", + [ + Field::new("order_id", "int").refers("orders", "id"), + Field::new("sku", "text"), + Field::new("quantity", "int").default_value("1"), + ], + ), + DbTable::new( + "recent_orders", + [Field::new("id", "int"), Field::new("total", "numeric")], + ) + .view(), + ] +} + +/// The connections demo's list, the one picked, the one in the form, and its last test. +struct Desk { + connections: Vec, + selected: Option, + editing: Connection, + test: Option, +} + +impl Desk { + fn new() -> Self { + let mut shop = Connection::new("shop", "Shop", Engine::Postgres); + shop.database = "shop".into(); + shop.user = "app".into(); + shop.state = ConnectionState::Connected; + let mut cache = Connection::new("cache", "Cache", Engine::Redis); + cache.host = "10.0.0.12".into(); + let mut analytics = Connection::new("analytics", "Analytics", Engine::MySql); + analytics.host = "db.internal".into(); + analytics.database = "events".into(); + analytics.state = ConnectionState::Failed("Password rejected for user report".into()); + let mut notes = Connection::new("notes", "Notes", Engine::Sqlite); + notes.database = "~/notes.db".into(); + Self { + editing: shop.clone(), + connections: vec![shop, cache, analytics, notes], + selected: Some("shop".into()), + test: None, + } + } +} + +pub fn connections(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let desk = keep("devtools-connections", Desk::new, window, cx); + let now = desk.read(cx); + let (connections, selected, editing, test) = ( + now.connections.clone(), + now.selected.clone(), + now.editing.clone(), + now.test.clone(), + ); + let [picked, opened, edited, removed, made, tested, saved] = [(); 7].map(|_| desk.clone()); + let mut form = ConnectionForm::new("devtools-connection-form", editing) + .on_test(move |connection, _, cx| { + change(&tested, cx, |desk| { + desk.test = Some(match connection.engine { + Engine::MySql => TestState::Failed("Password rejected".into()), + _ => TestState::Passed(23), + }) + }) + }) + .on_save(move |connection, _, cx| { + change(&saved, cx, |desk| { + match desk + .connections + .iter_mut() + .find(|each| each.key == connection.key) + { + Some(each) => *each = connection.clone(), + None => desk.connections.push(connection.clone()), + } + desk.selected = Some(connection.key.clone()); + desk.test = None; + }) + }); + if let Some(test) = test { + form = form.test(test); + } + section( + "ConnectionManager · ConnectionForm", + "Saved connections with their engine and address, or why one failed. Enter or a double press connects; the pencil opens one in the form, where Test tries it and Save keeps it.", + cx, + ) + .child( + div() + .flex() + .flex_wrap() + .gap_6() + .child( + div().w(px(340.)).child( + ConnectionManager::new("devtools-connections", connections) + .selected(selected) + .on_select(move |key, _, cx| change(&picked, cx, |desk| desk.selected = Some(key.clone()))) + .on_open(move |key, _, cx| { + change(&opened, cx, |desk| { + let each = desk.connections.iter_mut().find(|each| each.key == *key).expect("a listed connection"); + each.state = ConnectionState::Connected; + }) + }) + .on_edit(move |key, _, cx| { + change(&edited, cx, |desk| { + desk.editing = desk.connections.iter().find(|each| each.key == *key).expect("a listed connection").clone(); + desk.test = None; + }) + }) + .on_remove(move |key, _, cx| change(&removed, cx, |desk| desk.connections.retain(|each| each.key != *key))) + .on_new(move |_, cx| { + change(&made, cx, |desk| { + let key = SharedString::from(format!("new-{}", desk.connections.len() + 1)); + desk.editing = Connection::new(key, "", Engine::Postgres); + desk.test = None; + }) + }), + ), + ) + .child(div().w(px(360.)).child(form)), + ) +} + +/// The schema demo's tree selection and where each table sits in the diagram. +struct Map { + selected: Vec, + places: Vec<(SharedString, (f32, f32))>, + view: Viewport, +} + +pub fn schema(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let map = keep( + "devtools-schema", + || Map { + selected: vec!["public.orders".into()], + places: vec![ + ("order_items".into(), (20.0, 40.0)), + ("orders".into(), (300.0, 20.0)), + ("users".into(), (580.0, 60.0)), + ("recent_orders".into(), (20.0, 230.0)), + ], + view: Viewport::new(0.0, 0.0, 0.7), + }, + window, + cx, + ); + let now = map.read(cx); + let (selected, places, view) = (now.selected.clone(), now.places.clone(), now.view); + let [picked, moved, viewed] = [(); 3].map(|_| map.clone()); + let theme = cx.theme(); + let boxed = |width: f32, height: f32| { + div() + .w(px(width)) + .h(px(height)) + .rounded(theme.radius(Radius::Lg)) + .border_1() + .border_color(theme.colors.border) + .overflow_hidden() + }; + section( + "SchemaTree · ERDiagram", + "A database's schemas, tables and views, each field with its type and keys marked. The diagram draws each table as a box of fields and a line from every foreign key to the field it points at; a table's title drags it.", + cx, + ) + .child( + div() + .flex() + .flex_wrap() + .gap_6() + .child( + boxed(260.0, 320.0).p_1().child( + SchemaTree::new( + "devtools-schema-tree", + [Schema { name: "public".into(), tables: tables() }], + ) + .open(["public", "public.orders"]) + .selected(selected) + .on_select(move |keys, _, cx| change(&picked, cx, |map| map.selected = keys.to_vec())), + ), + ) + .child( + boxed(560.0, 320.0).child( + ERDiagram::new("devtools-erd", view, tables(), places) + .on_move(move |name, (dx, dy), _, cx| { + change(&moved, cx, |map| { + let place = map.places.iter_mut().find(|(each, _)| each == name).expect("a placed table"); + place.1 = (place.1.0 + dx, place.1.1 + dy); + }) + }) + .on_viewport(move |next, _, cx| change(&viewed, cx, |map| map.view = next)), + ), + ), + ) +} + +/// The structure demo's fields and indexes. +struct Build { + fields: Vec, + indexes: Vec, +} + +pub fn structure(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let build = keep( + "devtools-structure", + || Build { + fields: tables()[1].fields.clone(), + indexes: vec![ + Index { + name: "orders_pkey".into(), + fields: vec!["id".into()], + unique: true, + method: "btree".into(), + }, + Index { + name: "orders_user_placed".into(), + fields: vec!["user_id".into(), "placed_at".into()], + unique: false, + method: "btree".into(), + }, + ], + }, + window, + cx, + ); + let now = build.read(cx); + let (fields, indexes) = (now.fields.clone(), now.indexes.clone()); + let names: Vec = fields.iter().map(|field| field.name.clone()).collect(); + let [edited, dropped, created] = [(); 3].map(|_| build.clone()); + section( + "TableStructureEditor · IndexManager", + "A table's fields edited in place: a name or a default rewritten on a press, a type, whether it may be empty, and the key. Its indexes list their fields and method, and a new one is drawn up from the table's fields.", + cx, + ) + .child( + div() + .flex() + .flex_wrap() + .gap_6() + .child( + div().w(px(620.)).child( + TableStructureEditor::new("devtools-structure", fields) + .on_change(move |next, _, cx| change(&edited, cx, |build| build.fields = next)), + ), + ) + .child( + div().w(px(300.)).child( + IndexManager::new("devtools-indexes", indexes, names) + .on_drop(move |name, _, cx| change(&dropped, cx, |build| build.indexes.retain(|index| index.name != *name))) + .on_create(move |index, _, cx| change(&created, cx, |build| build.indexes.push(index))), + ), + ), + ) +} diff --git a/examples/gallery/pages/devtools/mod.rs b/examples/gallery/pages/devtools/mod.rs new file mode 100644 index 0000000..002baff --- /dev/null +++ b/examples/gallery/pages/devtools/mod.rs @@ -0,0 +1,28 @@ +use gpui::{AnyElement, App, Window, div, prelude::*}; + +mod data; +mod db; + +use super::Page; +use crate::script::Step; + +pub const PAGE: Page = Page { + number: 33, + slug: "devtools", + title: "DB & Dev Tools", + summary: "Connections, schemas and queries, data to read, requests to send, and the machines that run them.", + render, + script: SCRIPT, +}; + +const SCRIPT: &[Step] = &[]; + +fn render(window: &mut Window, cx: &mut App) -> AnyElement { + div() + .child(db::connections(window, cx)) + .child(db::schema(window, cx)) + .child(data::queries(window, cx)) + .child(db::structure(window, cx)) + .child(data::redis(window, cx)) + .into_any_element() +} diff --git a/examples/gallery/pages/mod.rs b/examples/gallery/pages/mod.rs index e1a0e3f..001e6bb 100644 --- a/examples/gallery/pages/mod.rs +++ b/examples/gallery/pages/mod.rs @@ -7,6 +7,7 @@ mod chat; mod collab; mod data; mod debug; +mod devtools; mod documents; mod editor; mod feedback; @@ -79,6 +80,7 @@ pub const ALL: &[Page] = &[ calendar::PAGE, project::PAGE, canvas::PAGE, + devtools::PAGE, theme::PAGE, ]; diff --git a/src/canvas/mod.rs b/src/canvas/mod.rs index d00fc4f..a246808 100644 --- a/src/canvas/mod.rs +++ b/src/canvas/mod.rs @@ -53,6 +53,7 @@ pub use mindmap_view::MindMap; pub use minimap::MiniMap; pub use nodes::NodeGraph; pub use palette::{BrushSettings, ToolPalette}; +pub(crate) use panel::{OnEdit, editing}; pub use plane::InfiniteCanvas; pub use shape::{Artboard, Corner, Guide, Shape, ShapeKind}; pub use style::{Border, BorderEditor, ColorPanel, Dash, Shadow, ShadowEditor}; diff --git a/src/canvas/nodes.rs b/src/canvas/nodes.rs index 1ff99e5..37e6191 100644 --- a/src/canvas/nodes.rs +++ b/src/canvas/nodes.rs @@ -33,6 +33,7 @@ pub struct NodeGraph { on_connect: Option, on_disconnect: Option, on_viewport: Option, + fixed: bool, } impl NodeGraph { @@ -53,9 +54,16 @@ impl NodeGraph { on_connect: None, on_disconnect: None, on_viewport: None, + fixed: false, } } + /// Wires stay as the owner gives them: only sockets that hold a wire show, none starts or lifts one, and a press there pans. + pub fn fixed_wires(mut self) -> Self { + self.fixed = true; + self + } + pub fn selected(mut self, keys: impl IntoIterator>) -> Self { self.selected = keys.into_iter().map(Into::into).collect(); self @@ -179,6 +187,7 @@ impl RenderOnce for NodeGraph { .on_drag(Pulling { owner }, |_, _, _, cx| cx.new(|_| EmptyView)) .child(Ellipsis::new(node.title.clone())); let socket = |out: bool, row: usize, port: &super::graph::Port| { + let fixed = self.fixed; let (press, edges, node_key) = (hold.clone(), edges.clone(), node.key.clone()); let (port_key, kind) = (port.key.clone(), port.kind.clone()); let nodes = nodes.clone(); @@ -203,6 +212,9 @@ impl RenderOnce for NodeGraph { .bg(hue(&kind)) .cursor_crosshair() .on_mouse_down(MouseButton::Left, move |event, _, cx| { + if fixed { + return; + } cx.stop_propagation(); let bounds = press.read(cx).bounds; let at = point_of(event.position, bounds); @@ -275,15 +287,24 @@ impl RenderOnce for NodeGraph { .children(output.map(|port| Ellipsis::new(port.name.clone()))), ) })); + let wired = |out: bool, port: &super::graph::Port| { + edges.iter().any(|edge| { + let end = if out { &edge.from } else { &edge.to }; + end.0 == node.key && end.1 == port.key + }) + }; + let shown = |out: bool, port: &super::graph::Port| !self.fixed || wired(out, port); let sockets: Vec = node .inputs .iter() .enumerate() + .filter(|(_, port)| shown(false, port)) .map(|(row, port)| socket(false, row, port).into_any_element()) .chain( node.outputs .iter() .enumerate() + .filter(|(_, port)| shown(true, port)) .map(|(row, port)| socket(true, row, port).into_any_element()), ) .collect(); diff --git a/src/devtools/connection_form.rs b/src/devtools/connection_form.rs new file mode 100644 index 0000000..8afd89e --- /dev/null +++ b/src/devtools/connection_form.rs @@ -0,0 +1,282 @@ +use std::rc::Rc; + +use gpui::{ + App, AppContext as _, ElementId, Entity, IntoElement, ParentElement, RenderOnce, SharedString, + Styled, Window, div, +}; + +use super::connections::{Connection, Engine}; +use crate::{ + buttons::{Button, ButtonVariant}, + feedback::InlineMessage, + forms::{Choice, FormField, Input, PasswordInput, Select, Switch, TextInput}, + primitives::Severity, +}; + +type OnConnection = Rc; + +/// What trying a connection came to. +#[derive(Clone, Debug, PartialEq)] +pub enum TestState { + Testing, + /// It answered, in so many milliseconds. + Passed(u32), + Failed(SharedString), +} + +/// The port a field names, from 1 to 65535. +fn port(field: &Entity, cx: &App) -> Option { + field + .read(cx) + .text() + .trim() + .parse() + .ok() + .filter(|port| *port > 0) +} + +/// The form's own fields, filled from the connection named by `seed`. +struct Fields { + seed: SharedString, + name: Entity, + host: Entity, + database: Entity, + user: Entity, + password: Entity, + port: Entity, + engine: Engine, + tls: bool, +} + +impl Fields { + fn filled(connection: &Connection, window: &mut Window, cx: &mut App) -> Self { + let mut field = |text: &SharedString| { + let text = text.to_string(); + cx.new(|cx| { + let mut input = TextInput::new(window, cx); + input.set_text(text, cx); + input + }) + }; + Self { + seed: connection.key.clone(), + name: field(&connection.name), + host: field(&connection.host), + database: field(&connection.database), + user: field(&connection.user), + password: field(&connection.password), + port: field( + &connection + .port + .map(|port| port.to_string()) + .unwrap_or_default() + .into(), + ), + engine: connection.engine, + tls: connection.tls, + } + } + + fn read(&self, from: &Connection, cx: &App) -> Connection { + let text = |input: &Entity| { + SharedString::from(input.read(cx).text().trim().to_string()) + }; + Connection { + key: from.key.clone(), + name: text(&self.name), + engine: self.engine, + host: text(&self.host), + port: port(&self.port, cx), + database: text(&self.database), + user: text(&self.user), + password: SharedString::from(self.password.read(cx).text().to_string()), + tls: self.tls, + state: from.state.clone(), + } + } +} + +/// A connection to fill in: its name and engine, then host and port, database, user, password and TLS, or only a file for SQLite. A new connection from the owner refills it. Test asks the owner to try it and shows what the owner reports; Save hands the owner the connection. Both wait for a name, and for a host or a file. +#[derive(IntoElement)] +pub struct ConnectionForm { + id: ElementId, + connection: Connection, + test: Option, + on_test: Option, + on_save: Option, +} + +impl ConnectionForm { + pub fn new(id: impl Into, connection: Connection) -> Self { + Self { + id: id.into(), + connection, + test: None, + on_test: None, + on_save: None, + } + } + + /// What the owner's last try came to. + pub fn test(mut self, state: TestState) -> Self { + self.test = Some(state); + self + } + + pub fn on_test( + mut self, + handler: impl Fn(Connection, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_test = Some(Rc::new(handler)); + self + } + + pub fn on_save( + mut self, + handler: impl Fn(Connection, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_save = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for ConnectionForm { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let (id, connection) = (self.id, self.connection); + let fields = window.use_keyed_state((id.clone(), "fields"), cx, |window, cx| { + Fields::filled(&connection, window, cx) + }); + if fields.read(cx).seed != connection.key { + log::info!("connection form: filled from {}", connection.key); + let fresh = Fields::filled(&connection, window, cx); + fields.update(cx, |fields, _| *fields = fresh); + } + let now = fields.read(cx); + let (engine, tls) = (now.engine, now.tls); + let (name, host, database, user, password, port_field) = ( + now.name.clone(), + now.host.clone(), + now.database.clone(), + now.user.clone(), + now.password.clone(), + now.port.clone(), + ); + let file = engine == Engine::Sqlite; + let filled = |input: &Entity| !input.read(cx).text().trim().is_empty(); + let good_port = file || port(&port_field, cx).is_some(); + let ready = filled(&name) && filled(if file { &database } else { &host }) && good_port; + 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| { + FormField::new((id.clone(), key), label).child(control) + }; + let ask = |handler: Option, what: &'static str| { + let (fields, from) = (fields.clone(), connection.clone()); + move |_: &gpui::ClickEvent, window: &mut Window, cx: &mut App| { + let connection = fields.read(cx).read(&from, cx); + log::info!("connection form: {what} {}", connection.key); + if let Some(handler) = &handler { + handler(connection, window, cx); + } + } + }; + let message = self.test.map(|test| match test { + TestState::Testing => InlineMessage::new(Severity::Info, "Trying the connection…"), + TestState::Passed(ms) => { + InlineMessage::new(Severity::Success, format!("Connected in {ms} ms")) + } + TestState::Failed(why) => InlineMessage::new(Severity::Danger, why), + }); + div() + .flex() + .flex_col() + .gap_4() + .child(field("name", "Name", Input::new(&name).into_any_element())) + .child(field( + "engine", + "Engine", + Select::new( + (id.clone(), "engine-select"), + Engine::ALL + .iter() + .map(|engine| Choice::new(engine.name(), engine.name())), + ) + .selected(engine.name()) + .on_change(move |value, _, cx| { + chosen.update(cx, |fields, cx| { + fields.engine = Engine::named(value); + let port = fields.engine.port().map(|port| port.to_string()); + fields + .port + .update(cx, |input, cx| input.set_text(port.unwrap_or_default(), cx)); + cx.notify(); + }) + }) + .into_any_element(), + )) + .children(file.then(|| field("file", "File", Input::new(&database).into_any_element()))) + .children((!file).then(|| { + div() + .flex() + .flex_wrap() + .gap_3() + .child(div().flex_1().min_w_40().child(field( + "host", + "Host", + Input::new(&host).into_any_element(), + ))) + .child(div().w_32().flex_none().child({ + let mut port = FormField::new((id.clone(), "port"), "Port") + .child(Input::new(&port_field).invalid(!good_port)); + if !good_port { + port = port.error("From 1 to 65535"); + } + port + })) + })) + .children((!file).then(|| { + field( + "database", + "Database", + Input::new(&database).into_any_element(), + ) + })) + .children((!file).then(|| field("user", "User", Input::new(&user).into_any_element()))) + .children((!file).then(|| { + field( + "password", + "Password", + PasswordInput::new(&password).into_any_element(), + ) + })) + .children((!file).then(|| { + Switch::new((id.clone(), "tls"), tls) + .label("Use TLS") + .on_change(move |on, _, cx| { + switched.update(cx, |fields, cx| { + fields.tls = on; + cx.notify(); + }) + }) + })) + .children(message) + .child( + div() + .flex() + .justify_end() + .gap_2() + .child( + Button::new((id.clone(), "test"), "Test") + .variant(ButtonVariant::Secondary) + .disabled(!ready || testing) + .on_click(ask(self.on_test, "test")), + ) + .child( + Button::new((id, "save"), "Save") + .variant(ButtonVariant::Primary) + .disabled(!ready) + .on_click(ask(self.on_save, "save")), + ), + ) + } +} diff --git a/src/devtools/connections.rs b/src/devtools/connections.rs new file mode 100644 index 0000000..2637f35 --- /dev/null +++ b/src/devtools/connections.rs @@ -0,0 +1,288 @@ +use std::rc::Rc; + +use gpui::{ + App, ElementId, IntoElement, ParentElement, RenderOnce, SharedString, Styled, Window, div, +}; + +use crate::{ + buttons::{Button, ButtonVariant, IconButton}, + data_display::{Badge, Tone}, + lists::{ListItem, SelectableList, row_action}, + primitives::{Icon, IconName}, + theme::{ActiveTheme, IconSize}, +}; + +/// A database a connection reaches. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Engine { + Postgres, + MySql, + Sqlite, + Redis, + MongoDb, +} + +impl Engine { + pub const ALL: [Engine; 5] = [ + Engine::Postgres, + Engine::MySql, + Engine::Sqlite, + Engine::Redis, + Engine::MongoDb, + ]; + + pub fn name(self) -> &'static str { + match self { + Engine::Postgres => "PostgreSQL", + Engine::MySql => "MySQL", + Engine::Sqlite => "SQLite", + Engine::Redis => "Redis", + Engine::MongoDb => "MongoDB", + } + } + + /// The port it listens on unless told otherwise; SQLite reads a file. + pub fn port(self) -> Option { + match self { + Engine::Postgres => Some(5432), + Engine::MySql => Some(3306), + Engine::Sqlite => None, + Engine::Redis => Some(6379), + Engine::MongoDb => Some(27017), + } + } + + pub(super) fn named(name: &str) -> Engine { + *Engine::ALL + .iter() + .find(|engine| engine.name() == name) + .unwrap_or_else(|| panic!("no engine {name}")) + } +} + +/// Where a connection stands. +#[derive(Clone, Debug, PartialEq)] +pub enum ConnectionState { + Connected, + Idle, + Failed(SharedString), +} + +/// A saved connection: its key and name, the engine, where it reaches, the database, who signs in, and where it stands. For SQLite the database is the file. +#[derive(Clone, Debug, PartialEq)] +pub struct Connection { + pub key: SharedString, + pub name: SharedString, + pub engine: Engine, + pub host: SharedString, + pub port: Option, + pub database: SharedString, + pub user: SharedString, + pub password: SharedString, + pub tls: bool, + pub state: ConnectionState, +} + +impl Connection { + pub fn new( + key: impl Into, + name: impl Into, + engine: Engine, + ) -> Self { + Self { + key: key.into(), + name: name.into(), + engine, + host: "localhost".into(), + port: engine.port(), + database: SharedString::default(), + user: SharedString::default(), + password: SharedString::default(), + tls: false, + state: ConnectionState::Idle, + } + } + + /// Where it reaches, as one line: host and port and any database, or the file. + pub fn address(&self) -> String { + match (self.port, self.database.is_empty()) { + (Some(port), true) => format!("{}:{port}", self.host), + (Some(port), false) => format!("{}:{port}/{}", self.host, self.database), + (None, _) => self.database.to_string(), + } + } +} + +type OnKey = Rc; +type Run = Rc; + +/// Saved connections, each with its engine and address, or why it failed, and a badge for where it stands. Enter or a double press connects; the pencil edits and the bin removes; New asks for a blank one. +#[derive(IntoElement)] +pub struct ConnectionManager { + id: ElementId, + connections: Vec, + selected: Option, + on_select: Option, + on_open: Option, + on_edit: Option, + on_remove: Option, + on_new: Option, +} + +impl ConnectionManager { + pub fn new( + id: impl Into, + connections: impl IntoIterator, + ) -> Self { + Self { + id: id.into(), + connections: connections.into_iter().collect(), + selected: None, + on_select: None, + on_open: None, + on_edit: None, + on_remove: None, + on_new: None, + } + } + + pub fn selected(mut self, key: Option>) -> Self { + self.selected = key.map(Into::into); + self + } + + pub fn on_select( + mut self, + handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_select = Some(Rc::new(handler)); + self + } + + /// Gets the connection to connect, on Enter or a double press. + pub fn on_open( + mut self, + handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_open = Some(Rc::new(handler)); + self + } + + pub fn on_edit( + mut self, + handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_edit = Some(Rc::new(handler)); + self + } + + pub fn on_remove( + mut self, + handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_remove = Some(Rc::new(handler)); + self + } + + pub fn on_new(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self { + self.on_new = Some(Rc::new(handler)); + self + } +} + +fn standing(state: &ConnectionState) -> (Tone, &'static str) { + match state { + ConnectionState::Connected => (Tone::Success, "Connected"), + ConnectionState::Idle => (Tone::Neutral, "Idle"), + ConnectionState::Failed(_) => (Tone::Danger, "Failed"), + } +} + +impl RenderOnce for ConnectionManager { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let id = self.id; + let theme = cx.theme(); + let muted = theme.colors.fg_muted; + let list = self.connections.iter().fold( + SelectableList::new((id.clone(), "list")).selected(self.selected.clone()), + |list, connection| { + let key = connection.key.clone(); + let (tone, words) = standing(&connection.state); + let detail = match &connection.state { + ConnectionState::Failed(why) => why.to_string(), + _ => format!("{} · {}", connection.engine.name(), connection.address()), + }; + let action = |icon: IconName, tip: &'static str, handler: &Option| { + let (key, handler) = (key.clone(), handler.clone()); + row_action( + IconButton::new((id.clone(), format!("{tip}-{key}")), icon) + .tooltip(tip) + .on_click(move |_, window, cx| { + if let Some(handler) = &handler { + handler(&key, window, cx); + } + }), + ) + }; + list.row( + key.clone(), + ListItem::new((id.clone(), format!("row-{key}")), connection.name.clone()) + .description(detail) + .leading( + Icon::new(IconName::Database) + .size(IconSize::Md) + .color(muted), + ) + .trailing( + div() + .flex() + .items_center() + .gap_1() + .child(Badge::new(words).tone(tone).dot()) + .child(action(IconName::Pencil, "Edit", &self.on_edit)) + .child(action(IconName::Trash2, "Remove", &self.on_remove)), + ), + ) + }, + ); + let (on_select, on_open, on_new) = (self.on_select, self.on_open, self.on_new); + let list = list + .on_change(move |keys, window, cx| { + if let (Some(key), Some(on_select)) = (keys.first(), &on_select) { + on_select(key, window, cx); + } + }) + .on_activate(move |key, window, cx| { + log::info!("connection manager: open {key}"); + if let Some(on_open) = &on_open { + on_open(key, window, cx); + } + }); + div() + .flex() + .flex_col() + .gap_2() + .child( + div() + .flex() + .items_center() + .justify_between() + .gap_2() + .child( + div() + .font_weight(gpui::FontWeight::SEMIBOLD) + .child("Connections"), + ) + .child( + Button::new((id, "new"), "New") + .variant(ButtonVariant::Secondary) + .on_click(move |_, window, cx| { + if let Some(on_new) = &on_new { + on_new(window, cx); + } + }), + ), + ) + .child(list) + } +} diff --git a/src/devtools/mod.rs b/src/devtools/mod.rs new file mode 100644 index 0000000..05ad3c9 --- /dev/null +++ b/src/devtools/mod.rs @@ -0,0 +1,16 @@ +mod connection_form; +mod connections; +mod queries; +mod redis; +mod schema; +mod structure; + +#[cfg(all(test, feature = "test-support"))] +mod tests; + +pub use connection_form::{ConnectionForm, TestState}; +pub use connections::{Connection, ConnectionManager, ConnectionState, Engine}; +pub use queries::{Outcome, PlanStep, QueryHistory, QueryPlanViewer, QueryRun}; +pub use redis::{RedisKey, RedisKeyBrowser, RedisValue}; +pub use schema::{DbTable, ERDiagram, Field, FieldKey, Schema, SchemaTree}; +pub use structure::{Index, IndexManager, TableStructureEditor}; diff --git a/src/devtools/queries.rs b/src/devtools/queries.rs new file mode 100644 index 0000000..32396bf --- /dev/null +++ b/src/devtools/queries.rs @@ -0,0 +1,248 @@ +use std::rc::Rc; + +use gpui::{ + App, ElementId, IntoElement, ParentElement, RenderOnce, SharedString, Styled, Window, div, +}; +use jiff::Timestamp; + +use crate::{ + forms::{Input, TextInput}, + lists::{ListItem, SelectableList}, + primitives::{Icon, IconName}, + tables::{Cell, Column, TreeRow, TreeTable}, + theme::{ActiveTheme, IconSize}, + typography::format, +}; + +/// How a query run ended: with so many rows, or failing. +#[derive(Clone, Debug, PartialEq)] +pub enum Outcome { + Rows(u64), + Failed(SharedString), +} + +/// A query that ran: its key, its text, when it ran, how long it took in milliseconds, and how it ended. +#[derive(Clone, Debug, PartialEq)] +pub struct QueryRun { + pub key: SharedString, + pub sql: SharedString, + pub at: Timestamp, + pub took_ms: u32, + pub outcome: Outcome, +} + +type OnKey = Rc; + +/// Queries that ran, newest first as given, found by their text. Each shows its first line, how it ended, how long it took and when; Enter or a double press asks to run one again. +#[derive(IntoElement)] +pub struct QueryHistory { + id: ElementId, + runs: Vec, + search: gpui::Entity, + now: Timestamp, + on_run: Option, +} + +impl QueryHistory { + /// `search` is the find field's text, which the owner keeps; `now` dates each run. + pub fn new( + id: impl Into, + runs: impl IntoIterator, + search: &gpui::Entity, + now: Timestamp, + ) -> Self { + Self { + id: id.into(), + runs: runs.into_iter().collect(), + search: search.clone(), + now, + on_run: None, + } + } + + pub fn on_run( + mut self, + handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_run = Some(Rc::new(handler)); + self + } +} + +/// A run's line under its text: rows or the failure, the time it took, and how long ago. +pub(crate) fn summary(run: &QueryRun, now: Timestamp) -> String { + let ended = match &run.outcome { + Outcome::Rows(rows) => format::plural(*rows, "row", "rows"), + Outcome::Failed(why) => why.to_string(), + }; + format!( + "{ended} · {} ms · {}", + run.took_ms, + format::relative(run.at, now) + ) +} + +impl RenderOnce for QueryHistory { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let query = self.search.read(cx).text().trim().to_lowercase(); + let theme = cx.theme(); + let (good, bad) = (theme.colors.success, theme.colors.danger); + let id = self.id; + let list = self + .runs + .iter() + .filter(|run| run.sql.to_lowercase().contains(&query)) + .fold(SelectableList::new((id.clone(), "runs")), |list, run| { + let first = run.sql.lines().next().unwrap_or_default().to_string(); + let (icon, ink) = match run.outcome { + Outcome::Rows(_) => (IconName::CircleCheck, good), + Outcome::Failed(_) => (IconName::CircleX, bad), + }; + list.row( + run.key.clone(), + ListItem::new((id.clone(), format!("run-{}", run.key)), first) + .description(summary(run, self.now)) + .leading(Icon::new(icon).size(IconSize::Sm).color(ink)), + ) + }); + let on_run = self.on_run; + div() + .flex() + .flex_col() + .gap_2() + .child(Input::new(&self.search).prefix(Icon::new(IconName::Search).size(IconSize::Sm))) + .child(list.on_activate(move |key, window, cx| { + log::info!("query history: run {key} again"); + if let Some(on_run) = &on_run { + on_run(key, window, cx); + } + })) + } +} + +/// A step of a query plan: what it does and on what, its cost, the rows it yields, its time in milliseconds, and the steps it reads from. +#[derive(Clone, Debug, PartialEq)] +pub struct PlanStep { + pub key: SharedString, + pub op: SharedString, + pub on: Option, + pub cost: f64, + pub rows: u64, + pub ms: f64, + pub children: Vec, +} + +impl PlanStep { + pub fn new( + key: impl Into, + op: impl Into, + cost: f64, + rows: u64, + ms: f64, + ) -> Self { + Self { + key: key.into(), + op: op.into(), + on: None, + cost, + rows, + ms, + children: Vec::new(), + } + } + + pub fn on(mut self, target: impl Into) -> Self { + self.on = Some(target.into()); + self + } + + pub fn child(mut self, step: PlanStep) -> Self { + self.children.push(step); + self + } +} + +fn row(step: &PlanStep, whole: f64) -> TreeRow { + let name = match &step.on { + Some(target) => format!("{} on {target}", step.op), + None => step.op.to_string(), + }; + TreeRow::new( + step.key.clone(), + [ + Cell::Text(name.into()), + Cell::Progress((step.cost / whole) as f32), + Cell::Number(step.rows as f64), + Cell::Number(step.ms), + ], + ) + .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. +#[derive(IntoElement)] +pub struct QueryPlanViewer { + id: ElementId, + plan: PlanStep, +} + +impl QueryPlanViewer { + pub fn new(id: impl Into, plan: PlanStep) -> Self { + assert!(plan.cost > 0.0, "a plan costs something, not {}", plan.cost); + Self { + id: id.into(), + plan, + } + } +} + +fn keys(step: &PlanStep, out: &mut Vec) { + out.push(step.key.clone()); + step.children.iter().for_each(|child| keys(child, out)); +} + +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 mut open = Vec::new(); + keys(&self.plan, &mut open); + TreeTable::new( + self.id, + [ + Column::new("step", "Step").width(step), + 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) + } +} + +#[cfg(test)] +mod tests { + use jiff::Timestamp; + + use super::{Outcome, QueryRun, summary}; + + #[test] + fn a_run_says_how_it_ended_how_long_it_took_and_when() { + let now: Timestamp = "2026-09-27T12:00:00Z".parse().expect("a time"); + let run = |outcome| QueryRun { + key: "a".into(), + sql: "select 1".into(), + at: "2026-09-27T11:57:00Z".parse().expect("a time"), + took_ms: 18, + outcome, + }; + assert_eq!( + summary(&run(Outcome::Rows(1)), now), + "1 row · 18 ms · 3 minutes ago" + ); + assert_eq!( + summary(&run(Outcome::Failed("relation missing".into())), now), + "relation missing · 18 ms · 3 minutes ago" + ); + } +} diff --git a/src/devtools/redis.rs b/src/devtools/redis.rs new file mode 100644 index 0000000..3528e5b --- /dev/null +++ b/src/devtools/redis.rs @@ -0,0 +1,268 @@ +use std::rc::Rc; + +use gpui::{ + App, ElementId, Entity, FontWeight, IntoElement, ParentElement, RenderOnce, SharedString, + Styled, Window, div, +}; + +use crate::{ + data_display::{Badge, Tone}, + feedback::EmptyState, + forms::{Input, TextInput}, + lists::{ListItem, SelectableList}, + primitives::{Icon, IconName}, + tables::Table, + theme::{ActiveTheme, IconSize, TextSize}, + typography::{Ellipsis, format}, +}; + +/// What a Redis key holds. +#[derive(Clone, Debug, PartialEq)] +pub enum RedisValue { + Text(SharedString), + Hash(Vec<(SharedString, SharedString)>), + List(Vec), + Set(Vec), + Sorted(Vec<(SharedString, f64)>), +} + +impl RedisValue { + /// The type's name as Redis gives it. + pub fn kind(&self) -> &'static str { + match self { + RedisValue::Text(_) => "string", + RedisValue::Hash(_) => "hash", + RedisValue::List(_) => "list", + RedisValue::Set(_) => "set", + RedisValue::Sorted(_) => "zset", + } + } + + fn tone(&self) -> Tone { + match self { + RedisValue::Text(_) => Tone::Neutral, + RedisValue::Hash(_) => Tone::Accent, + RedisValue::List(_) => Tone::Info, + RedisValue::Set(_) => Tone::Success, + RedisValue::Sorted(_) => Tone::Warning, + } + } +} + +/// A key, what it holds, and the seconds it has left, or none when it keeps. +#[derive(Clone, Debug, PartialEq)] +pub struct RedisKey { + pub key: SharedString, + pub value: RedisValue, + pub ttl: Option, +} + +/// Whether `key` matches a Redis glob: `*` for any run, `?` for one character. +pub(crate) fn matches(pattern: &str, key: &str) -> bool { + fn go(pattern: &[char], key: &[char]) -> bool { + match (pattern.first(), key.first()) { + (None, None) => true, + (Some('*'), _) => go(&pattern[1..], key) || (!key.is_empty() && go(pattern, &key[1..])), + (Some('?'), Some(_)) => go(&pattern[1..], &key[1..]), + (Some(p), Some(k)) if p == k => go(&pattern[1..], &key[1..]), + _ => false, + } + } + let (pattern, key): (Vec, Vec) = (pattern.chars().collect(), key.chars().collect()); + go(&pattern, &key) +} + +type OnKey = Rc; + +/// Keys found by a glob, each with its type and time left; the one selected shows its value laid out for its type: text, a hash's fields, a list's items by index, a set's members, a sorted set's members by score. +#[derive(IntoElement)] +pub struct RedisKeyBrowser { + id: ElementId, + keys: Vec, + pattern: Entity, + selected: Option, + on_select: Option, +} + +impl RedisKeyBrowser { + /// `pattern` is the glob field's text, which the owner keeps; empty finds every key. + pub fn new( + id: impl Into, + keys: impl IntoIterator, + pattern: &Entity, + ) -> Self { + Self { + id: id.into(), + keys: keys.into_iter().collect(), + pattern: pattern.clone(), + selected: None, + on_select: None, + } + } + + pub fn selected(mut self, key: Option>) -> Self { + self.selected = key.map(Into::into); + self + } + + pub fn on_select( + mut self, + handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_select = Some(Rc::new(handler)); + self + } +} + +fn value(key: &RedisKey, cx: &App) -> gpui::AnyElement { + let theme = cx.theme(); + let text = |words: &SharedString| words.clone(); + match &key.value { + RedisValue::Text(words) => div() + .p_3() + .rounded(theme.radius(crate::theme::Radius::Md)) + .bg(theme.colors.sunken) + .font_family(theme.mono_family.clone()) + .text_size(theme.text_size(TextSize::Sm)) + .child(words.clone()) + .into_any_element(), + RedisValue::Hash(fields) => fields + .iter() + .fold(Table::new(["Field", "Value"]), |table, (field, value)| { + table.row([text(field), text(value)]) + }) + .into_any_element(), + RedisValue::List(items) => items + .iter() + .enumerate() + .fold(Table::new(["Index", "Item"]), |table, (ix, item)| { + table.row([SharedString::from(ix.to_string()), text(item)]) + }) + .into_any_element(), + RedisValue::Set(members) => members + .iter() + .fold(Table::new(["Member"]), |table, member| { + table.row([text(member)]) + }) + .into_any_element(), + RedisValue::Sorted(members) => members + .iter() + .fold(Table::new(["Member", "Score"]), |table, (member, score)| { + table.row([ + text(member), + SharedString::from(format::number(*score, 2, format::Separators::EN)), + ]) + }) + .into_any_element(), + } +} + +impl RenderOnce for RedisKeyBrowser { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let id = self.id; + let pattern = self.pattern.read(cx).text().trim().to_string(); + let glob = if pattern.is_empty() { + "*".to_string() + } else { + pattern + }; + let found: Vec<&RedisKey> = self + .keys + .iter() + .filter(|key| matches(&glob, &key.key)) + .collect(); + let theme = cx.theme(); + let muted = theme.colors.fg_muted; + let list = found.iter().fold( + SelectableList::new((id.clone(), "keys")).selected(self.selected.clone()), + |list, key| { + let ttl = key.ttl.map_or("No expiry".to_string(), |seconds| { + format!( + "{} left", + format::duration(seconds, format::DurationStyle::Compact) + ) + }); + list.row( + key.key.clone(), + ListItem::new((id.clone(), format!("key-{}", key.key)), key.key.clone()) + .description(ttl) + .leading(Icon::new(IconName::Key).size(IconSize::Sm).color(muted)) + .trailing(Badge::new(key.value.kind()).tone(key.value.tone())), + ) + }, + ); + let on_select = self.on_select; + let list = list.on_change(move |keys, window, cx| { + if let (Some(key), Some(on_select)) = (keys.first(), &on_select) { + on_select(key, window, cx); + } + }); + let shown = self + .selected + .as_ref() + .and_then(|selected| self.keys.iter().find(|key| key.key == *selected)); + let detail = match shown { + Some(key) => div() + .flex() + .flex_col() + .gap_3() + .child( + div() + .flex() + .items_center() + .gap_2() + .child( + div() + .flex_1() + .min_w_0() + .font_weight(FontWeight::SEMIBOLD) + .child(Ellipsis::new(key.key.clone())), + ) + .child( + div() + .flex_none() + .child(Badge::new(key.value.kind()).tone(key.value.tone())), + ), + ) + .child(value(key, cx)) + .into_any_element(), + None => EmptyState::new((id.clone(), "none"), IconName::Key, "No key selected") + .body("Pick a key to see what it holds.") + .into_any_element(), + }; + div() + .flex() + .flex_wrap() + .gap_4() + .child( + div() + .flex_1() + .min_w(theme.label_width() * 2.) + .flex() + .flex_col() + .gap_2() + .child( + Input::new(&self.pattern) + .prefix(Icon::new(IconName::Search).size(IconSize::Sm)), + ) + .child(format!("{} of {} keys", found.len(), self.keys.len())) + .child(list), + ) + .child(div().flex_1().min_w(theme.label_width() * 2.).child(detail)) + } +} + +#[cfg(test)] +mod tests { + use super::matches; + + #[test] + fn globs_match_runs_and_single_characters() { + assert!(matches("user:*", "user:42")); + assert!(matches("user:*:name", "user:42:name")); + assert!(!matches("user:*:name", "user:42:mail")); + assert!(matches("h?llo", "hello")); + assert!(!matches("h?llo", "hllo")); + assert!(matches("*", "")); + } +} diff --git a/src/devtools/schema.rs b/src/devtools/schema.rs new file mode 100644 index 0000000..3b0c44d --- /dev/null +++ b/src/devtools/schema.rs @@ -0,0 +1,344 @@ +use std::rc::Rc; + +use gpui::{App, ElementId, IntoElement, RenderOnce, SharedString, Styled, Window}; + +use crate::{ + canvas::{Edge, Node, NodeGraph, Port, Viewport}, + lists::{Tree, TreeNode}, + primitives::IconName, +}; + +/// The key a field takes part in: its table's own, or one that points at another table's field. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum FieldKey { + Primary, + Foreign { + table: SharedString, + field: SharedString, + }, +} + +/// A field of a table: its name and type, whether it may be empty, the key it takes part in, and its default. +#[derive(Clone, Debug, PartialEq)] +pub struct Field { + pub name: SharedString, + pub ty: SharedString, + pub nullable: bool, + pub key: Option, + pub default: Option, +} + +impl Field { + pub fn new(name: impl Into, ty: impl Into) -> Self { + Self { + name: name.into(), + ty: ty.into(), + nullable: false, + key: None, + default: None, + } + } + + pub fn nullable(mut self) -> Self { + self.nullable = true; + self + } + + pub fn primary(mut self) -> Self { + self.key = Some(FieldKey::Primary); + self + } + + /// Points at `field` of `table`. + pub fn refers( + mut self, + table: impl Into, + field: impl Into, + ) -> Self { + self.key = Some(FieldKey::Foreign { + table: table.into(), + field: field.into(), + }); + self + } + + pub fn default_value(mut self, value: impl Into) -> Self { + self.default = Some(value.into()); + self + } + + /// Its type with the key it takes part in. + fn typed(&self) -> SharedString { + match &self.key { + Some(FieldKey::Primary) => format!("{} · PK", self.ty).into(), + Some(FieldKey::Foreign { .. }) => format!("{} · FK", self.ty).into(), + None => self.ty.clone(), + } + } +} + +/// A table, or a view, and its fields. +#[derive(Clone, Debug, PartialEq)] +pub struct DbTable { + pub name: SharedString, + pub view: bool, + pub fields: Vec, +} + +impl DbTable { + pub fn new(name: impl Into, fields: impl IntoIterator) -> Self { + Self { + name: name.into(), + view: false, + fields: fields.into_iter().collect(), + } + } + + pub fn view(mut self) -> Self { + self.view = true; + self + } +} + +/// A schema of a database, and its tables and views. +#[derive(Clone, Debug, PartialEq)] +pub struct Schema { + pub name: SharedString, + pub tables: Vec, +} + +type OnKeys = Rc; + +/// Schemas, their tables and views, and each one's fields with its type; keys are marked. A row's key is its path: `schema`, `schema.table` or `schema.table.field`. It fills its box, which the host sizes. +#[derive(IntoElement)] +pub struct SchemaTree { + id: ElementId, + schemas: Vec, + open: Vec, + selected: Vec, + on_select: Option, +} + +impl SchemaTree { + pub fn new(id: impl Into, schemas: impl IntoIterator) -> Self { + Self { + id: id.into(), + schemas: schemas.into_iter().collect(), + open: Vec::new(), + selected: Vec::new(), + on_select: None, + } + } + + /// The paths open at first. + pub fn open(mut self, keys: impl IntoIterator>) -> Self { + self.open = keys.into_iter().map(Into::into).collect(); + self + } + + pub fn selected(mut self, keys: impl IntoIterator>) -> Self { + self.selected = keys.into_iter().map(Into::into).collect(); + self + } + + pub fn on_select( + mut self, + handler: impl Fn(&[SharedString], &mut Window, &mut App) + 'static, + ) -> Self { + self.on_select = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for SchemaTree { + fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement { + let nodes = self.schemas.iter().map(|schema| { + TreeNode::new(schema.name.clone(), schema.name.clone()) + .icon(IconName::Database) + .children(schema.tables.iter().map(|table| { + let path = format!("{}.{}", schema.name, table.name); + TreeNode::new(path.clone(), table.name.clone()) + .icon(if table.view { + IconName::Eye + } else { + IconName::Table + }) + .children(table.fields.iter().map(|field| { + let icon = match field.key { + Some(FieldKey::Primary) => IconName::Key, + Some(FieldKey::Foreign { .. }) => IconName::Link, + None => IconName::Columns2, + }; + TreeNode::new(format!("{path}.{}", field.name), field.name.clone()) + .icon(icon) + .note(field.ty.clone()) + })) + })) + }); + let tree = Tree::new(self.id, nodes) + .open(self.open) + .selected(self.selected) + .size_full(); + match self.on_select { + Some(on_select) => tree.on_select(move |keys, window, cx| on_select(keys, window, cx)), + None => tree, + } + } +} + +type OnNudge = Rc; +type OnViewport = Rc; + +/// Tables as boxes of their fields, names down the left and types down the right, with a line from each foreign key to the field it points at. A table's title drags it; the owner keeps where each one sits. A key that points at no field fails loud. +#[derive(IntoElement)] +pub struct ERDiagram { + id: ElementId, + viewport: Viewport, + tables: Vec, + places: Vec<(SharedString, (f32, f32))>, + on_move: Option, + on_viewport: Option, +} + +impl ERDiagram { + /// `places` sets where each table sits, in canvas units, by name. + pub fn new( + id: impl Into, + viewport: Viewport, + tables: impl IntoIterator, + places: impl IntoIterator, (f32, f32))>, + ) -> Self { + Self { + id: id.into(), + viewport, + tables: tables.into_iter().collect(), + places: places + .into_iter() + .map(|(name, at)| (name.into(), at)) + .collect(), + on_move: None, + on_viewport: None, + } + } + + pub fn on_move( + mut self, + handler: impl Fn(&SharedString, (f32, f32), &mut Window, &mut App) + 'static, + ) -> Self { + self.on_move = Some(Rc::new(handler)); + self + } + + pub fn on_viewport( + mut self, + handler: impl Fn(Viewport, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_viewport = Some(Rc::new(handler)); + self + } +} + +/// The diagram's nodes and wires: a node per table and a wire per foreign key. +pub(crate) fn diagram( + tables: &[DbTable], + places: &[(SharedString, (f32, f32))], +) -> (Vec, Vec) { + let nodes = tables + .iter() + .map(|table| { + let at = places + .iter() + .find(|(name, _)| *name == table.name) + .unwrap_or_else(|| panic!("ER diagram: no place for {}", table.name)) + .1; + table.fields.iter().fold( + Node::new(table.name.clone(), table.name.clone(), at), + |node, field| { + node.input(Port::new(field.name.clone(), field.name.clone(), "field")) + .output(Port::new(field.name.clone(), field.typed(), "field")) + }, + ) + }) + .collect(); + let edges = tables + .iter() + .flat_map(|table| { + table + .fields + .iter() + .filter_map(move |field| match &field.key { + Some(FieldKey::Foreign { + table: target, + field: to, + }) => Some(Edge::new( + (table.name.clone(), field.name.clone()), + (target.clone(), to.clone()), + )), + _ => None, + }) + }) + .collect(); + (nodes, edges) +} + +impl RenderOnce for ERDiagram { + fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement { + let (nodes, edges) = diagram(&self.tables, &self.places); + let mut graph = NodeGraph::new(self.id, self.viewport, nodes, edges).fixed_wires(); + if let Some(on_move) = self.on_move { + graph = graph.on_move(move |key, by, window, cx| on_move(key, by, window, cx)); + } + if let Some(on_viewport) = self.on_viewport { + graph = graph.on_viewport(move |view, window, cx| on_viewport(view, window, cx)); + } + graph + } +} + +#[cfg(test)] +mod tests { + use super::{DbTable, Field, diagram}; + + #[test] + fn a_foreign_key_draws_a_wire_to_the_field_it_points_at() { + let tables = [ + DbTable::new( + "users", + [ + Field::new("id", "int").primary(), + Field::new("email", "text"), + ], + ), + DbTable::new( + "orders", + [ + Field::new("id", "int").primary(), + Field::new("user_id", "int").refers("users", "id"), + ], + ), + ]; + let (nodes, edges) = diagram( + &tables, + &[ + ("users".into(), (0.0, 0.0)), + ("orders".into(), (300.0, 0.0)), + ], + ); + assert_eq!(nodes[1].outputs[1].name, "int · FK"); + assert_eq!(edges.len(), 1); + assert_eq!( + ( + edges[0].from.0.as_ref(), + edges[0].to.0.as_ref(), + edges[0].to.1.as_ref() + ), + ("orders", "users", "id") + ); + } + + #[test] + #[should_panic(expected = "no place for orders")] + fn a_table_without_a_place_fails_loud() { + diagram(&[DbTable::new("orders", [])], &[]); + } +} diff --git a/src/devtools/structure.rs b/src/devtools/structure.rs new file mode 100644 index 0000000..0f293ee --- /dev/null +++ b/src/devtools/structure.rs @@ -0,0 +1,421 @@ +use std::rc::Rc; + +use gpui::{ + App, AppContext as _, ElementId, Entity, FontWeight, IntoElement, ParentElement, RenderOnce, + SharedString, Styled, Window, div, relative, +}; + +use super::schema::{Field, FieldKey}; +use crate::{ + buttons::{Button, ButtonVariant, IconButton}, + canvas::{OnEdit, editing}, + data_display::{Badge, Tone}, + forms::{Checkbox, Choice, InlineEdit, Input, MultiSelect, Select, Switch, TextInput}, + primitives::IconName, + theme::{ActiveTheme, TextSize}, + typography::Ellipsis, +}; + +/// The types a field's select offers; a field of any other keeps its own. +const TYPES: [&str; 10] = [ + "int", + "bigint", + "text", + "varchar(255)", + "boolean", + "numeric", + "date", + "timestamptz", + "uuid", + "jsonb", +]; + +/// Each column's share of a row: name, type, may be empty, primary key, default, and the remove button. +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)| { + row.child( + div() + .flex_none() + .w(relative(share)) + .min_w_0() + .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. +#[derive(IntoElement)] +pub struct TableStructureEditor { + id: ElementId, + fields: Vec, + on_change: Option>>, +} + +impl TableStructureEditor { + pub fn new(id: impl Into, fields: impl IntoIterator) -> Self { + Self { + id: id.into(), + fields: fields.into_iter().collect(), + on_change: None, + } + } + + pub fn on_change( + mut self, + handler: impl Fn(Vec, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_change = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for TableStructureEditor { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let (id, fields, on_change) = (self.id, self.fields, &self.on_change); + let theme = cx.theme(); + let heading = |text: &'static str| { + div() + .text_size(theme.text_size(TextSize::Xs)) + .text_color(theme.colors.fg_subtle) + .child(text) + .into_any_element() + }; + let head = cells([ + heading("Name"), + heading("Type"), + heading("Empty"), + heading("Key"), + heading("Default"), + div().into_any_element(), + ]); + let rows = fields.iter().enumerate().map(|(ix, field)| { + let at = |edit: fn(&mut Field, SharedString)| { + editing( + "table structure", + &fields, + on_change, + move |all: &mut Vec, value| edit(&mut all[ix], value), + ) + }; + let flag = |edit: fn(&mut Field, bool)| { + editing( + "table structure", + &fields, + on_change, + move |all: &mut Vec, on: bool| edit(&mut all[ix], on), + ) + }; + let named = at(|field, name| field.name = name); + let typed = at(|field, ty| field.ty = ty); + let defaulted = at(|field, value| field.default = (!value.is_empty()).then_some(value)); + let (empty, primary) = ( + flag(|field, on| field.nullable = on), + flag(|field, on| field.key = on.then_some(FieldKey::Primary)), + ); + let remove = editing( + "table structure", + &fields, + on_change, + move |all: &mut Vec, _: ()| { + all.remove(ix); + }, + ); + let mut types: Vec = TYPES.iter().map(|ty| Choice::new(*ty, *ty)).collect(); + if !TYPES.contains(&field.ty.as_ref()) { + types.insert(0, Choice::new(field.ty.clone(), field.ty.clone())); + } + let key = match &field.key { + Some(FieldKey::Foreign { table, field: to }) => { + Badge::new(format!("→ {table}.{to}")) + .tone(Tone::Info) + .into_any_element() + } + key => Checkbox::new( + (id.clone(), format!("primary-{ix}")), + *key == Some(FieldKey::Primary), + ) + .on_change(move |on, window, cx| primary(on, window, cx)) + .into_any_element(), + }; + cells([ + InlineEdit::new((id.clone(), format!("name-{ix}")), field.name.clone()) + .on_commit(move |name, window, cx| named(name.clone(), window, cx)) + .into_any_element(), + Select::new((id.clone(), format!("type-{ix}")), types) + .selected(field.ty.clone()) + .on_change(move |ty, window, cx| typed(ty.clone(), window, cx)) + .into_any_element(), + Checkbox::new((id.clone(), format!("empty-{ix}")), field.nullable) + .on_change(move |on, window, cx| empty(on, window, cx)) + .into_any_element(), + key, + InlineEdit::new( + (id.clone(), format!("default-{ix}")), + field.default.clone().unwrap_or_default(), + ) + .placeholder("None") + .on_commit(move |value, window, cx| defaulted(value.clone(), window, cx)) + .into_any_element(), + IconButton::new((id.clone(), format!("remove-{ix}")), IconName::Minus) + .tooltip("Remove the field") + .on_click(move |_, window, cx| remove((), window, cx)) + .into_any_element(), + ]) + }); + let add = editing( + "table structure", + &fields, + on_change, + |all: &mut Vec, _: ()| { + let name = (1..) + .map(|n| format!("field_{n}")) + .find(|name| all.iter().all(|field| field.name != name.as_str())) + .expect("a free name"); + all.push(Field::new(name, "text").nullable()); + }, + ); + div() + .flex() + .flex_col() + .gap_2() + .child(head) + .children(rows) + .child( + div().flex().child( + Button::new((id, "add"), "Add field") + .variant(ButtonVariant::Secondary) + .on_click(move |_, window, cx| add((), window, cx)), + ), + ) + } +} + +/// An index on a table: its name, its fields in order, whether it keeps them unique, and its method. +#[derive(Clone, Debug, PartialEq)] +pub struct Index { + pub name: SharedString, + pub fields: Vec, + pub unique: bool, + pub method: SharedString, +} + +const METHODS: [&str; 4] = ["btree", "hash", "gin", "gist"]; + +type OnName = Rc; +type OnIndex = Rc; + +/// A new index being drawn up: its name field, fields, uniqueness and method. +struct Draft { + name: Entity, + fields: Vec, + unique: bool, + method: SharedString, +} + +/// A table's indexes, each with its fields, its method and whether it is unique, and a button to drop it; under them, a new one to draw up from the table's fields. Create waits for a name and a field, and asks the owner. +#[derive(IntoElement)] +pub struct IndexManager { + id: ElementId, + indexes: Vec, + fields: Vec, + on_drop: Option, + on_create: Option, +} + +impl IndexManager { + /// `fields` are the table's, which a new index picks from. + pub fn new( + id: impl Into, + indexes: impl IntoIterator, + fields: impl IntoIterator>, + ) -> Self { + Self { + id: id.into(), + indexes: indexes.into_iter().collect(), + fields: fields.into_iter().map(Into::into).collect(), + on_drop: None, + on_create: None, + } + } + + pub fn on_drop( + mut self, + handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_drop = Some(Rc::new(handler)); + self + } + + pub fn on_create(mut self, handler: impl Fn(Index, &mut Window, &mut App) + 'static) -> Self { + self.on_create = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for IndexManager { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let id = self.id; + let draft = window.use_keyed_state((id.clone(), "draft"), cx, |window, cx| Draft { + name: cx.new(|cx| TextInput::new(window, cx).placeholder("Index name")), + fields: Vec::new(), + unique: false, + method: "btree".into(), + }); + let theme = cx.theme(); + let rows = self.indexes.iter().map(|index| { + let (name, on_drop) = (index.name.clone(), self.on_drop.clone()); + div() + .flex() + .items_center() + .gap_2() + .py_1() + .border_b_1() + .border_color(theme.colors.border) + .child( + div() + .flex_1() + .min_w_0() + .flex() + .flex_col() + .child( + div() + .font_weight(FontWeight::MEDIUM) + .child(Ellipsis::new(index.name.clone())), + ) + .child( + div() + .text_size(theme.text_size(TextSize::Sm)) + .text_color(theme.colors.fg_muted) + .child(Ellipsis::new(index.fields.join(", "))), + ), + ) + .child( + div() + .flex_none() + .flex() + .items_center() + .gap_1() + .children( + index + .unique + .then(|| Badge::new("unique").tone(Tone::Accent)), + ) + .child(Badge::new(index.method.clone())) + .child( + IconButton::new( + (id.clone(), format!("drop-{}", index.name)), + IconName::Trash2, + ) + .tooltip("Drop the index") + .on_click(move |_, window, cx| { + log::info!("index manager: drop {name}"); + if let Some(on_drop) = &on_drop { + on_drop(&name, window, cx); + } + }), + ), + ) + }); + let now = draft.read(cx); + let (name, picked, unique, method) = ( + now.name.clone(), + now.fields.clone(), + now.unique, + now.method.clone(), + ); + let ready = !name.read(cx).text().trim().is_empty() && !picked.is_empty(); + let [chose, flipped, pick_method, made] = [(); 4].map(|_| draft.clone()); + let on_create = self.on_create; + div() + .flex() + .flex_col() + .gap_3() + .child(div().flex().flex_col().children(rows)) + .child( + div() + .flex() + .flex_col() + .gap_2() + .child(Input::new(&name)) + .child( + MultiSelect::new( + (id.clone(), "fields"), + self.fields + .iter() + .map(|field| Choice::new(field.clone(), field.clone())), + ) + .selected(picked) + .placeholder("Fields") + .on_change(move |values, _, cx| { + chose.update(cx, |draft, cx| { + draft.fields = values.to_vec(); + cx.notify(); + }) + }), + ) + .child( + div() + .flex() + .items_center() + .justify_between() + .gap_2() + .child( + Switch::new((id.clone(), "unique"), unique) + .label("Unique") + .on_change(move |on, _, cx| { + flipped.update(cx, |draft, cx| { + draft.unique = on; + cx.notify(); + }) + }), + ) + .child( + Select::new( + (id.clone(), "method"), + METHODS.iter().map(|method| Choice::new(*method, *method)), + ) + .selected(method) + .on_change(move |value, _, cx| { + pick_method.update(cx, |draft, cx| { + draft.method = value.clone(); + cx.notify(); + }) + }), + ), + ) + .child( + Button::new((id, "create"), "Create index") + .variant(ButtonVariant::Primary) + .disabled(!ready) + .on_click(move |_, window, cx| { + let index = made.update(cx, |draft, cx| { + let name = SharedString::from( + draft.name.read(cx).text().trim().to_string(), + ); + draft + .name + .update(cx, |input, cx| input.set_text(String::new(), cx)); + let index = Index { + name, + fields: std::mem::take(&mut draft.fields), + unique: draft.unique, + method: draft.method.clone(), + }; + cx.notify(); + index + }); + log::info!("index manager: create {}", index.name); + if let Some(on_create) = &on_create { + on_create(index, window, cx); + } + }), + ), + ) + } +} diff --git a/src/devtools/tests/mod.rs b/src/devtools/tests/mod.rs new file mode 100644 index 0000000..a4bff87 --- /dev/null +++ b/src/devtools/tests/mod.rs @@ -0,0 +1,133 @@ +use gpui::{ + AnyElement, Context, Entity, IntoElement, KeyBinding, ParentElement, Render, Styled, + TestAppContext, VisualTestContext, Window, div, px, +}; + +use super::{Connection, ConnectionManager, Engine, Field, TableStructureEditor}; +use crate::{forms, primitives::FocusNext, theme::Theme}; + +/// A view that shows one tool and keeps the words it heard. +struct Bench { + part: fn(Entity) -> AnyElement, + said: Vec, +} + +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 settle(cx: &mut VisualTestContext) { + for _ in 0..3 { + cx.run_until_parked(); + cx.update(|window, _| window.refresh()); + } + cx.run_until_parked(); +} + +fn bench( + part: fn(Entity) -> AnyElement, + cx: &mut TestAppContext, +) -> (Entity, &mut VisualTestContext) { + cx.update(|cx| { + Theme::init(cx); + Theme::update(cx, |theme| theme.reduced_motion = true); + forms::bind_keys(cx); + cx.bind_keys([KeyBinding::new("tab", FocusNext, None)]); + }); + let (host, cx) = cx.add_window_view(|_, _| Bench { + part, + said: Vec::new(), + }); + settle(cx); + (host, cx) +} + +fn say(owner: &Entity, words: String, cx: &mut gpui::App) { + owner.update(cx, |bench, cx| { + bench.said.push(words); + cx.notify(); + }); +} + +fn said(host: &Entity, cx: &mut VisualTestContext) -> Vec { + host.read_with(cx, |bench, _| bench.said.clone()) +} + +fn tab(stops: usize, cx: &mut VisualTestContext) { + cx.update(|window, _| { + window.blur(); + (0..stops).for_each(|_| window.focus_next()); + }); + settle(cx); +} + +fn tap(key: &str, cx: &mut VisualTestContext) { + cx.simulate_keystrokes(key); + cx.simulate_event(gpui::KeyUpEvent { + keystroke: gpui::Keystroke::parse(key).expect("a key"), + }); + settle(cx); +} + +fn manager(owner: Entity) -> AnyElement { + let [opened, edited, picked] = [(); 3].map(|_| owner.clone()); + ConnectionManager::new( + "connections", + [ + Connection::new("local", "Local", Engine::Postgres), + Connection::new("cache", "Cache", Engine::Redis), + ], + ) + .on_open(move |key, _, cx| say(&opened, format!("open {key}"), cx)) + .on_edit(move |key, _, cx| say(&edited, format!("edit {key}"), cx)) + .on_select(move |key, _, cx| say(&picked, format!("select {key}"), cx)) + .into_any_element() +} + +/// Stops run: New, the list, then each row's Edit and Remove. +#[gpui::test] +fn enter_opens_a_connection_and_a_rows_button_leaves_the_row_alone(cx: &mut TestAppContext) { + let (host, cx) = bench(manager, cx); + tab(2, cx); + tap("enter", cx); + tab(3, cx); + tap("space", cx); + assert_eq!(said(&host, cx), ["open local", "edit local"]); +} + +fn structure(owner: Entity) -> AnyElement { + TableStructureEditor::new("structure", [Field::new("id", "int").primary()]) + .on_change(move |fields, _, cx| { + let names: Vec = fields + .iter() + .map(|field| format!("{}:{}", field.name, field.ty)) + .collect(); + say(&owner, names.join(" "), cx) + }) + .into_any_element() +} + +/// A row's stops: name, type, empty, key, default and remove; Add field comes after. +#[gpui::test] +fn add_field_appends_a_free_name(cx: &mut TestAppContext) { + let (host, cx) = bench(structure, cx); + tab(7, cx); + tap("space", cx); + assert_eq!(said(&host, cx), ["id:int field_1:text"]); +} + +#[test] +fn an_address_names_the_host_or_the_file() { + let mut local = Connection::new("local", "Local", Engine::Postgres); + local.database = "shop".into(); + assert_eq!(local.address(), "localhost:5432/shop"); + assert_eq!( + Connection::new("cache", "Cache", Engine::Redis).address(), + "localhost:6379" + ); + let mut file = Connection::new("file", "File", Engine::Sqlite); + file.database = "app.db".into(); + assert_eq!(file.address(), "app.db"); +} diff --git a/src/forms/code.rs b/src/forms/code.rs index 634e26c..716bd48 100644 --- a/src/forms/code.rs +++ b/src/forms/code.rs @@ -22,7 +22,11 @@ const KEYWORDS: &[&str] = &[ "as", "async", "await", "break", "case", "catch", "class", "const", "continue", "def", "do", "else", "enum", "export", "fn", "for", "from", "func", "function", "if", "impl", "import", "in", "let", "match", "mut", "new", "pub", "return", "static", "struct", "switch", "throw", - "try", "type", "use", "var", "while", "yield", + "try", "type", "use", "var", "while", "yield", "SELECT", "FROM", "WHERE", "JOIN", "LEFT", + "RIGHT", "INNER", "OUTER", "ON", "AND", "OR", "NOT", "AS", "GROUP", "BY", "ORDER", "HAVING", + "LIMIT", "OFFSET", "INSERT", "INTO", "VALUES", "UPDATE", "SET", "DELETE", "CREATE", "TABLE", + "INDEX", "DROP", "ALTER", "DESC", "ASC", "DISTINCT", "UNION", "WITH", "CASE", "WHEN", "THEN", + "ELSE", "END", "IS", "IN", "LIKE", "BETWEEN", "EXISTS", "INTERVAL", ]; const CONSTANTS: &[&str] = &[ "false", @@ -33,6 +37,9 @@ const CONSTANTS: &[&str] = &[ "this", "true", "undefined", + "NULL", + "TRUE", + "FALSE", ]; /// Splits source into spans; spaces fall between them. diff --git a/src/lib.rs b/src/lib.rs index d4b6fde..95b9d73 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -9,6 +9,7 @@ pub mod chat; pub mod collab; pub mod data_display; pub mod debug; +pub mod devtools; pub mod documents; pub mod editor; pub mod feedback; diff --git a/src/lists/mod.rs b/src/lists/mod.rs index eb00d84..33d0979 100644 --- a/src/lists/mod.rs +++ b/src/lists/mod.rs @@ -22,6 +22,7 @@ pub use long::{InfiniteList, VirtualList}; pub use outline::Outline; pub(crate) use sections::Sections; pub use select::SelectableList; +pub(crate) use select::row_action; pub(crate) use select::{Pick, picked}; pub use sortable::SortableList; pub use swipe::{SwipeAction, SwipeableListItem}; diff --git a/src/lists/select.rs b/src/lists/select.rs index c77f8ec..cc8126b 100644 --- a/src/lists/select.rs +++ b/src/lists/select.rs @@ -1,8 +1,8 @@ use std::rc::Rc; use gpui::{ - App, Div, ElementId, InteractiveElement, IntoElement, ParentElement, RenderOnce, ScrollHandle, - SharedString, StatefulInteractiveElement, StyleRefinement, Styled, Window, div, + App, Div, ElementId, InteractiveElement, IntoElement, MouseButton, ParentElement, RenderOnce, + ScrollHandle, SharedString, StatefulInteractiveElement, StyleRefinement, Styled, Window, div, }; use super::ListItem; @@ -276,6 +276,23 @@ impl RenderOnce for SelectableList { } } +/// A button inside a pressable row: its press and its Space or Enter stay its own, so the row neither selects nor activates. +pub(crate) fn row_action(button: impl IntoElement) -> gpui::Div { + div() + .flex_none() + .on_mouse_down(MouseButton::Left, |_, window, cx| { + window.prevent_default(); + cx.stop_propagation(); + }) + .on_key_down(|event, _, cx| { + let key = event.keystroke.key.as_str(); + if matches!(key, "space" | "enter") && !event.keystroke.modifiers.modified() { + cx.stop_propagation(); + } + }) + .child(button) +} + #[cfg(test)] mod tests { use gpui::SharedString; diff --git a/tasks/ch31-43.md b/tasks/ch31-43.md index 798eba0..538a0d5 100644 --- a/tasks/ch31-43.md +++ b/tasks/ch31-43.md @@ -81,17 +81,17 @@ ## T33a DB & Dev Tools · Databases — `src/devtools` -- [ ] ConnectionManager -- [ ] ConnectionForm -- [ ] SchemaTree -- [ ] QueryEditor → `editor::CodeEditor` -- [ ] QueryResultGrid → `tables::DataGrid` -- [ ] QueryHistory -- [ ] QueryPlanViewer -- [ ] TableStructureEditor -- [ ] ERDiagram -- [ ] IndexManager -- [ ] RedisKeyBrowser +- [x] ConnectionManager +- [x] ConnectionForm +- [x] SchemaTree +- [x] QueryEditor → `editor::CodeEditor` +- [x] QueryResultGrid → `tables::DataGrid` +- [x] QueryHistory +- [x] QueryPlanViewer +- [x] TableStructureEditor +- [x] ERDiagram +- [x] IndexManager +- [x] RedisKeyBrowser ## T33b DB & Dev Tools · Data formats