Detailed changes
@@ -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<SharedString>,
+ label: SharedString,
+ suffix: Option<SharedString>,
+}
+
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<SharedString> {
+ fn branch_trigger_label(&self, project: &Project, cx: &App) -> Option<StartThreadInLabel> {
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();
@@ -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,
@@ -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<dyn AgentSessionModes>,
@@ -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;
@@ -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<ModelPickerDelegate>;
@@ -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,
@@ -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,
@@ -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<usize>,
- occupied_reason: Option<String>,
},
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<SharedString> {
- 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<SharedString> {
+ 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<SharedString> {
+ 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<str> {
"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<Picker<Self>>) -> 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<Picker<Self>>) {}
- fn separators_after_indices(&self) -> Vec<usize> {
- 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<Picker<Self>>,
+ cx: &mut Context<Picker<Self>>,
) -> Option<Self::ListItem> {
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<usize> =
- 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<SharedString> {
None
}
+
+ fn documentation_aside(
+ &self,
+ _window: &mut Window,
+ cx: &mut Context<Picker<Self>>,
+ ) -> Option<DocumentationAside> {
+ 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<usize> {
+ let entry = self.matches.get(self.selected_index)?;
+ self.entry_aside_text(entry).map(|_| self.selected_index)
+ }
}
@@ -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<usize>,
@@ -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<str> {
"Search or create worktreesβ¦".into()
@@ -186,12 +229,8 @@ impl PickerDelegate for ThreadWorktreePickerDelegate {
self.selected_index = ix;
}
- fn separators_after_indices(&self) -> Vec<usize> {
- if self.matches.len() > 2 {
- vec![1]
- } else {
- Vec::new()
- }
+ fn can_select(&self, ix: usize, _window: &mut Window, _cx: &mut Context<Picker<Self>>) -> 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<SharedString> {
None
}
+
+ fn documentation_aside(
+ &self,
+ _window: &mut Window,
+ cx: &mut Context<Picker<Self>>,
+ ) -> Option<DocumentationAside> {
+ 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<usize> {
+ match self.matches.get(self.selected_index) {
+ Some(ThreadWorktreeEntry::CurrentWorktree | ThreadWorktreeEntry::NewWorktree) => {
+ Some(self.selected_index)
+ }
+ _ => None,
+ }
+ }
}
@@ -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,
+ }
+}