diff --git a/examples/gallery/pages/devtools/apis.rs b/examples/gallery/pages/devtools/apis.rs new file mode 100644 index 0000000..ebbb055 --- /dev/null +++ b/examples/gallery/pages/devtools/apis.rs @@ -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 { + 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, + picked: Vec, +} + +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, + draft: gpui::Entity, + 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, + variables: gpui::Entity, + search: gpui::Entity, + answer: Option, +} + +/// 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)) +} diff --git a/examples/gallery/pages/devtools/mod.rs b/examples/gallery/pages/devtools/mod.rs index ac2d7b8..40af54e 100644 --- a/examples/gallery/pages/devtools/mod.rs +++ b/examples/gallery/pages/devtools/mod.rs @@ -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() } diff --git a/src/devtools/collection.rs b/src/devtools/collection.rs new file mode 100644 index 0000000..37fc93e --- /dev/null +++ b/src/devtools/collection.rs @@ -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, + }, + Request { + key: SharedString, + name: SharedString, + method: Method, + }, +} + +impl Saved { + pub fn folder( + key: impl Into, + name: impl Into, + items: impl IntoIterator, + ) -> Self { + Saved::Folder { + key: key.into(), + name: name.into(), + items: items.into_iter().collect(), + } + } + + pub fn request( + key: impl Into, + name: impl Into, + 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; +type OnKeys = Rc; + +/// 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, + open: Vec, + selected: Vec, + on_open: Option, + on_select: Option, +} + +impl CollectionTree { + pub fn new(id: impl Into, items: impl IntoIterator) -> 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>) -> Self { + self.open = keys.into_iter().map(Into::into).collect(); + self + } + + pub fn selected(mut self, keys: impl IntoIterator>) -> 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 + } +} diff --git a/src/devtools/environment.rs b/src/devtools/environment.rs new file mode 100644 index 0000000..7d79d29 --- /dev/null +++ b/src/devtools/environment.rs @@ -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, +} + +type OnKey = Rc; + +/// 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, + selected: SharedString, + on_change: Option, +} + +impl EnvironmentSelector { + pub fn new( + id: impl Into, + environments: impl IntoIterator, + selected: impl Into, + ) -> 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, 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::::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"); + } +} diff --git a/src/devtools/graphql.rs b/src/devtools/graphql.rs new file mode 100644 index 0000000..1fc33f9 --- /dev/null +++ b/src/devtools/graphql.rs @@ -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; +type OnKey = Rc; + +/// 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, + query: Entity, + variables: Entity, + answer: Option, + search: Entity, + running: bool, + on_run: Option, + on_pick: Option, +} + +impl GraphQLExplorer { + /// The owner keeps the query's and the variables' editors, and the answer's find field. + pub fn new( + id: impl Into, + types: impl IntoIterator, + query: &Entity, + variables: &Entity, + search: &Entity, + ) -> 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) -> 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) + } +} diff --git a/src/devtools/mod.rs b/src/devtools/mod.rs index 187bed8..82dc7e7 100644 --- a/src/devtools/mod.rs +++ b/src/devtools/mod.rs @@ -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}; diff --git a/src/devtools/request.rs b/src/devtools/request.rs new file mode 100644 index 0000000..8365fdd --- /dev/null +++ b/src/devtools/request.rs @@ -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, method: Method, url: impl Into) -> 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 = 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) { + 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; + +/// The builder's own state, filled from the request named by `seed`. +struct Draft { + seed: SharedString, + method: Method, + url: Entity, + params: Vec<(String, String)>, + headers: Vec<(String, String)>, + body: Entity, + auth: SharedString, + token: Entity, + user: Entity, + password: Entity, + 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| 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, +} + +impl ApiRequestBuilder { + pub fn new(id: impl Into, 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, impl Into)>, + ) -> 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" + ); + } +} diff --git a/src/devtools/response.rs b/src/devtools/response.rs new file mode 100644 index 0000000..7f5ac56 --- /dev/null +++ b/src/devtools/response.rs @@ -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, impl Into)>, + ) -> 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, 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), ""); + } +} diff --git a/src/devtools/socket.rs b/src/devtools/socket.rs new file mode 100644 index 0000000..63ce4f2 --- /dev/null +++ b/src/devtools/socket.rs @@ -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; +type Run = Rc; + +/// 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, + draft: Entity, + now: Timestamp, + on_send: Option, + on_toggle: Option, +} + +impl WebSocketConsole { + /// `draft` is the message field's text, which the owner keeps; `now` dates each message. + pub fn new( + id: impl Into, + url: impl Into, + state: SocketState, + messages: impl IntoIterator, + draft: &Entity, + 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)), + ), + ) + } +} diff --git a/src/devtools/tests/api.rs b/src/devtools/tests/api.rs new file mode 100644 index 0000000..d47c893 --- /dev/null +++ b/src/devtools/tests/api.rs @@ -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) -> 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) -> 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) -> 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) -> 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}"#]); +} diff --git a/src/devtools/tests/mod.rs b/src/devtools/tests/mod.rs index 2334f3e..8a44c0d 100644 --- a/src/devtools/tests/mod.rs +++ b/src/devtools/tests/mod.rs @@ -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) -> AnyElement; diff --git a/tasks/ch31-43.md b/tasks/ch31-43.md index cc5e440..af66bc5 100644 --- a/tasks/ch31-43.md +++ b/tasks/ch31-43.md @@ -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