DB & Dev Tools · APIs: requests, answers, environments, sockets and GraphQL

ApiRequestBuilder builds a request: its method and address, then tabs
for the query's pairs, headers, body and sign-in, with the address that
goes out under the field, the environment's {{variables}} filled in or
the missing ones named. Send hands the owner the request. The query's
pairs go through forms::KeyValueInput. ResponseViewer shows an answer's
status in its class's tone with its words, its time and size, then its
body, JSON indented, or its headers in a HeadersTable.
EnvironmentSelector picks a set of variables and masks secrets until
their eye opens them. CollectionTree holds saved requests in folders;
Enter opens one. WebSocketConsole shows a socket's messages, each
marked sent or received with its time, and sends on Enter while the
socket is open and the field holds words. GraphQLExplorer browses a
schema beside a query and its variables; Run waits for variables that
read as JSON and the answer shows as a tree.
This commit is contained in:
ZacharyZhang-NY committed 2026-09-27 10:24:44 -04:00
1 parent 2ec826761e
commit 3c9f52f853
12 files changed
+1963 -8

No files matched your search

+300
View File
@@ -0,0 +1,300 @@
use ely_gpui_component::{
devtools::{
ApiRequest, ApiRequestBuilder, ApiResponse, CollectionTree, Direction, Environment,
EnvironmentSelector, GraphQLExplorer, Method, ResponseViewer, Saved, SchemaType,
SocketMessage, SocketState, TypeKind, WebSocketConsole,
},
editor::CodeEditor,
forms::TextInput,
theme::{ActiveTheme, Radius},
};
use gpui::{
App, AppContext as _, IntoElement, ParentElement, SharedString, Styled, Window, div, px,
};
use jiff::Timestamp;
use crate::ui::{change, keep, section};
fn environments() -> Vec<Environment> {
let env = |key: &str, name: &str, host: &str| Environment {
key: key.to_string().into(),
name: name.to_string().into(),
variables: vec![
("host".into(), host.to_string().into()),
("token".into(), "sk_live_4f9a2c".into()),
],
secrets: vec!["token".into()],
};
vec![
env("local", "Local", "localhost:8080"),
env("staging", "Staging", "staging.api.example.com"),
env("production", "Production", "api.example.com"),
]
}
/// The API demo's request, its environment, the answer and the request picked.
struct Desk {
request: ApiRequest,
environment: SharedString,
response: Option<ApiResponse>,
picked: Vec<SharedString>,
}
impl Desk {
fn new() -> Self {
let mut request = ApiRequest::new("list-orders", Method::Get, "https://{{host}}/v1/orders");
request.params = vec![
("status".into(), "open".into()),
("limit".into(), "20".into()),
];
request.headers = vec![("Accept".into(), "application/json".into())];
Self {
request,
environment: "staging".into(),
response: Some(answer()),
picked: vec!["list-orders".into()],
}
}
}
fn answer() -> ApiResponse {
ApiResponse {
status: 200,
took_ms: 142,
size: 1_380,
headers: vec![
("content-type".into(), "application/json".into()),
("cache-control".into(), "no-store".into()),
("x-request-id".into(), "a1f3-77c2".into()),
],
body: r#"{"orders":[{"id":1009,"total":89.0,"status":"open"},{"id":1010,"total":26.0,"status":"open"}],"next":null}"#.into(),
}
}
pub fn requests(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
let desk = keep("devtools-api", Desk::new, window, cx);
let now = desk.read(cx);
let (request, environment, response, picked) = (
now.request.clone(),
now.environment.clone(),
now.response.clone(),
now.picked.clone(),
);
let variables = environments()
.into_iter()
.find(|each| each.key == environment)
.expect("a listed environment")
.variables;
let [sent, chose, opened] = [(); 3].map(|_| desk.clone());
let collection = [
Saved::folder(
"orders",
"Orders",
[
Saved::request("list-orders", "List orders", Method::Get),
Saved::request("create-order", "Create an order", Method::Post),
Saved::request("cancel-order", "Cancel an order", Method::Delete),
],
),
Saved::folder(
"users",
"Users",
[
Saved::request("me", "Who am I", Method::Get),
Saved::request("rename", "Rename", Method::Patch),
],
),
];
let theme = cx.theme();
section(
"ApiRequestBuilder · ResponseViewer · HeadersTable · QueryParamsEditor → forms::KeyValueInput · EnvironmentSelector · CollectionTree",
"Saved requests in folders; the one open, built with its method and address, its query's pairs, headers, body and sign-in, and the address that goes out with the environment's variables filled in. Send brings back an answer: its status, time and size, then its body or its headers.",
cx,
)
.child(
div()
.flex()
.flex_wrap()
.gap_6()
.child(
div()
.w(px(260.))
.flex()
.flex_col()
.gap_4()
.child(
EnvironmentSelector::new("devtools-environments", environments(), environment)
.on_change(move |key, _, cx| change(&chose, cx, |desk| desk.environment = key.clone())),
)
.child(
div()
.h(px(220.))
.p_1()
.rounded(theme.radius(Radius::Lg))
.border_1()
.border_color(theme.colors.border)
.child(
CollectionTree::new("devtools-collection", collection)
.open(["orders"])
.selected(picked)
.on_open(move |key, _, cx| change(&opened, cx, |desk| desk.picked = vec![key.clone()])),
),
),
)
.child(
div()
.w(px(560.))
.flex()
.flex_col()
.gap_6()
.child(
ApiRequestBuilder::new("devtools-request", request)
.variables(variables)
.on_send(move |_, _, cx| change(&sent, cx, |desk| desk.response = Some(answer()))),
)
.children(response.map(|response| ResponseViewer::new("devtools-response", response))),
),
)
}
/// The socket demo's state, messages and draft.
struct Line {
state: SocketState,
messages: Vec<SocketMessage>,
draft: gpui::Entity<TextInput>,
now: Timestamp,
}
pub fn socket(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
let line = window.use_keyed_state("devtools-socket", cx, |window, cx| {
let now: Timestamp = "2026-09-27T12:00:00Z".parse().expect("a time");
let at = |seconds: i64| now - jiff::SignedDuration::from_secs(seconds);
let message = |direction, text: &str, seconds| SocketMessage {
direction,
text: text.to_string().into(),
at: at(seconds),
};
Line {
state: SocketState::Open,
messages: vec![
message(
Direction::Note,
"Connected to wss://stream.example.com/orders",
600,
),
message(
Direction::Sent,
r#"{"type":"subscribe","channel":"orders"}"#,
598,
),
message(
Direction::Received,
r#"{"type":"order","id":1011,"total":42.5}"#,
120,
),
],
draft: cx.new(|cx| TextInput::new(window, cx).placeholder("A message to send")),
now,
}
});
let now = line.read(cx);
let (state, messages, draft, clock) =
(now.state, now.messages.clone(), now.draft.clone(), now.now);
let (sent, toggled) = (line.clone(), line.clone());
section(
"WebSocketConsole",
"A socket's conversation: where it stands, each message marked sent or received with its time, JSON indented, and a field that sends on Enter.",
cx,
)
.child(
div().w(px(620.)).child(
WebSocketConsole::new("devtools-socket", "wss://stream.example.com/orders", state, messages, &draft, clock)
.on_send(move |text, _, cx| {
sent.update(cx, |line, cx| {
let at = line.now;
line.messages.push(SocketMessage { direction: Direction::Sent, text, at });
cx.notify();
})
})
.on_toggle(move |_, cx| {
toggled.update(cx, |line, cx| {
line.state = match line.state {
SocketState::Closed => SocketState::Open,
_ => SocketState::Closed,
};
cx.notify();
})
}),
),
)
}
/// The GraphQL demo's editors, find field and answer.
struct Graph {
query: gpui::Entity<CodeEditor>,
variables: gpui::Entity<CodeEditor>,
search: gpui::Entity<TextInput>,
answer: Option<SharedString>,
}
/// What the demo's query brings back.
const ANSWER: &str =
r#"{"data":{"order":{"id":"1009","total":89.0,"customer":{"name":"Ada Lovelace"}}}}"#;
pub fn graphql(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
let graph = window.use_keyed_state("devtools-graphql", cx, |window, cx| Graph {
query: cx.new(|cx| CodeEditor::new("query Order($id: ID!) {\n order(id: $id) {\n id\n total\n customer { name }\n }\n}", window, cx).language("GraphQL")),
variables: cx.new(|cx| CodeEditor::new(r#"{"id": "1009"}"#, window, cx).language("JSON")),
search: cx.new(|cx| TextInput::new(window, cx).placeholder("Find in the answer")),
answer: Some(ANSWER.into()),
});
let now = graph.read(cx);
let (query, variables, search, answer) = (
now.query.clone(),
now.variables.clone(),
now.search.clone(),
now.answer.clone(),
);
let ran = graph.clone();
let types = vec![
SchemaType {
name: "Query".into(),
kind: TypeKind::Object,
fields: vec![
("order".into(), "Order".into()),
("orders".into(), "[Order!]!".into()),
],
},
SchemaType {
name: "Order".into(),
kind: TypeKind::Object,
fields: vec![
("id".into(), "ID!".into()),
("total".into(), "Float!".into()),
("status".into(), "Status!".into()),
("customer".into(), "Customer!".into()),
],
},
SchemaType {
name: "Customer".into(),
kind: TypeKind::Object,
fields: vec![("name".into(), "String!".into())],
},
SchemaType {
name: "Status".into(),
kind: TypeKind::Enum,
fields: vec![("OPEN".into(), "".into()), ("SHIPPED".into(), "".into())],
},
];
let mut explorer = GraphQLExplorer::new("devtools-graphql", types, &query, &variables, &search)
.on_run(move |_, _, _, cx| change(&ran, cx, |graph| graph.answer = Some(ANSWER.into())));
if let Some(answer) = answer {
explorer = explorer.answer(answer);
}
section(
"GraphQLExplorer",
"A schema's types and fields beside a query and its variables; Run brings back the answer as a tree.",
cx,
)
.child(div().w(px(860.)).child(explorer))
}
+4
View File
@@ -1,5 +1,6 @@
use gpui::{AnyElement, App, Window, div, prelude::*};
mod apis;
mod data;
mod db;
mod formats;
@@ -26,5 +27,8 @@ fn render(window: &mut Window, cx: &mut App) -> AnyElement {
.child(db::structure(window, cx))
.child(data::redis(window, cx))
.child(formats::documents(window, cx))
.child(apis::requests(window, cx))
.child(apis::socket(window, cx))
.child(apis::graphql(window, cx))
.into_any_element()
}
+135
View File
@@ -0,0 +1,135 @@
use std::rc::Rc;
use gpui::{App, ElementId, IntoElement, RenderOnce, SharedString, Styled, Window};
use super::request::Method;
use crate::{
lists::{Tree, TreeNode},
primitives::IconName,
};
/// A folder of saved requests, or a request with its method.
#[derive(Clone, Debug, PartialEq)]
pub enum Saved {
Folder {
key: SharedString,
name: SharedString,
items: Vec<Saved>,
},
Request {
key: SharedString,
name: SharedString,
method: Method,
},
}
impl Saved {
pub fn folder(
key: impl Into<SharedString>,
name: impl Into<SharedString>,
items: impl IntoIterator<Item = Saved>,
) -> Self {
Saved::Folder {
key: key.into(),
name: name.into(),
items: items.into_iter().collect(),
}
}
pub fn request(
key: impl Into<SharedString>,
name: impl Into<SharedString>,
method: Method,
) -> Self {
Saved::Request {
key: key.into(),
name: name.into(),
method,
}
}
fn node(&self) -> TreeNode {
match self {
Saved::Folder { key, name, items } => TreeNode::new(key.clone(), name.clone())
.icon(IconName::Folder)
.children(items.iter().map(Saved::node)),
Saved::Request { key, name, method } => TreeNode::new(key.clone(), name.clone())
.icon(IconName::Send)
.note(method.name()),
}
}
}
type OnKey = Rc<dyn Fn(&SharedString, &mut Window, &mut App)>;
type OnKeys = Rc<dyn Fn(&[SharedString], &mut Window, &mut App)>;
/// Saved requests in folders, each with its method. Enter or a double press opens a request; a drag moves one into another folder or among its own. It fills its box, which the host sizes.
#[derive(IntoElement)]
pub struct CollectionTree {
id: ElementId,
items: Vec<Saved>,
open: Vec<SharedString>,
selected: Vec<SharedString>,
on_open: Option<OnKey>,
on_select: Option<OnKeys>,
}
impl CollectionTree {
pub fn new(id: impl Into<ElementId>, items: impl IntoIterator<Item = Saved>) -> Self {
Self {
id: id.into(),
items: items.into_iter().collect(),
open: Vec::new(),
selected: Vec::new(),
on_open: None,
on_select: None,
}
}
/// The folders 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
}
/// Gets the request to open.
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_select(
mut self,
handler: impl Fn(&[SharedString], &mut Window, &mut App) + 'static,
) -> Self {
self.on_select = Some(Rc::new(handler));
self
}
}
impl RenderOnce for CollectionTree {
fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
let mut tree = Tree::new(self.id, self.items.iter().map(Saved::node))
.open(self.open)
.selected(self.selected)
.size_full();
if let Some(on_open) = self.on_open {
tree = tree.on_activate(move |key, window, cx| {
log::info!("collection tree: open {key}");
on_open(key, window, cx)
});
}
if let Some(on_select) = self.on_select {
tree = tree.on_select(move |keys, window, cx| on_select(keys, window, cx));
}
tree
}
}
+190
View File
@@ -0,0 +1,190 @@
use std::rc::Rc;
use gpui::{
App, ElementId, FontWeight, IntoElement, ParentElement, RenderOnce, SharedString, Styled,
Window, div,
};
use crate::{
buttons::IconButton,
forms::{Choice, Select},
primitives::IconName,
theme::{ActiveTheme, TextSize},
typography::Ellipsis,
};
/// A named set of variables a request fills its `{{names}}` from; a secret's value shows masked.
#[derive(Clone, Debug, PartialEq)]
pub struct Environment {
pub key: SharedString,
pub name: SharedString,
pub variables: Vec<(SharedString, SharedString)>,
pub secrets: Vec<SharedString>,
}
type OnKey = Rc<dyn Fn(&SharedString, &mut Window, &mut App)>;
/// The environment requests use, picked from a list, over its variables; a secret shows as dots until its eye is pressed.
#[derive(IntoElement)]
pub struct EnvironmentSelector {
id: ElementId,
environments: Vec<Environment>,
selected: SharedString,
on_change: Option<OnKey>,
}
impl EnvironmentSelector {
pub fn new(
id: impl Into<ElementId>,
environments: impl IntoIterator<Item = Environment>,
selected: impl Into<SharedString>,
) -> Self {
Self {
id: id.into(),
environments: environments.into_iter().collect(),
selected: selected.into(),
on_change: None,
}
}
pub fn on_change(
mut self,
handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static,
) -> Self {
self.on_change = Some(Rc::new(handler));
self
}
}
/// What a row shows for a value: dots for a secret until it is opened.
fn shown_value(value: &SharedString, secret: bool, open: bool) -> SharedString {
match secret && !open {
true => "••••••••".into(),
false => value.clone(),
}
}
/// Opens `name` when it is shut, shuts it when it is open.
fn flipped(open: &mut Vec<SharedString>, name: &SharedString) {
match open.iter().position(|each| each == name) {
Some(ix) => {
open.remove(ix);
}
None => open.push(name.clone()),
}
}
impl RenderOnce for EnvironmentSelector {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let id = self.id;
let chosen = self
.environments
.iter()
.find(|environment| environment.key == self.selected)
.unwrap_or_else(|| {
panic!(
"environment selector {id:?}: no environment {}",
self.selected
)
})
.clone();
let shown =
window.use_keyed_state((id.clone(), "shown"), cx, |_, _| Vec::<SharedString>::new());
let theme = cx.theme();
let on_change = self
.on_change
.unwrap_or_else(|| panic!("environment selector {id:?} has no on_change"));
let rows = chosen.variables.iter().map(|(name, value)| {
let secret = chosen.secrets.contains(name);
let open = shown.read(cx).contains(name);
let (flip, name_key) = (shown.clone(), name.clone());
div()
.flex()
.items_center()
.gap_2()
.py_1()
.child(
div()
.flex_none()
.w(theme.label_width())
.font_family(theme.mono_family.clone())
.text_size(theme.text_size(TextSize::Sm))
.child(Ellipsis::new(format!("{{{{{name}}}}}"))),
)
.child(
div()
.flex_1()
.min_w_0()
.text_size(theme.text_size(TextSize::Sm))
.text_color(theme.colors.fg_muted)
.child(Ellipsis::new(shown_value(value, secret, open))),
)
.children(secret.then(|| {
IconButton::new(
(id.clone(), format!("reveal-{name}")),
if open {
IconName::EyeOff
} else {
IconName::Eye
},
)
.tooltip(if open {
"Hide the value"
} else {
"Show the value"
})
.on_click(move |_, _, cx| {
flip.update(cx, |shown, cx| {
flipped(shown, &name_key);
cx.notify();
})
})
}))
});
div()
.flex()
.flex_col()
.gap_2()
.child(
Select::new(
(id.clone(), "select"),
self.environments.iter().map(|environment| {
Choice::new(environment.key.clone(), environment.name.clone())
}),
)
.selected(chosen.key.clone())
.on_change(move |key, window, cx| {
log::info!("environment selector: {key}");
on_change(key, window, cx);
}),
)
.child(
div()
.text_size(theme.text_size(TextSize::Xs))
.text_color(theme.colors.fg_subtle)
.font_weight(FontWeight::MEDIUM)
.child(format!("{} variables", chosen.variables.len())),
)
.child(div().flex().flex_col().children(rows))
}
}
#[cfg(test)]
mod tests {
use gpui::SharedString;
use super::{flipped, shown_value};
#[test]
fn a_secret_shows_dots_until_its_eye_opens_it() {
let value = SharedString::from("s3cret");
assert_eq!(shown_value(&value, true, false), "••••••••");
assert_eq!(shown_value(&value, true, true), "s3cret");
assert_eq!(shown_value(&value, false, false), "s3cret");
let (mut open, name) = (Vec::new(), SharedString::from("token"));
flipped(&mut open, &name);
assert_eq!(open, std::slice::from_ref(&name));
flipped(&mut open, &name);
assert!(open.is_empty(), "a second press shuts it again");
}
}
+230
View File
@@ -0,0 +1,230 @@
use std::rc::Rc;
use gpui::{
App, ElementId, Entity, IntoElement, ParentElement, RenderOnce, SharedString, Styled, Window,
div, rems,
};
use super::{datum::read_json, document::JsonViewer};
use crate::{
buttons::{Button, ButtonVariant},
editor::CodeEditor,
feedback::{EmptyState, InlineMessage},
forms::TextInput,
lists::{Tree, TreeNode},
primitives::{IconName, Severity},
theme::{ActiveTheme, Radius, TextSize},
};
/// What kind of type a GraphQL schema declares.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum TypeKind {
Object,
Input,
Enum,
Scalar,
}
impl TypeKind {
fn word(self) -> &'static str {
match self {
TypeKind::Object => "type",
TypeKind::Input => "input",
TypeKind::Enum => "enum",
TypeKind::Scalar => "scalar",
}
}
}
/// A type in a schema: its name, its kind, and its fields with their types, or an enum's values.
#[derive(Clone, Debug, PartialEq)]
pub struct SchemaType {
pub name: SharedString,
pub kind: TypeKind,
pub fields: Vec<(SharedString, SharedString)>,
}
type OnRun = Rc<dyn Fn(String, String, &mut Window, &mut App)>;
type OnKey = Rc<dyn Fn(&SharedString, &mut Window, &mut App)>;
/// A GraphQL schema to browse beside a query and its variables to write, over the answer to read. The schema lists each type's fields with their types; Enter or a double press on a field hands the owner its path, `Type.field`. Run hands the owner the query and the variables; it rests while it runs and while the variables do not read as JSON, which says where. The answer shows as a tree.
#[derive(IntoElement)]
pub struct GraphQLExplorer {
id: ElementId,
types: Vec<SchemaType>,
query: Entity<CodeEditor>,
variables: Entity<CodeEditor>,
answer: Option<SharedString>,
search: Entity<TextInput>,
running: bool,
on_run: Option<OnRun>,
on_pick: Option<OnKey>,
}
impl GraphQLExplorer {
/// The owner keeps the query's and the variables' editors, and the answer's find field.
pub fn new(
id: impl Into<ElementId>,
types: impl IntoIterator<Item = SchemaType>,
query: &Entity<CodeEditor>,
variables: &Entity<CodeEditor>,
search: &Entity<TextInput>,
) -> Self {
Self {
id: id.into(),
types: types.into_iter().collect(),
query: query.clone(),
variables: variables.clone(),
answer: None,
search: search.clone(),
running: false,
on_run: None,
on_pick: None,
}
}
/// The last answer, as JSON.
pub fn answer(mut self, json: impl Into<SharedString>) -> Self {
self.answer = Some(json.into());
self
}
pub fn running(mut self, running: bool) -> Self {
self.running = running;
self
}
pub fn on_run(
mut self,
handler: impl Fn(String, String, &mut Window, &mut App) + 'static,
) -> Self {
self.on_run = Some(Rc::new(handler));
self
}
pub fn on_pick(
mut self,
handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static,
) -> Self {
self.on_pick = Some(Rc::new(handler));
self
}
}
impl RenderOnce for GraphQLExplorer {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let id = self.id;
let theme = cx.theme();
let nodes = self.types.iter().map(|ty| {
TreeNode::new(ty.name.clone(), ty.name.clone())
.note(ty.kind.word())
.icon(match ty.kind {
TypeKind::Object | TypeKind::Input => IconName::Braces,
TypeKind::Enum => IconName::ListOrdered,
TypeKind::Scalar => IconName::Hash,
})
.children(ty.fields.iter().map(|(field, of)| {
TreeNode::new(format!("{}.{field}", ty.name), field.clone()).note(of.clone())
}))
});
let mut schema = Tree::new((id.clone(), "schema"), nodes).size_full();
if let Some(on_pick) = self.on_pick {
schema = schema.on_activate(move |path, window, cx| {
log::info!("graphql explorer: pick {path}");
on_pick(path, window, cx);
});
}
let boxed = |height: gpui::Rems| {
div()
.h(height)
.rounded(theme.radius(Radius::Md))
.border_1()
.border_color(theme.colors.border)
.overflow_hidden()
};
let caption = |text: &'static str| {
div()
.text_size(theme.text_size(TextSize::Xs))
.text_color(theme.colors.fg_subtle)
.child(text)
};
let tall = theme.list_max_height();
let on_run = self
.on_run
.unwrap_or_else(|| panic!("graphql explorer {id:?} has no on_run"));
let (query, variables) = (self.query.clone(), self.variables.clone());
let typed = variables.read(cx).text().to_string();
let unread = (!typed.trim().is_empty())
.then(|| read_json(&typed).err())
.flatten();
let rests = self.running || unread.is_some();
let answer = match self.answer {
Some(json) => {
JsonViewer::new((id.clone(), "answer"), json, &self.search).into_any_element()
}
None => EmptyState::new((id.clone(), "none"), IconName::Play, "No answer yet")
.body("Run the query to see what comes back.")
.into_any_element(),
};
div()
.flex()
.flex_col()
.gap_4()
.child(
div()
.flex()
.flex_wrap()
.gap_4()
.child(
div()
.flex_1()
.min_w(theme.label_width())
.max_w(rems(theme.label_width().0 * 2.0))
.flex()
.flex_col()
.gap_1()
.child(caption("Schema"))
.child(boxed(tall).p_1().child(schema)),
)
.child(
div()
.flex_1()
.min_w(theme.label_width())
.flex()
.flex_col()
.gap_1()
.child(caption("Query"))
.child(boxed(rems(tall.0 * 0.6)).child(self.query))
.child(caption("Variables"))
.child(boxed(rems(tall.0 * 0.3)).child(self.variables))
.children(unread.map(|unread| {
InlineMessage::new(Severity::Danger, unread.to_string())
}))
.child(
div().flex().child(
Button::new(
(id, "run"),
if self.running { "Running…" } else { "Run" },
)
.variant(ButtonVariant::Primary)
.disabled(rests)
.on_click(
move |_, window, cx| {
let (query, variables) = (
query.read(cx).text().to_string(),
variables.read(cx).text().to_string(),
);
log::info!(
"graphql explorer: run {} bytes",
query.len()
);
on_run(query, variables, window, cx);
},
),
),
),
),
)
.child(answer)
}
}
+12
View File
@@ -1,24 +1,36 @@
mod collection;
mod connection_form;
mod connections;
mod datum;
mod document;
mod environment;
mod graphql;
mod json_editor;
mod queries;
mod redis;
mod request;
mod response;
mod schema;
mod socket;
mod structure;
mod xml;
#[cfg(all(test, feature = "test-support"))]
mod tests;
pub use collection::{CollectionTree, Saved};
pub use connection_form::{ConnectionForm, TestState};
pub use connections::{Connection, ConnectionManager, ConnectionState, Engine};
pub use datum::{Datum, Unread, read_json, read_yaml};
pub use document::{JsonViewer, YamlViewer};
pub use environment::{Environment, EnvironmentSelector};
pub use graphql::{GraphQLExplorer, SchemaType, TypeKind};
pub use json_editor::JsonEditor;
pub use queries::{Outcome, PlanStep, QueryHistory, QueryPlanViewer, QueryRun};
pub use redis::{RedisKey, RedisKeyBrowser, RedisValue};
pub use request::{ApiRequest, ApiRequestBuilder, Auth, Method, percent, resolve};
pub use response::{ApiResponse, HeadersTable, ResponseViewer, reason, status_tone};
pub use schema::{DbTable, ERDiagram, Field, FieldKey, Schema, SchemaTree};
pub use socket::{Direction, SocketMessage, SocketState, WebSocketConsole};
pub use structure::{Index, IndexManager, TableStructureEditor};
pub use xml::{XmlElement, XmlNode, XmlViewer, read_xml};
+488
View File
@@ -0,0 +1,488 @@
use std::rc::Rc;
use gpui::{
App, AppContext as _, ElementId, Entity, IntoElement, ParentElement, RenderOnce, SharedString,
Styled, Window, div,
};
use crate::{
buttons::{Button, ButtonVariant},
editor::CodeEditor,
feedback::InlineMessage,
forms::{Choice, FormField, Input, KeyValueInput, PasswordInput, Select, TextInput},
navigation::Tabs,
primitives::Severity,
theme::{ActiveTheme, Radius, TextSize},
typography::Ellipsis,
};
/// An HTTP method.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Method {
Get,
Post,
Put,
Patch,
Delete,
Head,
Options,
}
impl Method {
pub const ALL: [Method; 7] = [
Method::Get,
Method::Post,
Method::Put,
Method::Patch,
Method::Delete,
Method::Head,
Method::Options,
];
pub fn name(self) -> &'static str {
match self {
Method::Get => "GET",
Method::Post => "POST",
Method::Put => "PUT",
Method::Patch => "PATCH",
Method::Delete => "DELETE",
Method::Head => "HEAD",
Method::Options => "OPTIONS",
}
}
fn named(name: &str) -> Method {
*Method::ALL
.iter()
.find(|method| method.name() == name)
.unwrap_or_else(|| panic!("no method {name}"))
}
}
/// How a request signs in.
#[derive(Clone, Debug, PartialEq)]
pub enum Auth {
None,
Bearer(SharedString),
Basic {
user: SharedString,
password: SharedString,
},
}
/// A request to send: its method and address, its query's pairs, its headers, its body and how it signs in.
#[derive(Clone, Debug, PartialEq)]
pub struct ApiRequest {
pub key: SharedString,
pub method: Method,
pub url: SharedString,
pub params: Vec<(String, String)>,
pub headers: Vec<(String, String)>,
pub body: SharedString,
pub auth: Auth,
}
impl ApiRequest {
pub fn new(key: impl Into<SharedString>, method: Method, url: impl Into<SharedString>) -> Self {
Self {
key: key.into(),
method,
url: url.into(),
params: Vec::new(),
headers: Vec::new(),
body: SharedString::default(),
auth: Auth::None,
}
}
/// Its address with its query's pairs encoded after it; pairs with no key are left out.
pub fn address(&self) -> String {
let pairs: Vec<String> = self
.params
.iter()
.filter(|(key, _)| !key.trim().is_empty())
.map(|(key, value)| format!("{}={}", percent(key.trim()), percent(value)))
.collect();
match (pairs.is_empty(), self.url.contains('?')) {
(true, _) => self.url.to_string(),
(false, true) => format!("{}&{}", self.url, pairs.join("&")),
(false, false) => format!("{}?{}", self.url, pairs.join("&")),
}
}
}
/// Text made safe for a URL's query: letters, digits and `-_.~` stay, anything else becomes %XX of its UTF-8 bytes.
pub fn percent(text: &str) -> String {
text.bytes()
.map(|byte| match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
(byte as char).to_string()
}
_ => format!("%{byte:02X}"),
})
.collect()
}
/// `text` with each `{{name}}` swapped for its value, and the names that had none.
pub fn resolve(text: &str, variables: &[(SharedString, SharedString)]) -> (String, Vec<String>) {
let (mut out, mut missing, mut rest) = (String::new(), Vec::new(), text);
while let Some(open) = rest.find("{{") {
let Some(close) = rest[open..].find("}}") else {
break;
};
let name = rest[open + 2..open + close].trim();
out.push_str(&rest[..open]);
match variables.iter().find(|(key, _)| key == name) {
Some((_, value)) => out.push_str(value),
None => {
out.push_str(&rest[open..open + close + 2]);
missing.push(name.to_string());
}
}
rest = &rest[open + close + 2..];
}
out.push_str(rest);
(out, missing)
}
type OnRequest = Rc<dyn Fn(ApiRequest, &mut Window, &mut App)>;
/// The builder's own state, filled from the request named by `seed`.
struct Draft {
seed: SharedString,
method: Method,
url: Entity<TextInput>,
params: Vec<(String, String)>,
headers: Vec<(String, String)>,
body: Entity<CodeEditor>,
auth: SharedString,
token: Entity<TextInput>,
user: Entity<TextInput>,
password: Entity<TextInput>,
tab: SharedString,
}
impl Draft {
fn filled(request: &ApiRequest, window: &mut Window, cx: &mut App) -> Self {
let mut field = |text: &str| {
let text = text.to_string();
cx.new(|cx| {
let mut input = TextInput::new(window, cx);
input.set_text(text, cx);
input
})
};
let (auth, token, user, password) = match &request.auth {
Auth::None => ("none", "", "", ""),
Auth::Bearer(token) => ("bearer", token.as_ref(), "", ""),
Auth::Basic { user, password } => ("basic", "", user.as_ref(), password.as_ref()),
};
let (url, token, user, password) = (
field(&request.url),
field(token),
field(user),
field(password),
);
let body = request.body.to_string();
Self {
seed: request.key.clone(),
method: request.method,
url,
params: request.params.clone(),
headers: request.headers.clone(),
body: cx.new(|cx| CodeEditor::new(body, window, cx).language("JSON")),
auth: auth.into(),
token,
user,
password,
tab: "params".into(),
}
}
fn read(&self, key: &SharedString, cx: &App) -> ApiRequest {
let text =
|input: &Entity<TextInput>| SharedString::from(input.read(cx).text().to_string());
ApiRequest {
key: key.clone(),
method: self.method,
url: SharedString::from(self.url.read(cx).text().trim().to_string()),
params: self.params.clone(),
headers: self.headers.clone(),
body: SharedString::from(self.body.read(cx).text().to_string()),
auth: match self.auth.as_ref() {
"bearer" => Auth::Bearer(text(&self.token)),
"basic" => Auth::Basic {
user: text(&self.user),
password: text(&self.password),
},
"none" => Auth::None,
other => panic!("request builder: no sign-in named {other}"),
},
}
}
}
/// A request to build: its method and address, then tabs for the query's pairs, headers, body and sign-in. Under the address, the one that goes out, with the environment's `{{variables}}` filled in, or the ones it lacks. Send hands the owner the request, waiting for an address; a new request from the owner refills it.
#[derive(IntoElement)]
pub struct ApiRequestBuilder {
id: ElementId,
request: ApiRequest,
variables: Vec<(SharedString, SharedString)>,
sending: bool,
on_send: Option<OnRequest>,
}
impl ApiRequestBuilder {
pub fn new(id: impl Into<ElementId>, request: ApiRequest) -> Self {
Self {
id: id.into(),
request,
variables: Vec::new(),
sending: false,
on_send: None,
}
}
/// The environment's variables, which `{{name}}` in the address stands for.
pub fn variables(
mut self,
variables: impl IntoIterator<Item = (impl Into<SharedString>, impl Into<SharedString>)>,
) -> Self {
self.variables = variables
.into_iter()
.map(|(name, value)| (name.into(), value.into()))
.collect();
self
}
/// While the owner waits for an answer, Send rests.
pub fn sending(mut self, sending: bool) -> Self {
self.sending = sending;
self
}
pub fn on_send(
mut self,
handler: impl Fn(ApiRequest, &mut Window, &mut App) + 'static,
) -> Self {
self.on_send = Some(Rc::new(handler));
self
}
}
impl RenderOnce for ApiRequestBuilder {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let (id, request) = (self.id, self.request);
let draft = window.use_keyed_state((id.clone(), "draft"), cx, |window, cx| {
Draft::filled(&request, window, cx)
});
if draft.read(cx).seed != request.key {
log::info!("request builder: filled from {}", request.key);
let fresh = Draft::filled(&request, window, cx);
draft.update(cx, |draft, _| *draft = fresh);
}
let now = draft.read(cx).read(&request.key, cx);
let (tab, auth) = (draft.read(cx).tab.clone(), draft.read(cx).auth.clone());
let (url, body, token, user, password) = {
let draft = draft.read(cx);
(
draft.url.clone(),
draft.body.clone(),
draft.token.clone(),
draft.user.clone(),
draft.password.clone(),
)
};
let (going, missing) = resolve(&now.address(), &self.variables);
let theme = cx.theme();
let update = |edit: fn(&mut Draft, SharedString)| {
let draft = draft.clone();
move |value: &SharedString, _: &mut Window, cx: &mut App| {
draft.update(cx, |draft, cx| {
edit(draft, value.clone());
cx.notify();
})
}
};
let pairs = |edit: fn(&mut Draft, Vec<(String, String)>)| {
let draft = draft.clone();
move |rows: Vec<(String, String)>, _: &mut Window, cx: &mut App| {
draft.update(cx, |draft, cx| {
edit(draft, rows);
cx.notify();
})
}
};
let panel = match tab.as_ref() {
"params" => KeyValueInput::new(
(id.clone(), format!("params-{}", request.key)),
now.params.clone(),
)
.on_change(pairs(|draft, rows| draft.params = rows))
.into_any_element(),
"headers" => KeyValueInput::new(
(id.clone(), format!("headers-{}", request.key)),
now.headers.clone(),
)
.on_change(pairs(|draft, rows| draft.headers = rows))
.into_any_element(),
"body" => div()
.h(theme.list_max_height())
.rounded(theme.radius(Radius::Md))
.border_1()
.border_color(theme.colors.border)
.overflow_hidden()
.child(body)
.into_any_element(),
_ => div()
.flex()
.flex_col()
.gap_3()
.child(
Select::new(
(id.clone(), "auth"),
[
Choice::new("none", "No sign-in"),
Choice::new("bearer", "Bearer token"),
Choice::new("basic", "Basic"),
],
)
.selected(auth.clone())
.on_change(update(|draft, value| draft.auth = value)),
)
.children((auth == "bearer").then(|| {
FormField::new((id.clone(), "token"), "Token").child(PasswordInput::new(&token))
}))
.children(
(auth == "basic").then(|| {
FormField::new((id.clone(), "user"), "User").child(Input::new(&user))
}),
)
.children((auth == "basic").then(|| {
FormField::new((id.clone(), "password"), "Password")
.child(PasswordInput::new(&password))
}))
.into_any_element(),
};
let ready = !now.url.is_empty() && !self.sending;
let (sent, on_send) = (draft.clone(), self.on_send);
let key = request.key.clone();
div()
.flex()
.flex_col()
.gap_3()
.child(
div()
.flex()
.flex_wrap()
.gap_2()
.child(
div().flex_none().w(theme.label_width()).child(
Select::new(
(id.clone(), "method"),
Method::ALL
.iter()
.map(|method| Choice::new(method.name(), method.name())),
)
.selected(now.method.name())
.on_change(update(|draft, value| draft.method = Method::named(&value))),
),
)
.child(
div()
.flex_1()
.min_w(theme.label_width())
.child(Input::new(&url)),
)
.child(
Button::new(
(id.clone(), "send"),
if self.sending { "Sending…" } else { "Send" },
)
.variant(ButtonVariant::Primary)
.disabled(!ready)
.on_click(move |_, window, cx| {
let request = sent.read(cx).read(&key, cx);
log::info!(
"request builder: send {} {}",
request.method.name(),
request.address()
);
if let Some(on_send) = &on_send {
on_send(request, window, cx);
}
}),
),
)
.child(
div()
.text_size(theme.text_size(TextSize::Xs))
.text_color(theme.colors.fg_subtle)
.child(Ellipsis::new(going)),
)
.children((!missing.is_empty()).then(|| {
InlineMessage::new(
Severity::Warning,
format!("No value for {}", missing.join(", ")),
)
}))
.child(
Tabs::new(
(id, "tabs"),
[
Choice::new("params", "Params"),
Choice::new("headers", "Headers"),
Choice::new("body", "Body"),
Choice::new("auth", "Auth"),
],
tab,
)
.on_change(update(|draft, value| draft.tab = value))
.panel(panel),
)
}
}
#[cfg(test)]
mod tests {
use super::{ApiRequest, Method, percent, resolve};
#[test]
fn an_address_carries_its_encoded_pairs() {
let mut request = ApiRequest::new("r", Method::Get, "https://api.example.com/items");
request.params = vec![
("q".into(), "red shoes".into()),
("".into(), "dropped".into()),
("page".into(), "2".into()),
];
assert_eq!(
request.address(),
"https://api.example.com/items?q=red%20shoes&page=2"
);
assert_eq!(percent("café & co"), "caf%C3%A9%20%26%20co");
request.url = "https://api.example.com/items?sort=new".into();
request.params.truncate(1);
assert_eq!(
request.address(),
"https://api.example.com/items?sort=new&q=red%20shoes",
"pairs join a query already there"
);
}
#[test]
fn variables_fill_in_and_the_missing_are_named() {
let variables = [("host".into(), "api.example.com".into())];
assert_eq!(
resolve("https://{{host}}/v1?key={{ token }}", &variables),
(
"https://api.example.com/v1?key={{ token }}".to_string(),
vec!["token".to_string()]
)
);
assert_eq!(
resolve("{{host}}/{{open", &variables),
("api.example.com/{{open".to_string(), Vec::new()),
"braces that never close stay as written"
);
}
}
+185
View File
@@ -0,0 +1,185 @@
use gpui::{
App, ElementId, InteractiveElement, IntoElement, ParentElement, RenderOnce, SharedString,
StatefulInteractiveElement, Styled, Window, div,
};
use super::json_editor::reformat;
use crate::{
data_display::{Badge, Tone},
forms::Choice,
navigation::Tabs,
tables::Table,
theme::{ActiveTheme, Radius, TextSize},
typography::format,
};
/// What came back: its status, how long it took in milliseconds, its size in bytes, its headers and its body.
#[derive(Clone, Debug, PartialEq)]
pub struct ApiResponse {
pub status: u16,
pub took_ms: u32,
pub size: u64,
pub headers: Vec<(SharedString, SharedString)>,
pub body: SharedString,
}
/// A status's standard words.
pub fn reason(status: u16) -> &'static str {
match status {
200 => "OK",
201 => "Created",
204 => "No Content",
301 => "Moved Permanently",
302 => "Found",
304 => "Not Modified",
400 => "Bad Request",
401 => "Unauthorized",
403 => "Forbidden",
404 => "Not Found",
409 => "Conflict",
422 => "Unprocessable Content",
429 => "Too Many Requests",
500 => "Internal Server Error",
502 => "Bad Gateway",
503 => "Service Unavailable",
_ => "",
}
}
/// The tone a status class wears: success, a redirect, the client's fault, or the server's.
pub fn status_tone(status: u16) -> Tone {
match status {
200..=299 => Tone::Success,
300..=399 => Tone::Info,
400..=499 => Tone::Warning,
_ => Tone::Danger,
}
}
/// Headers in a table, names down the left and values beside them.
#[derive(IntoElement)]
pub struct HeadersTable {
headers: Vec<(SharedString, SharedString)>,
}
impl HeadersTable {
pub fn new(
headers: impl IntoIterator<Item = (impl Into<SharedString>, impl Into<SharedString>)>,
) -> Self {
Self {
headers: headers
.into_iter()
.map(|(name, value)| (name.into(), value.into()))
.collect(),
}
}
}
impl RenderOnce for HeadersTable {
fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
self.headers
.into_iter()
.fold(Table::new(["Header", "Value"]), |table, (name, value)| {
table.row([name, value])
})
}
}
/// An answer to a request: its status in its tone with the standard words, how long it took and how big it is; then its body, JSON indented, or its headers.
#[derive(IntoElement)]
pub struct ResponseViewer {
id: ElementId,
response: ApiResponse,
}
impl ResponseViewer {
pub fn new(id: impl Into<ElementId>, response: ApiResponse) -> Self {
Self {
id: id.into(),
response,
}
}
}
impl RenderOnce for ResponseViewer {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let response = self.response;
let tab = window.use_keyed_state((self.id.clone(), "tab"), cx, |_, _| {
SharedString::from("body")
});
let shown = tab.read(cx).clone();
let theme = cx.theme();
let body = reformat(&response.body, true).unwrap_or_else(|| response.body.to_string());
let panel = match shown.as_ref() {
"headers" => HeadersTable::new(response.headers.clone()).into_any_element(),
_ => div()
.id((self.id.clone(), "body"))
.max_h(theme.list_max_height())
.overflow_y_scroll()
.p_3()
.rounded(theme.radius(Radius::Md))
.bg(theme.colors.sunken)
.font_family(theme.mono_family.clone())
.text_size(theme.text_size(TextSize::Sm))
.child(body)
.into_any_element(),
};
let quiet = |text: String| {
div()
.text_size(theme.text_size(TextSize::Sm))
.text_color(theme.colors.fg_muted)
.child(text)
};
let tabs = Tabs::new(
(self.id, "tabs"),
[
Choice::new("body", "Body"),
Choice::new("headers", format!("Headers {}", response.headers.len())),
],
shown,
)
.panel(panel)
.on_change(move |next, _, cx| {
log::info!("response viewer: {next}");
tab.update(cx, |tab, cx| {
*tab = next.clone();
cx.notify();
})
});
div()
.flex()
.flex_col()
.gap_3()
.child(
div()
.flex()
.flex_wrap()
.items_center()
.gap_3()
.child(
Badge::new(format!("{} {}", response.status, reason(response.status)))
.tone(status_tone(response.status)),
)
.child(quiet(format!("{} ms", response.took_ms)))
.child(quiet(format::file_size(response.size, false))),
)
.child(tabs)
}
}
#[cfg(test)]
mod tests {
use super::{reason, status_tone};
use crate::data_display::Tone;
#[test]
fn a_status_takes_its_words_and_its_class_tone() {
assert_eq!(
(reason(404), status_tone(404)),
("Not Found", Tone::Warning)
);
assert_eq!(status_tone(201), Tone::Success);
assert_eq!(status_tone(503), Tone::Danger);
assert_eq!(reason(299), "");
}
}
+243
View File
@@ -0,0 +1,243 @@
use std::rc::Rc;
use gpui::{
App, ElementId, Entity, IntoElement, ParentElement, RenderOnce, SharedString, Styled, Window,
div, prelude::*,
};
use jiff::Timestamp;
use super::json_editor::reformat;
use crate::{
buttons::{Button, ButtonVariant},
data_display::{Badge, Tone},
forms::{Input, TextInput},
primitives::{Icon, IconName},
theme::{ActiveTheme, IconSize, Radius, TextSize},
typography::{Ellipsis, format},
};
/// Which way a message went: out, in, or a note about the connection itself.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Direction {
Sent,
Received,
Note,
}
/// A message on a socket: which way it went, its text, and when.
#[derive(Clone, Debug, PartialEq)]
pub struct SocketMessage {
pub direction: Direction,
pub text: SharedString,
pub at: Timestamp,
}
/// Where a socket stands.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SocketState {
Closed,
Opening,
Open,
}
type OnText = Rc<dyn Fn(SharedString, &mut Window, &mut App)>;
type Run = Rc<dyn Fn(&mut Window, &mut App)>;
/// A WebSocket's conversation: where it stands with Connect or Disconnect beside it, its messages each marked sent or received with its time, JSON indented, newest last, and a field that sends on Enter while open.
#[derive(IntoElement)]
pub struct WebSocketConsole {
id: ElementId,
url: SharedString,
state: SocketState,
messages: Vec<SocketMessage>,
draft: Entity<TextInput>,
now: Timestamp,
on_send: Option<OnText>,
on_toggle: Option<Run>,
}
impl WebSocketConsole {
/// `draft` is the message field's text, which the owner keeps; `now` dates each message.
pub fn new(
id: impl Into<ElementId>,
url: impl Into<SharedString>,
state: SocketState,
messages: impl IntoIterator<Item = SocketMessage>,
draft: &Entity<TextInput>,
now: Timestamp,
) -> Self {
Self {
id: id.into(),
url: url.into(),
state,
messages: messages.into_iter().collect(),
draft: draft.clone(),
now,
on_send: None,
on_toggle: None,
}
}
/// Gets the text to send; the field empties after.
pub fn on_send(
mut self,
handler: impl Fn(SharedString, &mut Window, &mut App) + 'static,
) -> Self {
self.on_send = Some(Rc::new(handler));
self
}
/// Asked to connect while closed, or to disconnect while open.
pub fn on_toggle(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
self.on_toggle = Some(Rc::new(handler));
self
}
}
impl RenderOnce for WebSocketConsole {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
let (tone, words) = match self.state {
SocketState::Closed => (Tone::Neutral, "Closed"),
SocketState::Opening => (Tone::Info, "Opening"),
SocketState::Open => (Tone::Success, "Open"),
};
let open = self.state == SocketState::Open;
let rows = self.messages.iter().map(|message| {
let (icon, ink) = match message.direction {
Direction::Sent => (IconName::ArrowUpRight, theme.colors.accent),
Direction::Received => (IconName::ArrowDownLeft, theme.colors.success),
Direction::Note => (IconName::Info, theme.colors.fg_subtle),
};
let text = reformat(&message.text, true).unwrap_or_else(|| message.text.to_string());
div()
.flex()
.items_start()
.gap_2()
.py_1p5()
.border_b_1()
.border_color(theme.colors.border)
.child(
div()
.flex_none()
.pt_0p5()
.child(Icon::new(icon).size(IconSize::Sm).color(ink)),
)
.child(
div()
.flex_1()
.min_w_0()
.flex()
.flex_wrap()
.gap_x_3()
.gap_y_0p5()
.child(
div()
.flex_1()
.min_w(theme.label_width())
.font_family(theme.mono_family.clone())
.text_size(theme.text_size(TextSize::Sm))
.when(message.direction == Direction::Note, |line| {
line.text_color(theme.colors.fg_muted)
})
.child(text),
)
.child(
div()
.flex_none()
.text_size(theme.text_size(TextSize::Xs))
.text_color(theme.colors.fg_subtle)
.child(format::relative(message.at, self.now)),
),
)
});
let on_send = self
.on_send
.unwrap_or_else(|| panic!("websocket console {:?} has no on_send", self.id));
let draft = self.draft.clone();
let ready = open && !draft.read(cx).text().trim().is_empty();
let send = Rc::new(move |window: &mut Window, cx: &mut App| {
let text = draft.read(cx).text().trim().to_string();
log::info!("websocket console: send {} bytes", text.len());
draft.update(cx, |draft, cx| draft.set_text(String::new(), cx));
on_send(text.into(), window, cx);
});
let (pressed, entered, on_toggle) = (send.clone(), send, self.on_toggle);
div()
.flex()
.flex_col()
.gap_3()
.child(
div()
.flex()
.flex_wrap()
.items_center()
.justify_between()
.gap_2()
.child(
div()
.flex()
.items_center()
.gap_2()
.min_w_0()
.child(Badge::new(words).tone(tone).dot())
.child(
div()
.min_w_0()
.text_size(theme.text_size(TextSize::Sm))
.child(Ellipsis::new(self.url.clone())),
),
)
.children(on_toggle.map(|on_toggle| {
Button::new(
(self.id.clone(), "toggle"),
if self.state == SocketState::Closed {
"Connect"
} else {
"Disconnect"
},
)
.variant(ButtonVariant::Secondary)
.disabled(self.state == SocketState::Opening)
.on_click(move |_, window, cx| {
log::info!("websocket console: connect or disconnect");
on_toggle(window, cx);
})
})),
)
.child(
div()
.id((self.id.clone(), "log"))
.max_h(theme.list_max_height())
.overflow_y_scroll()
.px_3()
.rounded(theme.radius(Radius::Md))
.border_1()
.border_color(theme.colors.border)
.children(rows),
)
.child(
div()
.flex()
.gap_2()
.child(
div()
.flex_1()
.min_w_0()
.capture_action(move |_: &crate::forms::Enter, window, cx| {
if ready {
cx.stop_propagation();
entered(window, cx);
}
})
.child(Input::new(&self.draft)),
)
.child(
Button::new((self.id, "send"), "Send")
.variant(ButtonVariant::Primary)
.disabled(!ready)
.on_click(move |_, window, cx| pressed(window, cx)),
),
)
}
}
+167
View File
@@ -0,0 +1,167 @@
use gpui::{
AnyElement, App, AppContext as _, Entity, IntoElement, ParentElement, Styled, TestAppContext,
Window, div, px,
};
use super::{Bench, bench, said, say, settle, tab, tap};
use crate::{
devtools::{
ApiRequest, ApiRequestBuilder, Auth, CollectionTree, GraphQLExplorer, Method, Saved,
SocketState, WebSocketConsole,
},
editor::CodeEditor,
forms::TextInput,
};
fn builder(_: &Bench, _: &mut Window, _: &mut App, owner: Entity<Bench>) -> AnyElement {
let mut request = ApiRequest::new("r", Method::Get, "https://{{host}}/items");
request.params = vec![("q".into(), "red shoes".into())];
request.auth = Auth::Basic {
user: "ada".into(),
password: "pw".into(),
};
ApiRequestBuilder::new("builder", request)
.variables([("host", "api.example.com")])
.on_send(move |request, _, cx| {
say(
&owner,
format!(
"{} {} {:?}",
request.method.name(),
request.address(),
request.auth
),
cx,
)
})
.into_any_element()
}
/// Stops: the method, the address, then Send.
#[gpui::test]
fn send_hands_on_the_request_with_its_pairs(cx: &mut TestAppContext) {
let (host, cx) = bench(builder, cx);
tab(3, cx);
tap("space", cx);
assert_eq!(
said(&host, cx),
[r#"GET https://{{host}}/items?q=red%20shoes Basic { user: "ada", password: "pw" }"#]
);
}
fn console(bench: &Bench, window: &mut Window, cx: &mut App, owner: Entity<Bench>) -> AnyElement {
let draft = window.use_keyed_state("draft", cx, TextInput::new);
let now: jiff::Timestamp = "2026-09-27T12:00:00Z".parse().expect("a time");
let state = match bench.choice {
0 => SocketState::Open,
_ => SocketState::Closed,
};
let toggled = owner.clone();
WebSocketConsole::new("socket", "wss://example.com", state, [], &draft, now)
.on_send(move |text, _, cx| say(&owner, format!("send {text}"), cx))
.on_toggle(move |_, cx| say(&toggled, "toggle".into(), cx))
.into_any_element()
}
/// Stops: Disconnect, the message field, then Send once it holds words.
#[gpui::test]
fn enter_in_the_field_sends_and_empties_it(cx: &mut TestAppContext) {
let (host, cx) = bench(console, cx);
tab(2, cx);
cx.simulate_input("ping");
tap("enter", cx);
tap("enter", cx);
assert_eq!(
said(&host, cx),
["send ping"],
"an empty field sends nothing"
);
}
/// Stops while closed: Connect, then the field; Send rests, so a third Tab wraps to Connect.
#[gpui::test]
fn a_closed_socket_sends_nothing_and_connect_asks_the_owner(cx: &mut TestAppContext) {
let (host, cx) = bench(console, cx);
host.update(cx, |bench, cx| {
bench.choice = 1;
cx.notify();
});
settle(cx);
tab(2, cx);
cx.simulate_input("ping");
tap("enter", cx);
tab(3, cx);
tap("space", cx);
assert_eq!(said(&host, cx), ["toggle"]);
}
fn saved(_: &Bench, _: &mut Window, _: &mut App, owner: Entity<Bench>) -> AnyElement {
let list = Saved::Request {
key: "list".into(),
name: "List orders".into(),
method: Method::Get,
};
let orders = Saved::Folder {
key: "orders".into(),
name: "Orders".into(),
items: vec![list],
};
div()
.h(px(200.0))
.child(
CollectionTree::new("saved", [orders])
.open(["orders"])
.on_open(move |key, _, cx| say(&owner, format!("open {key}"), cx)),
)
.into_any_element()
}
/// Stops: the tree, its cursor on the folder.
#[gpui::test]
fn enter_on_a_saved_request_opens_it(cx: &mut TestAppContext) {
let (host, cx) = bench(saved, cx);
tab(1, cx);
tap("down", cx);
tap("enter", cx);
assert_eq!(said(&host, cx), ["open list"]);
}
fn graph(bench: &Bench, window: &mut Window, cx: &mut App, owner: Entity<Bench>) -> AnyElement {
let (Some(variables), query) = (
bench.editor.clone(),
window.use_keyed_state("query", cx, |window, cx| {
CodeEditor::new("{ a }", window, cx)
}),
) else {
return div().into_any_element();
};
let search = window.use_keyed_state("search", cx, TextInput::new);
GraphQLExplorer::new("graph", [], &query, &variables, &search)
.on_run(move |query, variables, _, cx| say(&owner, format!("{query} {variables}"), cx))
.into_any_element()
}
/// Stops: the schema, the query, the variables, then Run while they read.
#[gpui::test]
fn run_waits_for_variables_that_read(cx: &mut TestAppContext) {
let (host, cx) = bench(graph, cx);
let variables = cx.update(|window, cx| cx.new(|cx| CodeEditor::new("{", window, cx)));
host.update(cx, |bench, cx| {
bench.editor = Some(variables.clone());
cx.notify();
});
settle(cx);
tab(4, cx);
tap("space", cx);
assert!(
said(&host, cx).is_empty(),
"variables that do not read keep Run at rest"
);
variables.update(cx, |editor, cx| {
editor.set_text(r#"{"id": 1}"#.to_string(), cx)
});
settle(cx);
tab(4, cx);
tap("space", cx);
assert_eq!(said(&host, cx), [r#"{ a } {"id": 1}"#]);
}
+1
View File
@@ -6,6 +6,7 @@ use gpui::{
use super::{Connection, ConnectionForm, ConnectionManager, Engine, Field, TableStructureEditor};
use crate::{editor::CodeEditor, forms, primitives::FocusNext, theme::Theme};
mod api;
mod formats;
type Part = fn(&Bench, &mut Window, &mut App, Entity<Bench>) -> AnyElement;
+8 -8
View File
@@ -103,14 +103,14 @@
## T33c DB & Dev Tools · APIs
- [ ] ApiRequestBuilder
- [ ] ResponseViewer
- [ ] HeadersTable
- [ ] QueryParamsEditor → `forms::KeyValueInput`
- [ ] EnvironmentSelector
- [ ] CollectionTree
- [ ] WebSocketConsole
- [ ] GraphQLExplorer
- [x] ApiRequestBuilder
- [x] ResponseViewer
- [x] HeadersTable
- [x] QueryParamsEditor → `forms::KeyValueInput`
- [x] EnvironmentSelector
- [x] CollectionTree
- [x] WebSocketConsole
- [x] GraphQLExplorer
## T33d DB & Dev Tools · Tools and machines