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
@@ -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`).
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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")),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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("*", ""));
|
||||
}
|
||||
}
|
||||
@@ -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", [])], &[]);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
Reference in new issue
Block a user