diff --git a/assets/icons/git_branch.svg b/assets/icons/git_branch.svg index fc6dcfe1b275974e64c292e56e7f962aa67cde06..3acb7d9bd5c4e1e2b21a9d0b953f6f1d2106e5f3 100644 --- a/assets/icons/git_branch.svg +++ b/assets/icons/git_branch.svg @@ -1 +1,6 @@ - + + + + + + diff --git a/assets/icons/git_branch_alt.svg b/assets/icons/git_branch_alt.svg deleted file mode 100644 index cf40195d8b2faaea629b04ec2430bd9e8afeff5f..0000000000000000000000000000000000000000 --- a/assets/icons/git_branch_alt.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/assets/icons/git_branch_plus.svg b/assets/icons/git_branch_plus.svg index cf60ce66b4086ba57ef4c2e56f3554d548e863fc..25669b8852d5bb24746c25172e34fd1a1d955e76 100644 --- a/assets/icons/git_branch_plus.svg +++ b/assets/icons/git_branch_plus.svg @@ -1,8 +1,8 @@ - - + + diff --git a/assets/icons/git_worktree.svg b/assets/icons/git_worktree.svg index 25b49bc69f34d8a742451709d4d4a164f29248b6..deb7172584fb43b501c81eb7f5f0a062d3207cf9 100644 --- a/assets/icons/git_worktree.svg +++ b/assets/icons/git_worktree.svg @@ -1,7 +1,7 @@ - - - - - + + + + + diff --git a/assets/icons/inception.svg b/assets/icons/inception.svg index 77a96c0b390ab9f2fe89143c2a89ba916000fabc..17015514670b9182937b27cac5e6ba16ddb12881 100644 --- a/assets/icons/inception.svg +++ b/assets/icons/inception.svg @@ -1,11 +1,9 @@ - - - - + + + + + + + - - - - - diff --git a/assets/icons/list_filter.svg b/assets/icons/list_filter.svg index 82f41f5f6832a8cb35e2703e0f8ce36d148454dd..fc3885437da0b57bed34bae14818dbec5da9a5f3 100644 --- a/assets/icons/list_filter.svg +++ b/assets/icons/list_filter.svg @@ -1 +1,5 @@ - + + + + + diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index c33d60adc3aef9f32aa173e257f1cc5d360a54db..c331c82046e3925f154c3b1fb2f55580392f2475 100644 --- a/assets/keymaps/default-linux.json +++ b/assets/keymaps/default-linux.json @@ -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", diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index e560bdb2628a8b188eb08606c758ef400094724a..21b260116ae5f8a58fcdb2871a3b4e8e6c692e4d 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -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", diff --git a/assets/keymaps/default-windows.json b/assets/keymaps/default-windows.json index f4824c40906435b351978da3259328d84263fb54..d3e185a6936266b6fde722d5622d6ac68d9dbf98 100644 --- a/assets/keymaps/default-windows.json +++ b/assets/keymaps/default-windows.json @@ -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", diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index 01a6ce4752502cae6b0744e1f0be2bbf886e1b8a..1abb7ac6ea5d2b70badc2dce6403b467b7b73188 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -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, #[serde(default)] last_active_thread: Option, - #[serde(default)] - start_thread_in: Option, } #[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::(cx) { + workspace.focus_panel::(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::(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::(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::(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::(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, - label: SharedString, - suffix: Option, -} - -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 { - 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, + active_file_path: Option, +} + +#[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>, new_thread_menu_handle: PopoverMenuHandle, start_thread_in_menu_handle: PopoverMenuHandle, - thread_branch_menu_handle: PopoverMenuHandle, agent_panel_menu_handle: PopoverMenuHandle, agent_navigation_menu_handle: PopoverMenuHandle, agent_navigation_menu: Option>, _extension_subscription: Option, _project_subscription: Subscription, - _git_store_subscription: Subscription, zoomed: bool, pending_serialization: Option>>, new_user_onboarding: Entity, @@ -823,7 +757,6 @@ pub struct AgentPanel { agent_layout_onboarding: Entity, 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, @@ -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.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, - ) { - 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) { - 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) { - 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) { - 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::>() - }; - - 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::>() - }) - .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 { 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.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, - content: Vec, - window: &mut Window, - cx: &mut Context, - ) { - 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, ) { - 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, - args: WorktreeCreationArgs, + action: &SwitchWorktree, + previous_workspace_state: PreviousWorkspaceState, window: &mut Window, cx: &mut Context, ) { + 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, + ) { + 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, + args: WorktreeCreationArgs, + previous_workspace_state: PreviousWorkspaceState, + window: &mut Window, + cx: &mut Context, + ) { + 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 = 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, all_paths: Vec, window_handle: Option>, - active_file_path: Option, + previous_workspace_state: PreviousWorkspaceState, path_remapping: Vec<(PathBuf, PathBuf)>, non_git_paths: Vec, 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.set_dock_structure(dock_structure, window, cx); + }, + ); + let task = multi_workspace.find_or_create_workspace( path_list, remote_connection_options, @@ -3588,6 +3351,8 @@ impl AgentPanel { ) }, &[], + Some(init), + OpenMode::Add, window, cx, ); @@ -3629,7 +3394,7 @@ impl AgentPanel { let initial_content = AgentInitialContent::ContentBlock { blocks: content, - auto_submit: true, + auto_submit: false, }; window_handle.update(cx, |_multi_workspace, window, cx| { @@ -3646,9 +3411,10 @@ impl AgentPanel { ); } - // If we had an active buffer, remap its path and reopen it. - let had_active_file = active_file_path.is_some(); - let remapped_active_path = active_file_path.and_then(|original_path| { + // Remap every previously-open file path into the new worktree. + // Paths that can't be remapped (e.g. files that don't exist on + // the target branch) are silently skipped — best-effort. + let remap_path = |original_path: PathBuf| -> Option { let best_match = path_remapping .iter() .filter_map(|(old_root, new_root)| { @@ -3668,17 +3434,35 @@ impl AgentPanel { } } None - }); + }; - if had_active_file && remapped_active_path.is_none() { - log::warn!( - "Active file could not be remapped to the new worktree; it will not be reopened" - ); + let remapped_active_path = previous_workspace_state + .active_file_path + .and_then(|p| remap_path(p)); + + // Collect all remapped paths, deduplicating and preserving order. + // The active file is placed last so it ends up as the focused tab. + let mut paths_to_open: Vec = Vec::new(); + let mut seen = HashSet::default(); + for path in previous_workspace_state.open_file_paths { + if let Some(remapped) = remap_path(path) { + if remapped_active_path.as_ref() != Some(&remapped) + && seen.insert(remapped.clone()) + { + paths_to_open.push(remapped); + } + } + } + + if let Some(active) = &remapped_active_path { + if seen.insert(active.clone()) { + paths_to_open.push(active.clone()); + } } - if let Some(path) = remapped_active_path { + if !paths_to_open.is_empty() { let open_task = workspace.open_paths( - vec![path], + paths_to_open, workspace::OpenOptions::default(), None, window, @@ -3686,19 +3470,24 @@ impl AgentPanel { ); cx.spawn(async move |_, _| -> anyhow::Result<()> { for item in open_task.await.into_iter().flatten() { - item?; + // Best-effort: files that don't exist on the target + // branch will fail to open and that's fine. + item.log_err(); } Ok(()) }) .detach_and_log_err(cx); } + }); + })?; - workspace.focus_panel::(window, cx); + window_handle.update(cx, |multi_workspace, window, cx| { + multi_workspace.activate(new_workspace.clone(), window, cx); + + new_workspace.update(cx, |workspace, cx| { + workspace.run_create_worktree_tasks(window, cx); - // If no active buffer was open, zoom the agent panel - // (equivalent to cmd-esc fullscreen behavior). - // This must happen after focus_panel, which activates - // and opens the panel in the dock. + workspace.focus_panel::(window, cx); if let Some(panel) = workspace.panel::(cx) { panel.update(cx, |panel, cx| { @@ -3714,14 +3503,6 @@ impl AgentPanel { ); }); } - }); - })?; - - window_handle.update(cx, |multi_workspace, window, cx| { - multi_workspace.activate(new_workspace.clone(), window, cx); - - new_workspace.update(cx, |workspace, cx| { - workspace.run_create_worktree_tasks(window, cx); }) })?; @@ -3736,7 +3517,6 @@ impl AgentPanel { }); } - this.start_thread_in = StartThreadIn::LocalProject; this.serialize(cx); cx.notify(); })?; @@ -4113,59 +3893,103 @@ impl AgentPanel { fn is_active_view_creating_worktree(&self, _cx: &App) -> bool { match &self.worktree_creation_status { - Some((view_id, WorktreeCreationStatus::Creating)) => { + Some((view_id, WorktreeCreationStatus::Creating(_))) => { self.active_conversation_view().map(|v| v.entity_id()) == Some(*view_id) } _ => false, } } - fn render_start_thread_in_selector(&self, cx: &mut Context) -> impl IntoElement { - let focus_handle = self.focus_handle(cx); + fn is_active_view_loading_worktree(&self, _cx: &App) -> bool { + match &self.worktree_creation_status { + Some((view_id, WorktreeCreationStatus::Loading(_))) => { + self.active_conversation_view().map(|v| v.entity_id()) == Some(*view_id) + } + _ => false, + } + } + + fn current_worktree_label(&self, cx: &App) -> SharedString { + let project = self.project.read(cx); + + if let Some(repo) = project.active_repository(cx) { + let repo = repo.read(cx); + let main_path = &repo.original_repo_abs_path; + let current_path = &repo.work_directory_abs_path; + + return linked_worktree_short_name(main_path, current_path) + .unwrap_or_else(|| "main".into()); + } + + project + .visible_worktrees(cx) + .next() + .and_then(|wt| { + wt.read(cx) + .abs_path() + .file_name() + .and_then(|name| name.to_str()) + .map(|name| SharedString::from(name.to_string())) + }) + .unwrap_or_else(|| "Worktree".into()) + } + fn render_start_thread_in_selector(&self, cx: &mut Context) -> impl IntoElement { let is_creating = self.is_active_view_creating_worktree(cx); + let is_loading = self.is_active_view_loading_worktree(cx); + let is_busy = is_creating || is_loading; - let trigger_parts = self - .start_thread_in - .trigger_label(self.project.read(cx), cx); + let label = match &self.worktree_creation_status { + Some((view_id, WorktreeCreationStatus::Creating(name))) + if self.active_conversation_view().map(|v| v.entity_id()) == Some(*view_id) => + { + SharedString::from(format!("Creating {name}…")) + } + Some((view_id, WorktreeCreationStatus::Loading(name))) + if self.active_conversation_view().map(|v| v.entity_id()) == Some(*view_id) => + { + SharedString::from(format!("Loading {name}…")) + } + _ => self.current_worktree_label(cx), + }; - let icon = if self.start_thread_in_menu_handle.is_deployed() { + let chevron_icon = if self.start_thread_in_menu_handle.is_deployed() { IconName::ChevronUp } else { IconName::ChevronDown }; - let trigger_button = ButtonLike::new("thread-target-trigger") - .disabled(is_creating) - .when_some(trigger_parts.prefix, |this, prefix| { - this.child(Label::new(prefix).color(Color::Muted)) - }) - .child(Label::new(trigger_parts.label)) - .when_some(trigger_parts.suffix, |this, suffix| { - this.child(Label::new(suffix).color(Color::Muted)) - }) - .child(Icon::new(icon).size(IconSize::XSmall).color(Color::Muted)); + let focus_handle = self.focus_handle(cx); + + let trigger_button = Button::new("thread-target-trigger", label) + .disabled(is_busy) + .loading(is_busy) + .start_icon( + Icon::new(IconName::GitWorktree) + .size(IconSize::Small) + .color(Color::Muted), + ) + .end_icon( + Icon::new(chevron_icon) + .size(IconSize::XSmall) + .color(Color::Muted), + ); let project = self.project.clone(); - let current_target = self.start_thread_in.clone(); - let fs = self.fs.clone(); PopoverMenu::new("thread-target-selector") .trigger_with_tooltip(trigger_button, { move |_window, cx| { Tooltip::for_action_in( - "Start Thread In…", - &CycleStartThreadIn, + "Select Worktree…", + &ToggleWorktreeSelector, &focus_handle, cx, ) } }) .menu(move |window, cx| { - let fs = fs.clone(); - Some(cx.new(|cx| { - ThreadWorktreePicker::new(project.clone(), ¤t_target, fs, window, cx) - })) + Some(cx.new(|cx| ThreadWorktreePicker::new(project.clone(), window, cx))) }) .with_handle(self.start_thread_in_menu_handle.clone()) .anchor(Corner::TopLeft) @@ -4175,51 +3999,6 @@ impl AgentPanel { }) } - fn render_new_worktree_branch_selector(&self, cx: &mut Context) -> impl IntoElement { - let is_creating = self.is_active_view_creating_worktree(cx); - - let project_ref = self.project.read(cx); - let trigger_parts = self - .start_thread_in - .branch_trigger_label(project_ref, cx) - .unwrap_or_else(|| StartThreadInLabel { - prefix: Some("From:".into()), - label: "HEAD".into(), - suffix: None, - }); - - let icon = if self.thread_branch_menu_handle.is_deployed() { - IconName::ChevronUp - } else { - IconName::ChevronDown - }; - - let trigger_button = ButtonLike::new("thread-branch-trigger") - .disabled(is_creating) - .when_some(trigger_parts.prefix, |this, prefix| { - this.child(Label::new(prefix).color(Color::Muted)) - }) - .child(Label::new(trigger_parts.label)) - .child(Icon::new(icon).size(IconSize::XSmall).color(Color::Muted)); - - let project = self.project.clone(); - let current_target = self.start_thread_in.clone(); - - PopoverMenu::new("thread-branch-selector") - .trigger_with_tooltip(trigger_button, Tooltip::text("Choose Worktree Branch…")) - .menu(move |window, cx| { - Some(cx.new(|cx| { - ThreadBranchPicker::new(project.clone(), ¤t_target, window, cx) - })) - }) - .with_handle(self.thread_branch_menu_handle.clone()) - .anchor(Corner::TopLeft) - .offset(gpui::Point { - x: px(1.0), - y: px(1.0), - }) - } - fn render_toolbar(&self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let agent_server_store = self.project.read(cx).agent_server_store().clone(); let has_visible_worktrees = self.project.read(cx).visible_worktrees(cx).next().is_some(); @@ -4306,7 +4085,6 @@ impl AgentPanel { { panel.update(cx, |panel, cx| { panel.selected_agent = Agent::NativeAgent; - panel.reset_start_thread_in_to_default(cx); let id = panel.create_thread(window, cx); panel.activate_retained_thread( id, true, window, cx, @@ -4394,9 +4172,6 @@ impl AgentPanel { panel.selected_agent = Agent::Custom { id: agent_id.clone(), }; - panel.reset_start_thread_in_to_default( - cx, - ); let id = panel.create_thread(window, cx); panel.activate_retained_thread( @@ -4574,14 +4349,6 @@ impl AgentPanel { && has_visible_worktrees && self.project_has_git_repository(cx), |this| this.child(self.render_start_thread_in_selector(cx)), - ) - .when( - agent_v2_enabled - && matches!( - self.start_thread_in, - StartThreadIn::NewWorktree { .. } - ), - |this| this.child(self.render_new_worktree_branch_selector(cx)), ), ) .child( @@ -4674,28 +4441,7 @@ impl AgentPanel { return None; } match status { - WorktreeCreationStatus::Creating => Some( - h_flex() - .absolute() - .bottom_12() - .w_full() - .p_2() - .gap_1() - .justify_center() - .bg(cx.theme().colors().editor_background) - .child( - Icon::new(IconName::LoadCircle) - .size(IconSize::Small) - .color(Color::Muted) - .with_rotate_animation(3), - ) - .child( - Label::new("Creating Worktree…") - .color(Color::Muted) - .size(LabelSize::Small), - ) - .into_any_element(), - ), + WorktreeCreationStatus::Creating(_) | WorktreeCreationStatus::Loading(_) => None, WorktreeCreationStatus::Error(message) => Some( Callout::new() .icon(IconName::XCircleFilled) @@ -5202,42 +4948,6 @@ impl AgentPanel { self.active_conversation_view() } - /// Sets the start_thread_in value directly, bypassing validation. - /// - /// This is a test-only helper for visual tests that need to show specific - /// start_thread_in states without requiring a real git repository. - pub fn set_start_thread_in_for_tests(&mut self, target: StartThreadIn, cx: &mut Context) { - self.start_thread_in = target; - cx.notify(); - } - - /// Returns the current worktree creation status. - /// - /// This is a test-only helper for visual tests. - pub fn worktree_creation_status_for_tests(&self) -> Option<&WorktreeCreationStatus> { - self.worktree_creation_status.as_ref().map(|(_, s)| s) - } - - /// Sets the worktree creation status directly, associating it with the - /// currently active conversation view. - /// - /// This is a test-only helper for visual tests that need to show the - /// "Creating worktree…" spinner or error banners. - pub fn set_worktree_creation_status_for_tests( - &mut self, - status: Option, - cx: &mut Context, - ) { - self.worktree_creation_status = status.map(|s| { - let view_id = self - .active_conversation_view() - .map(|v| v.entity_id()) - .unwrap_or_else(|| EntityId::from(0u64)); - (view_id, s) - }); - cx.notify(); - } - /// Opens the history view. /// /// This is a test-only helper that exposes the private `open_history()` @@ -6318,14 +6028,16 @@ mod tests { } #[gpui::test] - async fn test_thread_target_local_project(cx: &mut TestAppContext) { + async fn test_set_active_blocked_during_worktree_creation(cx: &mut TestAppContext) { init_test(cx); + + let fs = FakeFs::new(cx.executor()); cx.update(|cx| { agent::ThreadStore::init_global(cx); language_model::LanguageModelRegistry::test(cx); + ::set_global(fs.clone(), cx); }); - let fs = FakeFs::new(cx.executor()); fs.insert_tree( "/project", json!({ @@ -6336,7 +6048,6 @@ mod tests { }), ) .await; - fs.set_branch_name(Path::new("/project/.git"), Some("main")); let project = Project::test(fs.clone(), [Path::new("/project")], cx).await; @@ -6349,15 +6060,8 @@ mod tests { }) .unwrap(); - workspace.update(cx, |workspace, _cx| { - workspace.set_random_database_id(); - }); - let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx); - // Wait for the project to discover the git repository. - cx.run_until_parked(); - let panel = workspace.update_in(cx, |workspace, window, cx| { let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx)); workspace.add_panel(panel.clone(), window, cx); @@ -6366,330 +6070,36 @@ mod tests { cx.run_until_parked(); - // Default thread target should be LocalProject. - panel.read_with(cx, |panel, _cx| { - assert_eq!( - *panel.start_thread_in(), - StartThreadIn::LocalProject, - "default thread target should be LocalProject" - ); - }); - - // Start a new thread with the default LocalProject target. - // Use StubAgentServer so the thread connects immediately in tests. + // set_active no longer creates threads — verify it's a no-op. panel.update_in(cx, |panel, window, cx| { - panel.open_external_thread_with_server( - Rc::new(StubAgentServer::default_response()), - window, - cx, - ); - }); - - cx.run_until_parked(); - - // MultiWorkspace should still have exactly one workspace (no worktree created). - multi_workspace - .read_with(cx, |multi_workspace, _cx| { - assert_eq!( - multi_workspace.workspaces().count(), - 1, - "LocalProject should not create a new workspace" - ); - }) - .unwrap(); - - // The thread should be active in the panel. - panel.read_with(cx, |panel, cx| { - assert!( - panel.active_agent_thread(cx).is_some(), - "a thread should be running in the current workspace" - ); - }); - - // The thread target should still be LocalProject (unchanged). - panel.read_with(cx, |panel, _cx| { - assert_eq!( - *panel.start_thread_in(), - StartThreadIn::LocalProject, - "thread target should remain LocalProject" - ); - }); - - // No worktree creation status should be set. - panel.read_with(cx, |panel, _cx| { + panel.base_view = BaseView::Uninitialized; + Panel::set_active(panel, true, window, cx); assert!( - panel.worktree_creation_status.is_none(), - "no worktree creation should have occurred" + matches!(panel.base_view, BaseView::Uninitialized), + "set_active should not create a thread" ); }); } - #[gpui::test] - async fn test_thread_target_does_not_sync_to_external_linked_worktree_with_invalid_branch_target( - cx: &mut TestAppContext, - ) { - use git::repository::Worktree as GitWorktree; + #[test] + fn test_deserialize_agent_variants() { + // PascalCase (legacy AgentType format, persisted in panel state) + assert_eq!( + serde_json::from_str::(r#""NativeAgent""#).unwrap(), + Agent::NativeAgent, + ); + assert_eq!( + serde_json::from_str::(r#"{"Custom":{"name":"my-agent"}}"#).unwrap(), + Agent::Custom { + id: "my-agent".into(), + }, + ); - init_test(cx); - cx.update(|cx| { - agent::ThreadStore::init_global(cx); - language_model::LanguageModelRegistry::test(cx); - }); - - let fs = FakeFs::new(cx.executor()); - fs.insert_tree( - "/project", - json!({ - ".git": {}, - "src": { - "main.rs": "fn main() {}" - } - }), - ) - .await; - fs.set_branch_name(Path::new("/project/.git"), Some("main")); - fs.insert_branches(Path::new("/project/.git"), &["main", "feature-worktree"]); - - let project = Project::test(fs.clone(), [Path::new("/project")], cx).await; - - let multi_workspace = - cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); - - let workspace = multi_workspace - .read_with(cx, |multi_workspace, _cx| { - multi_workspace.workspace().clone() - }) - .unwrap(); - - workspace.update(cx, |workspace, _cx| { - workspace.set_random_database_id(); - }); - - let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx); - - cx.run_until_parked(); - - let panel = workspace.update_in(cx, |workspace, window, cx| { - let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx)); - workspace.add_panel(panel.clone(), window, cx); - panel - }); - - cx.run_until_parked(); - - panel.update_in(cx, |panel, window, cx| { - panel.set_start_thread_in( - &StartThreadIn::NewWorktree { - worktree_name: Some("feature worktree".to_string()), - branch_target: NewWorktreeBranchTarget::CurrentBranch, - }, - window, - cx, - ); - }); - - fs.add_linked_worktree_for_repo( - Path::new("/project/.git"), - true, - GitWorktree { - path: PathBuf::from("/linked-feature-worktree"), - ref_name: Some("refs/heads/feature-worktree".into()), - sha: "abcdef1".into(), - is_main: false, - }, - ) - .await; - - project - .update(cx, |project, cx| project.git_scans_complete(cx)) - .await; - cx.run_until_parked(); - - panel.read_with(cx, |panel, _cx| { - assert_eq!( - *panel.start_thread_in(), - StartThreadIn::NewWorktree { - worktree_name: Some("feature worktree".to_string()), - branch_target: NewWorktreeBranchTarget::CurrentBranch, - }, - "thread target should remain a named new worktree when the external linked worktree does not match the selected branch target", - ); - }); - } - - #[gpui::test] - async fn test_thread_target_serialization_round_trip(cx: &mut TestAppContext) { - init_test(cx); - cx.update(|cx| { - agent::ThreadStore::init_global(cx); - language_model::LanguageModelRegistry::test(cx); - }); - - let fs = FakeFs::new(cx.executor()); - fs.insert_tree( - "/project", - json!({ - ".git": {}, - "src": { - "main.rs": "fn main() {}" - } - }), - ) - .await; - fs.set_branch_name(Path::new("/project/.git"), Some("main")); - cx.update(|cx| ::set_global(fs.clone(), cx)); - - let project = Project::test(fs.clone(), [Path::new("/project")], cx).await; - - let multi_workspace = - cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); - - let workspace = multi_workspace - .read_with(cx, |multi_workspace, _cx| { - multi_workspace.workspace().clone() - }) - .unwrap(); - - workspace.update(cx, |workspace, _cx| { - workspace.set_random_database_id(); - }); - - let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx); - - // Wait for the project to discover the git repository. - cx.run_until_parked(); - - let panel = workspace.update_in(cx, |workspace, window, cx| { - let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx)); - workspace.add_panel(panel.clone(), window, cx); - panel - }); - - cx.run_until_parked(); - - // Default should be LocalProject. - panel.read_with(cx, |panel, _cx| { - assert_eq!(*panel.start_thread_in(), StartThreadIn::LocalProject); - }); - - // Change thread target to NewWorktree. - panel.update_in(cx, |panel, window, cx| { - panel.set_start_thread_in( - &StartThreadIn::NewWorktree { - worktree_name: None, - branch_target: NewWorktreeBranchTarget::default(), - }, - window, - cx, - ); - }); - - panel.read_with(cx, |panel, _cx| { - assert_eq!( - *panel.start_thread_in(), - StartThreadIn::NewWorktree { - worktree_name: None, - branch_target: NewWorktreeBranchTarget::default(), - }, - "thread target should be NewWorktree after set_thread_target" - ); - }); - - // Let serialization complete. - cx.run_until_parked(); - - // Load a fresh panel from the serialized data. - let async_cx = cx.update(|window, cx| window.to_async(cx)); - let loaded_panel = AgentPanel::load(workspace.downgrade(), async_cx) - .await - .expect("panel load should succeed"); - cx.run_until_parked(); - - loaded_panel.read_with(cx, |panel, _cx| { - assert_eq!( - *panel.start_thread_in(), - StartThreadIn::NewWorktree { - worktree_name: None, - branch_target: NewWorktreeBranchTarget::default(), - }, - "thread target should survive serialization round-trip" - ); - }); - } - - #[gpui::test] - async fn test_set_active_blocked_during_worktree_creation(cx: &mut TestAppContext) { - init_test(cx); - - let fs = FakeFs::new(cx.executor()); - cx.update(|cx| { - agent::ThreadStore::init_global(cx); - language_model::LanguageModelRegistry::test(cx); - ::set_global(fs.clone(), cx); - }); - - fs.insert_tree( - "/project", - json!({ - ".git": {}, - "src": { - "main.rs": "fn main() {}" - } - }), - ) - .await; - - let project = Project::test(fs.clone(), [Path::new("/project")], cx).await; - - let multi_workspace = - cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); - - let workspace = multi_workspace - .read_with(cx, |multi_workspace, _cx| { - multi_workspace.workspace().clone() - }) - .unwrap(); - - let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx); - - let panel = workspace.update_in(cx, |workspace, window, cx| { - let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx)); - workspace.add_panel(panel.clone(), window, cx); - panel - }); - - cx.run_until_parked(); - - // set_active no longer creates threads — verify it's a no-op. - panel.update_in(cx, |panel, window, cx| { - panel.base_view = BaseView::Uninitialized; - Panel::set_active(panel, true, window, cx); - assert!( - matches!(panel.base_view, BaseView::Uninitialized), - "set_active should not create a thread" - ); - }); - } - - #[test] - fn test_deserialize_agent_variants() { - // PascalCase (legacy AgentType format, persisted in panel state) - assert_eq!( - serde_json::from_str::(r#""NativeAgent""#).unwrap(), - Agent::NativeAgent, - ); - assert_eq!( - serde_json::from_str::(r#"{"Custom":{"name":"my-agent"}}"#).unwrap(), - Agent::Custom { - id: "my-agent".into(), - }, - ); - - // Legacy TextThread variant deserializes to NativeAgent - assert_eq!( - serde_json::from_str::(r#""TextThread""#).unwrap(), - Agent::NativeAgent, - ); + // Legacy TextThread variant deserializes to NativeAgent + assert_eq!( + serde_json::from_str::(r#""TextThread""#).unwrap(), + Agent::NativeAgent, + ); // snake_case (canonical format) assert_eq!( @@ -6848,6 +6258,7 @@ mod tests { name: "main".to_string(), }, }, + PreviousWorkspaceState::empty(), window, cx, ); @@ -6980,21 +6391,14 @@ mod tests { cx.run_until_parked(); - // Set the selected agent to Codex (a custom agent) and start_thread_in - // to NewWorktree. We do this AFTER opening the thread because - // open_external_thread_with_server overrides selected_agent. - panel.update_in(cx, |panel, window, cx| { + // Set the selected agent to Codex (a custom agent). We do this AFTER + // opening the thread because open_external_thread_with_server overrides + // selected_agent. + panel.update_in(cx, |panel, _window, cx| { panel.selected_agent = Agent::Custom { id: CODEX_ID.into(), }; - panel.set_start_thread_in( - &StartThreadIn::NewWorktree { - worktree_name: None, - branch_target: NewWorktreeBranchTarget::default(), - }, - window, - cx, - ); + cx.notify(); }); // Verify the panel has the Codex agent selected. @@ -7007,8 +6411,7 @@ mod tests { ); }); - // Directly call handle_worktree_creation_requested, which is what - // handle_first_send_requested does when start_thread_in == NewWorktree. + // Directly call handle_worktree_requested to trigger worktree creation. let content = vec![acp::ContentBlock::Text(acp::TextContent::new( "Hello from test", ))]; @@ -7019,6 +6422,7 @@ mod tests { worktree_name: None, branch_target: NewWorktreeBranchTarget::default(), }, + PreviousWorkspaceState::empty(), window, cx, ); @@ -7027,14 +6431,6 @@ mod tests { // Let the async worktree creation + workspace setup complete. cx.run_until_parked(); - panel.read_with(cx, |panel, _cx| { - assert_eq!( - panel.start_thread_in(), - &StartThreadIn::LocalProject, - "the original panel should reset start_thread_in back to the local project after creating a worktree workspace", - ); - }); - // Find the new workspace's AgentPanel and verify it used the Codex agent. let found_codex = multi_workspace .read_with(cx, |multi_workspace, cx| { @@ -7900,21 +7296,8 @@ mod tests { }); cx.run_until_parked(); - // Set start_thread_in to LinkedWorktree to bypass git worktree - // creation and directly test workspace opening for a known path. + // Trigger worktree creation for a known linked path. let linked_path = PathBuf::from("/project"); - panel.update_in(cx, |panel, window, cx| { - panel.set_start_thread_in( - &StartThreadIn::LinkedWorktree { - path: linked_path.clone(), - display_name: "project".to_string(), - }, - window, - cx, - ); - }); - - // Trigger worktree creation. let content = vec![acp::ContentBlock::Text(acp::TextContent::new( "Hello from remote test", ))]; @@ -7923,7 +7306,9 @@ mod tests { content, WorktreeCreationArgs::Linked { worktree_path: linked_path, + display_name: "test-worktree".to_string(), }, + PreviousWorkspaceState::empty(), window, cx, ); @@ -7962,6 +7347,206 @@ mod tests { .unwrap(); } + #[gpui::test] + async fn test_linked_worktree_switch_remaps_open_files(cx: &mut TestAppContext) { + init_test(cx); + + let app_state = cx.update(|cx| { + agent::ThreadStore::init_global(cx); + language_model::LanguageModelRegistry::test(cx); + + let app_state = workspace::AppState::test(cx); + workspace::init(app_state.clone(), cx); + app_state + }); + + let fs = app_state.fs.as_fake(); + fs.insert_tree( + "/project", + json!({ + ".git": {}, + "src": { + "main.rs": "fn main() {}", + "lib.rs": "pub fn hello() {}" + } + }), + ) + .await; + fs.set_branch_name(Path::new("/project/.git"), Some("main")); + + // Create a linked worktree directory with the same file structure. + let linked_path = PathBuf::from("/linked-worktree"); + fs.add_linked_worktree_for_repo( + Path::new("/project/.git"), + true, + git::repository::Worktree { + path: linked_path.clone(), + ref_name: Some("refs/heads/feature".into()), + sha: "abc123".into(), + is_main: false, + is_bare: false, + }, + ) + .await; + fs.insert_tree( + "/linked-worktree", + json!({ + "src": { + "main.rs": "fn main() { // linked }", + "lib.rs": "pub fn hello() { // linked }" + } + }), + ) + .await; + + let project = Project::test(app_state.fs.clone(), [Path::new("/project")], cx).await; + + let multi_workspace = + cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + multi_workspace + .update(cx, |multi_workspace, _, cx| { + multi_workspace.open_sidebar(cx); + }) + .unwrap(); + + let workspace = multi_workspace + .read_with(cx, |multi_workspace, _cx| { + multi_workspace.workspace().clone() + }) + .unwrap(); + + workspace.update(cx, |workspace, _cx| { + workspace.set_random_database_id(); + }); + + // Register observer so new workspaces get AgentPanel automatically. + cx.update(|cx| { + cx.observe_new( + |workspace: &mut Workspace, + window: Option<&mut Window>, + cx: &mut Context| { + if let Some(window) = window { + let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx)); + workspace.add_panel(panel, window, cx); + } + }, + ) + .detach(); + }); + + let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx); + cx.run_until_parked(); + + let panel = workspace.update_in(cx, |workspace, window, cx| { + let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx)); + workspace.add_panel(panel.clone(), window, cx); + panel + }); + + cx.run_until_parked(); + + // Open files in the original workspace. + workspace + .update_in(cx, |workspace, window, cx| { + workspace.open_paths( + vec![ + PathBuf::from("/project/src/main.rs"), + PathBuf::from("/project/src/lib.rs"), + ], + workspace::OpenOptions::default(), + None, + window, + cx, + ) + }) + .await; + cx.run_until_parked(); + + // Verify files are open. + workspace.read_with(cx, |workspace, cx| { + let open_paths = workspace.open_item_abs_paths(cx); + assert!( + open_paths.iter().any(|p| p.ends_with("src/main.rs")), + "main.rs should be open, got: {open_paths:?}" + ); + assert!( + open_paths.iter().any(|p| p.ends_with("src/lib.rs")), + "lib.rs should be open, got: {open_paths:?}" + ); + }); + + // Open a thread so the panel is in a valid state. + panel.update_in(cx, |panel, window, cx| { + panel.open_external_thread_with_server( + Rc::new(StubAgentServer::default_response()), + window, + cx, + ); + }); + cx.run_until_parked(); + + // Build a PreviousWorkspaceState with the open files. + let previous_state = + workspace.update_in(cx, |workspace, window, cx| PreviousWorkspaceState { + dock_structure: workspace.capture_dock_state(window, cx), + open_file_paths: vec![ + PathBuf::from("/project/src/main.rs"), + PathBuf::from("/project/src/lib.rs"), + ], + active_file_path: Some(PathBuf::from("/project/src/main.rs")), + }); + + // Trigger the linked worktree switch. + let content = vec![acp::ContentBlock::Text(acp::TextContent::new( + "Hello from linked worktree test", + ))]; + panel.update_in(cx, |panel, window, cx| { + panel.handle_worktree_requested( + content, + WorktreeCreationArgs::Linked { + worktree_path: linked_path.clone(), + display_name: "feature".to_string(), + }, + previous_state, + window, + cx, + ); + }); + + cx.run_until_parked(); + + // Find the new workspace. + let new_workspace = multi_workspace + .read_with(cx, |multi_workspace, _cx| { + multi_workspace + .workspaces() + .find(|ws| ws.entity_id() != workspace.entity_id()) + .cloned() + }) + .unwrap() + .expect("a new workspace should have been created for the linked worktree"); + + // Verify that files were remapped and opened in the new workspace. + // The original /project/src/main.rs should now be /linked-worktree/src/main.rs. + let new_open_paths = + new_workspace.read_with(cx, |workspace, cx| workspace.open_item_abs_paths(cx)); + + assert!( + new_open_paths + .iter() + .any(|p| p == &linked_path.join("src/main.rs")), + "main.rs should have been remapped to the linked worktree. \ + Open paths: {new_open_paths:?}" + ); + assert!( + new_open_paths + .iter() + .any(|p| p == &linked_path.join("src/lib.rs")), + "lib.rs should have been remapped to the linked worktree. \ + Open paths: {new_open_paths:?}" + ); + } + #[gpui::test] async fn test_selected_agent_syncs_when_navigating_between_threads(cx: &mut TestAppContext) { let (panel, mut cx) = setup_panel(cx).await; diff --git a/crates/agent_ui/src/agent_ui.rs b/crates/agent_ui/src/agent_ui.rs index d5883f9247dd7a1ec6aae9393f820294bcc71047..ba68ea093bf51ab4061001c86b30fa99bb2172a7 100644 --- a/crates/agent_ui/src/agent_ui.rs +++ b/crates/agent_ui/src/agent_ui.rs @@ -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, - #[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, + 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. diff --git a/crates/agent_ui/src/conversation_view/thread_view.rs b/crates/agent_ui/src/conversation_view/thread_view.rs index 21ea41b466a2ce671c3ac1e04b6e08928909e2ad..68b23e4270d96710c2dfaaa5755120239529d8f7 100644 --- a/crates/agent_ui/src/conversation_view/thread_view.rs +++ b/crates/agent_ui/src/conversation_view/thread_view.rs @@ -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 }, 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::(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; diff --git a/crates/agent_ui/src/thread_branch_picker.rs b/crates/agent_ui/src/thread_branch_picker.rs deleted file mode 100644 index b2961624332fa0c71927a24f58cb3908a25e4e48..0000000000000000000000000000000000000000 --- a/crates/agent_ui/src/thread_branch_picker.rs +++ /dev/null @@ -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>, - focus_handle: FocusHandle, - _subscriptions: Vec, -} - -impl ThreadBranchPicker { - pub fn new( - project: Entity, - current_target: &StartThreadIn, - window: &mut Window, - cx: &mut Context, - ) -> Self { - let project_worktree_paths: HashSet = 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 for ThreadBranchPicker {} - -impl Render for ThreadBranchPicker { - fn render(&mut self, _window: &mut Window, cx: &mut Context) -> 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, - }, - CreateNamed { - name: String, - }, -} - -pub(crate) struct ThreadBranchPickerDelegate { - matches: Vec, - all_branches: Vec, - occupied_branches: HashMap, - selected_index: usize, - worktree_name: Option, - branch_target: NewWorktreeBranchTarget, - project_worktree_paths: HashSet, - current_branch_name: String, - default_branch_name: Option, - has_multiple_repositories: bool, -} - -fn process_branches(branches: &Arc<[GitBranch]>) -> Vec { - 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 = 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, -) -> HashMap { - 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 { - 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 { - 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 { - 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 { - 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 { - "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>, - ) { - self.selected_index = ix; - } - - fn can_select(&self, ix: usize, _window: &mut Window, _cx: &mut Context>) -> bool { - !matches!(self.matches.get(ix), Some(ThreadBranchEntry::Separator)) - } - - fn update_matches( - &mut self, - query: String, - window: &mut Window, - cx: &mut Context>, - ) -> 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>) { - 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>) {} - - fn render_match( - &self, - ix: usize, - selected: bool, - _window: &mut Window, - cx: &mut Context>, - ) -> Option { - 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 { - None - } - - fn documentation_aside( - &self, - _window: &mut Window, - cx: &mut Context>, - ) -> Option { - 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 { - 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 - } - } -} diff --git a/crates/agent_ui/src/thread_worktree_archive.rs b/crates/agent_ui/src/thread_worktree_archive.rs index d8353f0761ebfe929eb56bd0bcca8f0ca3d37279..a2654ef3b43c4e24d23a8c0eff289d61d65a629d 100644 --- a/crates/agent_ui/src/thread_worktree_archive.rs +++ b/crates/agent_ui/src/thread_worktree_archive.rs @@ -981,6 +981,7 @@ mod tests { ref_name: Some("refs/heads/feature".into()), sha: "abc123".into(), is_main: false, + is_bare: false, }, ) .await; diff --git a/crates/agent_ui/src/thread_worktree_picker.rs b/crates/agent_ui/src/thread_worktree_picker.rs index 142f47f02ffd282409c86413a390ae9359a0f8dc..c77da77d0d353e27d8e45d896e9657a853633e05 100644 --- a/crates/agent_ui/src/thread_worktree_picker.rs +++ b/crates/agent_ui/src/thread_worktree_picker.rs @@ -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>, focus_handle: FocusHandle, - _subscription: gpui::Subscription, + _subscriptions: Vec, } impl ThreadWorktreePicker { - pub fn new( - project: Entity, - current_target: &StartThreadIn, - fs: Arc, - window: &mut Window, - cx: &mut Context, - ) -> Self { - let project_worktree_paths: Vec = project + pub fn new(project: Entity, window: &mut Window, cx: &mut Context) -> Self { + let project_worktree_paths: HashSet = 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 for ThreadWorktreePicker {} impl Render for ThreadWorktreePicker { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> 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, }, CreateNamed { name: String, + /// When Some, create from this branch name (e.g. "main"). When None, create from current branch. + from_branch: Option, disabled_reason: Option, }, } pub(crate) struct ThreadWorktreePickerDelegate { matches: Vec, - all_worktrees: Vec<(RepositoryId, Arc<[GitWorktree]>)>, - project_worktree_paths: Vec, + all_worktrees: Vec, + project_worktree_paths: HashSet, selected_index: usize, - preserved_branch_target: NewWorktreeBranchTarget, project: Entity, - fs: Arc, + current_branch_name: Option, + default_branch_name: Option, + has_multiple_repositories: bool, } impl ThreadWorktreePickerDelegate { - fn new_worktree_action(&self, worktree_name: Option) -> StartThreadIn { - StartThreadIn::NewWorktree { - worktree_name, - branch_target: self.preserved_branch_target.clone(), + fn build_fixed_entries(&self) -> Vec { + 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 { - "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>, ) -> 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 = 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 = 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>) { + fn confirm(&mut self, _secondary: bool, window: &mut Window, cx: &mut Context>) { 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 { 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, + 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::(); + + 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 { - 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>, - ) -> Option { - let entry = self.matches.get(self.selected_index)?; - let is_default = match entry { - ThreadWorktreeEntry::CurrentWorktree => { - let new_thread_location = AgentSettings::get_global(cx).new_thread_location; - Some(new_thread_location == NewThreadLocation::LocalProject) - } - ThreadWorktreeEntry::NewWorktree => { - let project = self.project.read(cx); - let is_disabled = project.repositories(cx).is_empty() || project.is_via_collab(); - if is_disabled { - None - } else { - let new_thread_location = AgentSettings::get_global(cx).new_thread_location; - Some(new_thread_location == NewThreadLocation::NewWorktree) - } - } - _ => None, - }?; - - let side = crate::ui::documentation_aside_side(cx); - - Some(DocumentationAside::new( - side, - Rc::new(move |_| { - HoldForDefault::new(is_default) - .more_content(false) - .into_any_element() - }), - )) + fn 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 { - match self.matches.get(self.selected_index) { - Some(ThreadWorktreeEntry::CurrentWorktree | ThreadWorktreeEntry::NewWorktree) => { - Some(self.selected_index) - } - _ => None, + fn build_delegate( + project: Entity, + all_worktrees: Vec, + project_worktree_paths: HashSet, + current_branch_name: Option, + default_branch_name: Option, + 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 { + 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>; + + async fn make_picker( + cx: &mut TestAppContext, + all_worktrees: Vec, + project_worktree_paths: HashSet, + current_branch_name: Option, + default_branch_name: Option, + 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::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:?}" + ); + } } diff --git a/crates/agent_ui/src/ui/hold_for_default.rs b/crates/agent_ui/src/ui/hold_for_default.rs index 436ca65ddd93b977a09c8de8eaeb25dc6c0eb1a0..972f61f00575ba006a327b7a5e8adb218d2bc4d8 100644 --- a/crates/agent_ui/src/ui/hold_for_default.rs +++ b/crates/agent_ui/src/ui/hold_for_default.rs @@ -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 diff --git a/crates/collab/tests/integration/git_tests.rs b/crates/collab/tests/integration/git_tests.rs index c273005264d0a53b6a083a4013f7597a56919016..b8248ce00214be868e6f1285027d2719b377747b 100644 --- a/crates/collab/tests/integration/git_tests.rs +++ b/crates/collab/tests/integration/git_tests.rs @@ -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; diff --git a/crates/component_preview/src/component_preview.rs b/crates/component_preview/src/component_preview.rs index 17a598aa78a410b43600afa9d1e09ed06e167647..c7a2316716bb574af51d4f6e0c244d666d5d67be 100644 --- a/crates/component_preview/src/component_preview.rs +++ b/crates/component_preview/src/component_preview.rs @@ -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/") }) diff --git a/crates/fs/src/fake_git_repo.rs b/crates/fs/src/fake_git_repo.rs index f224a13bbf3558d8fb45a37f2a975d99b187af8a..f857a53c6530b12f38559a62ed4a6ae02d39e5af 100644 --- a/crates/fs/src/fake_git_repo.rs +++ b/crates/fs/src/fake_git_repo.rs @@ -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, }); } } diff --git a/crates/git/src/repository.rs b/crates/git/src/repository.rs index 6c3b33e94bb9b0f181eab5bb3a2690107505e143..cb8172bab507818a9f06dc1d84afd0e9f11477c6 100644 --- a/crates/git/src/repository.rs +++ b/crates/git/src/repository.rs @@ -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>(raw_worktrees: T) -> Vec { @@ -303,6 +332,8 @@ pub fn parse_worktrees_from_str>(raw_worktrees: T) -> Vec>(raw_worktrees: T) -> Vec>(raw_worktrees: T) -> Vec 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 { - 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( diff --git a/crates/git_ui/src/worktree_picker.rs b/crates/git_ui/src/worktree_picker.rs index d544243cb88fa251adf8ef93f7c53c2855fb59ba..89d84cb7fb86cbf6359c8f3336a5ac9ae9fc1a9a 100644 --- a/crates/git_ui/src/worktree_picker.rs +++ b/crates/git_ui/src/worktree_picker.rs @@ -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_focus_handle: FocusHandle, - _subscription: Option, + _subscriptions: Vec, embedded: bool, } @@ -77,9 +75,10 @@ impl WorktreeList { cx: &mut Context, ) -> 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 { 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 = 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::>(); 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) diff --git a/crates/icons/src/icons.rs b/crates/icons/src/icons.rs index bdc3890432414e0a78f69a226bb9174510453331..3c6e09cf60451ea36d9ab44471137d96d76987da 100644 --- a/crates/icons/src/icons.rs +++ b/crates/icons/src/icons.rs @@ -146,7 +146,6 @@ pub enum IconName { GenericMinimize, GenericRestore, GitBranch, - GitBranchAlt, GitBranchPlus, GitCommit, GitGraph, diff --git a/crates/notifications/src/status_toast.rs b/crates/notifications/src/status_toast.rs index 40c5bdc8f85d0b9a46474760954247e8bba76ca9..8c177bfe9ca66a81af2b4441b6c4703e9d871395 100644 --- a/crates/notifications/src/status_toast.rs +++ b/crates/notifications/src/status_toast.rs @@ -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/") }) diff --git a/crates/project/src/git_store.rs b/crates/project/src/git_store.rs index e917f7bd3a7167f37980e6ef581a81a70530b56e..fe7da2e3f5c763659e183cc02627f3a59780fa14 100644 --- a/crates/project/src/git_store.rs +++ b/crates/project/src/git_store.rs @@ -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, } } diff --git a/crates/proto/proto/git.proto b/crates/proto/proto/git.proto index d0a594a2817ec50d9d35383587619e311f2950d8..d7c0d9bb9ac8c1b661d5306fe9f01336da7e5970 100644 --- a/crates/proto/proto/git.proto +++ b/crates/proto/proto/git.proto @@ -586,6 +586,7 @@ message Worktree { string ref_name = 2; string sha = 3; bool is_main = 4; + bool is_bare = 5; } message GitCreateWorktree { diff --git a/crates/recent_projects/src/recent_projects.rs b/crates/recent_projects/src/recent_projects.rs index c966a6508fcaffa811b6c61c37be02e5d9ffc322..246b5ec2ee1302e3be986e53252abfd90065357c 100644 --- a/crates/recent_projects/src/recent_projects.rs +++ b/crates/recent_projects/src/recent_projects.rs @@ -1140,6 +1140,8 @@ impl PickerDelegate for RecentProjectsDelegate { path_list, Some(key.clone()), &[], + None, + OpenMode::Activate, window, cx, ) diff --git a/crates/remote_server/src/remote_editing_tests.rs b/crates/remote_server/src/remote_editing_tests.rs index 571c5e7ea1aa5d623cf70d8fd06252bd0860de1b..d9d737bcd4b7f7c64ce69b995231c7ca5f751d24 100644 --- a/crates/remote_server/src/remote_editing_tests.rs +++ b/crates/remote_server/src/remote_editing_tests.rs @@ -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; diff --git a/crates/sidebar/src/sidebar.rs b/crates/sidebar/src/sidebar.rs index f3cd04dab619ce9e660220ca85e12cb4f04c9379..93e5d5b2b16f4724d4dba8b02a312e6507db73bf 100644 --- a/crates/sidebar/src/sidebar.rs +++ b/crates/sidebar/src/sidebar.rs @@ -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, ) diff --git a/crates/sidebar/src/sidebar_tests.rs b/crates/sidebar/src/sidebar_tests.rs index 709fa44a0b39e5b73b477dbdc2f4248e9f167a4d..10a6879c7bd9f8a0124c8571f251ba8098942b8e 100644 --- a/crates/sidebar/src/sidebar_tests.rs +++ b/crates/sidebar/src/sidebar_tests.rs @@ -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; diff --git a/crates/workspace/src/multi_workspace.rs b/crates/workspace/src/multi_workspace.rs index 80e9cbd6ebdcbdf5489b07e91418363b236071f4..504f1f3543b0e118f702561f24c22e592b89e92a 100644 --- a/crates/workspace/src/multi_workspace.rs +++ b/crates/workspace/src/multi_workspace.rs @@ -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>>> + 'static, excluding: &[Entity], + init: Option) + Send>>, + open_mode: OpenMode, window: &mut Window, cx: &mut Context, ) -> Task>> { @@ -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, excluding: &[Entity], + init: Option) + Send>>, + open_mode: OpenMode, window: &mut Window, cx: &mut Context, ) -> Task>> { @@ -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, ) -> Task>> { 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| { diff --git a/crates/workspace/src/multi_workspace_tests.rs b/crates/workspace/src/multi_workspace_tests.rs index 4df5bcfa0a09dbebddd2e2da5081f17a50c000c9..dd86e210f9643a70acc360d8a0820c9964172f2a 100644 --- a/crates/workspace/src/multi_workspace_tests.rs +++ b/crates/workspace/src/multi_workspace_tests.rs @@ -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, ) diff --git a/crates/workspace/src/persistence.rs b/crates/workspace/src/persistence.rs index e73f92937a63b72b75b5a3ba6c0abaa47e10cc6a..4dde067c1f74e8eb7570435c587bfba90bea146c 100644 --- a/crates/workspace/src/persistence.rs +++ b/crates/workspace/src/persistence.rs @@ -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, diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index 50b19299ef1acb4eaac26d665bc083fc44ff808a..8c8e927cd5904c7594fecd5e15e33b936102fd06 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -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, }; diff --git a/crates/worktree/tests/integration/main.rs b/crates/worktree/tests/integration/main.rs index f46d0877c7297bc7b8024b8c8a19e0ee801c64aa..76034c7f5fa01cd50f254fec241ed830c5f90b08 100644 --- a/crates/worktree/tests/integration/main.rs +++ b/crates/worktree/tests/integration/main.rs @@ -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; diff --git a/crates/zed/src/visual_test_runner.rs b/crates/zed/src/visual_test_runner.rs index 2f043bfb0c9e66d4ee56bfc78d0b9d69244d3777..58b911dbc2f1c8771cb54a7b27adcfe396457d72 100644 --- a/crates/zed/src/visual_test_runner.rs +++ b/crates/zed/src/visual_test_runner.rs @@ -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, - cx: &mut VisualTestAppContext, - update_baseline: bool, -) -> Result { - 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 = 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 = 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::(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::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::(cx)` returns None in the new workspace and - // the creation flow's `focus_panel::` 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::(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 = 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::(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::(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) - } -}