diff --git a/src/components/environment_panel.rs b/src/components/environment_panel.rs index 9811ff1..aa88401 100644 --- a/src/components/environment_panel.rs +++ b/src/components/environment_panel.rs @@ -3,24 +3,19 @@ use gpui::{ AnyElement, App, Context, Entity, FocusHandle, Focusable, FontWeight, IntoElement, Render, SharedString, Styled, Window, div, px, }; +use gpui_component::ActiveTheme; +use gpui_component::Selectable; 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, + EnvironmentsEntity, }; use crate::icons::IconName; @@ -28,31 +23,18 @@ type NewEnvironmentCallback = Rc, &mut Window, &mut App) + ' type ImportEnvironmentCallback = Rc; type DeleteEnvironmentCallback = Rc; type RenameEnvironmentCallback = Rc; - -struct VariableRow { - id: Uuid, - key_input: Entity, - value_input: Entity, - enabled: bool, - secret: bool, -} +type OpenEnvironmentCallback = Rc; 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, + on_open_environment: Option, focus_handle: FocusHandle, } @@ -62,7 +44,6 @@ impl EnvironmentPanel { collections: Entity, cx: &mut Context, ) -> Self { - let completion_engine = CompletionEngine::for_environments(environments.clone()); cx.subscribe( &environments, |this, environments, event: &EnvironmentEvent, cx| { @@ -70,7 +51,6 @@ impl EnvironmentPanel { this.selected_environment_id = environments .read(cx) .active_environment_id(this.collection_id); - this.loaded_environment_id = None; } cx.notify(); }, @@ -84,17 +64,11 @@ impl EnvironmentPanel { 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, + on_open_environment: None, focus_handle: cx.focus_handle(), } } @@ -108,9 +82,6 @@ impl EnvironmentPanel { .environments .read(cx) .active_environment_id(collection_id); - self.loaded_environment_id = None; - self.loaded_signature.clear(); - self.rows.clear(); cx.notify(); } @@ -139,12 +110,16 @@ impl EnvironmentPanel { self.on_rename_environment = Some(Rc::new(callback)); } + pub fn on_open_environment( + &mut self, + callback: impl Fn(Uuid, &mut Window, &mut App) + 'static, + ) { + self.on_open_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(); } } @@ -167,181 +142,6 @@ impl EnvironmentPanel { }); } - 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, @@ -457,79 +257,53 @@ impl EnvironmentPanel { 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(); + let on_open = self.on_open_environment.clone(); div() .id(SharedString::from(format!("environment-row-{id}"))) - .h(px(40.0)) + .h(px(32.0)) .px(px(8.0)) .flex() .items_center() - .gap(px(8.0)) - .rounded(px(6.0)) + .gap(px(6.0)) + .rounded(px(5.0)) .cursor_pointer() - .when(selected, |element| { - element - .bg(theme.sidebar_accent) - .border_1() - .border_color(theme.border.opacity(0.9)) - }) + .when(selected, |element| element.bg(theme.sidebar_accent)) .when(!selected, |element| { element.hover(|element| element.bg(theme.sidebar_accent.opacity(0.55))) }) - .on_click(move |_, _, cx| { + .on_click(move |_, window, cx| { this_for_select.update(cx, |panel, cx| { panel.select_environment(id, cx); }); + if let Some(ref cb) = on_open { + cb(id, window, cx); + } }) .child( div() - .size(px(9.0)) + .size(if active { px(8.0) } else { px(7.0) }) .rounded_full() .bg(color) .when(active, |element| { - element.border_2().border_color(theme.sidebar).shadow_sm() + element + .border_1() + .border_color(theme.foreground.opacity(0.6)) }), ) .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()), + .truncate() + .text_color(if selected { + theme.foreground + } else { + theme.secondary_foreground + }) + .child(environment.name.clone()), ) .child(self.render_environment_menu(environment, selected, this)) .into_any_element() @@ -555,80 +329,69 @@ impl EnvironmentPanel { (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( + let mut active_items = Vec::new(); + if let Some(env) = global { + active_items.push(env); + } + if let Some(env) = workspace { + active_items.push(env); + } + if let Some(env) = project { + active_items.push(env); + } + + if active_items.is_empty() { + return div().into_any_element(); + } + + let mut stack_children = Vec::new(); + for (i, env) in active_items.into_iter().enumerate() { + if i > 0 { + stack_children.push( div() - .flex_shrink_0() - .text_size(px(9.0)) + .text_size(px(10.0)) .text_color(theme.muted_foreground) - .child(suffix.to_string()), - ) - }; + .child("→") + .into_any_element(), + ); + } + stack_children.push( + div() + .flex() + .items_center() + .gap(px(4.0)) + .min_w_0() + .child(div().size(px(6.0)).rounded_full().bg(env.color.accent())) + .child( + div() + .truncate() + .text_size(px(10.0)) + .text_color(theme.secondary_foreground) + .child(env.name), + ) + .into_any_element(), + ); + } 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)) + .px(px(10.0)) + .py(px(4.0)) .flex() - .flex_col() + .items_center() .gap(px(6.0)) .child( div() - .text_size(px(9.0)) + .text_size(px(10.0)) .font_weight(FontWeight::SEMIBOLD) .text_color(theme.muted_foreground) - .child("VARIABLE PRECEDENCE"), + .child("Active:"), ) - .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"), - ) - }, + .child( + div() + .flex() + .items_center() + .gap(px(4.0)) + .children(stack_children), ) .into_any_element() } @@ -706,22 +469,15 @@ impl Focusable for EnvironmentPanel { } 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); + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + self.ensure_selection(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(); @@ -845,318 +601,6 @@ impl Render for EnvironmentPanel { ); } - 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() @@ -1249,28 +693,20 @@ impl Render for EnvironmentPanel { }) .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() + .flex_1() + .min_h_0() .overflow_y_scrollbar() .px(px(7.0)) - .pt(px(6.0)) - .pb(px(8.0)) + .pt(px(4.0)) + .pb(px(6.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), - ) + .when(environments.is_empty(), |element| { + element.child(self.render_empty_state(&theme)) + }) } } diff --git a/src/components/tab_bar.rs b/src/components/tab_bar.rs index 8bf79e0..e41e5df 100644 --- a/src/components/tab_bar.rs +++ b/src/components/tab_bar.rs @@ -7,26 +7,42 @@ use crate::entities::HttpMethod; use crate::icons::IconName; use crate::theme::method_color; +#[derive(Clone, Debug)] +pub enum TabIcon { + Method(HttpMethod), + Icon(IconName), +} + #[derive(Clone)] pub struct TabInfo { pub id: usize, pub index: usize, pub name: SharedString, - pub method: HttpMethod, + pub icon: TabIcon, pub is_active: bool, } impl TabInfo { - pub fn new(id: usize, index: usize, name: impl Into, method: HttpMethod) -> Self { + pub fn new(id: usize, index: usize, name: impl Into, icon: TabIcon) -> Self { Self { id, index, name: name.into(), - method, + icon, is_active: false, } } + #[allow(dead_code)] + pub fn with_method( + id: usize, + index: usize, + name: impl Into, + method: HttpMethod, + ) -> Self { + Self::new(id, index, name, TabIcon::Method(method)) + } + pub fn active(mut self) -> Self { self.is_active = true; self @@ -85,7 +101,7 @@ impl RenderOnce for TabBar { let main_view_for_close = main_view.clone(); let main_view_for_context = main_view.clone(); - RequestTab::new(tab, main_view_for_context, cx) + WorkspaceTab::new(tab, main_view_for_context, cx) .on_click(move |_, _, cx| { main_view_for_click .update(cx, |view, cx| view.switch_tab(index, cx)); @@ -118,10 +134,10 @@ impl RenderOnce for TabBar { } } -struct RequestTab { +#[derive(IntoElement)] +struct WorkspaceTab { info: TabInfo, main_view: Entity, - method_color: Hsla, bg_active: Hsla, bg_hover: Hsla, text_active: Hsla, @@ -130,11 +146,10 @@ struct RequestTab { on_close: Option>, } -impl RequestTab { +impl WorkspaceTab { fn new(info: TabInfo, main_view: Entity, cx: &App) -> Self { let theme = cx.theme(); Self { - method_color: method_color(&info.method, cx), info, main_view, bg_active: theme.muted, @@ -163,10 +178,9 @@ impl RequestTab { } } -impl IntoElement for RequestTab { - type Element = gpui::AnyElement; - - fn into_element(self) -> Self::Element { +impl RenderOnce for WorkspaceTab { + fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { + let theme = cx.theme(); let is_active = self.info.is_active; let tab_id = self.info.id; let tab_index = self.info.index; @@ -174,6 +188,29 @@ impl IntoElement for RequestTab { let main_view_for_rename = self.main_view.clone(); let main_view_for_close = self.main_view.clone(); let main_view_for_close_others = self.main_view; + + let icon_badge = match &self.info.icon { + TabIcon::Method(method) => { + let m_color = method_color(method, cx); + div() + .text_color(m_color) + .font_weight(gpui::FontWeight::BOLD) + .text_size(px(9.0)) + .child(method.as_str()) + .into_any_element() + } + TabIcon::Icon(icon) => div() + .flex() + .items_center() + .justify_center() + .child( + gpui_component::Icon::new(*icon) + .size(px(12.0)) + .text_color(theme.primary), + ) + .into_any_element(), + }; + let text_color = if is_active { self.text_active } else { @@ -198,13 +235,7 @@ impl IntoElement for RequestTab { .when_some(self.on_click, |element, callback| { element.on_click(move |event, window, cx| callback(event, window, cx)) }) - .child( - div() - .text_color(self.method_color) - .font_weight(gpui::FontWeight::BOLD) - .text_size(px(9.0)) - .child(self.info.method.as_str()), - ) + .child(icon_badge) .child( div() .max_w(px(180.0)) @@ -270,6 +301,5 @@ impl IntoElement for RequestTab { }), ) }) - .into_any_element() } } diff --git a/src/entities/environment.rs b/src/entities/environment.rs index 59219ee..e35cab5 100644 --- a/src/entities/environment.rs +++ b/src/entities/environment.rs @@ -713,6 +713,7 @@ impl EnvironmentsEntity { self.changed(EnvironmentEvent::Changed, cx); } + #[allow(dead_code)] pub fn duplicate_variable( &mut self, environment_id: Uuid, @@ -760,6 +761,14 @@ impl EnvironmentsEntity { self.changed(EnvironmentEvent::Changed, cx); } + pub fn clear_variables(&mut self, environment_id: Uuid, cx: &mut Context) { + let Some(environment) = self.get_mut(environment_id) else { + return; + }; + environment.variables.clear(); + self.changed(EnvironmentEvent::Changed, cx); + } + pub fn resolve_request( &self, collection_id: Option, diff --git a/src/views/environment_view.rs b/src/views/environment_view.rs new file mode 100644 index 0000000..01fd8ed --- /dev/null +++ b/src/views/environment_view.rs @@ -0,0 +1,764 @@ +use gpui::prelude::*; +use gpui::{ + App, Context, Entity, FocusHandle, Focusable, FontWeight, IntoElement, Render, SharedString, + Styled, Window, div, px, +}; +use gpui_component::ActiveTheme; +use gpui_component::Icon; +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 std::collections::HashMap; +use uuid::Uuid; + +use crate::completion::{ + CompletionContext, CompletionEngine, CompletionInput, configure_completion, +}; +use crate::entities::{ + CollectionsEntity, EnvironmentColor, EnvironmentEvent, EnvironmentScope, EnvironmentVariable, + EnvironmentsEntity, +}; +use crate::icons::IconName; + +struct VariableRow { + id: Uuid, + key_input: Entity, + value_input: Entity, + enabled: bool, + secret: bool, +} + +pub struct EnvironmentView { + environments: Entity, + collections: Entity, + collection_id: Option, + environment_id: Uuid, + search_input: Entity, + loaded_signature: Vec<(Uuid, bool)>, + rows: Vec, + color_picker: Option>, + color_picker_environment_id: Option, + color_picker_color: Option, + completion_engine: CompletionEngine, + focus_handle: FocusHandle, +} + +impl EnvironmentView { + pub fn new( + environment_id: Uuid, + environments: Entity, + collections: Entity, + window: &mut Window, + cx: &mut Context, + ) -> Self { + let completion_engine = CompletionEngine::for_environments(environments.clone()); + let search_input = + cx.new(|cx| InputState::new(window, cx).placeholder("Filter variables...")); + + cx.subscribe( + &environments, + |_this, _environments, _event: &EnvironmentEvent, cx| { + cx.notify(); + }, + ) + .detach(); + cx.subscribe(&collections, |_this, _, _, cx| cx.notify()) + .detach(); + + Self { + environments, + collections, + collection_id: None, + environment_id, + search_input, + loaded_signature: Vec::new(), + rows: Vec::new(), + color_picker: None, + color_picker_environment_id: None, + color_picker_color: None, + completion_engine, + focus_handle: cx.focus_handle(), + } + } + + #[allow(dead_code)] + pub fn environment_id(&self) -> Uuid { + self.environment_id + } + + #[allow(dead_code)] + pub fn set_collection_context(&mut self, collection_id: Option) { + self.collection_id = collection_id; + } + + fn sync_rows(&mut self, window: &mut Window, cx: &mut Context) { + let environment_id = self.environment_id; + self.sync_color_picker(Some(environment_id), window, cx); + let variables = self + .environments + .read(cx) + .get(environment_id) + .map(|environment| environment.variables.clone()) + .unwrap_or_default(); + let signature: Vec<_> = variables + .iter() + .map(|variable| (variable.id, variable.secret)) + .collect(); + + if 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_signature = signature; + 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()) + }); + let environment_id = self.environment_id; + cx.subscribe(&picker, move |this, _, event: &ColorPickerEvent, cx| { + let ColorPickerEvent::Change(Some(color)) = event 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("Key") + .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 clear_all_variables(&mut self, cx: &mut Context) { + let environment_id = self.environment_id; + self.environments.update(cx, |environments, cx| { + environments.clear_variables(environment_id, cx); + }); + } +} + +impl Focusable for EnvironmentView { + fn focus_handle(&self, _cx: &App) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl Render for EnvironmentView { + 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 environment_id = self.environment_id; + + let environment = self.environments.read(cx).get(environment_id).cloned(); + + let Some(environment) = environment else { + return div() + .flex_1() + .flex() + .items_center() + .justify_center() + .text_color(theme.muted_foreground) + .child("Environment not found") + .into_any_element(); + }; + + let active = self.environments.read(cx).is_active(environment_id); + let color = environment.color.accent(); + let scope_label = self.scope_label(environment.scope, cx); + let var_count = environment.variables.len(); + let color_picker = self.color_picker.clone(); + 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; + } + } + + // Single unified Header bar + let environments_for_menu = self.environments.clone(); + let environments_for_activate = self.environments.clone(); + let collection_id = self.collection_id; + let this = cx.entity().clone(); + + let header = div() + .h(px(40.0)) + .px(px(16.0)) + .flex() + .flex_row() + .items_center() + .justify_between() + .border_b_1() + .border_color(theme.border) + .bg(theme.secondary) + .child( + div() + .flex() + .flex_row() + .items_center() + .gap(px(8.0)) + // Color Dot + .child(div().size(px(10.0)).rounded_full().bg(color)) + // Environment Title + .child( + div() + .text_sm() + .font_weight(FontWeight::MEDIUM) + .text_color(theme.foreground) + .child(environment.name.clone()), + ) + // Bullet separator + .child( + div() + .text_xs() + .text_color(theme.muted_foreground.opacity(0.4)) + .child("•"), + ) + // Scope subtitle + .child( + div() + .text_xs() + .text_color(theme.muted_foreground) + .child(scope_label), + ) + // Minimal Active Badge + .when(active, |element| { + element.child( + div() + .px(px(6.0)) + .py(px(1.0)) + .rounded(px(4.0)) + .bg(color.opacity(0.15)) + .text_size(px(10.0)) + .font_weight(FontWeight::MEDIUM) + .text_color(color) + .child("Active"), + ) + }), + ) + .child( + div() + .flex() + .flex_row() + .items_center() + .gap(px(8.0)) + // Variable Count + .child( + div() + .text_xs() + .text_color(theme.muted_foreground) + .child(format!( + "{var_count} variable{}", + if var_count == 1 { "" } else { "s" } + )), + ) + // Search box with search icon + .child( + div() + .flex() + .flex_row() + .items_center() + .gap(px(6.0)) + .px(px(8.0)) + .py(px(3.0)) + .bg(theme.muted) + .rounded(px(6.0)) + .border_1() + .border_color(theme.border.opacity(0.5)) + .child( + Icon::new(IconName::Search) + .size(px(13.0)) + .text_color(theme.muted_foreground), + ) + .child( + div().w(px(160.0)).child( + Input::new(&self.search_input).appearance(false).small(), + ), + ), + ) + // Color picker + .when_some(color_picker, |element, picker| { + element.child( + ColorPicker::new(&picker) + .featured_colors( + EnvironmentColor::ALL + .iter() + .map(EnvironmentColor::accent) + .collect(), + ) + .small(), + ) + }) + // Clear All Trash Button + .child( + Button::new("clear-all-env-vars-btn") + .icon(IconName::Trash) + .ghost() + .small() + .tooltip("Clear All") + .on_click({ + let this = this.clone(); + move |_, _, cx| { + this.update(cx, |view, cx| { + view.clear_all_variables(cx); + }); + } + }), + ) + // Add New Variable Button + .child( + Button::new("add-env-var-btn") + .icon(IconName::Plus) + .ghost() + .small() + .tooltip("Add Variable") + .on_click({ + let envs = self.environments.clone(); + move |_, _, cx| { + envs.update(cx, |environments, cx| { + environments.add_variable(environment_id, cx); + }); + } + }), + ) + // Dropdown menu + .child( + Button::new(SharedString::from(format!("env-actions-{environment_id}"))) + .icon(IconName::Ellipsis) + .ghost() + .small() + .tooltip("Actions") + .dropdown_menu(move |mut menu, _window, _cx| { + let envs = environments_for_menu.clone(); + let envs_act = environments_for_activate.clone(); + if !active { + menu = menu.item( + PopupMenuItem::new("Set Active") + .icon(IconName::Check) + .on_click(move |_, _, cx| { + envs_act.update(cx, |e, cx| { + e.set_active( + collection_id, + Some(environment_id), + cx, + ); + }); + }), + ); + } + menu = menu.item( + PopupMenuItem::new("Duplicate") + .icon(IconName::CopyPlus) + .on_click(move |_, _, cx| { + envs.update(cx, |e, cx| { + e.duplicate_environment(environment_id, cx); + }); + }), + ); + menu + }), + ), + ); + + // Filter rows based on search input + let search_query = self.search_input.read(cx).text().to_string().to_lowercase(); + let filtered_rows: Vec<_> = self + .rows + .iter() + .filter(|row| { + if search_query.is_empty() { + return true; + } + let key = row.key_input.read(cx).text().to_string().to_lowercase(); + let value = row.value_input.read(cx).text().to_string().to_lowercase(); + key.contains(&search_query) || value.contains(&search_query) + }) + .collect(); + + // Main table content (rows or empty state) + let content = if filtered_rows.is_empty() { + if environment.variables.is_empty() { + div() + .flex_1() + .flex() + .flex_col() + .items_center() + .justify_center() + .gap(px(12.0)) + .px(px(24.0)) + .py(px(48.0)) + .child( + div() + .flex() + .items_center() + .justify_center() + .size(px(48.0)) + .rounded_full() + .bg(theme.secondary) + .border_1() + .border_color(theme.border) + .child(IconName::Variable), + ) + .child( + div() + .text_sm() + .font_weight(FontWeight::SEMIBOLD) + .text_color(theme.foreground) + .child("No variables in this environment"), + ) + .child( + div() + .text_xs() + .text_color(theme.muted_foreground) + .child("Add variables to store key-value pairs and reuse them across requests with {{variable_name}} syntax."), + ) + .child( + Button::new("empty-add-var-btn") + .label("Add variable") + .icon(IconName::Plus) + .primary() + .small() + .on_click({ + let envs = self.environments.clone(); + move |_, _, cx| { + envs.update(cx, |environments, cx| { + environments.add_variable(environment_id, cx); + }); + } + }), + ) + .into_any_element() + } else { + div() + .flex_1() + .flex() + .flex_col() + .items_center() + .justify_center() + .gap(px(8.0)) + .py(px(48.0)) + .child( + div() + .text_sm() + .font_weight(FontWeight::MEDIUM) + .text_color(theme.muted_foreground) + .child(format!("No variables match \"{}\"", search_query)), + ) + .child( + div() + .text_xs() + .text_color(theme.muted_foreground.opacity(0.7)) + .child("Try adjusting your search query."), + ) + .into_any_element() + } + } else { + div() + .id("env-var-rows-scroll") + .flex_1() + .flex() + .flex_col() + .overflow_y_scroll() + .children(filtered_rows.iter().map(|row| { + let variable_id = row.id; + let key_text = row.key_input.read(cx).text().to_string(); + let key_trimmed = key_text.trim().to_ascii_lowercase(); + let is_duplicate = !key_trimmed.is_empty() + && key_counts.get(&key_trimmed).copied().unwrap_or(0) > 1; + let environments_for_toggle = self.environments.clone(); + let environments_for_secret = self.environments.clone(); + let environments_for_remove = self.environments.clone(); + let secret = row.secret; + let enabled = row.enabled; + + div() + .id(SharedString::from(format!("env-row-{variable_id}"))) + .flex() + .flex_row() + .items_center() + .min_h(px(36.0)) + .px(px(16.0)) + .border_b_1() + .border_color(theme.border.opacity(0.3)) + .hover(|s| s.bg(theme.secondary.opacity(0.3))) + .when(!enabled, |el| el.opacity(0.5)) + .child( + div().w(px(32.0)).flex().items_center().child( + Checkbox::new(SharedString::from(format!( + "env-check-{variable_id}" + ))) + .checked(row.enabled) + .on_click(move |_, _, cx| { + environments_for_toggle.update(cx, |environments, cx| { + environments.toggle_variable( + environment_id, + variable_id, + cx, + ); + }); + }), + ), + ) + // Key input + .child( + div() + .w(px(220.0)) + .min_w(px(220.0)) + .when(is_duplicate, |d| d.text_color(theme.danger)) + .child(Input::new(&row.key_input).appearance(false).small()), + ) + // Value input + .child( + div().flex_1().min_w_0().child(CompletionInput::new( + &row.value_input, + Input::new(&row.value_input) + .appearance(false) + .small() + .when(row.secret, |input| input.mask_toggle()), + )), + ) + // Secret badge/toggle button + .child( + div().w(px(80.0)).flex().items_center().child( + Button::new(SharedString::from(format!( + "env-secret-toggle-{variable_id}" + ))) + .icon(if secret { + IconName::Lock + } else { + IconName::Unlock + }) + .ghost() + .small() + .tooltip(if secret { + "Secret value" + } else { + "Regular value" + }) + .on_click(move |_, _, cx| { + environments_for_secret.update(cx, |environments, cx| { + environments.toggle_secret(environment_id, variable_id, cx); + }); + }), + ), + ) + // Delete button + .child( + div().w(px(40.0)).flex().items_center().justify_end().child( + Button::new(SharedString::from(format!( + "env-del-btn-{variable_id}" + ))) + .icon(IconName::Trash) + .ghost() + .small() + .tooltip("Delete") + .on_click(move |_, _, cx| { + environments_for_remove.update(cx, |environments, cx| { + environments.remove_variable( + environment_id, + variable_id, + cx, + ); + }); + }), + ), + ) + })) + .into_any_element() + }; + + // Main table container + let table_container = div() + .id("env-variables-table-container") + .track_focus(&self.focus_handle) + .flex() + .flex_col() + .w_full() + .flex_1() + .overflow_hidden() + .bg(theme.muted) + // Table column header row + .child( + div() + .flex() + .flex_row() + .items_center() + .h(px(28.0)) + .px(px(16.0)) + .border_b_1() + .border_color(theme.border.opacity(0.5)) + .bg(theme.secondary.opacity(0.5)) + .child(div().w(px(32.0))) + .child( + div() + .w(px(220.0)) + .min_w(px(220.0)) + .text_color(theme.muted_foreground.opacity(0.7)) + .text_size(px(10.0)) + .child("Key"), + ) + .child( + div() + .flex_1() + .text_color(theme.muted_foreground.opacity(0.7)) + .text_size(px(10.0)) + .child("Value"), + ) + .child( + div() + .w(px(80.0)) + .text_color(theme.muted_foreground.opacity(0.7)) + .text_size(px(10.0)) + .child("Secret"), + ) + .child(div().w(px(40.0))), + ) + // Scrollable table rows or empty state + .child(content); + + div() + .flex() + .flex_col() + .size_full() + .child(header) + .child(table_container) + .into_any_element() + } +} diff --git a/src/views/main_view.rs b/src/views/main_view.rs index f8e67e2..93b36d1 100644 --- a/src/views/main_view.rs +++ b/src/views/main_view.rs @@ -26,7 +26,7 @@ use crate::actions::*; use crate::completion::{CompletionContext, CompletionEngine, configure_completion}; use crate::components::{ AppSidebar, BodyType, EnvironmentPanel, HistoryFilter, HistoryGroupBy, MethodDropdownState, - ProtocolSelector, ProtocolType, SidebarTab, TabBar, TabInfo, UrlBar, + ProtocolSelector, ProtocolType, SidebarTab, TabBar, TabIcon, TabInfo, UrlBar, }; use crate::entities::{ CollectionDestination, CollectionDestinationEntry, CollectionsEntity, EnvironmentColor, @@ -39,6 +39,7 @@ use crate::http::{HttpClient, InFlightRequest}; use crate::icons::IconName; use crate::importers::{ImportRegistry, ImportWarning, ImportedPayload}; use crate::utils::{close_dialog, open_dialog}; +use crate::views::environment_view::EnvironmentView; use crate::views::request_view::RequestView; use crate::views::response_view::ResponseView; use crate::views::{CommandId, CommandPaletteEvent, CommandPaletteView}; @@ -65,21 +66,103 @@ impl RequestGeneration { } } +pub enum TabContent { + Request { + request: Entity, + response: Entity, + url_input: Option>, + method_dropdown: Entity, + request_view: Entity, + response_view: Entity, + in_flight_request: Option, + request_generation: RequestGeneration, + }, + Environment { + environment_id: Uuid, + view: Entity, + }, +} + pub struct TabState { pub id: TabId, pub name: String, pub is_custom_name: bool, - pub request: Entity, - pub response: Entity, - pub url_input: Option>, - pub method_dropdown: Entity, - pub request_view: Entity, - pub response_view: Entity, - pub in_flight_request: Option, - pub request_generation: RequestGeneration, + pub content: TabContent, pub collection_id: Option, } +impl TabState { + pub fn is_environment(&self, id: Uuid) -> bool { + matches!(&self.content, TabContent::Environment { environment_id, .. } if *environment_id == id) + } + + #[allow(dead_code)] + pub fn environment_id(&self) -> Option { + match &self.content { + TabContent::Environment { environment_id, .. } => Some(*environment_id), + _ => None, + } + } + + #[allow(dead_code)] + pub fn environment_view(&self) -> Option<&Entity> { + match &self.content { + TabContent::Environment { view, .. } => Some(view), + _ => None, + } + } + + #[allow(dead_code)] + pub fn request(&self) -> Option<&Entity> { + match &self.content { + TabContent::Request { request, .. } => Some(request), + _ => None, + } + } + + #[allow(dead_code)] + pub fn response(&self) -> Option<&Entity> { + match &self.content { + TabContent::Request { response, .. } => Some(response), + _ => None, + } + } + + #[allow(dead_code)] + pub fn url_input(&self) -> Option<&Entity> { + match &self.content { + TabContent::Request { url_input, .. } => url_input.as_ref(), + _ => None, + } + } + + #[allow(dead_code)] + pub fn method_dropdown(&self) -> Option<&Entity> { + match &self.content { + TabContent::Request { + method_dropdown, .. + } => Some(method_dropdown), + _ => None, + } + } + + #[allow(dead_code)] + pub fn request_view(&self) -> Option<&Entity> { + match &self.content { + TabContent::Request { request_view, .. } => Some(request_view), + _ => None, + } + } + + #[allow(dead_code)] + pub fn response_view(&self) -> Option<&Entity> { + match &self.content { + TabContent::Request { response_view, .. } => Some(response_view), + _ => None, + } + } +} + #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum RequestResponseLayout { Stacked, @@ -204,14 +287,16 @@ impl MainView { id: TabId(0), name: "New Request".to_string(), is_custom_name: false, - request: request.clone(), - response: response.clone(), - url_input: None, - method_dropdown, - request_view, - response_view, - in_flight_request: None, - request_generation: RequestGeneration::default(), + content: TabContent::Request { + request: request.clone(), + response: response.clone(), + url_input: None, + method_dropdown, + request_view, + response_view, + in_flight_request: None, + request_generation: RequestGeneration::default(), + }, collection_id: None, }; let environment_panel = @@ -337,9 +422,10 @@ impl MainView { fn ensure_url_input(&mut self, tab_index: usize, window: &mut Window, cx: &mut Context) { let completion_engine = self.completion_engine.clone(); if let Some(tab) = self.tabs.get_mut(tab_index) - && tab.url_input.is_none() + && let TabContent::Request { url_input, .. } = &mut tab.content + && url_input.is_none() { - let url_input = cx.new(|cx| { + let input = cx.new(|cx| { configure_completion( InputState::new(window, cx).placeholder("Enter request URL..."), Some(&completion_engine), @@ -347,8 +433,8 @@ impl MainView { ) }); let tab_id = tab.id; - Self::subscribe_url_input(&url_input, tab_id, window, cx); - tab.url_input = Some(url_input); + Self::subscribe_url_input(&input, tab_id, window, cx); + *url_input = Some(input); } } @@ -365,8 +451,10 @@ impl MainView { return; } let text = state.read(cx).text().to_string(); - if let Some(tab) = this.tabs.iter().find(|tab| tab.id == tab_id) { - tab.request.update(cx, |request, cx| { + if let Some(tab) = this.tabs.iter().find(|tab| tab.id == tab_id) + && let TabContent::Request { request, .. } = &tab.content + { + request.update(cx, |request, cx| { request.set_url(text.clone(), cx); }); } @@ -380,7 +468,7 @@ impl MainView { state.update(cx, |s, cx| { s.set_value(url_value, window, cx); }); - this.apply_parsed_curl_to_tab(tab_id, &parsed, window, cx); + this.apply_curl_to_tab(tab_id, &parsed, window, cx); } Err(err) => { log::warn!("Failed to parse curl from URL bar: {}", err); @@ -391,7 +479,7 @@ impl MainView { } /// Apply a parsed curl to the currently-active tab. - fn apply_parsed_curl_to_tab( + fn apply_curl_to_tab( &mut self, tab_id: TabId, parsed: &crate::utils::ParsedCurl, @@ -401,16 +489,23 @@ impl MainView { let Some(tab) = self.tabs.iter().find(|tab| tab.id == tab_id) else { return; }; - let method_dropdown = tab.method_dropdown.clone(); - let request_view = tab.request_view.clone(); + if let TabContent::Request { + method_dropdown, + request_view, + .. + } = &tab.content + { + let method_dropdown = method_dropdown.clone(); + let request_view = request_view.clone(); - method_dropdown.update(cx, |state, cx| { - state.set_method(parsed.method, cx); - }); - request_view.update(cx, |view, cx| { - view.apply_parsed_curl(parsed, window, cx); - }); - cx.notify(); + method_dropdown.update(cx, |state, cx| { + state.set_method(parsed.method, cx); + }); + request_view.update(cx, |view, cx| { + view.apply_parsed_curl(parsed, window, cx); + }); + cx.notify(); + } } /// Ensure sidebar search inputs are initialized @@ -632,14 +727,16 @@ impl MainView { id: tab_id, name: tab_name, is_custom_name: true, - request: request.clone(), - response: response.clone(), - url_input: Some(url_input), - method_dropdown, - request_view, - response_view, - in_flight_request: None, - request_generation: RequestGeneration::default(), + content: TabContent::Request { + request: request.clone(), + response: response.clone(), + url_input: Some(url_input), + method_dropdown, + request_view, + response_view, + in_flight_request: None, + request_generation: RequestGeneration::default(), + }, collection_id: None, }; @@ -770,14 +867,16 @@ impl MainView { id: tab_id, name: tab_name, is_custom_name: true, - request: request.clone(), - response: response.clone(), - url_input: Some(url_input), - method_dropdown, - request_view, - response_view, - in_flight_request: None, - request_generation: RequestGeneration::default(), + content: TabContent::Request { + request: request.clone(), + response: response.clone(), + url_input: Some(url_input), + method_dropdown, + request_view, + response_view, + in_flight_request: None, + request_generation: RequestGeneration::default(), + }, collection_id: Some(collection_id), }; @@ -984,6 +1083,67 @@ impl MainView { }); } + pub fn open_environment_tab( + &mut self, + environment_id: Uuid, + window: &mut Window, + cx: &mut Context, + ) { + if let Some(index) = self + .tabs + .iter() + .position(|tab| tab.is_environment(environment_id)) + { + self.active_tab_index = index; + cx.notify(); + return; + } + + let env_name = self + .environments + .read(cx) + .get(environment_id) + .map(|e| e.name.clone()) + .unwrap_or_else(|| "Environment".to_string()); + + let collection_id = + self.environments + .read(cx) + .get(environment_id) + .and_then(|env| match env.scope { + EnvironmentScope::Project(col_id) => Some(col_id), + _ => None, + }); + + let environments = self.environments.clone(); + let collections = self.collections.clone(); + let editor_view = cx.new(|cx| { + let mut view = + EnvironmentView::new(environment_id, environments, collections, window, cx); + view.set_collection_context(collection_id); + view + }); + + let tab_id = TabId(self.next_tab_id); + self.next_tab_id += 1; + + let new_tab = TabState { + id: tab_id, + name: env_name, + is_custom_name: true, + content: TabContent::Environment { + environment_id, + view: editor_view, + }, + collection_id, + }; + + self.tabs.push(new_tab); + self.active_tab_index = self.tabs.len() - 1; + self.tab_scroll_handle.scroll_to_item(self.active_tab_index); + cx.notify(); + } + pub fn show_new_environment_dialog( &mut self, project_id: Option, @@ -1291,13 +1451,23 @@ impl MainView { ) -> Option { let (tab_name, is_custom_name, request_entity, request_view, url_input) = { let tab = self.tabs.get(tab_index)?; - ( - tab.name.clone(), - tab.is_custom_name, - tab.request.clone(), - tab.request_view.clone(), - tab.url_input.clone(), - ) + if let TabContent::Request { + request, + request_view, + url_input, + .. + } = &tab.content + { + ( + tab.name.clone(), + tab.is_custom_name, + request.clone(), + request_view.clone(), + url_input.clone(), + ) + } else { + return None; + } }; request_view.update(cx, |view, cx| { @@ -1404,14 +1574,19 @@ impl MainView { fn cancel_in_flight_for_tab(&mut self, index: usize, cx: &mut Context) { if let Some(tab) = self.tabs.get_mut(index) - && let Some(mut in_flight) = tab.in_flight_request.take() + && let TabContent::Request { + request, + response, + in_flight_request, + request_generation, + .. + } = &mut tab.content + && let Some(mut in_flight) = in_flight_request.take() { let _ = in_flight.cancel(); - tab.request_generation.advance(); - tab.request - .update(cx, |request, cx| request.set_sending(false, cx)); - tab.response - .update(cx, |response, cx| response.set_cancelled(cx)); + request_generation.advance(); + request.update(cx, |req, cx| req.set_sending(false, cx)); + response.update(cx, |resp, cx| resp.set_cancelled(cx)); } } @@ -1440,14 +1615,16 @@ impl MainView { id: tab_id, name: "New Request".to_string(), is_custom_name: false, - request: request.clone(), - response: response.clone(), - url_input: None, - method_dropdown, - request_view, - response_view, - in_flight_request: None, - request_generation: RequestGeneration::default(), + content: TabContent::Request { + request: request.clone(), + response: response.clone(), + url_input: None, + method_dropdown, + request_view, + response_view, + in_flight_request: None, + request_generation: RequestGeneration::default(), + }, collection_id: None, }; @@ -1466,9 +1643,15 @@ impl MainView { self.active_tab_index = index; // Notify the views to re-render with their current data - if let Some(tab) = self.tabs.get(index) { - tab.request_view.update(cx, |_, cx| cx.notify()); - tab.response_view.update(cx, |_, cx| cx.notify()); + if let Some(tab) = self.tabs.get(index) + && let TabContent::Request { + request_view, + response_view, + .. + } = &tab.content + { + request_view.update(cx, |_, cx| cx.notify()); + response_view.update(cx, |_, cx| cx.notify()); } // Scroll to the selected tab to ensure it's visible @@ -2289,10 +2472,21 @@ impl MainView { return; }; + let TabContent::Request { + request: request_entity, + response: response_entity, + url_input, + request_view, + .. + } = &tab.content + else { + return; + }; + let tab_id = tab.id; - let request_entity = tab.request.clone(); - let response_entity = tab.response.clone(); - let request_view = tab.request_view.clone(); + let request_entity = request_entity.clone(); + let response_entity = response_entity.clone(); + let request_view = request_view.clone(); let tab_name = tab.name.clone(); let collection_id = tab.collection_id; @@ -2303,7 +2497,7 @@ impl MainView { } // Get URL from input state. - let base_url = if let Some(ref url_input) = tab.url_input { + let base_url = if let Some(url_input) = url_input { url_input.read(cx).text().to_string() } else { String::new() @@ -2393,9 +2587,18 @@ impl MainView { self.http_client .spawn_request(method, resolved_url, resolved_headers, resolved_body); let generation = if let Some(tab) = self.tabs.get_mut(tab_index) { - let generation = tab.request_generation.advance(); - tab.in_flight_request = Some(in_flight_request); - generation + if let TabContent::Request { + in_flight_request: tab_in_flight, + request_generation, + .. + } = &mut tab.content + { + let generation_count = request_generation.advance(); + *tab_in_flight = Some(in_flight_request); + generation_count + } else { + return; + } } else { return; }; @@ -2410,12 +2613,19 @@ impl MainView { let Some(tab) = main.tabs.iter_mut().find(|tab| tab.id == tab_id) else { return; }; - if tab.request_generation != generation { - log::debug!("Discarded stale result for tab {}", tab_id.0); - return; - } + if let TabContent::Request { + request_generation, + in_flight_request, + .. + } = &mut tab.content + { + if *request_generation != generation { + log::debug!("Discarded stale result for tab {}", tab_id.0); + return; + } - tab.in_flight_request = None; + *in_flight_request = None; + } request_entity.update(cx, |req, cx| req.set_sending(false, cx)); match result { @@ -2467,20 +2677,27 @@ impl MainView { return; }; - let is_sending = tab.request.read(cx).is_sending(); - if !is_sending { - return; - } + if let TabContent::Request { + request, + response, + in_flight_request, + request_generation, + .. + } = &mut tab.content + { + let is_sending = request.read(cx).is_sending(); + if !is_sending { + return; + } - if let Some(mut in_flight) = tab.in_flight_request.take() { - let _ = in_flight.cancel(); + if let Some(mut in_flight) = in_flight_request.take() { + let _ = in_flight.cancel(); + } + request_generation.advance(); + request.update(cx, |r, cx| r.set_sending(false, cx)); + response.update(cx, |r, cx| r.set_cancelled(cx)); + cx.notify(); } - tab.request_generation.advance(); - tab.request - .update(cx, |request, cx| request.set_sending(false, cx)); - tab.response - .update(cx, |response, cx| response.set_cancelled(cx)); - cx.notify(); } pub fn toggle_sidebar(&mut self, cx: &mut Context) { @@ -2654,10 +2871,19 @@ impl MainView { pub fn duplicate_request(&mut self, window: &mut Window, cx: &mut Context) { if let Some(current_tab) = self.active_tab() { - let old_request = current_tab.request.clone(); - let old_url_input = current_tab.url_input.clone(); + let TabContent::Request { + request: old_request, + url_input: old_url_input, + request_view: old_request_view, + .. + } = ¤t_tab.content + else { + return; + }; + let old_request = old_request.clone(); + let old_url_input = old_url_input.clone(); let old_name = current_tab.name.clone(); - let old_request_view = current_tab.request_view.clone(); + let old_request_view = old_request_view.clone(); let old_collection_id = current_tab.collection_id; let old_body_type = old_request_view.read(cx).get_body_type(); @@ -2741,14 +2967,16 @@ impl MainView { id: tab_id, name: format!("{} (copy)", old_name), is_custom_name: true, - request: new_request.clone(), - response: new_response.clone(), - url_input: new_url_input, - method_dropdown: new_method_dropdown, - request_view: new_request_view, - response_view: new_response_view, - in_flight_request: None, - request_generation: RequestGeneration::default(), + content: TabContent::Request { + request: new_request.clone(), + response: new_response.clone(), + url_input: new_url_input, + method_dropdown: new_method_dropdown, + request_view: new_request_view, + response_view: new_response_view, + in_flight_request: None, + request_generation: RequestGeneration::default(), + }, collection_id: old_collection_id, }; @@ -2760,11 +2988,17 @@ impl MainView { } pub fn set_method(&mut self, method: HttpMethod, cx: &mut Context) { - if let Some(tab) = self.tabs.get(self.active_tab_index) { - tab.method_dropdown.update(cx, |state, cx| { + if let Some(tab) = self.tabs.get(self.active_tab_index) + && let TabContent::Request { + method_dropdown, + request, + .. + } = &tab.content + { + method_dropdown.update(cx, |state, cx| { state.set_method(method, cx); }); - tab.request.update(cx, |req, cx| { + request.update(cx, |req, cx| { req.set_method(method, cx); }); } @@ -2775,8 +3009,10 @@ impl MainView { tab: crate::views::request_view::RequestTab, cx: &mut Context, ) { - if let Some(active_tab) = self.tabs.get(self.active_tab_index) { - active_tab.request_view.update(cx, |view, cx| { + if let Some(active_tab) = self.tabs.get(self.active_tab_index) + && let TabContent::Request { request_view, .. } = &active_tab.content + { + request_view.update(cx, |view, cx| { view.set_tab(tab, cx); }); } @@ -2787,8 +3023,10 @@ impl MainView { tab: crate::views::response_view::ResponseTab, cx: &mut Context, ) { - if let Some(active_tab) = self.tabs.get(self.active_tab_index) { - active_tab.response_view.update(cx, |view, cx| { + if let Some(active_tab) = self.tabs.get(self.active_tab_index) + && let TabContent::Request { response_view, .. } = &active_tab.content + { + response_view.update(cx, |view, cx| { view.set_tab(tab, cx); }); } @@ -2796,7 +3034,10 @@ impl MainView { pub fn focus_url_bar(&mut self, window: &mut Window, cx: &mut Context) { if let Some(tab) = self.tabs.get(self.active_tab_index) - && let Some(url_input) = &tab.url_input + && let TabContent::Request { + url_input: Some(url_input), + .. + } = &tab.content { url_input.update(cx, |state, cx| { state.focus(window, cx); @@ -2846,18 +3087,33 @@ impl Render for MainView { .iter() .enumerate() .map(|(i, tab)| { - let method = tab.request.read(cx).method(); - let display_name = if tab.is_custom_name { - tab.name.clone() - } else { - let url = tab - .url_input - .as_ref() - .map(|input| input.read(cx).text().to_string()) - .unwrap_or_default(); - Self::derive_tab_name(&url) + let (display_name, icon) = match &tab.content { + TabContent::Request { + request, url_input, .. + } => { + let method = request.read(cx).method(); + let name = if tab.is_custom_name { + tab.name.clone() + } else { + let url = url_input + .as_ref() + .map(|input| input.read(cx).text().to_string()) + .unwrap_or_default(); + Self::derive_tab_name(&url) + }; + (name, TabIcon::Method(method)) + } + TabContent::Environment { environment_id, .. } => { + let name = self + .environments + .read(cx) + .get(*environment_id) + .map(|e| e.name.clone()) + .unwrap_or_else(|| tab.name.clone()); + (name, TabIcon::Icon(IconName::Package)) + } }; - let mut info = TabInfo::new(tab.id.0 as usize, i, display_name, method); + let mut info = TabInfo::new(tab.id.0 as usize, i, display_name, icon); if i == self.active_tab_index { info = info.active(); } @@ -2865,21 +3121,32 @@ impl Render for MainView { }) .collect(); - // Get current request state for URL bar - let (url_input, method_dropdown, request_entity, is_loading, request_view, response_view) = - if let Some(tab) = self.active_tab() { - let req = tab.request.read(cx); - ( - tab.url_input.clone(), - tab.method_dropdown.clone(), - tab.request.clone(), + // Get current request state for URL bar (only if active tab is a Request tab) + let request_tab_state = if let Some(tab) = self.active_tab() { + if let TabContent::Request { + request, + url_input, + method_dropdown, + request_view, + response_view, + .. + } = &tab.content + { + let req = request.read(cx); + Some(( + url_input.clone(), + method_dropdown.clone(), + request.clone(), req.is_sending(), - tab.request_view.clone(), - tab.response_view.clone(), - ) + request_view.clone(), + response_view.clone(), + )) } else { - return div().child("No active tab").into_any_element(); - }; + None + } + } else { + return div().child("No active tab").into_any_element(); + }; let this = cx.entity().clone(); let this_for_send = this.clone(); @@ -2888,6 +3155,7 @@ impl Render for MainView { let this_for_import_environment = this.clone(); let this_for_delete_environment = this.clone(); let this_for_rename_environment = this.clone(); + let this_for_open_environment = this.clone(); self.environment_panel.update(cx, |panel, cx| { panel.set_collection_context(active_collection_id, cx); panel.on_new_environment(move |project_id, window, cx| { @@ -2910,6 +3178,11 @@ impl Render for MainView { view.show_rename_environment_dialog(environment_id, name, window, cx); }); }); + panel.on_open_environment(move |environment_id, window, cx| { + this_for_open_environment.update(cx, |view, cx| { + view.open_environment_tab(environment_id, window, cx); + }); + }); }); div() @@ -3355,18 +3628,42 @@ impl Render for MainView { this.clone(), self.tab_scroll_handle.clone(), )) - // Content - vertical resizable split between request and response panels + // Content - vertical resizable split between request and response panels or environment view .child(div().flex_1().flex().flex_col().overflow_hidden().child( - self.render_request_response_split( - url_input, - method_dropdown, - request_entity, - is_loading, - this_for_send, - request_view, - response_view, - effective_layout, - ), + if let Some(active_tab) = self.active_tab() { + match &active_tab.content { + TabContent::Environment { view, .. } => { + view.clone().into_any_element() + } + TabContent::Request { .. } => { + if let Some(( + url_input, + method_dropdown, + request_entity, + is_loading, + request_view, + response_view, + )) = request_tab_state + { + self.render_request_response_split( + url_input, + method_dropdown, + request_entity, + is_loading, + this_for_send, + request_view, + response_view, + effective_layout, + ) + .into_any_element() + } else { + div().into_any_element() + } + } + } + } else { + div().into_any_element() + }, )), ) // Dialog layer - renders dialogs on top of everything diff --git a/src/views/mod.rs b/src/views/mod.rs index 2b48a55..2353218 100644 --- a/src/views/mod.rs +++ b/src/views/mod.rs @@ -1,4 +1,5 @@ mod command_palette; +mod environment_view; mod main_view; mod request_view; mod response_view;