Detailed changes
@@ -1 +1,6 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none"><path stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2" d="M5 3v7M11.5 6a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3ZM4.5 13a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3ZM11 6a5 5 0 0 1-5 5"/></svg>
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M4.5 13C5.32843 13 6 12.3284 6 11.5C6 10.6716 5.32843 10 4.5 10C3.67157 10 3 10.6716 3 11.5C3 12.3284 3.67157 13 4.5 13Z" stroke="#C6CAD0" stroke-width="1.2"/>
+<path d="M11.5 6C12.3284 6 13 5.3284 13 4.5C13 3.6716 12.3284 3 11.5 3C10.6716 3 10 3.6716 10 4.5C10 5.3284 10.6716 6 11.5 6Z" stroke="#C6CAD0" stroke-width="1.2"/>
+<path d="M4.5 10L4.5 3" stroke="#C6CAD0" stroke-width="1.2" stroke-linecap="round"/>
+<path d="M10 4.44133C8.54131 4.44133 7.14236 5.02697 6.11091 6.06943C5.07946 7.11188 4.5 8.52575 4.5 10" stroke="#C6CAD0" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>
@@ -1,7 +0,0 @@
-<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M4.5 14C5.32843 14 6 13.3284 6 12.5C6 11.6716 5.32843 11 4.5 11C3.67157 11 3 11.6716 3 12.5C3 13.3284 3.67157 14 4.5 14Z" stroke="black" stroke-width="1.2"/>
-<path d="M4.5 11V5.5" stroke="black" stroke-width="1.2"/>
-<path d="M4.5 10C4.5 10 4.875 8 6.5 8C7.29195 8 9.00787 8 9.87553 8C10.773 8 11.5 7.32843 11.5 6.5V5.5" stroke="black" stroke-width="1.2"/>
-<path d="M4.5 6C5.32843 6 6 5.32843 6 4.5C6 3.67157 5.32843 3 4.5 3C3.67157 3 3 3.67157 3 4.5C3 5.32843 3.67157 6 4.5 6Z" stroke="black" stroke-width="1.2"/>
-<path d="M11.5 6C12.3284 6 13 5.32843 13 4.5C13 3.67157 12.3284 3 11.5 3C10.6716 3 10 3.67157 10 4.5C10 5.32843 10.6716 6 11.5 6Z" stroke="black" stroke-width="1.2"/>
-</svg>
@@ -1,8 +1,8 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4 2V10" stroke="#C6CAD0" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M12 6C12.5304 6 13.0391 5.78929 13.4142 5.41421C13.7893 5.03914 14 4.53043 14 4C14 3.46957 13.7893 2.96086 13.4142 2.58579C13.0391 2.21071 12.5304 2 12 2C11.4696 2 10.9609 2.21071 10.5858 2.58579C10.2107 2.96086 10 3.46957 10 4C10 4.53043 10.2107 5.03914 10.5858 5.41421C10.9609 5.78929 11.4696 6 12 6Z" stroke="#C6CAD0" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M4 14C4.53043 14 5.03914 13.7893 5.41421 13.4142C5.78929 13.0391 6 12.5304 6 12C6 11.4696 5.78929 10.9609 5.41421 10.5858C5.03914 10.2107 4.53043 10 4 10C3.46957 10 2.96086 10.2107 2.58579 10.5858C2.21071 10.9609 2 11.4696 2 12C2 12.5304 2.21071 13.0391 2.58579 13.4142C2.96086 13.7893 3.46957 14 4 14Z" stroke="#C6CAD0" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M10 4C8.4087 4 6.88258 4.63214 5.75736 5.75736C4.63214 6.88258 4 8.4087 4 10" stroke="#C6CAD0" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M12 10V14" stroke="#C6CAD0" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M14 12H10" stroke="#C6CAD0" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M4 13C4.82843 13 5.5 12.3284 5.5 11.5C5.5 10.6716 4.82843 10 4 10C3.17157 10 2.5 10.6716 2.5 11.5C2.5 12.3284 3.17157 13 4 13Z" stroke="#C6CAD0" stroke-width="1.2"/>
+<path d="M11.5 5.5C12.3284 5.5 13 4.8284 13 4C13 3.1716 12.3284 2.5 11.5 2.5C10.6716 2.5 10 3.1716 10 4C10 4.8284 10.6716 5.5 11.5 5.5Z" stroke="#C6CAD0" stroke-width="1.2"/>
</svg>
@@ -1,7 +1,7 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M7.99567 13.0812C8.93101 13.0812 9.68925 12.3229 9.68925 11.3876C9.68925 10.4522 8.93101 9.694 7.99567 9.694C7.06033 9.694 6.30209 10.4522 6.30209 11.3876C6.30209 12.3229 7.06033 13.0812 7.99567 13.0812Z" stroke="#A9AFBC" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M4.61023 6.30643C5.54557 6.30643 6.30381 5.54819 6.30381 4.61286C6.30381 3.67752 5.54557 2.91928 4.61023 2.91928C3.6749 2.91928 2.91666 3.67752 2.91666 4.61286C2.91666 5.54819 3.6749 6.30643 4.61023 6.30643Z" stroke="#A9AFBC" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M11.3915 6.30643C12.3268 6.30643 13.0851 5.54819 13.0851 4.61286C13.0851 3.67752 12.3268 2.91928 11.3915 2.91928C10.4561 2.91928 9.69791 3.67752 9.69791 4.61286C9.69791 5.54819 10.4561 6.30643 11.3915 6.30643Z" stroke="#A9AFBC" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M11.3889 6.306V7.43505C11.3889 7.77377 11.1631 7.99958 10.8244 7.99958H5.17912C4.8404 7.99958 4.61459 7.77377 4.61459 7.43505V6.306" stroke="#A9AFBC" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="M8 8V9.69358" stroke="#A9AFBC" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M6.81506 7.834L11.3571 3.29195" stroke="#C6CAD0" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M8.53091 9.5509L11.3556 12.3756" stroke="#C6CAD0" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M2.28955 7.834H6.80326" stroke="#C6CAD0" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M11.7743 6.31279V2.87418H8.33571" stroke="#C6CAD0" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M8.33417 12.7932L11.7728 12.7932L11.7728 9.35463" stroke="#C6CAD0" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
@@ -1,11 +1,9 @@
-<svg width="28" height="28" viewBox="0 0 28 28" fill="none" id="svg1378540956_510">
-<g clip-path="url(#svg1378540956_510_clip0_1_1506)" transform="translate(4, 4) scale(0.857)">
-<path d="M17.0547 0.372066H8.52652L-0.00165176 8.90024V17.4284H8.52652V8.90024H17.0547V0.372066Z" fill="#1A1C20"></path>
-<path d="M10.1992 27.6279H18.7274L27.2556 19.0998V10.5716H18.7274V19.0998H10.1992V27.6279Z" fill="#1A1C20"></path>
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<mask id="mask0_4326_1680" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="1" y="1" width="14" height="14">
+<path d="M14.6738 1.32619H1.32619V14.6738H14.6738V1.32619Z" fill="white"/>
+</mask>
+<g mask="url(#mask0_4326_1680)">
+<path d="M9.6781 1.32619H5.50173L1.32536 5.50256V9.67892H5.50173V5.50256H9.6781V1.32619Z" fill="#C6CAD0"/>
+<path d="M6.32088 14.6738H10.4973L14.6736 10.4974V6.32104H10.4973V10.4974H6.32088V14.6738Z" fill="#C6CAD0"/>
</g>
-<defs>
-<clipPath id="svg1378540956_510_clip0_1_1506">
-<rect width="27.2559" height="27.2559" fill="white" transform="translate(0 0.37207)"></rect>
-</clipPath>
-</defs>
</svg>
@@ -1 +1,5 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-list-filter-icon lucide-list-filter"><path d="M3 6h18"/><path d="M7 12h10"/><path d="M10 18h4"/></svg>
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M2 4H14" stroke="#C6CAD0" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M4.66666 8H11.3333" stroke="#C6CAD0" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M6.66666 12H9.33332" stroke="#C6CAD0" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>
@@ -237,7 +237,7 @@
"shift-alt-j": "agent::ToggleNavigationMenu",
"shift-alt-i": "agent::ToggleOptionsMenu",
"ctrl-alt-shift-n": "agent::ToggleNewThreadMenu",
- "ctrl-shift-t": "agent::CycleStartThreadIn",
+ "ctrl-shift-t": "agent::ToggleWorktreeSelector",
"shift-alt-escape": "agent::ExpandMessageEditor",
"ctrl->": "agent::AddSelectionToThread",
"ctrl-shift-e": "project_panel::ToggleFocus",
@@ -275,7 +275,7 @@
"cmd-shift-j": "agent::ToggleNavigationMenu",
"cmd-alt-m": "agent::ToggleOptionsMenu",
"cmd-alt-shift-n": "agent::ToggleNewThreadMenu",
- "cmd-shift-t": "agent::CycleStartThreadIn",
+ "cmd-shift-t": "agent::ToggleWorktreeSelector",
"shift-alt-escape": "agent::ExpandMessageEditor",
"cmd->": "agent::AddSelectionToThread",
"cmd-shift-e": "project_panel::ToggleFocus",
@@ -238,7 +238,7 @@
"shift-alt-j": "agent::ToggleNavigationMenu",
"shift-alt-i": "agent::ToggleOptionsMenu",
"ctrl-shift-alt-n": "agent::ToggleNewThreadMenu",
- "ctrl-shift-t": "agent::CycleStartThreadIn",
+ "ctrl-shift-t": "agent::ToggleWorktreeSelector",
"shift-alt-escape": "agent::ExpandMessageEditor",
"ctrl-shift-.": "agent::AddSelectionToThread",
"ctrl-shift-e": "project_panel::ToggleFocus",
@@ -32,13 +32,13 @@ use zed_actions::{
use crate::DEFAULT_THREAD_TITLE;
use crate::thread_metadata_store::{ThreadId, ThreadMetadata, ThreadMetadataStore};
use crate::{
- AddContextServer, AgentDiffPane, ConversationView, CopyThreadToClipboard, CycleStartThreadIn,
+ AddContextServer, AgentDiffPane, ConversationView, CopyThreadToClipboard, CreateWorktree,
Follow, InlineAssistant, LoadThreadFromClipboard, NewThread, NewWorktreeBranchTarget,
OpenActiveThreadAsMarkdown, OpenAgentDiff, OpenHistory, ResetTrialEndUpsell, ResetTrialUpsell,
- StartThreadIn, ToggleNavigationMenu, ToggleNewThreadMenu, ToggleOptionsMenu,
+ SwitchWorktree, ToggleNavigationMenu, ToggleNewThreadMenu, ToggleOptionsMenu,
+ ToggleWorktreeSelector,
agent_configuration::{AgentConfiguration, AssistantConfigurationEvent},
conversation_view::{AcpThreadViewEvent, ThreadView},
- thread_branch_picker::ThreadBranchPicker,
thread_worktree_picker::ThreadWorktreePicker,
ui::EndTrialUpsell,
};
@@ -67,7 +67,6 @@ use gpui::{
};
use language::LanguageRegistry;
use language_model::LanguageModelRegistry;
-use project::git_store::{GitStoreEvent, RepositoryEvent};
use project::project_settings::ProjectSettings;
use project::{Project, ProjectPath, Worktree, WorktreePaths, linked_worktree_short_name};
use prompt_store::{PromptStore, UserPromptId};
@@ -80,13 +79,13 @@ use terminal::terminal_settings::TerminalSettings;
use terminal_view::{TerminalView, terminal_panel::TerminalPanel};
use theme_settings::ThemeSettings;
use ui::{
- Button, ButtonLike, Callout, CommonAnimationExt, ContextMenu, ContextMenuEntry, PopoverMenu,
- PopoverMenuHandle, Tab, Tooltip, prelude::*, utils::WithRemSize,
+ Button, Callout, ContextMenu, ContextMenuEntry, PopoverMenu, PopoverMenuHandle, Tab, Tooltip,
+ prelude::*, utils::WithRemSize,
};
use util::{ResultExt as _, debug_panic};
use workspace::{
- CollaboratorId, DraggedSelection, DraggedTab, PathList, SerializedPathList,
- ToggleWorkspaceSidebar, ToggleZoom, Workspace, WorkspaceId,
+ CollaboratorId, DockStructure, DraggedSelection, DraggedTab, OpenMode, PathList,
+ SerializedPathList, ToggleWorkspaceSidebar, ToggleZoom, Workspace, WorkspaceId,
dock::{DockPosition, Panel, PanelEvent},
};
@@ -170,8 +169,6 @@ struct SerializedAgentPanel {
selected_agent: Option<Agent>,
#[serde(default)]
last_active_thread: Option<SerializedActiveThread>,
- #[serde(default)]
- start_thread_in: Option<StartThreadIn>,
}
#[derive(Serialize, Deserialize, Debug)]
@@ -279,6 +276,14 @@ pub fn init(cx: &mut App) {
});
}
})
+ .register_action(|workspace, _: &ToggleWorktreeSelector, window, cx| {
+ if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
+ workspace.focus_panel::<AgentPanel>(window, cx);
+ panel.update(cx, |panel, cx| {
+ panel.toggle_worktree_selector(&ToggleWorktreeSelector, window, cx);
+ });
+ }
+ })
.register_action(|_workspace, _: &ResetOnboarding, window, cx| {
window.dispatch_action(workspace::RestoreBanner.boxed_clone(), cx);
window.refresh();
@@ -417,20 +422,6 @@ pub fn init(cx: &mut App) {
});
},
)
- .register_action(|workspace, action: &StartThreadIn, window, cx| {
- if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
- panel.update(cx, |panel, cx| {
- panel.set_start_thread_in(action, window, cx);
- });
- }
- })
- .register_action(|workspace, _: &CycleStartThreadIn, window, cx| {
- if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
- panel.update(cx, |panel, cx| {
- panel.cycle_start_thread_in(window, cx);
- });
- }
- })
.register_action(
|workspace: &mut Workspace, _: &AddSelectionToThread, window, cx| {
let active_editor = workspace
@@ -493,6 +484,28 @@ pub fn init(cx: &mut App) {
});
});
},
+ )
+ .register_action(
+ |workspace: &mut Workspace, action: &CreateWorktree, window, cx| {
+ let previous_state =
+ AgentPanel::capture_workspace_state(workspace, window, cx);
+ if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
+ panel.update(cx, |panel, cx| {
+ panel.create_worktree(action, previous_state, window, cx);
+ });
+ }
+ },
+ )
+ .register_action(
+ |workspace: &mut Workspace, action: &SwitchWorktree, window, cx| {
+ let previous_state =
+ AgentPanel::capture_workspace_state(workspace, window, cx);
+ if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
+ panel.update(cx, |panel, cx| {
+ panel.switch_to_worktree(action, previous_state, window, cx);
+ });
+ }
+ },
);
},
)
@@ -642,124 +655,10 @@ enum WhichFontSize {
None,
}
-struct StartThreadInLabel {
- prefix: Option<SharedString>,
- label: SharedString,
- suffix: Option<SharedString>,
-}
-
-impl StartThreadIn {
- fn trigger_label(&self, project: &Project, cx: &App) -> StartThreadInLabel {
- match self {
- Self::LocalProject => {
- let suffix = project.active_repository(cx).and_then(|repo| {
- let repo = repo.read(cx);
- let work_dir = &repo.original_repo_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 let Some(name) = linked_worktree_short_name(
- repo.original_repo_abs_path.as_ref(),
- repo.work_directory_abs_path.as_ref(),
- ) {
- if visible_paths
- .iter()
- .any(|p| p.as_path() == repo.work_directory_abs_path.as_ref())
- {
- return Some(SharedString::from(format!("({})", 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),
- ..
- } => 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 branch_trigger_label(&self, project: &Project, cx: &App) -> Option<StartThreadInLabel> {
- match self {
- Self::NewWorktree { branch_target, .. } => {
- let label: SharedString = match branch_target {
- NewWorktreeBranchTarget::CurrentBranch => {
- 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())
- }
- }
- NewWorktreeBranchTarget::ExistingBranch { name } => name.clone().into(),
- NewWorktreeBranchTarget::CreateBranch {
- from_ref: Some(from_ref),
- ..
- } => from_ref.clone().into(),
- NewWorktreeBranchTarget::CreateBranch { name, .. } => name.clone().into(),
- };
-
- Some(StartThreadInLabel {
- prefix: None,
- label,
- suffix: None,
- })
- }
- _ => None,
- }
- }
-}
-
#[derive(Clone, Debug)]
-#[allow(dead_code)]
pub enum WorktreeCreationStatus {
- Creating,
+ Creating(SharedString),
+ Loading(SharedString),
Error(SharedString),
}
@@ -771,9 +670,46 @@ enum WorktreeCreationArgs {
},
Linked {
worktree_path: PathBuf,
+ display_name: String,
},
}
+struct PreviousWorkspaceState {
+ dock_structure: DockStructure,
+ open_file_paths: Vec<PathBuf>,
+ active_file_path: Option<PathBuf>,
+}
+
+#[cfg(test)]
+impl PreviousWorkspaceState {
+ /// An empty state with all docks hidden and no open files.
+ fn empty() -> Self {
+ use workspace::DockData;
+
+ Self {
+ dock_structure: DockStructure {
+ left: DockData {
+ visible: false,
+ active_panel: None,
+ zoom: false,
+ },
+ right: DockData {
+ visible: false,
+ active_panel: None,
+ zoom: false,
+ },
+ bottom: DockData {
+ visible: false,
+ active_panel: None,
+ zoom: false,
+ },
+ },
+ open_file_paths: Vec::new(),
+ active_file_path: None,
+ }
+ }
+}
+
impl BaseView {
pub fn which_font_size_used(&self) -> WhichFontSize {
WhichFontSize::AgentFont
@@ -809,13 +745,11 @@ pub struct AgentPanel {
retained_threads: HashMap<ThreadId, Entity<ConversationView>>,
new_thread_menu_handle: PopoverMenuHandle<ContextMenu>,
start_thread_in_menu_handle: PopoverMenuHandle<ThreadWorktreePicker>,
- thread_branch_menu_handle: PopoverMenuHandle<ThreadBranchPicker>,
agent_panel_menu_handle: PopoverMenuHandle<ContextMenu>,
agent_navigation_menu_handle: PopoverMenuHandle<ContextMenu>,
agent_navigation_menu: Option<Entity<ContextMenu>>,
_extension_subscription: Option<Subscription>,
_project_subscription: Subscription,
- _git_store_subscription: Subscription,
zoomed: bool,
pending_serialization: Option<Task<Result<()>>>,
new_user_onboarding: Entity<AgentPanelOnboarding>,
@@ -823,7 +757,6 @@ pub struct AgentPanel {
agent_layout_onboarding: Entity<ai_onboarding::AgentLayoutOnboarding>,
agent_layout_onboarding_dismissed: AtomicBool,
selected_agent: Agent,
- start_thread_in: StartThreadIn,
pending_thread_loads: usize,
worktree_creation_status: Option<(EntityId, WorktreeCreationStatus)>,
_thread_view_subscription: Option<Subscription>,
@@ -840,7 +773,6 @@ impl AgentPanel {
};
let selected_agent = self.selected_agent.clone();
- let start_thread_in = Some(self.start_thread_in.clone());
let last_active_thread = self.active_agent_thread(cx).map(|thread| {
let thread = thread.read(cx);
@@ -861,7 +793,6 @@ impl AgentPanel {
SerializedAgentPanel {
selected_agent: Some(selected_agent),
last_active_thread,
- start_thread_in,
},
kvp,
)
@@ -929,8 +860,7 @@ impl AgentPanel {
};
let panel = workspace.update_in(cx, |workspace, window, cx| {
- let panel =
- cx.new(|cx| Self::new(workspace, prompt_store, window, cx));
+ let panel = cx.new(|cx| Self::new(workspace, prompt_store, window, cx));
panel.update(cx, |panel, cx| {
let is_via_collab = panel.project.read(cx).is_via_collab();
@@ -939,8 +869,8 @@ impl AgentPanel {
// Collab workspaces only support NativeAgent, so inheriting a
// custom agent would cause set_active β new_agent_thread_inner
// to bypass the collab guard in external_thread.
- let global_fallback = global_last_used_agent
- .filter(|agent| !is_via_collab || agent.is_native());
+ let global_fallback =
+ global_last_used_agent.filter(|agent| !is_via_collab || agent.is_native());
if let Some(serialized_panel) = &serialized_panel {
if let Some(selected_agent) = serialized_panel.selected_agent.clone() {
@@ -948,26 +878,6 @@ impl AgentPanel {
} else if let Some(agent) = global_fallback {
panel.selected_agent = agent;
}
- if let Some(ref start_thread_in) = serialized_panel.start_thread_in {
- let is_valid = match &start_thread_in {
- StartThreadIn::LocalProject => true,
- StartThreadIn::NewWorktree { .. } => {
- let project = panel.project.read(cx);
- agent_v2_enabled(cx) && !project.is_via_collab()
- }
- StartThreadIn::LinkedWorktree { path, .. } => {
- agent_v2_enabled(cx) && path.exists()
- }
- };
- if is_valid {
- panel.start_thread_in = start_thread_in.clone();
- } else {
- log::info!(
- "deserialized start_thread_in {:?} is no longer valid, falling back to LocalProject",
- start_thread_in,
- );
- }
- }
} else if let Some(agent) = global_fallback {
panel.selected_agent = agent;
}
@@ -981,7 +891,10 @@ impl AgentPanel {
panel.load_agent_thread(
agent,
thread_info.session_id.clone().into(),
- thread_info.work_dirs.as_ref().map(|dirs| PathList::deserialize(dirs)),
+ thread_info
+ .work_dirs
+ .as_ref()
+ .map(|dirs| PathList::deserialize(dirs)),
thread_info.title.as_ref().map(|t| t.clone().into()),
false,
window,
@@ -1152,27 +1065,6 @@ impl AgentPanel {
}
_ => {}
});
- let git_store = project.read(cx).git_store().clone();
- let _git_store_subscription = cx.subscribe(&git_store, |this, _, event, cx| {
- let should_sync = matches!(
- event,
- GitStoreEvent::ActiveRepositoryChanged(_)
- | GitStoreEvent::RepositoryAdded
- | GitStoreEvent::RepositoryRemoved(_)
- | GitStoreEvent::RepositoryUpdated(
- _,
- RepositoryEvent::HeadChanged
- | RepositoryEvent::BranchListChanged
- | RepositoryEvent::GitWorktreeListChanged,
- _,
- )
- );
-
- if should_sync {
- this.sync_start_thread_in_with_git_state(cx);
- }
- });
-
let mut panel = Self {
workspace_id,
base_view,
@@ -1191,20 +1083,17 @@ impl AgentPanel {
retained_threads: HashMap::default(),
new_thread_menu_handle: PopoverMenuHandle::default(),
start_thread_in_menu_handle: PopoverMenuHandle::default(),
- thread_branch_menu_handle: PopoverMenuHandle::default(),
agent_panel_menu_handle: PopoverMenuHandle::default(),
agent_navigation_menu_handle: PopoverMenuHandle::default(),
agent_navigation_menu: None,
_extension_subscription: extension_subscription,
_project_subscription,
- _git_store_subscription,
zoomed: false,
pending_serialization: None,
new_user_onboarding: onboarding,
agent_layout_onboarding,
thread_store,
selected_agent: Agent::default(),
- start_thread_in: StartThreadIn::default(),
pending_thread_loads: 0,
worktree_creation_status: None,
_thread_view_subscription: None,
@@ -1715,6 +1604,15 @@ impl AgentPanel {
self.new_thread_menu_handle.toggle(window, cx);
}
+ pub fn toggle_worktree_selector(
+ &mut self,
+ _: &ToggleWorktreeSelector,
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ ) {
+ self.start_thread_in_menu_handle.toggle(window, cx);
+ }
+
pub fn increase_font_size(
&mut self,
action: &IncreaseBufferFontSize,
@@ -2391,10 +2289,7 @@ impl AgentPanel {
cx.subscribe_in(
&tv,
window,
- |this, view, event: &AcpThreadViewEvent, window, cx| match event {
- AcpThreadViewEvent::FirstSendRequested { content } => {
- this.handle_first_send_requested(view.clone(), content.clone(), window, cx);
- }
+ |this, _view, event: &AcpThreadViewEvent, _window, cx| match event {
AcpThreadViewEvent::MessageSentOrQueued => {
let Some(thread_id) = this.active_thread_id(cx) else {
return;
@@ -2407,223 +2302,6 @@ impl AgentPanel {
})
}
- pub fn start_thread_in(&self) -> &StartThreadIn {
- &self.start_thread_in
- }
-
- fn set_start_thread_in(
- &mut self,
- action: &StartThreadIn,
- window: &mut Window,
- cx: &mut Context<Self>,
- ) {
- let new_target = match action {
- StartThreadIn::LocalProject => StartThreadIn::LocalProject,
- StartThreadIn::NewWorktree { .. } => {
- if !agent_v2_enabled(cx) {
- return;
- }
- if !self.project_has_git_repository(cx) {
- log::error!(
- "set_start_thread_in: cannot use worktree mode without a git repository"
- );
- return;
- }
- if self.project.read(cx).is_via_collab() {
- log::error!(
- "set_start_thread_in: cannot use worktree mode in a collab project"
- );
- return;
- }
- action.clone()
- }
- StartThreadIn::LinkedWorktree { .. } => {
- if !agent_v2_enabled(cx) {
- return;
- }
- if !self.project_has_git_repository(cx) {
- log::error!(
- "set_start_thread_in: cannot use LinkedWorktree without a git repository"
- );
- return;
- }
- if self.project.read(cx).is_via_collab() {
- log::error!(
- "set_start_thread_in: cannot use LinkedWorktree in a collab project"
- );
- return;
- }
- action.clone()
- }
- };
- self.start_thread_in = new_target;
- if let Some(thread) = self.active_thread_view(cx) {
- thread.update(cx, |thread, cx| thread.focus_handle(cx).focus(window, cx));
- }
- self.serialize(cx);
- cx.notify();
- }
-
- fn cycle_start_thread_in(&mut self, window: &mut Window, cx: &mut Context<Self>) {
- if !agent_v2_enabled(cx) {
- return;
- }
-
- let next = match &self.start_thread_in {
- StartThreadIn::LocalProject => StartThreadIn::NewWorktree {
- worktree_name: None,
- branch_target: NewWorktreeBranchTarget::default(),
- },
- StartThreadIn::NewWorktree { .. } | StartThreadIn::LinkedWorktree { .. } => {
- StartThreadIn::LocalProject
- }
- };
- self.set_start_thread_in(&next, window, cx);
- }
-
- fn reset_start_thread_in_to_default(&mut self, cx: &mut Context<Self>) {
- use settings::{NewThreadLocation, Settings};
- if !agent_v2_enabled(cx) {
- if self.start_thread_in != StartThreadIn::LocalProject {
- self.start_thread_in = StartThreadIn::LocalProject;
- self.serialize(cx);
- cx.notify();
- }
- return;
- }
-
- let default = AgentSettings::get_global(cx).new_thread_location;
- let start_thread_in = match default {
- NewThreadLocation::LocalProject => StartThreadIn::LocalProject,
- NewThreadLocation::NewWorktree => {
- if self.project_has_git_repository(cx) {
- StartThreadIn::NewWorktree {
- worktree_name: None,
- branch_target: NewWorktreeBranchTarget::default(),
- }
- } else {
- StartThreadIn::LocalProject
- }
- }
- };
- if self.start_thread_in != start_thread_in {
- self.start_thread_in = start_thread_in;
- self.serialize(cx);
- cx.notify();
- }
- }
-
- fn sync_start_thread_in_with_git_state(&mut self, cx: &mut Context<Self>) {
- if !agent_v2_enabled(cx) {
- if self.start_thread_in != StartThreadIn::LocalProject {
- self.start_thread_in = StartThreadIn::LocalProject;
- self.serialize(cx);
- cx.notify();
- }
- return;
- }
-
- if matches!(self.start_thread_in, StartThreadIn::LocalProject) {
- return;
- }
-
- let visible_worktree_paths: Vec<_> = self
- .project
- .read(cx)
- .visible_worktrees(cx)
- .map(|worktree| worktree.read(cx).abs_path().to_path_buf())
- .collect();
- let repositories = self.project.read(cx).repositories(cx);
- let linked_worktrees = if repositories.len() > 1 {
- Vec::new()
- } else {
- repositories
- .values()
- .flat_map(|repo| repo.read(cx).linked_worktrees().iter().cloned())
- .filter(|worktree| !visible_worktree_paths.contains(&worktree.path))
- .collect::<Vec<_>>()
- };
-
- let updated_start_thread_in = match &self.start_thread_in {
- StartThreadIn::NewWorktree {
- worktree_name: Some(worktree_name),
- branch_target,
- } => {
- let normalized_worktree_name = worktree_name.replace(' ', "-");
- linked_worktrees
- .iter()
- .find(|worktree| {
- worktree.display_name() == normalized_worktree_name
- && self.linked_worktree_matches_branch_target(
- worktree,
- branch_target,
- cx,
- )
- })
- .map(|worktree| StartThreadIn::LinkedWorktree {
- path: worktree.path.clone(),
- display_name: worktree.display_name().to_string(),
- })
- }
- StartThreadIn::LinkedWorktree { path, .. } => linked_worktrees
- .iter()
- .find(|worktree| worktree.path == *path)
- .map(|worktree| StartThreadIn::LinkedWorktree {
- path: worktree.path.clone(),
- display_name: worktree.display_name().to_string(),
- })
- .or(Some(StartThreadIn::LocalProject)),
- _ => None,
- };
-
- if let Some(updated_start_thread_in) = updated_start_thread_in {
- if self.start_thread_in != updated_start_thread_in {
- self.start_thread_in = updated_start_thread_in;
- self.serialize(cx);
- }
- cx.notify();
- }
- }
-
- fn linked_worktree_matches_branch_target(
- &self,
- worktree: &git::repository::Worktree,
- branch_target: &NewWorktreeBranchTarget,
- cx: &App,
- ) -> bool {
- let active_repository = self.project.read(cx).active_repository(cx);
- let current_branch_name = active_repository.as_ref().and_then(|repo| {
- repo.read(cx)
- .branch
- .as_ref()
- .map(|branch| branch.name().to_string())
- });
- let existing_branch_names = active_repository
- .as_ref()
- .map(|repo| {
- repo.read(cx)
- .branch_list
- .iter()
- .map(|branch| branch.name().to_string())
- .collect::<HashSet<_>>()
- })
- .unwrap_or_default();
-
- match branch_target {
- NewWorktreeBranchTarget::CurrentBranch => {
- current_branch_name.as_deref() == worktree.branch_name()
- }
- NewWorktreeBranchTarget::ExistingBranch { name } => {
- existing_branch_names.contains(name)
- && worktree.branch_name() == Some(name.as_str())
- }
- NewWorktreeBranchTarget::CreateBranch { name, .. } => {
- !existing_branch_names.contains(name)
- && worktree.branch_name() == Some(name.as_str())
- }
- }
- }
-
pub(crate) fn selected_agent(&self) -> Option<Agent> {
Some(self.selected_agent.clone())
}
@@ -2672,7 +2350,6 @@ impl AgentPanel {
}
pub fn new_agent_thread(&mut self, agent: Agent, window: &mut Window, cx: &mut Context<Self>) {
- self.reset_start_thread_in_to_default(cx);
self.new_agent_thread_inner(agent, true, window, cx);
}
@@ -2912,49 +2589,6 @@ impl AgentPanel {
self.active_conversation_view().is_some() && !self.active_thread_has_messages(cx)
}
- fn handle_first_send_requested(
- &mut self,
- thread_view: Entity<ThreadView>,
- content: Vec<acp::ContentBlock>,
- window: &mut Window,
- cx: &mut Context<Self>,
- ) {
- match &self.start_thread_in {
- StartThreadIn::NewWorktree {
- worktree_name,
- branch_target,
- } => {
- self.handle_worktree_requested(
- content,
- WorktreeCreationArgs::New {
- worktree_name: worktree_name.clone(),
- branch_target: branch_target.clone(),
- },
- window,
- cx,
- );
- }
- StartThreadIn::LinkedWorktree { path, .. } => {
- self.handle_worktree_requested(
- content,
- WorktreeCreationArgs::Linked {
- worktree_path: path.clone(),
- },
- window,
- cx,
- );
- }
- StartThreadIn::LocalProject => {
- cx.defer_in(window, move |_this, window, cx| {
- thread_view.update(cx, |thread_view, cx| {
- let editor = thread_view.message_editor.clone();
- thread_view.send_impl(editor, window, cx);
- });
- });
- }
- }
- }
-
// TODO: The mapping from workspace root paths to git repositories needs a
// unified approach across the codebase: this method, `sidebar::is_root_repo`,
// thread persistence (which PathList is saved to the database), and thread
@@ -3288,42 +2922,163 @@ impl AgentPanel {
}
}
- fn set_worktree_creation_error(
+ fn capture_workspace_state(
+ workspace: &Workspace,
+ window: &Window,
+ cx: &App,
+ ) -> PreviousWorkspaceState {
+ let dock_structure = workspace.capture_dock_state(window, cx);
+ let open_file_paths = workspace.open_item_abs_paths(cx);
+ let active_file_path = workspace
+ .active_item(cx)
+ .and_then(|item| item.project_path(cx))
+ .and_then(|pp| workspace.project().read(cx).absolute_path(&pp, cx));
+
+ PreviousWorkspaceState {
+ dock_structure,
+ open_file_paths,
+ active_file_path,
+ }
+ }
+
+ fn create_worktree(
&mut self,
- message: SharedString,
+ action: &CreateWorktree,
+ previous_workspace_state: PreviousWorkspaceState,
window: &mut Window,
cx: &mut Context<Self>,
) {
- if let Some((_, status)) = &mut self.worktree_creation_status {
- *status = WorktreeCreationStatus::Error(message);
+ if !self.project_has_git_repository(cx) {
+ log::error!("create_worktree: no git repository in the project");
+ return;
}
- if matches!(self.base_view, BaseView::Uninitialized) {
- let selected_agent = self.selected_agent.clone();
- self.new_agent_thread(selected_agent, window, cx);
+ if self.project.read(cx).is_via_collab() {
+ log::error!("create_worktree: not supported in collab projects");
+ return;
}
- cx.notify();
+ if matches!(
+ self.worktree_creation_status,
+ Some((
+ _,
+ WorktreeCreationStatus::Creating(_) | WorktreeCreationStatus::Loading(_)
+ ))
+ ) {
+ return;
+ }
+
+ let content = self.take_active_initial_content(cx);
+ let content_blocks = match content {
+ Some(AgentInitialContent::ContentBlock { blocks, .. }) => blocks,
+ _ => Vec::new(),
+ };
+
+ self.handle_worktree_requested(
+ content_blocks,
+ WorktreeCreationArgs::New {
+ worktree_name: action.worktree_name.clone(),
+ branch_target: action.branch_target.clone(),
+ },
+ previous_workspace_state,
+ window,
+ cx,
+ );
}
- fn handle_worktree_requested(
+ fn switch_to_worktree(
&mut self,
- content: Vec<acp::ContentBlock>,
- args: WorktreeCreationArgs,
+ action: &SwitchWorktree,
+ previous_workspace_state: PreviousWorkspaceState,
window: &mut Window,
cx: &mut Context<Self>,
) {
+ if !self.project_has_git_repository(cx) {
+ log::error!("switch_to_worktree: no git repository in the project");
+ return;
+ }
+ if self.project.read(cx).is_via_collab() {
+ log::error!("switch_to_worktree: not supported in collab projects");
+ return;
+ }
if matches!(
self.worktree_creation_status,
- Some((_, WorktreeCreationStatus::Creating))
+ Some((
+ _,
+ WorktreeCreationStatus::Creating(_) | WorktreeCreationStatus::Loading(_)
+ ))
) {
return;
}
- let conversation_view_id = self
+ let content = self.take_active_initial_content(cx);
+ let content_blocks = match content {
+ Some(AgentInitialContent::ContentBlock { blocks, .. }) => blocks,
+ _ => Vec::new(),
+ };
+
+ self.handle_worktree_requested(
+ content_blocks,
+ WorktreeCreationArgs::Linked {
+ worktree_path: action.path.clone(),
+ display_name: action.display_name.clone(),
+ },
+ previous_workspace_state,
+ window,
+ cx,
+ );
+ }
+
+ fn set_worktree_creation_error(
+ &mut self,
+ message: SharedString,
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ ) {
+ if let Some((_, status)) = &mut self.worktree_creation_status {
+ *status = WorktreeCreationStatus::Error(message);
+ }
+ if matches!(self.base_view, BaseView::Uninitialized) {
+ let selected_agent = self.selected_agent.clone();
+ self.new_agent_thread(selected_agent, window, cx);
+ }
+ cx.notify();
+ }
+
+ fn handle_worktree_requested(
+ &mut self,
+ content: Vec<acp::ContentBlock>,
+ args: WorktreeCreationArgs,
+ previous_workspace_state: PreviousWorkspaceState,
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ ) {
+ if matches!(
+ self.worktree_creation_status,
+ Some((
+ _,
+ WorktreeCreationStatus::Creating(_) | WorktreeCreationStatus::Loading(_)
+ ))
+ ) {
+ return;
+ }
+
+ let conversation_view_id = self
.active_conversation_view()
.map(|v| v.entity_id())
.unwrap_or_else(|| EntityId::from(0u64));
- self.worktree_creation_status =
- Some((conversation_view_id, WorktreeCreationStatus::Creating));
+ let display_name: SharedString = match &args {
+ WorktreeCreationArgs::New {
+ worktree_name: Some(name),
+ ..
+ } => name.clone().into(),
+ WorktreeCreationArgs::New { .. } => "worktree".into(),
+ WorktreeCreationArgs::Linked { display_name, .. } => display_name.clone().into(),
+ };
+ let status = if matches!(args, WorktreeCreationArgs::Linked { .. }) {
+ WorktreeCreationStatus::Loading(display_name)
+ } else {
+ WorktreeCreationStatus::Creating(display_name)
+ };
+ self.worktree_creation_status = Some((conversation_view_id, status));
cx.notify();
let (git_repos, non_git_paths) = self.classify_worktrees(cx);
@@ -3357,16 +3112,6 @@ impl AgentPanel {
(None, None)
};
- let active_file_path = self.workspace.upgrade().and_then(|workspace| {
- let workspace = workspace.read(cx);
- let active_item = workspace.active_item(cx)?;
- let project_path = active_item.project_path(cx)?;
- workspace
- .project()
- .read(cx)
- .absolute_path(&project_path, cx)
- });
-
let remote_connection_options = self.project.read(cx).remote_connection_options(cx);
if remote_connection_options.is_some() {
@@ -3392,6 +3137,11 @@ impl AgentPanel {
let selected_agent = self.selected_agent();
+ let git_repo_work_dirs: Vec<PathBuf> = git_repos
+ .iter()
+ .map(|repo| repo.read(cx).work_directory_abs_path.to_path_buf())
+ .collect();
+
let task = cx.spawn_in(window, async move |this, cx| {
let (all_paths, path_remapping, has_non_git) = match args {
WorktreeCreationArgs::New {
@@ -3500,11 +3250,15 @@ impl AgentPanel {
all_paths.extend(non_git_paths.iter().cloned());
(all_paths, path_remapping, has_non_git)
}
- WorktreeCreationArgs::Linked { worktree_path } => {
+ WorktreeCreationArgs::Linked { worktree_path, .. } => {
+ let path_remapping: Vec<(PathBuf, PathBuf)> = git_repo_work_dirs
+ .iter()
+ .map(|work_dir| (work_dir.clone(), worktree_path.clone()))
+ .collect();
let mut all_paths = vec![worktree_path];
let has_non_git = !non_git_paths.is_empty();
all_paths.extend(non_git_paths.iter().cloned());
- (all_paths, Vec::new(), has_non_git)
+ (all_paths, path_remapping, has_non_git)
}
};
@@ -3524,7 +3278,7 @@ impl AgentPanel {
this,
all_paths,
window_handle,
- active_file_path,
+ previous_workspace_state,
path_remapping,
non_git_paths,
has_non_git,
@@ -3557,7 +3311,7 @@ impl AgentPanel {
this: WeakEntity<Self>,
all_paths: Vec<PathBuf>,
window_handle: Option<gpui::WindowHandle<workspace::MultiWorkspace>>,
- active_file_path: Option<PathBuf>,
+ previous_workspace_state: PreviousWorkspaceState,
path_remapping: Vec<(PathBuf, PathBuf)>,
non_git_paths: Vec<PathBuf>,
has_non_git: bool,
@@ -3575,6 +3329,15 @@ impl AgentPanel {
let active_workspace = multi_workspace.workspace().clone();
let modal_workspace = active_workspace.clone();
+ let dock_structure = previous_workspace_state.dock_structure;
+ let init = Box::new(
+ move |workspace: &mut Workspace,
+ window: &mut Window,
+ cx: &mut Context<Workspace>| {
+ workspace.set_dock_structure(dock_structure, window, cx);
+ },
+ );
+
let task = multi_workspace.find_or_create_workspace(
path_list,
remote_connection_options,
@@ -27,7 +27,6 @@ mod terminal_codegen;
mod terminal_inline_assistant;
#[cfg(any(test, feature = "test-support"))]
pub mod test_support;
-mod thread_branch_picker;
mod thread_history;
mod thread_history_view;
mod thread_import;
@@ -90,8 +89,8 @@ actions!(
[
/// Toggles the menu to create new agent threads.
ToggleNewThreadMenu,
- /// Cycles through the options for where new threads start (current project or new worktree).
- CycleStartThreadIn,
+ /// Toggles the worktree selector popover for choosing which worktree to use.
+ ToggleWorktreeSelector,
/// Toggles the navigation menu for switching between threads and views.
ToggleNavigationMenu,
/// Toggles the options menu for agent settings and preferences.
@@ -340,23 +339,25 @@ pub enum NewWorktreeBranchTarget {
},
}
-/// Sets where new threads will run.
-#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Action)]
+/// Creates a new git worktree and switches the workspace to it.
+/// Dispatched by the unified worktree picker when the user selects a "Create new worktree" entry.
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Action)]
#[action(namespace = agent)]
-#[serde(rename_all = "snake_case", tag = "kind")]
-pub enum StartThreadIn {
- #[default]
- LocalProject,
- NewWorktree {
- /// When this is None, Zed will randomly generate a worktree name
- /// otherwise, the provided name will be used.
- #[serde(default)]
- worktree_name: Option<String>,
- #[serde(default)]
- branch_target: NewWorktreeBranchTarget,
- },
- /// A linked worktree that already exists on disk.
- LinkedWorktree { path: PathBuf, display_name: String },
+#[serde(deny_unknown_fields)]
+pub struct CreateWorktree {
+ /// When this is None, Zed will randomly generate a worktree name.
+ pub worktree_name: Option<String>,
+ pub branch_target: NewWorktreeBranchTarget,
+}
+
+/// Switches the workspace to an existing linked worktree.
+/// Dispatched by the unified worktree picker when the user selects an existing worktree.
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Action)]
+#[action(namespace = agent)]
+#[serde(deny_unknown_fields)]
+pub struct SwitchWorktree {
+ pub path: PathBuf,
+ pub display_name: String,
}
/// Content to initialize new external agent with.
@@ -9,7 +9,6 @@ use cloud_api_types::{SubmitAgentThreadFeedbackBody, SubmitAgentThreadFeedbackCo
use editor::actions::OpenExcerpts;
use feature_flags::AcpBetaFeatureFlag;
-use crate::StartThreadIn;
use crate::message_editor::SharedSessionCapabilities;
use gpui::{Corner, List};
@@ -207,7 +206,6 @@ impl RenderOnce for GeneratingSpinnerElement {
}
pub enum AcpThreadViewEvent {
- FirstSendRequested { content: Vec<acp::ContentBlock> },
MessageSentOrQueued,
}
@@ -909,49 +907,6 @@ impl ThreadView {
let message_editor = self.message_editor.clone();
- // Intercept the first send so the agent panel can capture the full
- // content blocks β needed for "Start thread in New Worktree",
- // which must create a workspace before sending the message there.
- let intercept_first_send = self.thread.read(cx).entries().is_empty()
- && !message_editor.read(cx).is_empty(cx)
- && self
- .workspace
- .upgrade()
- .and_then(|workspace| workspace.read(cx).panel::<AgentPanel>(cx))
- .is_some_and(|panel| {
- !matches!(
- panel.read(cx).start_thread_in(),
- StartThreadIn::LocalProject
- )
- });
-
- if intercept_first_send {
- cx.emit(AcpThreadViewEvent::MessageSentOrQueued);
- let content_task = self.resolve_message_contents(&message_editor, cx);
-
- cx.spawn(async move |this, cx| match content_task.await {
- Ok((content, _tracked_buffers)) => {
- if content.is_empty() {
- return;
- }
-
- this.update(cx, |_, cx| {
- cx.emit(AcpThreadViewEvent::FirstSendRequested { content });
- })
- .ok();
- }
- Err(error) => {
- this.update(cx, |this, cx| {
- this.handle_thread_error(error, cx);
- })
- .ok();
- }
- })
- .detach();
-
- return;
- }
-
let is_editor_empty = message_editor.read(cx).is_empty(cx);
let is_generating = thread.read(cx).status() != ThreadStatus::Idle;
@@ -1,837 +0,0 @@
-use std::rc::Rc;
-
-use collections::{HashMap, HashSet};
-use std::path::PathBuf;
-use std::sync::Arc;
-
-use fuzzy::StringMatchCandidate;
-use git::repository::{Branch as GitBranch, Worktree as GitWorktree};
-use gpui::{
- AnyElement, App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable,
- IntoElement, ParentElement, Render, SharedString, Styled, Subscription, Task, Window, rems,
-};
-use picker::{Picker, PickerDelegate, PickerEditorPosition};
-use project::Project;
-use project::git_store::RepositoryEvent;
-use ui::{
- Divider, DocumentationAside, HighlightedLabel, Icon, IconName, Label, LabelCommon, ListItem,
- ListItemSpacing, prelude::*,
-};
-use util::ResultExt as _;
-
-use crate::{NewWorktreeBranchTarget, StartThreadIn};
-
-pub(crate) struct ThreadBranchPicker {
- picker: Entity<Picker<ThreadBranchPickerDelegate>>,
- focus_handle: FocusHandle,
- _subscriptions: Vec<Subscription>,
-}
-
-impl ThreadBranchPicker {
- pub fn new(
- project: Entity<Project>,
- current_target: &StartThreadIn,
- window: &mut Window,
- cx: &mut Context<Self>,
- ) -> Self {
- let project_worktree_paths: HashSet<PathBuf> = project
- .read(cx)
- .visible_worktrees(cx)
- .map(|worktree| worktree.read(cx).abs_path().to_path_buf())
- .collect();
-
- let has_multiple_repositories = project.read(cx).repositories(cx).len() > 1;
- let current_branch_name = project
- .read(cx)
- .active_repository(cx)
- .and_then(|repo| {
- repo.read(cx)
- .branch
- .as_ref()
- .map(|branch| branch.name().to_string())
- })
- .unwrap_or_else(|| "HEAD".to_string());
-
- let repository = if has_multiple_repositories {
- None
- } else {
- project.read(cx).active_repository(cx)
- };
-
- let (all_branches, occupied_branches) = repository
- .as_ref()
- .map(|repo| {
- let snapshot = repo.read(cx);
- let branches = process_branches(&snapshot.branch_list);
- let occupied =
- compute_occupied_branches(&snapshot.linked_worktrees, &project_worktree_paths);
- (branches, occupied)
- })
- .unwrap_or_default();
-
- let default_branch_request = repository
- .clone()
- .map(|repo| repo.update(cx, |repo, _| repo.default_branch(false)));
-
- let (worktree_name, branch_target) = match current_target {
- StartThreadIn::NewWorktree {
- worktree_name,
- branch_target,
- } => (worktree_name.clone(), branch_target.clone()),
- _ => (None, NewWorktreeBranchTarget::default()),
- };
-
- let delegate = ThreadBranchPickerDelegate {
- matches: vec![ThreadBranchEntry::CurrentBranch],
- all_branches,
- occupied_branches,
- selected_index: 0,
- worktree_name,
- branch_target,
- project_worktree_paths,
- current_branch_name,
- default_branch_name: None,
- has_multiple_repositories,
- };
-
- let picker = cx.new(|cx| {
- Picker::list(delegate, window, cx)
- .list_measure_all()
- .modal(false)
- .max_height(Some(rems(20.).into()))
- });
-
- let focus_handle = picker.focus_handle(cx);
-
- let mut subscriptions = Vec::new();
-
- if let Some(repo) = &repository {
- subscriptions.push(cx.subscribe_in(
- repo,
- window,
- |this, repo, event: &RepositoryEvent, window, cx| match event {
- RepositoryEvent::BranchListChanged => {
- let all_branches = process_branches(&repo.read(cx).branch_list);
- this.picker.update(cx, |picker, cx| {
- picker.delegate.all_branches = all_branches;
- picker.refresh(window, cx);
- });
- }
- RepositoryEvent::GitWorktreeListChanged => {
- let project_worktree_paths =
- this.picker.read(cx).delegate.project_worktree_paths.clone();
- let occupied = compute_occupied_branches(
- &repo.read(cx).linked_worktrees,
- &project_worktree_paths,
- );
- this.picker.update(cx, |picker, cx| {
- picker.delegate.occupied_branches = occupied;
- picker.refresh(window, cx);
- });
- }
- _ => {}
- },
- ));
- }
-
- // Fetch default branch asynchronously since it requires a git operation
- if let Some(default_branch_request) = default_branch_request {
- let picker_handle = picker.downgrade();
- cx.spawn_in(window, async move |_this, cx| {
- let default_branch = default_branch_request
- .await
- .ok()
- .and_then(Result::ok)
- .flatten();
-
- picker_handle.update_in(cx, |picker, window, cx| {
- picker.delegate.default_branch_name =
- default_branch.map(|branch| branch.to_string());
- picker.refresh(window, cx);
- })?;
-
- anyhow::Ok(())
- })
- .detach_and_log_err(cx);
- }
-
- subscriptions.push(cx.subscribe(&picker, |_, _, _, cx| {
- cx.emit(DismissEvent);
- }));
-
- Self {
- picker,
- focus_handle,
- _subscriptions: subscriptions,
- }
- }
-}
-
-impl Focusable for ThreadBranchPicker {
- fn focus_handle(&self, _cx: &App) -> FocusHandle {
- self.focus_handle.clone()
- }
-}
-
-impl EventEmitter<DismissEvent> for ThreadBranchPicker {}
-
-impl Render for ThreadBranchPicker {
- fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
- v_flex()
- .w(rems(22.))
- .elevation_3(cx)
- .child(self.picker.clone())
- .on_mouse_down_out(cx.listener(|_, _, _, cx| {
- cx.emit(DismissEvent);
- }))
- }
-}
-
-#[derive(Clone)]
-enum ThreadBranchEntry {
- CurrentBranch,
- DefaultBranch,
- Separator,
- ExistingBranch {
- branch: GitBranch,
- positions: Vec<usize>,
- },
- CreateNamed {
- name: String,
- },
-}
-
-pub(crate) struct ThreadBranchPickerDelegate {
- matches: Vec<ThreadBranchEntry>,
- all_branches: Vec<GitBranch>,
- occupied_branches: HashMap<String, String>,
- selected_index: usize,
- worktree_name: Option<String>,
- branch_target: NewWorktreeBranchTarget,
- project_worktree_paths: HashSet<PathBuf>,
- current_branch_name: String,
- default_branch_name: Option<String>,
- has_multiple_repositories: bool,
-}
-
-fn process_branches(branches: &Arc<[GitBranch]>) -> Vec<GitBranch> {
- let remote_upstreams: HashSet<_> = branches
- .iter()
- .filter_map(|branch| {
- branch
- .upstream
- .as_ref()
- .filter(|upstream| upstream.is_remote())
- .map(|upstream| upstream.ref_name.clone())
- })
- .collect();
-
- let mut result: Vec<GitBranch> = branches
- .iter()
- .filter(|branch| !remote_upstreams.contains(&branch.ref_name))
- .cloned()
- .collect();
-
- result.sort_by_key(|branch| {
- (
- branch.is_remote(),
- !branch.is_head,
- branch
- .most_recent_commit
- .as_ref()
- .map(|commit| 0 - commit.commit_timestamp),
- )
- });
-
- result
-}
-
-fn compute_occupied_branches(
- worktrees: &[GitWorktree],
- project_worktree_paths: &HashSet<PathBuf>,
-) -> HashMap<String, String> {
- let mut occupied_branches = HashMap::default();
- for worktree in worktrees {
- let Some(branch_name) = worktree.branch_name().map(ToOwned::to_owned) else {
- continue;
- };
-
- let reason = if project_worktree_paths.contains(&worktree.path) {
- format!(
- "This branch is already checked out in the current project worktree at {}.",
- worktree.path.display()
- )
- } else {
- format!(
- "This branch is already checked out in a linked worktree at {}.",
- worktree.path.display()
- )
- };
-
- occupied_branches.insert(branch_name, reason);
- }
- occupied_branches
-}
-
-impl ThreadBranchPickerDelegate {
- fn new_worktree_action(&self, branch_target: NewWorktreeBranchTarget) -> StartThreadIn {
- StartThreadIn::NewWorktree {
- worktree_name: self.worktree_name.clone(),
- branch_target,
- }
- }
-
- fn selected_entry_name(&self) -> Option<&str> {
- match &self.branch_target {
- NewWorktreeBranchTarget::CurrentBranch => None,
- NewWorktreeBranchTarget::ExistingBranch { name } => Some(name),
- NewWorktreeBranchTarget::CreateBranch {
- from_ref: Some(from_ref),
- ..
- } => Some(from_ref),
- NewWorktreeBranchTarget::CreateBranch { name, .. } => Some(name),
- }
- }
-
- fn prefer_create_entry(&self) -> bool {
- matches!(
- &self.branch_target,
- NewWorktreeBranchTarget::CreateBranch { from_ref: None, .. }
- )
- }
-
- fn fixed_matches(&self) -> Vec<ThreadBranchEntry> {
- let mut matches = vec![ThreadBranchEntry::CurrentBranch];
- if !self.has_multiple_repositories
- && self
- .default_branch_name
- .as_ref()
- .is_some_and(|default_branch_name| default_branch_name != &self.current_branch_name)
- {
- matches.push(ThreadBranchEntry::DefaultBranch);
- }
- matches
- }
-
- fn is_branch_occupied(&self, branch_name: &str) -> bool {
- self.occupied_branches.contains_key(branch_name)
- }
-
- fn branch_aside_text(&self, branch_name: &str, is_remote: bool) -> Option<SharedString> {
- if self.is_branch_occupied(branch_name) {
- Some(
- "This branch is already checked out in another worktree. \
- The new worktree will start in detached HEAD state."
- .into(),
- )
- } else if is_remote {
- Some("A new local branch will be created from this remote branch.".into())
- } else {
- None
- }
- }
-
- fn entry_branch_name(&self, entry: &ThreadBranchEntry) -> Option<SharedString> {
- match entry {
- ThreadBranchEntry::CurrentBranch => {
- Some(SharedString::from(self.current_branch_name.clone()))
- }
- ThreadBranchEntry::DefaultBranch => {
- self.default_branch_name.clone().map(SharedString::from)
- }
- ThreadBranchEntry::ExistingBranch { branch, .. } => {
- Some(SharedString::from(branch.name().to_string()))
- }
- _ => None,
- }
- }
-
- 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,
- }
- }
-
- fn sync_selected_index(&mut self) {
- let selected_entry_name = self.selected_entry_name().map(ToOwned::to_owned);
- let prefer_create = self.prefer_create_entry();
-
- if prefer_create {
- if let Some(ref selected_entry_name) = selected_entry_name {
- if let Some(index) = self.matches.iter().position(|entry| {
- matches!(
- entry,
- ThreadBranchEntry::CreateNamed { name } if name == selected_entry_name
- )
- }) {
- self.selected_index = index;
- return;
- }
- }
- } else if let Some(ref selected_entry_name) = selected_entry_name {
- if selected_entry_name == &self.current_branch_name {
- if let Some(index) = self
- .matches
- .iter()
- .position(|entry| matches!(entry, ThreadBranchEntry::CurrentBranch))
- {
- self.selected_index = index;
- return;
- }
- }
-
- if self
- .default_branch_name
- .as_ref()
- .is_some_and(|default_branch_name| default_branch_name == selected_entry_name)
- {
- if let Some(index) = self
- .matches
- .iter()
- .position(|entry| matches!(entry, ThreadBranchEntry::DefaultBranch))
- {
- self.selected_index = index;
- return;
- }
- }
-
- if let Some(index) = self.matches.iter().position(|entry| {
- matches!(
- entry,
- ThreadBranchEntry::ExistingBranch { branch, .. }
- if branch.name() == selected_entry_name.as_str()
- )
- }) {
- self.selected_index = index;
- return;
- }
- }
-
- if self.matches.len() > 1
- && self
- .matches
- .iter()
- .skip(1)
- .all(|entry| matches!(entry, ThreadBranchEntry::CreateNamed { .. }))
- {
- self.selected_index = 1;
- return;
- }
-
- self.selected_index = 0;
- }
-}
-
-impl PickerDelegate for ThreadBranchPickerDelegate {
- type ListItem = AnyElement;
-
- fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
- "Search branchesβ¦".into()
- }
-
- fn editor_position(&self) -> PickerEditorPosition {
- PickerEditorPosition::Start
- }
-
- fn match_count(&self) -> usize {
- self.matches.len()
- }
-
- fn selected_index(&self) -> usize {
- self.selected_index
- }
-
- fn set_selected_index(
- &mut self,
- ix: usize,
- _window: &mut Window,
- _cx: &mut Context<Picker<Self>>,
- ) {
- 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,
- window: &mut Window,
- cx: &mut Context<Picker<Self>>,
- ) -> Task<()> {
- if self.has_multiple_repositories {
- let mut matches = self.fixed_matches();
-
- 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(' ', "-"),
- });
- }
-
- self.matches = matches;
- self.sync_selected_index();
- return Task::ready(());
- }
-
- let all_branches = self.all_branches.clone();
-
- if query.is_empty() {
- let mut matches = self.fixed_matches();
- 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 {
- branch,
- positions: Vec::new(),
- });
- }
-
- if let Some(selected_entry_name) = self.selected_entry_name().map(ToOwned::to_owned) {
- let has_existing = matches.iter().any(|entry| {
- matches!(
- entry,
- ThreadBranchEntry::ExistingBranch { branch, .. }
- if branch.name() == selected_entry_name
- )
- });
- if self.prefer_create_entry() && !has_existing {
- matches.push(ThreadBranchEntry::CreateNamed {
- name: selected_entry_name,
- });
- }
- }
-
- self.matches = matches;
- self.sync_selected_index();
- return Task::ready(());
- }
-
- let candidates: Vec<_> = all_branches
- .iter()
- .enumerate()
- .map(|(ix, branch)| StringMatchCandidate::new(ix, branch.name()))
- .collect();
- let executor = cx.background_executor().clone();
- let query_clone = query.clone();
- let normalized_query = query.replace(' ', "-");
-
- let task = cx.background_executor().spawn(async move {
- fuzzy::match_strings(
- &candidates,
- &query_clone,
- true,
- true,
- 10000,
- &Default::default(),
- executor,
- )
- .await
- });
-
- let all_branches_clone = all_branches;
- cx.spawn_in(window, async move |picker, cx| {
- let fuzzy_matches = task.await;
-
- 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();
- if branch.name() == picker.delegate.current_branch_name
- || picker.delegate.default_branch_name.as_ref().is_some_and(
- |default_branch_name| branch.name() == default_branch_name,
- )
- {
- continue;
- }
- if !has_dynamic_entries {
- matches.push(ThreadBranchEntry::Separator);
- has_dynamic_entries = true;
- }
- matches.push(ThreadBranchEntry::ExistingBranch {
- branch,
- positions: candidate.positions.clone(),
- });
- }
-
- if fuzzy_matches.is_empty() {
- if !has_dynamic_entries {
- matches.push(ThreadBranchEntry::Separator);
- }
- matches.push(ThreadBranchEntry::CreateNamed {
- name: normalized_query.clone(),
- });
- }
-
- picker.delegate.matches = matches;
- if let Some(index) =
- picker.delegate.matches.iter().position(|entry| {
- matches!(entry, ThreadBranchEntry::ExistingBranch { .. })
- })
- {
- picker.delegate.selected_index = index;
- } else if !fuzzy_matches.is_empty() {
- picker.delegate.selected_index = 0;
- } else if let Some(index) =
- picker.delegate.matches.iter().position(|entry| {
- matches!(entry, ThreadBranchEntry::CreateNamed { .. })
- })
- {
- picker.delegate.selected_index = index;
- } else {
- picker.delegate.sync_selected_index();
- }
- cx.notify();
- })
- .log_err();
- })
- }
-
- fn confirm(&mut self, _secondary: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
- let Some(entry) = self.matches.get(self.selected_index) else {
- return;
- };
-
- match entry {
- ThreadBranchEntry::Separator => return,
- ThreadBranchEntry::CurrentBranch => {
- window.dispatch_action(
- Box::new(self.new_worktree_action(NewWorktreeBranchTarget::CurrentBranch)),
- cx,
- );
- }
- ThreadBranchEntry::DefaultBranch => {
- let Some(default_branch_name) = self.default_branch_name.clone() else {
- return;
- };
- window.dispatch_action(
- Box::new(
- self.new_worktree_action(NewWorktreeBranchTarget::ExistingBranch {
- name: default_branch_name,
- }),
- ),
- cx,
- );
- }
- ThreadBranchEntry::ExistingBranch { branch, .. } => {
- let branch_target = if branch.is_remote() {
- let branch_name = branch
- .ref_name
- .as_ref()
- .strip_prefix("refs/remotes/")
- .and_then(|stripped| stripped.split_once('/').map(|(_, name)| name))
- .unwrap_or(branch.name())
- .to_string();
- NewWorktreeBranchTarget::CreateBranch {
- name: branch_name,
- from_ref: Some(branch.name().to_string()),
- }
- } else {
- NewWorktreeBranchTarget::ExistingBranch {
- name: branch.name().to_string(),
- }
- };
- window.dispatch_action(Box::new(self.new_worktree_action(branch_target)), cx);
- }
- ThreadBranchEntry::CreateNamed { name } => {
- window.dispatch_action(
- Box::new(
- self.new_worktree_action(NewWorktreeBranchTarget::CreateBranch {
- name: name.clone(),
- from_ref: None,
- }),
- ),
- cx,
- );
- }
- }
-
- cx.emit(DismissEvent);
- }
-
- fn dismissed(&mut self, _window: &mut Window, _cx: &mut Context<Picker<Self>>) {}
-
- fn render_match(
- &self,
- ix: usize,
- selected: bool,
- _window: &mut Window,
- cx: &mut Context<Picker<Self>>,
- ) -> Option<Self::ListItem> {
- let entry = self.matches.get(ix)?;
-
- match entry {
- ThreadBranchEntry::Separator => Some(
- div()
- .py(DynamicSpacing::Base04.rems(cx))
- .child(Divider::horizontal())
- .into_any_element(),
- ),
- 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)
- .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, ..
- } => {
- let branch_name = branch.name().to_string();
- 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)
- .child(Label::new(format!("Create Branch: \"{name}\"β¦")))
- .into_any_element(),
- ),
- }
- }
-
- 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 branch_name = self.entry_branch_name(entry);
- let aside_text = self.entry_aside_text(entry);
-
- if branch_name.is_none() && aside_text.is_none() {
- return None;
- }
-
- let side = crate::ui::documentation_aside_side(cx);
-
- Some(DocumentationAside::new(
- side,
- Rc::new(move |cx| {
- v_flex()
- .gap_1()
- .when_some(branch_name.clone(), |this, name| {
- this.child(Label::new(name))
- })
- .when_some(aside_text.clone(), |this, text| {
- this.child(
- div()
- .when(branch_name.is_some(), |this| {
- this.pt_1()
- .border_t_1()
- .border_color(cx.theme().colors().border_variant)
- })
- .child(Label::new(text).color(Color::Muted)),
- )
- })
- .into_any_element()
- }),
- ))
- }
-
- fn documentation_aside_index(&self) -> Option<usize> {
- let entry = self.matches.get(self.selected_index)?;
- if self.entry_branch_name(entry).is_some() || self.entry_aside_text(entry).is_some() {
- Some(self.selected_index)
- } else {
- None
- }
- }
-}
@@ -981,6 +981,7 @@ mod tests {
ref_name: Some("refs/heads/feature".into()),
sha: "abc123".into(),
is_main: false,
+ is_bare: false,
},
)
.await;
@@ -1,109 +1,71 @@
use std::path::PathBuf;
-use std::rc::Rc;
use std::sync::Arc;
-use agent_settings::AgentSettings;
-use fs::Fs;
+use collections::HashSet;
use fuzzy::StringMatchCandidate;
use git::repository::Worktree as GitWorktree;
use gpui::{
AnyElement, App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable,
- IntoElement, ParentElement, Render, SharedString, Styled, Task, Window, rems,
+ IntoElement, ParentElement, Render, SharedString, Styled, Subscription, Task, Window, rems,
};
use picker::{Picker, PickerDelegate, PickerEditorPosition};
-use project::{Project, git_store::RepositoryId};
-use settings::{NewThreadLocation, Settings, update_settings_file};
-use ui::{
- Divider, DocumentationAside, HighlightedLabel, Label, LabelCommon, ListItem, ListItemSpacing,
- Tooltip, prelude::*,
-};
+use project::Project;
+use project::git_store::RepositoryEvent;
+use ui::{Divider, HighlightedLabel, ListItem, ListItemSpacing, Tooltip, prelude::*};
use util::ResultExt as _;
use util::paths::PathExt;
-use crate::ui::HoldForDefault;
-use crate::{NewWorktreeBranchTarget, StartThreadIn};
+use crate::{CreateWorktree, NewWorktreeBranchTarget, SwitchWorktree};
pub(crate) struct ThreadWorktreePicker {
picker: Entity<Picker<ThreadWorktreePickerDelegate>>,
focus_handle: FocusHandle,
- _subscription: gpui::Subscription,
+ _subscriptions: Vec<Subscription>,
}
impl ThreadWorktreePicker {
- pub fn new(
- project: Entity<Project>,
- current_target: &StartThreadIn,
- fs: Arc<dyn Fs>,
- window: &mut Window,
- cx: &mut Context<Self>,
- ) -> Self {
- let project_worktree_paths: Vec<PathBuf> = project
+ pub fn new(project: Entity<Project>, window: &mut Window, cx: &mut Context<Self>) -> Self {
+ let project_worktree_paths: HashSet<PathBuf> = project
.read(cx)
.visible_worktrees(cx)
.map(|wt| wt.read(cx).abs_path().to_path_buf())
.collect();
- let preserved_branch_target = match current_target {
- StartThreadIn::NewWorktree { branch_target, .. } => branch_target.clone(),
- _ => NewWorktreeBranchTarget::default(),
- };
-
- 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 = project.read(cx).repositories(cx).len() > 1;
- let has_multiple_repositories = all_worktrees.len() > 1;
+ let current_branch_name = project.read(cx).active_repository(cx).and_then(|repo| {
+ repo.read(cx)
+ .branch
+ .as_ref()
+ .map(|branch| branch.name().to_string())
+ });
- let linked_worktrees: Vec<_> = if has_multiple_repositories {
- Vec::new()
+ let repository = if has_multiple_repositories {
+ None
} else {
- all_worktrees
- .iter()
- .flat_map(|(_, worktrees)| worktrees.iter())
- .filter(|worktree| {
- !project_worktree_paths
- .iter()
- .any(|project_path| project_path == &worktree.path)
- })
- .cloned()
- .collect()
+ project.read(cx).active_repository(cx)
};
- let mut initial_matches = vec![
- ThreadWorktreeEntry::CurrentWorktree,
- ThreadWorktreeEntry::NewWorktree,
- ];
+ // Fetch worktrees from the git backend (includes main + all linked)
+ let all_worktrees_request = repository
+ .clone()
+ .map(|repo| repo.update(cx, |repo, _| repo.worktrees()));
- 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 default_branch_request = repository
+ .clone()
+ .map(|repo| repo.update(cx, |repo, _| repo.default_branch(false)));
- 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 initial_matches = vec![ThreadWorktreeEntry::CreateFromCurrentBranch];
let delegate = ThreadWorktreePickerDelegate {
matches: initial_matches,
- all_worktrees,
+ all_worktrees: Vec::new(),
project_worktree_paths,
- selected_index,
+ selected_index: 0,
project,
- preserved_branch_target,
- fs,
+ current_branch_name,
+ default_branch_name: None,
+ has_multiple_repositories,
};
let picker = cx.new(|cx| {
@@ -113,14 +75,82 @@ impl ThreadWorktreePicker {
.max_height(Some(rems(20.).into()))
});
- let subscription = cx.subscribe(&picker, |_, _, _, cx| {
+ let mut subscriptions = Vec::new();
+
+ // Fetch worktrees and default branch asynchronously
+ {
+ let picker_handle = picker.downgrade();
+ cx.spawn_in(window, async move |_this, cx| {
+ let all_worktrees: Vec<_> = match all_worktrees_request {
+ Some(req) => match req.await {
+ Ok(Ok(worktrees)) => {
+ worktrees.into_iter().filter(|wt| !wt.is_bare).collect()
+ }
+ Ok(Err(err)) => {
+ log::warn!("ThreadWorktreePicker: git worktree list failed: {err}");
+ return anyhow::Ok(());
+ }
+ Err(_) => {
+ log::warn!("ThreadWorktreePicker: worktree request was cancelled");
+ return anyhow::Ok(());
+ }
+ },
+ None => Vec::new(),
+ };
+
+ let default_branch = match default_branch_request {
+ Some(req) => req.await.ok().and_then(Result::ok).flatten(),
+ None => None,
+ };
+
+ picker_handle.update_in(cx, |picker, window, cx| {
+ picker.delegate.all_worktrees = all_worktrees;
+ picker.delegate.default_branch_name =
+ default_branch.map(|branch| branch.to_string());
+ picker.refresh(window, cx);
+ })?;
+
+ anyhow::Ok(())
+ })
+ .detach_and_log_err(cx);
+ }
+
+ // Subscribe to repository events to live-update the worktree list
+ if let Some(repo) = &repository {
+ let picker_entity = picker.downgrade();
+ subscriptions.push(cx.subscribe_in(
+ repo,
+ window,
+ move |_this, repo, event: &RepositoryEvent, window, cx| {
+ if matches!(event, RepositoryEvent::GitWorktreeListChanged) {
+ let worktrees_request = repo.update(cx, |repo, _| repo.worktrees());
+ let picker = picker_entity.clone();
+ cx.spawn_in(window, async move |_, cx| {
+ let all_worktrees: Vec<_> = worktrees_request
+ .await??
+ .into_iter()
+ .filter(|wt| !wt.is_bare)
+ .collect();
+ picker.update_in(cx, |picker, window, cx| {
+ picker.delegate.all_worktrees = all_worktrees;
+ picker.refresh(window, cx);
+ })?;
+ anyhow::Ok(())
+ })
+ .detach_and_log_err(cx);
+ }
+ },
+ ));
+ }
+
+ subscriptions.push(cx.subscribe(&picker, |_, _, _, cx| {
cx.emit(DismissEvent);
- });
+ }));
Self {
focus_handle: picker.focus_handle(cx),
picker,
- _subscription: subscription,
+ _subscriptions: subscriptions,
}
}
}
@@ -136,7 +166,7 @@ impl EventEmitter<DismissEvent> for ThreadWorktreePicker {}
impl Render for ThreadWorktreePicker {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
v_flex()
- .w(rems(20.))
+ .w(rems(34.))
.elevation_3(cx)
.child(self.picker.clone())
.on_mouse_down_out(cx.listener(|_, _, _, cx| {
@@ -147,34 +177,62 @@ impl Render for ThreadWorktreePicker {
#[derive(Clone)]
enum ThreadWorktreeEntry {
- CurrentWorktree,
- NewWorktree,
+ CreateFromCurrentBranch,
+ CreateFromDefaultBranch {
+ default_branch_name: String,
+ },
Separator,
- LinkedWorktree {
+ Worktree {
worktree: GitWorktree,
positions: Vec<usize>,
},
CreateNamed {
name: String,
+ /// When Some, create from this branch name (e.g. "main"). When None, create from current branch.
+ from_branch: Option<String>,
disabled_reason: Option<String>,
},
}
pub(crate) struct ThreadWorktreePickerDelegate {
matches: Vec<ThreadWorktreeEntry>,
- all_worktrees: Vec<(RepositoryId, Arc<[GitWorktree]>)>,
- project_worktree_paths: Vec<PathBuf>,
+ all_worktrees: Vec<GitWorktree>,
+ project_worktree_paths: HashSet<PathBuf>,
selected_index: usize,
- preserved_branch_target: NewWorktreeBranchTarget,
project: Entity<Project>,
- fs: Arc<dyn Fs>,
+ current_branch_name: Option<String>,
+ default_branch_name: Option<String>,
+ has_multiple_repositories: bool,
}
impl ThreadWorktreePickerDelegate {
- fn new_worktree_action(&self, worktree_name: Option<String>) -> StartThreadIn {
- StartThreadIn::NewWorktree {
- worktree_name,
- branch_target: self.preserved_branch_target.clone(),
+ fn build_fixed_entries(&self) -> Vec<ThreadWorktreeEntry> {
+ let mut entries = Vec::new();
+
+ entries.push(ThreadWorktreeEntry::CreateFromCurrentBranch);
+
+ if !self.has_multiple_repositories {
+ if let Some(ref default_branch) = self.default_branch_name {
+ let is_different = self
+ .current_branch_name
+ .as_ref()
+ .is_none_or(|current| current != default_branch);
+ if is_different {
+ entries.push(ThreadWorktreeEntry::CreateFromDefaultBranch {
+ default_branch_name: default_branch.clone(),
+ });
+ }
+ }
+ }
+
+ entries
+ }
+
+ fn all_repo_worktrees(&self) -> &[GitWorktree] {
+ if self.has_multiple_repositories {
+ &[]
+ } else {
+ &self.all_worktrees
}
}
@@ -183,10 +241,11 @@ impl ThreadWorktreePickerDelegate {
return;
}
+ // When filtering, prefer selecting the first worktree match
if let Some(index) = self
.matches
.iter()
- .position(|entry| matches!(entry, ThreadWorktreeEntry::LinkedWorktree { .. }))
+ .position(|entry| matches!(entry, ThreadWorktreeEntry::Worktree { .. }))
{
self.selected_index = index;
} else if let Some(index) = self
@@ -205,7 +264,7 @@ impl PickerDelegate for ThreadWorktreePickerDelegate {
type ListItem = AnyElement;
fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
- "Search or create worktreesβ¦".into()
+ "Select a worktree for this threadβ¦".into()
}
fn editor_position(&self) -> PickerEditorPosition {
@@ -239,31 +298,18 @@ impl PickerDelegate for ThreadWorktreePickerDelegate {
window: &mut Window,
cx: &mut Context<Picker<Self>>,
) -> Task<()> {
- let has_multiple_repositories = self.all_worktrees.len() > 1;
-
- let linked_worktrees: Vec<_> = if has_multiple_repositories {
- Vec::new()
- } else {
- self.all_worktrees
- .iter()
- .flat_map(|(_, worktrees)| worktrees.iter())
- .filter(|worktree| {
- !self
- .project_worktree_paths
- .iter()
- .any(|project_path| project_path == &worktree.path)
- })
- .cloned()
- .collect()
- };
+ let repo_worktrees = self.all_repo_worktrees().to_vec();
let normalized_query = query.replace(' ', "-");
- let has_named_worktree = self.all_worktrees.iter().any(|(_, worktrees)| {
- worktrees
- .iter()
- .any(|worktree| worktree.display_name() == normalized_query)
+ let main_worktree_path = self
+ .all_worktrees
+ .iter()
+ .find(|wt| wt.is_main)
+ .map(|wt| wt.path.clone());
+ let has_named_worktree = self.all_worktrees.iter().any(|worktree| {
+ worktree.directory_name(main_worktree_path.as_deref()) == normalized_query
});
- let create_named_disabled_reason = if has_multiple_repositories {
+ let create_named_disabled_reason: Option<String> = if self.has_multiple_repositories {
Some("Cannot create a named worktree in a project with multiple repositories".into())
} else if has_named_worktree {
Some("A worktree with this name already exists".into())
@@ -271,147 +317,196 @@ impl PickerDelegate for ThreadWorktreePickerDelegate {
None
};
- let mut matches = vec![
- ThreadWorktreeEntry::CurrentWorktree,
- ThreadWorktreeEntry::NewWorktree,
- ];
+ let show_default_branch_create = !self.has_multiple_repositories
+ && self.default_branch_name.as_ref().is_some_and(|default| {
+ self.current_branch_name
+ .as_ref()
+ .is_none_or(|current| current != default)
+ });
+ let default_branch_name = self.default_branch_name.clone();
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(),
- positions: Vec::new(),
+ let mut matches = self.build_fixed_entries();
+
+ if !repo_worktrees.is_empty() {
+ let main_worktree_path = repo_worktrees
+ .iter()
+ .find(|wt| wt.is_main)
+ .map(|wt| wt.path.clone());
+
+ let mut sorted = repo_worktrees;
+ let project_paths = &self.project_worktree_paths;
+
+ sorted.sort_by(|a, b| {
+ let a_is_current = project_paths.contains(&a.path);
+ let b_is_current = project_paths.contains(&b.path);
+ b_is_current.cmp(&a_is_current).then_with(|| {
+ a.directory_name(main_worktree_path.as_deref())
+ .cmp(&b.directory_name(main_worktree_path.as_deref()))
+ })
});
+
+ matches.push(ThreadWorktreeEntry::Separator);
+ for worktree in sorted {
+ matches.push(ThreadWorktreeEntry::Worktree {
+ worktree,
+ positions: Vec::new(),
+ });
+ }
}
- } else if linked_worktrees.is_empty() {
- matches.push(ThreadWorktreeEntry::Separator);
- matches.push(ThreadWorktreeEntry::CreateNamed {
- name: normalized_query,
- disabled_reason: create_named_disabled_reason,
- });
- } else {
- let candidates: Vec<_> = linked_worktrees
- .iter()
- .enumerate()
- .map(|(ix, worktree)| StringMatchCandidate::new(ix, worktree.display_name()))
- .collect();
-
- let executor = cx.background_executor().clone();
- let query_clone = query.clone();
-
- let task = cx.background_executor().spawn(async move {
- fuzzy::match_strings(
- &candidates,
- &query_clone,
- true,
- true,
- 10000,
- &Default::default(),
- executor,
- )
- .await
- });
- let linked_worktrees_clone = linked_worktrees;
- return cx.spawn_in(window, async move |picker, cx| {
- let fuzzy_matches = task.await;
+ self.matches = matches;
+ self.sync_selected_index(false);
+ return Task::ready(());
+ }
+
+ // When the user is typing, fuzzy-match worktree names using display_name
+ // For the main worktree, also match against "main"
+ let main_worktree_path = repo_worktrees
+ .iter()
+ .find(|wt| wt.is_main)
+ .map(|wt| wt.path.clone());
+ let candidates: Vec<_> = repo_worktrees
+ .iter()
+ .enumerate()
+ .map(|(ix, worktree)| {
+ StringMatchCandidate::new(
+ ix,
+ &worktree.directory_name(main_worktree_path.as_deref()),
+ )
+ })
+ .collect();
- picker
- .update_in(cx, |picker, _window, cx| {
- let mut new_matches = vec![
- ThreadWorktreeEntry::CurrentWorktree,
- ThreadWorktreeEntry::NewWorktree,
- ];
+ let executor = cx.background_executor().clone();
- let has_extra_entries = !fuzzy_matches.is_empty();
+ let task = cx.background_executor().spawn(async move {
+ fuzzy::match_strings(
+ &candidates,
+ &query,
+ true,
+ true,
+ 10000,
+ &Default::default(),
+ executor,
+ )
+ .await
+ });
- if has_extra_entries {
- new_matches.push(ThreadWorktreeEntry::Separator);
- }
+ let repo_worktrees_clone = repo_worktrees;
+ cx.spawn_in(window, async move |picker, cx| {
+ let fuzzy_matches = task.await;
- for candidate in &fuzzy_matches {
- new_matches.push(ThreadWorktreeEntry::LinkedWorktree {
- worktree: linked_worktrees_clone[candidate.candidate_id].clone(),
- positions: candidate.positions.clone(),
- });
- }
+ picker
+ .update_in(cx, |picker, _window, cx| {
+ let mut new_matches: Vec<ThreadWorktreeEntry> = Vec::new();
- let has_exact_match = linked_worktrees_clone
- .iter()
- .any(|worktree| worktree.display_name() == query);
+ for candidate in &fuzzy_matches {
+ new_matches.push(ThreadWorktreeEntry::Worktree {
+ worktree: repo_worktrees_clone[candidate.candidate_id].clone(),
+ positions: candidate.positions.clone(),
+ });
+ }
- if !has_exact_match {
- if !has_extra_entries {
- new_matches.push(ThreadWorktreeEntry::Separator);
- }
+ if !new_matches.is_empty() {
+ new_matches.push(ThreadWorktreeEntry::Separator);
+ }
+ new_matches.push(ThreadWorktreeEntry::CreateNamed {
+ name: normalized_query.clone(),
+ from_branch: None,
+ disabled_reason: create_named_disabled_reason.clone(),
+ });
+ if show_default_branch_create {
+ if let Some(ref default_branch) = default_branch_name {
new_matches.push(ThreadWorktreeEntry::CreateNamed {
name: normalized_query.clone(),
+ from_branch: Some(default_branch.clone()),
disabled_reason: create_named_disabled_reason.clone(),
});
}
+ }
- picker.delegate.matches = new_matches;
- picker.delegate.sync_selected_index(true);
-
- cx.notify();
- })
- .log_err();
- });
- }
-
- self.matches = matches;
- self.sync_selected_index(!query.is_empty());
+ picker.delegate.matches = new_matches;
+ picker.delegate.sync_selected_index(true);
- Task::ready(())
+ cx.notify();
+ })
+ .log_err();
+ })
}
- fn confirm(&mut self, secondary: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
+ fn confirm(&mut self, _secondary: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
let Some(entry) = self.matches.get(self.selected_index) else {
return;
};
match entry {
ThreadWorktreeEntry::Separator => return,
- ThreadWorktreeEntry::CurrentWorktree => {
- if secondary {
- update_settings_file(self.fs.clone(), cx, |settings, _| {
- settings
- .agent
- .get_or_insert_default()
- .set_new_thread_location(NewThreadLocation::LocalProject);
- });
- }
- window.dispatch_action(Box::new(StartThreadIn::LocalProject), cx);
- }
- ThreadWorktreeEntry::NewWorktree => {
- if secondary {
- update_settings_file(self.fs.clone(), cx, |settings, _| {
- settings
- .agent
- .get_or_insert_default()
- .set_new_thread_location(NewThreadLocation::NewWorktree);
- });
- }
- window.dispatch_action(Box::new(self.new_worktree_action(None)), cx);
+
+ ThreadWorktreeEntry::CreateFromCurrentBranch => {
+ window.dispatch_action(
+ Box::new(CreateWorktree {
+ worktree_name: None,
+ branch_target: NewWorktreeBranchTarget::CurrentBranch,
+ }),
+ cx,
+ );
}
- ThreadWorktreeEntry::LinkedWorktree { worktree, .. } => {
+
+ ThreadWorktreeEntry::CreateFromDefaultBranch {
+ default_branch_name,
+ } => {
window.dispatch_action(
- Box::new(StartThreadIn::LinkedWorktree {
- path: worktree.path.clone(),
- display_name: worktree.display_name().to_string(),
+ Box::new(CreateWorktree {
+ worktree_name: None,
+ branch_target: NewWorktreeBranchTarget::ExistingBranch {
+ name: default_branch_name.clone(),
+ },
}),
cx,
);
}
+
+ ThreadWorktreeEntry::Worktree { worktree, .. } => {
+ let is_current = self.project_worktree_paths.contains(&worktree.path);
+
+ if is_current {
+ // Already in this worktree β just dismiss
+ } else {
+ let main_worktree_path = self
+ .all_worktrees
+ .iter()
+ .find(|wt| wt.is_main)
+ .map(|wt| wt.path.as_path());
+ window.dispatch_action(
+ Box::new(SwitchWorktree {
+ path: worktree.path.clone(),
+ display_name: worktree.directory_name(main_worktree_path),
+ }),
+ cx,
+ );
+ }
+ }
+
ThreadWorktreeEntry::CreateNamed {
name,
+ from_branch,
disabled_reason: None,
} => {
- window.dispatch_action(Box::new(self.new_worktree_action(Some(name.clone()))), cx);
+ let branch_target = match from_branch {
+ Some(branch) => NewWorktreeBranchTarget::ExistingBranch {
+ name: branch.clone(),
+ },
+ None => NewWorktreeBranchTarget::CurrentBranch,
+ };
+ window.dispatch_action(
+ Box::new(CreateWorktree {
+ worktree_name: Some(name.clone()),
+ branch_target,
+ }),
+ cx,
+ );
}
+
ThreadWorktreeEntry::CreateNamed {
disabled_reason: Some(_),
..
@@ -434,8 +529,43 @@ impl PickerDelegate for ThreadWorktreePickerDelegate {
) -> Option<Self::ListItem> {
let entry = self.matches.get(ix)?;
let project = self.project.read(cx);
- let is_new_worktree_disabled =
- project.repositories(cx).is_empty() || project.is_via_collab();
+ let is_create_disabled = project.repositories(cx).is_empty() || project.is_via_collab();
+
+ let no_git_reason: SharedString = "Requires a Git repository in the project".into();
+
+ let create_new_list_item = |id: SharedString,
+ label: SharedString,
+ disabled_tooltip: Option<SharedString>,
+ selected: bool| {
+ let is_disabled = disabled_tooltip.is_some();
+ ListItem::new(id)
+ .inset(true)
+ .spacing(ListItemSpacing::Sparse)
+ .toggle_state(selected)
+ .child(
+ h_flex()
+ .w_full()
+ .gap_2p5()
+ .child(
+ Icon::new(IconName::Plus)
+ .map(|this| {
+ if is_disabled {
+ this.color(Color::Disabled)
+ } else {
+ this.color(Color::Muted)
+ }
+ })
+ .size(IconSize::Small),
+ )
+ .child(
+ Label::new(label).when(is_disabled, |this| this.color(Color::Disabled)),
+ ),
+ )
+ .when_some(disabled_tooltip, |this, reason| {
+ this.tooltip(Tooltip::text(reason))
+ })
+ .into_any_element()
+ };
match entry {
ThreadWorktreeEntry::Separator => Some(
@@ -444,178 +574,464 @@ impl PickerDelegate for ThreadWorktreePickerDelegate {
.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::CreateFromCurrentBranch => {
+ let branch_label = if self.has_multiple_repositories {
+ "current branches".to_string()
+ } else {
+ self.current_branch_name
+ .clone()
+ .unwrap_or_else(|| "HEAD".to_string())
+ };
+
+ let label = format!("Create new worktree based on {branch_label}");
+
+ let disabled_tooltip = is_create_disabled.then(|| no_git_reason.clone());
+
+ let item = create_new_list_item(
+ "create-from-current".to_string().into(),
+ label.into(),
+ disabled_tooltip,
+ selected,
+ );
+
+ Some(item.into_any_element())
}
- ThreadWorktreeEntry::NewWorktree => {
- let item = ListItem::new("new-worktree")
- .inset(true)
- .spacing(ListItemSpacing::Sparse)
- .toggle_state(selected)
- .disabled(is_new_worktree_disabled)
- .child(
- 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
- }
- .into_any_element(),
- )
+ ThreadWorktreeEntry::CreateFromDefaultBranch {
+ default_branch_name,
+ } => {
+ let label = format!("Create new worktree based on {default_branch_name}");
+
+ let disabled_tooltip = is_create_disabled.then(|| no_git_reason.clone());
+
+ let item = create_new_list_item(
+ "create-from-main".to_string().into(),
+ label.into(),
+ disabled_tooltip,
+ selected,
+ );
+
+ Some(item.into_any_element())
}
- ThreadWorktreeEntry::LinkedWorktree {
+
+ ThreadWorktreeEntry::Worktree {
worktree,
positions,
} => {
- let display_name = worktree.display_name();
- let first_line = display_name.lines().next().unwrap_or(display_name);
+ let main_worktree_path = self
+ .all_worktrees
+ .iter()
+ .find(|wt| wt.is_main)
+ .map(|wt| wt.path.as_path());
+ let display_name = worktree.directory_name(main_worktree_path);
+ let first_line = display_name.lines().next().unwrap_or(&display_name);
let positions: Vec<_> = positions
.iter()
.copied()
.filter(|&pos| pos < first_line.len())
.collect();
- let path = worktree.path.compact();
+ let path = worktree.path.compact().to_string_lossy().to_string();
+ let sha = worktree.sha.chars().take(7).collect::<String>();
+
+ let is_current = self.project_worktree_paths.contains(&worktree.path);
+
+ let entry_icon = if is_current {
+ IconName::Check
+ } else {
+ IconName::GitWorktree
+ };
Some(
- ListItem::new(SharedString::from(format!("linked-worktree-{ix}")))
+ ListItem::new(SharedString::from(format!("worktree-{ix}")))
.inset(true)
.spacing(ListItemSpacing::Sparse)
.toggle_state(selected)
.child(
- v_flex()
- .min_w_0()
- .overflow_hidden()
+ h_flex()
+ .w_full()
+ .gap_2p5()
.child(
- HighlightedLabel::new(first_line.to_owned(), positions)
- .truncate(),
+ Icon::new(entry_icon)
+ .color(if is_current {
+ Color::Accent
+ } else {
+ Color::Muted
+ })
+ .size(IconSize::Small),
)
.child(
- Label::new(path.to_string_lossy().to_string())
- .size(LabelSize::Small)
- .color(Color::Muted)
- .truncate_start(),
+ v_flex()
+ .w_full()
+ .min_w_0()
+ .child(
+ HighlightedLabel::new(first_line.to_owned(), positions)
+ .truncate(),
+ )
+ .child(
+ h_flex()
+ .w_full()
+ .min_w_0()
+ .gap_1p5()
+ .when_some(
+ worktree.branch_name().map(|b| b.to_string()),
+ |this, branch| {
+ this.child(
+ Label::new(branch)
+ .size(LabelSize::Small)
+ .color(Color::Muted),
+ )
+ .child(
+ Label::new("\u{2022}")
+ .alpha(0.5)
+ .color(Color::Muted)
+ .size(LabelSize::Small),
+ )
+ },
+ )
+ .when(!sha.is_empty(), |this| {
+ this.child(
+ Label::new(sha)
+ .size(LabelSize::Small)
+ .color(Color::Muted),
+ )
+ .child(
+ Label::new("\u{2022}")
+ .alpha(0.5)
+ .color(Color::Muted)
+ .size(LabelSize::Small),
+ )
+ })
+ .child(
+ Label::new(path)
+ .truncate_start()
+ .color(Color::Muted)
+ .size(LabelSize::Small)
+ .flex_1(),
+ ),
+ ),
),
)
.into_any_element(),
)
}
+
ThreadWorktreeEntry::CreateNamed {
name,
+ from_branch,
disabled_reason,
} => {
- let is_disabled = disabled_reason.is_some();
- let item = ListItem::new("create-named-worktree")
- .inset(true)
- .spacing(ListItemSpacing::Sparse)
- .toggle_state(selected)
- .disabled(is_disabled)
- .child(Label::new(format!("Create Worktree: \"{name}\"β¦")).color(
- if is_disabled {
- Color::Disabled
- } else {
- Color::Default
- },
- ));
+ let branch_label = from_branch
+ .as_deref()
+ .unwrap_or(self.current_branch_name.as_deref().unwrap_or("HEAD"));
+ let label = format!("Create \"{name}\" based on {branch_label}");
+ let element_id = match from_branch {
+ Some(branch) => format!("create-named-from-{branch}"),
+ None => "create-named-from-current".to_string(),
+ };
- Some(
- if let Some(reason) = disabled_reason.clone() {
- item.tooltip(Tooltip::text(reason))
- } else {
- item
- }
- .into_any_element(),
- )
+ let item = create_new_list_item(
+ element_id.into(),
+ label.into(),
+ disabled_reason.clone().map(SharedString::from),
+ selected,
+ );
+
+ Some(item.into_any_element())
}
}
}
+}
- fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option<SharedString> {
- None
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use fs::FakeFs;
+ use gpui::TestAppContext;
+ use project::Project;
+ use settings::SettingsStore;
+
+ fn init_test(cx: &mut TestAppContext) {
+ cx.update(|cx| {
+ let settings_store = SettingsStore::test(cx);
+ cx.set_global(settings_store);
+ theme_settings::init(theme::LoadThemes::JustBase, cx);
+ editor::init(cx);
+ release_channel::init("0.0.0".parse().unwrap(), cx);
+ crate::agent_panel::init(cx);
+ });
}
- 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 make_worktree(path: &str, branch: &str, is_main: bool) -> GitWorktree {
+ GitWorktree {
+ path: PathBuf::from(path),
+ ref_name: Some(format!("refs/heads/{branch}").into()),
+ sha: "abc1234".into(),
+ is_main,
+ is_bare: false,
+ }
}
- fn documentation_aside_index(&self) -> Option<usize> {
- match self.matches.get(self.selected_index) {
- Some(ThreadWorktreeEntry::CurrentWorktree | ThreadWorktreeEntry::NewWorktree) => {
- Some(self.selected_index)
- }
- _ => None,
+ fn build_delegate(
+ project: Entity<Project>,
+ all_worktrees: Vec<GitWorktree>,
+ project_worktree_paths: HashSet<PathBuf>,
+ current_branch_name: Option<String>,
+ default_branch_name: Option<String>,
+ has_multiple_repositories: bool,
+ ) -> ThreadWorktreePickerDelegate {
+ ThreadWorktreePickerDelegate {
+ matches: vec![ThreadWorktreeEntry::CreateFromCurrentBranch],
+ all_worktrees,
+ project_worktree_paths,
+ selected_index: 0,
+ project,
+ current_branch_name,
+ default_branch_name,
+ has_multiple_repositories,
}
}
+
+ fn entry_names(delegate: &ThreadWorktreePickerDelegate) -> Vec<String> {
+ delegate
+ .matches
+ .iter()
+ .map(|entry| match entry {
+ ThreadWorktreeEntry::CreateFromCurrentBranch => {
+ "CreateFromCurrentBranch".to_string()
+ }
+ ThreadWorktreeEntry::CreateFromDefaultBranch {
+ default_branch_name,
+ } => format!("CreateFromDefaultBranch({default_branch_name})"),
+ ThreadWorktreeEntry::Separator => "---".to_string(),
+ ThreadWorktreeEntry::Worktree { worktree, .. } => {
+ format!("Worktree({})", worktree.path.display())
+ }
+ ThreadWorktreeEntry::CreateNamed {
+ name,
+ from_branch,
+ disabled_reason,
+ } => {
+ let branch = from_branch
+ .as_deref()
+ .map(|b| format!("from {b}"))
+ .unwrap_or_else(|| "from current".to_string());
+ if disabled_reason.is_some() {
+ format!("CreateNamed({name}, {branch}, disabled)")
+ } else {
+ format!("CreateNamed({name}, {branch})")
+ }
+ }
+ })
+ .collect()
+ }
+
+ type PickerWindow = gpui::WindowHandle<Picker<ThreadWorktreePickerDelegate>>;
+
+ async fn make_picker(
+ cx: &mut TestAppContext,
+ all_worktrees: Vec<GitWorktree>,
+ project_worktree_paths: HashSet<PathBuf>,
+ current_branch_name: Option<String>,
+ default_branch_name: Option<String>,
+ has_multiple_repositories: bool,
+ ) -> PickerWindow {
+ let fs = FakeFs::new(cx.executor());
+ let project = Project::test(fs, [], cx).await;
+
+ cx.add_window(|window, cx| {
+ let delegate = build_delegate(
+ project,
+ all_worktrees,
+ project_worktree_paths,
+ current_branch_name,
+ default_branch_name,
+ has_multiple_repositories,
+ );
+ Picker::list(delegate, window, cx)
+ .list_measure_all()
+ .modal(false)
+ })
+ }
+
+ #[gpui::test]
+ async fn test_empty_query_entries(cx: &mut TestAppContext) {
+ init_test(cx);
+
+ // When on `main` with default branch also `main`, only CreateFromCurrentBranch
+ // is shown as a fixed entry. Worktrees are listed with the current one first.
+ let worktrees = vec![
+ make_worktree("/repo", "main", true),
+ make_worktree("/repo-feature", "feature", false),
+ make_worktree("/repo-bugfix", "bugfix", false),
+ ];
+ let project_paths: HashSet<PathBuf> = [PathBuf::from("/repo")].into_iter().collect();
+
+ let picker = make_picker(
+ cx,
+ worktrees,
+ project_paths,
+ Some("main".into()),
+ Some("main".into()),
+ false,
+ )
+ .await;
+
+ picker
+ .update(cx, |picker, window, cx| picker.refresh(window, cx))
+ .unwrap();
+ cx.run_until_parked();
+
+ let names = picker
+ .read_with(cx, |picker, _| entry_names(&picker.delegate))
+ .unwrap();
+
+ assert_eq!(
+ names,
+ vec![
+ "CreateFromCurrentBranch",
+ "---",
+ "Worktree(/repo)",
+ "Worktree(/repo-bugfix)",
+ "Worktree(/repo-feature)",
+ ]
+ );
+
+ // When current branch differs from default, CreateFromDefaultBranch appears.
+ picker
+ .update(cx, |picker, _window, cx| {
+ picker.delegate.current_branch_name = Some("feature".into());
+ picker.delegate.default_branch_name = Some("main".into());
+ cx.notify();
+ })
+ .unwrap();
+ picker
+ .update(cx, |picker, window, cx| picker.refresh(window, cx))
+ .unwrap();
+ cx.run_until_parked();
+
+ let names = picker
+ .read_with(cx, |picker, _| entry_names(&picker.delegate))
+ .unwrap();
+
+ assert!(names.contains(&"CreateFromDefaultBranch(main)".to_string()));
+ }
+
+ #[gpui::test]
+ async fn test_query_filtering_and_create_entries(cx: &mut TestAppContext) {
+ init_test(cx);
+
+ let picker = make_picker(
+ cx,
+ vec![
+ make_worktree("/repo", "main", true),
+ make_worktree("/repo-feature", "feature", false),
+ make_worktree("/repo-bugfix", "bugfix", false),
+ make_worktree("/my-worktree", "experiment", false),
+ ],
+ HashSet::default(),
+ Some("dev".into()),
+ Some("main".into()),
+ false,
+ )
+ .await;
+
+ // Partial match filters to matching worktrees and offers to create
+ // from both current branch and default branch.
+ picker
+ .update(cx, |picker, window, cx| {
+ picker.set_query("feat", window, cx)
+ })
+ .unwrap();
+ cx.run_until_parked();
+
+ let names = picker
+ .read_with(cx, |picker, _| entry_names(&picker.delegate))
+ .unwrap();
+ assert!(names.contains(&"Worktree(/repo-feature)".to_string()));
+ assert!(
+ names.contains(&"CreateNamed(feat, from current)".to_string()),
+ "should offer to create from current branch, got: {names:?}"
+ );
+ assert!(
+ names.contains(&"CreateNamed(feat, from main)".to_string()),
+ "should offer to create from default branch, got: {names:?}"
+ );
+ assert!(!names.contains(&"Worktree(/repo-bugfix)".to_string()));
+
+ // Exact match: both create entries appear but are disabled.
+ picker
+ .update(cx, |picker, window, cx| {
+ picker.set_query("repo-feature", window, cx)
+ })
+ .unwrap();
+ cx.run_until_parked();
+
+ let names = picker
+ .read_with(cx, |picker, _| entry_names(&picker.delegate))
+ .unwrap();
+ assert!(
+ names.contains(&"CreateNamed(repo-feature, from current, disabled)".to_string()),
+ "exact name match should show disabled create entries, got: {names:?}"
+ );
+
+ // Spaces are normalized to hyphens: "my worktree" matches "my-worktree".
+ picker
+ .update(cx, |picker, window, cx| {
+ picker.set_query("my worktree", window, cx)
+ })
+ .unwrap();
+ cx.run_until_parked();
+
+ let names = picker
+ .read_with(cx, |picker, _| entry_names(&picker.delegate))
+ .unwrap();
+ assert!(
+ names.contains(&"CreateNamed(my-worktree, from current, disabled)".to_string()),
+ "spaces should normalize to hyphens and detect existing worktree, got: {names:?}"
+ );
+ }
+
+ #[gpui::test]
+ async fn test_multi_repo_hides_worktrees_and_disables_create_named(cx: &mut TestAppContext) {
+ init_test(cx);
+
+ let picker = make_picker(
+ cx,
+ vec![
+ make_worktree("/repo", "main", true),
+ make_worktree("/repo-feature", "feature", false),
+ ],
+ HashSet::default(),
+ Some("main".into()),
+ Some("main".into()),
+ true,
+ )
+ .await;
+
+ picker
+ .update(cx, |picker, window, cx| picker.refresh(window, cx))
+ .unwrap();
+ cx.run_until_parked();
+
+ let names = picker
+ .read_with(cx, |picker, _| entry_names(&picker.delegate))
+ .unwrap();
+ assert_eq!(names, vec!["CreateFromCurrentBranch"]);
+
+ picker
+ .update(cx, |picker, window, cx| {
+ picker.set_query("new-thing", window, cx)
+ })
+ .unwrap();
+ cx.run_until_parked();
+
+ let names = picker
+ .read_with(cx, |picker, _| entry_names(&picker.delegate))
+ .unwrap();
+ assert!(
+ names.contains(&"CreateNamed(new-thing, from current, disabled)".to_string()),
+ "multi-repo should disable create named, got: {names:?}"
+ );
+ }
}
@@ -15,6 +15,7 @@ impl HoldForDefault {
}
}
+ #[allow(dead_code)]
pub fn more_content(mut self, more_content: bool) -> Self {
self.more_content = more_content;
self
@@ -514,6 +514,7 @@ async fn test_linked_worktrees_sync(
ref_name: Some("refs/heads/feature-branch".into()),
sha: "bbb222".into(),
is_main: false,
+ is_bare: false,
},
)
.await;
@@ -525,6 +526,7 @@ async fn test_linked_worktrees_sync(
ref_name: Some("refs/heads/bugfix-branch".into()),
sha: "ccc333".into(),
is_main: false,
+ is_bare: false,
},
)
.await;
@@ -597,6 +599,7 @@ async fn test_linked_worktrees_sync(
ref_name: Some("refs/heads/hotfix-branch".into()),
sha: "ddd444".into(),
is_main: false,
+ is_bare: false,
},
)
.await;
@@ -561,7 +561,7 @@ impl ComponentPreview {
workspace.update(cx, |workspace, cx| {
let status_toast =
StatusToast::new("`zed/new-notification-system` created!", cx, |this, _cx| {
- this.icon(ToastIcon::new(IconName::GitBranchAlt).color(Color::Muted))
+ this.icon(ToastIcon::new(IconName::GitBranch).color(Color::Muted))
.action("Open Pull Request", |_, cx| {
cx.open_url("https://github.com/")
})
@@ -494,6 +494,7 @@ impl GitRepository for FakeGitRepository {
ref_name: Some(branch_ref.into()),
sha: head_sha.into(),
is_main: true,
+ is_bare: false,
};
(main_wt, state.refs.clone())
})?;
@@ -532,6 +533,7 @@ impl GitRepository for FakeGitRepository {
ref_name: ref_name.map(Into::into),
sha: sha.into(),
is_main: false,
+ is_bare: false,
});
}
}
@@ -237,6 +237,7 @@ pub struct Worktree {
// todo(git_worktree) This type should be a Oid
pub sha: SharedString,
pub is_main: bool,
+ pub is_bare: bool,
}
/// Describes how a new worktree should choose or create its checked-out HEAD.
@@ -291,6 +292,34 @@ impl Worktree {
self.branch_name()
.unwrap_or(&self.sha[..self.sha.len().min(SHORT_SHA_LENGTH)])
}
+
+ pub fn directory_name(&self, main_worktree_path: Option<&Path>) -> String {
+ if self.is_main {
+ return "main".to_string();
+ }
+
+ let dir_name = self
+ .path
+ .file_name()
+ .and_then(|name| name.to_str())
+ .unwrap_or(self.display_name());
+
+ if let Some(main_path) = main_worktree_path {
+ let main_dir = main_path.file_name().and_then(|n| n.to_str());
+ if main_dir == Some(dir_name) {
+ if let Some(parent_name) = self
+ .path
+ .parent()
+ .and_then(|p| p.file_name())
+ .and_then(|n| n.to_str())
+ {
+ return parent_name.to_string();
+ }
+ }
+ }
+
+ dir_name.to_string()
+ }
}
pub fn parse_worktrees_from_str<T: AsRef<str>>(raw_worktrees: T) -> Vec<Worktree> {
@@ -303,6 +332,8 @@ pub fn parse_worktrees_from_str<T: AsRef<str>>(raw_worktrees: T) -> Vec<Worktree
let mut sha = None;
let mut ref_name = None;
+ let mut is_bare = false;
+
for line in entry.lines() {
let line = line.trim();
if line.is_empty() {
@@ -314,8 +345,10 @@ pub fn parse_worktrees_from_str<T: AsRef<str>>(raw_worktrees: T) -> Vec<Worktree
sha = Some(rest.to_string());
} else if let Some(rest) = line.strip_prefix("branch ") {
ref_name = Some(rest.to_string());
+ } else if line == "bare" {
+ is_bare = true;
}
- // Ignore other lines: detached, bare, locked, prunable, etc.
+ // Ignore other lines: detached, locked, prunable, etc.
}
if let (Some(path), Some(sha)) = (path, sha) {
@@ -324,6 +357,7 @@ pub fn parse_worktrees_from_str<T: AsRef<str>>(raw_worktrees: T) -> Vec<Worktree
ref_name: ref_name.map(Into::into),
sha: sha.into(),
is_main: is_first,
+ is_bare,
});
is_first = false;
}
@@ -4118,6 +4152,7 @@ mod tests {
assert_eq!(result[0].sha.as_ref(), "abc123def");
assert_eq!(result[0].ref_name, Some("refs/heads/main".into()));
assert!(result[0].is_main);
+ assert!(!result[0].is_bare);
// Multiple worktrees
let input = "worktree /home/user/project\nHEAD abc123\nbranch refs/heads/main\n\n\
@@ -4127,9 +4162,11 @@ mod tests {
assert_eq!(result[0].path, PathBuf::from("/home/user/project"));
assert_eq!(result[0].ref_name, Some("refs/heads/main".into()));
assert!(result[0].is_main);
+ assert!(!result[0].is_bare);
assert_eq!(result[1].path, PathBuf::from("/home/user/project-wt"));
assert_eq!(result[1].ref_name, Some("refs/heads/feature".into()));
assert!(!result[1].is_main);
+ assert!(!result[1].is_bare);
// Detached HEAD entry (included with ref_name: None)
let input = "worktree /home/user/project\nHEAD abc123\nbranch refs/heads/main\n\n\
@@ -4143,6 +4180,7 @@ mod tests {
assert_eq!(result[1].ref_name, None);
assert_eq!(result[1].sha.as_ref(), "def456");
assert!(!result[1].is_main);
+ assert!(!result[1].is_bare);
// Bare repo entry (included with ref_name: None)
let input = "worktree /home/user/bare.git\nHEAD abc123\nbare\n\n\
@@ -4152,9 +4190,11 @@ mod tests {
assert_eq!(result[0].path, PathBuf::from("/home/user/bare.git"));
assert_eq!(result[0].ref_name, None);
assert!(result[0].is_main);
+ assert!(result[0].is_bare);
assert_eq!(result[1].path, PathBuf::from("/home/user/project"));
assert_eq!(result[1].ref_name, Some("refs/heads/main".into()));
assert!(!result[1].is_main);
+ assert!(!result[1].is_bare);
// Extra porcelain lines (locked, prunable) should be ignored
let input = "worktree /home/user/project\nHEAD abc123\nbranch refs/heads/main\n\n\
@@ -944,7 +944,7 @@ impl PickerDelegate for BranchListDelegate {
} else if branch.is_remote() {
IconName::Screen
} else {
- IconName::GitBranchAlt
+ IconName::GitBranch
}
}
};
@@ -3864,9 +3864,9 @@ impl GitPanel {
let status_toast = StatusToast::new(message, cx, move |this, _cx| {
use remote_output::SuccessStyle::*;
match style {
- Toast => this.icon(ToastIcon::new(IconName::GitBranchAlt).color(Color::Muted)),
+ Toast => this.icon(ToastIcon::new(IconName::GitBranch).color(Color::Muted)),
ToastWithLog { output } => this
- .icon(ToastIcon::new(IconName::GitBranchAlt).color(Color::Muted))
+ .icon(ToastIcon::new(IconName::GitBranch).color(Color::Muted))
.action("View Log", move |window, cx| {
let output = output.clone();
let output =
@@ -3878,7 +3878,7 @@ impl GitPanel {
.ok();
}),
PushPrLink { text, link } => this
- .icon(ToastIcon::new(IconName::GitBranchAlt).color(Color::Muted))
+ .icon(ToastIcon::new(IconName::GitBranch).color(Color::Muted))
.action(text, move |_, cx| cx.open_url(&link)),
}
.dismiss_button(true)
@@ -5807,7 +5807,7 @@ impl Panel for GitPanel {
}
fn icon(&self, _: &Window, cx: &App) -> Option<ui::IconName> {
- Some(ui::IconName::GitBranchAlt).filter(|_| GitPanelSettings::get_global(cx).button)
+ Some(ui::IconName::GitBranch).filter(|_| GitPanelSettings::get_global(cx).button)
}
fn icon_tooltip(&self, _window: &Window, _cx: &App) -> Option<&'static str> {
@@ -6127,15 +6127,13 @@ impl RenderOnce for PanelRepoFooter {
.flex_1()
.overflow_hidden()
.gap_px()
- .child(
- Icon::new(IconName::GitBranchAlt)
- .size(IconSize::Small)
- .color(if single_repo {
- Color::Disabled
- } else {
- Color::Muted
- }),
- )
+ .child(Icon::new(IconName::GitBranch).size(IconSize::Small).color(
+ if single_repo {
+ Color::Disabled
+ } else {
+ Color::Muted
+ },
+ ))
.child(repo_selector)
.when(show_separator, |this| {
this.child(
@@ -11,7 +11,7 @@ use gpui::{
use picker::{Picker, PickerDelegate, PickerEditorPosition};
use project::project_settings::ProjectSettings;
use project::{
- git_store::Repository,
+ git_store::{Repository, RepositoryEvent},
trusted_worktrees::{PathTrust, TrustedWorktrees},
};
use remote::{RemoteConnectionOptions, remote_client::ConnectionIdentifier};
@@ -26,8 +26,6 @@ use workspace::{
use crate::git_panel::show_error_toast;
-const MAIN_WORKTREE_DISPLAY_NAME: &str = "main";
-
actions!(
git,
[
@@ -64,7 +62,7 @@ pub struct WorktreeList {
width: Rems,
pub picker: Entity<Picker<WorktreeListDelegate>>,
picker_focus_handle: FocusHandle,
- _subscription: Option<Subscription>,
+ _subscriptions: Vec<Subscription>,
embedded: bool,
}
@@ -77,9 +75,10 @@ impl WorktreeList {
cx: &mut Context<Self>,
) -> Self {
let mut this = Self::new_inner(repository, workspace, width, false, window, cx);
- this._subscription = Some(cx.subscribe(&this.picker, |_, _, _, cx| {
- cx.emit(DismissEvent);
- }));
+ this._subscriptions
+ .push(cx.subscribe(&this.picker, |_, _, _, cx| {
+ cx.emit(DismissEvent);
+ }));
this
}
@@ -104,7 +103,7 @@ impl WorktreeList {
.context("No active repository")?
.await??
.into_iter()
- .filter(|worktree| worktree.ref_name.is_some()) // hide worktrees without a branch
+ .filter(|worktree| !worktree.is_bare) // hide bare repositories
.collect();
let default_branch = default_branch_request
@@ -128,7 +127,7 @@ impl WorktreeList {
})
.detach_and_log_err(cx);
- let delegate = WorktreeListDelegate::new(workspace, repository, window, cx);
+ let delegate = WorktreeListDelegate::new(workspace, repository.clone(), window, cx);
let picker = cx.new(|cx| {
Picker::uniform_list(delegate, window, cx)
.show_scrollbar(true)
@@ -139,11 +138,38 @@ impl WorktreeList {
picker.delegate.focus_handle = picker_focus_handle.clone();
});
+ let mut subscriptions = Vec::new();
+ if let Some(repo) = &repository {
+ let picker_entity = picker.clone();
+ subscriptions.push(cx.subscribe(
+ repo,
+ move |_this, repo, event: &RepositoryEvent, cx| {
+ if matches!(event, RepositoryEvent::GitWorktreeListChanged) {
+ let worktrees_request = repo.update(cx, |repo, _| repo.worktrees());
+ let picker = picker_entity.clone();
+ cx.spawn(async move |_, cx| {
+ let all_worktrees: Vec<_> = worktrees_request
+ .await??
+ .into_iter()
+ .filter(|worktree| !worktree.is_bare)
+ .collect();
+ picker.update(cx, |picker, cx| {
+ picker.delegate.all_worktrees = Some(all_worktrees);
+ picker.delegate.refresh_forbidden_deletion_path(cx);
+ });
+ anyhow::Ok(())
+ })
+ .detach_and_log_err(cx);
+ }
+ },
+ ));
+ }
+
Self {
picker,
picker_focus_handle,
width,
- _subscription: None,
+ _subscriptions: subscriptions,
embedded,
}
}
@@ -156,9 +182,10 @@ impl WorktreeList {
cx: &mut Context<Self>,
) -> Self {
let mut this = Self::new_inner(repository, workspace, width, true, window, cx);
- this._subscription = Some(cx.subscribe(&this.picker, |_, _, _, cx| {
- cx.emit(DismissEvent);
- }));
+ this._subscriptions
+ .push(cx.subscribe(&this.picker, |_, _, _, cx| {
+ cx.emit(DismissEvent);
+ }));
this
}
@@ -695,6 +722,11 @@ impl PickerDelegate for WorktreeListDelegate {
};
cx.spawn_in(window, async move |picker, cx| {
+ let main_worktree_path = all_worktrees
+ .iter()
+ .find(|wt| wt.is_main)
+ .map(|wt| wt.path.clone());
+
let mut matches: Vec<WorktreeEntry> = if query.is_empty() {
all_worktrees
.into_iter()
@@ -709,12 +741,10 @@ impl PickerDelegate for WorktreeListDelegate {
.iter()
.enumerate()
.map(|(ix, worktree)| {
- let name = if worktree.is_main {
- MAIN_WORKTREE_DISPLAY_NAME
- } else {
- worktree.display_name()
- };
- StringMatchCandidate::new(ix, name)
+ StringMatchCandidate::new(
+ ix,
+ &worktree.directory_name(main_worktree_path.as_deref()),
+ )
})
.collect::<Vec<StringMatchCandidate>>();
fuzzy::match_strings(
@@ -739,12 +769,7 @@ impl PickerDelegate for WorktreeListDelegate {
.update(cx, |picker, _| {
if !query.is_empty()
&& !matches.first().is_some_and(|entry| {
- let name = if entry.worktree.is_main {
- MAIN_WORKTREE_DISPLAY_NAME
- } else {
- entry.worktree.display_name()
- };
- name == query
+ entry.worktree.directory_name(main_worktree_path.as_deref()) == query
})
{
let query = query.replace(' ', "-");
@@ -754,6 +779,7 @@ impl PickerDelegate for WorktreeListDelegate {
ref_name: Some(format!("refs/heads/{query}").into()),
sha: Default::default(),
is_main: false,
+ is_bare: false,
},
positions: Vec::new(),
is_new: true,
@@ -821,12 +847,13 @@ impl PickerDelegate for WorktreeListDelegate {
),
)
} else {
- let display_name = if entry.worktree.is_main {
- MAIN_WORKTREE_DISPLAY_NAME
- } else {
- entry.worktree.display_name()
- };
- let first_line = display_name.lines().next().unwrap_or(display_name);
+ let main_worktree_path = self
+ .all_worktrees
+ .as_ref()
+ .and_then(|wts| wts.iter().find(|wt| wt.is_main))
+ .map(|wt| wt.path.as_path());
+ let display_name = entry.worktree.directory_name(main_worktree_path);
+ let first_line = display_name.lines().next().unwrap_or(&display_name);
let positions: Vec<_> = entry
.positions
.iter()
@@ -903,6 +930,22 @@ impl PickerDelegate for WorktreeListDelegate {
.w_full()
.min_w_0()
.gap_1p5()
+ .when_some(
+ entry.worktree.branch_name().map(|b| b.to_string()),
+ |this, branch| {
+ this.child(
+ Label::new(branch)
+ .size(LabelSize::Small)
+ .color(Color::Muted),
+ )
+ .child(
+ Label::new("β’")
+ .alpha(0.5)
+ .color(Color::Muted)
+ .size(LabelSize::Small),
+ )
+ },
+ )
.child(
Label::new(sha)
.size(LabelSize::Small)
@@ -146,7 +146,6 @@ pub enum IconName {
GenericMinimize,
GenericRestore,
GitBranch,
- GitBranchAlt,
GitBranchPlus,
GitCommit,
GitGraph,
@@ -206,7 +206,7 @@ impl Component for StatusToast {
let pr_example =
StatusToast::new("`zed/new-notification-system` created!", cx, |this, _cx| {
- this.icon(ToastIcon::new(IconName::GitBranchAlt).color(Color::Muted))
+ this.icon(ToastIcon::new(IconName::GitBranch).color(Color::Muted))
.action("Open Pull Request", |_, cx| {
cx.open_url("https://github.com/")
})
@@ -7441,15 +7441,21 @@ fn worktree_to_proto(worktree: &git::repository::Worktree) -> proto::Worktree {
.unwrap_or_default(),
sha: worktree.sha.to_string(),
is_main: worktree.is_main,
+ is_bare: worktree.is_bare,
}
}
fn proto_to_worktree(proto: &proto::Worktree) -> git::repository::Worktree {
git::repository::Worktree {
path: PathBuf::from(proto.path.clone()),
- ref_name: Some(SharedString::from(&proto.ref_name)),
+ ref_name: if proto.ref_name.is_empty() {
+ None
+ } else {
+ Some(SharedString::from(&proto.ref_name))
+ },
sha: proto.sha.clone().into(),
is_main: proto.is_main,
+ is_bare: proto.is_bare,
}
}
@@ -586,6 +586,7 @@ message Worktree {
string ref_name = 2;
string sha = 3;
bool is_main = 4;
+ bool is_bare = 5;
}
message GitCreateWorktree {
@@ -1140,6 +1140,8 @@ impl PickerDelegate for RecentProjectsDelegate {
path_list,
Some(key.clone()),
&[],
+ None,
+ OpenMode::Activate,
window,
cx,
)
@@ -1566,6 +1566,7 @@ async fn test_remote_root_repo_common_dir(cx: &mut TestAppContext, server_cx: &m
ref_name: Some("refs/heads/feature-branch".into()),
sha: "abc123".into(),
is_main: false,
+ is_bare: false,
},
)
.await;
@@ -46,7 +46,7 @@ use util::ResultExt as _;
use util::path_list::PathList;
use workspace::{
CloseWindow, FocusWorkspaceSidebar, MultiWorkspace, MultiWorkspaceEvent, NextProject,
- NextThread, Open, PreviousProject, PreviousThread, ProjectGroupKey, SaveIntent,
+ NextThread, Open, OpenMode, PreviousProject, PreviousThread, ProjectGroupKey, SaveIntent,
ShowFewerThreads, ShowMoreThreads, Sidebar as WorkspaceSidebar, SidebarSide, Toast,
ToggleWorkspaceSidebar, Workspace, notifications::NotificationId, sidebar_side_context_menu,
};
@@ -944,6 +944,8 @@ impl Sidebar {
provisional_key,
|options, window, cx| connect_remote(active_workspace, options, window, cx),
&[],
+ None,
+ OpenMode::Activate,
window,
cx,
)
@@ -980,6 +982,8 @@ impl Sidebar {
provisional_key,
|options, window, cx| connect_remote(active_workspace, options, window, cx),
&[],
+ None,
+ OpenMode::Activate,
window,
cx,
)
@@ -2522,6 +2526,8 @@ impl Sidebar {
provisional_key,
|options, window, cx| connect_remote(active_workspace, options, window, cx),
&[],
+ None,
+ OpenMode::Activate,
window,
cx,
)
@@ -3156,6 +3162,8 @@ impl Sidebar {
connect_remote(active_workspace, options, window, cx)
},
&excluded,
+ None,
+ OpenMode::Activate,
window,
cx,
)
@@ -3347,6 +3347,7 @@ async fn test_cmd_n_shows_new_thread_entry_in_absorbed_worktree(cx: &mut TestApp
ref_name: Some("refs/heads/feature-a".into()),
sha: "aaa".into(),
is_main: false,
+ is_bare: false,
},
)
.await;
@@ -3463,6 +3464,7 @@ async fn test_search_matches_worktree_name(cx: &mut TestAppContext) {
ref_name: Some("refs/heads/rosewood".into()),
sha: "abc".into(),
is_main: false,
+ is_bare: false,
},
)
.await;
@@ -3547,6 +3549,7 @@ async fn test_git_worktree_added_live_updates_sidebar(cx: &mut TestAppContext) {
ref_name: Some("refs/heads/rosewood".into()),
sha: "abc".into(),
is_main: false,
+ is_bare: false,
},
)
.await;
@@ -3588,6 +3591,7 @@ async fn test_two_worktree_workspaces_absorbed_when_main_added(cx: &mut TestAppC
ref_name: Some("refs/heads/feature-a".into()),
sha: "aaa".into(),
is_main: false,
+ is_bare: false,
},
)
.await;
@@ -3599,6 +3603,7 @@ async fn test_two_worktree_workspaces_absorbed_when_main_added(cx: &mut TestAppC
ref_name: Some("refs/heads/feature-b".into()),
sha: "bbb".into(),
is_main: false,
+ is_bare: false,
},
)
.await;
@@ -3698,6 +3703,7 @@ async fn test_threadless_workspace_shows_new_thread_with_worktree_chip(cx: &mut
ref_name: Some("refs/heads/feature-a".into()),
sha: "aaa".into(),
is_main: false,
+ is_bare: false,
},
)
.await;
@@ -3709,6 +3715,7 @@ async fn test_threadless_workspace_shows_new_thread_with_worktree_chip(cx: &mut
ref_name: Some("refs/heads/feature-b".into()),
sha: "bbb".into(),
is_main: false,
+ is_bare: false,
},
)
.await;
@@ -3781,6 +3788,7 @@ async fn test_multi_worktree_thread_shows_multiple_chips(cx: &mut TestAppContext
ref_name: Some(format!("refs/heads/{branch}").into()),
sha: "aaa".into(),
is_main: false,
+ is_bare: false,
},
)
.await;
@@ -3858,6 +3866,7 @@ async fn test_same_named_worktree_chips_are_deduplicated(cx: &mut TestAppContext
ref_name: Some("refs/heads/olivetti".into()),
sha: "aaa".into(),
is_main: false,
+ is_bare: false,
},
)
.await;
@@ -3931,6 +3940,7 @@ async fn test_absorbed_worktree_running_thread_shows_live_status(cx: &mut TestAp
ref_name: Some("refs/heads/feature-a".into()),
sha: "aaa".into(),
is_main: false,
+ is_bare: false,
},
)
.await;
@@ -4027,6 +4037,7 @@ async fn test_absorbed_worktree_completion_triggers_notification(cx: &mut TestAp
ref_name: Some("refs/heads/feature-a".into()),
sha: "aaa".into(),
is_main: false,
+ is_bare: false,
},
)
.await;
@@ -4111,6 +4122,7 @@ async fn test_clicking_worktree_thread_opens_workspace_when_none_exists(cx: &mut
ref_name: Some("refs/heads/feature-a".into()),
sha: "aaa".into(),
is_main: false,
+ is_bare: false,
},
)
.await;
@@ -4207,6 +4219,7 @@ async fn test_clicking_worktree_thread_does_not_briefly_render_as_separate_proje
ref_name: Some("refs/heads/feature-a".into()),
sha: "aaa".into(),
is_main: false,
+ is_bare: false,
},
)
.await;
@@ -4349,6 +4362,7 @@ async fn test_clicking_absorbed_worktree_thread_activates_worktree_workspace(
ref_name: Some("refs/heads/feature-a".into()),
sha: "aaa".into(),
is_main: false,
+ is_bare: false,
},
)
.await;
@@ -4961,6 +4975,7 @@ async fn test_archive_thread_uses_next_threads_own_workspace(cx: &mut TestAppCon
ref_name: Some("refs/heads/feature-a".into()),
sha: "aaa".into(),
is_main: false,
+ is_bare: false,
},
)
.await;
@@ -5127,6 +5142,7 @@ async fn test_archive_last_worktree_thread_removes_workspace(cx: &mut TestAppCon
ref_name: Some("refs/heads/feature-a".into()),
sha: "abc".into(),
is_main: false,
+ is_bare: false,
},
)
.await;
@@ -5282,6 +5298,7 @@ async fn test_restore_worktree_when_branch_has_moved(cx: &mut TestAppContext) {
ref_name: Some("refs/heads/feature-a".into()),
sha: "original-sha".into(),
is_main: false,
+ is_bare: false,
},
)
.await;
@@ -5394,6 +5411,7 @@ async fn test_restore_worktree_when_branch_has_not_moved(cx: &mut TestAppContext
ref_name: Some("refs/heads/feature-b".into()),
sha: "original-sha".into(),
is_main: false,
+ is_bare: false,
},
)
.await;
@@ -5490,6 +5508,7 @@ async fn test_restore_worktree_when_branch_does_not_exist(cx: &mut TestAppContex
ref_name: Some("refs/heads/feature-d".into()),
sha: "original-sha".into(),
is_main: false,
+ is_bare: false,
},
)
.await;
@@ -5593,6 +5612,7 @@ async fn test_restore_worktree_thread_uses_main_repo_project_group_key(cx: &mut
ref_name: Some("refs/heads/feature-c".into()),
sha: "original-sha".into(),
is_main: false,
+ is_bare: false,
},
)
.await;
@@ -5739,6 +5759,7 @@ async fn test_archive_last_worktree_thread_not_blocked_by_remote_thread_at_same_
ref_name: Some("refs/heads/feature-a".into()),
sha: "abc".into(),
is_main: false,
+ is_bare: false,
},
)
.await;
@@ -5902,6 +5923,7 @@ async fn test_linked_worktree_threads_not_duplicated_across_groups(cx: &mut Test
ref_name: Some("refs/heads/feature-a".into()),
sha: "aaa".into(),
is_main: false,
+ is_bare: false,
},
)
.await;
@@ -7376,6 +7398,7 @@ async fn test_archive_last_thread_on_linked_worktree_does_not_create_new_thread_
ref_name: Some("refs/heads/ochre-drift".into()),
sha: "aaa".into(),
is_main: false,
+ is_bare: false,
},
)
.await;
@@ -7545,6 +7568,7 @@ async fn test_archive_last_thread_on_linked_worktree_with_no_siblings_leaves_gro
ref_name: Some("refs/heads/ochre-drift".into()),
sha: "aaa".into(),
is_main: false,
+ is_bare: false,
},
)
.await;
@@ -7675,6 +7699,7 @@ async fn test_unarchive_linked_worktree_thread_into_project_group_shows_only_res
ref_name: Some("refs/heads/ochre-drift".into()),
sha: "aaa".into(),
is_main: false,
+ is_bare: false,
},
)
.await;
@@ -7842,6 +7867,7 @@ async fn test_archive_thread_on_linked_worktree_selects_sibling_thread(cx: &mut
ref_name: Some("refs/heads/ochre-drift".into()),
sha: "aaa".into(),
is_main: false,
+ is_bare: false,
},
)
.await;
@@ -7991,6 +8017,7 @@ async fn test_linked_worktree_workspace_reachable_and_dismissable(cx: &mut TestA
ref_name: Some("refs/heads/feature-a".into()),
sha: "aaa".into(),
is_main: false,
+ is_bare: false,
},
)
.await;
@@ -8144,6 +8171,7 @@ async fn test_linked_worktree_workspace_shows_main_worktree_threads(cx: &mut Tes
ref_name: Some("refs/heads/feature-a".into()),
sha: "abc".into(),
is_main: false,
+ is_bare: false,
},
)
.await;
@@ -8391,6 +8419,7 @@ async fn test_legacy_thread_with_canonical_path_opens_main_repo_workspace(cx: &m
ref_name: Some("refs/heads/feature-a".into()),
sha: "abc".into(),
is_main: false,
+ is_bare: false,
},
)
.await;
@@ -8546,6 +8575,7 @@ async fn test_linked_worktree_workspace_reachable_after_adding_unrelated_project
ref_name: Some(format!("refs/heads/{}", worktree_name).into()),
sha: "aaa".into(),
is_main: false,
+ is_bare: false,
},
)
.await;
@@ -9061,6 +9091,7 @@ async fn test_worktree_add_only_regroups_threads_for_changed_workspace(cx: &mut
ref_name: Some("refs/heads/feature".into()),
sha: "aaa".into(),
is_main: false,
+ is_bare: false,
},
)
.await;
@@ -9219,6 +9250,7 @@ async fn test_linked_worktree_workspace_reachable_after_adding_worktree_to_proje
ref_name: Some("refs/heads/wt-0".into()),
sha: "aaa".into(),
is_main: false,
+ is_bare: false,
},
)
.await;
@@ -9660,6 +9692,7 @@ mod property_test {
ref_name: Some(format!("refs/heads/{}", worktree_name).into()),
sha: "aaa".into(),
is_main: false,
+ is_bare: false,
},
)
.await;
@@ -10448,6 +10481,7 @@ async fn test_remote_project_integration_does_not_briefly_render_as_separate_pro
ref_name: Some("refs/heads/feature-wt".into()),
sha: "abc123".into(),
is_main: false,
+ is_bare: false,
},
)
.await;
@@ -10536,6 +10570,7 @@ async fn test_archive_removes_worktree_even_when_workspace_paths_diverge(cx: &mu
ref_name: Some("refs/heads/feature-a".into()),
sha: "abc".into(),
is_main: false,
+ is_bare: false,
},
)
.await;
@@ -10680,6 +10715,7 @@ async fn test_archive_mixed_workspace_closes_only_archived_worktree_items(cx: &m
ref_name: Some("refs/heads/feature-b".into()),
sha: "def".into(),
is_main: false,
+ is_bare: false,
},
)
.await;
@@ -864,6 +864,8 @@ impl MultiWorkspace {
neighbor_key.path_list().clone(),
Some(neighbor_key.clone()),
&excluded_workspaces,
+ None,
+ OpenMode::Activate,
window,
cx,
);
@@ -992,6 +994,8 @@ impl MultiWorkspace {
) -> Task<Result<Option<Entity<remote::RemoteClient>>>>
+ 'static,
excluding: &[Entity<Workspace>],
+ init: Option<Box<dyn FnOnce(&mut Workspace, &mut Window, &mut Context<Workspace>) + Send>>,
+ open_mode: OpenMode,
window: &mut Window,
cx: &mut Context<Self>,
) -> Task<Result<Entity<Workspace>>> {
@@ -1005,6 +1009,8 @@ impl MultiWorkspace {
paths,
provisional_project_group_key,
excluding,
+ init,
+ open_mode,
window,
cx,
);
@@ -1067,6 +1073,8 @@ impl MultiWorkspace {
path_list: PathList,
project_group: Option<ProjectGroupKey>,
excluding: &[Entity<Workspace>],
+ init: Option<Box<dyn FnOnce(&mut Workspace, &mut Window, &mut Context<Workspace>) + Send>>,
+ open_mode: OpenMode,
window: &mut Window,
cx: &mut Context<Self>,
) -> Task<Result<Entity<Workspace>>> {
@@ -1133,8 +1141,8 @@ impl MultiWorkspace {
app_state,
requesting_window,
None,
- None,
- OpenMode::Activate,
+ init,
+ open_mode,
cx,
)
})
@@ -1675,7 +1683,15 @@ impl MultiWorkspace {
cx: &mut Context<Self>,
) -> Task<Result<Entity<Workspace>>> {
if self.multi_workspace_enabled(cx) {
- self.find_or_create_local_workspace(PathList::new(&paths), None, &[], window, cx)
+ self.find_or_create_local_workspace(
+ PathList::new(&paths),
+ None,
+ &[],
+ None,
+ OpenMode::Activate,
+ window,
+ cx,
+ )
} else {
let workspace = self.workspace().clone();
cx.spawn_in(window, async move |_this, cx| {
@@ -368,6 +368,8 @@ async fn test_find_or_create_local_workspace_reuses_active_workspace_when_sideba
PathList::new(&[PathBuf::from("/root_a")]),
None,
&[],
+ None,
+ OpenMode::Activate,
window,
cx,
)
@@ -431,6 +433,8 @@ async fn test_find_or_create_workspace_uses_project_group_key_when_paths_are_mis
Some(project_group_key.clone()),
|_options, _window, _cx| Task::ready(Ok(None)),
&[],
+ None,
+ OpenMode::Activate,
window,
cx,
)
@@ -496,6 +500,8 @@ async fn test_find_or_create_local_workspace_reuses_active_workspace_after_sideb
PathList::new(&[PathBuf::from("/root_a")]),
None,
&[],
+ None,
+ OpenMode::Activate,
window,
cx,
)
@@ -2513,6 +2513,7 @@ pub fn delete_unloaded_items(
#[cfg(test)]
mod tests {
use super::*;
+ use crate::OpenMode;
use crate::PathList;
use crate::ProjectGroupKey;
use crate::{
@@ -5066,7 +5067,15 @@ mod tests {
mw.remove(
vec![workspace_a.clone()],
move |this, window, cx| {
- this.find_or_create_local_workspace(path_list, None, &excluded, window, cx)
+ this.find_or_create_local_workspace(
+ path_list,
+ None,
+ &excluded,
+ None,
+ OpenMode::Activate,
+ window,
+ cx,
+ )
},
window,
cx,
@@ -89,7 +89,7 @@ use persistence::{SerializedWindowBounds, model::SerializedWorkspace};
pub use persistence::{
WorkspaceDb, delete_unloaded_items,
model::{
- DockStructure, ItemId, MultiWorkspaceState, SerializedMultiWorkspace,
+ DockData, DockStructure, ItemId, MultiWorkspaceState, SerializedMultiWorkspace,
SerializedProjectGroup, SerializedWorkspaceLocation, SessionWorkspace,
},
read_serialized_multi_workspaces, resolve_worktree_workspaces,
@@ -162,7 +162,7 @@ use crate::{dock::PanelSizeState, item::ItemBufferKind, notifications::Notificat
use crate::{
persistence::{
SerializedAxis,
- model::{DockData, SerializedItem, SerializedPane, SerializedPaneGroup},
+ model::{SerializedItem, SerializedPane, SerializedPaneGroup},
},
security_modal::SecurityModal,
};
@@ -2769,6 +2769,7 @@ async fn test_root_repo_common_dir(executor: BackgroundExecutor, cx: &mut TestAp
ref_name: Some("refs/heads/feature".into()),
sha: "abc123".into(),
is_main: false,
+ is_bare: false,
},
)
.await;
@@ -2871,6 +2872,7 @@ async fn test_linked_worktree_git_file_event_does_not_panic(
ref_name: Some("refs/heads/feature".into()),
sha: "abc123".into(),
is_main: false,
+ is_bare: false,
},
)
.await;
@@ -552,27 +552,6 @@ fn run_visual_tests(project_path: PathBuf, update_baseline: bool) -> Result<()>
}
}
- // Run Test 11: Thread target selector visual tests
- #[cfg(feature = "visual-tests")]
- {
- println!("\n--- Test 11: start_thread_in_selector (6 variants) ---");
- match run_start_thread_in_selector_visual_tests(app_state.clone(), &mut cx, update_baseline)
- {
- Ok(TestResult::Passed) => {
- println!("β start_thread_in_selector: PASSED");
- passed += 1;
- }
- Ok(TestResult::BaselineUpdated(_)) => {
- println!("β start_thread_in_selector: Baselines updated");
- updated += 1;
- }
- Err(e) => {
- eprintln!("β start_thread_in_selector: FAILED - {}", e);
- failed += 1;
- }
- }
- }
-
// Run Test: Sidebar with duplicate project names
println!("\n--- Test: sidebar_duplicate_names ---");
match run_sidebar_duplicate_project_names_visual_tests(
@@ -3066,30 +3045,6 @@ fn run_error_wrapping_visual_tests(
Ok(test_result)
}
-#[cfg(all(target_os = "macos", feature = "visual-tests"))]
-/// Runs a git command in the given directory and returns an error with
-/// stderr/stdout context if the command fails (non-zero exit status).
-fn run_git_command(args: &[&str], dir: &std::path::Path) -> Result<()> {
- let output = std::process::Command::new("git")
- .args(args)
- .current_dir(dir)
- .output()
- .with_context(|| format!("failed to spawn `git {}`", args.join(" ")))?;
-
- if !output.status.success() {
- let stdout = String::from_utf8_lossy(&output.stdout);
- let stderr = String::from_utf8_lossy(&output.stderr);
- anyhow::bail!(
- "`git {}` failed (exit {})\nstdout: {}\nstderr: {}",
- args.join(" "),
- output.status,
- stdout.trim(),
- stderr.trim(),
- );
- }
- Ok(())
-}
-
#[cfg(target_os = "macos")]
/// Helper to create a project, add a worktree at the given path, and return the project.
fn create_project_with_worktree(
@@ -3362,597 +3317,3 @@ fn run_sidebar_duplicate_project_names_visual_tests(
Ok(TestResult::Passed)
}
}
-
-#[cfg(all(target_os = "macos", feature = "visual-tests"))]
-fn run_start_thread_in_selector_visual_tests(
- app_state: Arc<AppState>,
- cx: &mut VisualTestAppContext,
- update_baseline: bool,
-) -> Result<TestResult> {
- use agent_ui::{AgentPanel, NewWorktreeBranchTarget, StartThreadIn, WorktreeCreationStatus};
-
- // Create a temp directory with a real git repo so "New Worktree" is enabled
- let temp_dir = tempfile::tempdir()?;
- let temp_path = temp_dir.keep();
- let canonical_temp = temp_path.canonicalize()?;
- let project_path = canonical_temp.join("project");
- std::fs::create_dir_all(&project_path)?;
-
- // Initialize git repo
- run_git_command(&["init"], &project_path)?;
- run_git_command(&["config", "user.email", "test@test.com"], &project_path)?;
- run_git_command(&["config", "user.name", "Test User"], &project_path)?;
-
- // Create source files
- let src_dir = project_path.join("src");
- std::fs::create_dir_all(&src_dir)?;
- std::fs::write(
- src_dir.join("main.rs"),
- r#"fn main() {
- println!("Hello, world!");
-
- let x = 42;
- let y = x * 2;
-
- if y > 50 {
- println!("y is greater than 50");
- } else {
- println!("y is not greater than 50");
- }
-
- for i in 0..10 {
- println!("i = {}", i);
- }
-}
-
-fn helper_function(a: i32, b: i32) -> i32 {
- a + b
-}
-"#,
- )?;
-
- std::fs::write(
- project_path.join("Cargo.toml"),
- r#"[package]
-name = "test_project"
-version = "0.1.0"
-edition = "2021"
-"#,
- )?;
-
- // Commit so git status is clean
- run_git_command(&["add", "."], &project_path)?;
- run_git_command(&["commit", "-m", "Initial commit"], &project_path)?;
-
- let project = cx.update(|cx| {
- project::Project::local(
- app_state.client.clone(),
- app_state.node_runtime.clone(),
- app_state.user_store.clone(),
- app_state.languages.clone(),
- app_state.fs.clone(),
- None,
- project::LocalProjectFlags {
- init_worktree_trust: false,
- ..Default::default()
- },
- cx,
- )
- });
-
- // Use a wide window so we see project panel + editor + agent panel
- let window_size = size(px(1280.0), px(800.0));
- let bounds = Bounds {
- origin: point(px(0.0), px(0.0)),
- size: window_size,
- };
-
- let workspace_window: WindowHandle<MultiWorkspace> = cx
- .update(|cx| {
- cx.open_window(
- WindowOptions {
- window_bounds: Some(WindowBounds::Windowed(bounds)),
- focus: false,
- show: false,
- ..Default::default()
- },
- |window, cx| {
- let workspace = cx.new(|cx| {
- Workspace::new(None, project.clone(), app_state.clone(), window, cx)
- });
- cx.new(|cx| MultiWorkspace::new(workspace, window, cx))
- },
- )
- })
- .context("Failed to open thread target selector test window")?;
-
- cx.run_until_parked();
-
- // Create the sidebar outside the MultiWorkspace update to avoid a
- // re-entrant read panic (Sidebar::new reads the MultiWorkspace).
- let sidebar = cx
- .update_window(workspace_window.into(), |root_view, window, cx| {
- let multi_workspace_handle: Entity<MultiWorkspace> = root_view.downcast().unwrap();
- cx.new(|cx| sidebar::Sidebar::new(multi_workspace_handle, window, cx))
- })
- .context("Failed to create sidebar")?;
-
- workspace_window
- .update(cx, |multi_workspace, _window, cx| {
- multi_workspace.register_sidebar(sidebar.clone(), cx);
- })
- .context("Failed to register sidebar")?;
-
- // Open the sidebar
- workspace_window
- .update(cx, |multi_workspace, window, cx| {
- multi_workspace.toggle_sidebar(window, cx);
- })
- .context("Failed to toggle sidebar")?;
-
- cx.run_until_parked();
-
- // Add the git project as a worktree
- let add_worktree_task = workspace_window
- .update(cx, |multi_workspace, _window, cx| {
- let workspace = multi_workspace.workspaces().next().unwrap();
- let project = workspace.read(cx).project().clone();
- project.update(cx, |project, cx| {
- project.find_or_create_worktree(&project_path, true, cx)
- })
- })
- .context("Failed to start adding worktree")?;
-
- cx.background_executor.allow_parking();
- cx.foreground_executor
- .block_test(add_worktree_task)
- .context("Failed to add worktree")?;
- cx.background_executor.forbid_parking();
-
- cx.run_until_parked();
-
- // Wait for worktree scan and git status
- for _ in 0..5 {
- cx.advance_clock(Duration::from_millis(100));
- cx.run_until_parked();
- }
-
- // Open the project panel
- let (weak_workspace, async_window_cx) = workspace_window
- .update(cx, |multi_workspace, window, cx| {
- let workspace = multi_workspace.workspaces().next().unwrap();
- (workspace.read(cx).weak_handle(), window.to_async(cx))
- })
- .context("Failed to get workspace handle")?;
-
- cx.background_executor.allow_parking();
- let project_panel = cx
- .foreground_executor
- .block_test(ProjectPanel::load(weak_workspace, async_window_cx))
- .context("Failed to load project panel")?;
- cx.background_executor.forbid_parking();
-
- workspace_window
- .update(cx, |multi_workspace, window, cx| {
- let workspace = multi_workspace.workspaces().next().unwrap();
- workspace.update(cx, |workspace, cx| {
- workspace.add_panel(project_panel, window, cx);
- workspace.open_panel::<ProjectPanel>(window, cx);
- });
- })
- .context("Failed to add project panel")?;
-
- cx.run_until_parked();
-
- // Open main.rs in the editor
- let open_file_task = workspace_window
- .update(cx, |multi_workspace, window, cx| {
- let workspace = multi_workspace.workspaces().next().unwrap();
- workspace.update(cx, |workspace, cx| {
- let worktree = workspace.project().read(cx).worktrees(cx).next();
- if let Some(worktree) = worktree {
- let worktree_id = worktree.read(cx).id();
- let rel_path: std::sync::Arc<util::rel_path::RelPath> =
- util::rel_path::rel_path("src/main.rs").into();
- let project_path: project::ProjectPath = (worktree_id, rel_path).into();
- Some(workspace.open_path(project_path, None, true, window, cx))
- } else {
- None
- }
- })
- })
- .log_err()
- .flatten();
-
- if let Some(task) = open_file_task {
- cx.background_executor.allow_parking();
- cx.foreground_executor.block_test(task).log_err();
- cx.background_executor.forbid_parking();
- }
-
- cx.run_until_parked();
-
- // Load the AgentPanel
- let (weak_workspace, async_window_cx) = workspace_window
- .update(cx, |multi_workspace, window, cx| {
- let workspace = multi_workspace.workspaces().next().unwrap();
- (workspace.read(cx).weak_handle(), window.to_async(cx))
- })
- .context("Failed to get workspace handle for agent panel")?;
-
- // Register an observer so that workspaces created by the worktree creation
- // flow get AgentPanel and ProjectPanel loaded automatically. Without this,
- // `workspace.panel::<AgentPanel>(cx)` returns None in the new workspace and
- // the creation flow's `focus_panel::<AgentPanel>` call is a no-op.
- let _workspace_observer = cx.update(|cx| {
- cx.observe_new(move |workspace: &mut Workspace, window, cx| {
- let Some(window) = window else { return };
- let panels_task = cx.spawn_in(window, async move |workspace_handle, cx| {
- let project_panel = ProjectPanel::load(workspace_handle.clone(), cx.clone());
- let agent_panel = AgentPanel::load(workspace_handle.clone(), cx.clone());
- if let Ok(panel) = project_panel.await {
- workspace_handle
- .update_in(cx, |workspace, window, cx| {
- workspace.add_panel(panel, window, cx);
- })
- .log_err();
- }
- if let Ok(panel) = agent_panel.await {
- workspace_handle
- .update_in(cx, |workspace, window, cx| {
- workspace.add_panel(panel, window, cx);
- })
- .log_err();
- }
- anyhow::Ok(())
- });
- workspace.set_panels_task(panels_task);
- })
- });
-
- cx.background_executor.allow_parking();
- let panel = cx
- .foreground_executor
- .block_test(AgentPanel::load(weak_workspace, async_window_cx))
- .context("Failed to load AgentPanel")?;
- cx.background_executor.forbid_parking();
-
- workspace_window
- .update(cx, |multi_workspace, window, cx| {
- let workspace = multi_workspace.workspaces().next().unwrap();
- workspace.update(cx, |workspace, cx| {
- workspace.add_panel(panel.clone(), window, cx);
- workspace.open_panel::<AgentPanel>(window, cx);
- });
- })
- .context("Failed to add and open AgentPanel")?;
-
- cx.run_until_parked();
-
- // Inject the stub server and open a thread so the toolbar is visible
- let connection = StubAgentConnection::new();
- let stub_agent: Rc<dyn AgentServer> = Rc::new(StubAgentServer::new(connection));
-
- cx.update_window(workspace_window.into(), |_, window, cx| {
- panel.update(cx, |panel, cx| {
- panel.open_external_thread_with_server(stub_agent.clone(), window, cx);
- });
- })?;
-
- cx.run_until_parked();
-
- // ---- Screenshot 1: Default "Local Project" selector (dropdown closed) ----
- cx.update_window(workspace_window.into(), |_, window, _cx| {
- window.refresh();
- })?;
- cx.run_until_parked();
-
- let result_default = run_visual_test(
- "start_thread_in_selector_default",
- workspace_window.into(),
- cx,
- update_baseline,
- );
-
- // ---- Screenshot 2: Dropdown open showing menu entries ----
- cx.update_window(workspace_window.into(), |_, window, cx| {
- panel.update(cx, |panel, cx| {
- panel.open_start_thread_in_menu_for_tests(window, cx);
- });
- })?;
- cx.run_until_parked();
-
- cx.update_window(workspace_window.into(), |_, window, _cx| {
- window.refresh();
- })?;
- cx.run_until_parked();
-
- let result_open_dropdown = run_visual_test(
- "start_thread_in_selector_open",
- workspace_window.into(),
- cx,
- update_baseline,
- );
-
- // ---- Screenshot 3: "New Worktree" selected (dropdown closed, label changed) ----
- // First dismiss the dropdown, then change the target so the toolbar label is visible
- cx.update_window(workspace_window.into(), |_, _window, cx| {
- panel.update(cx, |panel, cx| {
- panel.close_start_thread_in_menu_for_tests(cx);
- });
- })?;
- cx.run_until_parked();
-
- cx.update_window(workspace_window.into(), |_, _window, cx| {
- panel.update(cx, |panel, cx| {
- panel.set_start_thread_in_for_tests(
- StartThreadIn::NewWorktree {
- worktree_name: None,
- branch_target: NewWorktreeBranchTarget::default(),
- },
- cx,
- );
- });
- })?;
- cx.run_until_parked();
-
- cx.update_window(workspace_window.into(), |_, window, _cx| {
- window.refresh();
- })?;
- cx.run_until_parked();
-
- let result_new_worktree = run_visual_test(
- "start_thread_in_selector_new_worktree",
- workspace_window.into(),
- cx,
- update_baseline,
- );
-
- // ---- Screenshot 4: "Creating worktreeβ¦" status banner ----
- cx.update_window(workspace_window.into(), |_, _window, cx| {
- panel.update(cx, |panel, cx| {
- panel
- .set_worktree_creation_status_for_tests(Some(WorktreeCreationStatus::Creating), cx);
- });
- })?;
- cx.run_until_parked();
-
- cx.update_window(workspace_window.into(), |_, window, _cx| {
- window.refresh();
- })?;
- cx.run_until_parked();
-
- let result_creating = run_visual_test(
- "worktree_creation_status_creating",
- workspace_window.into(),
- cx,
- update_baseline,
- );
-
- // ---- Screenshot 5: Error status banner ----
- cx.update_window(workspace_window.into(), |_, _window, cx| {
- panel.update(cx, |panel, cx| {
- panel.set_worktree_creation_status_for_tests(
- Some(WorktreeCreationStatus::Error(
- "Failed to create worktree: branch already exists".into(),
- )),
- cx,
- );
- });
- })?;
- cx.run_until_parked();
-
- cx.update_window(workspace_window.into(), |_, window, _cx| {
- window.refresh();
- })?;
- cx.run_until_parked();
-
- let result_error = run_visual_test(
- "worktree_creation_status_error",
- workspace_window.into(),
- cx,
- update_baseline,
- );
-
- // ---- Screenshot 6: Worktree creation succeeded ----
- // Clear the error status and re-select New Worktree to ensure a clean state.
- cx.update_window(workspace_window.into(), |_, _window, cx| {
- panel.update(cx, |panel, cx| {
- panel.set_worktree_creation_status_for_tests(None, cx);
- });
- })?;
- cx.run_until_parked();
-
- cx.update_window(workspace_window.into(), |_, window, cx| {
- window.dispatch_action(
- Box::new(StartThreadIn::NewWorktree {
- worktree_name: None,
- branch_target: NewWorktreeBranchTarget::default(),
- }),
- cx,
- );
- })?;
- cx.run_until_parked();
-
- // Insert a message into the active thread's message editor and submit.
- let thread_view = cx
- .read(|cx| panel.read(cx).active_thread_view(cx))
- .ok_or_else(|| anyhow::anyhow!("No active thread view"))?;
-
- cx.update_window(workspace_window.into(), |_, window, cx| {
- let message_editor = thread_view.read(cx).message_editor.clone();
- message_editor.update(cx, |message_editor, cx| {
- message_editor.set_message(
- vec![acp::ContentBlock::Text(acp::TextContent::new(
- "Add a CLI flag to set the log level".to_string(),
- ))],
- window,
- cx,
- );
- message_editor.send(cx);
- });
- })?;
- cx.run_until_parked();
-
- // Wait for the full worktree creation flow to complete. The creation status
- // is cleared to `None` at the very end of the async task, after panels are
- // loaded, the agent panel is focused, and the new workspace is activated.
- cx.background_executor.allow_parking();
- let mut creation_complete = false;
- for _ in 0..120 {
- cx.run_until_parked();
- let status_cleared = cx.read(|cx| {
- panel
- .read(cx)
- .worktree_creation_status_for_tests()
- .is_none()
- });
- let workspace_count = workspace_window.update(cx, |multi_workspace, _window, _cx| {
- multi_workspace.workspaces().count()
- })?;
- if workspace_count == 2 && status_cleared {
- creation_complete = true;
- break;
- }
- cx.advance_clock(Duration::from_millis(100));
- }
- cx.background_executor.forbid_parking();
-
- if !creation_complete {
- return Err(anyhow::anyhow!("Worktree creation did not complete"));
- }
-
- // The creation flow called `external_thread` on the new workspace's agent
- // panel, which tried to launch a real agent binary and failed. Replace the
- // error state by injecting the stub server, and shrink the panel so the
- // editor content is visible.
- workspace_window.update(cx, |multi_workspace, window, cx| {
- let new_workspace = multi_workspace.workspaces().nth(1).unwrap();
- new_workspace.update(cx, |workspace, cx| {
- if let Some(new_panel) = workspace.panel::<AgentPanel>(cx) {
- new_panel.update(cx, |panel, cx| {
- panel.open_external_thread_with_server(stub_agent.clone(), window, cx);
- });
- }
- });
- })?;
- cx.run_until_parked();
-
- // Type and send a message so the thread target dropdown disappears.
- let new_panel = workspace_window.update(cx, |multi_workspace, _window, cx| {
- let new_workspace = multi_workspace.workspaces().nth(1).unwrap();
- new_workspace.read(cx).panel::<AgentPanel>(cx)
- })?;
- if let Some(new_panel) = new_panel {
- let new_thread_view = cx.read(|cx| new_panel.read(cx).active_thread_view(cx));
- if let Some(new_thread_view) = new_thread_view {
- cx.update_window(workspace_window.into(), |_, window, cx| {
- let message_editor = new_thread_view.read(cx).message_editor.clone();
- message_editor.update(cx, |editor, cx| {
- editor.set_message(
- vec![acp::ContentBlock::Text(acp::TextContent::new(
- "Add a CLI flag to set the log level".to_string(),
- ))],
- window,
- cx,
- );
- editor.send(cx);
- });
- })?;
- cx.run_until_parked();
- }
- }
-
- cx.update_window(workspace_window.into(), |_, window, _cx| {
- window.refresh();
- })?;
- cx.run_until_parked();
-
- let result_succeeded = run_visual_test(
- "worktree_creation_succeeded",
- workspace_window.into(),
- cx,
- update_baseline,
- );
-
- // Clean up β drop the workspace observer first so no new panels are
- // registered on workspaces created during teardown.
- drop(_workspace_observer);
-
- workspace_window
- .update(cx, |multi_workspace, _window, cx| {
- let workspace = multi_workspace.workspaces().next().unwrap();
- let project = workspace.read(cx).project().clone();
- project.update(cx, |project, cx| {
- let worktree_ids: Vec<_> =
- project.worktrees(cx).map(|wt| wt.read(cx).id()).collect();
- for id in worktree_ids {
- project.remove_worktree(id, cx);
- }
- });
- })
- .log_err();
-
- cx.run_until_parked();
-
- cx.update_window(workspace_window.into(), |_, window, _cx| {
- window.remove_window();
- })
- .log_err();
-
- cx.run_until_parked();
-
- for _ in 0..15 {
- cx.advance_clock(Duration::from_millis(100));
- cx.run_until_parked();
- }
-
- // Delete the preserved temp directory so visual-test runs don't
- // accumulate filesystem artifacts.
- if let Err(err) = std::fs::remove_dir_all(&temp_path) {
- log::warn!(
- "failed to clean up visual-test temp dir {}: {err}",
- temp_path.display()
- );
- }
-
- // Reset feature flags
- cx.update(|cx| {
- cx.update_flags(false, vec![]);
- });
-
- let results = [
- ("default", result_default),
- ("open_dropdown", result_open_dropdown),
- ("new_worktree", result_new_worktree),
- ("creating", result_creating),
- ("error", result_error),
- ("succeeded", result_succeeded),
- ];
-
- let mut has_baseline_update = None;
- let mut failures = Vec::new();
-
- for (name, result) in &results {
- match result {
- Ok(TestResult::Passed) => {}
- Ok(TestResult::BaselineUpdated(p)) => {
- has_baseline_update = Some(p.clone());
- }
- Err(e) => {
- failures.push(format!("{}: {}", name, e));
- }
- }
- }
-
- if !failures.is_empty() {
- Err(anyhow::anyhow!(
- "start_thread_in_selector failures: {}",
- failures.join("; ")
- ))
- } else if let Some(p) = has_baseline_update {
- Ok(TestResult::BaselineUpdated(p))
- } else {
- Ok(TestResult::Passed)
- }
-}