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 | |