diff --git a/Cargo.lock b/Cargo.lock index ca64fb2..e7ffba6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1340,6 +1340,12 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f" +[[package]] +name = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + [[package]] name = "data-url" version = "0.3.2" @@ -5494,9 +5500,11 @@ dependencies = [ "chrono", "dirs 6.0.0", "env_logger", + "futures-util", "gpui", "gpui-component", "gpui_platform", + "http", "log", "lsp-types", "mime_guess", @@ -5506,6 +5514,7 @@ dependencies = [ "serde", "serde_json", "tokio", + "tokio-tungstenite", "tree-sitter", "tree-sitter-css", "tree-sitter-html", @@ -5518,6 +5527,17 @@ dependencies = [ "uuid", ] +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + [[package]] name = "sha1_smol" version = "1.0.1" @@ -6338,9 +6358,21 @@ dependencies = [ "mio", "pin-project-lite", "socket2", + "tokio-macros", "windows-sys 0.61.2", ] +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.2", +] + [[package]] name = "tokio-rustls" version = "0.26.4" @@ -6351,6 +6383,22 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-tungstenite" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a9daff607c6d2bf6c16fd681ccb7eecc83e4e2cdc1ca067ffaadfca5de7f084" +dependencies = [ + "futures-util", + "log", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tungstenite", + "webpki-roots 0.26.11", +] + [[package]] name = "tokio-util" version = "0.7.18" @@ -6665,6 +6713,25 @@ dependencies = [ "core_maths", ] +[[package]] +name = "tungstenite" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4793cb5e56680ecbb1d843515b23b6de9a75eb04b66643e256a396d43be33c13" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.9.5", + "rustls", + "rustls-pki-types", + "sha1", + "thiserror 2.0.19", + "utf-8", +] + [[package]] name = "typeid" version = "1.0.3" @@ -7094,6 +7161,24 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.9", +] + +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "weezl" version = "0.1.12" diff --git a/Cargo.toml b/Cargo.toml index 4d0f5c5..944971a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,7 +27,7 @@ anyhow = "1.0.104" chrono = { version = "0.4.45", features = ["serde"] } uuid = { version = "1.24.0", features = ["v4"] } serde = { version = "1.0.229", features = ["derive", "rc"] } -tokio = { version = "1.53.1", features = ["rt-multi-thread", "sync", "fs", "time"] } +tokio = { version = "1.53.1", features = ["rt-multi-thread", "sync", "fs", "time", "macros", "net", "io-util"] } rust-embed = "8.12.0" urlencoding = "2.1.3" mime_guess = "2.0.5" @@ -50,3 +50,6 @@ rodio = { version = "0.22.2", default-features = false, features = [ "vorbis", "wav", ] } +tokio-tungstenite = { version = "0.26", features = ["rustls-tls-webpki-roots"] } +futures-util = { version = "0.3", features = ["sink"] } +http = "1" diff --git a/src/components/auth_editor.rs b/src/components/auth_editor.rs index 387a625..fe613ca 100644 --- a/src/components/auth_editor.rs +++ b/src/components/auth_editor.rs @@ -380,6 +380,50 @@ impl AuthEditor { api_key_in_header: self.api_key_location == ApiKeyLocation::Header, } } + + /// Load a config into the editor (for restored WebSocket / saved requests). + pub fn load_config( + &mut self, + config: &AuthConfig, + window: &mut Window, + cx: &mut Context, + ) { + self.auth_type = config.auth_type; + self.auth_type_select.update(cx, |state, cx| { + state.set_selected_value(&config.auth_type, window, cx); + }); + self.api_key_location = if config.api_key_in_header { + ApiKeyLocation::Header + } else { + ApiKeyLocation::QueryParam + }; + self.api_key_location_select.update(cx, |state, cx| { + state.set_selected_value(&self.api_key_location, window, cx); + }); + + self.ensure_inputs(window, cx); + if let Some(input) = &self.username_input { + let value = config.username.clone(); + input.update(cx, |state, cx| state.set_value(value, window, cx)); + } + if let Some(input) = &self.password_input { + let value = config.password.clone(); + input.update(cx, |state, cx| state.set_value(value, window, cx)); + } + if let Some(input) = &self.token_input { + let value = config.token.clone(); + input.update(cx, |state, cx| state.set_value(value, window, cx)); + } + if let Some(input) = &self.api_key_name_input { + let value = config.api_key_name.clone(); + input.update(cx, |state, cx| state.set_value(value, window, cx)); + } + if let Some(input) = &self.api_key_value_input { + let value = config.api_key_value.clone(); + input.update(cx, |state, cx| state.set_value(value, window, cx)); + } + cx.notify(); + } } impl Focusable for AuthEditor { diff --git a/src/components/collections_panel.rs b/src/components/collections_panel.rs index d563947..d0bb64d 100644 --- a/src/components/collections_panel.rs +++ b/src/components/collections_panel.rs @@ -61,7 +61,9 @@ enum CollectionTreeRow { collection_id: Uuid, id: Uuid, name: String, - method: HttpMethod, + /// HTTP method label (GET/POST/...) or "WS" for WebSocket. + method_label: String, + is_websocket: bool, }, } @@ -230,13 +232,20 @@ impl CollectionsPanel { CollectionNode::Request(request) => { let id = Self::request_tree_id(collection_id, request.id); let name = request.display_name(); + let (method_label, is_websocket) = match &request.request { + crate::entities::SavedRequestData::Http(http) => { + (http.method.as_str().to_string(), false) + } + crate::entities::SavedRequestData::WebSocket(_) => ("WS".to_string(), true), + }; rows.insert( id.clone(), CollectionTreeRow::Request { collection_id, id: request.id, name: name.clone(), - method: request.request.method, + method_label, + is_websocket, }, ); TreeItem::new(id, name) @@ -552,7 +561,10 @@ impl CollectionsPanel { .into_any_element() } - fn render_method_badge(method_str: &'static str, method_color: gpui::Hsla) -> AnyElement { + fn render_method_badge( + method_str: impl Into, + method_color: gpui::Hsla, + ) -> AnyElement { div() .min_w(px(36.0)) .flex_shrink_0() @@ -564,7 +576,7 @@ impl CollectionsPanel { .font_weight(gpui::FontWeight::BOLD) .text_size(px(9.0)) .text_center() - .child(method_str) + .child(method_str.into()) .into_any_element() } @@ -849,20 +861,37 @@ impl CollectionsPanel { collection_id: Uuid, request_id: Uuid, name: &str, - method: HttpMethod, + method_label: &str, + is_websocket: bool, depth: usize, theme: &gpui_component::theme::ThemeColor, callbacks: &PanelCallbacks, ) -> AnyElement { let group_id = SharedString::from(format!("request-row-{collection_id}-{request_id}")); let action_id = SharedString::from(format!("request-actions-{collection_id}-{request_id}")); - let m_color = method_color(&method, cx); + let m_color = if is_websocket { + // Teal accent for WebSocket badges. + gpui::hsla(168.0 / 360.0, 0.67, 0.47, 1.0) + } else { + // Map label back to HttpMethod for color when possible. + let method = match method_label { + "GET" => HttpMethod::Get, + "POST" => HttpMethod::Post, + "PUT" => HttpMethod::Put, + "DELETE" => HttpMethod::Delete, + "PATCH" => HttpMethod::Patch, + "HEAD" => HttpMethod::Head, + "OPTIONS" => HttpMethod::Options, + _ => HttpMethod::Get, + }; + method_color(&method, cx) + }; let callbacks_for_load = callbacks.clone(); let callbacks_for_menu = callbacks.clone(); let callbacks_for_action = callbacks.clone(); let context_name = name.to_string(); let action_name = name.to_string(); - let method_str = method.as_str(); + let method_str: SharedString = method_label.to_string().into(); let row_id = SharedString::from(format!("request-row-{collection_id}-{request_id}")); let row = div() @@ -970,14 +999,16 @@ impl CollectionsPanel { collection_id, id, name, - method, + method_label, + is_websocket, } => Self::render_request_row( window, cx, *collection_id, *id, name, - *method, + method_label, + *is_websocket, depth, theme, callbacks, diff --git a/src/components/header_editor.rs b/src/components/header_editor.rs index 6ac7917..2951795 100644 --- a/src/components/header_editor.rs +++ b/src/components/header_editor.rs @@ -38,7 +38,9 @@ struct DraggedHeader { /// Header editor pub struct HeaderEditor { - request: Entity, + /// Optional HTTP request entity for two-way sync. Standalone mode uses + /// only local rows (e.g. WebSocket configuration panels). + request: Option>, header_rows: Vec, pending_initial_headers: Option>, bulk_mode: bool, @@ -60,7 +62,7 @@ impl HeaderEditor { .detach(); let mut editor = Self { - request, + request: Some(request), header_rows: Vec::new(), pending_initial_headers: None, bulk_mode: false, @@ -74,6 +76,33 @@ impl HeaderEditor { editor } + /// Create a header editor not bound to an HTTP request entity. + pub fn new_standalone( + initial_headers: Vec
, + completion_engine: Option, + cx: &mut Context, + ) -> Self { + let pending = if initial_headers.is_empty() { + None + } else { + Some( + initial_headers + .into_iter() + .map(|h| (h.key, h.value, h.enabled)) + .collect(), + ) + }; + Self { + request: None, + header_rows: Vec::new(), + pending_initial_headers: pending, + bulk_mode: false, + bulk_editor: None, + focus_handle: cx.focus_handle(), + completion_engine, + } + } + fn ensure_bulk_editor(&mut self, window: &mut Window, cx: &mut Context) { if self.bulk_editor.is_none() { let completion_engine = self.completion_engine.clone(); @@ -199,8 +228,11 @@ impl HeaderEditor { return; } - let headers: Vec<(String, String, bool)> = self - .request + let Some(request) = self.request.as_ref() else { + return; + }; + + let headers: Vec<(String, String, bool)> = request .read(cx) .headers() .iter() @@ -327,12 +359,14 @@ impl HeaderEditor { enabled: true, }); - // Also add to request entity - let key_for_req = key_owned.clone(); - let value_for_req = value_owned.clone(); - self.request.update(cx, |req, cx| { - req.add_header(Header::new(&key_for_req, &value_for_req), cx); - }); + // Also add to request entity when bound to HTTP request. + if let Some(request) = self.request.as_ref() { + let key_for_req = key_owned.clone(); + let value_for_req = value_owned.clone(); + request.update(cx, |req, cx| { + req.add_header(Header::new(&key_for_req, &value_for_req), cx); + }); + } cx.notify(); } @@ -381,10 +415,12 @@ impl HeaderEditor { if index < self.header_rows.len() { self.header_rows.remove(index); - // Also remove from request entity - self.request.update(cx, |req, cx| { - req.remove_header(index, cx); - }); + // Also remove from request entity when bound to HTTP request. + if let Some(request) = self.request.as_ref() { + request.update(cx, |req, cx| { + req.remove_header(index, cx); + }); + } cx.notify(); } @@ -394,10 +430,12 @@ impl HeaderEditor { pub fn clear_all_headers(&mut self, cx: &mut Context) { self.header_rows.clear(); - // Also clear from request entity - self.request.update(cx, |req, cx| { - req.clear_headers(cx); - }); + // Also clear from request entity when bound to HTTP request. + if let Some(request) = self.request.as_ref() { + request.update(cx, |req, cx| { + req.clear_headers(cx); + }); + } cx.notify(); } @@ -438,6 +476,22 @@ impl HeaderEditor { .unwrap_or_default(); } + // A standalone editor may not have been rendered yet. Preserve its + // seeded values when a request is connected or saved from another tab. + if self.header_rows.is_empty() + && let Some(headers) = self.pending_initial_headers.as_ref() + { + return headers + .iter() + .filter(|(key, _, _)| !key.is_empty()) + .map(|(key, value, enabled)| Header { + key: key.clone(), + value: value.clone(), + enabled: *enabled, + }) + .collect(); + } + self.header_rows .iter() .map(|row| Header { diff --git a/src/components/history_panel.rs b/src/components/history_panel.rs index 836479f..adc61b7 100644 --- a/src/components/history_panel.rs +++ b/src/components/history_panel.rs @@ -271,10 +271,15 @@ impl HistoryPanel { on_delete: Option>, on_star: Option>, ) -> AnyElement { - let method_str = entry.method.as_str().to_string(); + let method_str = if entry.is_websocket { + "WS".to_string() + } else { + entry.method.as_str().to_string() + }; let is_starred = entry.starred; let full_timestamp = entry.full_timestamp.clone(); let url_display = entry.url_display.clone(); + let websocket_message_count = entry.websocket_message_count; let entry_id = entry.id; let star_icon = if is_starred { @@ -337,6 +342,15 @@ impl HistoryPanel { .child(url_display), ), ) + .when(websocket_message_count > 0, |element| { + element.child( + div() + .flex_shrink_0() + .text_size(px(9.0)) + .text_color(theme.muted_foreground) + .child(format!("{} msgs", websocket_message_count)), + ) + }) .child( div() .absolute() @@ -485,7 +499,11 @@ impl RenderOnce for HistoryPanel { row_index, entry, &list_theme, - method_color(&entry.method, cx), + if entry.is_websocket { + list_theme.primary + } else { + method_color(&entry.method, cx) + }, on_load.clone(), on_delete.clone(), on_star.clone(), diff --git a/src/components/mod.rs b/src/components/mod.rs index 54ad95c..38f2eec 100644 --- a/src/components/mod.rs +++ b/src/components/mod.rs @@ -18,6 +18,9 @@ pub mod protocol_selector; pub mod status_badge; pub mod tab_bar; pub mod url_bar; +pub mod websocket_composer; +pub mod websocket_message_list; +pub mod websocket_status; pub use app_sidebar::*; pub use auth_editor::*; @@ -37,3 +40,6 @@ pub use protocol_selector::*; pub use status_badge::*; pub use tab_bar::*; pub use url_bar::*; +pub use websocket_composer::*; +pub use websocket_message_list::*; +pub use websocket_status::*; diff --git a/src/components/panel_tab.rs b/src/components/panel_tab.rs index 9fe940c..93971ba 100644 --- a/src/components/panel_tab.rs +++ b/src/components/panel_tab.rs @@ -105,6 +105,7 @@ impl PanelTabBar { impl RenderOnce for PanelTabBar { fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { let theme = cx.theme(); + let align_end = self.align_end; div() .id("panel-tab-bar") @@ -119,13 +120,16 @@ impl RenderOnce for PanelTabBar { .border_b_1() .border_color(theme.border) }) - .when(self.align_end, |style| style.justify_end()) - .overflow_x_scroll() .child( div() + .id("panel-tabs-scroll") .flex() .items_center() + .flex_1() + .min_w_0() .gap(px(2.0)) + .overflow_x_scroll() + .when(align_end, |style| style.justify_end()) .children(self.children), ) } diff --git a/src/components/params_editor.rs b/src/components/params_editor.rs index dc36535..d40328f 100644 --- a/src/components/params_editor.rs +++ b/src/components/params_editor.rs @@ -167,6 +167,25 @@ impl ParamsEditor { cx.notify(); } + /// Replace all params from a simple list (e.g. loading a saved WebSocket request). + pub fn set_params( + &mut self, + params: &[(String, String, bool)], + window: &mut Window, + cx: &mut Context, + ) { + let entries = params + .iter() + .map(|(key, value, enabled)| BulkKvEntry { + key: key.clone(), + value: value.clone(), + enabled: *enabled, + }) + .collect(); + self.replace_from_bulk_entries(entries, window, cx); + cx.notify(); + } + /// Add a new empty param row pub fn add_param(&mut self, window: &mut Window, cx: &mut Context) { let completion_engine = self.completion_engine.clone(); diff --git a/src/components/protocol_selector.rs b/src/components/protocol_selector.rs index e4ff278..964f4fd 100644 --- a/src/components/protocol_selector.rs +++ b/src/components/protocol_selector.rs @@ -1,6 +1,7 @@ use gpui::prelude::*; use gpui::{App, IntoElement, Styled, Window, div, px}; use gpui_component::ActiveTheme; +use std::rc::Rc; #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum ProtocolType { @@ -22,18 +23,32 @@ impl ProtocolType { } fn is_available(self) -> bool { - matches!(self, Self::Rest) + matches!(self, Self::Rest | Self::WebSocket) } } +pub type OnProtocolChange = Rc; + #[derive(IntoElement)] pub struct ProtocolSelector { selected: ProtocolType, + on_change: Option, } impl ProtocolSelector { pub fn new(selected: ProtocolType) -> Self { - Self { selected } + Self { + selected, + on_change: None, + } + } + + pub fn on_change( + mut self, + callback: impl Fn(ProtocolType, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_change = Some(Rc::new(callback)); + self } } @@ -46,6 +61,8 @@ impl RenderOnce for ProtocolSelector { ProtocolType::GraphQL, ProtocolType::Sse, ]; + let on_change = self.on_change; + let selected = self.selected; div() .flex() @@ -54,11 +71,13 @@ impl RenderOnce for ProtocolSelector { .p(px(2.0)) .bg(theme.muted) .rounded(px(6.0)) - .children(protocols.into_iter().map(|protocol| { - let is_selected = protocol == self.selected; + .children(protocols.into_iter().map(move |protocol| { + let is_selected = protocol == selected; let is_available = protocol.is_available(); + let on_change = on_change.clone(); div() + .id(protocol.label()) .relative() .flex() .items_center() @@ -88,6 +107,13 @@ impl RenderOnce for ProtocolSelector { } else { gpui::FontWeight::NORMAL }) + .when(is_available, |el| { + el.on_click(move |_, window, cx| { + if let Some(ref callback) = on_change { + callback(protocol, window, cx); + } + }) + }) .child(protocol.label()) .when(!is_available, |style| { style.child( diff --git a/src/components/websocket_composer.rs b/src/components/websocket_composer.rs new file mode 100644 index 0000000..9e1410c --- /dev/null +++ b/src/components/websocket_composer.rs @@ -0,0 +1,560 @@ +//! Message composer for WebSocket send (text / JSON / binary). + +use bytes::Bytes; +use gpui::prelude::FluentBuilder; +use gpui::prelude::*; +use gpui::{ + App, Context, Entity, EventEmitter, FocusHandle, Focusable, IntoElement, PathPromptOptions, + Render, SharedString, Styled, Window, div, hsla, px, +}; +use gpui_component::ActiveTheme; +use gpui_component::Disableable; +use gpui_component::Icon; +use gpui_component::Sizable; +use gpui_component::WindowExt; +use gpui_component::button::{Button, ButtonCustomVariant, ButtonVariants}; +use gpui_component::input::{Input, InputEvent, InputState}; +use gpui_component::notification::NotificationType; + +use crate::completion::{ + CompletionContext, CompletionEngine, CompletionInput, configure_completion, +}; +use crate::components::{PanelTab, PanelTabBar}; +use crate::entities::compact_json; +use crate::icons::IconName; + +const MAX_BINARY_SEND_BYTES: u64 = 16 * 1024 * 1024; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum ComposerMode { + #[default] + Text, + Json, + Binary, +} + +impl ComposerMode { + pub fn hint(self) -> &'static str { + match self { + Self::Text => "Plain text frame", + Self::Json => "Validated & compacted on send", + Self::Binary => "Raw bytes · max 16 MB", + } + } +} + +pub struct WebSocketComposer { + mode: ComposerMode, + text_input: Option>, + binary_path: Option, + binary_bytes: Option, + can_send: bool, + focus_handle: FocusHandle, + completion_engine: Option, + pending_send: Option, +} + +#[derive(Debug, Clone)] +pub enum ComposerPayload { + Text(String), + Binary(Bytes), + Invalid(String), +} + +#[derive(Debug, Clone)] +pub enum WebSocketComposerEvent { + SendRequested, +} + +impl WebSocketComposer { + pub fn new(completion_engine: Option, cx: &mut Context) -> Self { + Self { + mode: ComposerMode::Text, + text_input: None, + binary_path: None, + binary_bytes: None, + can_send: false, + focus_handle: cx.focus_handle(), + completion_engine, + pending_send: None, + } + } + + pub fn set_can_send(&mut self, can_send: bool, cx: &mut Context) { + self.can_send = can_send; + cx.notify(); + } + + pub fn take_pending_send(&mut self) -> Option { + self.pending_send.take() + } + + pub fn format_json_content( + &mut self, + window: &mut Window, + cx: &mut Context, + ) -> Result<(), String> { + let Some(input) = self.text_input.clone() else { + return Err("No text editor".into()); + }; + let text = input.read(cx).text().to_string(); + let pretty = crate::entities::format_json(&text)?; + input.update(cx, |state, cx| state.set_value(pretty, window, cx)); + Ok(()) + } + + pub fn queue_send(&mut self, cx: &mut Context) { + if !self.can_send { + return; + } + match self.mode { + ComposerMode::Text | ComposerMode::Json => { + let text = self + .text_input + .as_ref() + .map(|i| i.read(cx).text().to_string()) + .unwrap_or_default(); + if text.is_empty() { + return; + } + if self.mode == ComposerMode::Json { + match compact_json(&text) { + Ok(compact) => self.pending_send = Some(ComposerPayload::Text(compact)), + Err(err) => self.pending_send = Some(ComposerPayload::Invalid(err)), + } + } else { + self.pending_send = Some(ComposerPayload::Text(text)); + } + } + ComposerMode::Binary => { + if let Some(bytes) = self.binary_bytes.clone() { + self.pending_send = Some(ComposerPayload::Binary(bytes)); + } + } + } + if self.pending_send.is_some() { + cx.emit(WebSocketComposerEvent::SendRequested); + } + cx.notify(); + } + + fn ensure_text_input(&mut self, window: &mut Window, cx: &mut Context) { + if self.text_input.is_none() { + self.text_input = Some(self.create_text_input(String::new(), window, cx)); + } + } + + fn set_mode(&mut self, mode: ComposerMode, window: &mut Window, cx: &mut Context) { + if self.mode == mode { + return; + } + self.mode = mode; + // Recreate editor for syntax mode changes. + if matches!(mode, ComposerMode::Text | ComposerMode::Json) { + let existing = self + .text_input + .as_ref() + .map(|i| i.read(cx).text().to_string()) + .unwrap_or_default(); + self.text_input = Some(self.create_text_input(existing, window, cx)); + } + cx.notify(); + } + + fn create_text_input( + &self, + value: String, + window: &mut Window, + cx: &mut Context, + ) -> Entity { + let completion_engine = self.completion_engine.clone(); + let syntax = if self.mode == ComposerMode::Json { + "json" + } else { + "text" + }; + let input = cx.new(|cx| { + configure_completion( + InputState::new(window, cx) + .code_editor(syntax) + .line_number(false) + .soft_wrap(true) + .default_value(&value) + .placeholder(if syntax == "json" { + r#"{"type":"ping"}"# + } else { + "Write a message…" + }), + completion_engine.as_ref(), + CompletionContext::Body, + ) + }); + cx.subscribe(&input, |_, _, event, cx| { + if matches!(event, InputEvent::Change) { + cx.notify(); + } + }) + .detach(); + input + } + + pub fn mark_send_accepted(&mut self, window: &mut Window, cx: &mut Context) { + match self.mode { + ComposerMode::Text | ComposerMode::Json => { + if let Some(input) = self.text_input.as_ref() { + input.update(cx, |state, cx| { + state.set_value(String::new(), window, cx); + }); + } + } + ComposerMode::Binary => { + self.binary_path = None; + self.binary_bytes = None; + } + } + cx.notify(); + } + + fn pick_binary_file(&mut self, window: &mut Window, cx: &mut Context) { + let options = PathPromptOptions { + files: true, + directories: false, + multiple: false, + prompt: Some("Select binary file to send".into()), + }; + let paths_receiver = cx.prompt_for_paths(options); + let this = cx.entity().clone(); + + cx.spawn_in(window, async move |_weak, cx| { + let Ok(Ok(Some(paths))) = paths_receiver.await else { + return; + }; + let Some(path) = paths.first().cloned() else { + return; + }; + let path_for_read = path.clone(); + let (tx, rx) = tokio::sync::oneshot::channel(); + crate::utils::shared_tokio_runtime().spawn_blocking(move || { + let result = std::fs::metadata(&path_for_read).and_then(|metadata| { + if metadata.len() > MAX_BINARY_SEND_BYTES { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "File is larger than the 16 MB message limit", + )); + } + std::fs::read(&path_for_read) + }); + let _ = tx.send(result); + }); + let result = rx.await.unwrap_or_else(|_| { + Err(std::io::Error::other("binary reader stopped unexpectedly")) + }); + + let _ = cx.update(|window, app| match result { + Ok(bytes) => { + this.update(app, |composer, cx| { + composer.binary_path = Some( + path.file_name() + .map(|s| s.to_string_lossy().into_owned()) + .unwrap_or_else(|| path.display().to_string()), + ); + composer.binary_bytes = Some(Bytes::from(bytes)); + cx.notify(); + }); + } + Err(err) => { + log::error!("Failed to read binary file: {err}"); + window.push_notification( + ( + NotificationType::Error, + SharedString::from(format!("Could not use file: {err}")), + ), + app, + ); + } + }); + }) + .detach(); + } +} + +impl Focusable for WebSocketComposer { + fn focus_handle(&self, _cx: &App) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl EventEmitter for WebSocketComposer {} + +impl Render for WebSocketComposer { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + self.ensure_text_input(window, cx); + let theme = cx.theme(); + let this = cx.entity().clone(); + let can_send = self.can_send; + let mode = self.mode; + let send_variant = ButtonCustomVariant::new(cx) + .color(hsla(168.0 / 360.0, 0.67, 0.47, 1.0)) + .foreground(theme.background) + .border(hsla(168.0 / 360.0, 0.67, 0.47, 1.0)) + .hover(hsla(168.0 / 360.0, 0.67, 0.44, 1.0)) + .active(hsla(168.0 / 360.0, 0.67, 0.41, 1.0)); + + let has_payload = match mode { + ComposerMode::Binary => self.binary_bytes.is_some(), + ComposerMode::Text | ComposerMode::Json => self + .text_input + .as_ref() + .is_some_and(|input| input.read(cx).text().len() > 0), + }; + let send_enabled = can_send && has_payload; + + let binary_selected = self.binary_path.is_some(); + let binary_label = if let Some(name) = &self.binary_path { + let size = self + .binary_bytes + .as_ref() + .map(|b| crate::entities::format_byte_size(b.len())) + .unwrap_or_default(); + format!("{name} · {size}") + } else { + "Drop a file or browse to attach raw bytes".to_string() + }; + + div() + .id("ws-composer") + .flex() + .flex_col() + .size_full() + .border_t_1() + .border_color(theme.border) + .bg(theme.background) + .child( + div() + .flex() + .flex_col() + .w_full() + .h_full() + .bg(theme.background) + .overflow_hidden() + .child( + PanelTabBar::new() + .child( + PanelTab::new("Text") + .active(mode == ComposerMode::Text) + .on_click({ + let this = this.clone(); + move |_, window, cx| { + this.update(cx, |composer, cx| { + composer.set_mode(ComposerMode::Text, window, cx); + }); + } + }), + ) + .child( + PanelTab::new("JSON") + .active(mode == ComposerMode::Json) + .on_click({ + let this = this.clone(); + move |_, window, cx| { + this.update(cx, |composer, cx| { + composer.set_mode(ComposerMode::Json, window, cx); + }); + } + }), + ) + .child( + PanelTab::new("Binary") + .active(mode == ComposerMode::Binary) + .on_click({ + let this = this.clone(); + move |_, window, cx| { + this.update(cx, |composer, cx| { + composer.set_mode(ComposerMode::Binary, window, cx); + }); + } + }), + ), + ) + .child(match mode { + ComposerMode::Text | ComposerMode::Json => div() + .w_full() + .flex_1() + .min_h_0() + .flex() + .flex_col() + .overflow_hidden() + .bg(theme.muted) + .when_some(self.text_input.as_ref(), |el, input| { + el.child(CompletionInput::new( + input, + Input::new(input).appearance(false).size_full().p_0(), + )) + }) + .into_any_element(), + ComposerMode::Binary => { + let this = this.clone(); + div() + .id("ws-binary-zone") + .w_full() + .flex_1() + .min_h_0() + .flex() + .items_center() + .justify_between() + .gap(px(10.0)) + .px(px(12.0)) + .bg(theme.muted) + .cursor_pointer() + .hover(|style| style.bg(theme.list_hover)) + .on_click(move |_, window, cx| { + this.update(cx, |composer, cx| { + composer.pick_binary_file(window, cx); + }); + }) + .child( + div() + .flex() + .items_center() + .gap(px(10.0)) + .min_w_0() + .child( + div() + .flex() + .items_center() + .justify_center() + .w(px(32.0)) + .h(px(32.0)) + .child( + Icon::new(if binary_selected { + IconName::CircleCheck + } else { + IconName::FileUp + }) + .size(px(15.0)) + .text_color(if binary_selected { + theme.primary + } else { + theme.muted_foreground + }), + ), + ) + .child( + div() + .flex() + .flex_col() + .gap(px(2.0)) + .min_w_0() + .child( + div() + .text_size(px(11.0)) + .font_weight(gpui::FontWeight::MEDIUM) + .overflow_hidden() + .whitespace_nowrap() + .child(binary_label), + ) + .child( + div() + .text_size(px(9.0)) + .text_color(theme.muted_foreground) + .child(if binary_selected { + "Click anywhere to replace" + } else { + "Choose a file up to 16 MB" + }), + ), + ), + ) + .child( + Button::new("ws-browse-binary") + .ghost() + .xsmall() + .label(if binary_selected { "Replace" } else { "Browse" }), + ) + .into_any_element() + } + }) + .child( + div() + .flex() + .items_center() + .justify_between() + .gap(px(10.0)) + .h(px(38.0)) + .flex_shrink_0() + .px(px(12.0)) + .border_t_1() + .border_color(theme.border) + .bg(theme.secondary) + .child( + div() + .overflow_hidden() + .whitespace_nowrap() + .text_size(px(9.0)) + .text_color(theme.muted_foreground) + .child(if can_send { + if has_payload { + mode.hint() + } else { + "Write a message to send" + } + } else { + "Connect to send" + }), + ) + .child( + div() + .flex() + .items_center() + .gap(px(6.0)) + .when(mode == ComposerMode::Json, |element| { + let this = this.clone(); + element.child( + Button::new("ws-format-json-inline") + .ghost() + .xsmall() + .icon(Icon::new(IconName::Sparkles).size(px(12.0))) + .tooltip("Format JSON") + .on_click(move |_, window, cx| { + let result = this.update(cx, |composer, cx| { + composer.format_json_content(window, cx) + }); + if let Err(err) = result { + window.push_notification( + ( + NotificationType::Warning, + SharedString::from(err), + ), + cx, + ); + } + }), + ) + }) + .child( + div() + .text_size(px(9.0)) + .text_color(theme.muted_foreground) + .child("⌘↵"), + ) + .child({ + let this = this.clone(); + let button = Button::new("ws-send") + .compact() + .label("Send") + .on_click(move |_, _window, cx| { + this.update(cx, |composer, cx| { + composer.queue_send(cx); + }); + }); + if send_enabled { + button.custom(send_variant) + } else { + button.ghost().disabled(true) + } + }), + ), + ), + ) + } +} diff --git a/src/components/websocket_message_list.rs b/src/components/websocket_message_list.rs new file mode 100644 index 0000000..d8992e9 --- /dev/null +++ b/src/components/websocket_message_list.rs @@ -0,0 +1,291 @@ +//! Virtualized, compact message timeline for WebSocket frames. + +use bytes::Bytes; +use gpui::prelude::*; +use gpui::{ + App, ClipboardItem, Hsla, IntoElement, PathPromptOptions, SharedString, Styled, + UniformListScrollHandle, Window, div, hsla, px, uniform_list, +}; +use gpui_component::ActiveTheme; +use gpui_component::Icon; +use gpui_component::Sizable; +use gpui_component::WindowExt; +use gpui_component::button::{Button, ButtonVariants}; +use gpui_component::notification::NotificationType; +use std::collections::VecDeque; +use std::sync::Arc; + +use crate::entities::{ + MessageDirection, WebSocketMessageEntry, WebSocketPayload, format_byte_size, +}; +use crate::icons::IconName; +use crate::utils::shared_tokio_runtime; + +const MESSAGE_ROW_HEIGHT: f32 = 54.0; + +#[derive(IntoElement)] +pub struct WebSocketMessageList { + messages: VecDeque>, + scroll_handle: UniformListScrollHandle, +} + +impl WebSocketMessageList { + pub fn new( + messages: VecDeque>, + scroll_handle: UniformListScrollHandle, + ) -> Self { + Self { + messages, + scroll_handle, + } + } +} + +fn direction_color(direction: MessageDirection) -> Hsla { + match direction { + MessageDirection::Sent => hsla(210.0 / 360.0, 0.75, 0.58, 1.0), + MessageDirection::Received => hsla(168.0 / 360.0, 0.67, 0.47, 1.0), + MessageDirection::System => hsla(40.0 / 360.0, 0.70, 0.55, 1.0), + } +} + +fn direction_glyph(direction: MessageDirection) -> &'static str { + match direction { + MessageDirection::Sent => "↑", + MessageDirection::Received => "↓", + MessageDirection::System => "•", + } +} + +impl RenderOnce for WebSocketMessageList { + fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { + let theme = cx.theme().clone(); + let messages = self.messages; + let message_count = messages.len(); + + div() + .id("ws-message-list") + .flex() + .flex_col() + .flex_1() + .w_full() + .min_h_0() + .overflow_hidden() + .bg(theme.background) + .when(message_count == 0, |el| { + el.child( + div() + .flex() + .flex_col() + .flex_1() + .items_center() + .justify_center() + .gap(px(6.0)) + .text_color(theme.muted_foreground) + .child( + div() + .text_size(px(12.0)) + .font_weight(gpui::FontWeight::MEDIUM) + .child("No messages yet"), + ) + .child( + div() + .text_size(px(10.0)) + .opacity(0.65) + .child("Connect, then send a text, JSON, or binary frame"), + ), + ) + }) + .when(message_count > 0, |el| { + let list_theme = theme.clone(); + el.child( + uniform_list( + "ws-message-rows", + message_count, + move |range, _window, _cx| { + range + .map(|index| { + render_message_row(messages[index].clone(), list_theme.clone()) + }) + .collect::>() + }, + ) + .w_full() + .flex_1() + .track_scroll(&self.scroll_handle), + ) + }) + } +} + +fn render_message_row( + entry: Arc, + theme: gpui_component::theme::Theme, +) -> impl IntoElement { + let dir_color = direction_color(entry.direction); + let preview = entry.payload.preview(); + let kind = entry.payload.kind_label(); + let time = entry.format_time(); + let size = format_byte_size(entry.size_bytes); + let is_system = entry.direction == MessageDirection::System; + let copy_text = entry.payload.as_text().map(ToString::to_string); + let binary_bytes = match &entry.payload { + WebSocketPayload::Binary(bytes) => Some(bytes.clone()), + _ => None, + }; + let seq = entry.sequence; + + div() + .id(SharedString::from(format!("ws-msg-{seq}"))) + .flex() + .w_full() + .items_center() + .gap(px(10.0)) + .h(px(MESSAGE_ROW_HEIGHT)) + .px(px(12.0)) + .border_b_1() + .border_color(theme.border.opacity(0.38)) + .when(is_system, |el| el.bg(theme.muted.opacity(0.18))) + .hover(|style| style.bg(theme.muted.opacity(0.28))) + .child( + div() + .flex() + .items_center() + .justify_center() + .w(px(24.0)) + .h(px(24.0)) + .flex_shrink_0() + .rounded_full() + .bg(dir_color.opacity(0.14)) + .text_color(dir_color) + .text_size(px(14.0)) + .font_weight(gpui::FontWeight::BOLD) + .child(direction_glyph(entry.direction)), + ) + .child( + div() + .flex() + .flex_col() + .flex_1() + .min_w_0() + .gap(px(3.0)) + .child( + div() + .w_full() + .overflow_hidden() + .whitespace_nowrap() + .font_family(theme.mono_font_family.clone()) + .text_size(px(11.0)) + .text_color(if is_system { + theme.muted_foreground + } else { + theme.foreground + }) + .child(preview), + ) + .child( + div() + .flex() + .items_center() + .gap(px(6.0)) + .text_size(px(9.0)) + .text_color(theme.muted_foreground) + .child(entry.direction.as_str()) + .child("·") + .child(kind) + .child("·") + .child(time), + ), + ) + .child( + div() + .flex() + .items_center() + .gap(px(2.0)) + .flex_shrink_0() + .child( + div() + .pr(px(4.0)) + .text_size(px(9.0)) + .text_color(theme.muted_foreground) + .child(size), + ) + .when_some(copy_text, |el, text| { + el.child( + Button::new(SharedString::from(format!("ws-copy-{seq}"))) + .ghost() + .xsmall() + .icon(Icon::new(IconName::Copy).size(px(12.0))) + .tooltip("Copy message") + .on_click(move |_, _window, cx| { + cx.write_to_clipboard(ClipboardItem::new_string(text.clone())); + }), + ) + }) + .when_some(binary_bytes, |el, bytes| { + el.child( + Button::new(SharedString::from(format!("ws-save-{seq}"))) + .ghost() + .xsmall() + .icon(Icon::new(IconName::FileDown).size(px(12.0))) + .tooltip("Save binary message") + .on_click(move |_, window, cx| { + save_binary(bytes.clone(), window, cx); + }), + ) + }), + ) +} + +fn save_binary(bytes: Bytes, window: &mut Window, cx: &mut App) { + let options = PathPromptOptions { + files: false, + directories: true, + multiple: false, + prompt: Some("Select folder to save binary payload".into()), + }; + let paths_receiver = cx.prompt_for_paths(options); + + window + .spawn(cx, async move |cx| { + let Ok(Ok(Some(paths))) = paths_receiver.await else { + return; + }; + let Some(dir_path) = paths.first() else { + return; + }; + let file_path = dir_path.join(format!( + "ws-payload-{}.bin", + chrono::Utc::now().format("%Y%m%d-%H%M%S") + )); + let path_for_write = file_path.clone(); + let (tx, rx) = tokio::sync::oneshot::channel(); + shared_tokio_runtime().spawn_blocking(move || { + let result = std::fs::write(&path_for_write, bytes.as_ref()); + let _ = tx.send(result); + }); + let write_result = rx.await.unwrap_or_else(|_| { + Err(std::io::Error::other("binary writer stopped unexpectedly")) + }); + + let _ = cx.update(|window, app| match write_result { + Ok(()) => { + window.push_notification( + ( + NotificationType::Success, + SharedString::from(format!("Saved {}", file_path.display())), + ), + app, + ); + } + Err(err) => { + log::error!("Failed to save binary payload: {err}"); + window.push_notification( + (NotificationType::Error, "Failed to save binary payload"), + app, + ); + } + }); + }) + .detach(); +} diff --git a/src/components/websocket_status.rs b/src/components/websocket_status.rs new file mode 100644 index 0000000..89db4bf --- /dev/null +++ b/src/components/websocket_status.rs @@ -0,0 +1,129 @@ +//! Lightweight connection summary for the WebSocket timeline toolbar. + +use gpui::prelude::*; +use gpui::{App, Hsla, IntoElement, Styled, Window, div, hsla, px}; +use gpui_component::ActiveTheme; + +use crate::entities::{WebSocketConnectionState, WebSocketSessionState, format_byte_size}; + +#[derive(IntoElement)] +pub struct WebSocketStatusBar { + state: WebSocketConnectionState, + handshake_summary: Option, + subprotocol: Option, + sent_messages: u64, + received_messages: u64, + sent_bytes: u64, + received_bytes: u64, +} + +impl WebSocketStatusBar { + pub fn new(session: &WebSocketSessionState) -> Self { + Self { + state: session.connection_state, + handshake_summary: session.handshake.as_ref().map(|meta| { + let status = meta + .status_code + .map_or_else(|| "HTTP".to_string(), |status| format!("HTTP {status}")); + format!( + "{status} · {} ms · {} headers", + meta.duration_ms, + meta.response_headers.len() + ) + }), + subprotocol: session + .handshake + .as_ref() + .and_then(|meta| meta.selected_subprotocol.clone()), + sent_messages: session.sent_messages, + received_messages: session.received_messages, + sent_bytes: session.sent_bytes, + received_bytes: session.received_bytes, + } + } +} + +fn state_color(state: WebSocketConnectionState) -> Hsla { + match state { + WebSocketConnectionState::Disconnected => hsla(0.0, 0.0, 0.55, 1.0), + WebSocketConnectionState::Connecting | WebSocketConnectionState::Closing => { + hsla(40.0 / 360.0, 0.90, 0.55, 1.0) + } + WebSocketConnectionState::Connected => hsla(168.0 / 360.0, 0.67, 0.47, 1.0), + } +} + +impl RenderOnce for WebSocketStatusBar { + fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { + let theme = cx.theme(); + let color = state_color(self.state); + + div() + .flex() + .items_center() + .gap(px(8.0)) + .min_w_0() + .overflow_hidden() + .child( + div() + .w(px(7.0)) + .h(px(7.0)) + .flex_shrink_0() + .rounded_full() + .bg(color), + ) + .child( + div() + .text_size(px(10.0)) + .font_weight(gpui::FontWeight::SEMIBOLD) + .text_color(color) + .child(self.state.as_str()), + ) + .when_some(self.handshake_summary, |el, summary| { + el.child( + div() + .overflow_hidden() + .whitespace_nowrap() + .text_size(px(9.0)) + .text_color(theme.muted_foreground) + .child(summary), + ) + }) + .when_some(self.subprotocol, |el, protocol| { + el.child( + div() + .max_w(px(160.0)) + .overflow_hidden() + .whitespace_nowrap() + .px(px(6.0)) + .py(px(2.0)) + .rounded(px(3.0)) + .bg(theme.muted) + .text_size(px(9.0)) + .text_color(theme.muted_foreground) + .child(protocol), + ) + }) + .child(div().w(px(1.0)).h(px(12.0)).bg(theme.border)) + .child( + div() + .text_size(px(9.0)) + .text_color(theme.muted_foreground) + .child(format!( + "↑ {} / {}", + self.sent_messages, + format_byte_size(self.sent_bytes as usize) + )), + ) + .child( + div() + .text_size(px(9.0)) + .text_color(theme.muted_foreground) + .child(format!( + "↓ {} / {}", + self.received_messages, + format_byte_size(self.received_bytes as usize) + )), + ) + } +} diff --git a/src/entities/collections.rs b/src/entities/collections.rs index 0f2444a..f18e5b3 100644 --- a/src/entities/collections.rs +++ b/src/entities/collections.rs @@ -9,9 +9,11 @@ use uuid::Uuid; use crate::importers::{ImportedCollection, ImportedNode}; use crate::utils::{DebouncedJsonWriter, shared_tokio_runtime}; -use super::{RequestData, SidebarLoadState, default_workspace_id}; +use super::{ + RequestData, SavedRequestData, SidebarLoadState, WebSocketRequestData, default_workspace_id, +}; -const COLLECTIONS_STORAGE_VERSION: u32 = 2; +const COLLECTIONS_STORAGE_VERSION: u32 = 3; const SAVE_DEBOUNCE: Duration = Duration::from_secs(1); fn default_expanded() -> bool { @@ -21,36 +23,78 @@ fn default_expanded() -> bool { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CollectionRequestNode { pub id: Uuid, - pub request: RequestData, + #[serde(deserialize_with = "deserialize_saved_request")] + pub request: SavedRequestData, } impl CollectionRequestNode { pub fn new(request: RequestData) -> Self { + Self { + id: Uuid::new_v4(), + request: SavedRequestData::Http(request), + } + } + + pub fn new_saved(request: SavedRequestData) -> Self { Self { id: Uuid::new_v4(), request, } } + #[allow(dead_code)] + pub fn new_websocket(request: WebSocketRequestData) -> Self { + Self { + id: Uuid::new_v4(), + request: SavedRequestData::WebSocket(request), + } + } + fn from_legacy(id: Uuid, request: RequestData) -> Self { - Self { id, request } + Self { + id, + request: SavedRequestData::Http(request), + } } pub fn display_name(&self) -> String { - if !self.request.name.is_empty() && self.request.name != "New Request" { - self.request.name.clone() - } else if !self.request.url.is_empty() { - self.request - .url - .trim_start_matches("https://") - .trim_start_matches("http://") - .chars() - .take(40) - .collect() - } else { - "Untitled Request".to_string() + match &self.request { + SavedRequestData::Http(req) => { + if !req.name.is_empty() && req.name != "New Request" { + req.name.clone() + } else if !req.url.is_empty() { + req.url + .trim_start_matches("https://") + .trim_start_matches("http://") + .chars() + .take(40) + .collect() + } else { + "Untitled Request".to_string() + } + } + SavedRequestData::WebSocket(req) => req.display_name(), } } + + #[allow(dead_code)] + pub fn is_websocket(&self) -> bool { + self.request.is_websocket() + } +} + +/// Accept tagged SavedRequestData or legacy bare RequestData for migration. +fn deserialize_saved_request<'de, D>(deserializer: D) -> Result +where + D: serde::Deserializer<'de>, +{ + let value = serde_json::Value::deserialize(deserializer)?; + if value.get("request_kind").is_some() { + return serde_json::from_value(value).map_err(serde::de::Error::custom); + } + // Legacy HTTP RequestData (no request_kind tag). + let http: RequestData = serde_json::from_value(value).map_err(serde::de::Error::custom)?; + Ok(SavedRequestData::Http(http)) } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -125,14 +169,17 @@ impl CollectionNode { match self { Self::Folder(folder) => folder.name.to_lowercase().contains(query), Self::Request(request) => { - request.request.name.to_lowercase().contains(query) - || request.request.url.to_lowercase().contains(query) - || request - .request - .method - .as_str() - .to_lowercase() - .contains(query) + let name_match = request.request.name().to_lowercase().contains(query); + let url_match = request.request.url().to_lowercase().contains(query); + let method_match = match &request.request { + SavedRequestData::Http(http) => { + http.method.as_str().to_lowercase().contains(query) + } + SavedRequestData::WebSocket(_) => { + "websocket".contains(query) || "ws".contains(query) + } + }; + name_match || url_match || method_match } } } @@ -541,7 +588,7 @@ impl CollectionsEntity { match node { CollectionNode::Folder(folder) => folder.name = new_name.to_string(), - CollectionNode::Request(request) => request.request.name = new_name.to_string(), + CollectionNode::Request(request) => request.request.set_name(new_name.to_string()), } self.bump_revision(); @@ -620,7 +667,22 @@ impl CollectionsEntity { request: RequestData, cx: &mut Context, ) -> Option { - let node = CollectionNode::Request(CollectionRequestNode::new(request)); + self.add_saved_request_node( + collection_id, + parent_folder_id, + SavedRequestData::Http(request), + cx, + ) + } + + pub fn add_saved_request_node( + &mut self, + collection_id: Uuid, + parent_folder_id: Option, + request: SavedRequestData, + cx: &mut Context, + ) -> Option { + let node = CollectionNode::Request(CollectionRequestNode::new_saved(request)); let node_id = node.id(); let Some(collection) = self.collections.iter_mut().find(|c| c.id == collection_id) else { @@ -792,10 +854,15 @@ impl CollectionNode { fn deserialize_store( contents: &str, ) -> Result<(HashMap>, bool), serde_json::Error> { - if let Ok(store) = serde_json::from_str::(contents) - && store.version == COLLECTIONS_STORAGE_VERSION - { - return Ok((store.workspaces, false)); + if let Ok(store) = serde_json::from_str::(contents) { + if store.version == COLLECTIONS_STORAGE_VERSION { + return Ok((store.workspaces, false)); + } + // v2 and other prior versioned tree stores use the same node shape; + // SavedRequestData deserializer accepts legacy bare RequestData. + if store.version >= 2 && store.version < COLLECTIONS_STORAGE_VERSION { + return Ok((store.workspaces, true)); + } } if let Ok(store) = serde_json::from_str::(contents) { @@ -1032,7 +1099,89 @@ mod tests { let node = collections[0].nodes.first().expect("request node"); let request = node.request().expect("request"); assert_eq!(request.id, item_id); - assert_eq!(request.request.id, request_id); + let http = request.request.as_http().expect("http request"); + assert_eq!(http.id, request_id); + } + + #[test] + fn migrates_v2_http_requests_to_saved_request_data() { + let request = sample_request("Create User", "https://example.com/users"); + let collection = Collection { + id: Uuid::new_v4(), + name: "Workspace".to_string(), + expanded: true, + nodes: vec![CollectionNode::Request(CollectionRequestNode { + id: Uuid::new_v4(), + // Simulate v2 on-disk shape: bare RequestData JSON inside node + request: SavedRequestData::Http(request.clone()), + })], + }; + // Encode as v2 with bare request objects (no request_kind). + let bare = serde_json::json!({ + "version": 2, + "workspaces": { + (default_workspace_id().to_string()): [{ + "id": collection.id, + "name": collection.name, + "expanded": true, + "nodes": [{ + "kind": "request", + "id": collection.nodes[0].id(), + "request": { + "id": request.id, + "name": request.name, + "url": request.url, + "method": "Post", + "headers": [{"key":"Content-Type","value":"application/json","enabled":true}], + "body": {"Json": "{\"ok\":true}"} + } + }] + }] + } + }); + let encoded = bare.to_string(); + let (workspaces, migrated) = deserialize_store(&encoded).expect("decode v2"); + assert!(migrated); + let decoded = &workspaces[&default_workspace_id()]; + let node = decoded[0].nodes[0].request().unwrap(); + assert!(!node.request.is_websocket()); + assert_eq!(node.request.as_http().unwrap().url, request.url); + } + + #[test] + fn round_trips_websocket_saved_request() { + let ws = WebSocketRequestData { + id: Uuid::new_v4(), + name: "Chat WS".into(), + url: "ws://localhost:9001".into(), + params: vec![], + headers: vec![], + auth: Default::default(), + subprotocols: vec!["chat".into()], + }; + let collection = Collection { + id: Uuid::new_v4(), + name: "WS".into(), + expanded: true, + nodes: vec![CollectionNode::Request( + CollectionRequestNode::new_websocket(ws.clone()), + )], + }; + let store = CollectionsStore { + version: COLLECTIONS_STORAGE_VERSION, + workspaces: HashMap::from([(default_workspace_id(), vec![collection])]), + }; + let encoded = serde_json::to_string(&store).unwrap(); + let (workspaces, migrated) = deserialize_store(&encoded).unwrap(); + assert!(!migrated); + let node = workspaces[&default_workspace_id()][0].nodes[0] + .request() + .unwrap(); + assert!(node.is_websocket()); + assert_eq!( + node.request.as_websocket().unwrap().subprotocols, + vec!["chat"] + ); } #[test] @@ -1067,7 +1216,13 @@ mod tests { let folder = decoded[0].nodes[0].folder().expect("folder"); assert_eq!(folder.name, "Users"); assert_eq!( - folder.children[0].request().expect("request").request.url, + folder.children[0] + .request() + .expect("request") + .request + .as_http() + .expect("http") + .url, request.url ); } diff --git a/src/entities/environment.rs b/src/entities/environment.rs index 3ffdc6e..9648c34 100644 --- a/src/entities/environment.rs +++ b/src/entities/environment.rs @@ -783,6 +783,39 @@ impl EnvironmentsEntity { self.changed(EnvironmentEvent::Changed, cx); } + /// Resolve a single template string against the effective environment. + pub fn resolve_value( + &self, + collection_id: Option, + template: &str, + ) -> Result { + let values = self.effective_values(collection_id); + let mut resolver = Resolver::new(values); + let resolved = resolver.resolve(template); + resolver.finish()?; + Ok(resolved) + } + + /// Resolve headers only (keys and values). + pub fn resolve_headers( + &self, + collection_id: Option, + headers: &[Header], + ) -> Result, InterpolationError> { + let values = self.effective_values(collection_id); + let mut resolver = Resolver::new(values); + let headers = headers + .iter() + .map(|header| Header { + key: resolver.resolve(&header.key), + value: resolver.resolve(&header.value), + enabled: header.enabled, + }) + .collect(); + resolver.finish()?; + Ok(headers) + } + pub fn resolve_request( &self, collection_id: Option, diff --git a/src/entities/history.rs b/src/entities/history.rs index 24f411c..9db5108 100644 --- a/src/entities/history.rs +++ b/src/entities/history.rs @@ -9,7 +9,10 @@ use uuid::Uuid; use crate::utils::{DebouncedJsonWriter, shared_tokio_runtime}; -use super::{HttpMethod, RequestData, ResponseData, SidebarLoadState, default_workspace_id}; +use super::{ + HttpMethod, RequestBody, RequestData, ResponseData, SidebarLoadState, WebSocketHistoryResult, + WebSocketRequestData, default_workspace_id, +}; const HISTORY_STORAGE_VERSION: u32 = 2; const SAVE_DEBOUNCE: Duration = Duration::from_secs(1); @@ -28,6 +31,10 @@ pub struct HistoryEntry { pub timestamp: DateTime, #[serde(default)] pub starred: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub websocket_request: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub websocket_result: Option, } impl HistoryEntry { @@ -38,10 +45,36 @@ impl HistoryEntry { response, timestamp: Utc::now(), starred: false, + websocket_request: None, + websocket_result: None, + } + } + + pub fn new_websocket(request: WebSocketRequestData, result: WebSocketHistoryResult) -> Self { + let summary = RequestData { + id: request.id, + name: request.name.clone(), + url: request.url.clone(), + method: HttpMethod::Get, + headers: request.headers.clone(), + body: RequestBody::None, + is_sending: false, + }; + Self { + id: Uuid::new_v4(), + request: summary, + response: None, + timestamp: Utc::now(), + starred: false, + websocket_request: Some(request), + websocket_result: Some(result), } } pub fn display_name(&self) -> String { + if let Some(request) = &self.websocket_request { + return request.display_name(); + } if !self.request.name.is_empty() && self.request.name != "New Request" { self.request.name.clone() } else if !self.request.url.is_empty() { @@ -111,6 +144,8 @@ pub struct HistoryRowEntry { pub url_display: String, pub full_timestamp: String, pub starred: bool, + pub is_websocket: bool, + pub websocket_message_count: usize, } impl HistoryRowEntry { @@ -125,6 +160,11 @@ impl HistoryRowEntry { }, full_timestamp: entry.timestamp.format("%b %d, %Y at %H:%M:%S").to_string(), starred: entry.starred, + is_websocket: entry.websocket_request.is_some(), + websocket_message_count: entry + .websocket_result + .as_ref() + .map_or(0, |result| result.messages.len()), } } } @@ -161,7 +201,9 @@ impl HistoryRowsSnapshot { .method .as_str() .to_ascii_lowercase() - .contains(&query)) + .contains(&query) + || (entry.websocket_request.is_some() + && ("ws".contains(&query) || "websocket".contains(&query)))) }; match grouping { @@ -441,6 +483,88 @@ impl HistoryEntity { cx.notify(); } + pub fn add_websocket_entry( + &mut self, + request: WebSocketRequestData, + result: WebSocketHistoryResult, + cx: &mut Context, + ) -> Uuid { + let entry = HistoryEntry::new_websocket(request, result); + let id = entry.id; + + let entries = Arc::make_mut(&mut self.entries); + entries.insert(0, Arc::new(entry)); + if entries.len() > self.max_entries { + entries.pop(); + } + + self.save_to_file(); + cx.emit(HistoryEvent::EntryAdded(id)); + cx.notify(); + id + } + + pub fn append_websocket_message( + &mut self, + id: Uuid, + message: &super::WebSocketMessageEntry, + persist: bool, + cx: &mut Context, + ) -> bool { + let appended = { + let entries = Arc::make_mut(&mut self.entries); + let Some(entry) = entries.iter_mut().find(|entry| entry.id == id) else { + return false; + }; + let entry = Arc::make_mut(entry); + let Some(result) = entry.websocket_result.as_mut() else { + return false; + }; + result.append_message(message) + }; + if persist { + self.save_to_file(); + if appended { + cx.emit(HistoryEvent::EntryUpdated(id)); + cx.notify(); + } + } + appended + } + + pub fn clear_websocket_messages(&mut self, id: Uuid, cx: &mut Context) -> bool { + let cleared = { + let entries = Arc::make_mut(&mut self.entries); + let Some(entry) = entries.iter_mut().find(|entry| entry.id == id) else { + return false; + }; + let entry = Arc::make_mut(entry); + let Some(result) = entry.websocket_result.as_mut() else { + return false; + }; + if result.messages.is_empty() { + false + } else { + result.messages.clear(); + true + } + }; + if cleared { + self.save_to_file(); + cx.emit(HistoryEvent::EntryUpdated(id)); + cx.notify(); + } + cleared + } + + pub fn persist_websocket_entry(&mut self, id: Uuid, cx: &mut Context) { + if self.entries.iter().any(|entry| entry.id == id) { + self.save_to_file(); + cx.emit(HistoryEvent::EntryUpdated(id)); + cx.notify(); + } + } + pub fn remove_entry(&mut self, id: Uuid, cx: &mut Context) { let entries = Arc::make_mut(&mut self.entries); if let Some(pos) = entries.iter().position(|e| e.id == id) { @@ -513,7 +637,9 @@ impl HistoryEntity { fn extract_domain(url: &str) -> String { let url = url .trim_start_matches("https://") - .trim_start_matches("http://"); + .trim_start_matches("http://") + .trim_start_matches("wss://") + .trim_start_matches("ws://"); url.split('/').next().unwrap_or("Unknown").to_string() } @@ -626,6 +752,8 @@ mod tests { response: None, timestamp: Utc::now(), starred: false, + websocket_request: None, + websocket_result: None, } } @@ -799,9 +927,54 @@ mod tests { HistoryEntity::extract_domain("http://localhost:3000/v1/health"), "localhost:3000" ); + assert_eq!( + HistoryEntity::extract_domain("wss://echo.example.com/socket"), + "echo.example.com" + ); assert_eq!( HistoryEntity::extract_domain("example.com/path"), "example.com" ); } + + #[test] + fn websocket_entries_round_trip_and_render_as_websocket_rows() { + let request = WebSocketRequestData { + url: "wss://echo.example.com/socket".to_string(), + ..WebSocketRequestData::default() + }; + let entry = HistoryEntry::new_websocket( + request.clone(), + WebSocketHistoryResult { + connected: true, + duration_ms: Some(42), + status_code: Some(101), + selected_subprotocol: Some("chat".to_string()), + error: None, + messages: Vec::new(), + }, + ); + + assert_eq!(entry.display_name(), "echo.example.com/socket"); + assert!(HistoryRowEntry::from_entry(&entry).is_websocket); + + let encoded = serde_json::to_string(&entry).expect("serialize websocket history"); + let decoded: HistoryEntry = + serde_json::from_str(&encoded).expect("deserialize websocket history"); + assert_eq!(decoded.websocket_request.unwrap().url, request.url); + assert_eq!(decoded.websocket_result.unwrap().status_code, Some(101)); + } + + #[test] + fn legacy_http_entries_default_websocket_fields_to_none() { + let entry = sample_entry("Health", "https://example.com/health", HttpMethod::Get); + let mut json = serde_json::to_value(entry).expect("serialize history entry"); + let object = json.as_object_mut().expect("history entry object"); + object.remove("websocket_request"); + object.remove("websocket_result"); + + let decoded: HistoryEntry = serde_json::from_value(json).expect("decode legacy history"); + assert!(decoded.websocket_request.is_none()); + assert!(decoded.websocket_result.is_none()); + } } diff --git a/src/entities/mod.rs b/src/entities/mod.rs index d38a366..9621075 100644 --- a/src/entities/mod.rs +++ b/src/entities/mod.rs @@ -5,6 +5,7 @@ pub mod load_state; pub mod preferences; pub mod request; pub mod response; +pub mod websocket; pub mod workspace; pub use collections::*; @@ -14,4 +15,5 @@ pub use load_state::*; pub use preferences::*; pub use request::*; pub use response::*; +pub use websocket::*; pub use workspace::*; diff --git a/src/entities/websocket.rs b/src/entities/websocket.rs new file mode 100644 index 0000000..f20dc28 --- /dev/null +++ b/src/entities/websocket.rs @@ -0,0 +1,1053 @@ +//! WebSocket request configuration and pure session state. + +use bytes::Bytes; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use std::collections::{HashSet, VecDeque}; +use std::sync::Arc; +use uuid::Uuid; + +use super::{Header, RequestData}; + +/// Maximum messages retained in the session timeline. +pub const DEFAULT_MAX_MESSAGES: usize = 1_000; +/// Approximate total payload budget retained in the timeline (~10 MiB). +pub const DEFAULT_MAX_TIMELINE_BYTES: usize = 10 * 1024 * 1024; +/// Default max incoming frame size (16 MiB). +pub const DEFAULT_MAX_FRAME_BYTES: usize = 16 * 1024 * 1024; +/// Preview length for binary/text payloads in the UI. +pub const PREVIEW_MAX_CHARS: usize = 256; +/// Maximum frames retained for one persisted History entry. +pub const HISTORY_MAX_MESSAGES: usize = 100; +/// Maximum exact payload data retained for one persisted History entry (256 KiB). +pub const HISTORY_MAX_TIMELINE_BYTES: usize = 256 * 1024; + +/// A query parameter for WebSocket URL construction. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct QueryParamData { + pub key: String, + pub value: String, + #[serde(default = "default_true")] + pub enabled: bool, +} + +fn default_true() -> bool { + true +} + +impl QueryParamData { + #[cfg(test)] + pub fn new(key: impl Into, value: impl Into) -> Self { + Self { + key: key.into(), + value: value.into(), + enabled: true, + } + } +} + +/// Persisted authentication configuration for WebSocket requests. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "snake_case")] +pub enum AuthTypeData { + #[default] + None, + Basic, + Bearer, + ApiKey, +} + +/// Serializable auth config (secrets stored as templates, never resolved). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct AuthConfigData { + #[serde(default)] + pub auth_type: AuthTypeData, + #[serde(default)] + pub username: String, + #[serde(default)] + pub password: String, + #[serde(default)] + pub token: String, + #[serde(default)] + pub api_key_name: String, + #[serde(default)] + pub api_key_value: String, + /// When true, API key is sent as a header; otherwise as a query parameter. + #[serde(default = "default_true")] + pub api_key_in_header: bool, +} + +/// Persisted WebSocket request configuration. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WebSocketRequestData { + pub id: Uuid, + pub name: String, + pub url: String, + #[serde(default)] + pub params: Vec, + #[serde(default)] + pub headers: Vec
, + #[serde(default)] + pub auth: AuthConfigData, + #[serde(default)] + pub subprotocols: Vec, +} + +impl Default for WebSocketRequestData { + fn default() -> Self { + Self { + id: Uuid::new_v4(), + name: String::from("New WebSocket"), + url: String::new(), + params: Vec::new(), + headers: Vec::new(), + auth: AuthConfigData::default(), + subprotocols: Vec::new(), + } + } +} + +impl WebSocketRequestData { + pub fn display_name(&self) -> String { + if !self.name.is_empty() && self.name != "New WebSocket" && self.name != "New Request" { + return self.name.clone(); + } + if !self.url.is_empty() { + return self + .url + .trim_start_matches("wss://") + .trim_start_matches("ws://") + .chars() + .take(40) + .collect(); + } + "New WebSocket".to_string() + } +} + +/// Tagged saved-request type for collection storage. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "request_kind", content = "data", rename_all = "snake_case")] +pub enum SavedRequestData { + Http(RequestData), + WebSocket(WebSocketRequestData), +} + +impl SavedRequestData { + pub fn name(&self) -> &str { + match self { + Self::Http(r) => &r.name, + Self::WebSocket(r) => &r.name, + } + } + + pub fn set_name(&mut self, name: String) { + match self { + Self::Http(r) => r.name = name, + Self::WebSocket(r) => r.name = name, + } + } + + pub fn url(&self) -> &str { + match self { + Self::Http(r) => &r.url, + Self::WebSocket(r) => &r.url, + } + } + + pub fn is_websocket(&self) -> bool { + matches!(self, Self::WebSocket(_)) + } + + #[cfg(test)] + pub fn as_http(&self) -> Option<&RequestData> { + match self { + Self::Http(r) => Some(r), + Self::WebSocket(_) => None, + } + } + + #[cfg(test)] + pub fn as_websocket(&self) -> Option<&WebSocketRequestData> { + match self { + Self::WebSocket(r) => Some(r), + Self::Http(_) => None, + } + } +} + +/// Runtime connection state for a WebSocket session. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum WebSocketConnectionState { + #[default] + Disconnected, + Connecting, + Connected, + Closing, +} + +impl WebSocketConnectionState { + pub fn as_str(self) -> &'static str { + match self { + Self::Disconnected => "Disconnected", + Self::Connecting => "Connecting", + Self::Connected => "Connected", + Self::Closing => "Closing", + } + } + + pub fn can_connect(self) -> bool { + matches!(self, Self::Disconnected) + } + + pub fn can_send(self) -> bool { + matches!(self, Self::Connected) + } + + pub fn button_label(self) -> &'static str { + match self { + Self::Disconnected => "Connect", + Self::Connecting => "Cancel", + Self::Connected => "Disconnect", + Self::Closing => "Disconnect", + } + } +} + +/// Direction of a timeline entry. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum MessageDirection { + Sent, + Received, + System, +} + +impl MessageDirection { + pub fn as_str(self) -> &'static str { + match self { + Self::Sent => "Sent", + Self::Received => "Received", + Self::System => "Event", + } + } +} + +/// Message payload kinds. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum WebSocketPayload { + Text(String), + Binary(Bytes), + Ping(Bytes), + Pong(Bytes), + Close { code: Option, reason: String }, + Error(String), +} + +impl WebSocketPayload { + pub fn size_bytes(&self) -> usize { + match self { + Self::Text(s) => s.len(), + Self::Binary(b) | Self::Ping(b) | Self::Pong(b) => b.len(), + Self::Close { reason, .. } => reason.len() + 2, + Self::Error(s) => s.len(), + } + } + + pub fn kind_label(&self) -> &'static str { + match self { + Self::Text(_) => "Text", + Self::Binary(_) => "Binary", + Self::Ping(_) => "Ping", + Self::Pong(_) => "Pong", + Self::Close { .. } => "Close", + Self::Error(_) => "Error", + } + } + + /// Human-readable preview truncated for the timeline. + pub fn preview(&self) -> String { + match self { + Self::Text(s) => truncate_preview(s), + Self::Binary(b) => format_binary_preview(b), + Self::Ping(b) => { + if b.is_empty() { + "ping".to_string() + } else { + format!("ping ({})", format_byte_size(b.len())) + } + } + Self::Pong(b) => { + if b.is_empty() { + "pong".to_string() + } else { + format!("pong ({})", format_byte_size(b.len())) + } + } + Self::Close { code, reason } => { + let code_str = code + .map(|c| c.to_string()) + .unwrap_or_else(|| "—".to_string()); + if reason.is_empty() { + format!("code {code_str}") + } else { + format!("code {code_str}: {reason}") + } + } + Self::Error(s) => truncate_preview(s), + } + } + + pub fn as_text(&self) -> Option<&str> { + match self { + Self::Text(s) => Some(s), + Self::Error(s) => Some(s), + _ => None, + } + } +} + +/// A single entry in the message timeline. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct WebSocketMessageEntry { + pub sequence: u64, + pub direction: MessageDirection, + pub payload: WebSocketPayload, + pub timestamp: DateTime, + pub size_bytes: usize, +} + +impl WebSocketMessageEntry { + pub fn new(sequence: u64, direction: MessageDirection, payload: WebSocketPayload) -> Self { + let size_bytes = payload.size_bytes(); + Self { + sequence, + direction, + payload, + timestamp: Utc::now(), + size_bytes, + } + } + + pub fn format_time(&self) -> String { + self.timestamp.format("%H:%M:%S%.3f").to_string() + } +} + +/// Handshake metadata captured on connect. +#[derive(Debug, Clone, Default)] +pub struct HandshakeMetadata { + pub duration_ms: u64, + pub response_headers: Vec<(String, String)>, + pub selected_subprotocol: Option, + pub status_code: Option, +} + +/// Compact, persisted outcome for a WebSocket connection attempt. +/// +/// Message frames intentionally stay in the live session instead of History so +/// a chatty socket cannot make the history store grow without bound. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct WebSocketHistoryResult { + pub connected: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub duration_ms: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub status_code: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub selected_subprotocol: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub error: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub messages: Vec, +} + +impl WebSocketHistoryResult { + pub fn connected(meta: &HandshakeMetadata) -> Self { + Self { + connected: true, + duration_ms: Some(meta.duration_ms), + status_code: meta.status_code, + selected_subprotocol: meta.selected_subprotocol.clone(), + error: None, + messages: Vec::new(), + } + } + + pub fn failed(error: String) -> Self { + Self { + connected: false, + duration_ms: None, + status_code: None, + selected_subprotocol: None, + error: Some(error), + messages: Vec::new(), + } + } + + /// Append an exact timeline entry while keeping History storage bounded. + /// Frames larger than the complete per-entry budget are intentionally not + /// persisted; the live session still retains them under its own limits. + pub fn append_message(&mut self, entry: &WebSocketMessageEntry) -> bool { + if entry.size_bytes > HISTORY_MAX_TIMELINE_BYTES { + return false; + } + + let mut stored_bytes: usize = self.messages.iter().map(|message| message.size_bytes).sum(); + while self.messages.len() >= HISTORY_MAX_MESSAGES + || stored_bytes.saturating_add(entry.size_bytes) > HISTORY_MAX_TIMELINE_BYTES + { + if self.messages.is_empty() { + break; + } + let removed = self.messages.remove(0); + stored_bytes = stored_bytes.saturating_sub(removed.size_bytes); + } + self.messages.push(entry.clone()); + true + } +} + +/// Close frame metadata. +#[derive(Debug, Clone, Default)] +pub struct CloseMetadata { + pub code: Option, + pub reason: String, + pub remote: bool, +} + +/// Resource limits for the session timeline. +#[derive(Debug, Clone, Copy)] +pub struct SessionLimits { + pub max_messages: usize, + pub max_timeline_bytes: usize, +} + +impl Default for SessionLimits { + fn default() -> Self { + Self { + max_messages: DEFAULT_MAX_MESSAGES, + max_timeline_bytes: DEFAULT_MAX_TIMELINE_BYTES, + } + } +} + +/// Pure session controller / state reducer. +#[derive(Debug, Clone)] +pub struct WebSocketSessionState { + pub connection_state: WebSocketConnectionState, + pub generation: u64, + pub handshake: Option, + pub messages: VecDeque>, + pub next_sequence: u64, + pub timeline_bytes: usize, + pub last_error: Option, + pub close: Option, + pub sent_messages: u64, + pub received_messages: u64, + pub sent_bytes: u64, + pub received_bytes: u64, + pub limits: SessionLimits, +} + +impl Default for WebSocketSessionState { + fn default() -> Self { + Self::new(SessionLimits::default()) + } +} + +impl WebSocketSessionState { + pub fn new(limits: SessionLimits) -> Self { + Self { + connection_state: WebSocketConnectionState::Disconnected, + generation: 0, + handshake: None, + messages: VecDeque::new(), + next_sequence: 1, + timeline_bytes: 0, + last_error: None, + close: None, + sent_messages: 0, + received_messages: 0, + sent_bytes: 0, + received_bytes: 0, + limits, + } + } + + /// Restore a completed session transcript for read-only History viewing. + pub fn restore_history(&mut self, result: &WebSocketHistoryResult) { + self.messages = result.messages.iter().cloned().map(Arc::new).collect(); + self.timeline_bytes = self.messages.iter().map(|message| message.size_bytes).sum(); + self.next_sequence = self + .messages + .back() + .map_or(1, |message| message.sequence.wrapping_add(1)); + self.sent_messages = 0; + self.received_messages = 0; + self.sent_bytes = 0; + self.received_bytes = 0; + for message in &self.messages { + match message.direction { + MessageDirection::Sent => { + self.sent_messages = self.sent_messages.saturating_add(1); + self.sent_bytes = self.sent_bytes.saturating_add(message.size_bytes as u64); + } + MessageDirection::Received => { + self.received_messages = self.received_messages.saturating_add(1); + self.received_bytes = self + .received_bytes + .saturating_add(message.size_bytes as u64); + } + MessageDirection::System => {} + } + } + self.handshake = if result.connected { + Some(HandshakeMetadata { + duration_ms: result.duration_ms.unwrap_or_default(), + response_headers: Vec::new(), + selected_subprotocol: result.selected_subprotocol.clone(), + status_code: result.status_code, + }) + } else { + None + }; + self.last_error = result.error.clone(); + self.connection_state = WebSocketConnectionState::Disconnected; + } + + /// Begin a new connection attempt; returns the generation for this attempt. + pub fn begin_connect(&mut self) -> u64 { + self.generation = self.generation.wrapping_add(1); + self.connection_state = WebSocketConnectionState::Connecting; + self.handshake = None; + self.last_error = None; + self.close = None; + self.generation + } + + /// Ignore events whose generation does not match the active session. + pub fn is_current_generation(&self, generation: u64) -> bool { + self.generation == generation + } + + pub fn on_connected(&mut self, generation: u64, meta: HandshakeMetadata) { + if !self.is_current_generation(generation) { + return; + } + self.connection_state = WebSocketConnectionState::Connected; + let duration = meta.duration_ms; + let protocol = meta + .selected_subprotocol + .clone() + .unwrap_or_else(|| "none".to_string()); + self.handshake = Some(meta); + self.push_system(WebSocketPayload::Text(format!( + "Connected in {duration} ms · protocol: {protocol}" + ))); + } + + pub fn on_message(&mut self, generation: u64, payload: WebSocketPayload) { + if !self.is_current_generation(generation) { + return; + } + let size = payload.size_bytes() as u64; + self.received_messages = self.received_messages.saturating_add(1); + self.received_bytes = self.received_bytes.saturating_add(size); + self.push(MessageDirection::Received, payload); + } + + pub fn on_pong(&mut self, generation: u64, data: Bytes) { + if !self.is_current_generation(generation) { + return; + } + self.push(MessageDirection::Received, WebSocketPayload::Pong(data)); + } + + pub fn on_sent(&mut self, generation: u64, payload: WebSocketPayload) { + if !self.is_current_generation(generation) { + return; + } + let size = payload.size_bytes() as u64; + self.sent_messages = self.sent_messages.saturating_add(1); + self.sent_bytes = self.sent_bytes.saturating_add(size); + self.push(MessageDirection::Sent, payload); + } + + pub fn on_closing(&mut self, generation: u64) { + if !self.is_current_generation(generation) { + return; + } + if matches!( + self.connection_state, + WebSocketConnectionState::Connected | WebSocketConnectionState::Connecting + ) { + self.connection_state = WebSocketConnectionState::Closing; + } + } + + pub fn on_closed(&mut self, generation: u64, meta: CloseMetadata) { + if !self.is_current_generation(generation) { + return; + } + self.connection_state = WebSocketConnectionState::Disconnected; + let code = meta.code; + let reason = if meta.reason.is_empty() { + if meta.remote { + "Server closed the connection".to_string() + } else { + "Client closed the connection".to_string() + } + } else { + meta.reason.clone() + }; + self.close = Some(meta); + self.push_system(WebSocketPayload::Close { code, reason }); + } + + pub fn on_error(&mut self, generation: u64, error: String) { + if !self.is_current_generation(generation) { + return; + } + self.connection_state = WebSocketConnectionState::Disconnected; + self.last_error = Some(error.clone()); + self.push_system(WebSocketPayload::Error(error)); + } + + pub fn on_cancelled(&mut self, generation: u64) { + if !self.is_current_generation(generation) { + return; + } + self.connection_state = WebSocketConnectionState::Disconnected; + self.push_system(WebSocketPayload::Text("Connection cancelled".to_string())); + } + + pub fn clear_messages(&mut self) { + self.messages.clear(); + self.timeline_bytes = 0; + self.next_sequence = 1; + } + + fn push_system(&mut self, payload: WebSocketPayload) { + self.push(MessageDirection::System, payload); + } + + fn push(&mut self, direction: MessageDirection, payload: WebSocketPayload) { + let entry = Arc::new(WebSocketMessageEntry::new( + self.next_sequence, + direction, + payload, + )); + self.next_sequence = self.next_sequence.wrapping_add(1); + self.timeline_bytes = self.timeline_bytes.saturating_add(entry.size_bytes); + self.messages.push_back(entry); + self.evict_if_needed(); + } + + fn evict_if_needed(&mut self) { + while self.messages.len() > self.limits.max_messages + || self.timeline_bytes > self.limits.max_timeline_bytes + { + if self.messages.is_empty() { + break; + } + let Some(removed) = self.messages.pop_front() else { + break; + }; + self.timeline_bytes = self.timeline_bytes.saturating_sub(removed.size_bytes); + } + } +} + +/// Validate and normalize a WebSocket URL scheme. +pub fn validate_ws_url(url: &str) -> Result<(), String> { + let trimmed = url.trim(); + if trimmed.is_empty() { + return Err("URL cannot be empty".to_string()); + } + let uri: http::Uri = trimmed + .parse() + .map_err(|_| "Enter a valid WebSocket URL".to_string())?; + if !matches!(uri.scheme_str(), Some("ws" | "wss")) { + return Err("URL must start with ws:// or wss://".to_string()); + } + if uri.host().is_none_or(str::is_empty) { + return Err("WebSocket URL must include a host".to_string()); + } + Ok(()) +} + +/// Append enabled query parameters to a URL. +pub fn append_query_params(base_url: &str, params: &[QueryParamData]) -> String { + let enabled: Vec<&QueryParamData> = params + .iter() + .filter(|p| p.enabled && !p.key.is_empty()) + .collect(); + if enabled.is_empty() { + return base_url.to_string(); + } + + let query = enabled + .iter() + .map(|p| { + if p.value.is_empty() { + urlencoding::encode(&p.key).into_owned() + } else { + format!( + "{}={}", + urlencoding::encode(&p.key), + urlencoding::encode(&p.value) + ) + } + }) + .collect::>() + .join("&"); + + let (url_without_fragment, fragment) = base_url + .split_once('#') + .map_or((base_url, None), |(url, fragment)| (url, Some(fragment))); + let separator = if url_without_fragment.contains('?') { + if url_without_fragment.ends_with('?') || url_without_fragment.ends_with('&') { + "" + } else { + "&" + } + } else { + "?" + }; + let mut result = format!("{url_without_fragment}{separator}{query}"); + if let Some(fragment) = fragment { + result.push('#'); + result.push_str(fragment); + } + result +} + +/// Parse a comma-separated subprotocol list. +pub fn parse_subprotocols(input: &str) -> Vec { + let mut seen = HashSet::new(); + input + .split(',') + .map(str::trim) + .filter(|value| !value.is_empty()) + .filter(|value| seen.insert((*value).to_string())) + .map(ToString::to_string) + .collect() +} + +/// Format byte size for display. +pub fn format_byte_size(bytes: usize) -> String { + const KB: f64 = 1024.0; + const MB: f64 = KB * 1024.0; + let b = bytes as f64; + if b >= MB { + format!("{:.1} MB", b / MB) + } else if b >= KB { + format!("{:.1} KB", b / KB) + } else { + format!("{bytes} B") + } +} + +fn truncate_preview(s: &str) -> String { + let mut chars = s.chars(); + let preview: String = chars.by_ref().take(PREVIEW_MAX_CHARS).collect(); + if chars.next().is_some() { + format!("{preview}…") + } else { + preview + } +} + +fn format_binary_preview(bytes: &Bytes) -> String { + if bytes.is_empty() { + return "empty".to_string(); + } + let preview_len = bytes.len().min(16); + let hex: String = bytes[..preview_len] + .iter() + .map(|b| format!("{b:02x}")) + .collect::>() + .join(" "); + let suffix = if bytes.len() > preview_len { "…" } else { "" }; + format!("{} · {hex}{suffix}", format_byte_size(bytes.len())) +} + +/// Pretty-format JSON text if valid; otherwise return original with error. +pub fn format_json(text: &str) -> Result { + let value: serde_json::Value = + serde_json::from_str(text).map_err(|e| format!("Invalid JSON: {e}"))?; + serde_json::to_string_pretty(&value).map_err(|e| format!("Failed to format JSON: {e}")) +} + +/// Compact JSON for sending (validates first). +pub fn compact_json(text: &str) -> Result { + let value: serde_json::Value = + serde_json::from_str(text).map_err(|e| format!("Invalid JSON: {e}"))?; + serde_json::to_string(&value).map_err(|e| format!("Failed to serialize JSON: {e}")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn validates_ws_schemes() { + assert!(validate_ws_url("ws://localhost:9001").is_ok()); + assert!(validate_ws_url("wss://example.com/socket").is_ok()); + assert!(validate_ws_url("http://example.com").is_err()); + assert!(validate_ws_url("ws://").is_err()); + assert!(validate_ws_url("ws:///socket").is_err()); + assert!(validate_ws_url("").is_err()); + assert!(validate_ws_url(" ").is_err()); + } + + #[test] + fn appends_query_params_encoded() { + let params = vec![ + QueryParamData::new("q", "hello world"), + QueryParamData { + key: "empty".into(), + value: String::new(), + enabled: true, + }, + QueryParamData { + key: "off".into(), + value: "1".into(), + enabled: false, + }, + ]; + let url = append_query_params("ws://localhost/socket", ¶ms); + assert!(url.starts_with("ws://localhost/socket?")); + assert!(url.contains("q=hello%20world") || url.contains("q=hello+world")); + assert!(url.contains("empty")); + assert!(!url.contains("off=")); + } + + #[test] + fn merges_query_with_existing() { + let params = vec![QueryParamData::new("b", "2")]; + let url = append_query_params("ws://localhost/s?a=1", ¶ms); + assert_eq!(url, "ws://localhost/s?a=1&b=2"); + } + + #[test] + fn inserts_query_before_fragment_and_handles_trailing_separator() { + let params = vec![QueryParamData::new("b", "2")]; + assert_eq!( + append_query_params("ws://localhost/s?a=1#section", ¶ms), + "ws://localhost/s?a=1&b=2#section" + ); + assert_eq!( + append_query_params("ws://localhost/s?", ¶ms), + "ws://localhost/s?b=2" + ); + } + + #[test] + fn parses_subprotocols() { + assert_eq!( + parse_subprotocols("graphql-ws, chat"), + vec!["graphql-ws".to_string(), "chat".to_string()] + ); + assert!(parse_subprotocols(" , ").is_empty()); + assert_eq!( + parse_subprotocols("chat, chat, graphql-ws"), + vec!["chat", "graphql-ws"] + ); + } + + #[test] + fn connection_state_transitions() { + let mut s = WebSocketSessionState::default(); + assert_eq!(s.connection_state, WebSocketConnectionState::Disconnected); + let generation = s.begin_connect(); + assert_eq!(s.connection_state, WebSocketConnectionState::Connecting); + s.on_connected( + generation, + HandshakeMetadata { + duration_ms: 12, + selected_subprotocol: Some("chat".into()), + ..Default::default() + }, + ); + assert_eq!(s.connection_state, WebSocketConnectionState::Connected); + assert!(s.connection_state.can_send()); + s.on_closing(generation); + assert_eq!(s.connection_state, WebSocketConnectionState::Closing); + s.on_closed( + generation, + CloseMetadata { + code: Some(1000), + reason: "bye".into(), + remote: true, + }, + ); + assert_eq!(s.connection_state, WebSocketConnectionState::Disconnected); + assert_eq!(s.close.as_ref().unwrap().code, Some(1000)); + } + + #[test] + fn ignores_stale_generation_events() { + let mut s = WebSocketSessionState::default(); + let generation1 = s.begin_connect(); + let generation2 = s.begin_connect(); + assert_ne!(generation1, generation2); + s.on_connected(generation1, HandshakeMetadata::default()); + assert_eq!(s.connection_state, WebSocketConnectionState::Connecting); + s.on_connected(generation2, HandshakeMetadata::default()); + assert_eq!(s.connection_state, WebSocketConnectionState::Connected); + s.on_error(generation1, "old".into()); + s.on_sent(generation1, WebSocketPayload::Text("stale".into())); + assert_eq!(s.connection_state, WebSocketConnectionState::Connected); + assert!(s.last_error.is_none()); + assert_eq!(s.sent_messages, 0); + } + + #[test] + fn message_ordering_and_sequence() { + let mut s = WebSocketSessionState::default(); + let generation = s.begin_connect(); + s.on_connected(generation, HandshakeMetadata::default()); + s.on_sent(generation, WebSocketPayload::Text("a".into())); + s.on_message(generation, WebSocketPayload::Text("b".into())); + // system connect message + 2 data messages + assert!(s.messages.len() >= 3); + let seqs: Vec = s.messages.iter().map(|m| m.sequence).collect(); + for window in seqs.windows(2) { + assert!(window[0] < window[1]); + } + assert_eq!(s.sent_messages, 1); + assert_eq!(s.received_messages, 1); + } + + #[test] + fn history_transcript_is_bounded_and_restores_counters() { + let mut history = WebSocketHistoryResult::failed("closed".into()); + for sequence in 1..=(HISTORY_MAX_MESSAGES as u64 + 5) { + let direction = if sequence % 2 == 0 { + MessageDirection::Sent + } else { + MessageDirection::Received + }; + assert!(history.append_message(&WebSocketMessageEntry::new( + sequence, + direction, + WebSocketPayload::Text("hello".into()), + ))); + } + + assert_eq!(history.messages.len(), HISTORY_MAX_MESSAGES); + assert_eq!(history.messages.first().unwrap().sequence, 6); + + let mut restored = WebSocketSessionState::default(); + restored.restore_history(&history); + assert_eq!(restored.messages.len(), HISTORY_MAX_MESSAGES); + assert_eq!( + restored.sent_messages + restored.received_messages, + HISTORY_MAX_MESSAGES as u64 + ); + assert_eq!(restored.next_sequence, HISTORY_MAX_MESSAGES as u64 + 6); + assert_eq!( + restored.connection_state, + WebSocketConnectionState::Disconnected + ); + } + + #[test] + fn history_transcript_skips_a_frame_larger_than_its_budget() { + let mut history = WebSocketHistoryResult::failed("closed".into()); + let oversized = WebSocketMessageEntry::new( + 1, + MessageDirection::Received, + WebSocketPayload::Binary(Bytes::from(vec![0; HISTORY_MAX_TIMELINE_BYTES + 1])), + ); + + assert!(!history.append_message(&oversized)); + assert!(history.messages.is_empty()); + } + + #[test] + fn timeline_evicts_by_count() { + let mut s = WebSocketSessionState::new(SessionLimits { + max_messages: 3, + max_timeline_bytes: DEFAULT_MAX_TIMELINE_BYTES, + }); + for i in 0..5 { + s.on_sent(0, WebSocketPayload::Text(format!("msg{i}"))); + } + assert_eq!(s.messages.len(), 3); + assert_eq!(s.messages[0].payload.as_text().unwrap(), "msg2"); + } + + #[test] + fn timeline_evicts_by_bytes() { + let mut s = WebSocketSessionState::new(SessionLimits { + max_messages: 1000, + max_timeline_bytes: 20, + }); + s.on_sent(0, WebSocketPayload::Text("abcdefghij".into())); // 10 + s.on_sent(0, WebSocketPayload::Text("klmnopqrst".into())); // 10 + s.on_sent(0, WebSocketPayload::Text("uvwxyz".into())); // 6 -> should evict + assert!(s.timeline_bytes <= 20); + assert!(s.messages.len() < 3 || s.timeline_bytes <= 20); + } + + #[test] + fn rejects_send_when_disconnected() { + let s = WebSocketSessionState::default(); + assert!(!s.connection_state.can_send()); + assert!(s.connection_state.can_connect()); + } + + #[test] + fn binary_preview_and_size() { + let payload = WebSocketPayload::Binary(Bytes::from(vec![0u8, 1, 2, 255])); + assert_eq!(payload.size_bytes(), 4); + assert!(payload.preview().contains("4 B")); + assert!(payload.preview().contains("00")); + } + + #[test] + fn json_format_and_compact() { + let pretty = format_json(r#"{"a":1}"#).unwrap(); + assert!(pretty.contains('\n')); + let compact = compact_json(&pretty).unwrap(); + assert_eq!(compact, r#"{"a":1}"#); + assert!(format_json("not json").is_err()); + } + + #[test] + fn saved_request_roundtrip() { + let ws = WebSocketRequestData { + id: Uuid::new_v4(), + name: "Chat".into(), + url: "ws://localhost:9001".into(), + params: vec![QueryParamData::new("room", "1")], + headers: vec![Header::new("X-Test", "1")], + auth: AuthConfigData { + auth_type: AuthTypeData::Bearer, + token: "{{token}}".into(), + ..Default::default() + }, + subprotocols: vec!["chat".into()], + }; + let saved = SavedRequestData::WebSocket(ws.clone()); + let encoded = serde_json::to_string(&saved).unwrap(); + let decoded: SavedRequestData = serde_json::from_str(&encoded).unwrap(); + let ws2 = decoded.as_websocket().unwrap(); + assert_eq!(ws2.url, ws.url); + assert_eq!(ws2.auth.token, "{{token}}"); + assert_eq!(ws2.subprotocols, vec!["chat"]); + } + + #[test] + fn http_saved_request_roundtrip() { + let http = RequestData::default(); + let saved = SavedRequestData::Http(http.clone()); + let encoded = serde_json::to_string(&saved).unwrap(); + assert!(encoded.contains("http")); + let decoded: SavedRequestData = serde_json::from_str(&encoded).unwrap(); + assert!(!decoded.is_websocket()); + assert_eq!(decoded.as_http().unwrap().method, http.method); + } +} diff --git a/src/main.rs b/src/main.rs index 18f7e3b..3d484e8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -30,6 +30,7 @@ mod importers; mod theme; mod utils; mod views; +mod websocket; use app::SetuApp; diff --git a/src/views/main_view.rs b/src/views/main_view.rs index 1f70c29..5afc147 100644 --- a/src/views/main_view.rs +++ b/src/views/main_view.rs @@ -32,8 +32,8 @@ use crate::entities::{ CollectionDestination, CollectionDestinationEntry, CollectionsEntity, EnvironmentColor, EnvironmentScope, EnvironmentVariable, EnvironmentsEntity, HistoryEntity, HistoryGrouping, HistoryRow, HttpMethod, PreferredLayout, RequestBody, RequestData, RequestEntity, RequestEvent, - ResponseData, ResponseEntity, SidebarLoadState, UiPreferences, UiPreferencesStore, - WorkspacesEntity, + ResponseData, ResponseEntity, SavedRequestData, SidebarLoadState, UiPreferences, + UiPreferencesStore, WebSocketHistoryResult, WebSocketRequestData, WorkspacesEntity, }; use crate::http::{HttpClient, InFlightRequest}; use crate::icons::IconName; @@ -42,6 +42,7 @@ use crate::utils::{close_dialog, open_dialog}; use crate::views::environment_view::EnvironmentView; use crate::views::request_view::RequestView; use crate::views::response_view::ResponseView; +use crate::views::websocket_view::WebSocketView; use crate::views::{CommandId, CommandPaletteEvent, CommandPaletteView}; #[derive(Clone)] @@ -77,6 +78,9 @@ pub enum TabContent { in_flight_request: Option, request_generation: RequestGeneration, }, + WebSocket { + view: Entity, + }, Environment { environment_id: Uuid, view: Entity, @@ -96,6 +100,14 @@ impl TabState { matches!(&self.content, TabContent::Environment { environment_id, .. } if *environment_id == id) } + pub fn protocol(&self) -> ProtocolType { + match &self.content { + TabContent::Request { .. } => ProtocolType::Rest, + TabContent::WebSocket { .. } => ProtocolType::WebSocket, + TabContent::Environment { .. } => ProtocolType::Rest, + } + } + #[allow(dead_code)] pub fn environment_id(&self) -> Option { match &self.content { @@ -642,6 +654,8 @@ impl MainView { let history = self.history.read(cx); history.get_entry(entry_id).map(|entry| { ( + entry.websocket_request.clone(), + entry.websocket_result.clone(), entry.request.clone(), entry.response.clone(), entry.display_name(), @@ -649,10 +663,24 @@ impl MainView { }) }; - let Some((request_data, response_data, tab_name)) = entry_data else { + let Some((websocket_request, websocket_result, request_data, response_data, tab_name)) = + entry_data + else { return; }; + if let Some(websocket_request) = websocket_request { + self.open_websocket_tab_with_history( + websocket_request, + websocket_result, + tab_name, + None, + window, + cx, + ); + return; + } + // Derive body type from the stored request body let body_type = BodyType::from_request_body(&request_data.body); @@ -817,10 +845,20 @@ impl MainView { .map(|node| (node.request.clone(), node.display_name())) }; - let Some((request_data, tab_name)) = item_data else { + let Some((saved_request, tab_name)) = item_data else { return; }; + if let SavedRequestData::WebSocket(ws_data) = saved_request { + self.open_websocket_tab(ws_data, tab_name, Some(collection_id), window, cx); + return; + } + + let request_data = match saved_request { + SavedRequestData::Http(data) => data, + SavedRequestData::WebSocket(_) => unreachable!(), + }; + // Derive body type from the stored request body let body_type = BodyType::from_request_body(&request_data.body); @@ -1438,6 +1476,7 @@ impl MainView { }); } + #[allow(dead_code)] pub fn save_request_to_destination( &mut self, destination: CollectionDestination, @@ -1447,11 +1486,24 @@ impl MainView { ) { let mut request_data = request_data; request_data.name = request_name; + self.save_saved_request_to_destination( + destination, + SavedRequestData::Http(request_data), + cx, + ); + } + + pub fn save_saved_request_to_destination( + &mut self, + destination: CollectionDestination, + request: SavedRequestData, + cx: &mut Context, + ) { self.collections.update(cx, |collections, cx| { - collections.add_request_node( + collections.add_saved_request_node( destination.collection_id, destination.folder_id, - request_data, + request, cx, ); }); @@ -1461,10 +1513,35 @@ impl MainView { cx.notify(); } + #[allow(dead_code)] fn build_active_request_snapshot(&mut self, cx: &mut Context) -> Option { self.build_request_snapshot_for_tab(self.active_tab_index, cx) } + fn build_active_saved_request_snapshot( + &mut self, + cx: &mut Context, + ) -> Option { + let tab_index = self.active_tab_index; + let tab = self.tabs.get(tab_index)?; + match &tab.content { + TabContent::Request { .. } => self + .build_request_snapshot_for_tab(tab_index, cx) + .map(SavedRequestData::Http), + TabContent::WebSocket { view } => { + let view = view.clone(); + let mut data = view.update(cx, |v, cx| v.snapshot_request(cx)); + if !self.tabs[tab_index].is_custom_name { + data.name = data.display_name(); + } else { + data.name = self.tabs[tab_index].name.clone(); + } + Some(SavedRequestData::WebSocket(data)) + } + TabContent::Environment { .. } => None, + } + } + fn build_request_snapshot_for_tab( &mut self, tab_index: usize, @@ -1594,21 +1671,223 @@ impl MainView { } fn cancel_in_flight_for_tab(&mut self, index: usize, cx: &mut Context) { - if let Some(tab) = self.tabs.get_mut(index) - && let TabContent::Request { + let Some(tab) = self.tabs.get_mut(index) else { + return; + }; + match &mut tab.content { + TabContent::Request { request, response, in_flight_request, request_generation, .. - } = &mut tab.content - && let Some(mut in_flight) = in_flight_request.take() - { - let _ = in_flight.cancel(); - request_generation.advance(); - request.update(cx, |req, cx| req.set_sending(false, cx)); - response.update(cx, |resp, cx| resp.set_cancelled(cx)); + } => { + if let Some(mut in_flight) = in_flight_request.take() { + let _ = in_flight.cancel(); + request_generation.advance(); + request.update(cx, |req, cx| req.set_sending(false, cx)); + response.update(cx, |resp, cx| resp.set_cancelled(cx)); + } + } + TabContent::WebSocket { view } => { + view.update(cx, |v, cx| v.terminate(cx)); + } + TabContent::Environment { .. } => {} + } + } + + /// Open a WebSocket tab with the given configuration. + pub fn open_websocket_tab( + &mut self, + request: WebSocketRequestData, + tab_name: String, + collection_id: Option, + _window: &mut Window, + cx: &mut Context, + ) { + self.open_websocket_tab_with_history(request, None, tab_name, collection_id, _window, cx); + } + + fn open_websocket_tab_with_history( + &mut self, + request: WebSocketRequestData, + initial_history: Option, + tab_name: String, + collection_id: Option, + _window: &mut Window, + cx: &mut Context, + ) { + let is_custom_name = tab_name != "New WebSocket"; + let environments = self.environments.clone(); + let history = self.history.clone(); + let completion_engine = self.completion_engine.clone(); + let view = cx.new(|cx| { + WebSocketView::new( + request, + environments, + history, + collection_id, + completion_engine, + initial_history, + cx, + ) + }); + let tab_id = TabId(self.next_tab_id); + self.next_tab_id += 1; + let tab = TabState { + id: tab_id, + name: tab_name, + is_custom_name, + content: TabContent::WebSocket { view }, + collection_id, + }; + self.tabs.push(tab); + self.active_tab_index = self.tabs.len() - 1; + self.tab_scroll_handle.scroll_to_item(self.active_tab_index); + cx.notify(); + } + + /// Create a blank WebSocket tab. + pub fn new_websocket_tab(&mut self, window: &mut Window, cx: &mut Context) { + self.open_websocket_tab( + WebSocketRequestData::default(), + "New WebSocket".to_string(), + None, + window, + cx, + ); + } + + /// Switch the active tab's protocol (REST <-> WebSocket). + pub fn set_active_protocol( + &mut self, + protocol: ProtocolType, + window: &mut Window, + cx: &mut Context, + ) { + let Some(tab) = self.tabs.get(self.active_tab_index) else { + return; + }; + let current = tab.protocol(); + if current == protocol || matches!(tab.content, TabContent::Environment { .. }) { + return; + } + if !matches!(protocol, ProtocolType::Rest | ProtocolType::WebSocket) { + return; + } + + // Convert empty-ish tabs in place; otherwise open a fresh tab of the target type. + let is_emptyish = match &tab.content { + TabContent::Request { + request, url_input, .. + } => { + let url = url_input + .as_ref() + .map(|i| i.read(cx).text().to_string()) + .unwrap_or_else(|| request.read(cx).url().to_string()); + url.trim().is_empty() && !tab.is_custom_name + } + TabContent::WebSocket { view } => { + view.read(cx).current_url(cx).trim().is_empty() && !tab.is_custom_name + } + TabContent::Environment { .. } => false, + }; + + if !is_emptyish { + // Ask before discarding non-empty configuration. + let this = cx.entity().clone(); + let target = protocol; + open_dialog(window, cx, move |dialog, _, _| { + let this_click = this.clone(); + dialog + .title("Switch protocol?") + .child( + "Switching protocols opens a new tab so your current request is kept. Continue?", + ) + .footer({ + DialogFooter::new() + .child( + Button::new("switch-protocol-confirm") + .primary() + .label("Open new tab") + .on_click(move |_, window, cx| { + this_click.update(cx, |view, cx| match target { + ProtocolType::WebSocket => { + view.new_websocket_tab(window, cx); + } + ProtocolType::Rest => { + view.new_tab(cx); + } + _ => {} + }); + close_dialog(window, cx); + }), + ) + .child( + Button::new("switch-protocol-cancel") + .label("Cancel") + .on_click(|_, window, cx| close_dialog(window, cx)), + ) + }) + }); + return; } + + // Replace empty active tab content. + let collection_id = tab.collection_id; + let tab_id = tab.id; + match protocol { + ProtocolType::WebSocket => { + let environments = self.environments.clone(); + let history = self.history.clone(); + let completion_engine = self.completion_engine.clone(); + let view = cx.new(|cx| { + WebSocketView::new( + WebSocketRequestData::default(), + environments, + history, + collection_id, + completion_engine, + None, + cx, + ) + }); + if let Some(tab) = self.tabs.get_mut(self.active_tab_index) { + tab.name = "New WebSocket".to_string(); + tab.is_custom_name = false; + tab.content = TabContent::WebSocket { view }; + tab.id = tab_id; + } + } + ProtocolType::Rest => { + let request = cx.new(|_| RequestEntity::new()); + let response = cx.new(|_| ResponseEntity::new()); + let method_dropdown = cx.new(|_| MethodDropdownState::new(HttpMethod::Get)); + let completion_engine = self.completion_engine.clone(); + let request_view = cx.new(|cx| { + RequestView::new(request.clone(), BodyType::None, cx) + .with_completion_engine(completion_engine) + }); + let response_view = cx.new(|cx| ResponseView::new(response.clone(), cx)); + Self::subscribe_request_changes(&request, cx); + if let Some(tab) = self.tabs.get_mut(self.active_tab_index) { + tab.name = "New Request".to_string(); + tab.is_custom_name = false; + tab.content = TabContent::Request { + request, + response, + url_input: None, + method_dropdown, + request_view, + response_view, + in_flight_request: None, + request_generation: RequestGeneration::default(), + }; + } + } + _ => {} + } + cx.notify(); } fn cancel_in_flight_for_all_tabs(&mut self, cx: &mut Context) { @@ -2073,7 +2352,7 @@ impl MainView { } pub fn show_save_to_collection_dialog(&mut self, window: &mut Window, cx: &mut Context) { - let Some(request_data) = self.build_active_request_snapshot(cx) else { + let Some(saved_request) = self.build_active_saved_request_snapshot(cx) else { window.push_notification( (NotificationType::Warning, "No active request to save."), cx, @@ -2087,8 +2366,9 @@ impl MainView { }; let this = cx.entity().clone(); + let default_name = saved_request.name().to_string(); let request_name_input = - cx.new(|cx| InputState::new(window, cx).default_value(&request_data.name)); + cx.new(|cx| InputState::new(window, cx).default_value(&default_name)); if destination_options.is_empty() { let collection_name_input = @@ -2096,7 +2376,7 @@ impl MainView { let request_name_for_footer = request_name_input.clone(); let collection_name_for_footer = collection_name_input.clone(); let this_for_footer = this.clone(); - let request_for_footer = request_data.clone(); + let request_for_footer = saved_request.clone(); open_dialog(window, cx, move |dialog, _, _| { let request_name_for_buttons = request_name_for_footer.clone(); @@ -2156,13 +2436,14 @@ impl MainView { collections .create_collection(&collection_name, cx) }); - view.save_request_to_destination( + let mut request = request_click.clone(); + request.set_name(request_name); + view.save_saved_request_to_destination( CollectionDestination { collection_id, folder_id: None, }, - request_name, - request_click.clone(), + request, cx, ); }); @@ -2192,7 +2473,7 @@ impl MainView { let request_name_for_footer = request_name_input.clone(); let destination_for_footer = destination_select.clone(); let this_for_footer = this.clone(); - let request_for_footer = request_data.clone(); + let request_for_footer = saved_request.clone(); open_dialog(window, cx, move |dialog, _, _| { let request_name_for_buttons = request_name_for_footer.clone(); @@ -2241,10 +2522,11 @@ impl MainView { }; this_click.update(cx, |view, cx| { - view.save_request_to_destination( + let mut request = request_click.clone(); + request.set_name(request_name); + view.save_saved_request_to_destination( selection.destination, - request_name, - request_click.clone(), + request, cx, ); }); @@ -2494,12 +2776,30 @@ impl MainView { } /// Send the current request - pub fn send_request(&mut self, cx: &mut Context) { + pub fn send_request(&mut self, window: &mut Window, cx: &mut Context) { let tab_index = self.active_tab_index; let Some(tab) = self.tabs.get(tab_index) else { return; }; + // WebSocket: connect when idle, send composer payload when connected. + if let TabContent::WebSocket { view } = &tab.content { + let view = view.clone(); + view.update(cx, |v, cx| match v.connection_state() { + crate::entities::WebSocketConnectionState::Disconnected => { + v.connect(window, cx); + } + crate::entities::WebSocketConnectionState::Connected => { + v.send_from_composer(window, cx); + } + crate::entities::WebSocketConnectionState::Connecting => { + v.cancel_connect(cx); + } + crate::entities::WebSocketConnectionState::Closing => {} + }); + return; + } + let TabContent::Request { request: request_entity, response: response_entity, @@ -2705,26 +3005,41 @@ impl MainView { return; }; - if let TabContent::Request { - request, - response, - in_flight_request, - request_generation, - .. - } = &mut tab.content - { - let is_sending = request.read(cx).is_sending(); - if !is_sending { - return; - } + match &mut tab.content { + TabContent::Request { + request, + response, + in_flight_request, + request_generation, + .. + } => { + let is_sending = request.read(cx).is_sending(); + if !is_sending { + return; + } - if let Some(mut in_flight) = in_flight_request.take() { - let _ = in_flight.cancel(); + if let Some(mut in_flight) = in_flight_request.take() { + let _ = in_flight.cancel(); + } + request_generation.advance(); + request.update(cx, |r, cx| r.set_sending(false, cx)); + response.update(cx, |r, cx| r.set_cancelled(cx)); + cx.notify(); } - request_generation.advance(); - request.update(cx, |r, cx| r.set_sending(false, cx)); - response.update(cx, |r, cx| r.set_cancelled(cx)); - cx.notify(); + TabContent::WebSocket { view } => { + let view = view.clone(); + view.update(cx, |v, cx| match v.connection_state() { + crate::entities::WebSocketConnectionState::Connecting => { + v.cancel_connect(cx); + } + crate::entities::WebSocketConnectionState::Connected + | crate::entities::WebSocketConnectionState::Closing => { + v.disconnect(cx); + } + crate::entities::WebSocketConnectionState::Disconnected => {} + }); + } + TabContent::Environment { .. } => {} } } @@ -2797,7 +3112,9 @@ impl MainView { pub fn execute_command(&mut self, cmd_id: CommandId, cx: &mut Context) { match cmd_id { - CommandId::SendRequest => self.send_request(cx), + CommandId::SendRequest => { + self.pending_window_command = Some(CommandId::SendRequest); + } CommandId::CancelRequest => self.cancel_request(cx), CommandId::NewRequest => self.new_tab(cx), CommandId::CloseTab => self.close_current_tab(cx), @@ -2896,6 +3213,17 @@ impl MainView { pub fn duplicate_request(&mut self, window: &mut Window, cx: &mut Context) { if let Some(current_tab) = self.active_tab() { + if let TabContent::WebSocket { view } = ¤t_tab.content { + let view = view.clone(); + let name = format!("{} (copy)", current_tab.name); + let collection_id = current_tab.collection_id; + let mut request = view.update(cx, |view, cx| view.snapshot_request(cx)); + request.id = Uuid::new_v4(); + request.name = name.clone(); + self.open_websocket_tab(request, name, collection_id, window, cx); + return; + } + let TabContent::Request { request: old_request, url_input: old_url_input, @@ -3058,15 +3386,19 @@ impl MainView { } pub fn focus_url_bar(&mut self, window: &mut Window, cx: &mut Context) { - if let Some(tab) = self.tabs.get(self.active_tab_index) - && let TabContent::Request { - url_input: Some(url_input), - .. - } = &tab.content - { - url_input.update(cx, |state, cx| { - state.focus(window, cx); - }); + if let Some(tab) = self.tabs.get(self.active_tab_index) { + match &tab.content { + TabContent::Request { + url_input: Some(url_input), + .. + } => { + url_input.update(cx, |state, cx| state.focus(window, cx)); + } + TabContent::WebSocket { view } => { + view.update(cx, |view, cx| view.focus_url(window, cx)); + } + _ => {} + } } } } @@ -3087,6 +3419,7 @@ impl Render for MainView { CommandId::FocusUrlBar => self.focus_url_bar(window, cx), CommandId::SaveToCollection => self.show_save_to_collection_dialog(window, cx), CommandId::ImportCollection => self.import_collection_from_file(window, cx), + CommandId::SendRequest => self.send_request(window, cx), _ => {} } } @@ -3128,6 +3461,14 @@ impl Render for MainView { }; (name, TabIcon::Method(method)) } + TabContent::WebSocket { view } => { + let name = if tab.is_custom_name { + tab.name.clone() + } else { + view.read(cx).display_name(cx) + }; + (name, TabIcon::Icon(IconName::Link)) + } TabContent::Environment { environment_id, .. } => { let name = self .environments @@ -3221,8 +3562,8 @@ impl Render for MainView { .bg(theme.background) .text_color(theme.foreground) // Request actions - .on_action(cx.listener(|this, _: &SendRequest, _window, cx| { - this.send_request(cx); + .on_action(cx.listener(|this, _: &SendRequest, window, cx| { + this.send_request(window, cx); })) .on_action(cx.listener(|this, _: &CancelRequest, _window, cx| { this.cancel_request(cx); @@ -3660,6 +4001,7 @@ impl Render for MainView { TabContent::Environment { view, .. } => { view.clone().into_any_element() } + TabContent::WebSocket { view } => view.clone().into_any_element(), TabContent::Request { .. } => { if let Some(( url_input, @@ -3852,9 +4194,9 @@ impl MainView { UrlBar::new(input) .method_dropdown(method_dropdown, request) .loading(is_loading) - .on_send(move |_, _, cx| { + .on_send(move |_, window, cx| { this_for_send.update(cx, |view, cx| { - view.send_request(cx); + view.send_request(window, cx); }); }) .on_cancel(move |_, _, cx| { @@ -4125,7 +4467,18 @@ impl MainView { menu }), ) - .child(ProtocolSelector::new(ProtocolType::Rest)), + .child({ + let selected = self + .active_tab() + .map(|tab| tab.protocol()) + .unwrap_or(ProtocolType::Rest); + let this_for_protocol = this_for_layout.clone(); + ProtocolSelector::new(selected).on_change(move |protocol, window, cx| { + this_for_protocol.update(cx, |view, cx| { + view.set_active_protocol(protocol, window, cx); + }); + }) + }), ) .child( div() diff --git a/src/views/mod.rs b/src/views/mod.rs index 2353218..c67d50d 100644 --- a/src/views/mod.rs +++ b/src/views/mod.rs @@ -3,6 +3,7 @@ mod environment_view; mod main_view; mod request_view; mod response_view; +pub mod websocket_view; pub use command_palette::*; pub use main_view::*; diff --git a/src/views/websocket_view.rs b/src/views/websocket_view.rs new file mode 100644 index 0000000..24ae6ce --- /dev/null +++ b/src/views/websocket_view.rs @@ -0,0 +1,1073 @@ +//! Full WebSocket request panel: URL bar, config tabs, status, timeline, composer. + +use gpui::prelude::*; +use gpui::{ + App, Context, Entity, FocusHandle, Focusable, IntoElement, Render, SharedString, Styled, + UniformListScrollHandle, Window, div, px, +}; +use gpui_component::ActiveTheme; +use gpui_component::Icon; +use gpui_component::Sizable; +use gpui_component::WindowExt; +use gpui_component::button::{Button, ButtonVariants}; +use gpui_component::input::{Input, InputState}; +use gpui_component::notification::NotificationType; +use gpui_component::resizable::{ResizableState, resizable_panel, v_resizable}; +use tokio::sync::mpsc; + +use crate::completion::{ + CompletionContext, CompletionEngine, CompletionInput, configure_completion, +}; +use crate::components::{ + AuthConfig, AuthEditor, AuthType, ComposerPayload, HeaderEditor, PanelTab, PanelTabBar, + ParamsEditor, QueryParam, WebSocketComposer, WebSocketComposerEvent, WebSocketMessageList, + WebSocketStatusBar, +}; +use crate::entities::{ + AuthConfigData, AuthTypeData, EnvironmentsEntity, Header, HistoryEntity, QueryParamData, + WebSocketConnectionState, WebSocketHistoryResult, WebSocketRequestData, WebSocketSessionState, + append_query_params, parse_subprotocols, validate_ws_url, +}; +use crate::icons::IconName; +use crate::websocket::{WebSocketClient, WebSocketConnectConfig, WebSocketEvent, WebSocketSession}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +enum WebSocketTab { + #[default] + Messages, + Params, + Headers, + Auth, + Settings, +} + +struct ResolvedWebSocketRequest { + url: String, + headers: Vec
, + params: Vec, + auth: AuthConfigData, + subprotocols: Vec, +} + +pub struct WebSocketView { + request: WebSocketRequestData, + url_input: Option>, + subprotocol_input: Option>, + active_tab: WebSocketTab, + params_editor: Option>, + header_editor: Option>, + auth_editor: Option>, + composer: Entity, + session: WebSocketSessionState, + connection: Option, + message_scroll: UniformListScrollHandle, + message_composer_split: Entity, + focus_handle: FocusHandle, + completion_engine: CompletionEngine, + environments: Entity, + history: Entity, + collection_id: Option, + pending_history_request: Option<(u64, WebSocketRequestData)>, + active_history_entry: Option<(u64, uuid::Uuid)>, + history_start_sequence: u64, + history_unsaved_messages: usize, + last_error_banner: Option, + pending_composer_flush: bool, +} + +impl WebSocketView { + pub fn new( + request: WebSocketRequestData, + environments: Entity, + history: Entity, + collection_id: Option, + completion_engine: CompletionEngine, + initial_history: Option, + cx: &mut Context, + ) -> Self { + let composer = cx.new(|cx| WebSocketComposer::new(Some(completion_engine.clone()), cx)); + let message_composer_split = cx.new(|_| ResizableState::default()); + cx.subscribe(&composer, |this, _composer, event, cx| { + if matches!(event, WebSocketComposerEvent::SendRequested) { + // Flush is done on next render with window access. + this.pending_composer_flush = true; + cx.notify(); + } + }) + .detach(); + let mut session = WebSocketSessionState::default(); + if let Some(result) = initial_history.as_ref() { + session.restore_history(result); + } + Self { + request, + url_input: None, + subprotocol_input: None, + active_tab: WebSocketTab::Messages, + params_editor: None, + header_editor: None, + auth_editor: None, + composer, + session, + connection: None, + message_scroll: UniformListScrollHandle::new(), + message_composer_split, + focus_handle: cx.focus_handle(), + completion_engine, + environments, + history, + collection_id, + pending_history_request: None, + active_history_entry: None, + history_start_sequence: 1, + history_unsaved_messages: 0, + last_error_banner: None, + pending_composer_flush: false, + } + } + + pub fn connection_state(&self) -> WebSocketConnectionState { + self.session.connection_state + } + + pub fn current_url(&self, cx: &App) -> String { + self.url_input + .as_ref() + .map(|input| input.read(cx).text().to_string()) + .unwrap_or_else(|| self.request.url.clone()) + } + + pub fn display_name(&self, cx: &App) -> String { + let mut request = self.request.clone(); + request.url = self.current_url(cx); + request.display_name() + } + + pub fn focus_url(&mut self, window: &mut Window, cx: &mut Context) { + self.ensure_url_input(window, cx); + if let Some(input) = self.url_input.as_ref() { + input.update(cx, |state, cx| state.focus(window, cx)); + } + } + + /// Snapshot current editor state into the persisted request config. + pub fn snapshot_request(&mut self, cx: &App) -> WebSocketRequestData { + if let Some(url) = &self.url_input { + self.request.url = url.read(cx).text().to_string(); + } + if let Some(params) = &self.params_editor { + self.request.params = params + .read(cx) + .get_params(cx) + .into_iter() + .map(|p: QueryParam| QueryParamData { + key: p.key, + value: p.value, + enabled: p.enabled, + }) + .collect(); + } + if let Some(headers) = &self.header_editor { + self.request.headers = headers.read(cx).get_headers(cx); + } + if let Some(auth) = &self.auth_editor { + self.request.auth = auth_config_to_data(&auth.read(cx).get_config(cx)); + } + if let Some(sub) = &self.subprotocol_input { + self.request.subprotocols = + crate::entities::parse_subprotocols(&sub.read(cx).text().to_string()); + } + self.request.clone() + } + + pub fn primary_action(&mut self, window: &mut Window, cx: &mut Context) { + match self.session.connection_state { + WebSocketConnectionState::Disconnected => self.connect(window, cx), + WebSocketConnectionState::Connecting => self.cancel_connect(cx), + WebSocketConnectionState::Connected | WebSocketConnectionState::Closing => { + self.disconnect(cx) + } + } + } + + pub fn connect(&mut self, window: &mut Window, cx: &mut Context) { + if !self.session.connection_state.can_connect() { + return; + } + + let request = self.snapshot_request(cx); + let base_url = request.url.trim().to_string(); + if let Err(err) = validate_ws_url(&base_url) { + self.last_error_banner = Some(err.clone()); + window.push_notification((NotificationType::Warning, SharedString::from(err)), cx); + cx.notify(); + return; + } + + // Resolve environment variables just-in-time (never persist resolved secrets). + let resolved = match self.resolve_for_connect(&request, cx) { + Ok(v) => v, + Err(err) => { + self.last_error_banner = Some(err.clone()); + window.push_notification((NotificationType::Error, SharedString::from(err)), cx); + cx.notify(); + return; + } + }; + + let generation = self.session.begin_connect(); + self.pending_history_request = Some((generation, request.clone())); + self.active_history_entry = None; + self.history_start_sequence = self.session.next_sequence; + self.history_unsaved_messages = 0; + self.last_error_banner = None; + // Drop any previous session. + if let Some(mut old) = self.connection.take() { + old.abort(); + } + + let config = WebSocketConnectConfig { + url: resolved.url, + headers: resolved.headers, + params: resolved.params, + auth: resolved.auth, + subprotocols: resolved.subprotocols, + generation, + ..Default::default() + }; + + let (session, event_rx) = WebSocketClient::connect(config); + self.connection = Some(session); + self.composer.update(cx, |c, cx| c.set_can_send(false, cx)); + self.spawn_event_loop(generation, event_rx, cx); + cx.notify(); + } + + fn resolve_for_connect( + &self, + request: &WebSocketRequestData, + cx: &App, + ) -> Result { + let env = self.environments.read(cx); + let url = env + .resolve_value(self.collection_id, &request.url) + .map_err(|e| e.user_message())?; + let headers = env + .resolve_headers(self.collection_id, &request.headers) + .map_err(|e| e.user_message())?; + + let mut params = Vec::new(); + for p in &request.params { + let key = env + .resolve_value(self.collection_id, &p.key) + .map_err(|e| e.user_message())?; + let value = env + .resolve_value(self.collection_id, &p.value) + .map_err(|e| e.user_message())?; + params.push(QueryParamData { + key, + value, + enabled: p.enabled, + }); + } + + let auth = resolve_auth(&env, self.collection_id, &request.auth)?; + let subprotocols = resolve_subprotocol_templates(&request.subprotocols, |protocol| { + env.resolve_value(self.collection_id, protocol) + .map_err(|e| e.user_message()) + })?; + // Build full URL for validation display (transport also appends params). + let _full = append_query_params(url.trim(), ¶ms); + Ok(ResolvedWebSocketRequest { + url, + headers, + params, + auth, + subprotocols, + }) + } + + fn spawn_event_loop( + &self, + generation: u64, + mut event_rx: mpsc::Receiver, + cx: &mut Context, + ) { + cx.spawn(async move |view, cx| { + while let Some(event) = event_rx.recv().await { + let should_continue = cx.update(|app| { + view.update(app, |this, cx| { + if !this.session.is_current_generation(generation) { + return false; + } + this.handle_event(event, cx); + true + }) + .unwrap_or(false) + }); + // `AsyncApp::update` yields the closure result directly in this GPUI version. + if !should_continue { + break; + } + } + }) + .detach(); + } + + fn handle_event(&mut self, event: WebSocketEvent, cx: &mut Context) { + let generation = self.session.generation; + let message_count_before = self.session.messages.len(); + let should_scroll = matches!( + event, + WebSocketEvent::Connected(_) + | WebSocketEvent::Sent(_) + | WebSocketEvent::Message(_) + | WebSocketEvent::Pong(_) + | WebSocketEvent::Closed(_) + | WebSocketEvent::Error(_) + ); + let was_near_bottom = if message_count_before == 0 || !self.message_scroll.is_scrollable() { + true + } else { + self.message_scroll + .0 + .borrow() + .base_handle + .bottom_item() + .saturating_add(2) + >= message_count_before + }; + match event { + WebSocketEvent::Connected(meta) => { + let mut result = WebSocketHistoryResult::connected(&meta); + self.session.on_connected(generation, meta); + self.copy_current_transcript(&mut result); + self.record_history(generation, result, cx); + self.active_tab = WebSocketTab::Messages; + self.composer.update(cx, |c, cx| c.set_can_send(true, cx)); + } + WebSocketEvent::Sent(payload) => { + self.session.on_sent(generation, payload); + self.append_latest_history_message(false, cx); + } + WebSocketEvent::Message(payload) => { + self.session.on_message(generation, payload); + self.append_latest_history_message(false, cx); + } + WebSocketEvent::Pong(data) => { + self.session.on_pong(generation, data); + self.append_latest_history_message(false, cx); + } + WebSocketEvent::Closed(meta) => { + self.session.on_closed(generation, meta); + self.append_latest_history_message(true, cx); + self.connection = None; + self.composer.update(cx, |c, cx| c.set_can_send(false, cx)); + } + WebSocketEvent::Error(err) => { + if err.kind == crate::websocket::WebSocketErrorKind::Cancelled { + self.discard_pending_history(generation); + self.session.on_cancelled(generation); + } else { + let is_connect_failure = self + .pending_history_request + .as_ref() + .is_some_and(|(pending_generation, _)| *pending_generation == generation); + self.session.on_error(generation, err.message.clone()); + if is_connect_failure { + let mut result = WebSocketHistoryResult::failed(err.message.clone()); + self.copy_current_transcript(&mut result); + self.record_history(generation, result, cx); + } else { + self.append_latest_history_message(true, cx); + } + self.last_error_banner = Some(err.message); + } + self.connection = None; + self.composer.update(cx, |c, cx| c.set_can_send(false, cx)); + } + } + if should_scroll && was_near_bottom { + self.message_scroll.scroll_to_bottom(); + } + cx.notify(); + } + + pub fn cancel_connect(&mut self, cx: &mut Context) { + if let Some(session) = &self.connection { + let _ = session.disconnect(); + } + // Advance generation so late connect results are ignored. + let generation = self.session.generation; + self.discard_pending_history(generation); + self.session.on_cancelled(generation); + self.session.generation = self.session.generation.wrapping_add(1); + if let Some(mut session) = self.connection.take() { + session.abort(); + } + self.composer.update(cx, |c, cx| c.set_can_send(false, cx)); + cx.notify(); + } + + pub fn disconnect(&mut self, cx: &mut Context) { + let generation = self.session.generation; + self.session.on_closing(generation); + let disconnect_result = self + .connection + .as_ref() + .map(WebSocketSession::disconnect) + .unwrap_or_else(|| { + Err(crate::websocket::WebSocketError::connection( + "Session ended", + )) + }); + if let Err(error) = disconnect_result { + if let Some(mut session) = self.connection.take() { + session.abort(); + } + self.session.on_error(generation, error.message); + self.composer + .update(cx, |composer, cx| composer.set_can_send(false, cx)); + } + cx.notify(); + } + + pub fn clear_messages(&mut self, cx: &mut Context) { + self.session.clear_messages(); + if let Some((_, entry_id)) = self.active_history_entry { + self.history.update(cx, |history, cx| { + history.clear_websocket_messages(entry_id, cx); + }); + self.history_unsaved_messages = 0; + } + cx.notify(); + } + + fn record_history( + &mut self, + generation: u64, + result: WebSocketHistoryResult, + cx: &mut Context, + ) { + let Some((pending_generation, _)) = self.pending_history_request.as_ref() else { + return; + }; + if *pending_generation != generation { + return; + } + let Some((_, request)) = self.pending_history_request.take() else { + return; + }; + let entry_id = self.history.update(cx, |history, cx| { + history.add_websocket_entry(request, result, cx) + }); + self.active_history_entry = Some((generation, entry_id)); + self.history_unsaved_messages = 0; + } + + fn copy_current_transcript(&self, result: &mut WebSocketHistoryResult) { + for message in self + .session + .messages + .iter() + .filter(|message| message.sequence >= self.history_start_sequence) + { + result.append_message(message); + } + } + + fn append_latest_history_message(&mut self, terminal: bool, cx: &mut Context) { + let Some((generation, entry_id)) = self.active_history_entry else { + return; + }; + if generation != self.session.generation { + return; + } + let Some(message) = self.session.messages.back().cloned() else { + return; + }; + if message.sequence < self.history_start_sequence { + return; + } + + let persist = terminal || self.history_unsaved_messages >= 9; + let appended = self.history.update(cx, |history, cx| { + history.append_websocket_message(entry_id, &message, persist, cx) + }); + if persist { + self.history_unsaved_messages = 0; + } else if appended { + self.history_unsaved_messages += 1; + } + } + + fn flush_history(&mut self, cx: &mut Context) { + if self.history_unsaved_messages == 0 { + return; + } + let Some((_, entry_id)) = self.active_history_entry else { + return; + }; + self.history.update(cx, |history, cx| { + history.persist_websocket_entry(entry_id, cx); + }); + self.history_unsaved_messages = 0; + } + + fn discard_pending_history(&mut self, generation: u64) { + if self + .pending_history_request + .as_ref() + .is_some_and(|(pending_generation, _)| *pending_generation == generation) + { + self.pending_history_request = None; + } + } + + pub fn send_from_composer(&mut self, window: &mut Window, cx: &mut Context) { + self.composer.update(cx, |c, cx| c.queue_send(cx)); + self.flush_composer(window, cx); + } + + fn flush_composer(&mut self, window: &mut Window, cx: &mut Context) { + let Some(payload) = self.composer.update(cx, |c, _| c.take_pending_send()) else { + return; + }; + + if !self.session.connection_state.can_send() { + return; + } + + match payload { + ComposerPayload::Invalid(err) => { + window.push_notification((NotificationType::Warning, SharedString::from(err)), cx); + } + ComposerPayload::Text(text) => { + let resolved = self + .environments + .read(cx) + .resolve_value(self.collection_id, &text) + .map_err(|e| e.user_message()); + match resolved { + Ok(resolved_text) => { + if let Some(session) = &self.connection { + match session.send_text(resolved_text) { + Ok(()) => self.composer.update(cx, |composer, cx| { + composer.mark_send_accepted(window, cx); + }), + Err(err) => window.push_notification( + (NotificationType::Error, SharedString::from(err.message)), + cx, + ), + } + } + } + Err(err) => { + window.push_notification( + (NotificationType::Error, SharedString::from(err)), + cx, + ); + } + } + } + ComposerPayload::Binary(bytes) => { + if let Some(session) = &self.connection { + match session.send_binary(bytes) { + Ok(()) => self.composer.update(cx, |composer, cx| { + composer.mark_send_accepted(window, cx); + }), + Err(err) => window.push_notification( + (NotificationType::Error, SharedString::from(err.message)), + cx, + ), + } + } + } + } + } + + pub fn terminate(&mut self, cx: &mut Context) { + self.flush_history(cx); + self.abort_connection(); + } + + fn abort_connection(&mut self) { + if let Some(mut session) = self.connection.take() { + session.abort(); + } + } + + fn ensure_url_input(&mut self, window: &mut Window, cx: &mut Context) { + if self.url_input.is_none() { + let completion_engine = self.completion_engine.clone(); + let url = self.request.url.clone(); + self.url_input = Some(cx.new(|cx| { + configure_completion( + InputState::new(window, cx) + .placeholder("ws://localhost:9001/socket") + .default_value(&url), + Some(&completion_engine), + CompletionContext::Url, + ) + })); + } + } + + fn ensure_config_editors(&mut self, window: &mut Window, cx: &mut Context) { + let completion_engine = self.completion_engine.clone(); + if self.params_editor.is_none() { + let params = self.request.params.clone(); + self.params_editor = Some(cx.new(|cx| { + let mut editor = ParamsEditor::new(Some(completion_engine.clone()), cx); + if !params.is_empty() { + let list: Vec<(String, String, bool)> = params + .iter() + .map(|p| (p.key.clone(), p.value.clone(), p.enabled)) + .collect(); + editor.set_params(&list, window, cx); + } + editor + })); + } + if self.header_editor.is_none() { + let headers = self.request.headers.clone(); + self.header_editor = Some(cx.new(|cx| { + HeaderEditor::new_standalone(headers, Some(completion_engine.clone()), cx) + })); + } + if self.auth_editor.is_none() { + let auth = auth_data_to_config(&self.request.auth); + self.auth_editor = Some(cx.new(|cx| { + let mut editor = AuthEditor::new(window, Some(completion_engine.clone()), cx); + editor.load_config(&auth, window, cx); + editor + })); + } + if self.subprotocol_input.is_none() { + let value = self.request.subprotocols.join(", "); + self.subprotocol_input = Some(cx.new(|cx| { + InputState::new(window, cx) + .placeholder("graphql-ws, chat") + .default_value(&value) + })); + } + } +} + +impl Drop for WebSocketView { + fn drop(&mut self) { + self.abort_connection(); + } +} + +impl Focusable for WebSocketView { + fn focus_handle(&self, _cx: &App) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl Render for WebSocketView { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + self.ensure_url_input(window, cx); + self.ensure_config_editors(window, cx); + + if self.pending_composer_flush { + self.pending_composer_flush = false; + self.flush_composer(window, cx); + } + + let theme = cx.theme(); + let state = self.session.connection_state; + let this = cx.entity().clone(); + let this_connect = this.clone(); + let this_clear = this.clone(); + let has_messages = !self.session.messages.is_empty(); + let active_tab = self.active_tab; + + let tab_content = match active_tab { + WebSocketTab::Messages => div() + .flex() + .flex_col() + .flex_1() + .min_h_0() + .overflow_hidden() + .child( + div() + .flex() + .items_center() + .justify_between() + .h(px(34.0)) + .px(px(12.0)) + .flex_shrink_0() + .bg(theme.secondary) + .border_b_1() + .border_color(theme.border) + .child(WebSocketStatusBar::new(&self.session)) + .when(has_messages, |el| { + el.child( + Button::new("ws-clear") + .ghost() + .xsmall() + .label("Clear") + .icon(Icon::new(IconName::Trash).size(px(12.0))) + .tooltip("Clear message timeline") + .on_click(move |_, _window, cx| { + this_clear.update(cx, |view, cx| view.clear_messages(cx)); + }), + ) + }), + ) + .child( + div() + .flex() + .flex_1() + .min_h_0() + .w_full() + .overflow_hidden() + .child( + v_resizable("websocket-message-composer-split") + .with_state(&self.message_composer_split) + .child( + resizable_panel() + .size_range(px(120.0)..gpui::Pixels::MAX) + .child( + div() + .flex() + .flex_col() + .size_full() + .overflow_hidden() + .child(WebSocketMessageList::new( + self.session.messages.clone(), + self.message_scroll.clone(), + )), + ), + ) + .child( + resizable_panel() + .size(px(178.0)) + .size_range(px(110.0)..px(420.0)) + .child(self.composer.clone()), + ), + ), + ) + .into_any_element(), + WebSocketTab::Params => div() + .flex() + .flex_col() + .flex_1() + .min_h_0() + .overflow_hidden() + .when_some(self.params_editor.as_ref(), |el, editor| { + el.child(editor.clone()) + }) + .into_any_element(), + WebSocketTab::Headers => div() + .flex() + .flex_col() + .flex_1() + .min_h_0() + .overflow_hidden() + .when_some(self.header_editor.as_ref(), |el, editor| { + el.child(editor.clone()) + }) + .into_any_element(), + WebSocketTab::Auth => div() + .flex() + .flex_col() + .flex_1() + .min_h_0() + .overflow_hidden() + .when_some(self.auth_editor.as_ref(), |el, editor| { + el.child(editor.clone()) + }) + .into_any_element(), + WebSocketTab::Settings => div() + .flex() + .flex_col() + .flex_1() + .min_h_0() + .overflow_hidden() + .p(px(16.0)) + .child( + div() + .w_full() + .max_w(px(680.0)) + .flex() + .flex_col() + .gap(px(7.0)) + .child( + div() + .text_size(px(11.0)) + .font_weight(gpui::FontWeight::MEDIUM) + .child("WebSocket subprotocols"), + ) + .child( + div() + .h(px(36.0)) + .px(px(9.0)) + .bg(theme.muted) + .rounded(px(5.0)) + .flex() + .items_center() + .when_some(self.subprotocol_input.as_ref(), |el, input| { + el.child(Input::new(input).appearance(false).size_full()) + }), + ) + .child( + div() + .text_size(px(9.0)) + .text_color(theme.muted_foreground) + .child("Comma-separated, for example: graphql-ws, chat"), + ), + ) + .into_any_element(), + }; + + div() + .id("websocket-view") + .track_focus(&self.focus_handle) + .flex() + .flex_col() + .size_full() + .overflow_hidden() + .bg(theme.background) + .child( + div() + .flex() + .items_center() + .gap(px(8.0)) + .px(px(12.0)) + .py(px(8.0)) + .child( + div() + .flex() + .flex_1() + .items_center() + .h(px(36.0)) + .px(px(4.0)) + .bg(theme.muted) + .rounded(px(6.0)) + .child( + div() + .px(px(10.0)) + .text_size(px(10.0)) + .font_weight(gpui::FontWeight::BOLD) + .text_color(theme.primary) + .child("WS"), + ) + .child(div().w(px(1.0)).h(px(18.0)).bg(theme.border)) + .child(div().flex_1().px(px(8.0)).when_some( + self.url_input.as_ref(), + |el, input| { + el.child(CompletionInput::new( + input, + Input::new(input).appearance(false).size_full(), + )) + }, + )), + ) + .child({ + let button = Button::new("ws-connect") + .w(px(104.0)) + .label(state.button_label()) + .on_click(move |_, window, cx| { + this_connect.update(cx, |view, cx| { + view.primary_action(window, cx); + }); + }); + match state { + WebSocketConnectionState::Disconnected => button.primary(), + WebSocketConnectionState::Connecting => button.danger(), + WebSocketConnectionState::Connected + | WebSocketConnectionState::Closing => button, + } + }), + ) + .when_some(self.last_error_banner.clone(), |el, error| { + el.child( + div() + .flex() + .items_center() + .gap(px(7.0)) + .mx(px(12.0)) + .mb(px(8.0)) + .px(px(9.0)) + .py(px(6.0)) + .rounded(px(5.0)) + .bg(theme.danger.opacity(0.1)) + .border_1() + .border_color(theme.danger.opacity(0.35)) + .text_color(theme.danger) + .child(Icon::new(IconName::TriangleAlert).size(px(13.0))) + .child( + div() + .flex_1() + .overflow_hidden() + .whitespace_nowrap() + .text_size(px(10.0)) + .child(error), + ), + ) + }) + .child( + PanelTabBar::new() + .child({ + let this = this.clone(); + PanelTab::new("Messages") + .active(active_tab == WebSocketTab::Messages) + .on_click(move |_, _, cx| { + this.update(cx, |view, cx| { + view.active_tab = WebSocketTab::Messages; + cx.notify(); + }); + }) + }) + .child({ + let this = this.clone(); + PanelTab::new("Params") + .active(active_tab == WebSocketTab::Params) + .on_click(move |_, _, cx| { + this.update(cx, |view, cx| { + view.active_tab = WebSocketTab::Params; + cx.notify(); + }); + }) + }) + .child({ + let this = this.clone(); + PanelTab::new("Headers") + .active(active_tab == WebSocketTab::Headers) + .on_click(move |_, _, cx| { + this.update(cx, |view, cx| { + view.active_tab = WebSocketTab::Headers; + cx.notify(); + }); + }) + }) + .child({ + let this = this.clone(); + PanelTab::new("Auth") + .active(active_tab == WebSocketTab::Auth) + .on_click(move |_, _, cx| { + this.update(cx, |view, cx| { + view.active_tab = WebSocketTab::Auth; + cx.notify(); + }); + }) + }) + .child({ + let this = this.clone(); + PanelTab::new("Settings") + .active(active_tab == WebSocketTab::Settings) + .on_click(move |_, _, cx| { + this.update(cx, |view, cx| { + view.active_tab = WebSocketTab::Settings; + cx.notify(); + }); + }) + }), + ) + .child(tab_content) + } +} + +fn auth_config_to_data(config: &AuthConfig) -> AuthConfigData { + AuthConfigData { + auth_type: match config.auth_type { + AuthType::None => AuthTypeData::None, + AuthType::Basic => AuthTypeData::Basic, + AuthType::Bearer => AuthTypeData::Bearer, + AuthType::ApiKey => AuthTypeData::ApiKey, + }, + username: config.username.clone(), + password: config.password.clone(), + token: config.token.clone(), + api_key_name: config.api_key_name.clone(), + api_key_value: config.api_key_value.clone(), + api_key_in_header: config.api_key_in_header, + } +} + +fn auth_data_to_config(data: &AuthConfigData) -> AuthConfig { + AuthConfig { + auth_type: match data.auth_type { + AuthTypeData::None => AuthType::None, + AuthTypeData::Basic => AuthType::Basic, + AuthTypeData::Bearer => AuthType::Bearer, + AuthTypeData::ApiKey => AuthType::ApiKey, + }, + username: data.username.clone(), + password: data.password.clone(), + token: data.token.clone(), + api_key_name: data.api_key_name.clone(), + api_key_value: data.api_key_value.clone(), + api_key_in_header: data.api_key_in_header, + } +} + +fn resolve_auth( + env: &EnvironmentsEntity, + collection_id: Option, + auth: &AuthConfigData, +) -> Result { + Ok(AuthConfigData { + auth_type: auth.auth_type, + username: env + .resolve_value(collection_id, &auth.username) + .map_err(|e| e.user_message())?, + password: env + .resolve_value(collection_id, &auth.password) + .map_err(|e| e.user_message())?, + token: env + .resolve_value(collection_id, &auth.token) + .map_err(|e| e.user_message())?, + api_key_name: env + .resolve_value(collection_id, &auth.api_key_name) + .map_err(|e| e.user_message())?, + api_key_value: env + .resolve_value(collection_id, &auth.api_key_value) + .map_err(|e| e.user_message())?, + api_key_in_header: auth.api_key_in_header, + }) +} + +fn resolve_subprotocol_templates( + templates: &[String], + mut resolve: impl FnMut(&str) -> Result, +) -> Result, E> { + let resolved = templates + .iter() + .map(|template| resolve(template)) + .collect::, _>>()?; + Ok(parse_subprotocols(&resolved.join(","))) +} + +#[cfg(test)] +mod tests { + use super::resolve_subprotocol_templates; + + #[test] + fn resolves_subprotocol_templates_before_parsing() { + let templates = vec!["{{ws_protocols}}".to_string(), "chat".to_string()]; + + let protocols = resolve_subprotocol_templates(&templates, |template| match template { + "{{ws_protocols}}" => Ok::<_, String>("graphql-ws, chat".to_string()), + value => Ok(value.to_string()), + }) + .expect("subprotocol templates should resolve"); + + assert_eq!(protocols, vec!["graphql-ws", "chat"]); + } + + #[test] + fn rejects_unresolved_subprotocol_templates() { + let templates = vec!["{{missing_protocol}}".to_string()]; + + let error = resolve_subprotocol_templates(&templates, |_| { + Err::("Unresolved variable: missing_protocol".to_string()) + }) + .expect_err("resolution errors must be returned before transport validation"); + + assert_eq!(error, "Unresolved variable: missing_protocol"); + } +} diff --git a/src/websocket/client.rs b/src/websocket/client.rs new file mode 100644 index 0000000..20f30a8 --- /dev/null +++ b/src/websocket/client.rs @@ -0,0 +1,950 @@ +//! Standalone WebSocket transport client + +use std::time::{Duration, Instant}; + +use bytes::Bytes; +use futures_util::{SinkExt, StreamExt}; +use tokio::sync::mpsc; +use tokio::task::JoinHandle; +use tokio_tungstenite::tungstenite::client::IntoClientRequest; +use tokio_tungstenite::tungstenite::http::{HeaderName, HeaderValue}; +use tokio_tungstenite::tungstenite::protocol::frame::coding::CloseCode; +use tokio_tungstenite::tungstenite::protocol::{CloseFrame, Message, WebSocketConfig}; +use tokio_tungstenite::{MaybeTlsStream, WebSocketStream, connect_async_with_config}; + +use crate::entities::{ + CloseMetadata, HandshakeMetadata, WebSocketPayload, append_query_params, validate_ws_url, +}; +use crate::utils::shared_tokio_runtime; + +use super::types::{ + CLOSE_TIMEOUT_MS, COMMAND_CHANNEL_CAPACITY, EVENT_CHANNEL_CAPACITY, WebSocketCommand, + WebSocketConnectConfig, WebSocketError, WebSocketErrorKind, WebSocketEvent, apply_auth, +}; + +type WsStream = WebSocketStream>; + +/// Handle for an active WebSocket session. Dropping disconnects. +pub struct WebSocketSession { + command_tx: Option>, + task: Option>, + pub generation: u64, +} + +impl WebSocketSession { + pub fn send(&self, command: WebSocketCommand) -> Result<(), WebSocketError> { + let Some(tx) = self.command_tx.as_ref() else { + return Err(WebSocketError::connection("Session is closed")); + }; + tx.try_send(command).map_err(|e| match e { + mpsc::error::TrySendError::Full(_) => { + WebSocketError::new(WebSocketErrorKind::Other, "Send buffer full; try again") + } + mpsc::error::TrySendError::Closed(_) => WebSocketError::connection("Session is closed"), + }) + } + + pub fn send_text(&self, text: impl Into) -> Result<(), WebSocketError> { + self.send(WebSocketCommand::SendText(text.into())) + } + + pub fn send_binary(&self, data: Bytes) -> Result<(), WebSocketError> { + self.send(WebSocketCommand::SendBinary(data)) + } + + pub fn ping(&self, data: Bytes) -> Result<(), WebSocketError> { + self.send(WebSocketCommand::Ping(data)) + } + + pub fn disconnect(&self) -> Result<(), WebSocketError> { + self.send(WebSocketCommand::Disconnect) + } + + /// Abort the background task immediately (e.g. tab closed). + pub fn abort(&mut self) { + self.command_tx.take(); + if let Some(task) = self.task.take() { + task.abort(); + } + } +} + +impl Drop for WebSocketSession { + fn drop(&mut self) { + self.abort(); + } +} + +/// WebSocket client entry point. +pub struct WebSocketClient; + +impl WebSocketClient { + /// Connect asynchronously and return a session handle plus event receiver. + pub fn connect( + config: WebSocketConnectConfig, + ) -> (WebSocketSession, mpsc::Receiver) { + let (command_tx, command_rx) = mpsc::channel(COMMAND_CHANNEL_CAPACITY); + let (event_tx, event_rx) = mpsc::channel(EVENT_CHANNEL_CAPACITY); + let generation = config.generation; + + let runtime = shared_tokio_runtime(); + let task = runtime.spawn(async move { + run_session(config, command_rx, event_tx).await; + }); + + ( + WebSocketSession { + command_tx: Some(command_tx), + task: Some(task), + generation, + }, + event_rx, + ) + } +} + +async fn run_session( + config: WebSocketConnectConfig, + mut command_rx: mpsc::Receiver, + event_tx: mpsc::Sender, +) { + let started = Instant::now(); + + let prepared = match prepare_request(&config) { + Ok(r) => r, + Err(err) => { + let _ = event_tx.send(WebSocketEvent::Error(err)).await; + return; + } + }; + + // Race connect against Disconnect/cancel from the UI. + let connect_result = tokio::select! { + biased; + cmd = command_rx.recv() => { + let _ = cmd; + let _ = event_tx + .send(WebSocketEvent::Error(WebSocketError::cancelled())) + .await; + return; + } + result = tokio::time::timeout( + Duration::from_millis(config.connect_timeout_ms), + connect_async_with_config(prepared.request, Some(prepared.ws_config), false), + ) => { + result + } + }; + + let (mut ws, response) = match connect_result { + Ok(Ok(pair)) => pair, + Err(_) => { + let _ = event_tx + .send(WebSocketEvent::Error(WebSocketError::connection( + "Connection timed out", + ))) + .await; + return; + } + Ok(Err(err)) => { + let message = sanitize_error(&err.to_string()); + let kind = if message.contains("Connection refused") + || message.contains("connection refused") + { + WebSocketErrorKind::Connection + } else if message.contains("404") + || message.contains("401") + || message.contains("403") + || message.contains("HTTP error") + { + WebSocketErrorKind::Handshake + } else { + WebSocketErrorKind::Connection + }; + let _ = event_tx + .send(WebSocketEvent::Error(WebSocketError::new(kind, message))) + .await; + return; + } + }; + + let duration_ms = started.elapsed().as_millis() as u64; + let selected_subprotocol = response + .headers() + .get("sec-websocket-protocol") + .and_then(|v| v.to_str().ok()) + .map(|s| s.to_string()); + + let response_headers: Vec<(String, String)> = response + .headers() + .iter() + .filter_map(|(k, v)| { + let key = k.as_str().to_string(); + // Never surface authorization echo headers with secrets in UI logs. + if key.eq_ignore_ascii_case("authorization") { + return None; + } + v.to_str().ok().map(|val| (key, val.to_string())) + }) + .collect(); + + let meta = HandshakeMetadata { + duration_ms, + response_headers, + selected_subprotocol, + status_code: Some(response.status().as_u16()), + }; + + if event_tx + .send(WebSocketEvent::Connected(meta)) + .await + .is_err() + { + return; + } + + loop { + tokio::select! { + cmd = command_rx.recv() => { + match cmd { + None | Some(WebSocketCommand::Disconnect) => { + let _ = graceful_close(&mut ws).await; + let _ = event_tx.send(WebSocketEvent::Closed(CloseMetadata { + code: Some(1000), + reason: "Client disconnected".into(), + remote: false, + })).await; + return; + } + Some(WebSocketCommand::SendText(text)) => { + let payload = WebSocketPayload::Text(text); + let message = match &payload { + WebSocketPayload::Text(text) => Message::Text(text.clone().into()), + _ => unreachable!(), + }; + if let Err(err) = ws.send(message).await { + let _ = event_tx.send(WebSocketEvent::Error( + WebSocketError::connection(sanitize_error(&err.to_string())) + )).await; + return; + } + if event_tx.send(WebSocketEvent::Sent(payload)).await.is_err() { + return; + } + } + Some(WebSocketCommand::SendBinary(data)) => { + if let Err(err) = ws.send(Message::Binary(data.clone())).await { + let _ = event_tx.send(WebSocketEvent::Error( + WebSocketError::connection(sanitize_error(&err.to_string())) + )).await; + return; + } + if event_tx + .send(WebSocketEvent::Sent(WebSocketPayload::Binary(data))) + .await + .is_err() + { + return; + } + } + Some(WebSocketCommand::Ping(data)) => { + if let Err(err) = ws.send(Message::Ping(data)).await { + let _ = event_tx.send(WebSocketEvent::Error( + WebSocketError::connection(sanitize_error(&err.to_string())) + )).await; + return; + } + } + } + } + msg = ws.next() => { + match msg { + Some(Ok(Message::Text(text))) => { + let payload = WebSocketPayload::Text(text.to_string()); + if event_tx.send(WebSocketEvent::Message(payload)).await.is_err() { + return; + } + } + Some(Ok(Message::Binary(data))) => { + let payload = WebSocketPayload::Binary(data); + if event_tx.send(WebSocketEvent::Message(payload)).await.is_err() { + return; + } + } + Some(Ok(Message::Ping(data))) => { + // Tungstenite auto-replies with pong; surface for the timeline. + let payload = WebSocketPayload::Ping(data.clone()); + if event_tx.send(WebSocketEvent::Message(payload)).await.is_err() { + return; + } + } + Some(Ok(Message::Pong(data))) => { + if event_tx.send(WebSocketEvent::Pong(data)).await.is_err() { + return; + } + } + Some(Ok(Message::Close(frame))) => { + let meta = close_meta_from_frame(frame, true); + let _ = event_tx.send(WebSocketEvent::Closed(meta)).await; + return; + } + Some(Ok(Message::Frame(_))) => { + // Raw frames are not expected with the default config. + } + Some(Err(err)) => { + let message = sanitize_error(&err.to_string()); + let _ = event_tx.send(WebSocketEvent::Error( + WebSocketError::connection(message) + )).await; + return; + } + None => { + let _ = event_tx.send(WebSocketEvent::Closed(CloseMetadata { + code: None, + reason: "Connection closed".into(), + remote: true, + })).await; + return; + } + } + } + } + } +} + +#[derive(Debug)] +struct PreparedRequest { + request: tokio_tungstenite::tungstenite::http::Request<()>, + ws_config: WebSocketConfig, +} + +fn prepare_request(config: &WebSocketConnectConfig) -> Result { + validate_ws_url(&config.url).map_err(WebSocketError::invalid_url)?; + + let mut headers = config + .headers + .iter() + .filter(|h| h.enabled && !h.key.is_empty()) + .cloned() + .collect::>(); + let mut params = config.params.clone(); + apply_auth(&config.auth, &mut headers, &mut params); + + let url = append_query_params(config.url.trim(), ¶ms); + + let mut request = url + .into_client_request() + .map_err(|e| WebSocketError::invalid_url(e.to_string()))?; + + for header in &headers { + let name = HeaderName::from_bytes(header.key.as_bytes()).map_err(|_| { + WebSocketError::new( + WebSocketErrorKind::Protocol, + format!("Invalid header name: {}", header.key), + ) + })?; + // Skip hop-by-hop / reserved headers the library sets. + let lower = header.key.to_ascii_lowercase(); + if matches!( + lower.as_str(), + "host" + | "upgrade" + | "connection" + | "sec-websocket-key" + | "sec-websocket-version" + | "sec-websocket-extensions" + | "sec-websocket-protocol" + ) { + continue; + } + let value = HeaderValue::from_str(&header.value).map_err(|_| { + WebSocketError::new( + WebSocketErrorKind::Protocol, + format!("Header {} contains an invalid value", header.key), + ) + })?; + request.headers_mut().append(name, value); + } + + if !config.subprotocols.is_empty() { + if let Some(invalid) = config + .subprotocols + .iter() + .find(|protocol| !is_http_token(protocol)) + { + return Err(WebSocketError::new( + WebSocketErrorKind::Protocol, + format!("Invalid WebSocket subprotocol: {invalid}"), + )); + } + let protocols = config.subprotocols.join(", "); + let value = HeaderValue::from_str(&protocols).map_err(|_| { + WebSocketError::new( + WebSocketErrorKind::Protocol, + "WebSocket subprotocol list is invalid", + ) + })?; + request + .headers_mut() + .insert("sec-websocket-protocol", value); + } + + let mut ws_config = WebSocketConfig::default(); + ws_config.max_message_size = Some(config.max_frame_bytes); + ws_config.max_frame_size = Some(config.max_frame_bytes); + + Ok(PreparedRequest { request, ws_config }) +} + +fn is_http_token(value: &str) -> bool { + !value.is_empty() + && value.bytes().all(|byte| { + byte.is_ascii_alphanumeric() + || matches!( + byte, + b'!' | b'#' + | b'$' + | b'%' + | b'&' + | b'\'' + | b'*' + | b'+' + | b'-' + | b'.' + | b'^' + | b'_' + | b'`' + | b'|' + | b'~' + ) + }) +} + +async fn graceful_close(ws: &mut WsStream) { + let frame = CloseFrame { + code: CloseCode::Normal, + reason: "Client disconnect".into(), + }; + let _ = tokio::time::timeout(Duration::from_millis(CLOSE_TIMEOUT_MS), async { + let _ = ws.send(Message::Close(Some(frame))).await; + // Drain until close or timeout. + while let Some(msg) = ws.next().await { + if matches!(msg, Ok(Message::Close(_)) | Err(_)) { + break; + } + } + }) + .await; +} + +fn close_meta_from_frame(frame: Option, remote: bool) -> CloseMetadata { + match frame { + Some(f) => CloseMetadata { + code: Some(u16::from(f.code)), + reason: f.reason.to_string(), + remote, + }, + None => CloseMetadata { + code: None, + reason: String::new(), + remote, + }, + } +} + +/// Strip potentially sensitive query/header fragments from error strings. +fn sanitize_error(message: &str) -> String { + // Avoid echoing full URLs that may contain tokens in query strings. + let mut out = message.to_string(); + if let Some(idx) = out.find("Authorization") { + out.replace_range(idx.., "Authorization: [redacted]"); + } + for scheme in ["wss://", "ws://"] { + let mut search_from = 0; + while let Some(relative_start) = out[search_from..].find(scheme) { + let start = search_from + relative_start; + let Some(relative_query) = out[start..].find('?') else { + break; + }; + let query_start = start + relative_query; + let query_end = out[query_start..] + .find(|character: char| { + character.is_whitespace() || matches!(character, ')' | ']' | ',') + }) + .map_or(out.len(), |offset| query_start + offset); + out.replace_range(query_start..query_end, "?[redacted]"); + search_from = query_start + "?[redacted]".len(); + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::entities::{AuthConfigData, AuthTypeData, Header, QueryParamData}; + use crate::websocket::types::apply_auth; + use futures_util::{SinkExt, StreamExt}; + use std::sync::Arc; + use tokio::net::TcpListener; + use tokio::sync::{Mutex, oneshot}; + use tokio_tungstenite::accept_hdr_async; + use tokio_tungstenite::tungstenite::handshake::server::{ + Request as HsRequest, Response as HsResponse, + }; + + async fn start_echo_server() -> ( + String, + oneshot::Sender<()>, + Arc>>, + ) { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let (shutdown_tx, mut shutdown_rx) = oneshot::channel::<()>(); + let captured_headers = Arc::new(Mutex::new(Vec::<(String, String)>::new())); + let headers_for_task = captured_headers.clone(); + + tokio::spawn(async move { + loop { + tokio::select! { + _ = &mut shutdown_rx => break, + accept = listener.accept() => { + let Ok((stream, _)) = accept else { break }; + let headers_for_conn = headers_for_task.clone(); + tokio::spawn(async move { + let callback = |req: &HsRequest, + mut response: HsResponse| + -> Result< + HsResponse, + tokio_tungstenite::tungstenite::handshake::server::ErrorResponse, + > { + if let Ok(mut guard) = headers_for_conn.try_lock() { + for (k, v) in req.headers().iter() { + if let Ok(val) = v.to_str() { + guard.push((k.as_str().to_string(), val.to_string())); + } + } + } + if let Some(proto) = req.headers().get("sec-websocket-protocol") + && let Ok(s) = proto.to_str() + { + let first = s.split(',').next().unwrap_or(s).trim(); + if let Ok(v) = HeaderValue::from_str(first) { + response + .headers_mut() + .insert("sec-websocket-protocol", v); + } + } + Ok(response) + }; + let Ok(mut ws) = accept_hdr_async(stream, callback).await else { + return; + }; + while let Some(Ok(msg)) = ws.next().await { + match msg { + Message::Text(t) => { + if t == "__close__" { + let _ = ws.close(None).await; + break; + } + if t == "__ping__" { + let _ = ws.send(Message::Ping(Bytes::from_static(b"x"))).await; + continue; + } + if t.starts_with("__server_bin__") { + let _ = ws.send(Message::Binary(Bytes::from_static(&[1,2,3,4]))).await; + continue; + } + if t.starts_with("__server_text__") { + let _ = ws.send(Message::Text("from-server".into())).await; + continue; + } + let _ = ws.send(Message::Text(t)).await; + } + Message::Binary(b) => { + let _ = ws.send(Message::Binary(b)).await; + } + Message::Ping(p) => { + let _ = ws.send(Message::Pong(p)).await; + } + Message::Close(_) => break, + _ => {} + } + } + }); + } + } + } + }); + + (format!("ws://{addr}"), shutdown_tx, captured_headers) + } + + async fn wait_event( + rx: &mut mpsc::Receiver, + timeout_ms: u64, + ) -> Option { + tokio::time::timeout(Duration::from_millis(timeout_ms), rx.recv()) + .await + .ok() + .flatten() + } + + #[tokio::test] + async fn connects_and_roundtrips_text() { + let (url, shutdown, _) = start_echo_server().await; + let (session, mut events) = WebSocketClient::connect(WebSocketConnectConfig { + url, + generation: 1, + ..Default::default() + }); + + let connected = wait_event(&mut events, 2000).await; + assert!(matches!(connected, Some(WebSocketEvent::Connected(_)))); + + session.send_text("hello").unwrap(); + assert!(matches!( + wait_event(&mut events, 2000).await, + Some(WebSocketEvent::Sent(WebSocketPayload::Text(text))) if text == "hello" + )); + let msg = wait_event(&mut events, 2000).await; + match msg { + Some(WebSocketEvent::Message(WebSocketPayload::Text(t))) => { + assert_eq!(t, "hello"); + } + other => panic!("unexpected: {other:?}"), + } + + let _ = session.disconnect(); + let closed = wait_event(&mut events, 2000).await; + assert!(matches!(closed, Some(WebSocketEvent::Closed(_)))); + let _ = shutdown.send(()); + } + + #[tokio::test] + async fn roundtrips_binary_without_utf8_loss() { + let (url, shutdown, _) = start_echo_server().await; + let (session, mut events) = WebSocketClient::connect(WebSocketConnectConfig { + url, + generation: 1, + ..Default::default() + }); + assert!(matches!( + wait_event(&mut events, 2000).await, + Some(WebSocketEvent::Connected(_)) + )); + + let payload = Bytes::from(vec![0u8, 159, 255, 1, 2]); + session.send_binary(payload.clone()).unwrap(); + assert!(matches!( + wait_event(&mut events, 2000).await, + Some(WebSocketEvent::Sent(WebSocketPayload::Binary(bytes))) if bytes == payload + )); + match wait_event(&mut events, 2000).await { + Some(WebSocketEvent::Message(WebSocketPayload::Binary(b))) => { + assert_eq!(b.as_ref(), payload.as_ref()); + } + other => panic!("unexpected: {other:?}"), + } + let _ = session.disconnect(); + let _ = wait_event(&mut events, 2000).await; + let _ = shutdown.send(()); + } + + #[tokio::test] + async fn sends_headers_and_query_params() { + let (url, shutdown, captured) = start_echo_server().await; + let (session, mut events) = WebSocketClient::connect(WebSocketConnectConfig { + url: format!("{url}/path"), + headers: vec![ + Header::new("X-Custom", "abc"), + Header { + key: "X-Off".into(), + value: "no".into(), + enabled: false, + }, + ], + params: vec![QueryParamData::new("room", "42")], + generation: 1, + ..Default::default() + }); + assert!(matches!( + wait_event(&mut events, 2000).await, + Some(WebSocketEvent::Connected(_)) + )); + + // Give accept callback a tick. + tokio::task::yield_now().await; + let headers = captured.lock().await.clone(); + assert!( + headers + .iter() + .any(|(k, v)| k.eq_ignore_ascii_case("x-custom") && v == "abc"), + "headers: {headers:?}" + ); + assert!(!headers.iter().any(|(k, _)| k.eq_ignore_ascii_case("x-off"))); + + let _ = session.disconnect(); + let _ = wait_event(&mut events, 2000).await; + let _ = shutdown.send(()); + } + + #[tokio::test] + async fn applies_bearer_auth() { + let (url, shutdown, captured) = start_echo_server().await; + let (session, mut events) = WebSocketClient::connect(WebSocketConnectConfig { + url, + auth: AuthConfigData { + auth_type: AuthTypeData::Bearer, + token: "secret-token".into(), + ..Default::default() + }, + generation: 1, + ..Default::default() + }); + assert!(matches!( + wait_event(&mut events, 2000).await, + Some(WebSocketEvent::Connected(_)) + )); + tokio::task::yield_now().await; + let headers = captured.lock().await.clone(); + assert!(headers.iter().any(|(k, v)| { + k.eq_ignore_ascii_case("authorization") && v == "Bearer secret-token" + })); + let _ = session.disconnect(); + let _ = wait_event(&mut events, 2000).await; + let _ = shutdown.send(()); + } + + #[tokio::test] + async fn negotiates_subprotocol() { + let (url, shutdown, _) = start_echo_server().await; + let (session, mut events) = WebSocketClient::connect(WebSocketConnectConfig { + url, + subprotocols: vec!["graphql-ws".into(), "chat".into()], + generation: 1, + ..Default::default() + }); + match wait_event(&mut events, 2000).await { + Some(WebSocketEvent::Connected(meta)) => { + assert_eq!(meta.selected_subprotocol.as_deref(), Some("graphql-ws")); + } + other => panic!("unexpected: {other:?}"), + } + let _ = session.disconnect(); + let _ = wait_event(&mut events, 2000).await; + let _ = shutdown.send(()); + } + + #[tokio::test] + async fn receives_server_initiated_messages() { + let (url, shutdown, _) = start_echo_server().await; + let (session, mut events) = WebSocketClient::connect(WebSocketConnectConfig { + url, + generation: 1, + ..Default::default() + }); + assert!(matches!( + wait_event(&mut events, 2000).await, + Some(WebSocketEvent::Connected(_)) + )); + session.send_text("__server_text__").unwrap(); + // Echo of command then server text — drain until from-server. + let mut found = false; + for _ in 0..5 { + match wait_event(&mut events, 2000).await { + Some(WebSocketEvent::Message(WebSocketPayload::Text(t))) if t == "from-server" => { + found = true; + break; + } + Some(WebSocketEvent::Sent(_)) | Some(WebSocketEvent::Message(_)) => continue, + other => panic!("unexpected: {other:?}"), + } + } + assert!(found); + let _ = session.disconnect(); + let _ = wait_event(&mut events, 2000).await; + let _ = shutdown.send(()); + } + + #[tokio::test] + async fn handles_ping_pong() { + let (url, shutdown, _) = start_echo_server().await; + let (session, mut events) = WebSocketClient::connect(WebSocketConnectConfig { + url, + generation: 1, + ..Default::default() + }); + assert!(matches!( + wait_event(&mut events, 2000).await, + Some(WebSocketEvent::Connected(_)) + )); + session.ping(Bytes::from_static(b"hi")).unwrap(); + let mut got_pong = false; + for _ in 0..5 { + match wait_event(&mut events, 2000).await { + Some(WebSocketEvent::Pong(data)) => { + assert_eq!(data.as_ref(), b"hi"); + got_pong = true; + break; + } + Some(_) => continue, + None => break, + } + } + assert!(got_pong); + let _ = session.disconnect(); + let _ = wait_event(&mut events, 2000).await; + let _ = shutdown.send(()); + } + + #[tokio::test] + async fn reports_connection_refusal() { + // Unbound port — connection refused. + let (session, mut events) = WebSocketClient::connect(WebSocketConnectConfig { + url: "ws://127.0.0.1:1".into(), + generation: 1, + ..Default::default() + }); + match wait_event(&mut events, 3000).await { + Some(WebSocketEvent::Error(err)) => { + assert!( + matches!( + err.kind, + WebSocketErrorKind::Connection | WebSocketErrorKind::Handshake + ), + "{err:?}" + ); + } + other => panic!("expected error, got {other:?}"), + } + drop(session); + } + + #[tokio::test] + async fn rejects_invalid_scheme() { + let (_session, mut events) = WebSocketClient::connect(WebSocketConnectConfig { + url: "http://example.com".into(), + generation: 1, + ..Default::default() + }); + match wait_event(&mut events, 1000).await { + Some(WebSocketEvent::Error(err)) => { + assert_eq!(err.kind, WebSocketErrorKind::InvalidUrl); + } + other => panic!("unexpected: {other:?}"), + } + } + + #[tokio::test] + async fn cancel_while_connecting() { + // Black-hole style: connect to a non-routable address may hang; use + // a local listener that never accepts upgrades... simpler: open TCP + // listener and never upgrade — cancel during connect. + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + // Keep listener alive without accepting. + let (session, mut events) = WebSocketClient::connect(WebSocketConnectConfig { + url: format!("ws://{addr}"), + generation: 1, + ..Default::default() + }); + // Immediately disconnect/cancel. + let _ = session.disconnect(); + match wait_event(&mut events, 3000).await { + Some(WebSocketEvent::Error(err)) => { + assert_eq!(err.kind, WebSocketErrorKind::Cancelled); + } + Some(WebSocketEvent::Connected(_)) => { + // Race: connected first, then we disconnect. + let _ = session.disconnect(); + } + other => panic!("unexpected: {other:?}"), + } + drop(listener); + } + + #[tokio::test] + async fn drop_session_stops_task() { + let (url, shutdown, _) = start_echo_server().await; + let (session, mut events) = WebSocketClient::connect(WebSocketConnectConfig { + url, + generation: 1, + ..Default::default() + }); + assert!(matches!( + wait_event(&mut events, 2000).await, + Some(WebSocketEvent::Connected(_)) + )); + drop(session); + // Event channel should eventually close / no panics. + let _ = wait_event(&mut events, 500).await; + let _ = shutdown.send(()); + } + + #[test] + fn apply_auth_basic_and_api_key_query() { + let mut headers = Vec::new(); + let mut params = Vec::new(); + apply_auth( + &AuthConfigData { + auth_type: AuthTypeData::Basic, + username: "u".into(), + password: "p".into(), + ..Default::default() + }, + &mut headers, + &mut params, + ); + assert!( + headers + .iter() + .any(|h| h.key == "Authorization" && h.value.starts_with("Basic ")) + ); + + headers.clear(); + params.clear(); + apply_auth( + &AuthConfigData { + auth_type: AuthTypeData::ApiKey, + api_key_name: "key".into(), + api_key_value: "val".into(), + api_key_in_header: false, + ..Default::default() + }, + &mut headers, + &mut params, + ); + assert!(params.iter().any(|p| p.key == "key" && p.value == "val")); + assert!(headers.is_empty()); + } + + #[test] + fn rejects_invalid_headers_and_subprotocols_before_connecting() { + let bad_header = prepare_request(&WebSocketConnectConfig { + url: "ws://localhost/socket".into(), + headers: vec![Header::new("bad header", "value")], + ..Default::default() + }) + .expect_err("invalid header must fail"); + assert_eq!(bad_header.kind, WebSocketErrorKind::Protocol); + + let bad_subprotocol = prepare_request(&WebSocketConnectConfig { + url: "ws://localhost/socket".into(), + subprotocols: vec!["chat protocol".into()], + ..Default::default() + }) + .expect_err("invalid subprotocol must fail"); + assert_eq!(bad_subprotocol.kind, WebSocketErrorKind::Protocol); + } + + #[test] + fn redacts_query_values_from_transport_errors() { + let sanitized = sanitize_error( + "failed to connect to wss://example.com/socket?token=secret&room=private (closed)", + ); + assert!(!sanitized.contains("secret")); + assert!(!sanitized.contains("private")); + assert!(sanitized.contains("?[redacted]")); + } +} diff --git a/src/websocket/mod.rs b/src/websocket/mod.rs new file mode 100644 index 0000000..55e6d47 --- /dev/null +++ b/src/websocket/mod.rs @@ -0,0 +1,9 @@ +//! WebSocket transport layer + +#![allow(dead_code)] + +pub mod client; +pub mod types; + +pub use client::*; +pub use types::*; diff --git a/src/websocket/types.rs b/src/websocket/types.rs new file mode 100644 index 0000000..9253962 --- /dev/null +++ b/src/websocket/types.rs @@ -0,0 +1,164 @@ +//! Transport-level types for the WebSocket client. + +use bytes::Bytes; +use std::fmt; + +use crate::entities::{ + AuthConfigData, AuthTypeData, CloseMetadata, DEFAULT_MAX_FRAME_BYTES, HandshakeMetadata, + Header, QueryParamData, WebSocketPayload, +}; + +/// Configuration used to open a WebSocket connection. +#[derive(Debug, Clone)] +pub struct WebSocketConnectConfig { + pub url: String, + pub headers: Vec
, + pub params: Vec, + pub auth: AuthConfigData, + pub subprotocols: Vec, + pub max_frame_bytes: usize, + pub connect_timeout_ms: u64, + /// Optional generation id carried through for stale-event protection. + pub generation: u64, +} + +impl Default for WebSocketConnectConfig { + fn default() -> Self { + Self { + url: String::new(), + headers: Vec::new(), + params: Vec::new(), + auth: AuthConfigData::default(), + subprotocols: Vec::new(), + max_frame_bytes: DEFAULT_MAX_FRAME_BYTES, + connect_timeout_ms: CONNECT_TIMEOUT_MS, + generation: 0, + } + } +} + +/// Commands the UI can send to a live session. +#[derive(Debug, Clone)] +pub enum WebSocketCommand { + SendText(String), + SendBinary(Bytes), + Ping(Bytes), + Disconnect, +} + +/// Events emitted by the transport task. +#[derive(Debug, Clone)] +pub enum WebSocketEvent { + Connected(HandshakeMetadata), + Sent(WebSocketPayload), + Message(WebSocketPayload), + Pong(Bytes), + Closed(CloseMetadata), + Error(WebSocketError), +} + +/// Transport errors with user-facing messages (never includes secrets). +#[derive(Debug, Clone)] +pub struct WebSocketError { + pub message: String, + pub kind: WebSocketErrorKind, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WebSocketErrorKind { + InvalidUrl, + Handshake, + Connection, + Protocol, + MessageTooLarge, + Cancelled, + Other, +} + +impl WebSocketError { + pub fn new(kind: WebSocketErrorKind, message: impl Into) -> Self { + Self { + kind, + message: message.into(), + } + } + + pub fn invalid_url(message: impl Into) -> Self { + Self::new(WebSocketErrorKind::InvalidUrl, message) + } + + pub fn handshake(message: impl Into) -> Self { + Self::new(WebSocketErrorKind::Handshake, message) + } + + pub fn connection(message: impl Into) -> Self { + Self::new(WebSocketErrorKind::Connection, message) + } + + pub fn cancelled() -> Self { + Self::new(WebSocketErrorKind::Cancelled, "Connection cancelled") + } +} + +impl fmt::Display for WebSocketError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.message) + } +} + +impl std::error::Error for WebSocketError {} + +/// Apply authentication to headers and query params (resolved values only). +pub fn apply_auth( + auth: &AuthConfigData, + headers: &mut Vec
, + params: &mut Vec, +) { + match auth.auth_type { + AuthTypeData::None => {} + AuthTypeData::Basic => { + use base64::Engine as _; + let credentials = format!("{}:{}", auth.username, auth.password); + let encoded = base64::engine::general_purpose::STANDARD.encode(credentials); + upsert_header(headers, "Authorization", format!("Basic {encoded}")); + } + AuthTypeData::Bearer => { + if !auth.token.is_empty() { + upsert_header(headers, "Authorization", format!("Bearer {}", auth.token)); + } + } + AuthTypeData::ApiKey => { + if auth.api_key_name.is_empty() { + return; + } + if auth.api_key_in_header { + upsert_header(headers, &auth.api_key_name, auth.api_key_value.clone()); + } else { + params.push(QueryParamData { + key: auth.api_key_name.clone(), + value: auth.api_key_value.clone(), + enabled: true, + }); + } + } + } +} + +fn upsert_header(headers: &mut Vec
, key: &str, value: String) { + if let Some(header) = headers.iter_mut().find(|h| h.key.eq_ignore_ascii_case(key)) { + header.value = value; + header.enabled = true; + } else { + headers.push(Header::new(key, value)); + } +} + +/// Channel capacity defaults — provide backpressure without unbounded growth. +pub const COMMAND_CHANNEL_CAPACITY: usize = 64; +pub const EVENT_CHANNEL_CAPACITY: usize = 256; + +/// Connections that cannot complete promptly should return control to the UI. +pub const CONNECT_TIMEOUT_MS: u64 = 10_000; + +/// Graceful close timeout before aborting the socket task. +pub const CLOSE_TIMEOUT_MS: u64 = 1_500;