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. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
94ae7e73e7
commit
4c9727d67c
20 files changed
+2602
-14
No files matched your search
@@ -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()))),
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -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))),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
|
||||
Reference in new issue
Block a user