diff --git a/Cargo.lock b/Cargo.lock index 20d74ad..56ef5a0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5498,6 +5498,7 @@ dependencies = [ "gpui-component", "gpui_platform", "log", + "lsp-types", "mime_guess", "reqwest", "rodio", diff --git a/Cargo.toml b/Cargo.toml index 2e665db..c8f9b02 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,6 +21,7 @@ reqwest = { version = "0.13.4", default-features = false, features = [ ] } log = "0.4.29" env_logger = "0.11.8" +lsp-types = "0.97.0" serde_json = "1.0.151" anyhow = "1.0.104" chrono = { version = "0.4.45", features = ["serde"] } diff --git a/Todo.md b/Todo.md index 444daf9..51028b0 100644 --- a/Todo.md +++ b/Todo.md @@ -6,10 +6,27 @@ - [x] Fix "Save response" for binary/image responses to write raw bytes, not text. - [x] Ensure duplicated requests preserve method/body/headers exactly. -- [ ] Environment variables and secret variables (`{{base_url}}`, tokens, etc.) with workspace/project scopes. - [ ] Import/Export: cURL import, request/collection JSON export, and shareable files. -- [ ] Finish Postman import coverage: request-level auth, string-based requests, GraphQL bodies, and file bodies currently degrade or get skipped. +- [ ] Finish Postman import coverage: scripts/tests, saved response examples, certificates, and bulk data-dump folder import. - [ ] Implement protocol modes marked "SOON": WebSocket, GraphQL, and SSE. - [ ] Stronger auth support: OAuth 2.0 flows, Digest auth, and API key conveniences. - [ ] Cookie jar/session persistence with per-domain controls. - [ ] Better response rendering: HTML preview, XML pretty print, better binary metadata/download UX. + +## Environments & Workspaces + +- [x] Named environments with `{{variable}}` interpolation across URLs, params, headers, auth, and bodies. +- [x] Global variables plus workspace and collection/project overrides. +- [x] Local secret values, enable/disable controls, nested variables, environment colors, and duplication. +- [x] Grouped environment manager with separate edit and active states, preset colors, and a full custom color picker. +- [x] Real persisted workspace containers and a top-bar switcher that isolate collections, history, and environments. +- [x] Import Postman collections as new workspaces, plus environment exports and collection variables in their appropriate workspace. +- [ ] Scope UI preferences and restorable request-tab sessions per workspace. +- [ ] Encrypt secret values with an OS-keychain-backed key instead of relying only on local file permissions. +- [ ] Export environment files; exported secret values must be omitted. +- [x] Extensible variable autocomplete across request editors with effective scope details and no value disclosure. +- [ ] Highlight `{{variable}}` expressions inline in every request editor. +- [ ] Initial/current values and temporary session overrides. +- [ ] Request-scoped, folder-scoped, predefined (`$timestamp`, `$uuid`), and OS environment variables. +- [ ] Pre-request/test scripting APIs for reading and updating variables. +- [ ] Git/team workspace sync, conflict handling, roles, and per-user secret values. diff --git a/assets/icons/box.svg b/assets/icons/box.svg new file mode 100644 index 0000000..e50035c --- /dev/null +++ b/assets/icons/box.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/icons/eye-closed.svg b/assets/icons/eye-closed.svg new file mode 100644 index 0000000..d621450 --- /dev/null +++ b/assets/icons/eye-closed.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/icons/eye.svg b/assets/icons/eye.svg new file mode 100644 index 0000000..f1efd5e --- /dev/null +++ b/assets/icons/eye.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/icons/lock.svg b/assets/icons/lock.svg new file mode 100644 index 0000000..442e07b --- /dev/null +++ b/assets/icons/lock.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/package.svg b/assets/icons/package.svg new file mode 100644 index 0000000..6330bb7 --- /dev/null +++ b/assets/icons/package.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/icons/unlock.svg b/assets/icons/unlock.svg new file mode 100644 index 0000000..b217bec --- /dev/null +++ b/assets/icons/unlock.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/variable.svg b/assets/icons/variable.svg new file mode 100644 index 0000000..6f89d21 --- /dev/null +++ b/assets/icons/variable.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/app.rs b/src/app.rs index 679b78a..733b1b3 100644 --- a/src/app.rs +++ b/src/app.rs @@ -4,6 +4,7 @@ use gpui_component::Root; use crate::actions::*; use crate::assets::Assets; +use crate::completion::init_completion_navigation; use crate::theme::init_theme; use crate::utils::set_app_focus_handle; use crate::views::MainView; @@ -21,6 +22,7 @@ impl SetuApp { .run(|cx: &mut App| { // Initialize gpui-component (must be called before using any gpui-component features) gpui_component::init(cx); + init_completion_navigation(cx); // Apply our custom color theme to gpui-component init_theme(cx); diff --git a/src/completion.rs b/src/completion.rs new file mode 100644 index 0000000..9e73525 --- /dev/null +++ b/src/completion.rs @@ -0,0 +1,385 @@ +use anyhow::Result; +use gpui::{ + App, Context, Entity, Focusable, Global, IntoElement, ParentElement, RenderOnce, Styled, + Subscription, Task, WeakEntity, Window, div, +}; +use gpui_component::input::{ + CompletionProvider, Input, InputState, MoveDown, MoveUp, Rope, RopeExt, +}; +use lsp_types::{ + CompletionContext as LspCompletionContext, CompletionItem, CompletionItemKind, + CompletionResponse, CompletionTextEdit, Documentation, TextEdit, +}; +use std::cell::Cell; +use std::ops::Range; +use std::rc::Rc; +use uuid::Uuid; + +use crate::entities::{EnvironmentScope, EnvironmentsEntity}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CompletionContext { + Url, + HeaderName, + HeaderValue, + QueryName, + QueryValue, + Auth, + Body, + FormName, + FormValue, + EnvironmentValue, +} + +impl CompletionContext { + fn supports_templates(self) -> bool { + matches!( + self, + Self::Url + | Self::HeaderName + | Self::HeaderValue + | Self::QueryName + | Self::QueryValue + | Self::Auth + | Self::Body + | Self::FormName + | Self::FormValue + | Self::EnvironmentValue + ) + } +} + +pub struct CompletionRequest { + pub context: CompletionContext, + pub text: String, + pub cursor: usize, + pub collection_id: Option, +} + +pub struct CompletionSuggestion { + pub label: String, + pub detail: Option, + pub documentation: Option, + pub insert_text: String, + pub replace_range: Range, + pub kind: CompletionItemKind, + pub filter_text: Option, +} + +pub trait CompletionSource { + fn suggestions(&self, request: &CompletionRequest, cx: &App) -> Vec; +} + +#[derive(Clone)] +pub struct CompletionEngine { + sources: Rc>>, + collection_id: Rc>>, +} + +impl CompletionEngine { + pub fn for_environments(environments: gpui::Entity) -> Self { + let collection_id = Rc::new(Cell::new(None)); + Self { + sources: Rc::new(Vec::new()), + collection_id, + } + .with_source(Rc::new(EnvironmentCompletionSource { environments })) + } + + pub fn with_source(mut self, source: Rc) -> Self { + Rc::make_mut(&mut self.sources).push(source); + self + } + + pub fn set_collection_id(&self, collection_id: Option) { + self.collection_id.set(collection_id); + } + + pub fn configure_input(&self, mut input: InputState, context: CompletionContext) -> InputState { + input.lsp.completion_provider = Some(Rc::new(ContextualCompletionProvider { + sources: self.sources.clone(), + context, + collection_id: self.collection_id.clone(), + })); + input + } +} + +pub fn configure_completion( + input: InputState, + engine: Option<&CompletionEngine>, + context: CompletionContext, +) -> InputState { + if let Some(engine) = engine { + engine.configure_input(input, context) + } else { + input + } +} + +#[derive(Default)] +struct CompletionNavigationRegistry { + inputs: Vec>, + _interceptor: Option, +} + +impl Global for CompletionNavigationRegistry {} + +/// Registers a pre-dispatch keyboard interceptor for completion-enabled inputs. +/// +/// This avoids the single-line Up/Down action gap in gpui-component while +/// leaving the dependency untouched. +pub fn init_completion_navigation(cx: &mut App) { + if cx.has_global::() { + return; + } + cx.set_global(CompletionNavigationRegistry::default()); + let interceptor = cx.intercept_keystrokes(|event, window, cx| { + if event.keystroke.modifiers.modified() { + return; + } + let action: Box = match event.keystroke.key.as_str() { + "up" => Box::new(MoveUp), + "down" => Box::new(MoveDown), + _ => return, + }; + let inputs = cx.global::().inputs.clone(); + for input in inputs.into_iter().rev().filter_map(|input| input.upgrade()) { + if !input.read(cx).focus_handle(cx).is_focused(window) { + continue; + } + let handled = input.update(cx, |input, cx| { + input.handle_action_for_context_menu(action, window, cx) + }); + if handled { + cx.stop_propagation(); + } + break; + } + }); + cx.global_mut::()._interceptor = Some(interceptor); +} + +/// Registers an input with Setu's completion key router while preserving the +/// normal gpui-component `Input` rendering and focus behavior. +#[derive(IntoElement)] +pub struct CompletionInput { + input: Input, + state: Entity, +} + +impl CompletionInput { + pub fn new(state: &Entity, input: Input) -> Self { + Self { + input, + state: state.clone(), + } + } +} + +impl RenderOnce for CompletionInput { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let registry = cx.global_mut::(); + registry.inputs.retain(|input| input.upgrade().is_some()); + if !registry.inputs.iter().any(|input| input == &self.state) { + registry.inputs.push(self.state.downgrade()); + } + div().w_full().child(self.input) + } +} + +struct ContextualCompletionProvider { + sources: Rc>>, + context: CompletionContext, + collection_id: Rc>>, +} + +impl CompletionProvider for ContextualCompletionProvider { + fn completions( + &self, + rope: &Rope, + offset: usize, + _trigger: LspCompletionContext, + _window: &mut Window, + cx: &mut Context, + ) -> Task> { + let request = CompletionRequest { + context: self.context, + text: rope.to_string(), + cursor: offset, + collection_id: self.collection_id.get(), + }; + let mut suggestions = self + .sources + .iter() + .flat_map(|source| source.suggestions(&request, cx)) + .collect::>(); + suggestions.sort_by(|left, right| left.label.cmp(&right.label)); + + let items = suggestions + .into_iter() + .map(|suggestion| { + let start = rope.offset_to_position(suggestion.replace_range.start); + let end = rope.offset_to_position(suggestion.replace_range.end); + let glyph = completion_kind_glyph(suggestion.kind); + let highlighted_prefix = suggestion + .filter_text + .as_deref() + .filter(|query| !query.is_empty()) + .map(|query| format!("{glyph} {query}")) + .unwrap_or_else(|| glyph.to_string()); + CompletionItem { + label: format!("{glyph} {}", suggestion.label), + kind: Some(suggestion.kind), + detail: suggestion.detail, + documentation: suggestion.documentation.map(Documentation::String), + filter_text: Some(highlighted_prefix), + text_edit: Some(CompletionTextEdit::Edit(TextEdit { + range: lsp_types::Range::new(start, end), + new_text: suggestion.insert_text, + })), + ..Default::default() + } + }) + .collect(); + Task::ready(Ok(CompletionResponse::Array(items))) + } + + fn is_completion_trigger( + &self, + _offset: usize, + _new_text: &str, + _cx: &mut Context, + ) -> bool { + true + } +} + +fn completion_kind_glyph(kind: CompletionItemKind) -> &'static str { + if kind == CompletionItemKind::VARIABLE { + "x" + } else if kind == CompletionItemKind::CLASS { + "c" + } else if kind == CompletionItemKind::FUNCTION || kind == CompletionItemKind::METHOD { + "f" + } else if kind == CompletionItemKind::PROPERTY || kind == CompletionItemKind::FIELD { + "p" + } else if kind == CompletionItemKind::MODULE { + "m" + } else if kind == CompletionItemKind::FILE { + "f" + } else if kind == CompletionItemKind::FOLDER { + "d" + } else if kind == CompletionItemKind::KEYWORD { + "k" + } else if kind == CompletionItemKind::SNIPPET { + "s" + } else if kind == CompletionItemKind::CONSTANT || kind == CompletionItemKind::VALUE { + "v" + } else { + "·" + } +} + +struct EnvironmentCompletionSource { + environments: gpui::Entity, +} + +impl CompletionSource for EnvironmentCompletionSource { + fn suggestions(&self, request: &CompletionRequest, cx: &App) -> Vec { + if !request.context.supports_templates() { + return Vec::new(); + } + let Some((replace_range, query)) = template_query(&request.text, request.cursor) else { + return Vec::new(); + }; + let normalized_query = query.to_ascii_lowercase(); + self.environments + .read(cx) + .effective_variable_completions(request.collection_id) + .into_iter() + .filter(|variable| { + variable + .key + .to_ascii_lowercase() + .starts_with(&normalized_query) + }) + .map(|variable| { + let origin = match variable.scope { + EnvironmentScope::Global => "Inherited from Global Variables", + EnvironmentScope::Workspace => "Inherited from Workspace Variables", + EnvironmentScope::Project(_) => "Inherited from Project Variables", + }; + let documentation = if variable.secret { + format!("{origin}\n\nSecret variable") + } else { + origin.to_string() + }; + CompletionSuggestion { + label: variable.key.clone(), + detail: Some("variable".to_string()), + documentation: Some(documentation), + insert_text: format!("{{{{{}}}}}", variable.key), + replace_range: replace_range.clone(), + kind: CompletionItemKind::VARIABLE, + filter_text: Some(query.clone()), + } + }) + .collect() + } +} + +fn template_query(text: &str, cursor: usize) -> Option<(Range, String)> { + if cursor > text.len() || !text.is_char_boundary(cursor) { + return None; + } + let before_cursor = &text[..cursor]; + let start = before_cursor.rfind("{{")?; + let query = &before_cursor[start + 2..]; + if query.contains("}}") || query.chars().any(char::is_whitespace) { + return None; + } + // When editing an existing template (for example `{{v}}`), include its + // closing delimiter in the edit. Otherwise accepting `var` would replace + // only `{{v` with `{{var}}` and leave the old `}}` behind. + let end = if text[cursor..].starts_with("}}") { + cursor + 2 + } else { + cursor + }; + Some((start..end, query.to_string())) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn extracts_open_template_at_cursor() { + assert_eq!( + template_query("https://{{base_u", 16), + Some((8..16, "base_u".to_string())) + ); + } + + #[test] + fn replaces_existing_template_closing_delimiter() { + assert_eq!( + template_query("prefix {{v}} suffix", 10), + Some((7..12, "v".to_string())) + ); + } + + #[test] + fn ignores_closed_or_whitespace_templates() { + assert_eq!(template_query("{{done}}", 8), None); + assert_eq!(template_query("{{not valid", 11), None); + } + + #[test] + fn maps_completion_kinds_to_compact_glyphs() { + assert_eq!(completion_kind_glyph(CompletionItemKind::VARIABLE), "x"); + assert_eq!(completion_kind_glyph(CompletionItemKind::CLASS), "c"); + assert_eq!(completion_kind_glyph(CompletionItemKind::FUNCTION), "f"); + } +} diff --git a/src/components/app_sidebar.rs b/src/components/app_sidebar.rs index 0018aba..b2d38cd 100644 --- a/src/components/app_sidebar.rs +++ b/src/components/app_sidebar.rs @@ -10,6 +10,7 @@ use crate::entities::{CollectionsEntity, HistoryEntity, HistoryRow}; use crate::icons::IconName; use super::collections_panel::CollectionsPanel; +use super::environment_panel::EnvironmentPanel; use super::history_panel::{HistoryFilter, HistoryGroupBy, HistoryPanel}; #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] @@ -17,6 +18,7 @@ pub enum SidebarTab { #[default] History, Collections, + Environments, } #[derive(IntoElement)] @@ -24,6 +26,7 @@ pub struct AppSidebar { active_tab: SidebarTab, history: Entity, collections: Entity, + environment_panel: Entity, history_search: Entity, collections_search: Entity, history_rows: Arc>, @@ -56,6 +59,7 @@ impl AppSidebar { pub fn new( history: Entity, collections: Entity, + environment_panel: Entity, history_search: Entity, collections_search: Entity, history_rows: Arc>, @@ -65,6 +69,7 @@ impl AppSidebar { active_tab: SidebarTab::History, history, collections, + environment_panel, history_search, collections_search, history_rows, @@ -372,6 +377,7 @@ impl AppSidebar { .id(match tab { SidebarTab::History => "history-tab-btn", SidebarTab::Collections => "collections-tab-btn", + SidebarTab::Environments => "environments-tab-btn", }) .flex() .items_center() @@ -402,6 +408,7 @@ impl RenderOnce for AppSidebar { let panel = match self.active_tab { SidebarTab::History => self.build_history_panel().into_any_element(), SidebarTab::Collections => self.build_collections_panel().into_any_element(), + SidebarTab::Environments => self.environment_panel.clone().into_any_element(), }; div() @@ -425,6 +432,11 @@ impl RenderOnce for AppSidebar { SidebarTab::Collections, IconName::Folder, &theme, + )) + .child(self.render_icon_button( + SidebarTab::Environments, + IconName::Package, + &theme, )), ) .child( diff --git a/src/components/auth_editor.rs b/src/components/auth_editor.rs index 5226352..387a625 100644 --- a/src/components/auth_editor.rs +++ b/src/components/auth_editor.rs @@ -10,6 +10,10 @@ use gpui_component::select::{Select, SelectEvent, SelectItem, SelectState}; use gpui_component::ActiveTheme; +use crate::completion::{ + CompletionContext, CompletionEngine, CompletionInput, configure_completion, +}; + /// Authentication type #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum AuthType { @@ -213,10 +217,15 @@ pub struct AuthEditor { api_key_location_select: Entity>>, focus_handle: FocusHandle, + completion_engine: Option, } impl AuthEditor { - pub fn new(window: &mut Window, cx: &mut Context) -> Self { + pub fn new( + window: &mut Window, + completion_engine: Option, + cx: &mut Context, + ) -> Self { // Create auth type select state let auth_type_items: Vec = AuthType::all().to_vec(); let auth_type_select = cx.new(|cx| { @@ -274,22 +283,53 @@ impl AuthEditor { api_key_location: ApiKeyLocation::Header, api_key_location_select, focus_handle: cx.focus_handle(), + completion_engine, } } /// Initialize inputs lazily fn ensure_inputs(&mut self, window: &mut Window, cx: &mut Context) { if self.username_input.is_none() { - self.username_input = - Some(cx.new(|cx| InputState::new(window, cx).placeholder("Username"))); - self.password_input = - Some(cx.new(|cx| InputState::new(window, cx).placeholder("Password"))); - self.token_input = - Some(cx.new(|cx| InputState::new(window, cx).placeholder("Bearer token"))); - self.api_key_name_input = - Some(cx.new(|cx| InputState::new(window, cx).placeholder("Key name"))); - self.api_key_value_input = - Some(cx.new(|cx| InputState::new(window, cx).placeholder("Key value"))); + let completion_engine = self.completion_engine.clone(); + self.username_input = Some(cx.new(|cx| { + configure_completion( + InputState::new(window, cx).placeholder("Username"), + completion_engine.as_ref(), + CompletionContext::Auth, + ) + })); + let completion_engine = self.completion_engine.clone(); + self.password_input = Some(cx.new(|cx| { + configure_completion( + InputState::new(window, cx).placeholder("Password"), + completion_engine.as_ref(), + CompletionContext::Auth, + ) + })); + let completion_engine = self.completion_engine.clone(); + self.token_input = Some(cx.new(|cx| { + configure_completion( + InputState::new(window, cx).placeholder("Bearer token"), + completion_engine.as_ref(), + CompletionContext::Auth, + ) + })); + let completion_engine = self.completion_engine.clone(); + self.api_key_name_input = Some(cx.new(|cx| { + configure_completion( + InputState::new(window, cx).placeholder("Key name"), + completion_engine.as_ref(), + CompletionContext::Auth, + ) + })); + let completion_engine = self.completion_engine.clone(); + self.api_key_value_input = Some(cx.new(|cx| { + configure_completion( + InputState::new(window, cx).placeholder("Key value"), + completion_engine.as_ref(), + CompletionContext::Auth, + ) + })); } } @@ -451,7 +491,10 @@ impl AuthEditor { .rounded(px(6.0)) .border_1() .border_color(theme.border) - .child(Input::new(input).appearance(false).small()), + .child(CompletionInput::new( + input, + Input::new(input).appearance(false).small(), + )), ) }), ) @@ -475,7 +518,10 @@ impl AuthEditor { .rounded(px(6.0)) .border_1() .border_color(theme.border) - .child(Input::new(input).appearance(false).small()), + .child(CompletionInput::new( + input, + Input::new(input).appearance(false).small(), + )), ) }), ) @@ -500,7 +546,10 @@ impl AuthEditor { .rounded(px(6.0)) .border_1() .border_color(theme.border) - .child(Input::new(input).appearance(false).small()), + .child(CompletionInput::new( + input, + Input::new(input).appearance(false).small(), + )), ) }) } @@ -549,7 +598,10 @@ impl AuthEditor { .rounded(px(6.0)) .border_1() .border_color(theme.border) - .child(Input::new(input).appearance(false).small()), + .child(CompletionInput::new( + input, + Input::new(input).appearance(false).small(), + )), ) }), ) @@ -573,7 +625,10 @@ impl AuthEditor { .rounded(px(6.0)) .border_1() .border_color(theme.border) - .child(Input::new(input).appearance(false).small()), + .child(CompletionInput::new( + input, + Input::new(input).appearance(false).small(), + )), ) }), ) diff --git a/src/components/collections_panel.rs b/src/components/collections_panel.rs index 3d8709e..d563947 100644 --- a/src/components/collections_panel.rs +++ b/src/components/collections_panel.rs @@ -334,7 +334,7 @@ impl CollectionsPanel { container = container.child( Button::new("import-first-collection") .small() - .label("Import Collection") + .label("Import Postman") .icon(Icon::new(IconName::FileUp).size(px(14.0))) .on_click(move |_, window, cx| handler(window, cx)), ); @@ -1081,7 +1081,7 @@ impl RenderOnce for CollectionsPanel { .ghost() .xsmall() .icon(Icon::new(IconName::FileUp).size(px(14.0))) - .tooltip("Import Collection") + .tooltip("Import a Postman collection or environment") .on_click(move |_, window, cx| handler(window, cx)), ); } diff --git a/src/components/environment_panel.rs b/src/components/environment_panel.rs new file mode 100644 index 0000000..9811ff1 --- /dev/null +++ b/src/components/environment_panel.rs @@ -0,0 +1,1276 @@ +use gpui::prelude::*; +use gpui::{ + AnyElement, App, Context, Entity, FocusHandle, Focusable, FontWeight, IntoElement, Render, + SharedString, Styled, Window, div, px, +}; +use gpui_component::Sizable; +use gpui_component::button::{Button, ButtonVariants}; +use gpui_component::checkbox::Checkbox; +use gpui_component::color_picker::{ColorPicker, ColorPickerEvent, ColorPickerState}; +use gpui_component::input::{Input, InputEvent, InputState}; +use gpui_component::menu::{DropdownMenu, PopupMenuItem}; +use gpui_component::scroll::ScrollableElement; +use gpui_component::{ActiveTheme, Selectable}; +use std::collections::HashMap; +use std::rc::Rc; +use uuid::Uuid; + +use crate::completion::{ + CompletionContext, CompletionEngine, CompletionInput, configure_completion, +}; +use crate::entities::{ + CollectionsEntity, Environment, EnvironmentColor, EnvironmentEvent, EnvironmentScope, + EnvironmentVariable, EnvironmentsEntity, +}; +use crate::icons::IconName; + +type NewEnvironmentCallback = Rc, &mut Window, &mut App) + 'static>; +type ImportEnvironmentCallback = Rc; +type DeleteEnvironmentCallback = Rc; +type RenameEnvironmentCallback = Rc; + +struct VariableRow { + id: Uuid, + key_input: Entity, + value_input: Entity, + enabled: bool, + secret: bool, +} + +pub struct EnvironmentPanel { + environments: Entity, + collections: Entity, + collection_id: Option, + selected_environment_id: Option, + loaded_environment_id: Option, + loaded_signature: Vec<(Uuid, bool)>, + rows: Vec, + color_picker: Option>, + color_picker_environment_id: Option, + color_picker_color: Option, + completion_engine: CompletionEngine, + on_new_environment: Option, + on_import_environment: Option, + on_delete_environment: Option, + on_rename_environment: Option, + focus_handle: FocusHandle, +} + +impl EnvironmentPanel { + pub fn new( + environments: Entity, + collections: Entity, + cx: &mut Context, + ) -> Self { + let completion_engine = CompletionEngine::for_environments(environments.clone()); + cx.subscribe( + &environments, + |this, environments, event: &EnvironmentEvent, cx| { + if matches!(event, EnvironmentEvent::ActiveChanged) { + this.selected_environment_id = environments + .read(cx) + .active_environment_id(this.collection_id); + this.loaded_environment_id = None; + } + cx.notify(); + }, + ) + .detach(); + cx.subscribe(&collections, |_this, _, _, cx| cx.notify()) + .detach(); + + Self { + environments, + collections, + collection_id: None, + selected_environment_id: None, + loaded_environment_id: None, + loaded_signature: Vec::new(), + rows: Vec::new(), + color_picker: None, + color_picker_environment_id: None, + color_picker_color: None, + completion_engine, + on_new_environment: None, + on_import_environment: None, + on_delete_environment: None, + on_rename_environment: None, + focus_handle: cx.focus_handle(), + } + } + + pub fn set_collection_context(&mut self, collection_id: Option, cx: &mut Context) { + if self.collection_id == collection_id { + return; + } + self.collection_id = collection_id; + self.selected_environment_id = self + .environments + .read(cx) + .active_environment_id(collection_id); + self.loaded_environment_id = None; + self.loaded_signature.clear(); + self.rows.clear(); + cx.notify(); + } + + pub fn on_new_environment( + &mut self, + callback: impl Fn(Option, &mut Window, &mut App) + 'static, + ) { + self.on_new_environment = Some(Rc::new(callback)); + } + + pub fn on_import_environment(&mut self, callback: impl Fn(&mut Window, &mut App) + 'static) { + self.on_import_environment = Some(Rc::new(callback)); + } + + pub fn on_delete_environment( + &mut self, + callback: impl Fn(Uuid, &mut Window, &mut App) + 'static, + ) { + self.on_delete_environment = Some(Rc::new(callback)); + } + + pub fn on_rename_environment( + &mut self, + callback: impl Fn(Uuid, String, &mut Window, &mut App) + 'static, + ) { + self.on_rename_environment = Some(Rc::new(callback)); + } + + pub fn select_environment(&mut self, environment_id: Uuid, cx: &mut Context) { + if self.selected_environment_id != Some(environment_id) { + self.selected_environment_id = Some(environment_id); + self.loaded_environment_id = None; + self.loaded_signature.clear(); + self.rows.clear(); + cx.notify(); + } + } + + fn ensure_selection(&mut self, cx: &mut Context) { + let environments = self.environments.read(cx); + if self + .selected_environment_id + .is_some_and(|id| environments.get(id).is_some()) + { + return; + } + self.selected_environment_id = environments + .active_environment_id(self.collection_id) + .or_else(|| { + environments + .environments() + .first() + .map(|environment| environment.id) + }); + } + + fn sync_rows(&mut self, window: &mut Window, cx: &mut Context) { + self.ensure_selection(cx); + let selected_id = self.selected_environment_id; + self.sync_color_picker(selected_id, window, cx); + let variables = selected_id + .and_then(|id| self.environments.read(cx).get(id)) + .map(|environment| environment.variables.clone()) + .unwrap_or_default(); + let signature: Vec<_> = variables + .iter() + .map(|variable| (variable.id, variable.secret)) + .collect(); + + if self.loaded_environment_id == selected_id && self.loaded_signature == signature { + for (row, variable) in self.rows.iter_mut().zip(variables) { + row.enabled = variable.enabled; + row.secret = variable.secret; + } + return; + } + + self.rows.clear(); + self.loaded_environment_id = selected_id; + self.loaded_signature = signature; + let Some(environment_id) = selected_id else { + return; + }; + for variable in variables { + self.rows + .push(self.build_row(environment_id, variable, window, cx)); + } + } + + fn ensure_color_picker(&mut self, window: &mut Window, cx: &mut Context) { + if self.color_picker.is_some() { + return; + } + let picker = cx.new(|cx| { + ColorPickerState::new(window, cx).default_value(EnvironmentColor::default().accent()) + }); + cx.subscribe(&picker, |this, _, event: &ColorPickerEvent, cx| { + let ColorPickerEvent::Change(Some(color)) = event else { + return; + }; + let Some(environment_id) = this.selected_environment_id else { + return; + }; + this.environments.update(cx, |environments, cx| { + environments.set_environment_color( + environment_id, + EnvironmentColor::custom(*color), + cx, + ); + }); + }) + .detach(); + self.color_picker = Some(picker); + } + + fn sync_color_picker( + &mut self, + environment_id: Option, + window: &mut Window, + cx: &mut Context, + ) { + let selected_color = environment_id + .and_then(|id| self.environments.read(cx).get(id)) + .map(|environment| environment.color.clone()); + if self.color_picker_environment_id == environment_id + && self.color_picker_color == selected_color + { + return; + } + let Some(selected_color) = selected_color else { + self.color_picker_environment_id = environment_id; + self.color_picker_color = None; + return; + }; + if let Some(picker) = &self.color_picker { + let color = selected_color.accent(); + picker.update(cx, |picker, cx| picker.set_value(color, window, cx)); + } + self.color_picker_environment_id = environment_id; + self.color_picker_color = Some(selected_color); + } + + fn build_row( + &self, + environment_id: Uuid, + variable: EnvironmentVariable, + window: &mut Window, + cx: &mut Context, + ) -> VariableRow { + let variable_id = variable.id; + let key_input = cx.new(|cx| { + InputState::new(window, cx) + .placeholder("VARIABLE_NAME") + .default_value(&variable.key) + }); + let value_input = cx.new(|cx| { + configure_completion( + InputState::new(window, cx) + .placeholder(if variable.secret { + "Secret value" + } else { + "Value" + }) + .default_value(&variable.value) + .masked(variable.secret), + Some(&self.completion_engine), + CompletionContext::EnvironmentValue, + ) + }); + + let environments_for_key = self.environments.clone(); + cx.subscribe(&key_input, move |_, input, event, cx| { + if matches!(event, InputEvent::Change) { + environments_for_key.update(cx, |environments, cx| { + environments.update_variable( + environment_id, + variable_id, + Some(input.read(cx).text().to_string()), + None, + cx, + ); + }); + } + }) + .detach(); + + let environments_for_value = self.environments.clone(); + cx.subscribe(&value_input, move |_, input, event, cx| { + if matches!(event, InputEvent::Change) { + environments_for_value.update(cx, |environments, cx| { + environments.update_variable( + environment_id, + variable_id, + None, + Some(input.read(cx).text().to_string()), + cx, + ); + }); + } + }) + .detach(); + + VariableRow { + id: variable.id, + key_input, + value_input, + enabled: variable.enabled, + secret: variable.secret, + } + } + + fn collection_name(&self, id: Uuid, cx: &App) -> Option { + self.collections + .read(cx) + .collections + .iter() + .find(|collection| collection.id == id) + .map(|collection| collection.name.clone()) + } + + fn scope_label(&self, scope: EnvironmentScope, cx: &App) -> String { + match scope { + EnvironmentScope::Global => "Global · All workspaces".to_string(), + EnvironmentScope::Workspace => "Workspace base".to_string(), + EnvironmentScope::Project(collection_id) => self + .collection_name(collection_id, cx) + .map(|name| format!("Project · {name}")) + .unwrap_or_else(|| "Unlinked project".to_string()), + } + } + + fn render_environment_menu( + &self, + environment: &Environment, + selected: bool, + this: Entity, + ) -> impl IntoElement { + let environment_id = environment.id; + let environment_scope = environment.scope; + let environment_name = environment.name.clone(); + let environment_color = environment.color.clone(); + let environments_for_activate = self.environments.clone(); + let environments_for_duplicate = self.environments.clone(); + let environments_for_color = self.environments.clone(); + let on_rename = self.on_rename_environment.clone(); + let on_delete = self.on_delete_environment.clone(); + + Button::new(SharedString::from(format!( + "environment-more-{environment_id}" + ))) + .icon(IconName::Ellipsis) + .ghost() + .xsmall() + .selected(selected) + .tooltip("Environment actions") + .dropdown_menu(move |mut menu, _window, _cx| { + let activate_label = match environment_scope { + EnvironmentScope::Global => "Use globally", + EnvironmentScope::Workspace => "Use as workspace base", + EnvironmentScope::Project(_) => "Use for this project", + }; + let environments = environments_for_activate.clone(); + menu = menu.item( + PopupMenuItem::new(activate_label) + .icon(IconName::CircleCheck) + .on_click(move |_, _, cx| { + environments.update(cx, |environments, cx| match environment_scope { + EnvironmentScope::Global => { + environments.set_active(None, Some(environment_id), cx); + } + EnvironmentScope::Workspace => { + environments.set_active(None, Some(environment_id), cx); + } + EnvironmentScope::Project(project_id) => { + environments.set_active(Some(project_id), Some(environment_id), cx); + } + }); + }), + ); + + let environments = environments_for_duplicate.clone(); + let this_for_duplicate = this.clone(); + menu = menu.item( + PopupMenuItem::new("Duplicate") + .icon(IconName::CopyPlus) + .on_click(move |_, _, cx| { + if let Some(id) = environments.update(cx, |environments, cx| { + environments.duplicate_environment(environment_id, cx) + }) { + this_for_duplicate.update(cx, |panel, cx| { + panel.select_environment(id, cx); + }); + } + }), + ); + + if let Some(callback) = on_rename.clone() { + let name = environment_name.clone(); + menu = menu.item( + PopupMenuItem::new("Rename") + .icon(IconName::FilePen) + .on_click(move |_, window, cx| { + callback(environment_id, name.clone(), window, cx); + }), + ); + } + + menu = menu.separator().label("Color"); + for color in EnvironmentColor::ALL { + let environments = environments_for_color.clone(); + let mut item = PopupMenuItem::new(color.label()); + if color == environment_color { + item = item.icon(IconName::Check); + } + menu = menu.item(item.on_click(move |_, _, cx| { + let color = color.clone(); + environments.update(cx, |environments, cx| { + environments.set_environment_color(environment_id, color, cx); + }); + })); + } + + if let Some(callback) = on_delete.clone() { + menu = menu.separator().item( + PopupMenuItem::new("Delete").icon(IconName::Trash).on_click( + move |_, window, cx| { + callback(environment_id, window, cx); + }, + ), + ); + } + menu + }) + } + + fn render_environment_row( + &self, + environment: &Environment, + this: Entity, + cx: &App, + ) -> AnyElement { + let theme = cx.theme(); + let id = environment.id; + let selected = self.selected_environment_id == Some(id); + let active = self.environments.read(cx).is_active(id); + let color = environment.color.accent(); + let variable_count = environment.variables.len(); + let this_for_select = this.clone(); + + div() + .id(SharedString::from(format!("environment-row-{id}"))) + .h(px(40.0)) + .px(px(8.0)) + .flex() + .items_center() + .gap(px(8.0)) + .rounded(px(6.0)) + .cursor_pointer() + .when(selected, |element| { + element + .bg(theme.sidebar_accent) + .border_1() + .border_color(theme.border.opacity(0.9)) + }) + .when(!selected, |element| { + element.hover(|element| element.bg(theme.sidebar_accent.opacity(0.55))) + }) + .on_click(move |_, _, cx| { + this_for_select.update(cx, |panel, cx| { + panel.select_environment(id, cx); + }); + }) + .child( + div() + .size(px(9.0)) + .rounded_full() + .bg(color) + .when(active, |element| { + element.border_2().border_color(theme.sidebar).shadow_sm() + }), + ) + .child( + div() + .flex_1() + .min_w_0() + .flex() + .items_center() + .gap(px(6.0)) + .child( + div() + .min_w_0() + .text_sm() + .truncate() + .text_color(if selected { + theme.foreground + } else { + theme.secondary_foreground + }) + .child(environment.name.clone()), + ) + .when(active, |element| { + element.child( + div() + .px(px(5.0)) + .py(px(1.0)) + .rounded(px(3.0)) + .bg(color.opacity(0.13)) + .text_size(px(9.0)) + .font_weight(FontWeight::SEMIBOLD) + .text_color(color) + .child("ACTIVE"), + ) + }), + ) + .child( + div() + .text_xs() + .text_color(theme.muted_foreground) + .child(variable_count.to_string()), + ) + .child(self.render_environment_menu(environment, selected, this)) + .into_any_element() + } + + fn render_active_stack(&self, cx: &App) -> AnyElement { + let theme = cx.theme(); + let (global, workspace, project) = { + let environments = self.environments.read(cx); + let global = environments + .active_global_environment_id() + .and_then(|id| environments.get(id)) + .cloned(); + let workspace = environments + .active_workspace_environment_id() + .and_then(|id| environments.get(id)) + .cloned(); + let project = self + .collection_id + .and_then(|id| environments.active_project_environment_id(id)) + .and_then(|id| environments.get(id)) + .cloned(); + (global, workspace, project) + }; + + let layer = |environment: &Environment, suffix: &str| { + div() + .flex() + .items_center() + .gap(px(6.0)) + .min_w_0() + .child( + div() + .size(px(7.0)) + .rounded_full() + .bg(environment.color.accent()), + ) + .child( + div() + .flex_1() + .min_w_0() + .truncate() + .text_xs() + .text_color(theme.secondary_foreground) + .child(environment.name.clone()), + ) + .child( + div() + .flex_shrink_0() + .text_size(px(9.0)) + .text_color(theme.muted_foreground) + .child(suffix.to_string()), + ) + }; + + div() + .mx(px(10.0)) + .mt(px(8.0)) + .mb(px(6.0)) + .p(px(9.0)) + .rounded(px(7.0)) + .border_1() + .border_color(theme.border) + .bg(theme.background.opacity(0.42)) + .flex() + .flex_col() + .gap(px(6.0)) + .child( + div() + .text_size(px(9.0)) + .font_weight(FontWeight::SEMIBOLD) + .text_color(theme.muted_foreground) + .child("VARIABLE PRECEDENCE"), + ) + .when_some(global.as_ref(), |element, environment| { + element.child(layer(environment, "global base")) + }) + .when_some(workspace.as_ref(), |element, environment| { + element + .when(global.is_some(), |element| { + element.child(div().ml(px(2.0)).h(px(7.0)).w(px(1.0)).bg(theme.border)) + }) + .child(layer(environment, "workspace")) + }) + .when_some(project.as_ref(), |element, environment| { + element + .child(div().ml(px(2.0)).h(px(7.0)).w(px(1.0)).bg(theme.border)) + .child(layer(environment, "overrides")) + }) + .when( + global.is_none() && workspace.is_none() && project.is_none(), + |element| { + element.child( + div() + .text_xs() + .text_color(theme.muted_foreground) + .child("No environment is active"), + ) + }, + ) + .into_any_element() + } + + fn has_layered_precedence(&self, cx: &App) -> bool { + let environments = self.environments.read(cx); + let active_layers = usize::from(environments.active_global_environment_id().is_some()) + + usize::from(environments.active_workspace_environment_id().is_some()) + + usize::from( + self.collection_id + .and_then(|id| environments.active_project_environment_id(id)) + .is_some(), + ); + active_layers > 1 + } + + fn render_empty_state(&self, theme: &gpui_component::theme::ThemeColor) -> AnyElement { + let on_new = self.on_new_environment.clone(); + let collection_id = self.collection_id; + div() + .flex_1() + .flex() + .flex_col() + .items_center() + .justify_center() + .px(px(28.0)) + .gap(px(9.0)) + .child( + div() + .size(px(36.0)) + .rounded(px(9.0)) + .flex() + .items_center() + .justify_center() + .bg(theme.primary.opacity(0.11)) + .child( + gpui_component::Icon::new(IconName::Package) + .size(px(17.0)) + .text_color(theme.primary), + ), + ) + .child( + div() + .text_sm() + .font_weight(FontWeight::SEMIBOLD) + .child("Create your first environment"), + ) + .child( + div() + .text_xs() + .text_center() + .text_color(theme.muted_foreground) + .child("Keep base URLs and credentials reusable across requests."), + ) + .child( + Button::new("environment-empty-new") + .label("New environment") + .icon(IconName::Plus) + .primary() + .small() + .on_click(move |_, window, cx| { + if let Some(ref callback) = on_new { + callback(collection_id, window, cx); + } + }), + ) + .into_any_element() + } +} + +impl Focusable for EnvironmentPanel { + fn focus_handle(&self, _cx: &App) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl Render for EnvironmentPanel { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + self.completion_engine.set_collection_id(self.collection_id); + self.ensure_color_picker(window, cx); + self.sync_rows(window, cx); + let theme = cx.theme(); + let this = cx.entity().clone(); + let environments = self.environments.read(cx).environments().to_vec(); + let selected = self + .selected_environment_id + .and_then(|id| environments.iter().find(|environment| environment.id == id)) + .cloned(); + let on_new = self.on_new_environment.clone(); + let on_import = self.on_import_environment.clone(); + let collection_id = self.collection_id; + let has_layered_precedence = self.has_layered_precedence(cx); + let color_picker = self.color_picker.clone(); + + let mut project_groups: HashMap> = HashMap::new(); + let mut global_environments = Vec::new(); + let mut workspace_environments = Vec::new(); + for environment in &environments { + match environment.scope { + EnvironmentScope::Global => global_environments.push(environment.clone()), + EnvironmentScope::Workspace => workspace_environments.push(environment.clone()), + EnvironmentScope::Project(project_id) => project_groups + .entry(project_id) + .or_default() + .push(environment.clone()), + } + } + + let mut navigator_groups = Vec::new(); + if !global_environments.is_empty() { + let rows = global_environments + .iter() + .map(|environment| self.render_environment_row(environment, this.clone(), cx)) + .collect::>(); + navigator_groups.push( + div() + .flex() + .flex_col() + .gap(px(2.0)) + .child( + div() + .px(px(8.0)) + .pt(px(5.0)) + .pb(px(3.0)) + .text_size(px(9.0)) + .font_weight(FontWeight::SEMIBOLD) + .text_color(theme.muted_foreground) + .child("GLOBAL"), + ) + .children(rows) + .into_any_element(), + ); + } + if !workspace_environments.is_empty() { + let rows = workspace_environments + .iter() + .map(|environment| self.render_environment_row(environment, this.clone(), cx)) + .collect::>(); + navigator_groups.push( + div() + .flex() + .flex_col() + .gap(px(2.0)) + .child( + div() + .px(px(8.0)) + .pt(px(5.0)) + .pb(px(3.0)) + .text_size(px(9.0)) + .font_weight(FontWeight::SEMIBOLD) + .text_color(theme.muted_foreground) + .child("WORKSPACE"), + ) + .children(rows) + .into_any_element(), + ); + } + + let collections = self.collections.read(cx).collections.clone(); + for collection in collections { + let Some(group) = project_groups.remove(&collection.id) else { + continue; + }; + let rows = group + .iter() + .map(|environment| self.render_environment_row(environment, this.clone(), cx)) + .collect::>(); + navigator_groups.push( + div() + .flex() + .flex_col() + .gap(px(2.0)) + .child( + div() + .px(px(8.0)) + .pt(px(8.0)) + .pb(px(3.0)) + .flex() + .items_center() + .gap(px(5.0)) + .text_size(px(9.0)) + .font_weight(FontWeight::SEMIBOLD) + .text_color(theme.muted_foreground) + .child(gpui_component::Icon::new(IconName::Folder).size(px(10.0))) + .child(collection.name.to_uppercase()), + ) + .children(rows) + .into_any_element(), + ); + } + + for (_project_id, group) in project_groups { + let rows = group + .iter() + .map(|environment| self.render_environment_row(environment, this.clone(), cx)) + .collect::>(); + navigator_groups.push( + div() + .flex() + .flex_col() + .gap(px(2.0)) + .child( + div() + .px(px(8.0)) + .pt(px(8.0)) + .pb(px(3.0)) + .text_size(px(9.0)) + .font_weight(FontWeight::SEMIBOLD) + .text_color(theme.muted_foreground) + .child("UNLINKED PROJECT"), + ) + .children(rows) + .into_any_element(), + ); + } + + let editor = if let Some(environment) = selected { + let environment_id = environment.id; + let color = environment.color.accent(); + let scope_label = self.scope_label(environment.scope, cx); + let mut key_counts = HashMap::new(); + for variable in &environment.variables { + let key = variable.key.trim(); + if !key.is_empty() { + *key_counts.entry(key.to_ascii_lowercase()).or_insert(0usize) += 1; + } + } + let duplicate_key_count = key_counts.values().filter(|count| **count > 1).count(); + let environments_for_add = self.environments.clone(); + let environments_for_header_add = self.environments.clone(); + let rows = self + .rows + .iter() + .map(|row| { + let variable_id = row.id; + let environments_for_toggle = self.environments.clone(); + let environments_for_secret = self.environments.clone(); + let environments_for_duplicate = self.environments.clone(); + let environments_for_remove = self.environments.clone(); + let secret = row.secret; + + div() + .id(SharedString::from(format!( + "environment-variable-{variable_id}" + ))) + .px(px(10.0)) + .py(px(8.0)) + .flex() + .flex_col() + .gap(px(6.0)) + .border_b_1() + .border_color(theme.border.opacity(0.72)) + .when(!row.enabled, |element| element.opacity(0.5)) + .child( + div() + .flex() + .items_center() + .gap(px(7.0)) + .child( + Checkbox::new(SharedString::from(format!( + "environment-enabled-{variable_id}" + ))) + .checked(row.enabled) + .on_click( + move |_, _, cx| { + environments_for_toggle.update( + cx, + |environments, cx| { + environments.toggle_variable( + environment_id, + variable_id, + cx, + ); + }, + ); + }, + ), + ) + .child( + div() + .flex_1() + .min_w_0() + .h(px(29.0)) + .rounded(px(5.0)) + .bg(theme.muted.opacity(0.7)) + .px(px(7.0)) + .child( + Input::new(&row.key_input).appearance(false).xsmall(), + ), + ) + .child( + Button::new(SharedString::from(format!( + "environment-variable-more-{variable_id}" + ))) + .icon(IconName::Ellipsis) + .ghost() + .xsmall() + .tooltip("Variable actions") + .dropdown_menu( + move |menu, _window, _cx| { + let environments = environments_for_secret.clone(); + let secret_item = PopupMenuItem::new(if secret { + "Make regular value" + } else { + "Mark as secret" + }) + .icon(if secret { + IconName::Unlock + } else { + IconName::Lock + }) + .on_click(move |_, _, cx| { + environments.update(cx, |environments, cx| { + environments.toggle_secret( + environment_id, + variable_id, + cx, + ); + }); + }); + let environments = environments_for_duplicate.clone(); + let duplicate_item = PopupMenuItem::new("Duplicate") + .icon(IconName::CopyPlus) + .on_click(move |_, _, cx| { + environments.update(cx, |environments, cx| { + environments.duplicate_variable( + environment_id, + variable_id, + cx, + ); + }); + }); + let environments = environments_for_remove.clone(); + let delete_item = PopupMenuItem::new("Delete") + .icon(IconName::Trash) + .on_click(move |_, _, cx| { + environments.update(cx, |environments, cx| { + environments.remove_variable( + environment_id, + variable_id, + cx, + ); + }); + }); + menu.item(secret_item) + .item(duplicate_item) + .separator() + .item(delete_item) + }, + ), + ), + ) + .child( + div() + .ml(px(27.0)) + .h(px(30.0)) + .rounded(px(5.0)) + .border_1() + .border_color(theme.input) + .bg(theme.background.opacity(0.55)) + .px(px(7.0)) + .child(CompletionInput::new( + &row.value_input, + Input::new(&row.value_input) + .appearance(false) + .xsmall() + .when(row.secret, |input| input.mask_toggle()), + )), + ) + .when(row.secret, |element| { + element.child( + div() + .ml(px(29.0)) + .flex() + .items_center() + .gap(px(4.0)) + .text_size(px(9.0)) + .text_color(theme.muted_foreground) + .child(gpui_component::Icon::new(IconName::Lock).size(px(9.0))) + .child("Local secret · excluded from history"), + ) + }) + }) + .collect::>(); + + div() + .h(px(0.0)) + .flex_1() + .min_h_0() + .flex() + .flex_col() + .border_t_1() + .border_color(theme.border) + .child( + div() + .px(px(11.0)) + .py(px(9.0)) + .flex() + .items_center() + .gap(px(8.0)) + .bg(theme.background.opacity(0.35)) + .child(div().w(px(3.0)).h(px(30.0)).rounded_full().bg(color)) + .child( + div() + .flex_1() + .min_w_0() + .flex() + .flex_col() + .gap(px(1.0)) + .child( + div() + .truncate() + .text_sm() + .font_weight(FontWeight::SEMIBOLD) + .child(environment.name), + ) + .child( + div() + .truncate() + .text_size(px(10.0)) + .text_color(theme.muted_foreground) + .child(scope_label), + ), + ) + .child( + div() + .px(px(6.0)) + .py(px(2.0)) + .rounded(px(4.0)) + .bg(theme.muted) + .text_size(px(9.0)) + .text_color(theme.muted_foreground) + .child(format!( + "{} VAR{}", + self.rows.len(), + if self.rows.len() == 1 { "" } else { "S" } + )), + ) + .when(duplicate_key_count > 0, |element| { + element.child( + div() + .px(px(6.0)) + .py(px(2.0)) + .rounded(px(4.0)) + .bg(theme.warning.opacity(0.12)) + .text_size(px(9.0)) + .font_weight(FontWeight::SEMIBOLD) + .text_color(theme.warning) + .child("DUPLICATE KEYS"), + ) + }) + .when_some(color_picker, |element, picker| { + element.child( + ColorPicker::new(&picker) + .featured_colors( + EnvironmentColor::ALL + .iter() + .map(EnvironmentColor::accent) + .collect(), + ) + .xsmall(), + ) + }) + .child( + Button::new("environment-add-variable-header") + .icon(IconName::Plus) + .label("Add") + .ghost() + .xsmall() + .tooltip("Add a variable") + .on_click(move |_, _, cx| { + environments_for_header_add.update(cx, |environments, cx| { + environments.add_variable(environment_id, cx); + }); + }), + ), + ) + .child( + div() + .flex_1() + .min_h_0() + .overflow_y_scrollbar() + .children(rows) + .when(self.rows.is_empty(), |element| { + element.child( + div() + .h(px(110.0)) + .flex() + .flex_col() + .items_center() + .justify_center() + .gap(px(5.0)) + .text_color(theme.muted_foreground) + .child( + gpui_component::Icon::new(IconName::Variable) + .size(px(16.0)), + ) + .child( + div().text_xs().child("No variables in this environment"), + ), + ) + }), + ) + .child( + div() + .p(px(9.0)) + .border_t_1() + .border_color(theme.border) + .child( + Button::new("environment-add-variable") + .w_full() + .justify_center() + .label("New variable") + .icon(IconName::Plus) + .outline() + .small() + .on_click(move |_, _, cx| { + environments_for_add.update(cx, |environments, cx| { + environments.add_variable(environment_id, cx); + }); + }), + ), + ) + .into_any_element() + } else { + self.render_empty_state(&theme) + }; + + div() + .flex() + .flex_col() + .w_full() + .h_full() + .min_h_0() + .bg(theme.sidebar) + .child( + div() + .h(px(52.0)) + .px(px(12.0)) + .flex() + .items_center() + .justify_between() + .border_b_1() + .border_color(theme.border) + .child( + div() + .flex() + .items_center() + .gap(px(8.0)) + .child( + div() + .size(px(28.0)) + .rounded(px(7.0)) + .flex() + .items_center() + .justify_center() + .bg(theme.primary.opacity(0.11)) + .child( + gpui_component::Icon::new(IconName::Package) + .size(px(14.0)) + .text_color(theme.primary), + ), + ) + .child( + div() + .flex() + .flex_col() + .gap(px(1.0)) + .child( + div() + .text_sm() + .font_weight(FontWeight::SEMIBOLD) + .child("Environments"), + ) + .child( + div() + .text_size(px(9.0)) + .text_color(theme.muted_foreground) + .child("Workspace and project values"), + ), + ), + ) + .child( + div() + .flex() + .items_center() + .gap(px(2.0)) + .child( + Button::new("environment-import") + .icon(IconName::FileUp) + .ghost() + .xsmall() + .tooltip("Import Postman data") + .on_click(move |_, window, cx| { + if let Some(ref callback) = on_import { + callback(window, cx); + } + }), + ) + .child( + Button::new("environment-new") + .icon(IconName::Plus) + .ghost() + .xsmall() + .tooltip("New environment") + .on_click(move |_, window, cx| { + if let Some(ref callback) = on_new { + callback(collection_id, window, cx); + } + }), + ), + ), + ) + .when(!environments.is_empty(), |element| { + element + .when(has_layered_precedence, |element| { + element.child(self.render_active_stack(cx)) + }) + .child( + div() + // The scrollbar wrapper only inherits concrete sizes from its + // child, so this must be an explicit height rather than max_h. + .h(px(190.0)) + .flex_shrink_0() + .overflow_y_scrollbar() + .px(px(7.0)) + .pt(px(6.0)) + .pb(px(8.0)) + .flex() + .flex_col() + .gap(px(3.0)) + .children(navigator_groups), + ) + }) + .child( + div() + .h(px(0.0)) + .flex_1() + .min_h_0() + .flex() + .flex_col() + .child(editor), + ) + } +} diff --git a/src/components/form_data_editor.rs b/src/components/form_data_editor.rs index 6fe0c30..0609edc 100644 --- a/src/components/form_data_editor.rs +++ b/src/components/form_data_editor.rs @@ -11,6 +11,10 @@ use gpui_component::input::{Input, InputState}; use crate::icons::IconName; use gpui_component::ActiveTheme; +use crate::completion::{ + CompletionContext, CompletionEngine, CompletionInput, configure_completion, +}; + pub struct FormDataRow { pub key_input: Entity, pub value_input: Entity, @@ -34,20 +38,36 @@ struct DraggedRow { pub struct FormDataEditor { rows: Vec, focus_handle: FocusHandle, + completion_engine: Option, } #[allow(dead_code)] impl FormDataEditor { - pub fn new(cx: &mut Context) -> Self { + pub fn new(completion_engine: Option, cx: &mut Context) -> Self { Self { rows: Vec::new(), focus_handle: cx.focus_handle(), + completion_engine, } } pub fn add_row(&mut self, window: &mut Window, cx: &mut Context) { - let key_input = cx.new(|cx| InputState::new(window, cx).placeholder("Key")); - let value_input = cx.new(|cx| InputState::new(window, cx).placeholder("Value")); + let completion_engine = self.completion_engine.clone(); + let key_input = cx.new(|cx| { + configure_completion( + InputState::new(window, cx).placeholder("Key"), + completion_engine.as_ref(), + CompletionContext::FormName, + ) + }); + let completion_engine = self.completion_engine.clone(); + let value_input = cx.new(|cx| { + configure_completion( + InputState::new(window, cx).placeholder("Value"), + completion_engine.as_ref(), + CompletionContext::FormValue, + ) + }); self.rows.push(FormDataRow { key_input, @@ -142,15 +162,25 @@ impl FormDataEditor { ) }; + let completion_engine = self.completion_engine.clone(); let key_input = cx.new(|cx| { - InputState::new(window, cx) - .placeholder("Key") - .default_value(&key) + configure_completion( + InputState::new(window, cx) + .placeholder("Key") + .default_value(&key), + completion_engine.as_ref(), + CompletionContext::FormName, + ) }); + let completion_engine = self.completion_engine.clone(); let value_input = cx.new(|cx| { - InputState::new(window, cx) - .placeholder("Value") - .default_value(&value) + configure_completion( + InputState::new(window, cx) + .placeholder("Value") + .default_value(&value), + completion_engine.as_ref(), + CompletionContext::FormValue, + ) }); self.rows.push(FormDataRow { @@ -172,15 +202,25 @@ impl FormDataEditor { self.rows.clear(); for (key, value) in data { + let completion_engine = self.completion_engine.clone(); let key_input = cx.new(|cx| { - InputState::new(window, cx) - .placeholder("Key") - .default_value(key) + configure_completion( + InputState::new(window, cx) + .placeholder("Key") + .default_value(key), + completion_engine.as_ref(), + CompletionContext::FormName, + ) }); + let completion_engine = self.completion_engine.clone(); let value_input = cx.new(|cx| { - InputState::new(window, cx) - .placeholder("Value") - .default_value(value) + configure_completion( + InputState::new(window, cx) + .placeholder("Value") + .default_value(value), + completion_engine.as_ref(), + CompletionContext::FormValue, + ) }); self.rows.push(FormDataRow { @@ -386,18 +426,14 @@ impl Render for FormDataEditor { ), ), ) - .child( - div() - .flex_1() - .pr(px(8.0)) - .child(Input::new(&row.key_input).appearance(false).xsmall()), - ) - .child( - div() - .flex_1() - .pr(px(8.0)) - .child(Input::new(&row.value_input).appearance(false).xsmall()), - ) + .child(div().flex_1().pr(px(8.0)).child(CompletionInput::new( + &row.key_input, + Input::new(&row.key_input).appearance(false).xsmall(), + ))) + .child(div().flex_1().pr(px(8.0)).child(CompletionInput::new( + &row.value_input, + Input::new(&row.value_input).appearance(false).xsmall(), + ))) .child( div() .w(px(40.0)) diff --git a/src/components/header_editor.rs b/src/components/header_editor.rs index 72343f8..5ab75d4 100644 --- a/src/components/header_editor.rs +++ b/src/components/header_editor.rs @@ -12,6 +12,10 @@ use crate::entities::{Header, RequestEntity}; use crate::icons::IconName; use gpui_component::ActiveTheme; +use crate::completion::{ + CompletionContext, CompletionEngine, CompletionInput, configure_completion, +}; + pub struct HeaderRow { pub key_input: Entity, pub value_input: Entity, @@ -32,10 +36,15 @@ pub struct HeaderEditor { header_rows: Vec, pending_initial_headers: Option>, focus_handle: FocusHandle, + completion_engine: Option, } impl HeaderEditor { - pub fn new(request: Entity, cx: &mut Context) -> Self { + pub fn new( + request: Entity, + completion_engine: Option, + cx: &mut Context, + ) -> Self { cx.subscribe(&request, |this, _request, _event, cx| { this.sync_headers_from_request(cx); cx.notify(); @@ -47,6 +56,7 @@ impl HeaderEditor { header_rows: Vec::new(), pending_initial_headers: None, focus_handle: cx.focus_handle(), + completion_engine, }; editor.sync_headers_from_request(cx); @@ -80,15 +90,25 @@ impl HeaderEditor { }; for (key, value, enabled) in headers { + let completion_engine = self.completion_engine.clone(); let key_input = cx.new(|cx| { - InputState::new(window, cx) - .placeholder("Header name") - .default_value(&key) + configure_completion( + InputState::new(window, cx) + .placeholder("Header name") + .default_value(&key), + completion_engine.as_ref(), + CompletionContext::HeaderName, + ) }); + let completion_engine = self.completion_engine.clone(); let value_input = cx.new(|cx| { - InputState::new(window, cx) - .placeholder("Value") - .default_value(&value) + configure_completion( + InputState::new(window, cx) + .placeholder("Value") + .default_value(&value), + completion_engine.as_ref(), + CompletionContext::HeaderValue, + ) }); let description_input = cx.new(|cx| InputState::new(window, cx).placeholder("Description")); @@ -106,8 +126,22 @@ impl HeaderEditor { /// Add a new empty header row pub fn add_header(&mut self, window: &mut Window, cx: &mut Context) { - let key_input = cx.new(|cx| InputState::new(window, cx).placeholder("Header name")); - let value_input = cx.new(|cx| InputState::new(window, cx).placeholder("Value")); + let completion_engine = self.completion_engine.clone(); + let key_input = cx.new(|cx| { + configure_completion( + InputState::new(window, cx).placeholder("Header name"), + completion_engine.as_ref(), + CompletionContext::HeaderName, + ) + }); + let completion_engine = self.completion_engine.clone(); + let value_input = cx.new(|cx| { + configure_completion( + InputState::new(window, cx).placeholder("Value"), + completion_engine.as_ref(), + CompletionContext::HeaderValue, + ) + }); let description_input = cx.new(|cx| InputState::new(window, cx).placeholder("Description")); self.header_rows.push(HeaderRow { @@ -134,15 +168,25 @@ impl HeaderEditor { let key_for_input = key_owned.clone(); let value_for_input = value_owned.clone(); + let completion_engine = self.completion_engine.clone(); let key_input = cx.new(|cx| { - InputState::new(window, cx) - .placeholder("Header name") - .default_value(&key_for_input) + configure_completion( + InputState::new(window, cx) + .placeholder("Header name") + .default_value(&key_for_input), + completion_engine.as_ref(), + CompletionContext::HeaderName, + ) }); + let completion_engine = self.completion_engine.clone(); let value_input = cx.new(|cx| { - InputState::new(window, cx) - .placeholder("Value") - .default_value(&value_for_input) + configure_completion( + InputState::new(window, cx) + .placeholder("Value") + .default_value(&value_for_input), + completion_engine.as_ref(), + CompletionContext::HeaderValue, + ) }); let description_input = cx.new(|cx| InputState::new(window, cx).placeholder("Description")); @@ -447,19 +491,16 @@ impl Render for HeaderEditor { ), ), ) - .child( - div() - .w(px(150.0)) - .min_w(px(150.0)) - .pr(px(8.0)) - .child(Input::new(&row.key_input).appearance(false).xsmall()), - ) - .child( - div() - .flex_1() - .pr(px(8.0)) - .child(Input::new(&row.value_input).appearance(false).xsmall()), - ) + .child(div().w(px(150.0)).min_w(px(150.0)).pr(px(8.0)).child( + CompletionInput::new( + &row.key_input, + Input::new(&row.key_input).appearance(false).xsmall(), + ), + )) + .child(div().flex_1().pr(px(8.0)).child(CompletionInput::new( + &row.value_input, + Input::new(&row.value_input).appearance(false).xsmall(), + ))) .child( div().w(px(150.0)).min_w(px(150.0)).pr(px(8.0)).child( Input::new(&row.description_input) diff --git a/src/components/mod.rs b/src/components/mod.rs index e2e21af..1d8f8f2 100644 --- a/src/components/mod.rs +++ b/src/components/mod.rs @@ -4,6 +4,7 @@ pub mod auth_editor; pub mod body_type_selector; pub mod collections_panel; pub mod custom_dropdown; +pub mod environment_panel; pub mod form_data_editor; pub mod header_editor; pub mod history_panel; @@ -21,6 +22,7 @@ pub use app_sidebar::*; pub use auth_editor::*; pub use body_type_selector::*; pub use custom_dropdown::*; +pub use environment_panel::*; pub use form_data_editor::*; pub use header_editor::*; pub use history_panel::*; diff --git a/src/components/multipart_form_data_editor.rs b/src/components/multipart_form_data_editor.rs index b6df77d..6155284 100644 --- a/src/components/multipart_form_data_editor.rs +++ b/src/components/multipart_form_data_editor.rs @@ -12,6 +12,10 @@ use std::path::PathBuf; use crate::icons::IconName; use gpui_component::ActiveTheme; +use crate::completion::{ + CompletionContext, CompletionEngine, CompletionInput, configure_completion, +}; + pub struct MultipartFormRow { pub key_input: Entity, pub value_input: Entity, @@ -38,20 +42,36 @@ struct DraggedMultipartRow { pub struct MultipartFormDataEditor { rows: Vec, focus_handle: FocusHandle, + completion_engine: Option, } #[allow(dead_code)] impl MultipartFormDataEditor { - pub fn new(cx: &mut Context) -> Self { + pub fn new(completion_engine: Option, cx: &mut Context) -> Self { Self { rows: Vec::new(), focus_handle: cx.focus_handle(), + completion_engine, } } pub fn add_row(&mut self, window: &mut Window, cx: &mut Context) { - let key_input = cx.new(|cx| InputState::new(window, cx).placeholder("Key")); - let value_input = cx.new(|cx| InputState::new(window, cx).placeholder("Value")); + let completion_engine = self.completion_engine.clone(); + let key_input = cx.new(|cx| { + configure_completion( + InputState::new(window, cx).placeholder("Key"), + completion_engine.as_ref(), + CompletionContext::FormName, + ) + }); + let completion_engine = self.completion_engine.clone(); + let value_input = cx.new(|cx| { + configure_completion( + InputState::new(window, cx).placeholder("Value"), + completion_engine.as_ref(), + CompletionContext::FormValue, + ) + }); self.rows.push(MultipartFormRow { key_input, @@ -168,15 +188,25 @@ impl MultipartFormDataEditor { self.rows.clear(); for field in fields { + let completion_engine = self.completion_engine.clone(); let key_input = cx.new(|cx| { - InputState::new(window, cx) - .placeholder("Key") - .default_value(&field.key) + configure_completion( + InputState::new(window, cx) + .placeholder("Key") + .default_value(&field.key), + completion_engine.as_ref(), + CompletionContext::FormName, + ) }); + let completion_engine = self.completion_engine.clone(); let value_input = cx.new(|cx| { - InputState::new(window, cx) - .placeholder("Value") - .default_value(&field.value) + configure_completion( + InputState::new(window, cx) + .placeholder("Value") + .default_value(&field.value), + completion_engine.as_ref(), + CompletionContext::FormValue, + ) }); let file_path = field.file_path.as_ref().map(PathBuf::from); @@ -441,13 +471,12 @@ impl Render for MultipartFormDataEditor { ), ), ) - .child( - div() - .flex_1() - .min_w(px(80.0)) - .pr(px(8.0)) - .child(Input::new(&row.key_input).appearance(false).xsmall()), - ) + .child(div().flex_1().min_w(px(80.0)).pr(px(8.0)).child( + CompletionInput::new( + &row.key_input, + Input::new(&row.key_input).appearance(false).xsmall(), + ), + )) .child( div() .flex_1() @@ -457,9 +486,10 @@ impl Render for MultipartFormDataEditor { .flex_row() .items_center() .when(!has_file, |el| { - el.child( + el.child(CompletionInput::new( + &row.value_input, Input::new(&row.value_input).appearance(false).xsmall(), - ) + )) }) .when(has_file, |el| { el.child( diff --git a/src/components/params_editor.rs b/src/components/params_editor.rs index c340134..aef3a1c 100644 --- a/src/components/params_editor.rs +++ b/src/components/params_editor.rs @@ -11,6 +11,8 @@ use gpui_component::input::{Input, InputState}; use crate::icons::IconName; use gpui_component::ActiveTheme; +use crate::completion::{CompletionContext, CompletionEngine, CompletionInput}; + pub struct ParamRow { pub key_input: Entity, pub value_input: Entity, @@ -37,20 +39,38 @@ struct DraggedParam { pub struct ParamsEditor { param_rows: Vec, focus_handle: FocusHandle, + completion_engine: Option, } impl ParamsEditor { - pub fn new(cx: &mut Context) -> Self { + pub fn new(completion_engine: Option, cx: &mut Context) -> Self { Self { param_rows: Vec::new(), focus_handle: cx.focus_handle(), + completion_engine, } } /// Add a new empty param row pub fn add_param(&mut self, window: &mut Window, cx: &mut Context) { - let key_input = cx.new(|cx| InputState::new(window, cx).placeholder("Param name")); - let value_input = cx.new(|cx| InputState::new(window, cx).placeholder("Value")); + let completion_engine = self.completion_engine.clone(); + let key_input = cx.new(|cx| { + let input = InputState::new(window, cx).placeholder("Param name"); + if let Some(engine) = completion_engine.as_ref() { + engine.configure_input(input, CompletionContext::QueryName) + } else { + input + } + }); + let completion_engine = self.completion_engine.clone(); + let value_input = cx.new(|cx| { + let input = InputState::new(window, cx).placeholder("Value"); + if let Some(engine) = completion_engine.as_ref() { + engine.configure_input(input, CompletionContext::QueryValue) + } else { + input + } + }); let description_input = cx.new(|cx| InputState::new(window, cx).placeholder("Description")); self.param_rows.push(ParamRow { @@ -331,19 +351,16 @@ impl Render for ParamsEditor { ), ), ) - .child( - div() - .w(px(150.0)) - .min_w(px(150.0)) - .pr(px(8.0)) - .child(Input::new(&row.key_input).appearance(false).xsmall()), - ) - .child( - div() - .flex_1() - .pr(px(8.0)) - .child(Input::new(&row.value_input).appearance(false).xsmall()), - ) + .child(div().w(px(150.0)).min_w(px(150.0)).pr(px(8.0)).child( + CompletionInput::new( + &row.key_input, + Input::new(&row.key_input).appearance(false).xsmall(), + ), + )) + .child(div().flex_1().pr(px(8.0)).child(CompletionInput::new( + &row.value_input, + Input::new(&row.value_input).appearance(false).xsmall(), + ))) .child( div().w(px(150.0)).min_w(px(150.0)).pr(px(8.0)).child( Input::new(&row.description_input) diff --git a/src/components/url_bar.rs b/src/components/url_bar.rs index 52a331a..af8ca50 100644 --- a/src/components/url_bar.rs +++ b/src/components/url_bar.rs @@ -6,6 +6,7 @@ use gpui_component::input::{Input, InputState}; use gpui_component::menu::{PopupMenu, PopupMenuItem}; use std::rc::Rc; +use crate::completion::CompletionInput; use crate::components::{MethodDropdown, MethodDropdownState}; use crate::entities::RequestEntity; use crate::icons::IconName; @@ -158,11 +159,12 @@ impl RenderOnce for UrlBar { .items_center() .h_full() .px(px(8.0)) - .child( + .child(CompletionInput::new( + &self.input_state, Input::new(&self.input_state) .appearance(false) // Remove default styling .size_full(), - ), + )), ) .child(div().mr(px(4.0)).child(split_button)) } diff --git a/src/entities/collections.rs b/src/entities/collections.rs index 71b7974..0f2444a 100644 --- a/src/entities/collections.rs +++ b/src/entities/collections.rs @@ -1,5 +1,6 @@ use gpui::{Context, EventEmitter}; use serde::{Deserialize, Serialize}; +use std::collections::HashMap; use std::fmt; use std::path::PathBuf; use std::time::Duration; @@ -8,9 +9,9 @@ use uuid::Uuid; use crate::importers::{ImportedCollection, ImportedNode}; use crate::utils::{DebouncedJsonWriter, shared_tokio_runtime}; -use super::{RequestData, SidebarLoadState}; +use super::{RequestData, SidebarLoadState, default_workspace_id}; -const COLLECTIONS_STORAGE_VERSION: u32 = 1; +const COLLECTIONS_STORAGE_VERSION: u32 = 2; const SAVE_DEBOUNCE: Duration = Duration::from_secs(1); fn default_expanded() -> bool { @@ -240,6 +241,12 @@ impl Collection { #[derive(Debug, Clone, Serialize, Deserialize)] struct CollectionsStore { + version: u32, + workspaces: HashMap>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct LegacyVersionedCollectionsStore { version: u32, collections: Vec, } @@ -335,6 +342,8 @@ pub enum CollectionsEvent { pub struct CollectionsEntity { pub collections: Vec, pub load_state: SidebarLoadState, + active_workspace_id: Uuid, + workspace_collections: HashMap>, revision: u64, persistor: Option>, } @@ -342,10 +351,16 @@ pub struct CollectionsEntity { #[allow(dead_code)] impl CollectionsEntity { pub fn new() -> Self { + Self::new_for_workspace(default_workspace_id()) + } + + pub fn new_for_workspace(active_workspace_id: Uuid) -> Self { let storage_path = Self::get_storage_path(); let entity = Self { collections: Vec::new(), load_state: SidebarLoadState::Loading, + active_workspace_id, + workspace_collections: HashMap::new(), revision: 0, persistor: storage_path .clone() @@ -364,18 +379,18 @@ impl CollectionsEntity { } pub fn spawn_storage_load() - -> tokio::sync::oneshot::Receiver, bool), String>> { + -> tokio::sync::oneshot::Receiver>, bool), String>> { let (tx, rx) = tokio::sync::oneshot::channel(); let path = Self::get_storage_path(); shared_tokio_runtime().spawn(async move { let result = async { let Some(path) = path else { - return Ok((Vec::new(), false)); + return Ok((HashMap::new(), false)); }; match tokio::fs::read_to_string(path).await { Ok(contents) => deserialize_store(&contents).map_err(|error| error.to_string()), Err(error) if error.kind() == std::io::ErrorKind::NotFound => { - Ok((Vec::new(), false)) + Ok((HashMap::new(), false)) } Err(error) => Err(error.to_string()), } @@ -388,12 +403,15 @@ impl CollectionsEntity { pub fn apply_storage_load( &mut self, - result: Result<(Vec, bool), String>, + result: Result<(HashMap>, bool), String>, cx: &mut Context, ) { match result { - Ok((collections, migrated)) => { - self.collections = collections; + Ok((mut workspaces, migrated)) => { + self.collections = workspaces + .remove(&self.active_workspace_id) + .unwrap_or_default(); + self.workspace_collections = workspaces; self.load_state = SidebarLoadState::Ready; self.bump_revision(); if migrated { @@ -407,14 +425,40 @@ impl CollectionsEntity { fn save_to_file(&self) { if let Some(persistor) = &self.persistor { + let mut workspaces = self.workspace_collections.clone(); + workspaces.insert(self.active_workspace_id, self.collections.clone()); let store = CollectionsStore { version: COLLECTIONS_STORAGE_VERSION, - collections: self.collections.clone(), + workspaces, }; persistor.schedule_save(store); } } + pub fn set_active_workspace(&mut self, workspace_id: Uuid, cx: &mut Context) { + if workspace_id == self.active_workspace_id { + return; + } + let previous = std::mem::take(&mut self.collections); + self.workspace_collections + .insert(self.active_workspace_id, previous); + self.active_workspace_id = workspace_id; + self.collections = self + .workspace_collections + .remove(&workspace_id) + .unwrap_or_default(); + self.bump_revision(); + self.save_to_file(); + cx.notify(); + } + + pub fn remove_workspace(&mut self, workspace_id: Uuid) { + if workspace_id != self.active_workspace_id { + self.workspace_collections.remove(&workspace_id); + self.save_to_file(); + } + } + fn bump_revision(&mut self) { self.revision = self.revision.wrapping_add(1); } @@ -745,19 +789,28 @@ impl CollectionNode { } } -fn deserialize_store(contents: &str) -> Result<(Vec, bool), serde_json::Error> { - if let Ok(store) = serde_json::from_str::(contents) { - return Ok((store.collections, false)); +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) { + return Ok(( + HashMap::from([(default_workspace_id(), store.collections)]), + true, + )); } let legacy_collections = serde_json::from_str::>(contents)?; - Ok(( - legacy_collections - .into_iter() - .map(LegacyCollection::into_collection) - .collect(), - true, - )) + let collections = legacy_collections + .into_iter() + .map(LegacyCollection::into_collection) + .collect(); + Ok((HashMap::from([(default_workspace_id(), collections)]), true)) } fn append_destination_entries( @@ -969,7 +1022,8 @@ mod tests { }}]"# ); - let (collections, migrated) = deserialize_store(&legacy).expect("legacy parse"); + let (workspaces, migrated) = deserialize_store(&legacy).expect("legacy parse"); + let collections = &workspaces[&default_workspace_id()]; assert!(migrated); assert_eq!(collections.len(), 1); assert_eq!(collections[0].id, collection_id); @@ -1000,10 +1054,11 @@ mod tests { let store = CollectionsStore { version: COLLECTIONS_STORAGE_VERSION, - collections: vec![collection.clone()], + workspaces: HashMap::from([(default_workspace_id(), vec![collection.clone()])]), }; let encoded = serde_json::to_string(&store).expect("encode"); - let (decoded, migrated) = deserialize_store(&encoded).expect("decode"); + let (workspaces, migrated) = deserialize_store(&encoded).expect("decode"); + let decoded = &workspaces[&default_workspace_id()]; assert!(!migrated); assert_eq!(decoded.len(), 1); diff --git a/src/entities/environment.rs b/src/entities/environment.rs new file mode 100644 index 0000000..59219ee --- /dev/null +++ b/src/entities/environment.rs @@ -0,0 +1,1326 @@ +use gpui::{Context, EventEmitter, Hsla, hsla}; +use gpui_component::Colorize; +use serde::{Deserialize, Serialize}; +use std::collections::{HashMap, HashSet}; +use std::path::PathBuf; +use std::time::Duration; +use uuid::Uuid; + +use crate::entities::{Header, MultipartField, RequestBody, default_workspace_id}; +use crate::utils::{DebouncedJsonWriter, shared_tokio_runtime}; + +const ENVIRONMENTS_STORAGE_VERSION: u32 = 3; +const SAVE_DEBOUNCE: Duration = Duration::from_millis(350); +const MAX_INTERPOLATION_DEPTH: usize = 16; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "scope", content = "collection_id", rename_all = "snake_case")] +pub enum EnvironmentScope { + Global, + Workspace, + Project(Uuid), +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum EnvironmentColor { + #[default] + Teal, + Blue, + Violet, + Amber, + Rose, + Slate, + Custom(String), +} + +impl EnvironmentColor { + pub const ALL: [Self; 6] = [ + Self::Teal, + Self::Blue, + Self::Violet, + Self::Amber, + Self::Rose, + Self::Slate, + ]; + + pub fn label(&self) -> &'static str { + match self { + Self::Teal => "Teal", + Self::Blue => "Blue", + Self::Violet => "Violet", + Self::Amber => "Amber", + Self::Rose => "Rose", + Self::Slate => "Slate", + Self::Custom(_) => "Custom", + } + } + + pub fn accent(&self) -> Hsla { + match self { + Self::Teal => hsla(165.0 / 360.0, 0.80, 0.48, 1.0), + Self::Blue => hsla(205.0 / 360.0, 0.82, 0.58, 1.0), + Self::Violet => hsla(270.0 / 360.0, 0.76, 0.66, 1.0), + Self::Amber => hsla(38.0 / 360.0, 0.92, 0.58, 1.0), + Self::Rose => hsla(348.0 / 360.0, 0.78, 0.62, 1.0), + Self::Slate => hsla(220.0 / 360.0, 0.12, 0.62, 1.0), + Self::Custom(hex) => { + Hsla::parse_hex(hex).unwrap_or_else(|_| EnvironmentColor::default().accent()) + } + } + } + + pub fn custom(color: Hsla) -> Self { + let hex = color.to_hex(); + Self::ALL + .into_iter() + .find(|preset| preset.accent().to_hex() == hex) + .unwrap_or(Self::Custom(hex)) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct EnvironmentVariable { + pub id: Uuid, + pub key: String, + pub value: String, + pub enabled: bool, + pub secret: bool, +} + +impl Default for EnvironmentVariable { + fn default() -> Self { + Self { + id: Uuid::new_v4(), + key: String::new(), + value: String::new(), + enabled: true, + secret: false, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Environment { + pub id: Uuid, + pub name: String, + pub scope: EnvironmentScope, + #[serde(default)] + pub color: EnvironmentColor, + #[serde(default)] + pub variables: Vec, +} + +impl Environment { + pub fn new(name: impl Into, scope: EnvironmentScope) -> Self { + Self { + id: Uuid::new_v4(), + name: name.into(), + scope, + color: EnvironmentColor::default(), + variables: Vec::new(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +struct EnvironmentWorkspaceStore { + environments: Vec, + active_workspace_environment: Option, + active_project_environments: HashMap, +} + +impl Default for EnvironmentWorkspaceStore { + fn default() -> Self { + Self { + environments: Vec::new(), + active_workspace_environment: None, + active_project_environments: HashMap::new(), + } + } +} + +impl EnvironmentWorkspaceStore { + fn starter() -> Self { + let environment = Environment::new("Development", EnvironmentScope::Workspace); + Self { + active_workspace_environment: Some(environment.id), + environments: vec![environment], + ..Self::default() + } + } + + fn validated(mut self) -> Self { + let ids: HashSet<_> = self + .environments + .iter() + .map(|environment| environment.id) + .collect(); + if self + .active_workspace_environment + .is_some_and(|id| !ids.contains(&id)) + { + self.active_workspace_environment = None; + } + self.active_project_environments + .retain(|_, environment_id| ids.contains(environment_id)); + self + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub(crate) struct EnvironmentsStore { + version: u32, + global_environments: Vec, + active_global_environment: Option, + workspaces: HashMap, +} + +impl Default for EnvironmentsStore { + fn default() -> Self { + Self { + version: ENVIRONMENTS_STORAGE_VERSION, + global_environments: Vec::new(), + active_global_environment: None, + workspaces: HashMap::new(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +struct LegacyEnvironmentsStore { + version: u32, + environments: Vec, + active_workspace_environment: Option, + active_project_environments: HashMap, +} + +impl Default for LegacyEnvironmentsStore { + fn default() -> Self { + Self { + version: 1, + environments: Vec::new(), + active_workspace_environment: None, + active_project_environments: HashMap::new(), + } + } +} + +#[derive(Debug, Clone)] +pub enum EnvironmentEvent { + Changed, + ActiveChanged, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct InterpolationError { + pub unresolved: Vec, + pub cycles: Vec, +} + +impl InterpolationError { + pub fn user_message(&self) -> String { + let mut parts = Vec::new(); + if !self.unresolved.is_empty() { + parts.push(format!( + "Missing environment variable{}: {}", + if self.unresolved.len() == 1 { "" } else { "s" }, + self.unresolved.join(", ") + )); + } + if !self.cycles.is_empty() { + parts.push(format!( + "Circular environment variable reference{}: {}", + if self.cycles.len() == 1 { "" } else { "s" }, + self.cycles.join(", ") + )); + } + parts.join(". ") + } +} + +#[derive(Debug, Clone)] +pub struct ResolvedRequestParts { + pub url: String, + pub headers: Vec
, + pub body: RequestBody, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EnvironmentVariableCompletion { + pub key: String, + pub environment_name: String, + pub scope: EnvironmentScope, + pub secret: bool, +} + +pub struct EnvironmentsEntity { + environments: Vec, + active_global_environment: Option, + active_workspace_environment: Option, + active_project_environments: HashMap, + active_workspace_id: Uuid, + workspace_environments: HashMap, + revision: u64, + persistor: Option>, +} + +impl EnvironmentsEntity { + #[allow(dead_code)] + pub fn new() -> Self { + Self::new_for_workspace(default_workspace_id()) + } + + pub fn new_for_workspace(active_workspace_id: Uuid) -> Self { + let store = EnvironmentWorkspaceStore::starter(); + Self { + environments: store.environments, + active_global_environment: None, + active_workspace_environment: store.active_workspace_environment, + active_project_environments: store.active_project_environments, + active_workspace_id, + workspace_environments: HashMap::new(), + revision: 0, + persistor: storage_path() + .map(|path| DebouncedJsonWriter::new("environments", path, SAVE_DEBOUNCE)), + } + } + + pub fn spawn_storage_load() + -> tokio::sync::oneshot::Receiver, String>> { + let (tx, rx) = tokio::sync::oneshot::channel(); + let path = storage_path(); + shared_tokio_runtime().spawn(async move { + let result = async { + let Some(path) = path else { + return Ok(None); + }; + match tokio::fs::read_to_string(path).await { + Ok(contents) => deserialize_environments_store(&contents) + .map(|store| Some(store.0)) + .map_err(|error| error.to_string()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(error) => Err(error.to_string()), + } + } + .await; + let _ = tx.send(result); + }); + rx + } + + pub fn apply_storage_load( + &mut self, + result: Result, String>, + cx: &mut Context, + ) { + match result { + Ok(Some(mut store)) => { + let active = store + .workspaces + .remove(&self.active_workspace_id) + .unwrap_or_else(EnvironmentWorkspaceStore::starter) + .validated(); + self.environments = store.global_environments; + self.environments.extend(active.environments); + self.active_global_environment = store + .active_global_environment + .filter(|id| self.get(*id).is_some()); + self.active_workspace_environment = active.active_workspace_environment; + self.active_project_environments = active.active_project_environments; + self.workspace_environments = store.workspaces; + self.save_to_file(); + } + Ok(None) => self.save_to_file(), + Err(error) => log::error!("Failed to load environments: {error}"), + } + self.bump_revision(); + cx.emit(EnvironmentEvent::Changed); + cx.notify(); + } + + pub fn get(&self, id: Uuid) -> Option<&Environment> { + self.environments + .iter() + .find(|environment| environment.id == id) + } + + pub fn environments(&self) -> &[Environment] { + &self.environments + } + + pub fn is_active(&self, environment_id: Uuid) -> bool { + let Some(environment) = self.get(environment_id) else { + return false; + }; + match environment.scope { + EnvironmentScope::Global => self.active_global_environment == Some(environment_id), + EnvironmentScope::Workspace => { + self.active_workspace_environment == Some(environment_id) + } + EnvironmentScope::Project(project_id) => { + self.active_project_environments.get(&project_id).copied() == Some(environment_id) + } + } + } + + pub fn available_for(&self, collection_id: Option) -> Vec<&Environment> { + self.environments + .iter() + .filter(|environment| match environment.scope { + EnvironmentScope::Global => true, + EnvironmentScope::Workspace => true, + EnvironmentScope::Project(project_id) => Some(project_id) == collection_id, + }) + .collect() + } + + pub fn active_environment_id(&self, collection_id: Option) -> Option { + collection_id + .and_then(|id| self.active_project_environments.get(&id).copied()) + .or(self.active_workspace_environment) + .or(self.active_global_environment) + .filter(|id| self.get(*id).is_some()) + } + + pub fn active_global_environment_id(&self) -> Option { + self.active_global_environment + .filter(|id| self.get(*id).is_some()) + } + + pub fn active_workspace_environment_id(&self) -> Option { + self.active_workspace_environment + .filter(|id| self.get(*id).is_some()) + } + + pub fn active_project_environment_id(&self, project_id: Uuid) -> Option { + self.active_project_environments + .get(&project_id) + .copied() + .filter(|id| self.get(*id).is_some()) + } + + pub fn active_environment(&self, collection_id: Option) -> Option<&Environment> { + self.active_environment_id(collection_id) + .and_then(|id| self.get(id)) + } + + pub fn effective_variable_completions( + &self, + collection_id: Option, + ) -> Vec { + let mut completions = HashMap::new(); + let mut add_environment = |environment: &Environment| { + for variable in &environment.variables { + let key = variable.key.trim(); + if !variable.enabled || key.is_empty() { + continue; + } + completions.insert( + key.to_string(), + EnvironmentVariableCompletion { + key: key.to_string(), + environment_name: environment.name.clone(), + scope: environment.scope, + secret: variable.secret, + }, + ); + } + }; + + if let Some(global) = self.active_global_environment.and_then(|id| self.get(id)) { + add_environment(global); + } + if let Some(workspace) = self + .active_workspace_environment + .and_then(|id| self.get(id)) + { + add_environment(workspace); + } + if let Some(project) = collection_id + .and_then(|id| self.active_project_environments.get(&id)) + .and_then(|id| self.get(*id)) + { + add_environment(project); + } + + let mut completions: Vec<_> = completions.into_values().collect(); + completions.sort_by(|left, right| { + left.key + .to_ascii_lowercase() + .cmp(&right.key.to_ascii_lowercase()) + }); + completions + } + + pub fn set_active( + &mut self, + collection_id: Option, + environment_id: Option, + cx: &mut Context, + ) { + match (collection_id, environment_id.and_then(|id| self.get(id))) { + (_, Some(environment)) if environment.scope == EnvironmentScope::Global => { + self.active_global_environment = Some(environment.id); + } + (Some(project_id), Some(environment)) + if environment.scope == EnvironmentScope::Project(project_id) => + { + self.active_project_environments + .insert(project_id, environment.id); + } + (Some(project_id), Some(environment)) + if environment.scope == EnvironmentScope::Workspace => + { + self.active_workspace_environment = Some(environment.id); + self.active_project_environments.remove(&project_id); + } + (Some(project_id), None) => { + self.active_project_environments.remove(&project_id); + } + (None, Some(environment)) if environment.scope == EnvironmentScope::Workspace => { + self.active_workspace_environment = Some(environment.id); + } + (None, None) => { + self.active_global_environment = None; + self.active_workspace_environment = None; + } + _ => return, + } + self.bump_revision(); + self.save_to_file(); + cx.emit(EnvironmentEvent::ActiveChanged); + cx.notify(); + } + + pub fn create_environment( + &mut self, + name: impl Into, + scope: EnvironmentScope, + cx: &mut Context, + ) -> Uuid { + let environment = Environment::new(name, scope); + let id = environment.id; + self.environments.push(environment); + match scope { + EnvironmentScope::Global => self.active_global_environment = Some(id), + EnvironmentScope::Workspace => self.active_workspace_environment = Some(id), + EnvironmentScope::Project(project_id) => { + self.active_project_environments.insert(project_id, id); + } + } + self.changed(EnvironmentEvent::ActiveChanged, cx); + id + } + + pub fn import_environment( + &mut self, + name: impl Into, + scope: EnvironmentScope, + variables: Vec, + cx: &mut Context, + ) -> Uuid { + let mut environment = Environment::new(name, scope); + environment.variables = variables; + let id = environment.id; + self.environments.push(environment); + match scope { + EnvironmentScope::Global => self.active_global_environment = Some(id), + EnvironmentScope::Workspace => self.active_workspace_environment = Some(id), + EnvironmentScope::Project(project_id) => { + self.active_project_environments.insert(project_id, id); + } + } + self.changed(EnvironmentEvent::ActiveChanged, cx); + id + } + + pub fn set_active_workspace(&mut self, workspace_id: Uuid, cx: &mut Context) { + if workspace_id == self.active_workspace_id { + return; + } + let (global_environments, workspace_environments): (Vec<_>, Vec<_>) = + std::mem::take(&mut self.environments) + .into_iter() + .partition(|environment| environment.scope == EnvironmentScope::Global); + let previous = EnvironmentWorkspaceStore { + environments: workspace_environments, + active_workspace_environment: self.active_workspace_environment.take(), + active_project_environments: std::mem::take(&mut self.active_project_environments), + }; + self.workspace_environments + .insert(self.active_workspace_id, previous); + self.active_workspace_id = workspace_id; + let active = self + .workspace_environments + .remove(&workspace_id) + .unwrap_or_else(EnvironmentWorkspaceStore::starter) + .validated(); + self.environments = global_environments; + self.environments.extend(active.environments); + self.active_workspace_environment = active.active_workspace_environment; + self.active_project_environments = active.active_project_environments; + self.changed(EnvironmentEvent::ActiveChanged, cx); + } + + pub fn remove_workspace(&mut self, workspace_id: Uuid) { + if workspace_id != self.active_workspace_id { + self.workspace_environments.remove(&workspace_id); + self.save_to_file(); + } + } + + pub fn duplicate_environment(&mut self, id: Uuid, cx: &mut Context) -> Option { + let source = self.get(id)?.clone(); + let scope = source.scope; + let duplicate = duplicate_environment_data(source); + let duplicate_id = duplicate.id; + self.environments.push(duplicate); + match scope { + EnvironmentScope::Global => { + self.active_global_environment = Some(duplicate_id); + } + EnvironmentScope::Workspace => { + self.active_workspace_environment = Some(duplicate_id); + } + EnvironmentScope::Project(project_id) => { + self.active_project_environments + .insert(project_id, duplicate_id); + } + } + self.changed(EnvironmentEvent::ActiveChanged, cx); + Some(duplicate_id) + } + + pub fn remove_environment(&mut self, id: Uuid, cx: &mut Context) { + let Some(index) = self + .environments + .iter() + .position(|environment| environment.id == id) + else { + return; + }; + self.environments.remove(index); + if self.active_global_environment == Some(id) { + self.active_global_environment = self + .environments + .iter() + .find(|environment| environment.scope == EnvironmentScope::Global) + .map(|environment| environment.id); + } + if self.active_workspace_environment == Some(id) { + self.active_workspace_environment = self + .environments + .iter() + .find(|environment| environment.scope == EnvironmentScope::Workspace) + .map(|environment| environment.id); + } + self.active_project_environments + .retain(|_, environment_id| *environment_id != id); + self.changed(EnvironmentEvent::Changed, cx); + } + + pub fn rename_environment(&mut self, id: Uuid, name: String, cx: &mut Context) { + let name = name.trim(); + if name.is_empty() { + return; + } + let Some(environment) = self.get_mut(id) else { + return; + }; + environment.name = name.to_string(); + self.changed(EnvironmentEvent::Changed, cx); + } + + pub fn set_environment_color( + &mut self, + id: Uuid, + color: EnvironmentColor, + cx: &mut Context, + ) { + let Some(environment) = self.get_mut(id) else { + return; + }; + environment.color = color; + self.changed(EnvironmentEvent::Changed, cx); + } + + pub fn remove_project_environments(&mut self, project_id: Uuid, cx: &mut Context) { + let previous_len = self.environments.len(); + self.environments + .retain(|environment| environment.scope != EnvironmentScope::Project(project_id)); + self.active_project_environments.remove(&project_id); + if self.environments.len() != previous_len { + self.changed(EnvironmentEvent::Changed, cx); + } + } + + pub fn add_variable(&mut self, environment_id: Uuid, cx: &mut Context) -> Option { + let variable = EnvironmentVariable::default(); + let id = variable.id; + self.get_mut(environment_id)?.variables.push(variable); + self.changed(EnvironmentEvent::Changed, cx); + Some(id) + } + + pub fn update_variable( + &mut self, + environment_id: Uuid, + variable_id: Uuid, + key: Option, + value: Option, + cx: &mut Context, + ) { + let Some(variable) = self.variable_mut(environment_id, variable_id) else { + return; + }; + if let Some(key) = key { + variable.key = key; + } + if let Some(value) = value { + variable.value = value; + } + self.changed(EnvironmentEvent::Changed, cx); + } + + pub fn toggle_variable( + &mut self, + environment_id: Uuid, + variable_id: Uuid, + cx: &mut Context, + ) { + let Some(variable) = self.variable_mut(environment_id, variable_id) else { + return; + }; + variable.enabled = !variable.enabled; + self.changed(EnvironmentEvent::Changed, cx); + } + + pub fn toggle_secret( + &mut self, + environment_id: Uuid, + variable_id: Uuid, + cx: &mut Context, + ) { + let Some(variable) = self.variable_mut(environment_id, variable_id) else { + return; + }; + variable.secret = !variable.secret; + self.changed(EnvironmentEvent::Changed, cx); + } + + pub fn duplicate_variable( + &mut self, + environment_id: Uuid, + variable_id: Uuid, + cx: &mut Context, + ) -> Option { + let source = self + .get(environment_id)? + .variables + .iter() + .find(|variable| variable.id == variable_id)? + .clone(); + let duplicate_id = Uuid::new_v4(); + let duplicate = EnvironmentVariable { + id: duplicate_id, + key: if source.key.is_empty() { + String::new() + } else { + format!("{}_copy", source.key) + }, + value: if source.secret { + String::new() + } else { + source.value + }, + ..source + }; + self.get_mut(environment_id)?.variables.push(duplicate); + self.changed(EnvironmentEvent::Changed, cx); + Some(duplicate_id) + } + + pub fn remove_variable( + &mut self, + environment_id: Uuid, + variable_id: Uuid, + cx: &mut Context, + ) { + let Some(environment) = self.get_mut(environment_id) else { + return; + }; + environment + .variables + .retain(|variable| variable.id != variable_id); + self.changed(EnvironmentEvent::Changed, cx); + } + + pub fn resolve_request( + &self, + collection_id: Option, + url: &str, + headers: &[Header], + body: &RequestBody, + ) -> Result { + let values = self.effective_values(collection_id); + let mut resolver = Resolver::new(values); + let url = resolver.resolve(url); + let headers = headers + .iter() + .map(|header| Header { + key: resolver.resolve(&header.key), + value: resolver.resolve(&header.value), + enabled: header.enabled, + }) + .collect(); + let body = match body { + RequestBody::None => RequestBody::None, + RequestBody::Text(value) => RequestBody::Text(resolver.resolve(value)), + RequestBody::Json(value) => RequestBody::Json(resolver.resolve(value)), + RequestBody::FormData(fields) => RequestBody::FormData( + fields + .iter() + .map(|(key, value)| (resolver.resolve(key), resolver.resolve(value))) + .collect(), + ), + RequestBody::MultipartFormData(fields) => RequestBody::MultipartFormData( + fields + .iter() + .map(|field| MultipartField { + key: resolver.resolve(&field.key), + value: resolver.resolve(&field.value), + file_path: field.file_path.as_ref().map(|path| resolver.resolve(path)), + }) + .collect(), + ), + }; + + resolver.finish()?; + Ok(ResolvedRequestParts { url, headers, body }) + } + + fn effective_values(&self, collection_id: Option) -> HashMap { + let mut values = HashMap::new(); + if let Some(global) = self.active_global_environment.and_then(|id| self.get(id)) { + insert_environment_values(global, &mut values); + } + if let Some(workspace) = self + .active_workspace_environment + .and_then(|id| self.get(id)) + { + insert_environment_values(workspace, &mut values); + } + if let Some(project) = collection_id + .and_then(|id| self.active_project_environments.get(&id)) + .and_then(|id| self.get(*id)) + { + insert_environment_values(project, &mut values); + } + values + } + + fn get_mut(&mut self, id: Uuid) -> Option<&mut Environment> { + self.environments + .iter_mut() + .find(|environment| environment.id == id) + } + + fn variable_mut( + &mut self, + environment_id: Uuid, + variable_id: Uuid, + ) -> Option<&mut EnvironmentVariable> { + self.get_mut(environment_id)? + .variables + .iter_mut() + .find(|variable| variable.id == variable_id) + } + + fn changed(&mut self, event: EnvironmentEvent, cx: &mut Context) { + self.bump_revision(); + self.save_to_file(); + cx.emit(event); + cx.notify(); + } + + fn bump_revision(&mut self) { + self.revision = self.revision.wrapping_add(1); + } + + fn save_to_file(&self) { + if let Some(persistor) = &self.persistor { + let mut workspaces = self.workspace_environments.clone(); + let global_environments = self + .environments + .iter() + .filter(|environment| environment.scope == EnvironmentScope::Global) + .cloned() + .collect(); + workspaces.insert( + self.active_workspace_id, + EnvironmentWorkspaceStore { + environments: self + .environments + .iter() + .filter(|environment| environment.scope != EnvironmentScope::Global) + .cloned() + .collect(), + active_workspace_environment: self.active_workspace_environment, + active_project_environments: self.active_project_environments.clone(), + }, + ); + persistor.schedule_save(EnvironmentsStore { + version: ENVIRONMENTS_STORAGE_VERSION, + global_environments, + active_global_environment: self.active_global_environment, + workspaces, + }); + } + } +} + +impl EventEmitter for EnvironmentsEntity {} + +fn storage_path() -> Option { + dirs::data_local_dir().map(|mut path| { + path.push("setu"); + path.push("environments.json"); + path + }) +} + +fn deserialize_environments_store( + contents: &str, +) -> Result<(EnvironmentsStore, bool), serde_json::Error> { + if let Ok(mut store) = serde_json::from_str::(contents) + && matches!(store.version, 2 | ENVIRONMENTS_STORAGE_VERSION) + { + let migrated = store.version != ENVIRONMENTS_STORAGE_VERSION; + store.version = ENVIRONMENTS_STORAGE_VERSION; + store + .global_environments + .retain(|environment| environment.scope == EnvironmentScope::Global); + let global_ids: HashSet<_> = store + .global_environments + .iter() + .map(|environment| environment.id) + .collect(); + if store + .active_global_environment + .is_some_and(|id| !global_ids.contains(&id)) + { + store.active_global_environment = None; + } + store.workspaces = store + .workspaces + .into_iter() + .map(|(workspace_id, workspace)| (workspace_id, workspace.validated())) + .collect(); + return Ok((store, migrated)); + } + + let legacy = serde_json::from_str::(contents)?; + let workspace = EnvironmentWorkspaceStore { + environments: legacy.environments, + active_workspace_environment: legacy.active_workspace_environment, + active_project_environments: legacy.active_project_environments, + } + .validated(); + Ok(( + EnvironmentsStore { + version: ENVIRONMENTS_STORAGE_VERSION, + global_environments: Vec::new(), + active_global_environment: None, + workspaces: HashMap::from([(default_workspace_id(), workspace)]), + }, + true, + )) +} + +fn insert_environment_values(environment: &Environment, values: &mut HashMap) { + for variable in &environment.variables { + let key = variable.key.trim(); + if variable.enabled && !key.is_empty() { + values.insert(key.to_string(), variable.value.clone()); + } + } +} + +fn duplicate_environment_data(source: Environment) -> Environment { + Environment { + id: Uuid::new_v4(), + name: format!("{} Copy", source.name), + scope: source.scope, + color: source.color, + variables: source + .variables + .into_iter() + .map(|variable| EnvironmentVariable { + id: Uuid::new_v4(), + value: if variable.secret { + String::new() + } else { + variable.value + }, + ..variable + }) + .collect(), + } +} + +struct Resolver { + values: HashMap, + cache: HashMap, + unresolved: HashSet, + cycles: HashSet, +} + +impl Resolver { + fn new(values: HashMap) -> Self { + Self { + values, + cache: HashMap::new(), + unresolved: HashSet::new(), + cycles: HashSet::new(), + } + } + + fn resolve(&mut self, template: &str) -> String { + self.resolve_template(template, &mut Vec::new(), 0) + } + + fn resolve_template( + &mut self, + template: &str, + stack: &mut Vec, + depth: usize, + ) -> String { + if depth >= MAX_INTERPOLATION_DEPTH { + if let Some(key) = stack.last() { + self.cycles.insert(key.clone()); + } + return template.to_string(); + } + + let mut output = String::with_capacity(template.len()); + let mut cursor = 0; + while let Some(relative_start) = template[cursor..].find("{{") { + let start = cursor + relative_start; + if start > 0 && template.as_bytes()[start - 1] == b'\\' { + output.push_str(&template[cursor..start - 1]); + output.push_str("{{"); + cursor = start + 2; + continue; + } + output.push_str(&template[cursor..start]); + let Some(relative_end) = template[start + 2..].find("}}") else { + output.push_str(&template[start..]); + cursor = template.len(); + break; + }; + let end = start + 2 + relative_end; + let key = template[start + 2..end].trim(); + if key.is_empty() { + output.push_str(&template[start..end + 2]); + } else { + output.push_str(&self.resolve_key(key, stack, depth + 1)); + } + cursor = end + 2; + } + output.push_str(&template[cursor..]); + output + } + + fn resolve_key(&mut self, key: &str, stack: &mut Vec, depth: usize) -> String { + if let Some(value) = self.cache.get(key) { + return value.clone(); + } + if stack.iter().any(|entry| entry == key) { + self.cycles.insert(key.to_string()); + return format!("{{{{{key}}}}}"); + } + let Some(raw_value) = self.values.get(key).cloned() else { + self.unresolved.insert(key.to_string()); + return format!("{{{{{key}}}}}"); + }; + + stack.push(key.to_string()); + let value = self.resolve_template(&raw_value, stack, depth); + stack.pop(); + self.cache.insert(key.to_string(), value.clone()); + value + } + + fn finish(self) -> Result<(), InterpolationError> { + if self.unresolved.is_empty() && self.cycles.is_empty() { + return Ok(()); + } + let mut unresolved: Vec<_> = self.unresolved.into_iter().collect(); + unresolved.sort(); + let mut cycles: Vec<_> = self.cycles.into_iter().collect(); + cycles.sort(); + Err(InterpolationError { unresolved, cycles }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn entity_with_variables( + workspace: &[(&str, &str)], + project: Option<(Uuid, &[(&str, &str)])>, + ) -> EnvironmentsEntity { + let mut workspace_environment = + Environment::new("Development", EnvironmentScope::Workspace); + workspace_environment.variables = workspace + .iter() + .map(|(key, value)| EnvironmentVariable { + key: (*key).to_string(), + value: (*value).to_string(), + ..EnvironmentVariable::default() + }) + .collect(); + let workspace_id = workspace_environment.id; + let mut environments = vec![workspace_environment]; + let mut active_project_environments = HashMap::new(); + if let Some((project_id, variables)) = project { + let mut project_environment = + Environment::new("Project", EnvironmentScope::Project(project_id)); + project_environment.variables = variables + .iter() + .map(|(key, value)| EnvironmentVariable { + key: (*key).to_string(), + value: (*value).to_string(), + ..EnvironmentVariable::default() + }) + .collect(); + active_project_environments.insert(project_id, project_environment.id); + environments.push(project_environment); + } + EnvironmentsEntity { + environments, + active_global_environment: None, + active_workspace_environment: Some(workspace_id), + active_project_environments, + active_workspace_id: default_workspace_id(), + workspace_environments: HashMap::new(), + revision: 0, + persistor: None, + } + } + + #[test] + fn interpolates_url_headers_and_body_recursively() { + let entity = entity_with_variables( + &[ + ("host", "api.example.com"), + ("base_url", "https://{{host}}"), + ("token", "secret"), + ], + None, + ); + let resolved = entity + .resolve_request( + None, + "{{base_url}}/users", + &[Header::new("Authorization", "Bearer {{token}}")], + &RequestBody::Json(r#"{"origin":"{{base_url}}"}"#.to_string()), + ) + .expect("request should resolve"); + + assert_eq!(resolved.url, "https://api.example.com/users"); + assert_eq!(resolved.headers[0].value, "Bearer secret"); + assert_eq!( + resolved.body, + RequestBody::Json(r#"{"origin":"https://api.example.com"}"#.to_string()) + ); + } + + #[test] + fn project_variables_override_workspace_variables() { + let project_id = Uuid::new_v4(); + let entity = entity_with_variables( + &[("base_url", "https://workspace.example")], + Some((project_id, &[("base_url", "https://project.example")])), + ); + let resolved = entity + .resolve_request(Some(project_id), "{{base_url}}", &[], &RequestBody::None) + .expect("request should resolve"); + assert_eq!(resolved.url, "https://project.example"); + } + + #[test] + fn completion_metadata_marks_secret_values() { + let mut entity = entity_with_variables(&[], None); + let workspace_id = entity + .active_workspace_environment + .expect("active workspace environment"); + entity + .get_mut(workspace_id) + .expect("workspace environment") + .variables + .push(EnvironmentVariable { + key: "token".to_string(), + value: "do-not-preview".to_string(), + secret: true, + ..EnvironmentVariable::default() + }); + + let token = entity + .effective_variable_completions(None) + .into_iter() + .find(|variable| variable.key == "token") + .expect("token completion"); + assert!(token.secret); + } + + #[test] + fn specificity_order_is_global_then_workspace_then_project() { + let project_id = Uuid::new_v4(); + let mut entity = entity_with_variables( + &[("base_url", "https://workspace.example")], + Some((project_id, &[("base_url", "https://project.example")])), + ); + let mut global = Environment::new("Shared", EnvironmentScope::Global); + global.variables = vec![ + EnvironmentVariable { + key: "base_url".to_string(), + value: "https://global.example".to_string(), + ..EnvironmentVariable::default() + }, + EnvironmentVariable { + key: "shared_id".to_string(), + value: "from-global".to_string(), + ..EnvironmentVariable::default() + }, + ]; + entity.active_global_environment = Some(global.id); + entity.environments.insert(0, global); + + let workspace = entity + .resolve_request(None, "{{base_url}}/{{shared_id}}", &[], &RequestBody::None) + .expect("workspace values should override global values"); + assert_eq!(workspace.url, "https://workspace.example/from-global"); + + let project = entity + .resolve_request(Some(project_id), "{{base_url}}", &[], &RequestBody::None) + .expect("project values should override workspace values"); + assert_eq!(project.url, "https://project.example"); + + let completions = entity.effective_variable_completions(Some(project_id)); + let base_url = completions + .iter() + .find(|variable| variable.key == "base_url") + .expect("effective base_url completion"); + assert_eq!(base_url.scope, EnvironmentScope::Project(project_id)); + assert_eq!( + completions + .iter() + .filter(|variable| variable.key == "base_url") + .count(), + 1 + ); + assert!( + completions + .iter() + .any(|variable| variable.key == "shared_id") + ); + } + + #[test] + fn reports_missing_and_circular_variables() { + let entity = entity_with_variables(&[("a", "{{b}}"), ("b", "{{a}}")], None); + let error = entity + .resolve_request(None, "{{a}}/{{missing}}", &[], &RequestBody::None) + .expect_err("request should fail"); + assert_eq!(error.unresolved, vec!["missing"]); + assert!(!error.cycles.is_empty()); + } + + #[test] + fn supports_escaped_placeholders() { + let entity = entity_with_variables(&[("value", "done")], None); + let resolved = entity + .resolve_request(None, r"\{{value}}/{{value}}", &[], &RequestBody::None) + .expect("request should resolve"); + assert_eq!(resolved.url, "{{value}}/done"); + } + + #[test] + fn duplicated_environments_clear_secrets_and_keep_regular_values() { + let mut source = Environment::new("Production", EnvironmentScope::Workspace); + source.color = EnvironmentColor::Rose; + source.variables = vec![ + EnvironmentVariable { + key: "token".to_string(), + value: "super-secret".to_string(), + secret: true, + ..EnvironmentVariable::default() + }, + EnvironmentVariable { + key: "base_url".to_string(), + value: "https://api.example.com".to_string(), + ..EnvironmentVariable::default() + }, + ]; + + let duplicate = duplicate_environment_data(source); + assert_eq!(duplicate.name, "Production Copy"); + assert_eq!(duplicate.color, EnvironmentColor::Rose); + assert!(duplicate.variables[0].value.is_empty()); + assert_eq!(duplicate.variables[1].value, "https://api.example.com"); + } + + #[test] + fn legacy_environments_default_to_teal() { + let environment = Environment::new("Legacy", EnvironmentScope::Workspace); + let mut value = serde_json::to_value(environment).expect("serialize environment"); + value + .as_object_mut() + .expect("environment object") + .remove("color"); + + let decoded: Environment = + serde_json::from_value(value).expect("deserialize legacy environment"); + assert_eq!(decoded.color, EnvironmentColor::Teal); + } + + #[test] + fn custom_environment_colors_round_trip() { + let color = EnvironmentColor::custom(hsla(0.42, 0.71, 0.53, 1.0)); + let encoded = serde_json::to_string(&color).expect("serialize custom color"); + let decoded: EnvironmentColor = + serde_json::from_str(&encoded).expect("deserialize custom color"); + assert_eq!(decoded, color); + } + + #[test] + fn version_two_workspace_store_migrates_without_data_loss() { + let workspace_id = default_workspace_id(); + let environment = Environment::new("Development", EnvironmentScope::Workspace); + let environment_id = environment.id; + let contents = serde_json::json!({ + "version": 2, + "workspaces": { + workspace_id.to_string(): { + "environments": [environment], + "active_workspace_environment": environment_id, + "active_project_environments": {} + } + } + }) + .to_string(); + + let (store, migrated) = + deserialize_environments_store(&contents).expect("migrate version two store"); + assert!(migrated); + assert_eq!(store.version, ENVIRONMENTS_STORAGE_VERSION); + assert_eq!(store.workspaces[&workspace_id].environments.len(), 1); + } +} diff --git a/src/entities/history.rs b/src/entities/history.rs index bc9aade..24f411c 100644 --- a/src/entities/history.rs +++ b/src/entities/history.rs @@ -1,7 +1,7 @@ use chrono::{DateTime, Utc}; use gpui::{Context, EventEmitter}; use serde::{Deserialize, Serialize}; -use std::collections::{BTreeMap, HashSet}; +use std::collections::{BTreeMap, HashMap, HashSet}; use std::path::PathBuf; use std::sync::Arc; use std::time::Duration; @@ -9,10 +9,17 @@ use uuid::Uuid; use crate::utils::{DebouncedJsonWriter, shared_tokio_runtime}; -use super::{HttpMethod, RequestData, ResponseData, SidebarLoadState}; +use super::{HttpMethod, RequestData, ResponseData, SidebarLoadState, default_workspace_id}; +const HISTORY_STORAGE_VERSION: u32 = 2; const SAVE_DEBOUNCE: Duration = Duration::from_secs(1); +#[derive(Debug, Clone, Serialize, Deserialize)] +struct HistoryStore { + version: u32, + workspaces: HashMap>, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct HistoryEntry { pub id: Uuid, @@ -265,18 +272,26 @@ pub struct HistoryEntity { pub entries: Arc>>, pub load_state: SidebarLoadState, pub max_entries: usize, - persistor: Option>>>>, + active_workspace_id: Uuid, + workspace_entries: HashMap>>>, + persistor: Option>, collapsed_groups: Arc>, collapsed_url_groups: Arc>, } impl HistoryEntity { pub fn new() -> Self { + Self::new_for_workspace(default_workspace_id()) + } + + pub fn new_for_workspace(active_workspace_id: Uuid) -> Self { let storage_path = Self::get_storage_path(); let entity = Self { entries: Arc::new(Vec::new()), load_state: SidebarLoadState::Loading, max_entries: 5_000, + active_workspace_id, + workspace_entries: HashMap::new(), persistor: storage_path .clone() .map(|path| DebouncedJsonWriter::new("history", path, SAVE_DEBOUNCE)), @@ -296,28 +311,21 @@ impl HistoryEntity { } pub fn spawn_storage_load() - -> tokio::sync::oneshot::Receiver, bool), String>> { + -> tokio::sync::oneshot::Receiver>, bool), String>> + { let (tx, rx) = tokio::sync::oneshot::channel(); let path = Self::get_storage_path(); shared_tokio_runtime().spawn(async move { let result = async { let Some(path) = path else { - return Ok((Vec::new(), false)); + return Ok((HashMap::new(), false)); }; match tokio::fs::read_to_string(&path).await { Ok(contents) => { - let mut entries = serde_json::from_str::>(&contents) - .map_err(|error| error.to_string())?; - let mut compacted = false; - for entry in &mut entries { - if let Some(response) = entry.response.as_mut() { - compacted |= response.compact_storage(); - } - } - Ok((entries, compacted)) + deserialize_history_store(&contents).map_err(|error| error.to_string()) } Err(error) if error.kind() == std::io::ErrorKind::NotFound => { - Ok((Vec::new(), false)) + Ok((HashMap::new(), false)) } Err(error) => Err(error.to_string()), } @@ -330,14 +338,30 @@ impl HistoryEntity { pub fn apply_storage_load( &mut self, - result: Result<(Vec, bool), String>, + result: Result<(HashMap>, bool), String>, cx: &mut Context, ) { match result { - Ok((entries, compacted)) => { - self.entries = Arc::new(entries.into_iter().map(Arc::new).collect()); + Ok((mut workspaces, migrated)) => { + self.entries = Arc::new( + workspaces + .remove(&self.active_workspace_id) + .unwrap_or_default() + .into_iter() + .map(Arc::new) + .collect(), + ); + self.workspace_entries = workspaces + .into_iter() + .map(|(workspace_id, entries)| { + ( + workspace_id, + Arc::new(entries.into_iter().map(Arc::new).collect()), + ) + }) + .collect(); self.load_state = SidebarLoadState::Ready; - if compacted { + if migrated { self.save_to_file(); } cx.emit(HistoryEvent::Reloaded); @@ -349,7 +373,50 @@ impl HistoryEntity { fn save_to_file(&self) { if let Some(persistor) = &self.persistor { - persistor.schedule_save(self.entries.clone()); + let mut workspaces: HashMap<_, Vec<_>> = self + .workspace_entries + .iter() + .map(|(workspace_id, entries)| { + ( + *workspace_id, + entries.iter().map(|entry| (**entry).clone()).collect(), + ) + }) + .collect(); + workspaces.insert( + self.active_workspace_id, + self.entries.iter().map(|entry| (**entry).clone()).collect(), + ); + persistor.schedule_save(HistoryStore { + version: HISTORY_STORAGE_VERSION, + workspaces, + }); + } + } + + pub fn set_active_workspace(&mut self, workspace_id: Uuid, cx: &mut Context) { + if workspace_id == self.active_workspace_id { + return; + } + let previous = std::mem::replace(&mut self.entries, Arc::new(Vec::new())); + self.workspace_entries + .insert(self.active_workspace_id, previous); + self.active_workspace_id = workspace_id; + self.entries = self + .workspace_entries + .remove(&workspace_id) + .unwrap_or_else(|| Arc::new(Vec::new())); + self.collapsed_groups = Arc::new(HashSet::new()); + self.collapsed_url_groups = Arc::new(HashSet::new()); + self.save_to_file(); + cx.emit(HistoryEvent::Reloaded); + cx.notify(); + } + + pub fn remove_workspace(&mut self, workspace_id: Uuid) { + if workspace_id != self.active_workspace_id { + self.workspace_entries.remove(&workspace_id); + self.save_to_file(); } } @@ -501,6 +568,32 @@ impl HistoryEntity { } } +fn deserialize_history_store( + contents: &str, +) -> Result<(HashMap>, bool), serde_json::Error> { + let (mut workspaces, mut migrated) = + if let Ok(store) = serde_json::from_str::(contents) { + if store.version == HISTORY_STORAGE_VERSION { + (store.workspaces, false) + } else { + (HashMap::new(), true) + } + } else { + let entries = serde_json::from_str::>(contents)?; + (HashMap::from([(default_workspace_id(), entries)]), true) + }; + + for entries in workspaces.values_mut() { + for entry in entries { + if let Some(response) = entry.response.as_mut() { + migrated |= response.compact_storage(); + } + } + } + + Ok((workspaces, migrated)) +} + impl Default for HistoryEntity { fn default() -> Self { Self::new() @@ -572,6 +665,8 @@ mod tests { ]), max_entries: 500, load_state: SidebarLoadState::Ready, + active_workspace_id: default_workspace_id(), + workspace_entries: HashMap::new(), persistor: None, collapsed_groups: Arc::new(HashSet::new()), collapsed_url_groups: Arc::new(HashSet::new()), @@ -604,6 +699,8 @@ mod tests { ]), max_entries: 500, load_state: SidebarLoadState::Ready, + active_workspace_id: default_workspace_id(), + workspace_entries: HashMap::new(), persistor: None, collapsed_groups: Arc::new(HashSet::new()), collapsed_url_groups: Arc::new(HashSet::new()), @@ -635,6 +732,8 @@ mod tests { ]), load_state: SidebarLoadState::Ready, max_entries: 5_000, + active_workspace_id: default_workspace_id(), + workspace_entries: HashMap::new(), persistor: None, collapsed_groups: Arc::new(HashSet::new()), collapsed_url_groups: Arc::new(HashSet::new()), @@ -655,6 +754,8 @@ mod tests { ))]), load_state: SidebarLoadState::Ready, max_entries: 5_000, + active_workspace_id: default_workspace_id(), + workspace_entries: HashMap::new(), persistor: None, collapsed_groups: Arc::new(HashSet::new()), collapsed_url_groups: Arc::new(HashSet::from(["api.example.com".to_string()])), diff --git a/src/entities/mod.rs b/src/entities/mod.rs index d631423..d38a366 100644 --- a/src/entities/mod.rs +++ b/src/entities/mod.rs @@ -1,13 +1,17 @@ pub mod collections; +pub mod environment; pub mod history; pub mod load_state; pub mod preferences; pub mod request; pub mod response; +pub mod workspace; pub use collections::*; +pub use environment::*; pub use history::*; pub use load_state::*; pub use preferences::*; pub use request::*; pub use response::*; +pub use workspace::*; diff --git a/src/entities/request.rs b/src/entities/request.rs index f502d66..594981b 100644 --- a/src/entities/request.rs +++ b/src/entities/request.rs @@ -68,7 +68,7 @@ impl Header { } /// A multipart form field that can be either text or a file -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct MultipartField { pub key: String, pub value: String, @@ -99,7 +99,7 @@ impl MultipartField { } /// Request body content -#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] pub enum RequestBody { #[default] None, diff --git a/src/entities/workspace.rs b/src/entities/workspace.rs new file mode 100644 index 0000000..921f39c --- /dev/null +++ b/src/entities/workspace.rs @@ -0,0 +1,233 @@ +use gpui::{Context, EventEmitter}; +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; +use std::time::Duration; +use uuid::Uuid; + +use crate::utils::DebouncedJsonWriter; + +const WORKSPACES_STORAGE_VERSION: u32 = 1; +const SAVE_DEBOUNCE: Duration = Duration::from_millis(250); +const DEFAULT_WORKSPACE_UUID: u128 = 0x7365_7475_0000_4000_8000_0000_0000_0001; + +pub fn default_workspace_id() -> Uuid { + Uuid::from_u128(DEFAULT_WORKSPACE_UUID) +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Workspace { + pub id: Uuid, + pub name: String, +} + +impl Workspace { + fn personal() -> Self { + Self { + id: default_workspace_id(), + name: "Personal Workspace".to_string(), + } + } + + fn new(name: impl Into) -> Self { + Self { + id: Uuid::new_v4(), + name: name.into(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct WorkspacesStore { + version: u32, + workspaces: Vec, + active_workspace_id: Uuid, +} + +impl Default for WorkspacesStore { + fn default() -> Self { + let workspace = Workspace::personal(); + Self { + version: WORKSPACES_STORAGE_VERSION, + active_workspace_id: workspace.id, + workspaces: vec![workspace], + } + } +} + +impl WorkspacesStore { + fn validated(mut self) -> Self { + if self.version != WORKSPACES_STORAGE_VERSION || self.workspaces.is_empty() { + return Self::default(); + } + if !self + .workspaces + .iter() + .any(|workspace| workspace.id == self.active_workspace_id) + { + self.active_workspace_id = self.workspaces[0].id; + } + self + } +} + +#[derive(Debug, Clone)] +pub enum WorkspaceEvent { + Changed, + ActiveChanged, +} + +pub struct WorkspacesEntity { + workspaces: Vec, + active_workspace_id: Uuid, + persistor: Option>, +} + +impl WorkspacesEntity { + pub fn load() -> Self { + let path = storage_path(); + let store = path + .as_ref() + .and_then(|path| std::fs::read_to_string(path).ok()) + .and_then(|contents| serde_json::from_str::(&contents).ok()) + .map(WorkspacesStore::validated) + .unwrap_or_default(); + + Self { + workspaces: store.workspaces, + active_workspace_id: store.active_workspace_id, + persistor: path.map(|path| DebouncedJsonWriter::new("workspaces", path, SAVE_DEBOUNCE)), + } + } + + pub fn workspaces(&self) -> &[Workspace] { + &self.workspaces + } + + pub fn active_workspace_id(&self) -> Uuid { + self.active_workspace_id + } + + pub fn active_workspace(&self) -> &Workspace { + self.workspaces + .iter() + .find(|workspace| workspace.id == self.active_workspace_id) + .unwrap_or(&self.workspaces[0]) + } + + pub fn create_workspace(&mut self, name: impl Into, cx: &mut Context) -> Uuid { + let requested_name = name.into(); + let name = requested_name.trim(); + let workspace = Workspace::new(if name.is_empty() { + "Untitled Workspace" + } else { + name + }); + let id = workspace.id; + self.workspaces.push(workspace); + self.changed(WorkspaceEvent::Changed, cx); + id + } + + pub fn set_active_workspace(&mut self, id: Uuid, cx: &mut Context) -> bool { + if id == self.active_workspace_id + || !self.workspaces.iter().any(|workspace| workspace.id == id) + { + return false; + } + self.active_workspace_id = id; + self.changed(WorkspaceEvent::ActiveChanged, cx); + true + } + + pub fn rename_workspace(&mut self, id: Uuid, name: String, cx: &mut Context) { + let name = name.trim(); + if name.is_empty() { + return; + } + let Some(workspace) = self + .workspaces + .iter_mut() + .find(|workspace| workspace.id == id) + else { + return; + }; + workspace.name = name.to_string(); + self.changed(WorkspaceEvent::Changed, cx); + } + + pub fn remove_workspace(&mut self, id: Uuid, cx: &mut Context) -> bool { + if self.workspaces.len() <= 1 { + return false; + } + let Some(index) = self + .workspaces + .iter() + .position(|workspace| workspace.id == id) + else { + return false; + }; + self.workspaces.remove(index); + if self.active_workspace_id == id { + self.active_workspace_id = self.workspaces[0].id; + } + self.changed(WorkspaceEvent::Changed, cx); + true + } + + fn changed(&self, event: WorkspaceEvent, cx: &mut Context) { + self.save_to_file(); + cx.emit(event); + cx.notify(); + } + + fn save_to_file(&self) { + if let Some(persistor) = &self.persistor { + persistor.schedule_save(WorkspacesStore { + version: WORKSPACES_STORAGE_VERSION, + workspaces: self.workspaces.clone(), + active_workspace_id: self.active_workspace_id, + }); + } + } +} + +impl EventEmitter for WorkspacesEntity {} + +fn storage_path() -> Option { + dirs::data_local_dir().map(|mut path| { + path.push("setu"); + path.push("workspaces.json"); + path + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn invalid_active_workspace_falls_back_to_first_workspace() { + let first = Workspace::new("First"); + let store = WorkspacesStore { + version: WORKSPACES_STORAGE_VERSION, + workspaces: vec![first.clone()], + active_workspace_id: Uuid::new_v4(), + } + .validated(); + + assert_eq!(store.active_workspace_id, first.id); + } + + #[test] + fn empty_store_restores_personal_workspace() { + let store = WorkspacesStore { + version: WORKSPACES_STORAGE_VERSION, + workspaces: Vec::new(), + active_workspace_id: Uuid::new_v4(), + } + .validated(); + + assert_eq!(store.workspaces.len(), 1); + assert_eq!(store.active_workspace_id, default_workspace_id()); + } +} diff --git a/src/icons.rs b/src/icons.rs index d9af457..bb22053 100644 --- a/src/icons.rs +++ b/src/icons.rs @@ -54,6 +54,13 @@ pub enum IconName { Volume2, VolumeX, Ellipsis, + Variable, + Lock, + Unlock, + Box, + Package, + Eye, + EyeOff, } impl IconNamed for IconName { @@ -106,6 +113,13 @@ impl IconNamed for IconName { Self::Volume2 => "icons/volume-2.svg", Self::VolumeX => "icons/volume-x.svg", Self::Ellipsis => "icons/ellipsis.svg", + Self::Variable => "icons/variable.svg", + Self::Lock => "icons/lock.svg", + Self::Unlock => "icons/unlock.svg", + Self::Box => "icons/box.svg", + Self::Package => "icons/package.svg", + Self::Eye => "icons/eye.svg", + Self::EyeOff => "icons/eye-closed.svg", } .into() } diff --git a/src/importers/mod.rs b/src/importers/mod.rs index 7c03491..9367250 100644 --- a/src/importers/mod.rs +++ b/src/importers/mod.rs @@ -5,7 +5,7 @@ use std::path::Path; use crate::entities::RequestData; -pub use postman::PostmanCollectionImporter; +pub use postman::{PostmanCollectionImporter, import_postman_environment}; #[derive(Debug, Clone, PartialEq, Eq)] pub struct ImportWarning { @@ -60,6 +60,7 @@ impl ImportedNode { pub struct ImportedCollection { pub name: String, pub nodes: Vec, + pub variables: Vec, } impl ImportedCollection { @@ -72,6 +73,33 @@ impl ImportedCollection { } } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ImportedVariable { + pub key: String, + pub value: String, + pub enabled: bool, + pub secret: bool, +} + +#[derive(Debug, Clone)] +pub struct ImportedEnvironment { + pub name: String, + pub variables: Vec, +} + +#[derive(Debug, Clone)] +pub enum ImportedPayload { + Collection(ImportedCollection), + Environment(ImportedEnvironment), +} + +#[derive(Debug, Clone)] +pub struct ImportedFileResult { + pub provider: &'static str, + pub payload: ImportedPayload, + pub warnings: Vec, +} + #[derive(Debug, Clone)] pub struct ImportResult { pub provider: &'static str, @@ -98,18 +126,42 @@ impl Default for ImportRegistry { } impl ImportRegistry { + pub fn import_any_file(&self, path: &Path) -> Result { + let contents = std::fs::read_to_string(path) + .with_context(|| format!("Failed to read {}", path.display()))?; + + if let Some(environment) = import_postman_environment(path, &contents) { + return environment.map(|environment| ImportedFileResult { + provider: "Postman", + payload: ImportedPayload::Environment(environment), + warnings: Vec::new(), + }); + } + + self.import_contents(path, &contents) + .map(|result| ImportedFileResult { + provider: result.provider, + payload: ImportedPayload::Collection(result.collection), + warnings: result.warnings, + }) + } + + #[allow(dead_code)] pub fn import_file(&self, path: &Path) -> Result { let contents = std::fs::read_to_string(path) .with_context(|| format!("Failed to read {}", path.display()))?; + self.import_contents(path, &contents) + } + fn import_contents(&self, path: &Path, contents: &str) -> Result { for importer in &self.importers { - if importer.matches(path, &contents) { - return importer.import(path, &contents); + if importer.matches(path, contents) { + return importer.import(path, contents); } } Err(anyhow!( - "Unsupported collection file. Only Postman collection JSON is supported right now." + "Unsupported import file. Select a Postman collection or environment JSON export." )) } } diff --git a/src/importers/postman.rs b/src/importers/postman.rs index 359e24d..cbe0fe6 100644 --- a/src/importers/postman.rs +++ b/src/importers/postman.rs @@ -9,7 +9,10 @@ use uuid::Uuid; use crate::entities::{Header, HttpMethod, MultipartField, RequestBody, RequestData}; -use super::{CollectionImporter, ImportResult, ImportWarning, ImportedCollection, ImportedNode}; +use super::{ + CollectionImporter, ImportResult, ImportWarning, ImportedCollection, ImportedEnvironment, + ImportedNode, ImportedVariable, +}; #[derive(Default)] pub struct PostmanCollectionImporter; @@ -64,12 +67,11 @@ impl CollectionImporter for PostmanCollectionImporter { !document.event.is_empty(), "Collection scripts are skipped.", ); - warn_on_unsupported_fields( - &mut warnings, - &collection_path, - !document.variable.is_empty(), - "Collection variables are skipped.", - ); + let variables = document + .variable + .into_iter() + .filter_map(import_variable) + .collect(); let nodes = document .item @@ -89,6 +91,7 @@ impl CollectionImporter for PostmanCollectionImporter { collection: ImportedCollection { name: collection_name, nodes, + variables, }, warnings, }) @@ -103,7 +106,7 @@ struct PostmanCollectionDocument { #[serde(default)] event: Vec, #[serde(default)] - variable: Vec, + variable: Vec, #[serde(default)] auth: Option, } @@ -113,6 +116,89 @@ struct PostmanInfo { name: Option, } +#[derive(Debug, Deserialize)] +struct PostmanVariable { + #[serde(default)] + key: Option, + #[serde(default)] + value: Option, + #[serde(default)] + enabled: Option, + #[serde(default)] + disabled: Option, + #[serde(default, rename = "type")] + variable_type: Option, +} + +#[derive(Debug, Deserialize)] +struct PostmanEnvironmentDocument { + #[serde(default)] + name: Option, + #[serde(default)] + values: Vec, + #[serde(default)] + _postman_variable_scope: Option, +} + +pub fn import_postman_environment( + path: &Path, + contents: &str, +) -> Option> { + let value = serde_json::from_str::(contents).ok()?; + let has_values = value.get("values").and_then(Value::as_array).is_some(); + let scope = value.get("_postman_variable_scope").and_then(Value::as_str); + if !has_values || scope.is_some_and(|scope| scope != "environment" && scope != "globals") { + return None; + } + if value.get("info").is_some() || value.get("item").is_some() { + return None; + } + + Some( + serde_json::from_str::(contents) + .map_err(|error| anyhow!("Failed to parse Postman environment JSON: {error}")) + .map(|document| { + let name = document + .name + .or_else(|| { + path.file_stem() + .and_then(|stem| stem.to_str()) + .map(str::to_string) + }) + .filter(|name| !name.trim().is_empty()) + .unwrap_or_else(|| "Imported Environment".to_string()); + ImportedEnvironment { + name, + variables: document + .values + .into_iter() + .filter_map(import_variable) + .collect(), + } + }), + ) +} + +fn import_variable(variable: PostmanVariable) -> Option { + let key = variable.key?.trim().to_string(); + if key.is_empty() { + return None; + } + let value = match variable.value.unwrap_or(Value::String(String::new())) { + Value::String(value) => value, + Value::Null => String::new(), + value => value.to_string(), + }; + Some(ImportedVariable { + key, + value, + enabled: variable.enabled.unwrap_or(true) && !variable.disabled.unwrap_or(false), + secret: variable + .variable_type + .is_some_and(|kind| kind.eq_ignore_ascii_case("secret")), + }) +} + #[derive(Debug, Deserialize)] struct PostmanItem { #[serde(default)] @@ -1575,4 +1661,56 @@ mod tests { .contains("Duplicate x-www-form-urlencoded key") })); } + + #[test] + fn imports_collection_variables_with_enabled_and_secret_metadata() { + let importer = PostmanCollectionImporter; + let json = r#" + { + "info": { + "name": "Variables", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" + }, + "variable": [ + { "key": "base_url", "value": "https://api.example.com" }, + { "key": "token", "value": "secret", "type": "secret" }, + { "key": "disabled", "value": 42, "disabled": true } + ], + "item": [] + }"#; + + let result = importer + .import(Path::new("variables.postman_collection.json"), json) + .expect("import succeeds"); + + assert_eq!(result.collection.variables.len(), 3); + assert_eq!(result.collection.variables[0].key, "base_url"); + assert!(result.collection.variables[1].secret); + assert!(!result.collection.variables[2].enabled); + assert_eq!(result.collection.variables[2].value, "42"); + } + + #[test] + fn imports_postman_environment_exports() { + let json = r#" + { + "id": "example", + "name": "Production", + "values": [ + { "key": "base_url", "value": "https://api.example.com", "enabled": true }, + { "key": "token", "value": "secret", "enabled": false, "type": "secret" } + ], + "_postman_variable_scope": "environment" + }"#; + + let environment = + import_postman_environment(Path::new("production.postman_environment.json"), json) + .expect("recognized as an environment") + .expect("import succeeds"); + + assert_eq!(environment.name, "Production"); + assert_eq!(environment.variables.len(), 2); + assert!(!environment.variables[1].enabled); + assert!(environment.variables[1].secret); + } } diff --git a/src/main.rs b/src/main.rs index 2263491..18f7e3b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -21,6 +21,7 @@ mod actions; mod app; mod assets; +mod completion; mod components; mod entities; mod http; diff --git a/src/theme/gpui_theme.rs b/src/theme/gpui_theme.rs index 31ac0ff..993146f 100644 --- a/src/theme/gpui_theme.rs +++ b/src/theme/gpui_theme.rs @@ -125,7 +125,9 @@ fn apply_setu_teal_theme(cx: &mut App) { theme.primary = colors.accent; theme.primary_hover = colors.accent_hover; theme.primary_foreground = colors.text_primary; - theme.accent = colors.accent_muted; + // Keep transient selections restrained and neutral, like editor completion rows. + // Teal remains reserved for primary actions and focus. + theme.accent = hsla(240.0 / 360.0, 0.06, 0.19, 1.0); theme.accent_foreground = colors.text_primary; // Semantic colors diff --git a/src/views/command_palette.rs b/src/views/command_palette.rs index f221cef..a5c74b3 100644 --- a/src/views/command_palette.rs +++ b/src/views/command_palette.rs @@ -157,7 +157,7 @@ pub fn default_commands() -> Vec { ), Command::new( CommandId::ImportCollection, - "Import Collection", + "Import Postman Data", IconName::FileUp, ), Command::new( diff --git a/src/views/main_view.rs b/src/views/main_view.rs index 2593c30..f8e67e2 100644 --- a/src/views/main_view.rs +++ b/src/views/main_view.rs @@ -10,6 +10,7 @@ use gpui_component::WindowExt; use gpui_component::button::{Button, ButtonVariants}; use gpui_component::dialog::DialogFooter; use gpui_component::input::{Input, InputState}; +use gpui_component::menu::{DropdownMenu, PopupMenuItem}; use gpui_component::notification::NotificationType; use gpui_component::resizable::{ResizableState, h_resizable, resizable_panel, v_resizable}; use gpui_component::scroll::ScrollableElement; @@ -22,19 +23,21 @@ use std::time::Duration; use uuid::Uuid; use crate::actions::*; +use crate::completion::{CompletionContext, CompletionEngine, configure_completion}; use crate::components::{ - AppSidebar, BodyType, HistoryFilter, HistoryGroupBy, MethodDropdownState, ProtocolSelector, - ProtocolType, SidebarTab, TabBar, TabInfo, UrlBar, + AppSidebar, BodyType, EnvironmentPanel, HistoryFilter, HistoryGroupBy, MethodDropdownState, + ProtocolSelector, ProtocolType, SidebarTab, TabBar, TabInfo, UrlBar, }; use crate::entities::{ - CollectionDestination, CollectionDestinationEntry, CollectionsEntity, Header, HistoryEntity, - HistoryGrouping, HistoryRow, HttpMethod, PreferredLayout, RequestBody, RequestData, - RequestEntity, RequestEvent, ResponseData, ResponseEntity, SidebarLoadState, UiPreferences, - UiPreferencesStore, + CollectionDestination, CollectionDestinationEntry, CollectionsEntity, EnvironmentColor, + EnvironmentScope, EnvironmentVariable, EnvironmentsEntity, HistoryEntity, HistoryGrouping, + HistoryRow, HttpMethod, PreferredLayout, RequestBody, RequestData, RequestEntity, RequestEvent, + ResponseData, ResponseEntity, SidebarLoadState, UiPreferences, UiPreferencesStore, + WorkspacesEntity, }; use crate::http::{HttpClient, InFlightRequest}; use crate::icons::IconName; -use crate::importers::{ImportRegistry, ImportWarning}; +use crate::importers::{ImportRegistry, ImportWarning, ImportedPayload}; use crate::utils::{close_dialog, open_dialog}; use crate::views::request_view::RequestView; use crate::views::response_view::ResponseView; @@ -74,6 +77,7 @@ pub struct TabState { pub response_view: Entity, pub in_flight_request: Option, pub request_generation: RequestGeneration, + pub collection_id: Option, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -100,6 +104,24 @@ impl SelectItem for DestinationOption { } } +#[derive(Clone, Debug, PartialEq, Eq)] +struct EnvironmentScopeOption { + scope: EnvironmentScope, + label: String, +} + +impl SelectItem for EnvironmentScopeOption { + type Value = EnvironmentScopeOption; + + fn title(&self) -> SharedString { + self.label.clone().into() + } + + fn value(&self) -> &Self::Value { + self + } +} + #[derive(Clone, Copy, Debug)] enum RenameTarget { Collection(Uuid), @@ -109,9 +131,11 @@ enum RenameTarget { #[derive(Clone, Debug)] struct ImportSummary { provider: &'static str, - collection_name: String, - folder_count: usize, - request_count: usize, + item_kind: &'static str, + item_name: String, + folder_count: Option, + request_count: Option, + variable_count: usize, warnings: Vec, } @@ -129,6 +153,10 @@ pub struct MainView { // Shared state history: Entity, collections: Entity, + environments: Entity, + workspaces: Entity, + environment_panel: Entity, + completion_engine: CompletionEngine, http_client: HttpClient, // UI state @@ -157,7 +185,19 @@ impl MainView { let request = cx.new(|_| RequestEntity::new()); let response = cx.new(|_| ResponseEntity::new()); let method_dropdown = cx.new(|_| MethodDropdownState::new(HttpMethod::Get)); - let request_view = cx.new(|cx| RequestView::new(request.clone(), BodyType::None, cx)); + let focus_handle = cx.focus_handle(); + let command_palette = cx.new(|cx| CommandPaletteView::new(focus_handle.clone(), cx)); + let workspaces = cx.new(|_| WorkspacesEntity::load()); + let active_workspace_id = workspaces.read(cx).active_workspace_id(); + let history = cx.new(|_| HistoryEntity::new_for_workspace(active_workspace_id)); + let collections = cx.new(|_| CollectionsEntity::new_for_workspace(active_workspace_id)); + let environments = cx.new(|_| EnvironmentsEntity::new_for_workspace(active_workspace_id)); + let completion_engine = CompletionEngine::for_environments(environments.clone()); + let completion_engine_for_request = completion_engine.clone(); + let request_view = cx.new(|cx| { + RequestView::new(request.clone(), BodyType::None, cx) + .with_completion_engine(completion_engine_for_request) + }); let response_view = cx.new(|cx| ResponseView::new(response.clone(), cx)); let initial_tab = TabState { @@ -172,12 +212,10 @@ impl MainView { response_view, in_flight_request: None, request_generation: RequestGeneration::default(), + collection_id: None, }; - - let focus_handle = cx.focus_handle(); - let command_palette = cx.new(|cx| CommandPaletteView::new(focus_handle.clone(), cx)); - let history = cx.new(|_| HistoryEntity::new()); - let collections = cx.new(|_| CollectionsEntity::new()); + let environment_panel = + cx.new(|cx| EnvironmentPanel::new(environments.clone(), collections.clone(), cx)); let history_load = HistoryEntity::spawn_storage_load(); let history_for_load = history.clone(); cx.spawn(async move |_view, cx| { @@ -192,6 +230,20 @@ impl MainView { }) .detach(); + let environments_load = EnvironmentsEntity::spawn_storage_load(); + let environments_for_load = environments.clone(); + cx.spawn(async move |_view, cx| { + let result = environments_load + .await + .unwrap_or_else(|_| Err("Environment loader stopped unexpectedly".to_string())); + cx.update(|app| { + environments_for_load.update(app, |environments, cx| { + environments.apply_storage_load(result, cx); + }); + }) + }) + .detach(); + let collections_load = CollectionsEntity::spawn_storage_load(); let collections_for_load = collections.clone(); cx.spawn(async move |_view, cx| { @@ -222,6 +274,10 @@ impl MainView { .detach(); cx.subscribe(&collections, |_this, _, _event, cx| cx.notify()) .detach(); + cx.subscribe(&environments, |_this, _, _event, cx| cx.notify()) + .detach(); + cx.subscribe(&workspaces, |_this, _, _event, cx| cx.notify()) + .detach(); let http_client = HttpClient::new().expect("Failed to create HTTP client"); @@ -233,6 +289,10 @@ impl MainView { command_palette, history, collections, + environments, + workspaces, + environment_panel, + completion_engine, http_client, sidebar_visible: ui_preferences.sidebar_visible, sidebar_width: ui_preferences.sidebar_width, @@ -275,11 +335,17 @@ impl MainView { /// Ensure URL input is initialized for a tab fn ensure_url_input(&mut self, tab_index: usize, window: &mut Window, cx: &mut Context) { + let completion_engine = self.completion_engine.clone(); if let Some(tab) = self.tabs.get_mut(tab_index) && tab.url_input.is_none() { - let url_input = - cx.new(|cx| InputState::new(window, cx).placeholder("Enter request URL...")); + let url_input = cx.new(|cx| { + configure_completion( + InputState::new(window, cx).placeholder("Enter request URL..."), + Some(&completion_engine), + CompletionContext::Url, + ) + }); let tab_id = tab.id; Self::subscribe_url_input(&url_input, tab_id, window, cx); tab.url_input = Some(url_input); @@ -539,17 +605,24 @@ impl MainView { }); let method_dropdown = cx.new(|_| MethodDropdownState::new(request_data.method)); + let completion_engine = self.completion_engine.clone(); let request_view = cx.new(|cx| { RequestView::new(request.clone(), body_type, cx) + .with_completion_engine(completion_engine) .with_initial_body_content(body_content) .with_initial_form_data(form_data) .with_initial_multipart_data(multipart_data) }); let response_view = cx.new(|cx| ResponseView::new(response.clone(), cx)); + let completion_engine = self.completion_engine.clone(); let url_input = cx.new(|cx| { - InputState::new(window, cx) - .placeholder("Enter request URL...") - .default_value(&request_data.url) + configure_completion( + InputState::new(window, cx) + .placeholder("Enter request URL...") + .default_value(&request_data.url), + Some(&completion_engine), + CompletionContext::Url, + ) }); let tab_id = TabId(self.next_tab_id); Self::subscribe_url_input(&url_input, tab_id, window, cx); @@ -567,6 +640,7 @@ impl MainView { response_view, in_flight_request: None, request_generation: RequestGeneration::default(), + collection_id: None, }; self.tabs.push(tab); @@ -669,17 +743,24 @@ impl MainView { let response = cx.new(|_| ResponseEntity::new()); let method_dropdown = cx.new(|_| MethodDropdownState::new(request_data.method)); + let completion_engine = self.completion_engine.clone(); let request_view = cx.new(|cx| { RequestView::new(request.clone(), body_type, cx) + .with_completion_engine(completion_engine) .with_initial_body_content(body_content) .with_initial_form_data(form_data) .with_initial_multipart_data(multipart_data) }); let response_view = cx.new(|cx| ResponseView::new(response.clone(), cx)); + let completion_engine = self.completion_engine.clone(); let url_input = cx.new(|cx| { - InputState::new(window, cx) - .placeholder("Enter request URL...") - .default_value(&request_data.url) + configure_completion( + InputState::new(window, cx) + .placeholder("Enter request URL...") + .default_value(&request_data.url), + Some(&completion_engine), + CompletionContext::Url, + ) }); let tab_id = TabId(self.next_tab_id); Self::subscribe_url_input(&url_input, tab_id, window, cx); @@ -697,6 +778,7 @@ impl MainView { response_view, in_flight_request: None, request_generation: RequestGeneration::default(), + collection_id: Some(collection_id), }; self.tabs.push(tab); @@ -712,11 +794,425 @@ impl MainView { }); } + fn switch_workspace(&mut self, workspace_id: Uuid, cx: &mut Context) { + if self.workspaces.read(cx).active_workspace_id() == workspace_id { + return; + } + self.cancel_in_flight_for_all_tabs(cx); + self.collections.update(cx, |collections, cx| { + collections.set_active_workspace(workspace_id, cx); + }); + self.history.update(cx, |history, cx| { + history.set_active_workspace(workspace_id, cx); + }); + self.environments.update(cx, |environments, cx| { + environments.set_active_workspace(workspace_id, cx); + }); + self.workspaces.update(cx, |workspaces, cx| { + workspaces.set_active_workspace(workspace_id, cx); + }); + + self.tabs.clear(); + self.active_tab_index = 0; + self.new_tab(cx); + self.schedule_history_rows(cx); + cx.notify(); + } + + fn show_new_workspace_dialog(&mut self, window: &mut Window, cx: &mut Context) { + let input = cx.new(|cx| { + InputState::new(window, cx) + .placeholder("My Workspace") + .default_value("New Workspace") + }); + let input_for_create = input.clone(); + let this = cx.entity().clone(); + open_dialog(window, cx, move |dialog, _, cx| { + let this_for_create = this.clone(); + let input_for_create = input_for_create.clone(); + dialog + .title("New Workspace") + .child( + v_flex() + .gap_3() + .child( + div() + .text_sm() + .text_color(cx.theme().muted_foreground) + .child( + "Collections, request history, and environments stay isolated inside this workspace.", + ), + ) + .child(Input::new(&input)), + ) + .footer( + DialogFooter::new() + .child( + Button::new("create-workspace-confirm") + .label("Create workspace") + .primary() + .on_click(move |_, window, cx| { + let name = + input_for_create.read(cx).text().to_string(); + this_for_create.update(cx, |view, cx| { + let workspace_id = + view.workspaces.update(cx, |workspaces, cx| { + workspaces.create_workspace(name, cx) + }); + view.switch_workspace(workspace_id, cx); + }); + close_dialog(window, cx); + }), + ) + .child( + Button::new("create-workspace-cancel") + .label("Cancel") + .on_click(|_, window, cx| close_dialog(window, cx)), + ), + ) + }); + } + + fn show_rename_workspace_dialog( + &mut self, + workspace_id: Uuid, + current_name: String, + window: &mut Window, + cx: &mut Context, + ) { + let input = cx.new(|cx| { + InputState::new(window, cx) + .placeholder("Workspace name") + .default_value(current_name) + }); + let input_for_rename = input.clone(); + let this = cx.entity().clone(); + open_dialog(window, cx, move |dialog, _, _| { + let this_for_rename = this.clone(); + let input_for_rename = input_for_rename.clone(); + dialog + .title("Rename Workspace") + .child(Input::new(&input)) + .footer( + DialogFooter::new() + .child( + Button::new("rename-workspace-confirm") + .label("Rename") + .primary() + .on_click(move |_, window, cx| { + let name = input_for_rename.read(cx).text().to_string(); + this_for_rename.update(cx, |view, cx| { + view.workspaces.update(cx, |workspaces, cx| { + workspaces.rename_workspace(workspace_id, name, cx); + }); + }); + close_dialog(window, cx); + }), + ) + .child( + Button::new("rename-workspace-cancel") + .label("Cancel") + .on_click(|_, window, cx| close_dialog(window, cx)), + ), + ) + }); + } + + fn show_delete_workspace_dialog( + &mut self, + workspace_id: Uuid, + window: &mut Window, + cx: &mut Context, + ) { + let (name, fallback_id) = { + let workspaces = self.workspaces.read(cx); + let Some(workspace) = workspaces + .workspaces() + .iter() + .find(|workspace| workspace.id == workspace_id) + else { + return; + }; + let Some(fallback) = workspaces + .workspaces() + .iter() + .find(|workspace| workspace.id != workspace_id) + else { + return; + }; + (workspace.name.clone(), fallback.id) + }; + let this = cx.entity().clone(); + open_dialog(window, cx, move |dialog, _, _| { + let this_for_delete = this.clone(); + dialog + .title("Delete Workspace") + .child(format!( + "Delete “{name}” and its collections, history, and environments? This cannot be undone." + )) + .footer( + DialogFooter::new() + .child( + Button::new("delete-workspace-confirm") + .label("Delete workspace") + .danger() + .on_click(move |_, window, cx| { + this_for_delete.update(cx, |view, cx| { + view.switch_workspace(fallback_id, cx); + view.collections.update(cx, |collections, _| { + collections.remove_workspace(workspace_id); + }); + view.history.update(cx, |history, _| { + history.remove_workspace(workspace_id); + }); + view.environments.update(cx, |environments, _| { + environments.remove_workspace(workspace_id); + }); + view.workspaces.update(cx, |workspaces, cx| { + workspaces.remove_workspace(workspace_id, cx); + }); + }); + close_dialog(window, cx); + }), + ) + .child( + Button::new("delete-workspace-cancel") + .label("Cancel") + .on_click(|_, window, cx| close_dialog(window, cx)), + ), + ) + }); + } + + pub fn show_new_environment_dialog( + &mut self, + project_id: Option, + window: &mut Window, + cx: &mut Context, + ) { + let mut scope_options = vec![ + EnvironmentScopeOption { + scope: EnvironmentScope::Global, + label: "Global · Available in every workspace".to_string(), + }, + EnvironmentScopeOption { + scope: EnvironmentScope::Workspace, + label: "Workspace · Available in this workspace".to_string(), + }, + ]; + scope_options.extend( + self.collections + .read(cx) + .collections + .iter() + .map(|collection| EnvironmentScopeOption { + scope: EnvironmentScope::Project(collection.id), + label: format!("Project · {}", collection.name), + }), + ); + let selected_index = project_id + .and_then(|project_id| { + scope_options + .iter() + .position(|option| option.scope == EnvironmentScope::Project(project_id)) + }) + .unwrap_or(1); + let scope_select = cx.new(|cx| { + SelectState::new( + scope_options, + Some(gpui_component::IndexPath::new(selected_index)), + window, + cx, + ) + }); + let name_input = cx.new(|cx| { + InputState::new(window, cx) + .placeholder("Development, Staging, Production…") + .default_value("Development") + }); + let this = cx.entity().clone(); + let name_for_footer = name_input.clone(); + let scope_for_footer = scope_select.clone(); + + open_dialog(window, cx, move |dialog, _, cx| { + let this_for_create = this.clone(); + let name_for_create = name_for_footer.clone(); + let scope_for_create = scope_for_footer.clone(); + + dialog + .title("New Environment") + .child( + v_flex() + .gap_3() + .child( + div() + .text_sm() + .text_color(cx.theme().muted_foreground) + .child( + "Global environments are shared by every workspace. Workspace and project environments override them with more specific values.", + ), + ) + .child("Environment name") + .child(Input::new(&name_input)) + .child("Scope") + .child(Select::new(&scope_select).menu_width(px(360.0))), + ) + .footer( + DialogFooter::new() + .child( + Button::new("create-environment") + .label("Create environment") + .primary() + .on_click(move |_, window, cx| { + let name = name_for_create + .read(cx) + .text() + .to_string() + .trim() + .to_string(); + let name = if name.is_empty() { + "Development".to_string() + } else { + name + }; + let Some(scope) = scope_for_create + .read(cx) + .selected_value() + .map(|option| option.scope) + else { + return; + }; + this_for_create.update(cx, |view, cx| { + let environment_id = view.environments.update( + cx, + |environments, cx| { + environments.create_environment(name, scope, cx) + }, + ); + view.environment_panel.update(cx, |panel, cx| { + panel.select_environment(environment_id, cx); + }); + }); + close_dialog(window, cx); + }), + ) + .child( + Button::new("create-environment-cancel") + .label("Cancel") + .on_click(|_, window, cx| close_dialog(window, cx)), + ), + ) + }); + } + + pub fn show_delete_environment_dialog( + &mut self, + environment_id: Uuid, + window: &mut Window, + cx: &mut Context, + ) { + let Some(name) = self + .environments + .read(cx) + .get(environment_id) + .map(|environment| environment.name.clone()) + else { + return; + }; + let this = cx.entity().clone(); + open_dialog(window, cx, move |dialog, _, _| { + let this_for_delete = this.clone(); + dialog + .title("Delete Environment") + .child(format!( + "Delete “{name}”? Requests using its variables will stop resolving." + )) + .footer( + DialogFooter::new() + .child( + Button::new("delete-environment-confirm") + .label("Delete") + .danger() + .on_click(move |_, window, cx| { + this_for_delete.update(cx, |view, cx| { + view.environments.update(cx, |environments, cx| { + environments.remove_environment(environment_id, cx); + }); + }); + close_dialog(window, cx); + }), + ) + .child( + Button::new("delete-environment-cancel") + .label("Cancel") + .on_click(|_, window, cx| close_dialog(window, cx)), + ), + ) + }); + } + + pub fn show_rename_environment_dialog( + &mut self, + environment_id: Uuid, + current_name: String, + window: &mut Window, + cx: &mut Context, + ) { + let input = cx.new(|cx| { + InputState::new(window, cx) + .placeholder("Environment name") + .default_value(current_name) + }); + let this = cx.entity().clone(); + let input_for_footer = input.clone(); + open_dialog(window, cx, move |dialog, _, _| { + let this_for_rename = this.clone(); + let input_for_rename = input_for_footer.clone(); + dialog + .title("Rename Environment") + .child(Input::new(&input)) + .footer( + DialogFooter::new() + .child( + Button::new("rename-environment-confirm") + .label("Rename") + .primary() + .on_click(move |_, window, cx| { + let name = input_for_rename.read(cx).text().to_string(); + this_for_rename.update(cx, |view, cx| { + view.environments.update(cx, |environments, cx| { + environments.rename_environment( + environment_id, + name, + cx, + ); + }); + }); + close_dialog(window, cx); + }), + ) + .child( + Button::new("rename-environment-cancel") + .label("Cancel") + .on_click(|_, window, cx| close_dialog(window, cx)), + ), + ) + }); + } + /// Delete a collection pub fn delete_collection(&mut self, collection_id: Uuid, cx: &mut Context) { self.collections.update(cx, |collections, cx| { collections.remove_collection(collection_id, cx); }); + self.environments.update(cx, |environments, cx| { + environments.remove_project_environments(collection_id, cx); + }); + for tab in &mut self.tabs { + if tab.collection_id == Some(collection_id) { + tab.collection_id = None; + } + } } /// Delete an item from a collection @@ -778,6 +1274,10 @@ impl MainView { cx, ); }); + if let Some(tab) = self.tabs.get_mut(self.active_tab_index) { + tab.collection_id = Some(destination.collection_id); + } + cx.notify(); } fn build_active_request_snapshot(&mut self, cx: &mut Context) -> Option { @@ -926,7 +1426,11 @@ impl MainView { let request = cx.new(|_| RequestEntity::new()); let response = cx.new(|_| ResponseEntity::new()); let method_dropdown = cx.new(|_| MethodDropdownState::new(HttpMethod::Get)); - let request_view = cx.new(|cx| RequestView::new(request.clone(), BodyType::None, cx)); + 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)); let tab_id = TabId(self.next_tab_id); @@ -944,6 +1448,7 @@ impl MainView { response_view, in_flight_request: None, request_generation: RequestGeneration::default(), + collection_id: None, }; self.tabs.push(tab); @@ -1552,7 +2057,7 @@ impl MainView { files: true, directories: false, multiple: false, - prompt: Some("Select collection file to import".into()), + prompt: Some("Select a Postman collection or environment".into()), }; let paths_receiver = cx.prompt_for_paths(options); @@ -1579,21 +2084,94 @@ impl MainView { return; }; - let result = ImportRegistry::default().import_file(&path); + let result = ImportRegistry::default().import_any_file(&path); let _ = cx.update(|window, app| match result { Ok(result) => { - let summary = ImportSummary { - provider: result.provider, - collection_name: result.collection.name.clone(), - folder_count: result.collection.folder_count(), - request_count: result.collection.request_count(), - warnings: result.warnings.clone(), - }; - this.update(app, |view, cx| { - view.collections.update(cx, |collections, cx| { - collections.import_collection(result.collection, cx); - }); + let summary = match result.payload { + ImportedPayload::Collection(collection) => { + let name = collection.name.clone(); + let folder_count = collection.folder_count(); + let request_count = collection.request_count(); + let variables = collection.variables.clone(); + let variable_count = variables.len(); + + let workspace_id = view.workspaces.update(cx, |workspaces, cx| { + workspaces.create_workspace(name.clone(), cx) + }); + view.switch_workspace(workspace_id, cx); + + let collection_id = + view.collections.update(cx, |collections, cx| { + collections.import_collection(collection, cx) + }); + if !variables.is_empty() { + view.environments.update(cx, |environments, cx| { + environments.import_environment( + format!("{name} Variables"), + EnvironmentScope::Project(collection_id), + variables + .into_iter() + .map(|variable| EnvironmentVariable { + key: variable.key, + value: variable.value, + enabled: variable.enabled, + secret: variable.secret, + ..EnvironmentVariable::default() + }) + .collect(), + cx, + ); + }); + } + ImportSummary { + provider: result.provider, + item_kind: "Workspace", + item_name: name, + folder_count: Some(folder_count), + request_count: Some(request_count), + variable_count, + warnings: result.warnings, + } + } + ImportedPayload::Environment(environment) => { + let name = environment.name.clone(); + let variable_count = environment.variables.len(); + let environment_id = + view.environments.update(cx, |environments, cx| { + environments.import_environment( + name.clone(), + EnvironmentScope::Workspace, + environment + .variables + .into_iter() + .map(|variable| EnvironmentVariable { + key: variable.key, + value: variable.value, + enabled: variable.enabled, + secret: variable.secret, + ..EnvironmentVariable::default() + }) + .collect(), + cx, + ) + }); + view.environment_panel.update(cx, |panel, cx| { + panel.select_environment(environment_id, cx); + }); + view.sidebar_visible = true; + view.sidebar_tab = SidebarTab::Environments; + ImportSummary { + provider: result.provider, + item_kind: "Environment", + item_name: name, + folder_count: None, + request_count: None, + variable_count, + warnings: result.warnings, + } + } + }; view.show_import_summary_dialog(summary, window, cx); }); } @@ -1628,9 +2206,14 @@ impl MainView { v_flex() .gap_3() .child(format!("Provider: {}", summary.provider)) - .child(format!("Collection: {}", summary.collection_name)) - .child(format!("Folders imported: {}", summary.folder_count)) - .child(format!("Requests imported: {}", summary.request_count)) + .child(format!("{}: {}", summary.item_kind, summary.item_name)) + .when_some(summary.folder_count, |element, count| { + element.child(format!("Folders imported: {count}")) + }) + .when_some(summary.request_count, |element, count| { + element.child(format!("Requests imported: {count}")) + }) + .child(format!("Variables imported: {}", summary.variable_count)) .child(format!("Warnings: {}", warning_count)) .child(if warnings.is_empty() { div() @@ -1711,6 +2294,7 @@ impl MainView { let response_entity = tab.response.clone(); let request_view = tab.request_view.clone(); let tab_name = tab.name.clone(); + let collection_id = tab.collection_id; // Toggle behavior: send when idle, cancel when already sending. if request_entity.read(cx).is_sending() { @@ -1753,23 +2337,43 @@ impl MainView { base_url }; - // Mark as sending - request_entity.update(cx, |req, cx| { - req.set_sending(true, cx); + // Get request params, then resolve templates only for the outgoing request. + // Stored requests and history retain {{variables}} so secrets are not copied there. + let (method, template_headers, template_body) = { + let request = request_entity.read(cx); + ( + request.method(), + request.headers().to_vec(), + request.body().clone(), + ) + }; + let resolved = self.environments.read(cx).resolve_request( + collection_id, + &url, + &template_headers, + &template_body, + ); + let resolved = match resolved { + Ok(resolved) => resolved, + Err(error) => { + response_entity.update(cx, |response, cx| { + response.set_error(error.user_message(), cx); + }); + return; + } + }; + let resolved_url = resolved.url; + let resolved_headers = resolved.headers; + let resolved_body = resolved.body; + request_entity.update(cx, |request, cx| { + request.set_sending(true, cx); }); - - response_entity.update(cx, |resp, cx| { - resp.set_loading(cx); + response_entity.update(cx, |response, cx| { + response.set_loading(cx); }); - // Get request params (clone what we need before async) - let request = request_entity.read(cx); - let method = request.method(); - let headers: Vec
= request.headers().to_vec(); - let body: RequestBody = request.body().clone(); - let started_at = std::time::Instant::now(); - log::info!("Sending {} request to {}", method.as_str(), url); + log::info!("Sending {} request", method.as_str()); // Create request data for history before sending let history_request_data = RequestData { @@ -1777,8 +2381,8 @@ impl MainView { name: tab_name, url: url.clone(), method, - headers: headers.clone(), - body: body.clone(), + headers: template_headers, + body: template_body, is_sending: false, }; @@ -1786,7 +2390,8 @@ impl MainView { // Spawn HTTP request on Tokio runtime and keep a cancel handle on the tab. let (result_rx, in_flight_request) = - self.http_client.spawn_request(method, url, headers, body); + self.http_client + .spawn_request(method, resolved_url, resolved_headers, resolved_body); let generation = if let Some(tab) = self.tabs.get_mut(tab_index) { let generation = tab.request_generation.advance(); tab.in_flight_request = Some(in_flight_request); @@ -2053,6 +2658,7 @@ impl MainView { let old_url_input = current_tab.url_input.clone(); let old_name = current_tab.name.clone(); let old_request_view = current_tab.request_view.clone(); + let old_collection_id = current_tab.collection_id; let old_body_type = old_request_view.read(cx).get_body_type(); let old_method = old_request.read(cx).method(); @@ -2101,10 +2707,15 @@ impl MainView { let new_method_dropdown = cx.new(|_| MethodDropdownState::new(old_method)); let new_url_input = if old_url_input.is_some() { + let completion_engine = self.completion_engine.clone(); let input = cx.new(|cx| { - InputState::new(window, cx) - .placeholder("Enter request URL...") - .default_value(&duplicated_url) + configure_completion( + InputState::new(window, cx) + .placeholder("Enter request URL...") + .default_value(&duplicated_url), + Some(&completion_engine), + CompletionContext::Url, + ) }); let tab_id = TabId(self.next_tab_id); Self::subscribe_url_input(&input, tab_id, window, cx); @@ -2113,8 +2724,10 @@ impl MainView { None }; + let completion_engine = self.completion_engine.clone(); let new_request_view = cx.new(|cx| { RequestView::new(new_request.clone(), old_body_type, cx) + .with_completion_engine(completion_engine) .with_initial_body_content(body_content) .with_initial_form_data(form_data) .with_initial_multipart_data(multipart_data) @@ -2136,6 +2749,7 @@ impl MainView { response_view: new_response_view, in_flight_request: None, request_generation: RequestGeneration::default(), + collection_id: old_collection_id, }; self.tabs.push(new_tab); @@ -2199,6 +2813,8 @@ impl Focusable for MainView { impl Render for MainView { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + self.completion_engine + .set_collection_id(self.active_tab().and_then(|tab| tab.collection_id)); if let Some(cmd_id) = self.pending_window_command.take() { match cmd_id { CommandId::DuplicateRequest => self.duplicate_request(window, cx), @@ -2214,7 +2830,7 @@ impl Render for MainView { // Ensure sidebar search inputs are initialized self.ensure_sidebar_inputs(window, cx); - let theme = cx.theme(); + let theme = cx.theme().clone(); let viewport_width = window.viewport_size().width; let show_sidebar_rail = self.sidebar_visible && viewport_width < px(960.0); let show_full_sidebar = self.sidebar_visible && !show_sidebar_rail; @@ -2267,6 +2883,34 @@ impl Render for MainView { let this = cx.entity().clone(); let this_for_send = this.clone(); + let active_collection_id = self.active_tab().and_then(|tab| tab.collection_id); + let this_for_new_environment = this.clone(); + let this_for_import_environment = this.clone(); + let this_for_delete_environment = this.clone(); + let this_for_rename_environment = this.clone(); + self.environment_panel.update(cx, |panel, cx| { + panel.set_collection_context(active_collection_id, cx); + panel.on_new_environment(move |project_id, window, cx| { + this_for_new_environment.update(cx, |view, cx| { + view.show_new_environment_dialog(project_id, window, cx); + }); + }); + panel.on_import_environment(move |window, cx| { + this_for_import_environment.update(cx, |view, cx| { + view.import_collection_from_file(window, cx); + }); + }); + panel.on_delete_environment(move |environment_id, window, cx| { + this_for_delete_environment.update(cx, |view, cx| { + view.show_delete_environment_dialog(environment_id, window, cx); + }); + }); + panel.on_rename_environment(move |environment_id, name, window, cx| { + this_for_rename_environment.update(cx, |view, cx| { + view.show_rename_environment_dialog(environment_id, name, window, cx); + }); + }); + }); div() .id("main-view") @@ -2407,8 +3051,10 @@ impl Render for MainView { .when(show_sidebar_rail, |el| { let history_active = self.sidebar_tab == SidebarTab::History; let collections_active = self.sidebar_tab == SidebarTab::Collections; + let environments_active = self.sidebar_tab == SidebarTab::Environments; let this_for_history = this.clone(); let this_for_collections = this.clone(); + let this_for_environments = this.clone(); el.child( div() .w(px(44.0)) @@ -2446,6 +3092,19 @@ impl Render for MainView { view.set_sidebar_tab(SidebarTab::Collections, cx); }); }), + ) + .child( + Button::new("rail-environments") + .icon(Icon::new(IconName::Package).size(px(15.0))) + .ghost() + .xsmall() + .selected(environments_active) + .tooltip("Environments") + .on_click(move |_, _, cx| { + this_for_environments.update(cx, |view, cx| { + view.set_sidebar_tab(SidebarTab::Environments, cx); + }); + }), ), ) }) @@ -2453,6 +3112,7 @@ impl Render for MainView { .when(show_full_sidebar, |el| { let history = self.history.clone(); let collections = self.collections.clone(); + let environment_panel = self.environment_panel.clone(); let history_search = self .history_search .clone() @@ -2462,6 +3122,11 @@ impl Render for MainView { .clone() .expect("collections_search should be initialized"); let sidebar_tab = self.sidebar_tab; + let sidebar_render_width = if sidebar_tab == SidebarTab::Environments { + self.sidebar_width.max(340.0) + } else { + self.sidebar_width + }; let history_filter = self.history_filter; let history_group_by = self.history_group_by; @@ -2486,13 +3151,14 @@ impl Render for MainView { el.child( div() - .w(px(self.sidebar_width)) + .w(px(sidebar_render_width)) .h_full() .flex_shrink_0() .child( AppSidebar::new( history, collections, + environment_panel, history_search, collections_search, self.history_rows.clone(), @@ -2682,7 +3348,7 @@ impl Render for MainView { .h_full() .overflow_hidden() // Compact application toolbar - .child(self.render_header(&theme, this.clone())) + .child(self.render_header(&theme, this.clone(), cx)) // Tab bar - pass this entity directly with scroll handle .child(TabBar::new( tab_infos, @@ -2974,6 +3640,7 @@ impl MainView { &self, theme: &gpui_component::theme::ThemeColor, this: Entity, + cx: &App, ) -> impl IntoElement { let layout_icon = match self.request_response_layout { RequestResponseLayout::Stacked => IconName::LayoutSplit, @@ -2981,7 +3648,55 @@ impl MainView { }; let this_for_sidebar = this.clone(); let this_for_commands = this.clone(); - let this_for_layout = this; + let this_for_layout = this.clone(); + let this_for_manage_environments = this.clone(); + let this_for_workspace_menu = this; + let (active_workspace_id, active_workspace_name, workspace_options) = { + let workspaces = self.workspaces.read(cx); + ( + workspaces.active_workspace_id(), + workspaces.active_workspace().name.clone(), + workspaces.workspaces().to_vec(), + ) + }; + let collection_id = self.active_tab().and_then(|tab| tab.collection_id); + let (active_id, active_name, active_color, environment_options) = { + let environments = self.environments.read(cx); + let active_id = environments.active_environment_id(collection_id); + let active = environments.active_environment(collection_id); + let active_name = active + .map(|environment| environment.name.clone()) + .unwrap_or_else(|| "No environment".to_string()); + let active_color = active + .map(|environment| environment.color.clone()) + .unwrap_or(EnvironmentColor::Slate); + let environment_options: Vec<_> = environments + .available_for(collection_id) + .into_iter() + .map(|environment| { + let scope = match environment.scope { + EnvironmentScope::Global => "Global".to_string(), + EnvironmentScope::Workspace => "Workspace".to_string(), + EnvironmentScope::Project(project_id) => self + .collections + .read(cx) + .collections + .iter() + .find(|collection| collection.id == project_id) + .map(|collection| collection.name.clone()) + .unwrap_or_else(|| "Project".to_string()), + }; + ( + environment.id, + environment.name.clone(), + scope, + environment.color.clone(), + ) + }) + .collect(); + (active_id, active_name, active_color, environment_options) + }; + let environments_for_menu = self.environments.clone(); div() .flex() @@ -3019,6 +3734,75 @@ impl MainView { .text_size(px(15.0)) .child("setu"), ) + .child(div().w(px(1.0)).h(px(18.0)).bg(theme.border)) + .child( + Button::new("toolbar-workspace") + .icon(Icon::new(IconName::Box).size(px(14.0))) + .label(active_workspace_name.clone()) + .ghost() + .xsmall() + .tooltip("Switch workspace") + .dropdown_menu(move |mut menu, _window, _cx| { + menu = menu.label("Workspace"); + for workspace in &workspace_options { + let workspace_id = workspace.id; + let this_for_switch = this_for_workspace_menu.clone(); + let mut item = PopupMenuItem::new(workspace.name.clone()); + if workspace_id == active_workspace_id { + item = item.icon(IconName::Check); + } + menu = menu.item(item.on_click(move |_, _, cx| { + this_for_switch.update(cx, |view, cx| { + view.switch_workspace(workspace_id, cx); + }); + })); + } + + let this_for_new = this_for_workspace_menu.clone(); + let this_for_rename = this_for_workspace_menu.clone(); + let this_for_delete = this_for_workspace_menu.clone(); + let active_name = active_workspace_name.clone(); + let mut menu = menu.separator().item( + PopupMenuItem::new("New workspace") + .icon(IconName::Plus) + .on_click(move |_, window, cx| { + this_for_new.update(cx, |view, cx| { + view.show_new_workspace_dialog(window, cx); + }); + }), + ); + menu = menu.item( + PopupMenuItem::new("Rename workspace") + .icon(IconName::FilePen) + .on_click(move |_, window, cx| { + this_for_rename.update(cx, |view, cx| { + view.show_rename_workspace_dialog( + active_workspace_id, + active_name.clone(), + window, + cx, + ); + }); + }), + ); + if workspace_options.len() > 1 { + menu = menu.item( + PopupMenuItem::new("Delete workspace") + .icon(IconName::Trash) + .on_click(move |_, window, cx| { + this_for_delete.update(cx, |view, cx| { + view.show_delete_workspace_dialog( + active_workspace_id, + window, + cx, + ); + }); + }), + ); + } + menu + }), + ) .child(ProtocolSelector::new(ProtocolType::Rest)), ) .child( @@ -3026,6 +3810,52 @@ impl MainView { .flex() .items_center() .gap(px(4.0)) + .child( + Button::new("toolbar-environment") + .icon( + Icon::new(IconName::Package) + .size(px(14.0)) + .text_color(active_color.accent()), + ) + .label(active_name) + .ghost() + .xsmall() + .tooltip("Active environment") + .dropdown_menu(move |mut menu, _window, _cx| { + menu = menu.label("Environment"); + for (id, name, scope, _color) in &environment_options { + let environment_id = *id; + let environments = environments_for_menu.clone(); + let mut item = PopupMenuItem::new(format!("{name} · {scope}")); + if Some(environment_id) == active_id { + item = item.icon(IconName::Check); + } + menu = menu.item(item.on_click(move |_, _, cx| { + environments.update(cx, |environments, cx| { + environments.set_active( + collection_id, + Some(environment_id), + cx, + ); + }); + })); + } + let this_for_manage = this_for_manage_environments.clone(); + menu.separator().item( + PopupMenuItem::new("Manage environments") + .icon(IconName::Package) + .on_click(move |_, _, cx| { + this_for_manage.update(cx, |view, cx| { + view.sidebar_visible = true; + view.ui_preferences.sidebar_visible = true; + view.persist_ui_preferences(); + view.set_sidebar_tab(SidebarTab::Environments, cx); + }); + }), + ) + }), + ) + .child(div().w(px(1.0)).h(px(18.0)).bg(theme.border)) .child( Button::new("toolbar-command-search") .icon(Icon::new(IconName::Search).size(px(14.0))) diff --git a/src/views/request_view.rs b/src/views/request_view.rs index ed5cb67..a2f8a75 100644 --- a/src/views/request_view.rs +++ b/src/views/request_view.rs @@ -16,6 +16,7 @@ use crate::icons::IconName; use gpui_component::{ActiveTheme, Icon}; use std::collections::HashMap; +use crate::completion::{CompletionContext, CompletionEngine, CompletionInput}; #[allow(dead_code)] #[derive(Debug, Clone)] pub enum RequestViewEvent { @@ -50,6 +51,7 @@ pub struct RequestView { initial_body_content: Option, initial_form_data: Option>, initial_multipart_data: Option>, + completion_engine: Option, } impl RequestView { @@ -80,9 +82,15 @@ impl RequestView { initial_body_content: None, initial_form_data: None, initial_multipart_data: None, + completion_engine: None, } } + pub fn with_completion_engine(mut self, completion_engine: CompletionEngine) -> Self { + self.completion_engine = Some(completion_engine); + self + } + pub fn with_initial_body_content(mut self, content: Option) -> Self { self.initial_body_content = content; self @@ -121,14 +129,20 @@ impl RequestView { }); let wrap_lines = self.wrap_lines; + let completion_engine = self.completion_engine.clone(); let body_editor = cx.new(|cx| { - InputState::new(window, cx) + let input = InputState::new(window, cx) .code_editor(syntax_lang) .folding(true) .line_number(true) .searchable(true) .soft_wrap(wrap_lines) - .default_value(&initial_content) + .default_value(&initial_content); + if let Some(engine) = completion_engine.as_ref() { + engine.configure_input(input, CompletionContext::Body) + } else { + input + } }); self.body_editor = Some(body_editor); @@ -200,15 +214,18 @@ impl RequestView { fn ensure_params_editor(&mut self, cx: &mut Context) { if self.params_editor.is_none() { - self.params_editor = Some(cx.new(|cx| ParamsEditor::new(cx))); + let completion_engine = self.completion_engine.clone(); + self.params_editor = + Some(cx.new(|cx| ParamsEditor::new(completion_engine.clone(), cx))); } } fn ensure_form_data_editor(&mut self, window: &mut Window, cx: &mut Context) { if self.form_data_editor.is_none() { let initial_data = self.initial_form_data.take(); + let completion_engine = self.completion_engine.clone(); self.form_data_editor = Some(cx.new(|cx| { - let mut editor = FormDataEditor::new(cx); + let mut editor = FormDataEditor::new(completion_engine.clone(), cx); if let Some(data) = initial_data { editor.set_from_hashmap(&data, window, cx); } @@ -220,8 +237,9 @@ impl RequestView { fn ensure_multipart_form_data_editor(&mut self, window: &mut Window, cx: &mut Context) { if self.multipart_form_data_editor.is_none() { let initial_data = self.initial_multipart_data.take(); + let completion_engine = self.completion_engine.clone(); self.multipart_form_data_editor = Some(cx.new(|cx| { - let mut editor = MultipartFormDataEditor::new(cx); + let mut editor = MultipartFormDataEditor::new(completion_engine.clone(), cx); if let Some(data) = initial_data { editor.set_from_multipart_fields(&data, window, cx); } @@ -233,13 +251,17 @@ impl RequestView { fn ensure_header_editor(&mut self, cx: &mut Context) { if self.header_editor.is_none() { let request = self.request.clone(); - self.header_editor = Some(cx.new(|cx| HeaderEditor::new(request, cx))); + let completion_engine = self.completion_engine.clone(); + self.header_editor = + Some(cx.new(|cx| HeaderEditor::new(request, completion_engine.clone(), cx))); } } fn ensure_auth_editor(&mut self, window: &mut Window, cx: &mut Context) { if self.auth_editor.is_none() { - self.auth_editor = Some(cx.new(|cx| AuthEditor::new(window, cx))); + let completion_engine = self.completion_engine.clone(); + self.auth_editor = + Some(cx.new(|cx| AuthEditor::new(window, completion_engine.clone(), cx))); } } @@ -786,7 +808,10 @@ impl RequestView { .overflow_y_scroll() .bg(theme.muted) .when_some(self.body_editor.as_ref(), |el, editor| { - el.child(Input::new(editor).appearance(false).size_full().p_0()) + el.child(CompletionInput::new( + editor, + Input::new(editor).appearance(false).size_full().p_0(), + )) }), ) },