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:
1 parent
2ec826761e
commit
3c9f52f853
12 files changed
+1963
-8
No files matched your search
@@ -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))
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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), "");
|
||||
}
|
||||
}
|
||||
@@ -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)),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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}"#]);
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
Reference in new issue
Block a user