From 7d4031e1ef30bfcc09ca04e3eb76a381e987a299 Mon Sep 17 00:00:00 2001
From: Raunak Raj <71929976+bajrangCoder@users.noreply.github.com>
Date: Thu, 23 Jul 2026 13:31:08 +0530
Subject: [PATCH 1/5] feat: add workspace-scoped environments and Postman
variable import and variables etc
---
Todo.md | 20 +-
assets/icons/box.svg | 1 +
assets/icons/eye-closed.svg | 1 +
assets/icons/eye.svg | 1 +
assets/icons/lock.svg | 4 +
assets/icons/package.svg | 1 +
assets/icons/unlock.svg | 4 +
assets/icons/variable.svg | 8 +
src/components/app_sidebar.rs | 12 +
src/components/collections_panel.rs | 4 +-
src/components/environment_panel.rs | 1125 +++++++++++++++++++++++++++
src/components/mod.rs | 2 +
src/entities/collections.rs | 99 ++-
src/entities/environment.rs | 1068 +++++++++++++++++++++++++
src/entities/history.rs | 141 +++-
src/entities/mod.rs | 4 +
src/entities/request.rs | 4 +-
src/entities/workspace.rs | 233 ++++++
src/icons.rs | 14 +
src/importers/mod.rs | 60 +-
src/importers/postman.rs | 154 +++-
src/views/command_palette.rs | 2 +-
src/views/main_view.rs | 873 +++++++++++++++++++--
23 files changed, 3726 insertions(+), 109 deletions(-)
create mode 100644 assets/icons/box.svg
create mode 100644 assets/icons/eye-closed.svg
create mode 100644 assets/icons/eye.svg
create mode 100644 assets/icons/lock.svg
create mode 100644 assets/icons/package.svg
create mode 100644 assets/icons/unlock.svg
create mode 100644 assets/icons/variable.svg
create mode 100644 src/components/environment_panel.rs
create mode 100644 src/entities/environment.rs
create mode 100644 src/entities/workspace.rs
diff --git a/Todo.md b/Todo.md
index 444daf9..d9352b4 100644
--- a/Todo.md
+++ b/Todo.md
@@ -6,10 +6,26 @@
- [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] Workspace base variables plus 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.
+- [x] Real persisted workspace containers and a top-bar switcher that isolate collections, history, and environments.
+- [x] Import Postman environment exports and collection variables into the current 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.
+- [ ] Variable autocomplete, syntax highlighting, scope badges, and resolved-value previews in request editors.
+- [ ] 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/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/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..fab3b09
--- /dev/null
+++ b/src/components/environment_panel.rs
@@ -0,0 +1,1125 @@
+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::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::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,
+ 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 {
+ 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(),
+ 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;
+ 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 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| {
+ InputState::new(window, cx)
+ .placeholder(if variable.secret {
+ "Secret value"
+ } else {
+ "Value"
+ })
+ .default_value(&variable.value)
+ .masked(variable.secret)
+ });
+
+ 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::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;
+ 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::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::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| {
+ 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 (workspace, project) = {
+ let environments = self.environments.read(cx);
+ 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();
+ (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("ACTIVE VARIABLE STACK"),
+ )
+ .when_some(workspace.as_ref(), |element, environment| {
+ element.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(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 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.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 mut project_groups: HashMap> = HashMap::new();
+ let mut workspace_environments = Vec::new();
+ for environment in &environments {
+ match environment.scope {
+ 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 !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(
+ 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"),
+ )
+ })
+ .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 a Postman environment")
+ .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.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))
+ .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/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/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..91ff26b
--- /dev/null
+++ b/src/entities/environment.rs
@@ -0,0 +1,1068 @@
+use gpui::{Context, EventEmitter, Hsla, hsla};
+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 = 2;
+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 {
+ Workspace,
+ Project(Uuid),
+}
+
+#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum EnvironmentColor {
+ #[default]
+ Teal,
+ Blue,
+ Violet,
+ Amber,
+ Rose,
+ Slate,
+}
+
+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",
+ }
+ }
+
+ 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),
+ }
+ }
+}
+
+#[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,
+ workspaces: HashMap,
+}
+
+impl Default for EnvironmentsStore {
+ fn default() -> Self {
+ Self {
+ version: ENVIRONMENTS_STORAGE_VERSION,
+ 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,
+}
+
+pub struct EnvironmentsEntity {
+ environments: Vec,
+ 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_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