From 658e4e73c67d5e0f82b109ca0bd9ccd24aaa5e76 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Sun, 27 Sep 2026 10:44:21 -0400 Subject: [PATCH] =?UTF-8?q?DB=20&=20Dev=20Tools=20=C2=B7=20APIs:=20a=20log?= =?UTF-8?q?=20held=20at=20its=20newest,=20secrets=20per=20environment,=20m?= =?UTF-8?q?oves?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WebSocketConsole draws its log through chat::MessageList, so it holds at the newest message. EnvironmentSelector opens a secret for its own environment only. CollectionTree takes on_move and devtools::moved applies a drop; the gallery opens a saved request into the builder and moves them. Tests cover the builder's refill, Send at rest, Bearer sign-in, the indented body, the eye, a field's pick path and the log. --- TASKS.md | 1 + examples/gallery/pages/devtools/apis.rs | 145 ++++++++++++------- src/devtools/collection.rs | 124 +++++++++++++++- src/devtools/environment.rs | 40 ++++-- src/devtools/mod.rs | 2 +- src/devtools/request.rs | 1 + src/devtools/response.rs | 14 +- src/devtools/socket.rs | 119 +++++++++------- src/devtools/tests/api.rs | 179 ++++++++++++++++++++++-- 9 files changed, 488 insertions(+), 137 deletions(-) diff --git a/TASKS.md b/TASKS.md index 3fb20a8..adc2049 100644 --- a/TASKS.md +++ b/TASKS.md @@ -390,3 +390,4 @@ Per-component lines live in `tasks/`. Tags there: | T33b | 1 | FAIL | YAML lists at their key's indent and quoted `#` misread; a long note hid the key at 280px; XML text paths counted from 0 and entities stayed raw; odd-key paths and Format at rest untested | | T33b | 2 | FAIL | quoted YAML scalars kept their escapes and single-quoted keys their quotes; the comment scanner's escape and after-a-space rules untested | | T33b | 3 | FAIL | five escape arms untested and a quoted key holding `: ` split inside its quotes; both fixed after the last round with tests that go red on each mutation, no fourth round | +| T33c | 1 | FAIL | the socket log stayed at its top; a revealed secret stayed open across environments; the collection drew drags it never moved; the builder's refill, Send's rest and Bearer, the indented body, the eye and a field's pick path untested | diff --git a/examples/gallery/pages/devtools/apis.rs b/examples/gallery/pages/devtools/apis.rs index ebbb055..f3c1063 100644 --- a/examples/gallery/pages/devtools/apis.rs +++ b/examples/gallery/pages/devtools/apis.rs @@ -1,8 +1,8 @@ use ely_gpui_component::{ devtools::{ - ApiRequest, ApiRequestBuilder, ApiResponse, CollectionTree, Direction, Environment, + ApiRequest, ApiRequestBuilder, ApiResponse, Auth, CollectionTree, Direction, Environment, EnvironmentSelector, GraphQLExplorer, Method, ResponseViewer, Saved, SchemaType, - SocketMessage, SocketState, TypeKind, WebSocketConsole, + SocketMessage, SocketState, TypeKind, WebSocketConsole, moved, }, editor::CodeEditor, forms::TextInput, @@ -16,43 +16,89 @@ use jiff::Timestamp; use crate::ui::{change, keep, section}; fn environments() -> Vec { - let env = |key: &str, name: &str, host: &str| Environment { + let env = |key: &str, name: &str, host: &str, token: &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()), + ("token".into(), token.to_string().into()), ], secrets: vec!["token".into()], }; vec![ - env("local", "Local", "localhost:8080"), - env("staging", "Staging", "staging.api.example.com"), - env("production", "Production", "api.example.com"), + env("local", "Local", "localhost:8080", "sk_test_local"), + env( + "staging", + "Staging", + "staging.api.example.com", + "sk_test_4f9a2c", + ), + env( + "production", + "Production", + "api.example.com", + "sk_live_91c07e", + ), ] } -/// The API demo's request, its environment, the answer and the request picked. +/// The saved requests, each as the builder opens it. +fn saved_requests() -> Vec { + let mut list = ApiRequest::new("list-orders", Method::Get, "https://{{host}}/v1/orders"); + list.params = vec![ + ("status".into(), "open".into()), + ("limit".into(), "20".into()), + ]; + list.headers = vec![("Accept".into(), "application/json".into())]; + let mut create = ApiRequest::new("create-order", Method::Post, "https://{{host}}/v1/orders"); + create.body = r#"{"items": [{"sku": "tea", "quantity": 2}]}"#.into(); + create.auth = Auth::Bearer("{{token}}".into()); + let cancel = ApiRequest::new( + "cancel-order", + Method::Delete, + "https://{{host}}/v1/orders/1009", + ); + let me = ApiRequest::new("me", Method::Get, "https://{{host}}/v1/me"); + let mut rename = ApiRequest::new("rename", Method::Patch, "https://{{host}}/v1/me"); + rename.body = r#"{"name": "Ada"}"#.into(); + vec![list, create, cancel, me, rename] +} + +/// The API demo's saved requests and their folders, the one open, its environment and the last answer. struct Desk { - request: ApiRequest, + requests: Vec, + collection: Vec, + open: SharedString, 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, + requests: saved_requests(), + collection: vec![ + 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), + ], + ), + ], + open: "list-orders".into(), environment: "staging".into(), response: Some(answer()), - picked: vec!["list-orders".into()], } } } @@ -74,41 +120,28 @@ fn answer() -> ApiResponse { 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(), + let (collection, open, environment, response) = ( + now.collection.clone(), + now.open.clone(), now.environment.clone(), now.response.clone(), - now.picked.clone(), ); + let request = now + .requests + .iter() + .find(|request| request.key == open) + .expect("the open request is saved") + .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 [sent, chose, opened, dragged] = [(); 4].map(|_| desk.clone()); 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.", + "Saved requests in folders; a drag moves one, and Enter opens it in the builder 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( @@ -136,8 +169,17 @@ pub fn requests(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { .child( CollectionTree::new("devtools-collection", collection) .open(["orders"]) - .selected(picked) - .on_open(move |key, _, cx| change(&opened, cx, |desk| desk.picked = vec![key.clone()])), + .selected([open]) + .on_open(move |key, _, cx| { + change(&opened, cx, |desk| { + if desk.requests.iter().any(|request| request.key == *key) { + desk.open = key.clone(); + } + }) + }) + .on_move(move |key, target, at, _, cx| { + change(&dragged, cx, |desk| desk.collection = moved(&desk.collection, key, target, at)) + }), ), ), ) @@ -147,11 +189,16 @@ pub fn requests(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { .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()))), - ) + .child(ApiRequestBuilder::new("devtools-request", request).variables(variables).on_send( + move |request, _, cx| { + change(&sent, cx, |desk| { + if let Some(saved) = desk.requests.iter_mut().find(|each| each.key == request.key) { + *saved = request; + } + desk.response = Some(answer()); + }) + }, + )) .children(response.map(|response| ResponseViewer::new("devtools-response", response))), ), ) diff --git a/src/devtools/collection.rs b/src/devtools/collection.rs index 37fc93e..373b429 100644 --- a/src/devtools/collection.rs +++ b/src/devtools/collection.rs @@ -4,7 +4,7 @@ use gpui::{App, ElementId, IntoElement, RenderOnce, SharedString, Styled, Window use super::request::Method; use crate::{ - lists::{Tree, TreeNode}, + lists::{DropAt, Tree, TreeNode}, primitives::IconName, }; @@ -48,6 +48,12 @@ impl Saved { } } + fn key(&self) -> &SharedString { + match self { + Saved::Folder { key, .. } | Saved::Request { key, .. } => key, + } + } + fn node(&self) -> TreeNode { match self { Saved::Folder { key, name, items } => TreeNode::new(key.clone(), name.clone()) @@ -60,10 +66,58 @@ impl Saved { } } +/// Takes `key` out of `items`, wherever it lies. +fn taken(items: &mut Vec, key: &str) -> Option { + if let Some(ix) = items.iter().position(|item| item.key() == key) { + return Some(items.remove(ix)); + } + items.iter_mut().find_map(|item| match item { + Saved::Folder { items, .. } => taken(items, key), + Saved::Request { .. } => None, + }) +} + +/// Sets `item` at `at` of `target`, or hands it back when `target` is not in `items`. +fn placed(items: &mut Vec, item: Saved, target: &str, at: DropAt) -> Result<(), Saved> { + if let Some(ix) = items.iter().position(|each| each.key() == target) { + match (at, &mut items[ix]) { + (DropAt::Before, _) => items.insert(ix, item), + (DropAt::After, _) => items.insert(ix + 1, item), + (DropAt::Inside, Saved::Folder { items, .. }) => items.push(item), + (DropAt::Inside, Saved::Request { key, .. }) => { + panic!("collection: {key} is a request, and nothing goes inside it") + } + } + return Ok(()); + } + let mut item = item; + for each in items.iter_mut() { + if let Saved::Folder { items, .. } = each { + match placed(items, item, target, at) { + Ok(()) => return Ok(()), + Err(back) => item = back, + } + } + } + Err(item) +} + +/// `items` with `key` moved to `at` of `target`, as a drop in the tree asks. +pub fn moved(items: &[Saved], key: &str, target: &str, at: DropAt) -> Vec { + let mut items = items.to_vec(); + let item = taken(&mut items, key).unwrap_or_else(|| panic!("collection: no {key} to move")); + if let Err(item) = placed(&mut items, item, target, at) { + panic!("collection: no {target} to move {} beside", item.key()); + } + log::info!("collection: moved {key} {at:?} {target}"); + items +} + type OnKey = Rc; type OnKeys = Rc; +type OnMove = 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. +/// Saved requests in folders, each with its method. Enter or a double press opens a request; with `on_move`, a drag moves one into another folder or among its own, and `moved` applies it. It fills its box, which the host sizes. #[derive(IntoElement)] pub struct CollectionTree { id: ElementId, @@ -72,6 +126,7 @@ pub struct CollectionTree { selected: Vec, on_open: Option, on_select: Option, + on_move: Option, } impl CollectionTree { @@ -83,6 +138,7 @@ impl CollectionTree { selected: Vec::new(), on_open: None, on_select: None, + on_move: None, } } @@ -106,6 +162,15 @@ impl CollectionTree { self } + /// Runs with the request or folder dragged, the row it landed on, and where on that row. + pub fn on_move( + mut self, + handler: impl Fn(&SharedString, &SharedString, DropAt, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_move = Some(Rc::new(handler)); + self + } + pub fn on_select( mut self, handler: impl Fn(&[SharedString], &mut Window, &mut App) + 'static, @@ -130,6 +195,61 @@ impl RenderOnce for CollectionTree { if let Some(on_select) = self.on_select { tree = tree.on_select(move |keys, window, cx| on_select(keys, window, cx)); } + if let Some(on_move) = self.on_move { + tree = tree + .on_move(move |key, target, at, window, cx| on_move(key, target, at, window, cx)); + } tree } } + +#[cfg(test)] +mod tests { + use super::{Saved, moved}; + use crate::{devtools::Method, lists::DropAt}; + + fn keys(items: &[Saved]) -> Vec { + items + .iter() + .flat_map(|item| match item { + Saved::Folder { key, items, .. } => { + let inner = keys(items).join(","); + vec![format!("{key}[{inner}]")] + } + Saved::Request { key, .. } => vec![key.to_string()], + }) + .collect() + } + + #[test] + fn a_drop_moves_a_request_beside_a_row_or_into_a_folder() { + let items = vec![ + Saved::folder( + "a", + "A", + [ + Saved::request("x", "X", Method::Get), + Saved::request("y", "Y", Method::Get), + ], + ), + Saved::folder("b", "B", []), + Saved::request("z", "Z", Method::Post), + ]; + assert_eq!( + keys(&moved(&items, "x", "b", DropAt::Inside)), + ["a[y]", "b[x]", "z"] + ); + assert_eq!( + keys(&moved(&items, "z", "x", DropAt::Before)), + ["a[z,x,y]", "b[]"] + ); + assert_eq!( + keys(&moved(&items, "x", "z", DropAt::After)), + ["a[y]", "b[]", "z", "x"] + ); + assert_eq!( + keys(&moved(&items, "b", "a", DropAt::Before)), + ["b[]", "a[x,y]", "z"] + ); + } +} diff --git a/src/devtools/environment.rs b/src/devtools/environment.rs index 7d79d29..8103a68 100644 --- a/src/devtools/environment.rs +++ b/src/devtools/environment.rs @@ -1,8 +1,8 @@ use std::rc::Rc; use gpui::{ - App, ElementId, FontWeight, IntoElement, ParentElement, RenderOnce, SharedString, Styled, - Window, div, + App, ElementId, FontWeight, InteractiveElement, IntoElement, ParentElement, RenderOnce, + SharedString, Styled, Window, div, }; use crate::{ @@ -64,13 +64,16 @@ fn shown_value(value: &SharedString, secret: bool, open: bool) -> SharedString { } } -/// 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) { +/// A secret by its environment's key and its name. +type Secret = (SharedString, SharedString); + +/// Opens `secret` when it is shut, shuts it when it is open. +fn flipped(open: &mut Vec, secret: &Secret) { + match open.iter().position(|each| each == secret) { Some(ix) => { open.remove(ix); } - None => open.push(name.clone()), + None => open.push(secret.clone()), } } @@ -88,16 +91,21 @@ impl RenderOnce for EnvironmentSelector { ) }) .clone(); - let shown = - window.use_keyed_state((id.clone(), "shown"), cx, |_, _| Vec::::new()); + 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()); + let key: Secret = (chosen.key.clone(), name.clone()); + let open = shown.read(cx).contains(&key); + let flip = shown.clone(); + let seen = format!( + "environment-{}-{name}-{}", + chosen.key, + if secret && !open { "masked" } else { "shown" } + ); div() .flex() .items_center() @@ -117,6 +125,7 @@ impl RenderOnce for EnvironmentSelector { .min_w_0() .text_size(theme.text_size(TextSize::Sm)) .text_color(theme.colors.fg_muted) + .debug_selector(move || seen) .child(Ellipsis::new(shown_value(value, secret, open))), ) .children(secret.then(|| { @@ -135,7 +144,7 @@ impl RenderOnce for EnvironmentSelector { }) .on_click(move |_, _, cx| { flip.update(cx, |shown, cx| { - flipped(shown, &name_key); + flipped(shown, &key); cx.notify(); }) }) @@ -181,10 +190,11 @@ mod tests { 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); + let secret = (SharedString::from("staging"), SharedString::from("token")); + let mut open = Vec::new(); + flipped(&mut open, &secret); + assert_eq!(open, std::slice::from_ref(&secret)); + flipped(&mut open, &secret); assert!(open.is_empty(), "a second press shuts it again"); } } diff --git a/src/devtools/mod.rs b/src/devtools/mod.rs index 82dc7e7..5c6ca35 100644 --- a/src/devtools/mod.rs +++ b/src/devtools/mod.rs @@ -18,7 +18,7 @@ mod xml; #[cfg(all(test, feature = "test-support"))] mod tests; -pub use collection::{CollectionTree, Saved}; +pub use collection::{CollectionTree, Saved, moved}; pub use connection_form::{ConnectionForm, TestState}; pub use connections::{Connection, ConnectionManager, ConnectionState, Engine}; pub use datum::{Datum, Unread, read_json, read_yaml}; diff --git a/src/devtools/request.rs b/src/devtools/request.rs index 8365fdd..f637df6 100644 --- a/src/devtools/request.rs +++ b/src/devtools/request.rs @@ -261,6 +261,7 @@ impl ApiRequestBuilder { self } + /// Gets the request as written, `{{names}}` and all; `resolve` fills them from the environment. pub fn on_send( mut self, handler: impl Fn(ApiRequest, &mut Window, &mut App) + 'static, diff --git a/src/devtools/response.rs b/src/devtools/response.rs index 7f5ac56..4d747dd 100644 --- a/src/devtools/response.rs +++ b/src/devtools/response.rs @@ -85,6 +85,11 @@ impl RenderOnce for HeadersTable { } } +/// A body as the viewer shows it: JSON indented, anything else as it came. +fn shown_body(body: &str) -> String { + reformat(body, true).unwrap_or_else(|| body.to_string()) +} + /// 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 { @@ -109,7 +114,7 @@ impl RenderOnce for ResponseViewer { }); let shown = tab.read(cx).clone(); let theme = cx.theme(); - let body = reformat(&response.body, true).unwrap_or_else(|| response.body.to_string()); + let body = shown_body(&response.body); let panel = match shown.as_ref() { "headers" => HeadersTable::new(response.headers.clone()).into_any_element(), _ => div() @@ -169,6 +174,7 @@ impl RenderOnce for ResponseViewer { #[cfg(test)] mod tests { + use super::shown_body; use super::{reason, status_tone}; use crate::data_display::Tone; @@ -182,4 +188,10 @@ mod tests { assert_eq!(status_tone(503), Tone::Danger); assert_eq!(reason(299), ""); } + + #[test] + fn a_json_body_shows_indented_and_other_text_as_it_came() { + assert_eq!(shown_body(r#"{"a":[1]}"#), "{\n \"a\": [\n 1\n ]\n}"); + assert_eq!(shown_body("plain words"), "plain words"); + } } diff --git a/src/devtools/socket.rs b/src/devtools/socket.rs index 63ce4f2..3cc6375 100644 --- a/src/devtools/socket.rs +++ b/src/devtools/socket.rs @@ -1,14 +1,15 @@ use std::rc::Rc; use gpui::{ - App, ElementId, Entity, IntoElement, ParentElement, RenderOnce, SharedString, Styled, Window, - div, prelude::*, + AnyElement, App, ElementId, Entity, IntoElement, ParentElement, RenderOnce, SharedString, + Styled, Window, div, prelude::*, }; use jiff::Timestamp; use super::json_editor::reformat; use crate::{ buttons::{Button, ButtonVariant}, + chat::MessageList, data_display::{Badge, Tone}, forms::{Input, TextInput}, primitives::{Icon, IconName}, @@ -94,6 +95,60 @@ impl WebSocketConsole { } } +/// One message in the log: which way it went, its text, JSON indented, and when; the time drops below in a narrow box. +fn message_row(message: &SocketMessage, now: Timestamp, ix: usize, cx: &App) -> AnyElement { + let theme = cx.theme(); + 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, now)), + ), + ) + .w_full() + .debug_selector(move || format!("socket-message-{ix}")) + .into_any_element() +} + impl RenderOnce for WebSocketConsole { fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { let theme = cx.theme(); @@ -103,54 +158,7 @@ impl RenderOnce for WebSocketConsole { 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 (messages, now) = (Rc::new(self.messages), self.now); let on_send = self .on_send .unwrap_or_else(|| panic!("websocket console {:?} has no on_send", self.id)); @@ -207,14 +215,17 @@ impl RenderOnce for WebSocketConsole { ) .child( div() - .id((self.id.clone(), "log")) - .max_h(theme.list_max_height()) - .overflow_y_scroll() + .debug_selector(|| "socket-log".into()) + .h(theme.list_max_height()) .px_3() .rounded(theme.radius(Radius::Md)) .border_1() .border_color(theme.colors.border) - .children(rows), + .child(MessageList::new( + (self.id.clone(), "log"), + messages.len(), + move |ix, _, cx| message_row(&messages[ix], now, ix, cx), + )), ) .child( div() diff --git a/src/devtools/tests/api.rs b/src/devtools/tests/api.rs index d47c893..1c69f9e 100644 --- a/src/devtools/tests/api.rs +++ b/src/devtools/tests/api.rs @@ -1,42 +1,59 @@ use gpui::{ AnyElement, App, AppContext as _, Entity, IntoElement, ParentElement, Styled, TestAppContext, - Window, div, px, + VisualTestContext, Window, div, px, }; use super::{Bench, bench, said, say, settle, tab, tap}; use crate::{ devtools::{ - ApiRequest, ApiRequestBuilder, Auth, CollectionTree, GraphQLExplorer, Method, Saved, - SocketState, WebSocketConsole, + ApiRequest, ApiRequestBuilder, Auth, CollectionTree, Direction, Environment, + EnvironmentSelector, GraphQLExplorer, Method, Saved, SchemaType, SocketMessage, + SocketState, TypeKind, 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"); +fn builder(bench: &Bench, _: &mut Window, _: &mut App, owner: Entity) -> AnyElement { + let mut request = ApiRequest::new("a", Method::Get, "https://{{host}}/items"); request.params = vec![("q".into(), "red shoes".into())]; request.auth = Auth::Basic { user: "ada".into(), password: "pw".into(), }; + let mut sending = false; + match bench.choice { + 1 => { + request = ApiRequest::new("b", Method::Post, "https://b.example.com/orders"); + request.auth = Auth::Bearer("t0k".into()); + } + 2 => request = ApiRequest::new("c", Method::Get, ""), + 3 => sending = true, + _ => {} + } ApiRequestBuilder::new("builder", request) .variables([("host", "api.example.com")]) + .sending(sending) .on_send(move |request, _, cx| { - say( - &owner, - format!( - "{} {} {:?}", - request.method.name(), - request.address(), - request.auth - ), - cx, - ) + let words = format!( + "{} {} {:?}", + request.method.name(), + request.address(), + request.auth + ); + say(&owner, words, cx) }) .into_any_element() } +fn choose(host: &Entity, choice: usize, cx: &mut VisualTestContext) { + host.update(cx, |bench, cx| { + bench.choice = choice; + cx.notify(); + }); + settle(cx); +} + /// Stops: the method, the address, then Send. #[gpui::test] fn send_hands_on_the_request_with_its_pairs(cx: &mut TestAppContext) { @@ -49,6 +66,32 @@ fn send_hands_on_the_request_with_its_pairs(cx: &mut TestAppContext) { ); } +/// Stops: the method, the address, then Send. +#[gpui::test] +fn a_new_request_refills_the_builder_with_its_sign_in(cx: &mut TestAppContext) { + let (host, cx) = bench(builder, cx); + choose(&host, 1, cx); + tab(3, cx); + tap("space", cx); + assert_eq!( + said(&host, cx), + [r#"POST https://b.example.com/orders Bearer("t0k")"#] + ); +} + +/// Stops: the method and the address; Send rests, so a third Tab wraps to the method. +#[gpui::test] +fn send_rests_without_an_address_and_while_sending(cx: &mut TestAppContext) { + let (host, cx) = bench(builder, cx); + for choice in [2, 3] { + choose(&host, choice, cx); + tab(3, cx); + tap("space", cx); + tap("escape", cx); + } + assert!(said(&host, cx).is_empty()); +} + 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"); @@ -165,3 +208,109 @@ fn run_waits_for_variables_that_read(cx: &mut TestAppContext) { tap("space", cx); assert_eq!(said(&host, cx), [r#"{ a } {"id": 1}"#]); } + +fn environments(bench: &Bench, _: &mut Window, _: &mut App, _: Entity) -> AnyElement { + let env = |key: &str, token: &str| Environment { + key: key.to_string().into(), + name: key.to_string().into(), + variables: vec![("token".into(), token.to_string().into())], + secrets: vec!["token".into()], + }; + let selected = if bench.choice == 0 { + "staging" + } else { + "production" + }; + EnvironmentSelector::new( + "environments", + [ + env("staging", "sk_test_1111"), + env("production", "sk_live_9999"), + ], + selected, + ) + .on_change(|_, _, _| {}) + .into_any_element() +} + +/// Stops: the Select, then the token's eye. +#[gpui::test] +fn an_eye_opens_one_environments_secret_only(cx: &mut TestAppContext) { + let (host, cx) = bench(environments, cx); + tab(2, cx); + tap("space", cx); + assert!(cx.debug_bounds("environment-staging-token-shown").is_some()); + choose(&host, 1, cx); + assert!( + cx.debug_bounds("environment-production-token-masked") + .is_some() + ); + assert!( + cx.debug_bounds("environment-production-token-shown") + .is_none(), + "another environment's secret stays masked" + ); +} + +fn schema(_: &Bench, window: &mut Window, cx: &mut App, owner: Entity) -> AnyElement { + let query = window.use_keyed_state("query", cx, |window, cx| { + CodeEditor::new("{ a }", window, cx) + }); + let variables = window.use_keyed_state("variables", cx, |window, cx| { + CodeEditor::new("", window, cx) + }); + let search = window.use_keyed_state("search", cx, TextInput::new); + let order = SchemaType { + name: "Order".into(), + kind: TypeKind::Object, + fields: vec![("id".into(), "ID!".into())], + }; + GraphQLExplorer::new("graph", [order], &query, &variables, &search) + .on_run(|_, _, _, _| {}) + .on_pick(move |path, _, cx| say(&owner, path.to_string(), cx)) + .into_any_element() +} + +/// Stops: the schema first, its cursor on Order. +#[gpui::test] +fn enter_on_a_field_hands_its_path(cx: &mut TestAppContext) { + let (host, cx) = bench(schema, cx); + tab(1, cx); + for key in ["right", "down", "enter"] { + tap(key, cx); + } + assert_eq!(said(&host, cx), ["Order.id"]); +} + +fn log(_: &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 messages = (0..12).map(|ix| SocketMessage { + direction: Direction::Received, + text: format!("message {ix}").into(), + at: now, + }); + WebSocketConsole::new( + "socket", + "wss://example.com", + SocketState::Open, + messages, + &draft, + now, + ) + .on_send(move |text, _, cx| say(&owner, format!("send {text}"), cx)) + .into_any_element() +} + +#[gpui::test] +fn the_log_holds_at_its_newest_message(cx: &mut TestAppContext) { + let (_, cx) = bench(log, cx); + let newest = cx + .debug_bounds("socket-message-11") + .expect("the newest message is drawn"); + let log = cx.debug_bounds("socket-log").expect("the log"); + assert!( + newest.bottom() <= log.bottom() && newest.top() >= log.top(), + "the newest message lies inside the log: {newest:?} in {log:?}" + ); +}