DB & Dev Tools · Databases: connections, schemas, queries, structure, Redis

A new chapter, src/devtools. ConnectionManager lists saved connections
with their engine and address, or why one failed, and connects on Enter;
ConnectionForm fills a connection with a port checked from 1 to 65535,
tests it and saves it. SchemaTree shows schemas, tables, views and
fields with keys marked; ERDiagram draws tables and their foreign keys
on NodeGraph, whose fixed wires show only the sockets that hold one.
QueryHistory finds past runs and runs one again; QueryPlanViewer shows
each step's share of the cost. TableStructureEditor edits fields in
place; IndexManager lists and draws up indexes; RedisKeyBrowser finds
keys by glob and lays out each type. QueryEditor is editor::CodeEditor,
whose lexer now knows SQL's words, and QueryResultGrid is
tables::DataGrid. lists::row_action keeps a row's button its own.
This commit is contained in:
ZacharyZhang-NY committed 2026-09-27 08:51:45 -04:00
1 parent 56ae76664f
commit c137ddb0f7
20 files changed
+2602 -14

No files matched your search

+1
View File
@@ -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`).
+223
View File
@@ -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<QueryRun> {
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<CodeEditor>,
rows: Rc<Vec<Vec<SharedString>>>,
search: gpui::Entity<TextInput>,
now: Timestamp,
runs: Vec<QueryRun>,
}
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()))),
),
)
}
+286
View File
@@ -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<DbTable> {
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<Connection>,
selected: Option<SharedString>,
editing: Connection,
test: Option<TestState>,
}
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<SharedString>,
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<Field>,
indexes: Vec<Index>,
}
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<SharedString> = 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))),
),
),
)
}
+28
View File
@@ -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()
}
+2
View File
@@ -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,
];
+1
View File
@@ -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};
+21
View File
@@ -33,6 +33,7 @@ pub struct NodeGraph {
on_connect: Option<OnEdge>,
on_disconnect: Option<OnEdge>,
on_viewport: Option<OnViewport>,
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<Item = impl Into<SharedString>>) -> 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<AnyElement> = 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();
+282
View File
@@ -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<dyn Fn(Connection, &mut Window, &mut App)>;
/// 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<TextInput>, cx: &App) -> Option<u16> {
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<TextInput>,
host: Entity<TextInput>,
database: Entity<TextInput>,
user: Entity<TextInput>,
password: Entity<TextInput>,
port: Entity<TextInput>,
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<TextInput>| {
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<TestState>,
on_test: Option<OnConnection>,
on_save: Option<OnConnection>,
}
impl ConnectionForm {
pub fn new(id: impl Into<ElementId>, 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<TextInput>| !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<OnConnection>, 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")),
),
)
}
}
+288
View File
@@ -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<u16> {
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<u16>,
pub database: SharedString,
pub user: SharedString,
pub password: SharedString,
pub tls: bool,
pub state: ConnectionState,
}
impl Connection {
pub fn new(
key: impl Into<SharedString>,
name: impl Into<SharedString>,
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<dyn Fn(&SharedString, &mut Window, &mut App)>;
type Run = Rc<dyn Fn(&mut Window, &mut App)>;
/// 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<Connection>,
selected: Option<SharedString>,
on_select: Option<OnKey>,
on_open: Option<OnKey>,
on_edit: Option<OnKey>,
on_remove: Option<OnKey>,
on_new: Option<Run>,
}
impl ConnectionManager {
pub fn new(
id: impl Into<ElementId>,
connections: impl IntoIterator<Item = Connection>,
) -> 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<impl Into<SharedString>>) -> 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<OnKey>| {
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)
}
}
+16
View File
@@ -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};
+248
View File
@@ -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<dyn Fn(&SharedString, &mut Window, &mut App)>;
/// 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<QueryRun>,
search: gpui::Entity<TextInput>,
now: Timestamp,
on_run: Option<OnKey>,
}
impl QueryHistory {
/// `search` is the find field's text, which the owner keeps; `now` dates each run.
pub fn new(
id: impl Into<ElementId>,
runs: impl IntoIterator<Item = QueryRun>,
search: &gpui::Entity<TextInput>,
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<SharedString>,
pub cost: f64,
pub rows: u64,
pub ms: f64,
pub children: Vec<PlanStep>,
}
impl PlanStep {
pub fn new(
key: impl Into<SharedString>,
op: impl Into<SharedString>,
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<SharedString>) -> 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<ElementId>, 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<SharedString>) {
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"
);
}
}
+268
View File
@@ -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<SharedString>),
Set(Vec<SharedString>),
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<u64>,
}
/// 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<char>, Vec<char>) = (pattern.chars().collect(), key.chars().collect());
go(&pattern, &key)
}
type OnKey = Rc<dyn Fn(&SharedString, &mut Window, &mut App)>;
/// 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<RedisKey>,
pattern: Entity<TextInput>,
selected: Option<SharedString>,
on_select: Option<OnKey>,
}
impl RedisKeyBrowser {
/// `pattern` is the glob field's text, which the owner keeps; empty finds every key.
pub fn new(
id: impl Into<ElementId>,
keys: impl IntoIterator<Item = RedisKey>,
pattern: &Entity<TextInput>,
) -> 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<impl Into<SharedString>>) -> 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("*", ""));
}
}
+344
View File
@@ -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<FieldKey>,
pub default: Option<SharedString>,
}
impl Field {
pub fn new(name: impl Into<SharedString>, ty: impl Into<SharedString>) -> 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<SharedString>,
field: impl Into<SharedString>,
) -> Self {
self.key = Some(FieldKey::Foreign {
table: table.into(),
field: field.into(),
});
self
}
pub fn default_value(mut self, value: impl Into<SharedString>) -> 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<Field>,
}
impl DbTable {
pub fn new(name: impl Into<SharedString>, fields: impl IntoIterator<Item = Field>) -> 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<DbTable>,
}
type OnKeys = Rc<dyn Fn(&[SharedString], &mut Window, &mut App)>;
/// 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<Schema>,
open: Vec<SharedString>,
selected: Vec<SharedString>,
on_select: Option<OnKeys>,
}
impl SchemaTree {
pub fn new(id: impl Into<ElementId>, schemas: impl IntoIterator<Item = Schema>) -> 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<Item = impl Into<SharedString>>) -> Self {
self.open = keys.into_iter().map(Into::into).collect();
self
}
pub fn selected(mut self, keys: impl IntoIterator<Item = impl Into<SharedString>>) -> 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<dyn Fn(&SharedString, (f32, f32), &mut Window, &mut App)>;
type OnViewport = Rc<dyn Fn(Viewport, &mut Window, &mut App)>;
/// 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<DbTable>,
places: Vec<(SharedString, (f32, f32))>,
on_move: Option<OnNudge>,
on_viewport: Option<OnViewport>,
}
impl ERDiagram {
/// `places` sets where each table sits, in canvas units, by name.
pub fn new(
id: impl Into<ElementId>,
viewport: Viewport,
tables: impl IntoIterator<Item = DbTable>,
places: impl IntoIterator<Item = (impl Into<SharedString>, (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<Node>, Vec<Edge>) {
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", [])], &[]);
}
}
+421
View File
@@ -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<Field>,
on_change: Option<OnEdit<Vec<Field>>>,
}
impl TableStructureEditor {
pub fn new(id: impl Into<ElementId>, fields: impl IntoIterator<Item = Field>) -> Self {
Self {
id: id.into(),
fields: fields.into_iter().collect(),
on_change: None,
}
}
pub fn on_change(
mut self,
handler: impl Fn(Vec<Field>, &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<Field>, value| edit(&mut all[ix], value),
)
};
let flag = |edit: fn(&mut Field, bool)| {
editing(
"table structure",
&fields,
on_change,
move |all: &mut Vec<Field>, 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<Field>, _: ()| {
all.remove(ix);
},
);
let mut types: Vec<Choice> = 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<Field>, _: ()| {
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<SharedString>,
pub unique: bool,
pub method: SharedString,
}
const METHODS: [&str; 4] = ["btree", "hash", "gin", "gist"];
type OnName = Rc<dyn Fn(&SharedString, &mut Window, &mut App)>;
type OnIndex = Rc<dyn Fn(Index, &mut Window, &mut App)>;
/// A new index being drawn up: its name field, fields, uniqueness and method.
struct Draft {
name: Entity<TextInput>,
fields: Vec<SharedString>,
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<Index>,
fields: Vec<SharedString>,
on_drop: Option<OnName>,
on_create: Option<OnIndex>,
}
impl IndexManager {
/// `fields` are the table's, which a new index picks from.
pub fn new(
id: impl Into<ElementId>,
indexes: impl IntoIterator<Item = Index>,
fields: impl IntoIterator<Item = impl Into<SharedString>>,
) -> 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);
}
}),
),
)
}
}
+133
View File
@@ -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<Bench>) -> AnyElement,
said: Vec<String>,
}
impl Render for Bench {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> 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<Bench>) -> AnyElement,
cx: &mut TestAppContext,
) -> (Entity<Bench>, &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<Bench>, words: String, cx: &mut gpui::App) {
owner.update(cx, |bench, cx| {
bench.said.push(words);
cx.notify();
});
}
fn said(host: &Entity<Bench>, cx: &mut VisualTestContext) -> Vec<String> {
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<Bench>) -> 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<Bench>) -> AnyElement {
TableStructureEditor::new("structure", [Field::new("id", "int").primary()])
.on_change(move |fields, _, cx| {
let names: Vec<String> = 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");
}
+8 -1
View File
@@ -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.
+1
View File
@@ -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;
+1
View File
@@ -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};
+19 -2
View File
@@ -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;
+11 -11
View File
@@ -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