From eb783265698e9cf8b9cf981ac2610a4665ba76f4 Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Tue, 7 Apr 2026 14:41:11 -0300 Subject: [PATCH] agent_panel: Refine git pickers design in the empty thread view (#53324) Follow-up to https://github.com/zed-industries/zed/pull/52979 with some design refinements. Release Notes: - N/A --------- Co-authored-by: cameron Co-authored-by: Anthony Eid --- crates/agent_ui/src/agent_panel.rs | 192 +++++++++--- crates/agent_ui/src/config_options.rs | 17 +- crates/agent_ui/src/mode_selector.rs | 21 +- crates/agent_ui/src/model_selector.rs | 19 +- crates/agent_ui/src/profile_selector.rs | 16 +- crates/agent_ui/src/thread_branch_picker.rs | 255 ++++++++++------ crates/agent_ui/src/thread_worktree_picker.rs | 280 +++++++++++++----- crates/agent_ui/src/ui.rs | 13 + 8 files changed, 552 insertions(+), 261 deletions(-) diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index 01b897fc63da76247b5624f8316ea06b2c1f85e5..eeb8fbf8c32a01a71b8471342d8edc90cc8517cf 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -72,7 +72,7 @@ use terminal::terminal_settings::TerminalSettings; use terminal_view::{TerminalView, terminal_panel::TerminalPanel}; use theme_settings::ThemeSettings; use ui::{ - Button, Callout, CommonAnimationExt, ContextMenu, ContextMenuEntry, PopoverMenu, + Button, ButtonLike, Callout, CommonAnimationExt, ContextMenu, ContextMenuEntry, PopoverMenu, PopoverMenuHandle, Tab, Tooltip, prelude::*, utils::WithRemSize, }; use util::{ResultExt as _, debug_panic}; @@ -615,37 +615,130 @@ enum WhichFontSize { None, } +struct StartThreadInLabel { + prefix: Option, + label: SharedString, + suffix: Option, +} + impl StartThreadIn { - fn label(&self) -> SharedString { + fn trigger_label(&self, project: &Project, cx: &App) -> StartThreadInLabel { match self { - Self::LocalProject => "Current Worktree".into(), + Self::LocalProject => { + let suffix = project.active_repository(cx).and_then(|repo| { + let repo = repo.read(cx); + let work_dir = &repo.work_directory_abs_path; + let visible_paths: Vec<_> = project + .visible_worktrees(cx) + .map(|wt| wt.read(cx).abs_path().to_path_buf()) + .collect(); + + for linked in repo.linked_worktrees() { + if visible_paths.contains(&linked.path) { + return Some(SharedString::from(format!( + "({})", + linked.display_name() + ))); + } + } + + if visible_paths + .iter() + .any(|p| p.as_path() == work_dir.as_ref()) + { + return Some("(main)".into()); + } + + None + }); + + StartThreadInLabel { + prefix: None, + label: "Current Worktree".into(), + suffix, + } + } Self::NewWorktree { worktree_name: Some(worktree_name), .. - } => format!("New: {worktree_name}").into(), - Self::NewWorktree { .. } => "New Git Worktree".into(), - Self::LinkedWorktree { display_name, .. } => format!("From: {}", &display_name).into(), + } => StartThreadInLabel { + prefix: Some("New:".into()), + label: worktree_name.clone().into(), + suffix: None, + }, + Self::NewWorktree { .. } => StartThreadInLabel { + prefix: None, + label: "New Git Worktree".into(), + suffix: None, + }, + Self::LinkedWorktree { display_name, .. } => StartThreadInLabel { + prefix: Some("From:".into()), + label: display_name.clone().into(), + suffix: None, + }, } } - fn worktree_branch_label(&self, default_branch_label: SharedString) -> Option { + fn branch_trigger_label(&self, project: &Project, cx: &App) -> Option { match self { - Self::NewWorktree { branch_target, .. } => match branch_target { - NewWorktreeBranchTarget::CurrentBranch => Some(default_branch_label), - NewWorktreeBranchTarget::ExistingBranch { name } => { - Some(format!("From: {name}").into()) - } - NewWorktreeBranchTarget::CreateBranch { name, from_ref } => { - if let Some(from_ref) = from_ref { - Some(format!("From: {from_ref}").into()) - } else { - Some(format!("From: {name}").into()) + Self::NewWorktree { branch_target, .. } => { + let (branch_name, is_occupied) = match branch_target { + NewWorktreeBranchTarget::CurrentBranch => { + let name: SharedString = if project.repositories(cx).len() > 1 { + "current branches".into() + } else { + project + .active_repository(cx) + .and_then(|repo| { + repo.read(cx) + .branch + .as_ref() + .map(|branch| SharedString::from(branch.name().to_string())) + }) + .unwrap_or_else(|| "HEAD".into()) + }; + (name, false) } - } - }, + NewWorktreeBranchTarget::ExistingBranch { name } => { + let occupied = Self::is_branch_occupied(name, project, cx); + (name.clone().into(), occupied) + } + NewWorktreeBranchTarget::CreateBranch { + from_ref: Some(from_ref), + .. + } => { + let occupied = Self::is_branch_occupied(from_ref, project, cx); + (from_ref.clone().into(), occupied) + } + NewWorktreeBranchTarget::CreateBranch { name, .. } => { + (name.clone().into(), false) + } + }; + + let prefix = if is_occupied { + Some("New From:".into()) + } else { + None + }; + + Some(StartThreadInLabel { + prefix, + label: branch_name, + suffix: None, + }) + } _ => None, } } + + fn is_branch_occupied(branch_name: &str, project: &Project, cx: &App) -> bool { + project.repositories(cx).values().any(|repo| { + repo.read(cx) + .linked_worktrees + .iter() + .any(|wt| wt.branch_name() == Some(branch_name)) + }) + } } #[derive(Clone, Debug)] @@ -3346,7 +3439,9 @@ impl AgentPanel { Some(WorktreeCreationStatus::Creating) ); - let trigger_label = self.start_thread_in.label(); + let trigger_parts = self + .start_thread_in + .trigger_label(self.project.read(cx), cx); let icon = if self.start_thread_in_menu_handle.is_deployed() { IconName::ChevronUp @@ -3354,9 +3449,16 @@ impl AgentPanel { IconName::ChevronDown }; - let trigger_button = Button::new("thread-target-trigger", trigger_label) - .end_icon(Icon::new(icon).size(IconSize::XSmall).color(Color::Muted)) - .disabled(is_creating); + let trigger_button = ButtonLike::new("thread-target-trigger") + .disabled(is_creating) + .when_some(trigger_parts.prefix, |this, prefix| { + this.child(Label::new(prefix).color(Color::Muted)) + }) + .child(Label::new(trigger_parts.label)) + .when_some(trigger_parts.suffix, |this, suffix| { + this.child(Label::new(suffix).color(Color::Muted)) + }) + .child(Icon::new(icon).size(IconSize::XSmall).color(Color::Muted)); let project = self.project.clone(); let current_target = self.start_thread_in.clone(); @@ -3392,37 +3494,31 @@ impl AgentPanel { self.worktree_creation_status, Some(WorktreeCreationStatus::Creating) ); - let default_branch_label = if self.project.read(cx).repositories(cx).len() > 1 { - SharedString::from("From: current branches") - } else { - self.project - .read(cx) - .active_repository(cx) - .and_then(|repo| { - repo.read(cx) - .branch - .as_ref() - .map(|branch| SharedString::from(format!("From: {}", branch.name()))) - }) - .unwrap_or_else(|| SharedString::from("From: HEAD")) - }; - let trigger_label = self + + let project_ref = self.project.read(cx); + let trigger_parts = self .start_thread_in - .worktree_branch_label(default_branch_label) - .unwrap_or_else(|| SharedString::from("From: HEAD")); + .branch_trigger_label(project_ref, cx) + .unwrap_or_else(|| StartThreadInLabel { + prefix: Some("From:".into()), + label: "HEAD".into(), + suffix: None, + }); + let icon = if self.thread_branch_menu_handle.is_deployed() { IconName::ChevronUp } else { IconName::ChevronDown }; - let trigger_button = Button::new("thread-branch-trigger", trigger_label) - .start_icon( - Icon::new(IconName::GitBranch) - .size(IconSize::Small) - .color(Color::Muted), - ) - .end_icon(Icon::new(icon).size(IconSize::XSmall).color(Color::Muted)) - .disabled(is_creating); + + let trigger_button = ButtonLike::new("thread-branch-trigger") + .disabled(is_creating) + .when_some(trigger_parts.prefix, |this, prefix| { + this.child(Label::new(prefix).color(Color::Muted)) + }) + .child(Label::new(trigger_parts.label)) + .child(Icon::new(icon).size(IconSize::XSmall).color(Color::Muted)); + let project = self.project.clone(); let current_target = self.start_thread_in.clone(); diff --git a/crates/agent_ui/src/config_options.rs b/crates/agent_ui/src/config_options.rs index 44c0baa232222c0ba7c1d54acdecaabacfa85f12..cf2809b87b94eae8a3eb75844539ddffc652b7df 100644 --- a/crates/agent_ui/src/config_options.rs +++ b/crates/agent_ui/src/config_options.rs @@ -3,7 +3,7 @@ use std::{cmp::Reverse, rc::Rc, sync::Arc}; use acp_thread::AgentSessionConfigOptions; use agent_client_protocol as acp; use agent_servers::AgentServer; -use agent_settings::AgentSettings; + use collections::HashSet; use fs::Fs; use fuzzy::StringMatchCandidate; @@ -13,14 +13,13 @@ use gpui::{ use ordered_float::OrderedFloat; use picker::popover_menu::PickerPopoverMenu; use picker::{Picker, PickerDelegate}; -use settings::{Settings, SettingsStore}; +use settings::SettingsStore; use ui::{ - DocumentationSide, ElevationIndex, IconButton, ListItem, ListItemSpacing, PopoverMenuHandle, - Tooltip, prelude::*, + ElevationIndex, IconButton, ListItem, ListItemSpacing, PopoverMenuHandle, Tooltip, prelude::*, }; use util::ResultExt as _; -use crate::ui::HoldForDefault; +use crate::ui::{HoldForDefault, documentation_aside_side}; const PICKER_THRESHOLD: usize = 5; @@ -695,13 +694,7 @@ impl PickerDelegate for ConfigOptionPickerDelegate { let description = description.clone(); let is_default = *is_default; - let settings = AgentSettings::get_global(cx); - let side = match settings.dock { - settings::DockPosition::Left => DocumentationSide::Right, - settings::DockPosition::Bottom | settings::DockPosition::Right => { - DocumentationSide::Left - } - }; + let side = documentation_aside_side(cx); ui::DocumentationAside::new( side, diff --git a/crates/agent_ui/src/mode_selector.rs b/crates/agent_ui/src/mode_selector.rs index 60c9b8787092388ad2b3e2d5817834018dc7ea25..2b0754e9dc993c47fd32064219461df5304bad4d 100644 --- a/crates/agent_ui/src/mode_selector.rs +++ b/crates/agent_ui/src/mode_selector.rs @@ -1,17 +1,20 @@ use acp_thread::AgentSessionModes; use agent_client_protocol as acp; use agent_servers::AgentServer; -use agent_settings::AgentSettings; + use fs::Fs; use gpui::{Context, Entity, WeakEntity, Window, prelude::*}; -use settings::Settings as _; + use std::{rc::Rc, sync::Arc}; use ui::{ - Button, ContextMenu, ContextMenuEntry, DocumentationSide, KeyBinding, PopoverMenu, - PopoverMenuHandle, Tooltip, prelude::*, + Button, ContextMenu, ContextMenuEntry, KeyBinding, PopoverMenu, PopoverMenuHandle, Tooltip, + prelude::*, }; -use crate::{CycleModeSelector, ToggleProfileSelector, ui::HoldForDefault}; +use crate::{ + CycleModeSelector, ToggleProfileSelector, + ui::{HoldForDefault, documentation_aside_side}, +}; pub struct ModeSelector { connection: Rc, @@ -87,13 +90,7 @@ impl ModeSelector { let current_mode = self.connection.current_mode(); let default_mode = self.agent_server.default_mode(cx); - let settings = AgentSettings::get_global(cx); - let side = match settings.dock { - settings::DockPosition::Left => DocumentationSide::Right, - settings::DockPosition::Bottom | settings::DockPosition::Right => { - DocumentationSide::Left - } - }; + let side = documentation_aside_side(cx); for mode in all_modes { let is_selected = &mode.id == ¤t_mode; diff --git a/crates/agent_ui/src/model_selector.rs b/crates/agent_ui/src/model_selector.rs index 89ed3e490b33ca83cbdab25cfce77fee7cf9ccb6..89290bd9973216f04cdd1d70e442cf04a47b97f2 100644 --- a/crates/agent_ui/src/model_selector.rs +++ b/crates/agent_ui/src/model_selector.rs @@ -3,7 +3,7 @@ use std::{cmp::Reverse, rc::Rc, sync::Arc}; use acp_thread::{AgentModelIcon, AgentModelInfo, AgentModelList, AgentModelSelector}; use agent_client_protocol::ModelId; use agent_servers::AgentServer; -use agent_settings::AgentSettings; + use anyhow::Result; use collections::{HashSet, IndexMap}; use fs::Fs; @@ -16,12 +16,15 @@ use gpui::{ use itertools::Itertools; use ordered_float::OrderedFloat; use picker::{Picker, PickerDelegate}; -use settings::{Settings, SettingsStore}; -use ui::{DocumentationAside, DocumentationSide, IntoElement, prelude::*}; +use settings::SettingsStore; +use ui::{DocumentationAside, IntoElement, prelude::*}; use util::ResultExt; use zed_actions::agent::OpenSettings; -use crate::ui::{HoldForDefault, ModelSelectorFooter, ModelSelectorHeader, ModelSelectorListItem}; +use crate::ui::{ + HoldForDefault, ModelSelectorFooter, ModelSelectorHeader, ModelSelectorListItem, + documentation_aside_side, +}; pub type ModelSelector = Picker; @@ -385,13 +388,7 @@ impl PickerDelegate for ModelPickerDelegate { let description = description.clone(); let is_default = *is_default; - let settings = AgentSettings::get_global(cx); - let side = match settings.dock { - settings::DockPosition::Left => DocumentationSide::Right, - settings::DockPosition::Bottom | settings::DockPosition::Right => { - DocumentationSide::Left - } - }; + let side = documentation_aside_side(cx); DocumentationAside::new( side, diff --git a/crates/agent_ui/src/profile_selector.rs b/crates/agent_ui/src/profile_selector.rs index 963e32af55fda90f49edb0787f7327190c92681f..a73f78b1a5a91a7cd564fd498ad3932019c18821 100644 --- a/crates/agent_ui/src/profile_selector.rs +++ b/crates/agent_ui/src/profile_selector.rs @@ -1,4 +1,6 @@ -use crate::{CycleModeSelector, ManageProfiles, ToggleProfileSelector}; +use crate::{ + CycleModeSelector, ManageProfiles, ToggleProfileSelector, ui::documentation_aside_side, +}; use agent_settings::{ AgentProfile, AgentProfileId, AgentSettings, AvailableProfiles, builtin_profiles, }; @@ -15,8 +17,8 @@ use std::{ sync::{Arc, atomic::AtomicBool}, }; use ui::{ - DocumentationAside, DocumentationSide, HighlightedLabel, KeyBinding, LabelSize, ListItem, - ListItemSpacing, PopoverMenuHandle, Tooltip, prelude::*, + DocumentationAside, HighlightedLabel, KeyBinding, LabelSize, ListItem, ListItemSpacing, + PopoverMenuHandle, Tooltip, prelude::*, }; /// Trait for types that can provide and manage agent profiles @@ -629,13 +631,7 @@ impl PickerDelegate for ProfilePickerDelegate { let candidate = self.candidates.get(entry.candidate_index)?; let docs_aside = Self::documentation(candidate)?.to_string(); - let settings = AgentSettings::get_global(cx); - let side = match settings.dock { - settings::DockPosition::Left => DocumentationSide::Right, - settings::DockPosition::Bottom | settings::DockPosition::Right => { - DocumentationSide::Left - } - }; + let side = documentation_aside_side(cx); Some(DocumentationAside { side, diff --git a/crates/agent_ui/src/thread_branch_picker.rs b/crates/agent_ui/src/thread_branch_picker.rs index d69cbb4a60054ad83d767928c880f3a43caef4f1..99aced11de951c158b1c84c1f28c69da85a05359 100644 --- a/crates/agent_ui/src/thread_branch_picker.rs +++ b/crates/agent_ui/src/thread_branch_picker.rs @@ -1,4 +1,5 @@ use std::collections::{HashMap, HashSet}; +use std::rc::Rc; use collections::HashSet as CollectionsHashSet; use std::path::PathBuf; @@ -7,14 +8,14 @@ use std::sync::Arc; use fuzzy::StringMatchCandidate; use git::repository::Branch as GitBranch; use gpui::{ - App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, IntoElement, - ParentElement, Render, SharedString, Styled, Task, Window, rems, + AnyElement, App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, + IntoElement, ParentElement, Render, SharedString, Styled, Task, Window, rems, }; use picker::{Picker, PickerDelegate, PickerEditorPosition}; use project::Project; use ui::{ - HighlightedLabel, Icon, IconName, Label, LabelCommon, ListItem, ListItemSpacing, Tooltip, - prelude::*, + Divider, DocumentationAside, HighlightedLabel, Icon, IconName, Label, LabelCommon, ListItem, + ListItemSpacing, prelude::*, }; use util::ResultExt as _; @@ -206,10 +207,10 @@ impl Render for ThreadBranchPicker { enum ThreadBranchEntry { CurrentBranch, DefaultBranch, + Separator, ExistingBranch { branch: GitBranch, positions: Vec, - occupied_reason: Option, }, CreateNamed { name: String, @@ -269,42 +270,44 @@ impl ThreadBranchPickerDelegate { matches } - fn current_branch_label(&self) -> SharedString { - if self.has_multiple_repositories { - SharedString::from("New branch from: current branches") - } else { - SharedString::from(format!("New branch from: {}", self.current_branch_name)) - } + fn is_branch_occupied(&self, branch_name: &str) -> bool { + self.occupied_branches + .as_ref() + .is_some_and(|occupied| occupied.contains_key(branch_name)) } - fn default_branch_label(&self) -> Option { - let default_branch_name = self - .default_branch_name - .as_ref() - .filter(|name| *name != &self.current_branch_name)?; - let is_occupied = self - .occupied_branches - .as_ref() - .is_some_and(|occupied| occupied.contains_key(default_branch_name)); - let prefix = if is_occupied { - "New branch from" + fn branch_aside_text(&self, branch_name: &str, is_remote: bool) -> Option { + if self.is_branch_occupied(branch_name) { + Some( + format!( + "This branch is already checked out in another worktree. \ + A new branch will be created from {branch_name}." + ) + .into(), + ) + } else if is_remote { + Some("A new local branch will be created from this remote branch.".into()) } else { - "From" - }; - Some(SharedString::from(format!( - "{prefix}: {default_branch_name}" - ))) + None + } } - fn branch_label_prefix(&self, branch_name: &str) -> &'static str { - let is_occupied = self - .occupied_branches - .as_ref() - .is_some_and(|occupied| occupied.contains_key(branch_name)); - if is_occupied { - "New branch from: " - } else { - "From: " + fn entry_aside_text(&self, entry: &ThreadBranchEntry) -> Option { + match entry { + ThreadBranchEntry::CurrentBranch => Some(SharedString::from( + "A new branch will be created from the current branch.", + )), + ThreadBranchEntry::DefaultBranch => { + let default_branch_name = self + .default_branch_name + .as_ref() + .filter(|name| *name != &self.current_branch_name)?; + self.branch_aside_text(default_branch_name, false) + } + ThreadBranchEntry::ExistingBranch { branch, .. } => { + self.branch_aside_text(branch.name(), branch.is_remote()) + } + _ => None, } } @@ -379,7 +382,7 @@ impl ThreadBranchPickerDelegate { } impl PickerDelegate for ThreadBranchPickerDelegate { - type ListItem = ListItem; + type ListItem = AnyElement; fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc { "Search branches…".into() @@ -406,6 +409,10 @@ impl PickerDelegate for ThreadBranchPickerDelegate { self.selected_index = ix; } + fn can_select(&self, ix: usize, _window: &mut Window, _cx: &mut Context>) -> bool { + !matches!(self.matches.get(ix), Some(ThreadBranchEntry::Separator)) + } + fn update_matches( &mut self, query: String, @@ -418,10 +425,12 @@ impl PickerDelegate for ThreadBranchPickerDelegate { if query.is_empty() { if let Some(name) = self.selected_entry_name().map(ToOwned::to_owned) { if self.prefer_create_entry() { + matches.push(ThreadBranchEntry::Separator); matches.push(ThreadBranchEntry::CreateNamed { name }); } } } else { + matches.push(ThreadBranchEntry::Separator); matches.push(ThreadBranchEntry::CreateNamed { name: query.replace(' ', "-"), }); @@ -437,19 +446,25 @@ impl PickerDelegate for ThreadBranchPickerDelegate { self.selected_index = 0; return Task::ready(()); }; - let occupied_branches = self.occupied_branches.clone().unwrap_or_default(); if query.is_empty() { let mut matches = self.fixed_matches(); - for branch in all_branches.into_iter().filter(|branch| { - branch.name() != self.current_branch_name - && self - .default_branch_name - .as_ref() - .is_none_or(|default_branch_name| branch.name() != default_branch_name) - }) { + let filtered_branches: Vec<_> = all_branches + .into_iter() + .filter(|branch| { + branch.name() != self.current_branch_name + && self + .default_branch_name + .as_ref() + .is_none_or(|default_branch_name| branch.name() != default_branch_name) + }) + .collect(); + + if !filtered_branches.is_empty() { + matches.push(ThreadBranchEntry::Separator); + } + for branch in filtered_branches { matches.push(ThreadBranchEntry::ExistingBranch { - occupied_reason: occupied_branches.get(branch.name()).cloned(), branch, positions: Vec::new(), }); @@ -504,6 +519,7 @@ impl PickerDelegate for ThreadBranchPickerDelegate { picker .update_in(cx, |picker, _window, cx| { let mut matches = picker.delegate.fixed_matches(); + let mut has_dynamic_entries = false; for candidate in &fuzzy_matches { let branch = all_branches_clone[candidate.candidate_id].clone(); @@ -514,15 +530,20 @@ impl PickerDelegate for ThreadBranchPickerDelegate { { continue; } - let occupied_reason = occupied_branches.get(branch.name()).cloned(); + if !has_dynamic_entries { + matches.push(ThreadBranchEntry::Separator); + has_dynamic_entries = true; + } matches.push(ThreadBranchEntry::ExistingBranch { branch, positions: candidate.positions.clone(), - occupied_reason, }); } if fuzzy_matches.is_empty() { + if !has_dynamic_entries { + matches.push(ThreadBranchEntry::Separator); + } matches.push(ThreadBranchEntry::CreateNamed { name: normalized_query.clone(), }); @@ -558,6 +579,7 @@ impl PickerDelegate for ThreadBranchPickerDelegate { }; match entry { + ThreadBranchEntry::Separator => return, ThreadBranchEntry::CurrentBranch => { window.dispatch_action( Box::new(self.new_worktree_action(NewWorktreeBranchTarget::CurrentBranch)), @@ -615,76 +637,97 @@ impl PickerDelegate for ThreadBranchPickerDelegate { fn dismissed(&mut self, _window: &mut Window, _cx: &mut Context>) {} - fn separators_after_indices(&self) -> Vec { - let fixed_count = self.fixed_matches().len(); - if self.matches.len() > fixed_count { - vec![fixed_count - 1] - } else { - Vec::new() - } - } - fn render_match( &self, ix: usize, selected: bool, _window: &mut Window, - _cx: &mut Context>, + cx: &mut Context>, ) -> Option { let entry = self.matches.get(ix)?; match entry { - ThreadBranchEntry::CurrentBranch => Some( - ListItem::new("current-branch") - .inset(true) - .spacing(ListItemSpacing::Sparse) - .toggle_state(selected) - .start_slot(Icon::new(IconName::GitBranch).color(Color::Muted)) - .child(Label::new(self.current_branch_label())), + ThreadBranchEntry::Separator => Some( + div() + .py(DynamicSpacing::Base04.rems(cx)) + .child(Divider::horizontal()) + .into_any_element(), ), - ThreadBranchEntry::DefaultBranch => Some( - ListItem::new("default-branch") + ThreadBranchEntry::CurrentBranch => { + let branch_name = if self.has_multiple_repositories { + SharedString::from("current branches") + } else { + SharedString::from(self.current_branch_name.clone()) + }; + + Some( + ListItem::new("current-branch") + .inset(true) + .spacing(ListItemSpacing::Sparse) + .toggle_state(selected) + .child(Label::new(branch_name)) + .into_any_element(), + ) + } + ThreadBranchEntry::DefaultBranch => { + let default_branch_name = self + .default_branch_name + .as_ref() + .filter(|name| *name != &self.current_branch_name)?; + let is_occupied = self.is_branch_occupied(default_branch_name); + + let item = ListItem::new("default-branch") .inset(true) .spacing(ListItemSpacing::Sparse) .toggle_state(selected) - .start_slot(Icon::new(IconName::GitBranch).color(Color::Muted)) - .child(Label::new(self.default_branch_label()?)), - ), + .child(Label::new(default_branch_name.clone())); + + Some( + if is_occupied { + item.start_slot(Icon::new(IconName::GitBranchPlus).color(Color::Muted)) + } else { + item + } + .into_any_element(), + ) + } ThreadBranchEntry::ExistingBranch { - branch, - positions, - occupied_reason, + branch, positions, .. } => { - let prefix = self.branch_label_prefix(branch.name()); let branch_name = branch.name().to_string(); - let full_label = format!("{prefix}{branch_name}"); - let adjusted_positions: Vec = - positions.iter().map(|&p| p + prefix.len()).collect(); - - let item = ListItem::new(SharedString::from(format!("branch-{ix}"))) - .inset(true) - .spacing(ListItemSpacing::Sparse) - .toggle_state(selected) - .start_slot(Icon::new(IconName::GitBranch).color(Color::Muted)) - .child(HighlightedLabel::new(full_label, adjusted_positions).truncate()); - - Some(if let Some(reason) = occupied_reason.clone() { - item.tooltip(Tooltip::text(reason)) - } else if branch.is_remote() { - item.tooltip(Tooltip::text( - "Create a new local branch from this remote branch", - )) - } else { - item - }) + let needs_new_branch = self.is_branch_occupied(&branch_name) || branch.is_remote(); + + Some( + ListItem::new(SharedString::from(format!("branch-{ix}"))) + .inset(true) + .spacing(ListItemSpacing::Sparse) + .toggle_state(selected) + .child( + h_flex() + .min_w_0() + .gap_1() + .child( + HighlightedLabel::new(branch_name, positions.clone()) + .truncate(), + ) + .when(needs_new_branch, |item| { + item.child( + Icon::new(IconName::GitBranchPlus) + .size(IconSize::Small) + .color(Color::Muted), + ) + }), + ) + .into_any_element(), + ) } ThreadBranchEntry::CreateNamed { name } => Some( ListItem::new("create-named-branch") .inset(true) .spacing(ListItemSpacing::Sparse) .toggle_state(selected) - .start_slot(Icon::new(IconName::Plus).color(Color::Accent)) - .child(Label::new(format!("Create Branch: \"{name}\"…"))), + .child(Label::new(format!("Create Branch: \"{name}\"…"))) + .into_any_element(), ), } } @@ -692,4 +735,24 @@ impl PickerDelegate for ThreadBranchPickerDelegate { fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option { None } + + fn documentation_aside( + &self, + _window: &mut Window, + cx: &mut Context>, + ) -> Option { + let entry = self.matches.get(self.selected_index)?; + let aside_text = self.entry_aside_text(entry)?; + let side = crate::ui::documentation_aside_side(cx); + + Some(DocumentationAside::new( + side, + Rc::new(move |_| Label::new(aside_text.clone()).into_any_element()), + )) + } + + fn documentation_aside_index(&self) -> Option { + let entry = self.matches.get(self.selected_index)?; + self.entry_aside_text(entry).map(|_| self.selected_index) + } } diff --git a/crates/agent_ui/src/thread_worktree_picker.rs b/crates/agent_ui/src/thread_worktree_picker.rs index 47a6a12d71822e13ab3523a3a6b0bb1ee57c7b4b..142f47f02ffd282409c86413a390ae9359a0f8dc 100644 --- a/crates/agent_ui/src/thread_worktree_picker.rs +++ b/crates/agent_ui/src/thread_worktree_picker.rs @@ -1,4 +1,5 @@ use std::path::PathBuf; +use std::rc::Rc; use std::sync::Arc; use agent_settings::AgentSettings; @@ -6,17 +7,18 @@ use fs::Fs; use fuzzy::StringMatchCandidate; use git::repository::Worktree as GitWorktree; use gpui::{ - App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, IntoElement, - ParentElement, Render, SharedString, Styled, Task, Window, rems, + AnyElement, App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, + IntoElement, ParentElement, Render, SharedString, Styled, Task, Window, rems, }; use picker::{Picker, PickerDelegate, PickerEditorPosition}; use project::{Project, git_store::RepositoryId}; use settings::{NewThreadLocation, Settings, update_settings_file}; use ui::{ - HighlightedLabel, Icon, IconName, Label, LabelCommon, ListItem, ListItemSpacing, Tooltip, - prelude::*, + Divider, DocumentationAside, HighlightedLabel, Label, LabelCommon, ListItem, ListItemSpacing, + Tooltip, prelude::*, }; use util::ResultExt as _; +use util::paths::PathExt; use crate::ui::HoldForDefault; use crate::{NewWorktreeBranchTarget, StartThreadIn}; @@ -46,24 +48,60 @@ impl ThreadWorktreePicker { _ => NewWorktreeBranchTarget::default(), }; - let delegate = ThreadWorktreePickerDelegate { - matches: vec![ - ThreadWorktreeEntry::CurrentWorktree, - ThreadWorktreeEntry::NewWorktree, - ], - all_worktrees: project - .read(cx) - .repositories(cx) + let all_worktrees: Vec<_> = project + .read(cx) + .repositories(cx) + .iter() + .map(|(repo_id, repo)| (*repo_id, repo.read(cx).linked_worktrees.clone())) + .collect(); + + let has_multiple_repositories = all_worktrees.len() > 1; + + let linked_worktrees: Vec<_> = if has_multiple_repositories { + Vec::new() + } else { + all_worktrees .iter() - .map(|(repo_id, repo)| (*repo_id, repo.read(cx).linked_worktrees.clone())) - .collect(), + .flat_map(|(_, worktrees)| worktrees.iter()) + .filter(|worktree| { + !project_worktree_paths + .iter() + .any(|project_path| project_path == &worktree.path) + }) + .cloned() + .collect() + }; + + let mut initial_matches = vec![ + ThreadWorktreeEntry::CurrentWorktree, + ThreadWorktreeEntry::NewWorktree, + ]; + + if !linked_worktrees.is_empty() { + initial_matches.push(ThreadWorktreeEntry::Separator); + for worktree in &linked_worktrees { + initial_matches.push(ThreadWorktreeEntry::LinkedWorktree { + worktree: worktree.clone(), + positions: Vec::new(), + }); + } + } + + let selected_index = match current_target { + StartThreadIn::LocalProject => 0, + StartThreadIn::NewWorktree { .. } => 1, + StartThreadIn::LinkedWorktree { path, .. } => initial_matches + .iter() + .position(|entry| matches!(entry, ThreadWorktreeEntry::LinkedWorktree { worktree, .. } if worktree.path == *path)) + .unwrap_or(0), + }; + + let delegate = ThreadWorktreePickerDelegate { + matches: initial_matches, + all_worktrees, project_worktree_paths, - selected_index: match current_target { - StartThreadIn::LocalProject => 0, - StartThreadIn::NewWorktree { .. } => 1, - _ => 0, - }, - project: project.clone(), + selected_index, + project, preserved_branch_target, fs, }; @@ -111,6 +149,7 @@ impl Render for ThreadWorktreePicker { enum ThreadWorktreeEntry { CurrentWorktree, NewWorktree, + Separator, LinkedWorktree { worktree: GitWorktree, positions: Vec, @@ -139,7 +178,11 @@ impl ThreadWorktreePickerDelegate { } } - fn sync_selected_index(&mut self) { + fn sync_selected_index(&mut self, has_query: bool) { + if !has_query { + return; + } + if let Some(index) = self .matches .iter() @@ -159,7 +202,7 @@ impl ThreadWorktreePickerDelegate { } impl PickerDelegate for ThreadWorktreePickerDelegate { - type ListItem = ListItem; + type ListItem = AnyElement; fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc { "Search or create worktrees…".into() @@ -186,12 +229,8 @@ impl PickerDelegate for ThreadWorktreePickerDelegate { self.selected_index = ix; } - fn separators_after_indices(&self) -> Vec { - if self.matches.len() > 2 { - vec![1] - } else { - Vec::new() - } + fn can_select(&self, ix: usize, _window: &mut Window, _cx: &mut Context>) -> bool { + !matches!(self.matches.get(ix), Some(ThreadWorktreeEntry::Separator)) } fn update_matches( @@ -238,6 +277,9 @@ impl PickerDelegate for ThreadWorktreePickerDelegate { ]; if query.is_empty() { + if !linked_worktrees.is_empty() { + matches.push(ThreadWorktreeEntry::Separator); + } for worktree in &linked_worktrees { matches.push(ThreadWorktreeEntry::LinkedWorktree { worktree: worktree.clone(), @@ -245,6 +287,7 @@ impl PickerDelegate for ThreadWorktreePickerDelegate { }); } } else if linked_worktrees.is_empty() { + matches.push(ThreadWorktreeEntry::Separator); matches.push(ThreadWorktreeEntry::CreateNamed { name: normalized_query, disabled_reason: create_named_disabled_reason, @@ -283,6 +326,12 @@ impl PickerDelegate for ThreadWorktreePickerDelegate { ThreadWorktreeEntry::NewWorktree, ]; + let has_extra_entries = !fuzzy_matches.is_empty(); + + if has_extra_entries { + new_matches.push(ThreadWorktreeEntry::Separator); + } + for candidate in &fuzzy_matches { new_matches.push(ThreadWorktreeEntry::LinkedWorktree { worktree: linked_worktrees_clone[candidate.candidate_id].clone(), @@ -295,6 +344,9 @@ impl PickerDelegate for ThreadWorktreePickerDelegate { .any(|worktree| worktree.display_name() == query); if !has_exact_match { + if !has_extra_entries { + new_matches.push(ThreadWorktreeEntry::Separator); + } new_matches.push(ThreadWorktreeEntry::CreateNamed { name: normalized_query.clone(), disabled_reason: create_named_disabled_reason.clone(), @@ -302,7 +354,7 @@ impl PickerDelegate for ThreadWorktreePickerDelegate { } picker.delegate.matches = new_matches; - picker.delegate.sync_selected_index(); + picker.delegate.sync_selected_index(true); cx.notify(); }) @@ -311,7 +363,7 @@ impl PickerDelegate for ThreadWorktreePickerDelegate { } self.matches = matches; - self.sync_selected_index(); + self.sync_selected_index(!query.is_empty()); Task::ready(()) } @@ -322,6 +374,7 @@ impl PickerDelegate for ThreadWorktreePickerDelegate { }; match entry { + ThreadWorktreeEntry::Separator => return, ThreadWorktreeEntry::CurrentWorktree => { if secondary { update_settings_file(self.fs.clone(), cx, |settings, _| { @@ -383,48 +436,73 @@ impl PickerDelegate for ThreadWorktreePickerDelegate { let project = self.project.read(cx); let is_new_worktree_disabled = project.repositories(cx).is_empty() || project.is_via_collab(); - let new_thread_location = AgentSettings::get_global(cx).new_thread_location; - let is_local_default = new_thread_location == NewThreadLocation::LocalProject; - let is_new_worktree_default = new_thread_location == NewThreadLocation::NewWorktree; match entry { - ThreadWorktreeEntry::CurrentWorktree => Some( - ListItem::new("current-worktree") - .inset(true) - .spacing(ListItemSpacing::Sparse) - .toggle_state(selected) - .start_slot(Icon::new(IconName::Folder).color(Color::Muted)) - .child(Label::new("Current Worktree")) - .end_slot(HoldForDefault::new(is_local_default).more_content(false)) - .tooltip(Tooltip::text("Use the current project worktree")), + ThreadWorktreeEntry::Separator => Some( + div() + .py(DynamicSpacing::Base04.rems(cx)) + .child(Divider::horizontal()) + .into_any_element(), ), + ThreadWorktreeEntry::CurrentWorktree => { + let path_label = project.active_repository(cx).map(|repo| { + let path = repo.read(cx).work_directory_abs_path.clone(); + path.compact().to_string_lossy().to_string() + }); + + Some( + ListItem::new("current-worktree") + .inset(true) + .spacing(ListItemSpacing::Sparse) + .toggle_state(selected) + .child( + v_flex() + .min_w_0() + .overflow_hidden() + .child(Label::new("Current Worktree")) + .when_some(path_label, |this, path| { + this.child( + Label::new(path) + .size(LabelSize::Small) + .color(Color::Muted) + .truncate_start(), + ) + }), + ) + .into_any_element(), + ) + } ThreadWorktreeEntry::NewWorktree => { let item = ListItem::new("new-worktree") .inset(true) .spacing(ListItemSpacing::Sparse) .toggle_state(selected) .disabled(is_new_worktree_disabled) - .start_slot( - Icon::new(IconName::Plus).color(if is_new_worktree_disabled { - Color::Disabled - } else { - Color::Muted - }), - ) .child( - Label::new("New Git Worktree").color(if is_new_worktree_disabled { - Color::Disabled - } else { - Color::Default - }), + v_flex() + .min_w_0() + .overflow_hidden() + .child( + Label::new("New Git Worktree") + .when(is_new_worktree_disabled, |this| { + this.color(Color::Disabled) + }), + ) + .child( + Label::new("Get a fresh new worktree") + .size(LabelSize::Small) + .color(Color::Muted), + ), ); - Some(if is_new_worktree_disabled { - item.tooltip(Tooltip::text("Requires a Git repository in the project")) - } else { - item.end_slot(HoldForDefault::new(is_new_worktree_default).more_content(false)) - .tooltip(Tooltip::text("Start a thread in a new Git worktree")) - }) + Some( + if is_new_worktree_disabled { + item.tooltip(Tooltip::text("Requires a Git repository in the project")) + } else { + item + } + .into_any_element(), + ) } ThreadWorktreeEntry::LinkedWorktree { worktree, @@ -437,14 +515,29 @@ impl PickerDelegate for ThreadWorktreePickerDelegate { .copied() .filter(|&pos| pos < first_line.len()) .collect(); + let path = worktree.path.compact(); Some( ListItem::new(SharedString::from(format!("linked-worktree-{ix}"))) .inset(true) .spacing(ListItemSpacing::Sparse) .toggle_state(selected) - .start_slot(Icon::new(IconName::GitWorktree).color(Color::Muted)) - .child(HighlightedLabel::new(first_line.to_owned(), positions).truncate()), + .child( + v_flex() + .min_w_0() + .overflow_hidden() + .child( + HighlightedLabel::new(first_line.to_owned(), positions) + .truncate(), + ) + .child( + Label::new(path.to_string_lossy().to_string()) + .size(LabelSize::Small) + .color(Color::Muted) + .truncate_start(), + ), + ) + .into_any_element(), ) } ThreadWorktreeEntry::CreateNamed { @@ -457,11 +550,6 @@ impl PickerDelegate for ThreadWorktreePickerDelegate { .spacing(ListItemSpacing::Sparse) .toggle_state(selected) .disabled(is_disabled) - .start_slot(Icon::new(IconName::Plus).color(if is_disabled { - Color::Disabled - } else { - Color::Accent - })) .child(Label::new(format!("Create Worktree: \"{name}\"…")).color( if is_disabled { Color::Disabled @@ -470,11 +558,14 @@ impl PickerDelegate for ThreadWorktreePickerDelegate { }, )); - Some(if let Some(reason) = disabled_reason.clone() { - item.tooltip(Tooltip::text(reason)) - } else { - item - }) + Some( + if let Some(reason) = disabled_reason.clone() { + item.tooltip(Tooltip::text(reason)) + } else { + item + } + .into_any_element(), + ) } } } @@ -482,4 +573,49 @@ impl PickerDelegate for ThreadWorktreePickerDelegate { fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option { None } + + fn documentation_aside( + &self, + _window: &mut Window, + cx: &mut Context>, + ) -> Option { + let entry = self.matches.get(self.selected_index)?; + let is_default = match entry { + ThreadWorktreeEntry::CurrentWorktree => { + let new_thread_location = AgentSettings::get_global(cx).new_thread_location; + Some(new_thread_location == NewThreadLocation::LocalProject) + } + ThreadWorktreeEntry::NewWorktree => { + let project = self.project.read(cx); + let is_disabled = project.repositories(cx).is_empty() || project.is_via_collab(); + if is_disabled { + None + } else { + let new_thread_location = AgentSettings::get_global(cx).new_thread_location; + Some(new_thread_location == NewThreadLocation::NewWorktree) + } + } + _ => None, + }?; + + let side = crate::ui::documentation_aside_side(cx); + + Some(DocumentationAside::new( + side, + Rc::new(move |_| { + HoldForDefault::new(is_default) + .more_content(false) + .into_any_element() + }), + )) + } + + fn documentation_aside_index(&self) -> Option { + match self.matches.get(self.selected_index) { + Some(ThreadWorktreeEntry::CurrentWorktree | ThreadWorktreeEntry::NewWorktree) => { + Some(self.selected_index) + } + _ => None, + } + } } diff --git a/crates/agent_ui/src/ui.rs b/crates/agent_ui/src/ui.rs index d43b7e4b043bcd1b155699c5eea3ca695585b94b..d2447e6e4508e7ee25d0a832c4580ad1c67e3bb8 100644 --- a/crates/agent_ui/src/ui.rs +++ b/crates/agent_ui/src/ui.rs @@ -13,3 +13,16 @@ pub use hold_for_default::*; pub use mention_crease::*; pub use model_selector_components::*; pub use undo_reject_toast::*; + +/// Returns the appropriate [`DocumentationSide`] for documentation asides +/// in the agent panel, based on the current dock position. +pub fn documentation_aside_side(cx: &gpui::App) -> ui::DocumentationSide { + use agent_settings::AgentSettings; + use settings::Settings; + use ui::DocumentationSide; + + match AgentSettings::get_global(cx).dock { + settings::DockPosition::Left => DocumentationSide::Right, + settings::DockPosition::Bottom | settings::DockPosition::Right => DocumentationSide::Left, + } +}