diff --git a/Cargo.lock b/Cargo.lock index 2521700c1b6c0453bc14e740a2a32c60a35d34c2..123ae50e14ed3148ac84b0fd57a15e98fd4e6534 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -353,6 +353,7 @@ dependencies = [ "futures 0.3.32", "fuzzy", "git", + "git_ui", "gpui", "gpui_tokio", "heapless", @@ -7262,6 +7263,7 @@ dependencies = [ "db", "editor", "file_icons", + "fs", "futures 0.3.32", "fuzzy", "git", diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index 41618016de95757615ea6d528aff11906620c0c5..9ebace38d24163cf9c927311b3782200253b33c7 100644 --- a/assets/keymaps/default-linux.json +++ b/assets/keymaps/default-linux.json @@ -237,7 +237,6 @@ "shift-alt-j": "agent::ToggleNavigationMenu", "shift-alt-i": "agent::ToggleOptionsMenu", "ctrl-alt-shift-n": "agent::ToggleNewThreadMenu", - "ctrl-shift-t": "agent::ToggleWorktreeSelector", "shift-alt-escape": "agent::ExpandMessageEditor", "ctrl->": "agent::AddSelectionToThread", "ctrl-shift-e": "project_panel::ToggleFocus", @@ -630,6 +629,7 @@ // "alt-ctrl-shift-o": "["projects::OpenRemote", { "from_existing_connection": true }]", "alt-ctrl-shift-o": ["projects::OpenRemote", { "from_existing_connection": false, "create_new_window": false }], "alt-ctrl-shift-b": "branches::OpenRecent", + "alt-ctrl-shift-w": "git::Worktree", "alt-shift-enter": "toast::RunAction", "ctrl-~": "workspace::NewTerminal", "save": "workspace::Save", @@ -1387,15 +1387,6 @@ "ctrl-shift-enter": "workspace::OpenWithSystem", }, }, - { - "context": "GitWorktreeSelector || (GitWorktreeSelector > Picker > Editor)", - "use_key_equivalents": true, - "bindings": { - "ctrl-shift-space": "git::WorktreeFromDefaultOnWindow", - "ctrl-space": "git::WorktreeFromDefault", - "ctrl-shift-backspace": "git::DeleteWorktree", - }, - }, { // Handled under a more specific context to avoid conflicts with the // `OpenCurrentFile` keybind from the settings UI @@ -1531,9 +1522,15 @@ { "context": "GitPicker", "bindings": { - "alt-1": "git_picker::ActivateWorktreesTab", - "alt-2": "git_picker::ActivateBranchesTab", - "alt-3": "git_picker::ActivateStashTab", + "alt-1": "git_picker::ActivateBranchesTab", + "alt-2": "git_picker::ActivateStashTab", + }, + }, + { + "context": "WorktreePicker || (WorktreePicker > Picker > Editor)", + "use_key_equivalents": true, + "bindings": { + "ctrl-shift-backspace": "worktree_picker::DeleteWorktree", }, }, ] diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index fa255bfe6a21ce917b64fa8e4a056d1b53e6936c..94d7a0735c63809090c2c95cf26ed3585d8340dc 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -275,7 +275,6 @@ "cmd-shift-j": "agent::ToggleNavigationMenu", "cmd-alt-m": "agent::ToggleOptionsMenu", "cmd-alt-shift-n": "agent::ToggleNewThreadMenu", - "cmd-shift-t": "agent::ToggleWorktreeSelector", "shift-alt-escape": "agent::ExpandMessageEditor", "cmd->": "agent::AddSelectionToThread", "cmd-shift-e": "project_panel::ToggleFocus", @@ -698,6 +697,7 @@ "ctrl-cmd-o": ["projects::OpenRemote", { "from_existing_connection": false, "create_new_window": false }], "ctrl-cmd-shift-o": ["projects::OpenRemote", { "from_existing_connection": true, "create_new_window": false }], "cmd-ctrl-b": "branches::OpenRecent", + "cmd-ctrl-w": "git::Worktree", "ctrl-~": "workspace::NewTerminal", "cmd-s": "workspace::Save", "cmd-k s": "workspace::SaveWithoutFormat", @@ -1484,15 +1484,6 @@ "ctrl-shift-enter": "workspace::OpenWithSystem", }, }, - { - "context": "GitWorktreeSelector || (GitWorktreeSelector > Picker > Editor)", - "use_key_equivalents": true, - "bindings": { - "ctrl-shift-space": "git::WorktreeFromDefaultOnWindow", - "ctrl-space": "git::WorktreeFromDefault", - "cmd-shift-backspace": "git::DeleteWorktree", - }, - }, { // Handled under a more specific context to avoid conflicts with the // `OpenCurrentFile` keybind from the settings UI @@ -1592,9 +1583,15 @@ { "context": "GitPicker", "bindings": { - "cmd-1": "git_picker::ActivateWorktreesTab", - "cmd-2": "git_picker::ActivateBranchesTab", - "cmd-3": "git_picker::ActivateStashTab", + "cmd-1": "git_picker::ActivateBranchesTab", + "cmd-2": "git_picker::ActivateStashTab", + }, + }, + { + "context": "WorktreePicker || (WorktreePicker > Picker > Editor)", + "use_key_equivalents": true, + "bindings": { + "cmd-shift-backspace": "worktree_picker::DeleteWorktree", }, }, { diff --git a/assets/keymaps/default-windows.json b/assets/keymaps/default-windows.json index 39674ec90db9489b7430c08969fc3c424d483ff3..09a91523f3aa41941c80b21cb0bd226dd1f32afd 100644 --- a/assets/keymaps/default-windows.json +++ b/assets/keymaps/default-windows.json @@ -238,7 +238,6 @@ "shift-alt-j": "agent::ToggleNavigationMenu", "shift-alt-i": "agent::ToggleOptionsMenu", "ctrl-shift-alt-n": "agent::ToggleNewThreadMenu", - "ctrl-shift-t": "agent::ToggleWorktreeSelector", "shift-alt-escape": "agent::ExpandMessageEditor", "ctrl-shift-.": "agent::AddSelectionToThread", "ctrl-shift-e": "project_panel::ToggleFocus", @@ -626,6 +625,7 @@ // "ctrl-shift-alt-o": "["projects::OpenRemote", { "from_existing_connection": true }]", "ctrl-shift-alt-o": ["projects::OpenRemote", { "from_existing_connection": false, "create_new_window": false }], "shift-alt-b": "branches::OpenRecent", + "shift-alt-w": "git::Worktree", "shift-alt-enter": "toast::RunAction", "ctrl-shift-`": "workspace::NewTerminal", "ctrl-s": "workspace::Save", @@ -1402,15 +1402,6 @@ "ctrl-5": ["welcome::OpenRecentProject", 4], }, }, - { - "context": "GitWorktreeSelector || (GitWorktreeSelector > Picker > Editor)", - "use_key_equivalents": true, - "bindings": { - "ctrl-shift-space": "git::WorktreeFromDefaultOnWindow", - "ctrl-space": "git::WorktreeFromDefault", - "ctrl-shift-backspace": "git::DeleteWorktree", - }, - }, { // Handled under a more specific context to avoid conflicts with the // `OpenCurrentFile` keybind from the settings UI @@ -1509,9 +1500,15 @@ { "context": "GitPicker", "bindings": { - "alt-1": "git_picker::ActivateWorktreesTab", - "alt-2": "git_picker::ActivateBranchesTab", - "alt-3": "git_picker::ActivateStashTab", + "alt-1": "git_picker::ActivateBranchesTab", + "alt-2": "git_picker::ActivateStashTab", + }, + }, + { + "context": "WorktreePicker || (WorktreePicker > Picker > Editor)", + "use_key_equivalents": true, + "bindings": { + "ctrl-shift-backspace": "worktree_picker::DeleteWorktree", }, }, { diff --git a/assets/settings/default.json b/assets/settings/default.json index c25919678bc3ab4b54aa8ac29ed17cc5650b77e8..5b2fd3298738c1c184bf22cbf53a7736485a4eb4 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -473,8 +473,8 @@ "use_system_window_tabs": false, // Titlebar related settings "title_bar": { - // Whether to show the branch icon beside branch switcher in the titlebar. - "show_branch_icon": false, + // Whether to show git status indicators on the branch icon in the titlebar. + "show_branch_status_icon": false, // Whether to show the branch name button in the titlebar. "show_branch_name": true, // Whether to show the project host and name in the titlebar. diff --git a/crates/agent_ui/Cargo.toml b/crates/agent_ui/Cargo.toml index 6026a6f9ec4397a5849e5acc1e131df3307e825f..07dc04c3b6bff9fc7bd749c9685d01c766e48d91 100644 --- a/crates/agent_ui/Cargo.toml +++ b/crates/agent_ui/Cargo.toml @@ -55,6 +55,7 @@ file_icons.workspace = true fs.workspace = true futures.workspace = true git.workspace = true +git_ui.workspace = true fuzzy.workspace = true gpui.workspace = true gpui_tokio.workspace = true diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index 9b79b2c57c685f37566ad5f21e0afd45c127b6d1..2f86fb510cd0dd1726d024238bf98d990c39c601 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -1,5 +1,5 @@ use std::{ - path::{Path, PathBuf}, + path::PathBuf, rc::Rc, sync::{ Arc, @@ -32,14 +32,12 @@ use zed_actions::{ use crate::DEFAULT_THREAD_TITLE; use crate::thread_metadata_store::{ThreadId, ThreadMetadataStore}; use crate::{ - AddContextServer, AgentDiffPane, ConversationView, CopyThreadToClipboard, CreateWorktree, - Follow, InlineAssistant, LoadThreadFromClipboard, NewThread, NewWorktreeBranchTarget, - OpenActiveThreadAsMarkdown, OpenAgentDiff, OpenHistory, ResetTrialEndUpsell, ResetTrialUpsell, - ShowAllSidebarThreadMetadata, ShowThreadMetadata, SwitchWorktree, ToggleNavigationMenu, - ToggleNewThreadMenu, ToggleOptionsMenu, ToggleWorktreeSelector, + AddContextServer, AgentDiffPane, ConversationView, CopyThreadToClipboard, Follow, + InlineAssistant, LoadThreadFromClipboard, NewThread, OpenActiveThreadAsMarkdown, OpenAgentDiff, + OpenHistory, ResetTrialEndUpsell, ResetTrialUpsell, ShowAllSidebarThreadMetadata, + ShowThreadMetadata, ToggleNavigationMenu, ToggleNewThreadMenu, ToggleOptionsMenu, agent_configuration::{AgentConfiguration, AssistantConfigurationEvent}, conversation_view::{AcpThreadViewEvent, ThreadView}, - thread_worktree_picker::ThreadWorktreePicker, ui::EndTrialUpsell, }; use crate::{ @@ -51,7 +49,7 @@ use crate::{ManageProfiles, ThreadHistoryViewEvent}; use crate::{ThreadHistory, agent_connection_store::AgentConnectionStore}; use agent_settings::AgentSettings; use ai_onboarding::AgentPanelOnboarding; -use anyhow::{Context as _, Result, anyhow}; +use anyhow::{Context as _, Result}; use chrono::{DateTime, Utc}; use client::UserStore; use cloud_api_types::Plan; @@ -62,18 +60,14 @@ use extension_host::ExtensionStore; use fs::Fs; use gpui::{ Action, Animation, AnimationExt, AnyElement, App, AsyncWindowContext, ClipboardItem, Corner, - DismissEvent, Entity, EntityId, EventEmitter, ExternalPaths, FocusHandle, Focusable, - KeyContext, Pixels, Subscription, Task, UpdateGlobal, WeakEntity, prelude::*, - pulsating_between, + DismissEvent, Entity, EventEmitter, ExternalPaths, FocusHandle, Focusable, KeyContext, Pixels, + Subscription, Task, UpdateGlobal, WeakEntity, prelude::*, pulsating_between, }; use language::LanguageRegistry; use language_model::LanguageModelRegistry; -use project::project_settings::ProjectSettings; -use project::trusted_worktrees::{PathTrust, TrustedWorktrees}; -use project::{Project, ProjectPath, Worktree, linked_worktree_short_name}; +use project::{Project, ProjectPath, Worktree}; use prompt_store::{PromptStore, UserPromptId}; use release_channel::ReleaseChannel; -use remote::RemoteConnectionOptions; use rules_library::{RulesLibrary, open_rules_library}; use settings::TerminalDockPosition; use settings::{Settings, update_settings_file}; @@ -86,8 +80,8 @@ use ui::{ }; use util::{ResultExt as _, debug_panic}; use workspace::{ - CollaboratorId, DockStructure, DraggedSelection, DraggedTab, OpenMode, PathList, - SerializedPathList, ToggleWorkspaceSidebar, ToggleZoom, Workspace, WorkspaceId, + CollaboratorId, DraggedSelection, DraggedTab, PathList, SerializedPathList, + ToggleWorkspaceSidebar, ToggleZoom, Workspace, WorkspaceId, dock::{DockPosition, Panel, PanelEvent}, }; @@ -279,14 +273,6 @@ 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(); @@ -508,28 +494,6 @@ 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); - }); - } - }, ); }, ) @@ -719,61 +683,6 @@ enum WhichFontSize { None, } -#[derive(Clone, Debug)] -pub enum WorktreeCreationStatus { - Creating(SharedString), - Loading(SharedString), - Error(SharedString), -} - -#[derive(Clone, Debug)] -enum WorktreeCreationArgs { - New { - worktree_name: Option, - branch_target: NewWorktreeBranchTarget, - }, - 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,7 +718,6 @@ pub struct AgentPanel { draft_thread: Option>, retained_threads: HashMap>, new_thread_menu_handle: PopoverMenuHandle, - start_thread_in_menu_handle: PopoverMenuHandle, agent_panel_menu_handle: PopoverMenuHandle, agent_navigation_menu_handle: PopoverMenuHandle, agent_navigation_menu: Option>, @@ -820,10 +728,8 @@ pub struct AgentPanel { new_user_onboarding: Entity, new_user_onboarding_upsell_dismissed: AtomicBool, selected_agent: Agent, - worktree_creation_status: Option<(EntityId, WorktreeCreationStatus)>, _thread_view_subscription: Option, _active_thread_focus_subscription: Option, - _worktree_creation_task: Option>, show_trust_workspace_message: bool, _base_view_observation: Option, _draft_editor_observation: Option, @@ -1176,7 +1082,6 @@ impl AgentPanel { draft_thread: None, retained_threads: HashMap::default(), new_thread_menu_handle: PopoverMenuHandle::default(), - start_thread_in_menu_handle: PopoverMenuHandle::default(), agent_panel_menu_handle: PopoverMenuHandle::default(), agent_navigation_menu_handle: PopoverMenuHandle::default(), agent_navigation_menu: None, @@ -1187,10 +1092,8 @@ impl AgentPanel { new_user_onboarding: onboarding, thread_store, selected_agent: Agent::default(), - worktree_creation_status: None, _thread_view_subscription: None, _active_thread_focus_subscription: None, - _worktree_creation_task: None, show_trust_workspace_message: false, new_user_onboarding_upsell_dismissed: AtomicBool::new(OnboardingUpsell::dismissed(cx)), _base_view_observation: None, @@ -1760,15 +1663,6 @@ 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, @@ -2841,1133 +2735,272 @@ impl AgentPanel { .is_some_and(|active| active.entity_id() == draft.entity_id()) }) } +} - // 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 - // querying (which PathList is used to read threads back). All of these need - // to agree on how repos are resolved for a given workspace, especially in - // multi-root and nested-repo configurations. - /// Partitions the project's visible worktrees into git-backed repositories - /// and plain (non-git) paths. Git repos will have worktrees created for - /// them; non-git paths are carried over to the new workspace as-is. - /// - /// When multiple worktrees map to the same repository, the most specific - /// match wins (deepest work directory path), with a deterministic - /// tie-break on entity id. Each repository appears at most once. - fn classify_worktrees( - &self, - cx: &App, - ) -> (Vec>, Vec) { - let project = &self.project; - let repositories = project.read(cx).repositories(cx).clone(); - let mut git_repos: Vec> = Vec::new(); - let mut non_git_paths: Vec = Vec::new(); - let mut seen_repo_ids = std::collections::HashSet::new(); - - for worktree in project.read(cx).visible_worktrees(cx) { - let wt_path = worktree.read(cx).abs_path(); - - let matching_repo = repositories - .iter() - .filter_map(|(id, repo)| { - let work_dir = repo.read(cx).work_directory_abs_path.clone(); - if wt_path.starts_with(work_dir.as_ref()) { - Some((*id, repo.clone(), work_dir.as_ref().components().count())) - } else { - None - } - }) - .max_by( - |(left_id, _left_repo, left_depth), (right_id, _right_repo, right_depth)| { - left_depth - .cmp(right_depth) - .then_with(|| left_id.cmp(right_id)) - }, - ); - - if let Some((id, repo, _)) = matching_repo { - if seen_repo_ids.insert(id) { - git_repos.push(repo); +impl Focusable for AgentPanel { + fn focus_handle(&self, cx: &App) -> FocusHandle { + match self.visible_surface() { + VisibleSurface::Uninitialized => self.focus_handle.clone(), + VisibleSurface::AgentThread(conversation_view) => conversation_view.focus_handle(cx), + VisibleSurface::History(view) => view.read(cx).focus_handle(cx), + VisibleSurface::Configuration(configuration) => { + if let Some(configuration) = configuration { + configuration.focus_handle(cx) + } else { + self.focus_handle.clone() } - } else { - non_git_paths.push(wt_path.to_path_buf()); - } - } - - (git_repos, non_git_paths) - } - - fn resolve_worktree_branch_target( - branch_target: &NewWorktreeBranchTarget, - ) -> (Option, Option) { - match branch_target { - NewWorktreeBranchTarget::CurrentBranch => (None, None), - NewWorktreeBranchTarget::ExistingBranch { name } => { - (Some(name.clone()), Some(name.clone())) - } - NewWorktreeBranchTarget::CreateBranch { name, from_ref } => { - (Some(name.clone()), from_ref.clone()) } } } +} - fn maybe_propagate_worktree_trust( - this: &WeakEntity, - new_workspace: &Entity, - paths: &[PathBuf], - cx: &mut AsyncWindowContext, - ) { - cx.update(|_, cx| { - if ProjectSettings::get_global(cx).session.trust_all_worktrees { - return; - } - let Some(trusted_store) = TrustedWorktrees::try_get_global(cx) else { - return; - }; +fn agent_panel_dock_position(cx: &App) -> DockPosition { + AgentSettings::get_global(cx).dock.into() +} - let source_is_trusted = this - .upgrade() - .map(|panel| { - let source_worktree_store = panel.read(cx).project.read(cx).worktree_store(); - !trusted_store - .read(cx) - .has_restricted_worktrees(&source_worktree_store, cx) - }) - .unwrap_or(false); +pub enum AgentPanelEvent { + ActiveViewChanged, + ThreadFocused, + RetainedThreadChanged, + ThreadInteracted { thread_id: ThreadId }, +} - if !source_is_trusted { - return; - } +impl EventEmitter for AgentPanel {} +impl EventEmitter for AgentPanel {} - let worktree_store = new_workspace.read(cx).project().read(cx).worktree_store(); - let paths_to_trust: HashSet<_> = paths - .iter() - .filter_map(|path| { - let (worktree, _) = worktree_store.read(cx).find_worktree(path, cx)?; - Some(PathTrust::Worktree(worktree.read(cx).id())) - }) - .collect(); +impl Panel for AgentPanel { + fn persistent_name() -> &'static str { + "AgentPanel" + } - if !paths_to_trust.is_empty() { - trusted_store.update(cx, |store, cx| { - store.trust(&worktree_store, paths_to_trust, cx); - }); - } - }) - .ok(); + fn panel_key() -> &'static str { + AGENT_PANEL_KEY } - /// Kicks off an async git-worktree creation for each repository. Returns: - /// - /// - `creation_infos`: a vec of `(repo, new_path, receiver)` tuples—the - /// receiver resolves once the git worktree command finishes. - /// - `path_remapping`: `(old_work_dir, new_worktree_path)` pairs used - /// later to remap open editor tabs into the new workspace. - fn start_worktree_creations( - git_repos: &[Entity], - worktree_name: Option, - existing_worktree_names: &[String], - existing_worktree_paths: &HashSet, - base_ref: Option, - worktree_directory_setting: &str, - rng: &mut impl rand::Rng, - cx: &mut Context, - ) -> Result<( - Vec<( - Entity, - PathBuf, - futures::channel::oneshot::Receiver>, - )>, - Vec<(PathBuf, PathBuf)>, - )> { - let mut creation_infos = Vec::new(); - let mut path_remapping = Vec::new(); - - let worktree_name = worktree_name.unwrap_or_else(|| { - let existing_refs: Vec<&str> = - existing_worktree_names.iter().map(|s| s.as_str()).collect(); - crate::worktree_names::generate_worktree_name(&existing_refs, rng) - .unwrap_or_else(|| "worktree".to_string()) - }); - - for repo in git_repos { - let (work_dir, new_path, receiver) = repo.update(cx, |repo, _cx| { - let new_path = - repo.path_for_new_linked_worktree(&worktree_name, worktree_directory_setting)?; - if existing_worktree_paths.contains(&new_path) { - anyhow::bail!("A worktree already exists at {}", new_path.display()); - } - let target = git::repository::CreateWorktreeTarget::Detached { - base_sha: base_ref.clone(), - }; - let receiver = repo.create_worktree(target, new_path.clone()); - let work_dir = repo.work_directory_abs_path.clone(); - anyhow::Ok((work_dir, new_path, receiver)) - })?; - path_remapping.push((work_dir.to_path_buf(), new_path.clone())); - creation_infos.push((repo.clone(), new_path, receiver)); - } + fn position(&self, _window: &Window, cx: &App) -> DockPosition { + agent_panel_dock_position(cx) + } - Ok((creation_infos, path_remapping)) - } - - /// Waits for every in-flight worktree creation to complete. If any - /// creation fails, all successfully-created worktrees are rolled back - /// (removed) so the project isn't left in a half-migrated state. - async fn await_and_rollback_on_failure( - creation_infos: Vec<( - Entity, - PathBuf, - futures::channel::oneshot::Receiver>, - )>, - fs: Arc, - cx: &mut AsyncWindowContext, - ) -> Result> { - let mut created_paths: Vec = Vec::new(); - let mut repos_and_paths: Vec<(Entity, PathBuf)> = - Vec::new(); - let mut first_error: Option = None; - - for (repo, new_path, receiver) in creation_infos { - repos_and_paths.push((repo.clone(), new_path.clone())); - match receiver.await { - Ok(Ok(())) => { - created_paths.push(new_path); - } - Ok(Err(err)) => { - if first_error.is_none() { - first_error = Some(err); - } - } - Err(_canceled) => { - if first_error.is_none() { - first_error = Some(anyhow!("Worktree creation was canceled")); - } - } - } - } + fn position_is_valid(&self, position: DockPosition) -> bool { + position != DockPosition::Bottom + } - let Some(err) = first_error else { - return Ok(created_paths); + fn set_position(&mut self, position: DockPosition, _: &mut Window, cx: &mut Context) { + let side = match position { + DockPosition::Left => "left", + DockPosition::Right | DockPosition::Bottom => "right", }; + telemetry::event!("Agent Panel Side Changed", side = side); + settings::update_settings_file(self.fs.clone(), cx, move |settings, _| { + settings + .agent + .get_or_insert_default() + .set_dock(position.into()); + }); + } - // Rollback all attempted worktrees (both successful and failed, - // since a failed creation may have left an orphan directory). - let mut rollback_futures = Vec::new(); - for (rollback_repo, rollback_path) in &repos_and_paths { - let receiver = cx - .update(|_, cx| { - rollback_repo.update(cx, |repo, _cx| { - repo.remove_worktree(rollback_path.clone(), true) - }) - }) - .ok(); - - rollback_futures.push((rollback_path.clone(), receiver)); + fn default_size(&self, window: &Window, cx: &App) -> Pixels { + let settings = AgentSettings::get_global(cx); + match self.position(window, cx) { + DockPosition::Left | DockPosition::Right => settings.default_width, + DockPosition::Bottom => settings.default_height, } + } - let mut rollback_failures: Vec = Vec::new(); - for (path, receiver_opt) in rollback_futures { - let mut git_remove_failed = false; - - if let Some(receiver) = receiver_opt { - match receiver.await { - Ok(Ok(())) => {} - Ok(Err(rollback_err)) => { - log::error!( - "git worktree remove failed for {}: {rollback_err}", - path.display() - ); - git_remove_failed = true; - } - Err(canceled) => { - log::error!( - "git worktree remove failed for {}: {canceled}", - path.display() - ); - git_remove_failed = true; - } - } - } else { - log::error!( - "failed to dispatch git worktree remove for {}", - path.display() - ); - git_remove_failed = true; - } - - // `git worktree remove` normally removes this directory, but since - // `git worktree remove` failed (or wasn't dispatched), manually rm the directory. - if git_remove_failed { - if let Err(fs_err) = fs - .remove_dir( - &path, - fs::RemoveOptions { - recursive: true, - ignore_if_not_exists: true, - }, - ) - .await - { - let msg = format!("{}: failed to remove directory: {fs_err}", path.display()); - log::error!("{}", msg); - rollback_failures.push(msg); - } - } - } - let mut error_message = format!("Failed to create worktree: {err}"); - if !rollback_failures.is_empty() { - error_message.push_str("\n\nFailed to clean up: "); - error_message.push_str(&rollback_failures.join(", ")); + fn min_size(&self, window: &Window, cx: &App) -> Option { + match self.position(window, cx) { + DockPosition::Left | DockPosition::Right => Some(MIN_PANEL_WIDTH), + DockPosition::Bottom => None, } - Err(anyhow!(error_message)) } - /// Attempts to check out a branch in a newly created worktree. - /// First tries checking out an existing branch, then tries creating a new - /// branch. If both fail, the worktree stays in detached HEAD state. - async fn try_checkout_branch_in_worktree( - repo: &Entity, - branch_name: &str, - worktree_path: &Path, - cx: &mut AsyncWindowContext, - ) { - // First, try checking out the branch (it may already exist). - let Ok(receiver) = cx.update(|_, cx| { - repo.update(cx, |repo, _cx| { - repo.checkout_branch_in_worktree( - branch_name.to_string(), - worktree_path.to_path_buf(), - false, - ) - }) - }) else { - log::warn!( - "Failed to check out branch {branch_name} for worktree at {}. \ - Staying in detached HEAD state.", - worktree_path.display(), - ); + fn supports_flexible_size(&self) -> bool { + true + } - return; - }; + fn has_flexible_size(&self, _window: &Window, cx: &App) -> bool { + AgentSettings::get_global(cx).flexible + } - let Ok(result) = receiver.await else { - log::warn!( - "Branch checkout was canceled for worktree at {}. \ - Staying in detached HEAD state.", - worktree_path.display() - ); + fn set_flexible_size(&mut self, flexible: bool, _window: &mut Window, cx: &mut Context) { + settings::update_settings_file(self.fs.clone(), cx, move |settings, _| { + settings + .agent + .get_or_insert_default() + .set_flexible_size(flexible); + }); + } - return; - }; + fn set_active(&mut self, active: bool, window: &mut Window, cx: &mut Context) { + if active { + self.ensure_thread_initialized(window, cx); + } + } - if let Err(err) = result { - log::info!( - "Failed to check out branch '{branch_name}' in worktree at {}, \ - will try creating it: {err}", - worktree_path.display() - ); - } else { - log::info!( - "Checked out branch '{branch_name}' in worktree at {}", - worktree_path.display() - ); + fn remote_id() -> Option { + Some(proto::PanelId::AssistantPanel) + } - return; - } + fn icon(&self, _window: &Window, cx: &App) -> Option { + (self.enabled(cx) && AgentSettings::get_global(cx).button).then_some(IconName::ZedAssistant) + } - // Checkout failed, so try creating the branch. - let create_result = cx.update(|_, cx| { - repo.update(cx, |repo, _cx| { - repo.checkout_branch_in_worktree( - branch_name.to_string(), - worktree_path.to_path_buf(), - true, - ) - }) - }); + fn icon_tooltip(&self, _window: &Window, _cx: &App) -> Option<&'static str> { + Some("Agent Panel") + } - match create_result { - Ok(receiver) => match receiver.await { - Ok(Ok(())) => { - log::info!( - "Created and checked out branch '{branch_name}' in worktree at {}", - worktree_path.display() - ); - } - Ok(Err(err)) => { - log::warn!( - "Failed to create branch '{branch_name}' in worktree at {}: {err}. \ - Staying in detached HEAD state.", - worktree_path.display() - ); - } - Err(_) => { - log::warn!( - "Branch creation was canceled for worktree at {}. \ - Staying in detached HEAD state.", - worktree_path.display() - ); - } - }, - Err(err) => { - log::warn!( - "Failed to dispatch branch creation for worktree at {}: {err}. \ - Staying in detached HEAD state.", - worktree_path.display(), - ); - } - } + fn toggle_action(&self) -> Box { + Box::new(ToggleFocus) } - 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 activation_priority(&self) -> u32 { + 0 } - fn create_worktree( - &mut self, - action: &CreateWorktree, - previous_workspace_state: PreviousWorkspaceState, - window: &mut Window, - cx: &mut Context, - ) { - if !self.project_has_git_repository(cx) { - log::error!("create_worktree: no git repository in the project"); - return; - } - if self.project.read(cx).is_via_collab() { - log::error!("create_worktree: not supported in collab projects"); - return; - } - if matches!( - self.worktree_creation_status, - Some(( - _, - WorktreeCreationStatus::Creating(_) | WorktreeCreationStatus::Loading(_) - )) - ) { - return; - } + fn enabled(&self, cx: &App) -> bool { + AgentSettings::get_global(cx).enabled(cx) + } - let content = self.take_active_initial_content(cx); - let content_blocks = match content { - Some(AgentInitialContent::ContentBlock { blocks, .. }) => blocks, - _ => Vec::new(), - }; + fn is_agent_panel(&self) -> bool { + true + } - 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 is_zoomed(&self, _window: &Window, _cx: &App) -> bool { + self.zoomed } - fn switch_to_worktree( - &mut self, - 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(_) | 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::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); - } + fn set_zoomed(&mut self, zoomed: bool, _window: &mut Window, cx: &mut Context) { + self.zoomed = zoomed; 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)); - 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); - - if matches!(args, WorktreeCreationArgs::New { .. }) && git_repos.is_empty() { - self.set_worktree_creation_error( - "No git repositories found in the project".into(), - window, - cx, - ); - return; - } - - let remote_connection_options = self.project.read(cx).remote_connection_options(cx); - - if remote_connection_options.is_some() { - let is_disconnected = self - .project - .read(cx) - .remote_client() - .is_some_and(|client| client.read(cx).is_disconnected()); - if is_disconnected { - self.set_worktree_creation_error( - "Cannot create worktree: remote connection is not active".into(), - window, - cx, - ); - return; - } +impl AgentPanel { + fn ensure_thread_initialized(&mut self, window: &mut Window, cx: &mut Context) { + if matches!(self.base_view, BaseView::Uninitialized) { + self.activate_draft(false, window, cx); } - - let workspace = self.workspace.clone(); - let window_handle = window - .window_handle() - .downcast::(); - - 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 { - worktree_name, - branch_target, - } => { - let worktree_receivers: Vec<_> = this.update_in(cx, |_this, _window, cx| { - git_repos - .iter() - .map(|repo| repo.update(cx, |repo, _cx| repo.worktrees())) - .collect() - })?; - let worktree_directory_setting = this.update_in(cx, |_this, _window, cx| { - ProjectSettings::get_global(cx) - .git - .worktree_directory - .clone() - })?; - - let mut existing_worktree_names = Vec::new(); - let mut existing_worktree_paths = HashSet::default(); - for result in futures::future::join_all(worktree_receivers).await { - match result { - Ok(Ok(worktrees)) => { - for worktree in worktrees { - if let Some(name) = worktree - .path - .parent() - .and_then(|p| p.file_name()) - .and_then(|n| n.to_str()) - { - existing_worktree_names.push(name.to_string()); - } - existing_worktree_paths.insert(worktree.path.clone()); - } - } - Ok(Err(err)) => { - Err::<(), _>(err).log_err(); - } - Err(_) => {} - } - } - - let mut rng = rand::rng(); - - let (branch_to_checkout, base_ref) = - Self::resolve_worktree_branch_target(&branch_target); - - let (creation_infos, path_remapping) = - match this.update_in(cx, |_this, _window, cx| { - Self::start_worktree_creations( - &git_repos, - worktree_name, - &existing_worktree_names, - &existing_worktree_paths, - base_ref, - &worktree_directory_setting, - &mut rng, - cx, - ) - }) { - Ok(Ok(result)) => result, - Ok(Err(err)) | Err(err) => { - this.update_in(cx, |this, window, cx| { - this.set_worktree_creation_error( - format!("Failed to validate worktree directory: {err}") - .into(), - window, - cx, - ); - }) - .log_err(); - return anyhow::Ok(()); - } - }; - - let repo_paths: Vec<(Entity, PathBuf)> = - creation_infos - .iter() - .map(|(repo, path, _)| (repo.clone(), path.clone())) - .collect(); - - let fs = cx.update(|_, cx| ::global(cx))?; - - let created_paths = - match Self::await_and_rollback_on_failure(creation_infos, fs, cx).await { - Ok(paths) => paths, - Err(err) => { - this.update_in(cx, |this, window, cx| { - this.set_worktree_creation_error( - format!("{err}").into(), - window, - cx, - ); - })?; - return anyhow::Ok(()); - } - }; - - if let Some(ref branch_name) = branch_to_checkout { - for (repo, worktree_path) in &repo_paths { - Self::try_checkout_branch_in_worktree( - repo, - branch_name, - worktree_path, - cx, - ) - .await; - } - } - - let mut all_paths = created_paths; - let has_non_git = !non_git_paths.is_empty(); - all_paths.extend(non_git_paths.iter().cloned()); - (all_paths, path_remapping, has_non_git) - } - 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, path_remapping, has_non_git) - } - }; - - if workspace.upgrade().is_none() { - this.update_in(cx, |this, window, cx| { - this.set_worktree_creation_error( - "Workspace no longer available".into(), - window, - cx, - ); - })?; - return anyhow::Ok(()); - } - - let this_for_error = this.clone(); - if let Err(err) = Self::open_worktree_workspace_and_start_thread( - this, - all_paths, - window_handle, - previous_workspace_state, - path_remapping, - non_git_paths, - has_non_git, - content, - selected_agent, - remote_connection_options, - cx, - ) - .await - { - this_for_error - .update_in(cx, |this, window, cx| { - this.set_worktree_creation_error( - format!("Failed to set up workspace: {err}").into(), - window, - cx, - ); - }) - .log_err(); - } - anyhow::Ok(()) - }); - - self._worktree_creation_task = Some(cx.background_spawn(async move { - task.await.log_err(); - })); } - async fn open_worktree_workspace_and_start_thread( - this: WeakEntity, - all_paths: Vec, - window_handle: Option>, - previous_workspace_state: PreviousWorkspaceState, - path_remapping: Vec<(PathBuf, PathBuf)>, - non_git_paths: Vec, - has_non_git: bool, - content: Vec, - selected_agent: Option, - remote_connection_options: Option, - cx: &mut AsyncWindowContext, - ) -> Result<()> { - let window_handle = window_handle - .ok_or_else(|| anyhow!("No window handle available for workspace creation"))?; - - let (workspace_task, modal_workspace) = - window_handle.update(cx, |multi_workspace, window, cx| { - let path_list = PathList::new(&all_paths); - 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, - None, - move |connection_options, window, cx| { - remote_connection::connect_with_modal( - &active_workspace, - connection_options, - window, - cx, - ) - }, - &[], - Some(init), - OpenMode::Add, - window, - cx, - ); - (task, modal_workspace) - })?; - - let result = workspace_task.await; - remote_connection::dismiss_connection_modal(&modal_workspace, cx); - let new_workspace = result?; - - let panels_task = new_workspace.update(cx, |workspace, _cx| workspace.take_panels_task()); - - if let Some(task) = panels_task { - task.await.log_err(); + fn destination_has_meaningful_state(&self, cx: &App) -> bool { + if self.overlay_view.is_some() || !self.retained_threads.is_empty() { + return true; } - new_workspace - .update(cx, |workspace, cx| { - workspace.project().read(cx).wait_for_initial_scan(cx) - }) - .await; - - new_workspace - .update(cx, |workspace, cx| { - let repos = workspace - .project() + match &self.base_view { + BaseView::Uninitialized => false, + BaseView::AgentThread { conversation_view } => { + let has_entries = conversation_view .read(cx) - .repositories(cx) - .values() - .cloned() - .collect::>(); - - let tasks = repos - .into_iter() - .map(|repo| repo.update(cx, |repo, _| repo.barrier())); - futures::future::join_all(tasks) - }) - .await; - - Self::maybe_propagate_worktree_trust(&this, &new_workspace, &all_paths, cx); - - let initial_content = AgentInitialContent::ContentBlock { - blocks: content, - auto_submit: false, - }; - - window_handle.update(cx, |_multi_workspace, window, cx| { - new_workspace.update(cx, |workspace, cx| { - if has_non_git { - let toast_id = workspace::notifications::NotificationId::unique::(); - workspace.show_toast( - workspace::Toast::new( - toast_id, - "Some project folders are not git repositories. \ - They were included as-is without creating a worktree.", - ), - cx, - ); - } - - // 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)| { - original_path.strip_prefix(old_root).ok().map(|relative| { - (old_root.components().count(), new_root.join(relative)) - }) - }) - .max_by_key(|(depth, _)| *depth); - - if let Some((_, remapped_path)) = best_match { - return Some(remapped_path); - } - - for non_git in &non_git_paths { - if original_path.starts_with(non_git) { - return Some(original_path); - } - } - None - }; - - 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()); - } + .root_thread_view() + .is_some_and(|tv| !tv.read(cx).thread.read(cx).entries().is_empty()); + if has_entries { + return true; } - if !paths_to_open.is_empty() { - let open_task = workspace.open_paths( - paths_to_open, - workspace::OpenOptions { - focus: Some(false), - ..Default::default() - }, - None, - window, - cx, - ); - cx.spawn(async move |_, _| -> anyhow::Result<()> { - for item in open_task.await.into_iter().flatten() { - // Best-effort: files that don't exist on the target - // branch will fail to open and that's fine. - item.log_err(); - } - Ok(()) + conversation_view + .read(cx) + .root_thread_view() + .is_some_and(|thread_view| { + let thread_view = thread_view.read(cx); + thread_view + .thread + .read(cx) + .draft_prompt() + .is_some_and(|draft| !draft.is_empty()) + || !thread_view + .message_editor + .read(cx) + .text(cx) + .trim() + .is_empty() }) - .detach_and_log_err(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); - - workspace.focus_panel::(window, cx); - - if let Some(panel) = workspace.panel::(cx) { - panel.update(cx, |panel, cx| { - panel.external_thread( - selected_agent, - None, - None, - None, - Some(initial_content), - true, - "agent_panel", - window, - cx, - ); - }); - } - }) - })?; - - this.update_in(cx, |this, window, cx| { - this.worktree_creation_status = None; - - if let Some(thread_view) = this.active_thread_view(cx) { - thread_view.update(cx, |thread_view, cx| { - thread_view - .message_editor - .update(cx, |editor, cx| editor.clear(window, cx)); - }); - } - - this.serialize(cx); - cx.notify(); - })?; - - anyhow::Ok(()) - } -} - -impl Focusable for AgentPanel { - fn focus_handle(&self, cx: &App) -> FocusHandle { - match self.visible_surface() { - VisibleSurface::Uninitialized => self.focus_handle.clone(), - VisibleSurface::AgentThread(conversation_view) => conversation_view.focus_handle(cx), - VisibleSurface::History(view) => view.read(cx).focus_handle(cx), - VisibleSurface::Configuration(configuration) => { - if let Some(configuration) = configuration { - configuration.focus_handle(cx) - } else { - self.focus_handle.clone() - } } } } -} -fn agent_panel_dock_position(cx: &App) -> DockPosition { - AgentSettings::get_global(cx).dock.into() -} - -pub enum AgentPanelEvent { - ActiveViewChanged, - ThreadFocused, - RetainedThreadChanged, - ThreadInteracted { thread_id: ThreadId }, -} - -impl EventEmitter for AgentPanel {} -impl EventEmitter for AgentPanel {} - -impl Panel for AgentPanel { - fn persistent_name() -> &'static str { - "AgentPanel" - } - - fn panel_key() -> &'static str { - AGENT_PANEL_KEY - } - - fn position(&self, _window: &Window, cx: &App) -> DockPosition { - agent_panel_dock_position(cx) - } - - fn position_is_valid(&self, position: DockPosition) -> bool { - position != DockPosition::Bottom + fn active_initial_content(&self, cx: &App) -> Option { + self.active_thread_view(cx).and_then(|thread_view| { + thread_view + .read(cx) + .thread + .read(cx) + .draft_prompt() + .map(|draft| AgentInitialContent::ContentBlock { + blocks: draft.to_vec(), + auto_submit: false, + }) + .filter(|initial_content| match initial_content { + AgentInitialContent::ContentBlock { blocks, .. } => !blocks.is_empty(), + _ => true, + }) + .or_else(|| { + let text = thread_view.read(cx).message_editor.read(cx).text(cx); + if text.trim().is_empty() { + None + } else { + Some(AgentInitialContent::ContentBlock { + blocks: vec![acp::ContentBlock::Text(acp::TextContent::new(text))], + auto_submit: false, + }) + } + }) + }) } - fn set_position(&mut self, position: DockPosition, _: &mut Window, cx: &mut Context) { - let side = match position { - DockPosition::Left => "left", - DockPosition::Right | DockPosition::Bottom => "right", + fn source_panel_initialization( + source_workspace: &WeakEntity, + cx: &App, + ) -> Option<(Agent, AgentInitialContent)> { + let source_workspace = source_workspace.upgrade()?; + let source_panel = source_workspace.read(cx).panel::(cx)?; + let source_panel = source_panel.read(cx); + let initial_content = source_panel.active_initial_content(cx)?; + let agent = if source_panel.project.read(cx).is_via_collab() { + Agent::NativeAgent + } else { + source_panel.selected_agent.clone() }; - telemetry::event!("Agent Panel Side Changed", side = side); - settings::update_settings_file(self.fs.clone(), cx, move |settings, _| { - settings - .agent - .get_or_insert_default() - .set_dock(position.into()); - }); + Some((agent, initial_content)) } - fn default_size(&self, window: &Window, cx: &App) -> Pixels { - let settings = AgentSettings::get_global(cx); - match self.position(window, cx) { - DockPosition::Left | DockPosition::Right => settings.default_width, - DockPosition::Bottom => settings.default_height, - } - } - - fn min_size(&self, window: &Window, cx: &App) -> Option { - match self.position(window, cx) { - DockPosition::Left | DockPosition::Right => Some(MIN_PANEL_WIDTH), - DockPosition::Bottom => None, - } - } - - fn supports_flexible_size(&self) -> bool { - true - } - - fn has_flexible_size(&self, _window: &Window, cx: &App) -> bool { - AgentSettings::get_global(cx).flexible - } - - fn set_flexible_size(&mut self, flexible: bool, _window: &mut Window, cx: &mut Context) { - settings::update_settings_file(self.fs.clone(), cx, move |settings, _| { - settings - .agent - .get_or_insert_default() - .set_flexible_size(flexible); - }); - } - - fn set_active(&mut self, active: bool, window: &mut Window, cx: &mut Context) { - if active { - self.ensure_thread_initialized(window, cx); + pub fn initialize_from_source_workspace_if_needed( + &mut self, + source_workspace: WeakEntity, + window: &mut Window, + cx: &mut Context, + ) -> bool { + if self.destination_has_meaningful_state(cx) { + return false; } - } - - fn remote_id() -> Option { - Some(proto::PanelId::AssistantPanel) - } - - fn icon(&self, _window: &Window, cx: &App) -> Option { - (self.enabled(cx) && AgentSettings::get_global(cx).button).then_some(IconName::ZedAssistant) - } - fn icon_tooltip(&self, _window: &Window, _cx: &App) -> Option<&'static str> { - Some("Agent Panel") - } - - fn toggle_action(&self) -> Box { - Box::new(ToggleFocus) - } - - fn activation_priority(&self) -> u32 { - 0 - } - - fn enabled(&self, cx: &App) -> bool { - AgentSettings::get_global(cx).enabled(cx) - } + let Some((agent, initial_content)) = + Self::source_panel_initialization(&source_workspace, cx) + else { + return false; + }; - fn is_agent_panel(&self) -> bool { + let agent = if self.project.read(cx).is_via_collab() { + Agent::NativeAgent + } else { + agent + }; + let thread = self.create_agent_thread( + agent, + None, + None, + None, + Some(initial_content), + "agent_panel", + window, + cx, + ); + self.draft_thread = Some(thread.conversation_view.clone()); + self.observe_draft_editor(&thread.conversation_view, cx); + self.set_base_view(thread.into(), false, window, cx); true } - fn is_zoomed(&self, _window: &Window, _cx: &App) -> bool { - self.zoomed - } - - fn set_zoomed(&mut self, zoomed: bool, _window: &mut Window, cx: &mut Context) { - self.zoomed = zoomed; - cx.notify(); - } -} - -impl AgentPanel { - fn ensure_thread_initialized(&mut self, window: &mut Window, cx: &mut Context) { - if matches!(self.base_view, BaseView::Uninitialized) - && !matches!( - self.worktree_creation_status, - Some((_, WorktreeCreationStatus::Creating(_))) - ) - { - self.activate_draft(false, window, cx); - } - } - fn render_title_view(&self, _window: &mut Window, cx: &Context) -> AnyElement { let content = match self.visible_surface() { VisibleSurface::AgentThread(conversation_view) => { @@ -4233,121 +3266,9 @@ impl AgentPanel { }) } - fn project_has_git_repository(&self, cx: &App) -> bool { - !self.project.read(cx).repositories(cx).is_empty() - } - - fn is_active_view_creating_worktree(&self, _cx: &App) -> bool { - match &self.worktree_creation_status { - Some((view_id, WorktreeCreationStatus::Creating(_))) => { - self.active_conversation_view().map(|v| v.entity_id()) == Some(*view_id) - } - _ => false, - } - } - - 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 worktree".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 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 chevron_icon = if self.start_thread_in_menu_handle.is_deployed() { - IconName::ChevronUp - } else { - IconName::ChevronDown - }; - - 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(); - - PopoverMenu::new("thread-target-selector") - .trigger_with_tooltip(trigger_button, { - move |_window, cx| { - Tooltip::for_action_in( - "Select Worktree…", - &ToggleWorktreeSelector, - &focus_handle, - cx, - ) - } - }) - .menu(move |window, cx| { - Some(cx.new(|cx| ThreadWorktreePicker::new(project.clone(), window, cx))) - }) - .with_handle(self.start_thread_in_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(); + let focus_handle = self.focus_handle(cx); let (selected_agent_custom_icon, selected_agent_label) = @@ -4693,13 +3614,7 @@ impl AgentPanel { .size_full() .gap(DynamicSpacing::Base04.rems(cx)) .pl(DynamicSpacing::Base04.rems(cx)) - .child(agent_selector_menu) - .when( - agent_v2_enabled - && has_visible_worktrees - && self.project_has_git_repository(cx), - |this| this.child(self.render_start_thread_in_selector(cx)), - ), + .child(agent_selector_menu), ) .child( h_flex() @@ -4784,35 +3699,6 @@ impl AgentPanel { .child(toolbar_content) } - fn render_worktree_creation_status(&self, cx: &mut Context) -> Option { - let (view_id, status) = self.worktree_creation_status.as_ref()?; - let active_view_id = self.active_conversation_view().map(|v| v.entity_id()); - if active_view_id != Some(*view_id) { - return None; - } - match status { - WorktreeCreationStatus::Creating(_) | WorktreeCreationStatus::Loading(_) => None, - WorktreeCreationStatus::Error(message) => Some( - Callout::new() - .icon(IconName::XCircleFilled) - .severity(Severity::Error) - .title("Worktree Creation Error") - .description(message.clone()) - .border_position(ui::BorderPosition::Bottom) - .dismiss_action( - IconButton::new("dismiss-worktree-error", IconName::Close) - .icon_size(IconSize::Small) - .tooltip(Tooltip::text("Dismiss")) - .on_click(cx.listener(|this, _, _, cx| { - this.worktree_creation_status = None; - cx.notify(); - })), - ) - .into_any_element(), - ), - } - } - fn should_render_trial_end_upsell(&self, cx: &mut Context) -> bool { if TrialEndUpsell::dismissed(cx) { return false; @@ -5116,7 +4002,6 @@ impl Render for AgentPanel { parent.children(configuration.cloned()) } }) - .children(self.render_worktree_creation_status(cx)) .children(self.render_trial_end_upsell(window, cx)); match self.visible_font_size() { @@ -5250,26 +4135,8 @@ impl AgentPanel { /// /// This is a test-only helper that exposes the private `open_history()` /// method for visual tests. - pub fn open_history_for_tests(&mut self, window: &mut Window, cx: &mut Context) { - self.open_history(window, cx); - } - - /// Opens the start_thread_in selector popover menu. - /// - /// This is a test-only helper for visual tests. - pub fn open_start_thread_in_menu_for_tests( - &mut self, - window: &mut Window, - cx: &mut Context, - ) { - self.start_thread_in_menu_handle.show(window, cx); - } - - /// Dismisses the start_thread_in dropdown menu. - /// - /// This is a test-only helper for visual tests. - pub fn close_start_thread_in_menu_for_tests(&mut self, cx: &mut Context) { - self.start_thread_in_menu_handle.hide(cx); + pub fn open_history_for_tests(&mut self, window: &mut Window, cx: &mut Context) { + self.open_history(window, cx); } /// Creates a draft thread using a stub server and sets it as the active view. @@ -5302,6 +4169,7 @@ impl AgentPanel { #[cfg(test)] mod tests { use super::*; + use crate::NewWorktreeBranchTarget; use crate::conversation_view::tests::{StubAgentServer, init_test}; use crate::test_support::{ active_session_id, active_thread_id, open_thread_with_connection, @@ -5309,8 +4177,7 @@ mod tests { }; use acp_thread::{AgentConnection, StubAgentConnection, ThreadStatus, UserMessageId}; use action_log::ActionLog; - use agent_servers::CODEX_ID; - use anyhow::Result; + use anyhow::{Result, anyhow}; use feature_flags::FeatureFlagAppExt; use fs::FakeFs; use gpui::{App, TestAppContext, VisualTestContext}; @@ -6502,418 +5369,96 @@ mod tests { conversation_view.update(cx, |view, cx| { view.set_updated_at(base_time + Duration::from_secs(index as u64), cx); }); - } - panel.cleanup_retained_threads(cx); - }); - - panel.read_with(&cx, |panel, _cx| { - assert_eq!( - panel.retained_threads.len(), - 6, - "cleanup should keep the non-loadable idle thread in addition to five loadable ones" - ); - assert!( - panel.retained_threads.contains_key(&non_loadable_thread_id), - "idle non-loadable retained threads should not be cleanup candidates" - ); - assert!( - !panel.retained_threads.contains_key(&loadable_thread_ids[0]), - "oldest idle loadable retained thread should still be removed" - ); - for thread_id in &loadable_thread_ids[1..6] { - assert!( - panel.retained_threads.contains_key(thread_id), - "more recent idle loadable retained threads should be retained" - ); - } - assert!( - !panel.retained_threads.contains_key(&loadable_thread_ids[6]), - "the active loadable thread should not also be stored as a retained 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, - ); - - // snake_case (canonical format) - assert_eq!( - serde_json::from_str::(r#""native_agent""#).unwrap(), - Agent::NativeAgent, - ); - assert_eq!( - serde_json::from_str::(r#"{"custom":{"name":"my-agent"}}"#).unwrap(), - Agent::Custom { - id: "my-agent".into(), - }, - ); - - // Serialization uses snake_case - assert_eq!( - serde_json::to_string(&Agent::NativeAgent).unwrap(), - r#""native_agent""#, - ); - assert_eq!( - serde_json::to_string(&Agent::Custom { - id: "my-agent".into() - }) - .unwrap(), - r#"{"custom":{"name":"my-agent"}}"#, - ); - } - - #[gpui::test] - fn test_resolve_worktree_branch_target() { - let resolved = - AgentPanel::resolve_worktree_branch_target(&NewWorktreeBranchTarget::CreateBranch { - name: "new-branch".to_string(), - from_ref: Some("main".to_string()), - }); - assert_eq!( - resolved, - (Some("new-branch".to_string()), Some("main".to_string())) - ); - - let resolved = - AgentPanel::resolve_worktree_branch_target(&NewWorktreeBranchTarget::CreateBranch { - name: "new-branch".to_string(), - from_ref: None, - }); - assert_eq!(resolved, (Some("new-branch".to_string()), None)); - - let resolved = - AgentPanel::resolve_worktree_branch_target(&NewWorktreeBranchTarget::ExistingBranch { - name: "feature".to_string(), - }); - assert_eq!( - resolved, - (Some("feature".to_string()), Some("feature".to_string())) - ); - - let resolved = - AgentPanel::resolve_worktree_branch_target(&NewWorktreeBranchTarget::CurrentBranch); - assert_eq!(resolved, (None, None)); - } - - #[gpui::test] - async fn test_worktree_dir_name_is_random_when_using_existing_branch(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() {}" - } - }), - ) - .await; - // Put the main worktree on "develop" so that "main" is NOT - // occupied by any worktree. - fs.set_branch_name(Path::new("/project/.git"), Some("develop")); - fs.insert_branches(Path::new("/project/.git"), &["main", "develop"]); - - 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(); - }); - - 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(); - - panel.update_in(cx, |panel, window, cx| { - panel.open_external_thread_with_server( - Rc::new(StubAgentServer::default_response()), - window, - cx, - ); - }); - - cx.run_until_parked(); - - // Select "main" as an existing branch — this should NOT make the - // worktree directory named "main"; it should get a random name. - let content = vec![acp::ContentBlock::Text(acp::TextContent::new( - "Hello from test", - ))]; - panel.update_in(cx, |panel, window, cx| { - panel.handle_worktree_requested( - content, - WorktreeCreationArgs::New { - worktree_name: None, - branch_target: NewWorktreeBranchTarget::ExistingBranch { - name: "main".to_string(), - }, - }, - PreviousWorkspaceState::empty(), - window, - cx, - ); - }); - - cx.run_until_parked(); - - // Find the new workspace and check its worktree path. - let new_worktree_path = multi_workspace - .read_with(cx, |multi_workspace, cx| { - let new_workspace = multi_workspace - .workspaces() - .find(|ws| ws.entity_id() != workspace.entity_id()) - .expect("a new workspace should have been created"); - - let new_project = new_workspace.read(cx).project().clone(); - let worktree = new_project - .read(cx) - .visible_worktrees(cx) - .next() - .expect("new workspace should have a worktree"); - worktree.read(cx).abs_path().to_path_buf() - }) - .unwrap(); - - // The worktree directory path should contain a random adjective-noun - // name, NOT the branch name "main". - let path_str = new_worktree_path.to_string_lossy(); - assert!( - !path_str.contains("/main/"), - "worktree directory should use a random name, not the branch name. \ - Got path: {path_str}", - ); - // Verify it looks like an adjective-noun pair (contains a hyphen in - // the directory component above the project name). - let parent = new_worktree_path - .parent() - .and_then(|p| p.file_name()) - .and_then(|n| n.to_str()) - .expect("should have a parent directory name"); - assert!( - parent.contains('-'), - "worktree parent directory should be an adjective-noun pair (e.g. 'swift-falcon'), \ - got: {parent}", - ); - } - - #[gpui::test] - async fn test_worktree_creation_preserves_selected_agent(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() {}" - } - }), - ) - .await; - fs.set_branch_name(Path::new("/project/.git"), Some("main")); - - 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 a callback so new workspaces also get an AgentPanel. - 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); - - // 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(); - - // Open a thread (needed so there's an active thread view). - panel.update_in(cx, |panel, window, cx| { - panel.open_external_thread_with_server( - Rc::new(StubAgentServer::default_response()), - window, - cx, - ); - }); - - cx.run_until_parked(); - - // 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(), - }; - cx.notify(); - }); - - // Verify the panel has the Codex agent selected. - panel.read_with(cx, |panel, _cx| { - assert_eq!( - panel.selected_agent, - Agent::Custom { - id: CODEX_ID.into() - }, - ); - }); - - // Directly call handle_worktree_requested to trigger worktree creation. - let content = vec![acp::ContentBlock::Text(acp::TextContent::new( - "Hello from test", - ))]; - panel.update_in(cx, |panel, window, cx| { - panel.handle_worktree_requested( - content, - WorktreeCreationArgs::New { - worktree_name: None, - branch_target: NewWorktreeBranchTarget::default(), - }, - PreviousWorkspaceState::empty(), - window, - cx, - ); + } + panel.cleanup_retained_threads(cx); }); - // Let the async worktree creation + workspace setup complete. - cx.run_until_parked(); - - // Find the new workspace's AgentPanel and verify it used the Codex agent. - let found_codex = multi_workspace - .read_with(cx, |multi_workspace, cx| { - // There should be more than one workspace now (the original + the new worktree). + panel.read_with(&cx, |panel, _cx| { + assert_eq!( + panel.retained_threads.len(), + 6, + "cleanup should keep the non-loadable idle thread in addition to five loadable ones" + ); + assert!( + panel.retained_threads.contains_key(&non_loadable_thread_id), + "idle non-loadable retained threads should not be cleanup candidates" + ); + assert!( + !panel.retained_threads.contains_key(&loadable_thread_ids[0]), + "oldest idle loadable retained thread should still be removed" + ); + for thread_id in &loadable_thread_ids[1..6] { assert!( - multi_workspace.workspaces().count() > 1, - "expected a new workspace to have been created, found {}", - multi_workspace.workspaces().count(), + panel.retained_threads.contains_key(thread_id), + "more recent idle loadable retained threads should be retained" ); + } + assert!( + !panel.retained_threads.contains_key(&loadable_thread_ids[6]), + "the active loadable thread should not also be stored as a retained thread" + ); + }); + } - // Check the newest workspace's panel for the correct agent. - let new_workspace = multi_workspace - .workspaces() - .find(|ws| ws.entity_id() != workspace.entity_id()) - .expect("should find the new workspace"); - let new_panel = new_workspace - .read(cx) - .panel::(cx) - .expect("new workspace should have an AgentPanel"); + #[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(), + }, + ); - new_panel.read(cx).selected_agent.clone() - }) - .unwrap(); + // Legacy TextThread variant deserializes to NativeAgent + assert_eq!( + serde_json::from_str::(r#""TextThread""#).unwrap(), + Agent::NativeAgent, + ); + // snake_case (canonical format) + assert_eq!( + serde_json::from_str::(r#""native_agent""#).unwrap(), + Agent::NativeAgent, + ); assert_eq!( - found_codex, + serde_json::from_str::(r#"{"custom":{"name":"my-agent"}}"#).unwrap(), Agent::Custom { - id: CODEX_ID.into() + id: "my-agent".into(), + }, + ); + + // Serialization uses snake_case + assert_eq!( + serde_json::to_string(&Agent::NativeAgent).unwrap(), + r#""native_agent""#, + ); + assert_eq!( + serde_json::to_string(&Agent::Custom { + id: "my-agent".into() + }) + .unwrap(), + r#"{"custom":{"name":"my-agent"}}"#, + ); + } + + #[gpui::test] + fn test_resolve_worktree_branch_target() { + let resolved = git_ui::worktree_service::resolve_worktree_branch_target( + &NewWorktreeBranchTarget::ExistingBranch { + name: "feature".to_string(), }, - "the new worktree workspace should use the same agent (Codex) that was selected in the original panel", ); + assert_eq!(resolved, Some("feature".to_string())); + + let resolved = git_ui::worktree_service::resolve_worktree_branch_target( + &NewWorktreeBranchTarget::CurrentBranch, + ); + assert_eq!(resolved, None); } #[gpui::test] @@ -7451,7 +5996,12 @@ mod tests { let result = multi_workspace .update(cx, |_, window, cx| { window.spawn(cx, async move |cx| { - AgentPanel::await_and_rollback_on_failure(creation_infos, fs_clone, cx).await + git_ui::worktree_service::await_and_rollback_on_failure( + creation_infos, + fs_clone, + cx, + ) + .await }) }) .unwrap() @@ -7534,7 +6084,12 @@ mod tests { let result = multi_workspace .update(cx, |_, window, cx| { window.spawn(cx, async move |cx| { - AgentPanel::await_and_rollback_on_failure(creation_infos, fs_clone, cx).await + git_ui::worktree_service::await_and_rollback_on_failure( + creation_infos, + fs_clone, + cx, + ) + .await }) }) .unwrap() @@ -7600,7 +6155,12 @@ mod tests { let result = multi_workspace .update(cx, |_, window, cx| { window.spawn(cx, async move |cx| { - AgentPanel::await_and_rollback_on_failure(creation_infos, fs_clone, cx).await + git_ui::worktree_service::await_and_rollback_on_failure( + creation_infos, + fs_clone, + cx, + ) + .await }) }) .unwrap() @@ -7610,481 +6170,83 @@ mod tests { result.is_err(), "should return error when receiver is canceled" ); - let err_msg = result.unwrap_err().to_string(); - assert!( - err_msg.contains("canceled"), - "error should mention cancellation: {err_msg}" - ); - } - - #[gpui::test] - async fn test_rollback_cleans_up_orphan_directories(cx: &mut TestAppContext) { - init_test(cx); - let fs = FakeFs::new(cx.executor()); - cx.update(|cx| { - cx.update_flags(true, vec!["agent-v2".to_string()]); - 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; - cx.executor().run_until_parked(); - - let repository = project.read_with(cx, |project, cx| { - project.repositories(cx).values().next().unwrap().clone() - }); - - let multi_workspace = - cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); - - // Simulate the orphan state: create_dir_all was called but git - // worktree add failed, leaving a directory with leftover files. - let orphan_path = PathBuf::from("/worktrees/branch/orphan_project"); - fs.insert_tree( - "/worktrees/branch/orphan_project", - json!({ "leftover.txt": "junk" }), - ) - .await; - - assert!( - fs.is_dir(&orphan_path).await, - "orphan dir should exist before rollback" - ); - - let (sender, receiver) = futures::channel::oneshot::channel::>(); - sender.send(Err(anyhow!("hook failed"))).unwrap(); - - let creation_infos = vec![(repository.clone(), orphan_path.clone(), receiver)]; - - let fs_clone = fs.clone(); - let result = multi_workspace - .update(cx, |_, window, cx| { - window.spawn(cx, async move |cx| { - AgentPanel::await_and_rollback_on_failure(creation_infos, fs_clone, cx).await - }) - }) - .unwrap() - .await; - - cx.executor().run_until_parked(); - - assert!(result.is_err()); - assert!( - !fs.is_dir(&orphan_path).await, - "orphan worktree directory should be removed by filesystem cleanup" - ); - } - - #[gpui::test] - async fn test_worktree_creation_for_remote_project( - cx: &mut TestAppContext, - server_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 - }); - - server_cx.update(|cx| { - release_channel::init(semver::Version::new(0, 0, 0), cx); - }); - - // Set up the remote server side with a git repo. - let server_fs = FakeFs::new(server_cx.executor()); - server_fs - .insert_tree( - "/project", - json!({ - ".git": {}, - "src": { - "main.rs": "fn main() {}" - } - }), - ) - .await; - server_fs.set_branch_name(Path::new("/project/.git"), Some("main")); - - // Create a mock remote connection. - let (opts, server_session, _) = remote::RemoteClient::fake_server(cx, server_cx); - - server_cx.update(remote_server::HeadlessProject::init); - let server_executor = server_cx.executor(); - let _headless = server_cx.new(|cx| { - remote_server::HeadlessProject::new( - remote_server::HeadlessAppState { - session: server_session, - fs: server_fs.clone(), - http_client: Arc::new(http_client::BlockedHttpClient), - node_runtime: node_runtime::NodeRuntime::unavailable(), - languages: Arc::new(language::LanguageRegistry::new(server_executor.clone())), - extension_host_proxy: Arc::new(extension::ExtensionHostProxy::new()), - startup_time: Instant::now(), - }, - false, - cx, - ) - }); - - // Connect the client side and build a remote project. - // Use a separate Client to avoid double-registering proto handlers - // (Workspace::test_new creates its own WorkspaceStore from the - // project's client). - let remote_client = remote::RemoteClient::connect_mock(opts, cx).await; - let project = cx.update(|cx| { - let project_client = client::Client::new( - Arc::new(clock::FakeSystemClock::new()), - http_client::FakeHttpClient::with_404_response(), - cx, - ); - let user_store = cx.new(|cx| client::UserStore::new(project_client.clone(), cx)); - project::Project::remote( - remote_client, - project_client, - node_runtime::NodeRuntime::unavailable(), - user_store, - app_state.languages.clone(), - app_state.fs.clone(), - false, - cx, - ) - }); - - // Open the remote path as a worktree in the project. - let worktree_path = Path::new("/project"); - project - .update(cx, |project, cx| { - project.find_or_create_worktree(worktree_path, true, cx) - }) - .await - .expect("should be able to open remote worktree"); - cx.run_until_parked(); - - // Verify the project is indeed remote. - project.read_with(cx, |project, cx| { - assert!(!project.is_local(), "project should be remote, not local"); - assert!( - project.remote_connection_options(cx).is_some(), - "project should have remote connection options" - ); - }); - - // Create the workspace and agent panel. - 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, |mw, _cx| mw.workspace().clone()) - .unwrap(); - - workspace.update(cx, |workspace, _cx| { - workspace.set_random_database_id(); - }); - - // Register a callback so new workspaces also get an AgentPanel. - 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 a thread. - panel.update_in(cx, |panel, window, cx| { - panel.open_external_thread_with_server( - Rc::new(StubAgentServer::default_response()), - window, - cx, - ); - }); - cx.run_until_parked(); - - // Trigger worktree creation for a known linked path. - let linked_path = PathBuf::from("/project"); - let content = vec![acp::ContentBlock::Text(acp::TextContent::new( - "Hello from remote test", - ))]; - panel.update_in(cx, |panel, window, cx| { - panel.handle_worktree_requested( - content, - WorktreeCreationArgs::Linked { - worktree_path: linked_path, - display_name: "test-worktree".to_string(), - }, - PreviousWorkspaceState::empty(), - window, - cx, - ); - }); - - // The refactored code uses `find_or_create_workspace`, which - // finds the existing remote workspace (matching paths + host) - // and reuses it instead of creating a new connection. - cx.run_until_parked(); - - // The task should have completed: the existing workspace was - // found and reused. - panel.read_with(cx, |panel, _cx| { - assert!( - panel.worktree_creation_status.is_none(), - "worktree creation should have completed, but status is: {:?}", - panel.worktree_creation_status - ); - }); - - // The existing remote workspace was reused — no new workspace - // should have been created. - multi_workspace - .read_with(cx, |multi_workspace, cx| { - let project = workspace.read(cx).project().clone(); - assert!( - !project.read(cx).is_local(), - "workspace project should still be remote, not local" - ); - assert_eq!( - multi_workspace.workspaces().count(), - 1, - "existing remote workspace should be reused, not a new one created" - ); - }) - .unwrap(); + let err_msg = result.unwrap_err().to_string(); + assert!( + err_msg.contains("canceled"), + "error should mention cancellation: {err_msg}" + ); } #[gpui::test] - async fn test_linked_worktree_switch_remaps_open_files(cx: &mut TestAppContext) { + async fn test_rollback_cleans_up_orphan_directories(cx: &mut TestAppContext) { init_test(cx); - - let app_state = cx.update(|cx| { + let fs = FakeFs::new(cx.executor()); + cx.update(|cx| { + cx.update_flags(true, vec!["agent-v2".to_string()]); 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 + ::set_global(fs.clone(), cx); }); - let fs = app_state.fs.as_fake(); fs.insert_tree( "/project", json!({ ".git": {}, - "src": { - "main.rs": "fn main() {}", - "lib.rs": "pub fn hello() {}" - } + "src": { "main.rs": "fn main() {}" } }), ) .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(fs.clone(), [Path::new("/project")], cx).await; + cx.executor().run_until_parked(); - let project = Project::test(app_state.fs.clone(), [Path::new("/project")], cx).await; + let repository = project.read_with(cx, |project, cx| { + project.repositories(cx).values().next().unwrap().clone() + }); 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(); + // Simulate the orphan state: create_dir_all was called but git + // worktree add failed, leaving a directory with leftover files. + let orphan_path = PathBuf::from("/worktrees/branch/orphan_project"); + fs.insert_tree( + "/worktrees/branch/orphan_project", + json!({ "leftover.txt": "junk" }), + ) + .await; - // 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")), - }); + assert!( + fs.is_dir(&orphan_path).await, + "orphan dir should exist before rollback" + ); - // 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, - ); - }); + let (sender, receiver) = futures::channel::oneshot::channel::>(); + sender.send(Err(anyhow!("hook failed"))).unwrap(); - cx.run_until_parked(); + let creation_infos = vec![(repository.clone(), orphan_path.clone(), receiver)]; - // 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() + let fs_clone = fs.clone(); + let result = multi_workspace + .update(cx, |_, window, cx| { + window.spawn(cx, async move |cx| { + git_ui::worktree_service::await_and_rollback_on_failure( + creation_infos, + fs_clone, + cx, + ) + .await + }) }) .unwrap() - .expect("a new workspace should have been created for the linked worktree"); + .await; - // 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)); + cx.executor().run_until_parked(); + assert!(result.is_err()); 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:?}" + !fs.is_dir(&orphan_path).await, + "orphan worktree directory should be removed by filesystem cleanup" ); } @@ -8218,7 +6380,8 @@ mod tests { cx.run_until_parked(); panel.read_with(cx, |panel, cx| { - let (git_repos, non_git_paths) = panel.classify_worktrees(cx); + let (git_repos, non_git_paths) = + git_ui::worktree_service::classify_worktrees(panel.project.read(cx), cx); let git_work_dirs: Vec = git_repos .iter() @@ -8626,4 +6789,192 @@ mod tests { ); }); } + + #[gpui::test] + async fn test_initialize_from_source_transfers_draft_to_fresh_panel(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()); + let project_a = Project::test(fs.clone(), [], cx).await; + let project_b = Project::test(fs.clone(), [], cx).await; + + let multi_workspace = + cx.add_window(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx)); + + let workspace_a = multi_workspace + .read_with(cx, |mw, _cx| mw.workspace().clone()) + .unwrap(); + + let workspace_b = multi_workspace + .update(cx, |multi_workspace, window, cx| { + multi_workspace.test_add_workspace(project_b.clone(), window, cx) + }) + .unwrap(); + + let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx); + + // Set up panel_a with an active thread and type draft text. + let panel_a = workspace_a.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_a.update_in(cx, |panel, window, cx| { + panel.open_external_thread_with_server( + Rc::new(StubAgentServer::default_response()), + window, + cx, + ); + }); + cx.run_until_parked(); + + let thread_view_a = + panel_a.read_with(cx, |panel, cx| panel.active_thread_view(cx).unwrap()); + let editor_a = thread_view_a.read_with(cx, |view, _cx| view.message_editor.clone()); + editor_a.update_in(cx, |editor, window, cx| { + editor.set_text("Draft from workspace A", window, cx); + }); + + // Set up panel_b on workspace_b — starts as a fresh, empty panel. + let panel_b = workspace_b.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(); + + // Initializing panel_b from workspace_a should transfer the draft, + // even if panel_b already has an auto-created empty draft thread + // (which set_active creates during add_panel). + let transferred = panel_b.update_in(cx, |panel, window, cx| { + panel.initialize_from_source_workspace_if_needed(workspace_a.downgrade(), window, cx) + }); + assert!( + transferred, + "fresh destination panel should accept source content" + ); + + // Verify the panel was initialized: the base_view should now be an + // AgentThread (not Uninitialized) and a draft_thread should be set. + // We can't check the message editor text directly because the thread + // needs a connected server session (not available in unit tests without + // a stub server). The `transferred == true` return already proves that + // source_panel_initialization read the content successfully. + panel_b.read_with(cx, |panel, _cx| { + assert!( + panel.active_conversation_view().is_some(), + "panel_b should have a conversation view after initialization" + ); + assert!( + panel.draft_thread.is_some(), + "panel_b should have a draft_thread set after initialization" + ); + }); + } + + #[gpui::test] + async fn test_initialize_from_source_does_not_overwrite_existing_content( + 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()); + let project_a = Project::test(fs.clone(), [], cx).await; + let project_b = Project::test(fs.clone(), [], cx).await; + + let multi_workspace = + cx.add_window(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx)); + + let workspace_a = multi_workspace + .read_with(cx, |mw, _cx| mw.workspace().clone()) + .unwrap(); + + let workspace_b = multi_workspace + .update(cx, |multi_workspace, window, cx| { + multi_workspace.test_add_workspace(project_b.clone(), window, cx) + }) + .unwrap(); + + let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx); + + // Set up panel_a with draft text. + let panel_a = workspace_a.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_a.update_in(cx, |panel, window, cx| { + panel.open_external_thread_with_server( + Rc::new(StubAgentServer::default_response()), + window, + cx, + ); + }); + cx.run_until_parked(); + + let thread_view_a = + panel_a.read_with(cx, |panel, cx| panel.active_thread_view(cx).unwrap()); + let editor_a = thread_view_a.read_with(cx, |view, _cx| view.message_editor.clone()); + editor_a.update_in(cx, |editor, window, cx| { + editor.set_text("Draft from workspace A", window, cx); + }); + + // Set up panel_b with its OWN content — this is a non-fresh panel. + let panel_b = workspace_b.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_b.update_in(cx, |panel, window, cx| { + panel.open_external_thread_with_server( + Rc::new(StubAgentServer::default_response()), + window, + cx, + ); + }); + cx.run_until_parked(); + + let thread_view_b = + panel_b.read_with(cx, |panel, cx| panel.active_thread_view(cx).unwrap()); + let editor_b = thread_view_b.read_with(cx, |view, _cx| view.message_editor.clone()); + editor_b.update_in(cx, |editor, window, cx| { + editor.set_text("Existing work in workspace B", window, cx); + }); + + // Attempting to initialize panel_b from workspace_a should be rejected + // because panel_b already has meaningful content. + let transferred = panel_b.update_in(cx, |panel, window, cx| { + panel.initialize_from_source_workspace_if_needed(workspace_a.downgrade(), window, cx) + }); + assert!( + !transferred, + "destination panel with existing content should not be overwritten" + ); + + // Verify panel_b still has its original content. + panel_b.read_with(cx, |panel, cx| { + let thread_view = panel + .active_thread_view(cx) + .expect("panel_b should still have its thread view"); + let text = thread_view.read(cx).message_editor.read(cx).text(cx); + assert_eq!( + text, "Existing work in workspace B", + "destination panel's content should be preserved" + ); + }); + } } diff --git a/crates/agent_ui/src/agent_ui.rs b/crates/agent_ui/src/agent_ui.rs index f703507de126fceae632bfd562e4639a18ff052a..437627105c6a1837555518c33447c6797f89f098 100644 --- a/crates/agent_ui/src/agent_ui.rs +++ b/crates/agent_ui/src/agent_ui.rs @@ -32,12 +32,10 @@ mod thread_history_view; mod thread_import; pub mod thread_metadata_store; pub mod thread_worktree_archive; -mod thread_worktree_picker; + pub mod threads_archive_view; mod ui; -mod worktree_names; -use std::path::PathBuf; use std::rc::Rc; use std::sync::Arc; @@ -65,9 +63,7 @@ use std::any::TypeId; use workspace::Workspace; use crate::agent_configuration::{ConfigureContextServerModal, ManageProfilesModal}; -pub use crate::agent_panel::{ - AgentPanel, AgentPanelEvent, MaxIdleRetainedThreads, WorktreeCreationStatus, -}; +pub use crate::agent_panel::{AgentPanel, AgentPanelEvent, MaxIdleRetainedThreads}; use crate::agent_registry_ui::AgentRegistryPage; pub use crate::inline_assistant::InlineAssistant; pub use crate::thread_metadata_store::ThreadId; @@ -84,6 +80,7 @@ pub use thread_import::{ channels_with_threads, import_threads_from_other_channels, }; use zed_actions; +pub use zed_actions::{CreateWorktree, NewWorktreeBranchTarget, SwitchWorktree}; pub const DEFAULT_THREAD_TITLE: &str = "New Agent Thread"; const PARALLEL_AGENT_LAYOUT_BACKFILL_KEY: &str = "parallel_agent_layout_backfilled"; @@ -92,8 +89,6 @@ actions!( [ /// Toggles the menu to create new agent threads. ToggleNewThreadMenu, - /// 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. @@ -348,46 +343,6 @@ impl Agent { } } -/// Describes which branch to use when creating a new git worktree. -#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] -#[serde(rename_all = "snake_case", tag = "kind")] -pub enum NewWorktreeBranchTarget { - /// Create a new randomly named branch from the current HEAD. - /// Will match worktree name if the newly created worktree was also randomly named. - #[default] - CurrentBranch, - /// Check out an existing branch, or create a new branch from it if it's - /// already occupied by another worktree. - ExistingBranch { name: String }, - /// Create a new branch with an explicit name, optionally from a specific ref. - CreateBranch { - name: String, - #[serde(default)] - from_ref: Option, - }, -} - -/// 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(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. pub enum AgentInitialContent { ThreadSummary { diff --git a/crates/agent_ui/src/conversation_view.rs b/crates/agent_ui/src/conversation_view.rs index bd78440df1e49ce55b3cf311b64141a677f473f4..b796f6dcfcf26d054ba79079f8dc8a885b911991 100644 --- a/crates/agent_ui/src/conversation_view.rs +++ b/crates/agent_ui/src/conversation_view.rs @@ -2560,7 +2560,12 @@ impl ConversationView { .update(cx, |multi_workspace, window, cx| { window.activate_window(); if let Some(workspace) = workspace_handle.upgrade() { - multi_workspace.activate(workspace.clone(), window, cx); + multi_workspace.activate( + workspace.clone(), + None, + window, + cx, + ); workspace.update(cx, |workspace, cx| { workspace.reveal_panel::(window, cx); if let Some(panel) = diff --git a/crates/agent_ui/src/thread_worktree_picker.rs b/crates/agent_ui/src/thread_worktree_picker.rs deleted file mode 100644 index 93d04fd131d4241d15c2fbb0af96b5d69d3920af..0000000000000000000000000000000000000000 --- a/crates/agent_ui/src/thread_worktree_picker.rs +++ /dev/null @@ -1,1036 +0,0 @@ -use std::path::PathBuf; -use std::sync::Arc; - -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, Subscription, Task, Window, rems, -}; -use picker::{Picker, PickerDelegate, PickerEditorPosition}; -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::{CreateWorktree, NewWorktreeBranchTarget, SwitchWorktree}; - -pub(crate) struct ThreadWorktreePicker { - picker: Entity>, - focus_handle: FocusHandle, - _subscriptions: Vec, -} - -impl ThreadWorktreePicker { - 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 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()) - }); - - let repository = if has_multiple_repositories { - None - } else { - project.read(cx).active_repository(cx) - }; - - // Fetch worktrees from the git backend (includes main + all linked) - let all_worktrees_request = repository - .clone() - .map(|repo| repo.update(cx, |repo, _| repo.worktrees())); - - let default_branch_request = repository - .clone() - .map(|repo| repo.update(cx, |repo, _| repo.default_branch(false))); - - let initial_matches = vec![ThreadWorktreeEntry::CreateFromCurrentBranch]; - - let delegate = ThreadWorktreePickerDelegate { - matches: initial_matches, - all_worktrees: Vec::new(), - project_worktree_paths, - selected_index: 0, - project, - 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 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, - _subscriptions: subscriptions, - } - } -} - -impl Focusable for ThreadWorktreePicker { - fn focus_handle(&self, _cx: &App) -> FocusHandle { - self.focus_handle.clone() - } -} - -impl EventEmitter for ThreadWorktreePicker {} - -impl Render for ThreadWorktreePicker { - fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - v_flex() - .w(rems(34.)) - .elevation_3(cx) - .child(self.picker.clone()) - .on_mouse_down_out(cx.listener(|_, _, _, cx| { - cx.emit(DismissEvent); - })) - } -} - -#[derive(Clone)] -enum ThreadWorktreeEntry { - CreateFromCurrentBranch, - CreateFromDefaultBranch { - default_branch_name: String, - }, - Separator, - 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, - project_worktree_paths: HashSet, - selected_index: usize, - project: Entity, - current_branch_name: Option, - default_branch_name: Option, - has_multiple_repositories: bool, -} - -impl ThreadWorktreePickerDelegate { - 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 - } - } - - fn sync_selected_index(&mut self, has_query: bool) { - if !has_query { - return; - } - - // When filtering, prefer selecting the first worktree match - if let Some(index) = self - .matches - .iter() - .position(|entry| matches!(entry, ThreadWorktreeEntry::Worktree { .. })) - { - self.selected_index = index; - } else if let Some(index) = self - .matches - .iter() - .position(|entry| matches!(entry, ThreadWorktreeEntry::CreateNamed { .. })) - { - self.selected_index = index; - } else { - self.selected_index = 0; - } - } -} - -impl PickerDelegate for ThreadWorktreePickerDelegate { - type ListItem = AnyElement; - - fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc { - "Select a worktree for this thread…".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(ThreadWorktreeEntry::Separator)) - } - - fn update_matches( - &mut self, - query: String, - window: &mut Window, - cx: &mut Context>, - ) -> Task<()> { - let repo_worktrees = self.all_repo_worktrees().to_vec(); - - let normalized_query = query.replace(' ', "-"); - 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: 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()) - } else { - None - }; - - 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() { - 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(), - }); - } - } - - self.matches = matches; - self.sync_selected_index(false); - return Task::ready(()); - } - - // When the user is typing, fuzzy-match worktree names using display_name - 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(); - - let executor = cx.background_executor().clone(); - - let task = cx.background_executor().spawn(async move { - fuzzy::match_strings( - &candidates, - &query, - true, - true, - 10000, - &Default::default(), - executor, - ) - .await - }); - - let repo_worktrees_clone = repo_worktrees; - cx.spawn_in(window, async move |picker, cx| { - let fuzzy_matches = task.await; - - picker - .update_in(cx, |picker, _window, cx| { - let mut new_matches: Vec = Vec::new(); - - for candidate in &fuzzy_matches { - new_matches.push(ThreadWorktreeEntry::Worktree { - worktree: repo_worktrees_clone[candidate.candidate_id].clone(), - positions: candidate.positions.clone(), - }); - } - - 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(); - }) - } - - 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::CreateFromCurrentBranch => { - window.dispatch_action( - Box::new(CreateWorktree { - worktree_name: None, - branch_target: NewWorktreeBranchTarget::CurrentBranch, - }), - cx, - ); - } - - ThreadWorktreeEntry::CreateFromDefaultBranch { - default_branch_name, - } => { - window.dispatch_action( - 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, - } => { - 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(_), - .. - } => { - return; - } - } - - 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)?; - let project = self.project.read(cx); - 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( - div() - .py(DynamicSpacing::Base04.rems(cx)) - .child(Divider::horizontal()) - .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::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::Worktree { - worktree, - positions, - } => { - 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().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!("worktree-{ix}"))) - .inset(true) - .spacing(ListItemSpacing::Sparse) - .toggle_state(selected) - .child( - h_flex() - .w_full() - .gap_2p5() - .child( - Icon::new(entry_icon) - .color(if is_current { - Color::Accent - } else { - Color::Muted - }) - .size(IconSize::Small), - ) - .child( - 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 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(), - }; - - let item = create_new_list_item( - element_id.into(), - label.into(), - disabled_reason.clone().map(SharedString::from), - selected, - ); - - Some(item.into_any_element()) - } - } - } -} - -#[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 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 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/call/src/call_impl/mod.rs b/crates/call/src/call_impl/mod.rs index b4bad6d2f350c3caa03eccbb8ca6582a71c6128c..39cb4cd9e3cb90dcd0f57a76af2d82d2f854eb1e 100644 --- a/crates/call/src/call_impl/mod.rs +++ b/crates/call/src/call_impl/mod.rs @@ -40,7 +40,7 @@ pub fn init(client: Arc, user_store: Entity, cx: &mut App) { &cx.entity(), window, move |multi_workspace, _, event: &MultiWorkspaceEvent, window, cx| { - if !matches!(event, MultiWorkspaceEvent::ActiveWorkspaceChanged) + if !matches!(event, MultiWorkspaceEvent::ActiveWorkspaceChanged { .. }) && window.is_window_active() { return; diff --git a/crates/git_ui/Cargo.toml b/crates/git_ui/Cargo.toml index 6927ae16a5c4aa50e5d91563dbb84b1f2e085fd0..3aef05d909b095f99a0e6db245f83d737a5c106e 100644 --- a/crates/git_ui/Cargo.toml +++ b/crates/git_ui/Cargo.toml @@ -27,6 +27,7 @@ component.workspace = true db.workspace = true editor.workspace = true file_icons.workspace = true +fs.workspace = true futures.workspace = true fuzzy.workspace = true git.workspace = true @@ -45,6 +46,7 @@ picker.workspace = true project.workspace = true prompt_store.workspace = true proto.workspace = true +rand.workspace = true remote_connection.workspace = true remote.workspace = true schemars.workspace = true diff --git a/crates/git_ui/src/branch_picker.rs b/crates/git_ui/src/branch_picker.rs index 4c8248791d56cac5301981f00682d1c41b564c60..39453f18191d4901e53607eb8b839532d85fbbfb 100644 --- a/crates/git_ui/src/branch_picker.rs +++ b/crates/git_ui/src/branch_picker.rs @@ -97,10 +97,11 @@ pub fn create_embedded( workspace: WeakEntity, repository: Option>, width: Rems, + show_footer: bool, window: &mut Window, cx: &mut Context, ) -> BranchList { - BranchList::new_embedded(workspace, repository, width, window, cx) + BranchList::new_embedded(workspace, repository, width, show_footer, window, cx) } #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] @@ -164,6 +165,7 @@ impl BranchList { picker.update(cx, |picker, _| { picker.delegate.focus_handle = picker_focus_handle.clone(); + picker.delegate.show_footer = !embedded; }); let mut subscriptions = Vec::new(); @@ -223,6 +225,7 @@ impl BranchList { workspace: WeakEntity, repository: Option>, width: Rems, + show_footer: bool, window: &mut Window, cx: &mut Context, ) -> Self { @@ -235,6 +238,9 @@ impl BranchList { window, cx, ); + this.picker.update(cx, |picker, _| { + picker.delegate.show_footer = show_footer; + }); this._subscriptions .push(cx.subscribe(&this.picker, |_, _, _, cx| { cx.emit(DismissEvent); @@ -386,6 +392,7 @@ pub struct BranchListDelegate { state: PickerState, focus_handle: FocusHandle, restore_selected_branch: Option, + show_footer: bool, } #[derive(Debug)] @@ -452,6 +459,7 @@ impl BranchListDelegate { state: PickerState::List, focus_handle: cx.focus_handle(), restore_selected_branch: None, + show_footer: false, } } @@ -1172,7 +1180,7 @@ impl PickerDelegate for BranchListDelegate { } fn render_footer(&self, _: &mut Window, cx: &mut Context>) -> Option { - if self.editor_position() == PickerEditorPosition::End { + if !self.show_footer || self.editor_position() == PickerEditorPosition::End { return None; } let focus_handle = self.focus_handle.clone(); diff --git a/crates/git_ui/src/git_picker.rs b/crates/git_ui/src/git_picker.rs index 1a1ea84aaa16ba0a015d3079e4ff647e4d05c917..a1f55ce9fad106ec2b7445ad7c8e5b8ccdf0c751 100644 --- a/crates/git_ui/src/git_picker.rs +++ b/crates/git_ui/src/git_picker.rs @@ -14,18 +14,11 @@ use workspace::{ModalView, Workspace, pane}; use crate::branch_picker::{self, BranchList, DeleteBranch, FilterRemotes}; use crate::stash_picker::{self, DropStashItem, ShowStashItem, StashList}; -use crate::worktree_picker::{ - self, DeleteWorktree, WorktreeFromDefault, WorktreeFromDefaultOnWindow, WorktreeList, -}; -actions!( - git_picker, - [ActivateBranchesTab, ActivateWorktreesTab, ActivateStashTab,] -); +actions!(git_picker, [ActivateBranchesTab, ActivateStashTab,]); #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum GitPickerTab { - Worktrees, Branches, Stash, } @@ -34,7 +27,6 @@ impl Display for GitPickerTab { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { let label = match self { GitPickerTab::Branches => "Branches", - GitPickerTab::Worktrees => "Worktrees", GitPickerTab::Stash => "Stash", }; write!(f, "{}", label) @@ -47,7 +39,6 @@ pub struct GitPicker { repository: Option>, width: Rems, branch_list: Option>, - worktree_list: Option>, stash_list: Option>, _subscriptions: Vec, popover_style: bool, @@ -80,7 +71,6 @@ impl GitPicker { repository, width, branch_list: None, - worktree_list: None, stash_list: None, _subscriptions: Vec::new(), popover_style, @@ -95,9 +85,6 @@ impl GitPicker { GitPickerTab::Branches => { self.ensure_branch_list(window, cx); } - GitPickerTab::Worktrees => { - self.ensure_worktree_list(window, cx); - } GitPickerTab::Stash => { self.ensure_stash_list(window, cx); } @@ -110,11 +97,13 @@ impl GitPicker { cx: &mut Context, ) -> Entity { if self.branch_list.is_none() { + let show_footer = !self.popover_style; let branch_list = cx.new(|cx| { branch_picker::create_embedded( self.workspace.clone(), self.repository.clone(), self.width, + show_footer, window, cx, ) @@ -132,45 +121,19 @@ impl GitPicker { self.branch_list.clone().unwrap() } - fn ensure_worktree_list( - &mut self, - window: &mut Window, - cx: &mut Context, - ) -> Entity { - if self.worktree_list.is_none() { - let worktree_list = cx.new(|cx| { - worktree_picker::create_embedded( - self.repository.clone(), - self.workspace.clone(), - self.width, - window, - cx, - ) - }); - - let subscription = cx.subscribe(&worktree_list, |this, _, _: &DismissEvent, cx| { - if this.tab == GitPickerTab::Worktrees { - cx.emit(DismissEvent); - } - }); - - self._subscriptions.push(subscription); - self.worktree_list = Some(worktree_list); - } - self.worktree_list.clone().unwrap() - } - fn ensure_stash_list( &mut self, window: &mut Window, cx: &mut Context, ) -> Entity { if self.stash_list.is_none() { + let show_footer = !self.popover_style; let stash_list = cx.new(|cx| { stash_picker::create_embedded( self.repository.clone(), self.workspace.clone(), self.width, + show_footer, window, cx, ) @@ -190,9 +153,8 @@ impl GitPicker { fn activate_next_tab(&mut self, window: &mut Window, cx: &mut Context) { self.tab = match self.tab { - GitPickerTab::Worktrees => GitPickerTab::Branches, GitPickerTab::Branches => GitPickerTab::Stash, - GitPickerTab::Stash => GitPickerTab::Worktrees, + GitPickerTab::Stash => GitPickerTab::Branches, }; self.ensure_active_picker(window, cx); self.focus_active_picker(window, cx); @@ -201,8 +163,7 @@ impl GitPicker { fn activate_previous_tab(&mut self, window: &mut Window, cx: &mut Context) { self.tab = match self.tab { - GitPickerTab::Worktrees => GitPickerTab::Stash, - GitPickerTab::Branches => GitPickerTab::Worktrees, + GitPickerTab::Branches => GitPickerTab::Stash, GitPickerTab::Stash => GitPickerTab::Branches, }; self.ensure_active_picker(window, cx); @@ -217,11 +178,6 @@ impl GitPicker { branch_list.focus_handle(cx).focus(window, cx); } } - GitPickerTab::Worktrees => { - if let Some(worktree_list) = &self.worktree_list { - worktree_list.focus_handle(cx).focus(window, cx); - } - } GitPickerTab::Stash => { if let Some(stash_list) = &self.stash_list { stash_list.focus_handle(cx).focus(window, cx); @@ -233,30 +189,12 @@ impl GitPicker { fn render_tab_bar(&self, cx: &mut Context) -> impl IntoElement { let focus_handle = self.focus_handle(cx); let branches_focus_handle = focus_handle.clone(); - let worktrees_focus_handle = focus_handle.clone(); let stash_focus_handle = focus_handle; h_flex().p_2().pb_0p5().w_full().child( ToggleButtonGroup::single_row( "git-picker-tabs", [ - ToggleButtonSimple::new( - GitPickerTab::Worktrees.to_string(), - cx.listener(|this, _, window, cx| { - this.tab = GitPickerTab::Worktrees; - this.ensure_active_picker(window, cx); - this.focus_active_picker(window, cx); - cx.notify(); - }), - ) - .tooltip(move |_, cx| { - Tooltip::for_action_in( - "Toggle Worktree Picker", - &ActivateWorktreesTab, - &worktrees_focus_handle, - cx, - ) - }), ToggleButtonSimple::new( GitPickerTab::Branches.to_string(), cx.listener(|this, _, window, cx| { @@ -297,9 +235,8 @@ impl GitPicker { .style(ToggleButtonGroupStyle::Outlined) .auto_width() .selected_index(match self.tab { - GitPickerTab::Worktrees => 0, - GitPickerTab::Branches => 1, - GitPickerTab::Stash => 2, + GitPickerTab::Branches => 0, + GitPickerTab::Stash => 1, }), ) } @@ -314,10 +251,6 @@ impl GitPicker { let branch_list = self.ensure_branch_list(window, cx); branch_list.into_any_element() } - GitPickerTab::Worktrees => { - let worktree_list = self.ensure_worktree_list(window, cx); - worktree_list.into_any_element() - } GitPickerTab::Stash => { let stash_list = self.ensure_stash_list(window, cx); stash_list.into_any_element() @@ -339,13 +272,6 @@ impl GitPicker { }); } } - GitPickerTab::Worktrees => { - if let Some(worktree_list) = &self.worktree_list { - worktree_list.update(cx, |list, cx| { - list.handle_modifiers_changed(ev, window, cx); - }); - } - } GitPickerTab::Stash => { if let Some(stash_list) = &self.stash_list { stash_list.update(cx, |list, cx| { @@ -382,45 +308,6 @@ impl GitPicker { } } - fn handle_worktree_from_default( - &mut self, - _: &WorktreeFromDefault, - window: &mut Window, - cx: &mut Context, - ) { - if let Some(worktree_list) = &self.worktree_list { - worktree_list.update(cx, |list, cx| { - list.handle_new_worktree(false, window, cx); - }); - } - } - - fn handle_worktree_from_default_on_window( - &mut self, - _: &WorktreeFromDefaultOnWindow, - window: &mut Window, - cx: &mut Context, - ) { - if let Some(worktree_list) = &self.worktree_list { - worktree_list.update(cx, |list, cx| { - list.handle_new_worktree(true, window, cx); - }); - } - } - - fn handle_worktree_delete( - &mut self, - _: &DeleteWorktree, - window: &mut Window, - cx: &mut Context, - ) { - if let Some(worktree_list) = &self.worktree_list { - worktree_list.update(cx, |list, cx| { - list.handle_delete(&DeleteWorktree, window, cx); - }); - } - } - fn handle_drop_stash( &mut self, _: &DropStashItem, @@ -459,11 +346,6 @@ impl Focusable for GitPicker { return branch_list.focus_handle(cx); } } - GitPickerTab::Worktrees => { - if let Some(worktree_list) = &self.worktree_list { - return worktree_list.focus_handle(cx); - } - } GitPickerTab::Stash => { if let Some(stash_list) = &self.stash_list { return stash_list.focus_handle(cx); @@ -492,7 +374,6 @@ impl Render for GitPicker { key_context.add("GitPicker"); match self.tab { GitPickerTab::Branches => key_context.add("GitBranchSelector"), - GitPickerTab::Worktrees => key_context.add("GitWorktreeSelector"), GitPickerTab::Stash => key_context.add("StashList"), } key_context @@ -517,12 +398,6 @@ impl Render for GitPicker { this.focus_active_picker(window, cx); cx.notify(); })) - .on_action(cx.listener(|this, _: &ActivateWorktreesTab, window, cx| { - this.tab = GitPickerTab::Worktrees; - this.ensure_active_picker(window, cx); - this.focus_active_picker(window, cx); - cx.notify(); - })) .on_action(cx.listener(|this, _: &ActivateStashTab, window, cx| { this.tab = GitPickerTab::Stash; this.ensure_active_picker(window, cx); @@ -534,11 +409,6 @@ impl Render for GitPicker { el.on_action(cx.listener(Self::handle_delete_branch)) .on_action(cx.listener(Self::handle_filter_remotes)) }) - .when(self.tab == GitPickerTab::Worktrees, |el| { - el.on_action(cx.listener(Self::handle_worktree_from_default)) - .on_action(cx.listener(Self::handle_worktree_from_default_on_window)) - .on_action(cx.listener(Self::handle_worktree_delete)) - }) .when(self.tab == GitPickerTab::Stash, |el| { el.on_action(cx.listener(Self::handle_drop_stash)) .on_action(cx.listener(Self::handle_show_stash)) @@ -557,15 +427,6 @@ pub fn open_branches( open_with_tab(workspace, GitPickerTab::Branches, window, cx); } -pub fn open_worktrees( - workspace: &mut Workspace, - _: &zed_actions::git::Worktree, - window: &mut Window, - cx: &mut Context, -) { - open_with_tab(workspace, GitPickerTab::Worktrees, window, cx); -} - pub fn open_stash( workspace: &mut Workspace, _: &zed_actions::git::ViewStash, @@ -617,9 +478,6 @@ pub fn register(workspace: &mut Workspace) { open_with_tab(workspace, GitPickerTab::Branches, window, cx); }, ); - workspace.register_action(|workspace, _: &zed_actions::git::Worktree, window, cx| { - open_with_tab(workspace, GitPickerTab::Worktrees, window, cx); - }); workspace.register_action(|workspace, _: &zed_actions::git::ViewStash, window, cx| { open_with_tab(workspace, GitPickerTab::Stash, window, cx); }); diff --git a/crates/git_ui/src/git_ui.rs b/crates/git_ui/src/git_ui.rs index 1e7391178d2473a173a1503b4f2c724191c06a60..d91f562ee7c55b41afd0d65c88a7e3be2fea1be8 100644 --- a/crates/git_ui/src/git_ui.rs +++ b/crates/git_ui/src/git_ui.rs @@ -22,7 +22,7 @@ use menu::{Cancel, Confirm}; use project::git_store::Repository; use project_diff::ProjectDiff; use ui::prelude::*; -use workspace::{ModalView, Workspace, notifications::DetachAndPromptErr}; +use workspace::{ModalView, OpenMode, Workspace, notifications::DetachAndPromptErr}; use zed_actions; use crate::{git_panel::GitPanel, text_diff_view::TextDiffView}; @@ -45,7 +45,9 @@ pub(crate) mod remote_output; pub mod repository_selector; pub mod stash_picker; pub mod text_diff_view; +pub mod worktree_names; pub mod worktree_picker; +pub mod worktree_service; pub use conflict_view::MergeConflictIndicator; @@ -65,6 +67,64 @@ pub fn init(cx: &mut App) { repository_selector::register(workspace); git_picker::register(workspace); + workspace.register_action( + |workspace, action: &zed_actions::CreateWorktree, window, cx| { + worktree_service::handle_create_worktree(workspace, action, window, None, cx); + }, + ); + workspace.register_action( + |workspace, action: &zed_actions::SwitchWorktree, window, cx| { + worktree_service::handle_switch_worktree(workspace, action, window, None, cx); + }, + ); + + workspace.register_action(|workspace, _: &zed_actions::git::Worktree, window, cx| { + let focused_dock = workspace.focused_dock_position(window, cx); + let project = workspace.project().clone(); + let workspace_handle = workspace.weak_handle(); + workspace.toggle_modal(window, cx, |window, cx| { + worktree_picker::WorktreePicker::new_modal( + project, + workspace_handle, + focused_dock, + window, + cx, + ) + }); + }); + + workspace.register_action( + |workspace, action: &zed_actions::OpenWorktreeInNewWindow, window, cx| { + let path = action.path.clone(); + let is_remote = !workspace.project().read(cx).is_local(); + + if is_remote { + let connection_options = + workspace.project().read(cx).remote_connection_options(cx); + let app_state = workspace.app_state().clone(); + let workspace_handle = workspace.weak_handle(); + cx.spawn_in(window, async move |_, cx| { + if let Some(connection_options) = connection_options { + crate::worktree_picker::open_remote_worktree( + connection_options, + vec![path], + app_state, + workspace_handle, + cx, + ) + .await?; + } + anyhow::Ok(()) + }) + .detach_and_log_err(cx); + } else { + workspace + .open_workspace_for_paths(OpenMode::NewWindow, vec![path], window, cx) + .detach_and_log_err(cx); + } + }, + ); + let project = workspace.project().read(cx); if project.is_read_only(cx) { return; diff --git a/crates/git_ui/src/stash_picker.rs b/crates/git_ui/src/stash_picker.rs index 963fa9d22bc78a6c559642bc3e7bb2b553436824..6e6833f3cb4833512cab3111449c0e3c39c8a96e 100644 --- a/crates/git_ui/src/stash_picker.rs +++ b/crates/git_ui/src/stash_picker.rs @@ -46,10 +46,11 @@ pub fn create_embedded( repository: Option>, workspace: WeakEntity, width: Rems, + show_footer: bool, window: &mut Window, cx: &mut Context, ) -> StashList { - StashList::new_embedded(repository, workspace, width, window, cx) + StashList::new_embedded(repository, workspace, width, show_footer, window, cx) } pub struct StashList { @@ -133,6 +134,7 @@ impl StashList { let picker_focus_handle = picker.focus_handle(cx); picker.update(cx, |picker, _| { picker.delegate.focus_handle = picker_focus_handle.clone(); + picker.delegate.show_footer = !embedded; }); Self { @@ -147,10 +149,14 @@ impl StashList { repository: Option>, workspace: WeakEntity, width: Rems, + show_footer: bool, window: &mut Window, cx: &mut Context, ) -> Self { let mut this = Self::new_inner(repository, workspace, width, true, window, cx); + this.picker.update(cx, |picker, _| { + picker.delegate.show_footer = show_footer; + }); this._subscriptions .push(cx.subscribe(&this.picker, |_, _, _, cx| { cx.emit(DismissEvent); @@ -236,6 +242,7 @@ pub struct StashListDelegate { modifiers: Modifiers, focus_handle: FocusHandle, timezone: UtcOffset, + show_footer: bool, } impl StashListDelegate { @@ -257,6 +264,7 @@ impl StashListDelegate { modifiers: Default::default(), focus_handle: cx.focus_handle(), timezone, + show_footer: false, } } @@ -614,7 +622,7 @@ impl PickerDelegate for StashListDelegate { } fn render_footer(&self, _: &mut Window, cx: &mut Context>) -> Option { - if self.matches.is_empty() { + if !self.show_footer || self.matches.is_empty() { return None; } diff --git a/crates/agent_ui/src/worktree_names.rs b/crates/git_ui/src/worktree_names.rs similarity index 100% rename from crates/agent_ui/src/worktree_names.rs rename to crates/git_ui/src/worktree_names.rs diff --git a/crates/git_ui/src/worktree_picker.rs b/crates/git_ui/src/worktree_picker.rs index f9069d2920eedcd3ec75c1af781d90c818d49ba3..49a42438f45d7ed3727223c734305477a15cd76b 100644 --- a/crates/git_ui/src/worktree_picker.rs +++ b/crates/git_ui/src/worktree_picker.rs @@ -1,95 +1,88 @@ +use std::path::PathBuf; +use std::sync::Arc; + use anyhow::Context as _; use collections::HashSet; use fuzzy::StringMatchCandidate; - use git::repository::Worktree as GitWorktree; use gpui::{ - Action, App, AsyncWindowContext, Context, DismissEvent, Entity, EventEmitter, FocusHandle, - Focusable, InteractiveElement, IntoElement, Modifiers, ModifiersChangedEvent, ParentElement, - Render, SharedString, Styled, Subscription, Task, WeakEntity, Window, actions, rems, + Action, AnyElement, App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, + IntoElement, ParentElement, Render, SharedString, Styled, Subscription, Task, WeakEntity, + Window, actions, rems, }; use picker::{Picker, PickerDelegate, PickerEditorPosition}; -use project::project_settings::ProjectSettings; -use project::{ - git_store::{Repository, RepositoryEvent}, - trusted_worktrees::{PathTrust, TrustedWorktrees}, +use project::Project; +use project::git_store::RepositoryEvent; +use ui::{ + Button, Divider, HighlightedLabel, IconButton, KeyBinding, ListItem, ListItemSpacing, Tooltip, + prelude::*, }; -use remote::{RemoteConnectionOptions, remote_client::ConnectionIdentifier}; -use remote_connection::{RemoteConnectionModal, connect}; -use settings::Settings; -use std::{path::PathBuf, sync::Arc}; -use ui::{HighlightedLabel, KeyBinding, ListItem, ListItemSpacing, Tooltip, prelude::*}; -use util::{ResultExt, debug_panic, paths::PathExt}; +use util::ResultExt as _; +use util::paths::PathExt; use workspace::{ - ModalView, MultiWorkspace, OpenMode, Workspace, notifications::DetachAndPromptErr, + ModalView, MultiWorkspace, Workspace, dock::DockPosition, notifications::DetachAndPromptErr, }; use crate::git_panel::show_error_toast; +use zed_actions::{ + CreateWorktree, NewWorktreeBranchTarget, OpenWorktreeInNewWindow, SwitchWorktree, +}; -actions!( - git, - [ - WorktreeFromDefault, - WorktreeFromDefaultOnWindow, - DeleteWorktree - ] -); - -pub fn open( - workspace: &mut Workspace, - _: &zed_actions::git::Worktree, - window: &mut Window, - cx: &mut Context, -) { - let repository = workspace.project().read(cx).active_repository(cx); - let workspace_handle = workspace.weak_handle(); - workspace.toggle_modal(window, cx, |window, cx| { - WorktreeList::new(repository, workspace_handle, rems(34.), window, cx) - }) -} - -pub fn create_embedded( - repository: Option>, - workspace: WeakEntity, - width: Rems, - window: &mut Window, - cx: &mut Context, -) -> WorktreeList { - WorktreeList::new_embedded(repository, workspace, width, window, cx) -} +actions!(worktree_picker, [DeleteWorktree]); -pub struct WorktreeList { - width: Rems, - pub picker: Entity>, - picker_focus_handle: FocusHandle, +pub struct WorktreePicker { + picker: Entity>, + focus_handle: FocusHandle, _subscriptions: Vec, - embedded: bool, } -impl WorktreeList { - fn new( - repository: Option>, +impl WorktreePicker { + pub fn new( + project: Entity, workspace: WeakEntity, - width: Rems, window: &mut Window, cx: &mut Context, ) -> Self { - let mut this = Self::new_inner(repository, workspace, width, false, window, cx); - this._subscriptions - .push(cx.subscribe(&this.picker, |_, _, _, cx| { - cx.emit(DismissEvent); - })); - this + let focused_dock = workspace + .upgrade() + .and_then(|workspace| workspace.read(cx).focused_dock_position(window, cx)); + Self::new_inner(project, workspace, focused_dock, false, window, cx) + } + + pub fn new_modal( + project: Entity, + workspace: WeakEntity, + focused_dock: Option, + window: &mut Window, + cx: &mut Context, + ) -> Self { + Self::new_inner(project, workspace, focused_dock, true, window, cx) } fn new_inner( - repository: Option>, + project: Entity, workspace: WeakEntity, - width: Rems, - embedded: bool, + focused_dock: Option, + show_footer: bool, window: &mut Window, cx: &mut Context, ) -> Self { + let project_ref = project.read(cx); + let project_worktree_paths: HashSet = project_ref + .visible_worktrees(cx) + .map(|wt| wt.read(cx).abs_path().to_path_buf()) + .collect(); + + let has_multiple_repositories = project_ref.repositories(cx).len() > 1; + let repository = project_ref.active_repository(cx); + + let current_branch_name = repository.as_ref().and_then(|repo| { + repo.read(cx) + .branch + .as_ref() + .map(|branch| branch.name().to_string()) + }); + let all_worktrees_request = repository .clone() .map(|repository| repository.update(cx, |repository, _| repository.worktrees())); @@ -98,65 +91,94 @@ impl WorktreeList { repository.update(cx, |repository, _| repository.default_branch(false)) }); - cx.spawn_in(window, async move |this, cx| { - let all_worktrees: Vec<_> = all_worktrees_request - .context("No active repository")? - .await?? - .into_iter() - .filter(|worktree| !worktree.is_bare) // hide bare repositories - .collect(); - - let default_branch = default_branch_request - .context("No active repository")? - .await - .map(Result::ok) - .ok() - .flatten() - .flatten(); - - this.update_in(cx, |this, window, cx| { - this.picker.update(cx, |picker, cx| { - picker.delegate.all_worktrees = Some(all_worktrees); - picker.delegate.default_branch = default_branch; - picker.delegate.refresh_forbidden_deletion_path(cx); - picker.refresh(window, cx); - }) - })?; + let initial_matches = vec![WorktreeEntry::CreateFromCurrentBranch]; - anyhow::Ok(()) - }) - .detach_and_log_err(cx); + let delegate = WorktreePickerDelegate { + matches: initial_matches, + all_worktrees: Vec::new(), + project_worktree_paths, + selected_index: 0, + project, + workspace, + focused_dock, + current_branch_name, + default_branch_name: None, + has_multiple_repositories, + focus_handle: cx.focus_handle(), + show_footer, + }; - let delegate = WorktreeListDelegate::new(workspace, repository.clone(), window, cx); let picker = cx.new(|cx| { - Picker::uniform_list(delegate, window, cx) + Picker::list(delegate, window, cx) + .list_measure_all() .show_scrollbar(true) - .modal(!embedded) + .modal(false) + .max_height(Some(rems(20.).into())) }); + let picker_focus_handle = picker.focus_handle(cx); picker.update(cx, |picker, _| { - picker.delegate.focus_handle = picker_focus_handle.clone(); + picker.delegate.focus_handle = picker_focus_handle; }); let mut subscriptions = Vec::new(); + + { + 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!("WorktreePicker: git worktree list failed: {err}"); + return anyhow::Ok(()); + } + Err(_) => { + log::warn!("WorktreePicker: 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); + } + if let Some(repo) = &repository { - let picker_entity = picker.clone(); - subscriptions.push(cx.subscribe( + let picker_entity = picker.downgrade(); + subscriptions.push(cx.subscribe_in( repo, - move |_this, repo, event: &RepositoryEvent, cx| { + 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(async move |_, cx| { + cx.spawn_in(window, async move |_, cx| { let all_worktrees: Vec<_> = worktrees_request .await?? .into_iter() - .filter(|worktree| !worktree.is_bare) + .filter(|wt| !wt.is_bare) .collect(); - picker.update(cx, |picker, cx| { - picker.delegate.all_worktrees = Some(all_worktrees); - picker.delegate.refresh_forbidden_deletion_path(cx); - }); + picker.update_in(cx, |picker, window, cx| { + picker.delegate.all_worktrees = all_worktrees; + picker.refresh(window, cx); + })?; anyhow::Ok(()) }) .detach_and_log_err(cx); @@ -165,386 +187,166 @@ impl WorktreeList { )); } + subscriptions.push(cx.subscribe(&picker, |_, _, _, cx| { + cx.emit(DismissEvent); + })); + Self { + focus_handle: picker.focus_handle(cx), picker, - picker_focus_handle, - width, _subscriptions: subscriptions, - embedded, } } - - fn new_embedded( - repository: Option>, - workspace: WeakEntity, - width: Rems, - window: &mut Window, - cx: &mut Context, - ) -> Self { - let mut this = Self::new_inner(repository, workspace, width, true, window, cx); - this._subscriptions - .push(cx.subscribe(&this.picker, |_, _, _, cx| { - cx.emit(DismissEvent); - })); - this - } - - pub fn handle_modifiers_changed( - &mut self, - ev: &ModifiersChangedEvent, - _: &mut Window, - cx: &mut Context, - ) { - self.picker - .update(cx, |picker, _| picker.delegate.modifiers = ev.modifiers) - } - - pub fn handle_new_worktree( - &mut self, - replace_current_window: bool, - window: &mut Window, - cx: &mut Context, - ) { - self.picker.update(cx, |picker, cx| { - let ix = picker.delegate.selected_index(); - let Some(entry) = picker.delegate.matches.get(ix) else { - return; - }; - let Some(default_branch) = picker.delegate.default_branch.clone() else { - return; - }; - if !entry.is_new { - return; - } - picker.delegate.create_worktree( - entry.worktree.display_name(), - replace_current_window, - Some(default_branch.into()), - window, - cx, - ); - }) - } - - pub fn handle_delete( - &mut self, - _: &DeleteWorktree, - window: &mut Window, - cx: &mut Context, - ) { - self.picker.update(cx, |picker, cx| { - picker - .delegate - .delete_at(picker.delegate.selected_index, window, cx) - }) - } } -impl ModalView for WorktreeList {} -impl EventEmitter for WorktreeList {} -impl Focusable for WorktreeList { - fn focus_handle(&self, _: &App) -> FocusHandle { - self.picker_focus_handle.clone() +impl Focusable for WorktreePicker { + fn focus_handle(&self, _cx: &App) -> FocusHandle { + self.focus_handle.clone() } } -impl Render for WorktreeList { - fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { +impl ModalView for WorktreePicker {} +impl EventEmitter for WorktreePicker {} + +impl Render for WorktreePicker { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { v_flex() - .key_context("GitWorktreeSelector") - .w(self.width) - .on_modifiers_changed(cx.listener(Self::handle_modifiers_changed)) - .on_action(cx.listener(|this, _: &WorktreeFromDefault, w, cx| { - this.handle_new_worktree(false, w, cx) - })) - .on_action(cx.listener(|this, _: &WorktreeFromDefaultOnWindow, w, cx| { - this.handle_new_worktree(true, w, cx) + .key_context("WorktreePicker") + .w(rems(34.)) + .elevation_3(cx) + .child(self.picker.clone()) + .on_mouse_down_out(cx.listener(|_, _, _, cx| { + cx.emit(DismissEvent); })) .on_action(cx.listener(|this, _: &DeleteWorktree, window, cx| { - this.handle_delete(&DeleteWorktree, window, cx) + this.picker.update(cx, |picker, cx| { + let ix = picker.delegate.selected_index; + picker.delegate.delete_worktree(ix, window, cx); + }); })) - .child(self.picker.clone()) - .when(!self.embedded, |el| { - el.on_mouse_down_out({ - cx.listener(move |this, _, window, cx| { - this.picker.update(cx, |this, cx| { - this.cancel(&Default::default(), window, cx); - }) - }) - }) - }) } } -#[derive(Debug, Clone)] -struct WorktreeEntry { - worktree: GitWorktree, - positions: Vec, - is_new: bool, +#[derive(Clone)] +enum WorktreeEntry { + CreateFromCurrentBranch, + CreateFromDefaultBranch { + default_branch_name: String, + }, + Separator, + Worktree { + worktree: GitWorktree, + positions: Vec, + }, + CreateNamed { + name: String, + from_branch: Option, + disabled_reason: Option, + }, } -impl WorktreeEntry { - fn can_delete(&self, forbidden_deletion_path: Option<&PathBuf>) -> bool { - !self.is_new - && !self.worktree.is_main - && forbidden_deletion_path != Some(&self.worktree.path) - } -} - -pub struct WorktreeListDelegate { +struct WorktreePickerDelegate { matches: Vec, - all_worktrees: Option>, - workspace: WeakEntity, - repo: Option>, + all_worktrees: Vec, + project_worktree_paths: HashSet, selected_index: usize, - last_query: String, - modifiers: Modifiers, + project: Entity, + workspace: WeakEntity, + focused_dock: Option, + current_branch_name: Option, + default_branch_name: Option, + has_multiple_repositories: bool, focus_handle: FocusHandle, - default_branch: Option, - forbidden_deletion_path: Option, - current_worktree_path: Option, + show_footer: bool, } -impl WorktreeListDelegate { - fn new( - workspace: WeakEntity, - repo: Option>, - _window: &mut Window, - cx: &mut Context, - ) -> Self { - let current_worktree_path = repo - .as_ref() - .map(|r| r.read(cx).work_directory_abs_path.to_path_buf()); +impl WorktreePickerDelegate { + fn build_fixed_entries(&self) -> Vec { + let mut entries = Vec::new(); - Self { - matches: vec![], - all_worktrees: None, - workspace, - selected_index: 0, - repo, - last_query: Default::default(), - modifiers: Default::default(), - focus_handle: cx.focus_handle(), - default_branch: None, - forbidden_deletion_path: None, - current_worktree_path, - } - } + entries.push(WorktreeEntry::CreateFromCurrentBranch); - fn create_worktree( - &self, - worktree_branch: &str, - replace_current_window: bool, - commit: Option, - window: &mut Window, - cx: &mut Context>, - ) { - let Some(repo) = self.repo.clone() else { - return; - }; - - let branch = worktree_branch.to_string(); - let workspace = self.workspace.clone(); - cx.spawn_in(window, async move |_, cx| { - let (receiver, new_worktree_path) = repo.update(cx, |repo, cx| { - let worktree_directory_setting = ProjectSettings::get_global(cx) - .git - .worktree_directory - .clone(); - let new_worktree_path = - repo.path_for_new_linked_worktree(&branch, &worktree_directory_setting)?; - let receiver = repo.create_worktree( - git::repository::CreateWorktreeTarget::NewBranch { - branch_name: branch.clone(), - base_sha: commit, - }, - new_worktree_path.clone(), - ); - anyhow::Ok((receiver, new_worktree_path)) - })?; - receiver.await??; - - workspace.update(cx, |workspace, cx| { - if let Some(trusted_worktrees) = TrustedWorktrees::try_get_global(cx) { - let repo_path = &repo.read(cx).snapshot().work_directory_abs_path; - let project = workspace.project(); - if let Some((parent_worktree, _)) = - project.read(cx).find_worktree(repo_path, cx) - { - let worktree_store = project.read(cx).worktree_store(); - trusted_worktrees.update(cx, |trusted_worktrees, cx| { - if trusted_worktrees.can_trust( - &worktree_store, - parent_worktree.read(cx).id(), - cx, - ) { - trusted_worktrees.trust( - &worktree_store, - HashSet::from_iter([PathTrust::AbsPath( - new_worktree_path.clone(), - )]), - cx, - ); - } - }); - } + 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(WorktreeEntry::CreateFromDefaultBranch { + default_branch_name: default_branch.clone(), + }); } - })?; - - let (connection_options, app_state, is_local) = - workspace.update(cx, |workspace, cx| { - let project = workspace.project().clone(); - let connection_options = project.read(cx).remote_connection_options(cx); - let app_state = workspace.app_state().clone(); - let is_local = project.read(cx).is_local(); - (connection_options, app_state, is_local) - })?; - - if is_local { - workspace - .update_in(cx, |workspace, window, cx| { - workspace.open_workspace_for_paths( - OpenMode::Activate, - vec![new_worktree_path], - window, - cx, - ) - })? - .await?; - } else if let Some(connection_options) = connection_options { - open_remote_worktree( - connection_options, - vec![new_worktree_path], - app_state, - workspace.clone(), - replace_current_window, - cx, - ) - .await?; } + } - anyhow::Ok(()) - }) - .detach_and_prompt_err("Failed to create worktree", window, cx, |e, _, _| { - let msg = e.to_string(); - if msg.contains("git.worktree_directory") { - Some(format!("Invalid git.worktree_directory setting: {}", e)) - } else { - Some(msg) - } - }); + entries } - fn open_worktree( - &self, - worktree_path: &PathBuf, - replace_current_window: bool, - window: &mut Window, - cx: &mut Context>, - ) { - let workspace = self.workspace.clone(); - let path = worktree_path.clone(); - - let Some((connection_options, app_state, is_local)) = workspace - .update(cx, |workspace, cx| { - let project = workspace.project().clone(); - let connection_options = project.read(cx).remote_connection_options(cx); - let app_state = workspace.app_state().clone(); - let is_local = project.read(cx).is_local(); - (connection_options, app_state, is_local) - }) - .log_err() - else { - return; - }; - let open_mode = if replace_current_window { - OpenMode::Activate - } else { - OpenMode::NewWindow - }; + fn all_repo_worktrees(&self) -> &[GitWorktree] { + &self.all_worktrees + } - if is_local { - let open_task = workspace.update(cx, |workspace, cx| { - workspace.open_workspace_for_paths(open_mode, vec![path], window, cx) - }); - cx.spawn(async move |_, _| { - open_task?.await?; - anyhow::Ok(()) - }) - .detach_and_prompt_err( - "Failed to open worktree", - window, - cx, - |e, _, _| Some(e.to_string()), - ); - } else if let Some(connection_options) = connection_options { - cx.spawn_in(window, async move |_, cx| { - open_remote_worktree( - connection_options, - vec![path], - app_state, - workspace, - replace_current_window, - cx, - ) - .await - }) - .detach_and_prompt_err( - "Failed to open worktree", - window, - cx, - |e, _, _| Some(e.to_string()), - ); + fn creation_blocked_reason(&self, cx: &App) -> Option { + let project = self.project.read(cx); + if project.is_via_collab() { + Some("Worktree creation is not supported in collaborative projects".into()) + } else if project.repositories(cx).is_empty() { + Some("Requires a Git repository in the project".into()) + } else { + None } - - cx.emit(DismissEvent); } - fn base_branch<'a>(&'a self, cx: &'a mut Context>) -> Option<&'a str> { - self.repo - .as_ref() - .and_then(|repo| repo.read(cx).branch.as_ref().map(|b| b.name())) + fn can_delete_worktree(&self, worktree: &GitWorktree) -> bool { + !worktree.is_main && !self.project_worktree_paths.contains(&worktree.path) } - fn delete_at(&self, idx: usize, window: &mut Window, cx: &mut Context>) { - let Some(entry) = self.matches.get(idx).cloned() else { + fn delete_worktree(&self, ix: usize, window: &mut Window, cx: &mut Context>) { + let Some(entry) = self.matches.get(ix) else { + return; + }; + let WorktreeEntry::Worktree { worktree, .. } = entry else { return; }; - if !entry.can_delete(self.forbidden_deletion_path.as_ref()) { + if !self.can_delete_worktree(worktree) { return; } - let Some(repo) = self.repo.clone() else { + + let repo = self.project.read(cx).active_repository(cx); + let Some(repo) = repo else { return; }; + let path = worktree.path.clone(); let workspace = self.workspace.clone(); - let path = entry.worktree.path; cx.spawn_in(window, async move |picker, cx| { let result = repo .update(cx, |repo, _| repo.remove_worktree(path.clone(), false)) .await?; - if let Err(e) = result { - log::error!("Failed to remove worktree: {}", e); + if let Err(error) = result { + log::error!("Failed to remove worktree: {}", error); + if let Some(workspace) = workspace.upgrade() { cx.update(|_window, cx| { show_error_toast( workspace, format!("worktree remove {}", path.display()), - e, + error, cx, ) })?; } + return Ok(()); } - picker.update_in(cx, |picker, _, cx| { - picker.delegate.matches.retain(|e| e.worktree.path != path); - if let Some(all_worktrees) = &mut picker.delegate.all_worktrees { - all_worktrees.retain(|w| w.path != path); - } - picker.delegate.refresh_forbidden_deletion_path(cx); + picker.update_in(cx, |picker, _window, cx| { + picker.delegate.matches.retain(|e| { + !matches!(e, WorktreeEntry::Worktree { worktree, .. } if worktree.path == path) + }); + picker.delegate.all_worktrees.retain(|w| w.path != path); if picker.delegate.matches.is_empty() { picker.delegate.selected_index = 0; } else if picker.delegate.selected_index >= picker.delegate.matches.len() { @@ -555,139 +357,37 @@ impl WorktreeListDelegate { anyhow::Ok(()) }) - .detach(); + .detach_and_log_err(cx); } - fn refresh_forbidden_deletion_path(&mut self, cx: &App) { - let Some(workspace) = self.workspace.upgrade() else { - debug_panic!("Workspace should always be available or else the picker would be closed"); - self.forbidden_deletion_path = None; + fn sync_selected_index(&mut self, has_query: bool) { + if !has_query { return; - }; - - let visible_worktree_paths = workspace.read_with(cx, |workspace, cx| { - workspace - .project() - .read(cx) - .visible_worktrees(cx) - .map(|worktree| worktree.read(cx).abs_path().to_path_buf()) - .collect::>() - }); + } - self.forbidden_deletion_path = if visible_worktree_paths.len() == 1 { - visible_worktree_paths.into_iter().next() + if let Some(index) = self + .matches + .iter() + .position(|entry| matches!(entry, WorktreeEntry::Worktree { .. })) + { + self.selected_index = index; + } else if let Some(index) = self + .matches + .iter() + .position(|entry| matches!(entry, WorktreeEntry::CreateNamed { .. })) + { + self.selected_index = index; } else { - None - }; + self.selected_index = 0; + } } } -async fn open_remote_worktree( - connection_options: RemoteConnectionOptions, - paths: Vec, - app_state: Arc, - workspace: WeakEntity, - replace_current_window: bool, - cx: &mut AsyncWindowContext, -) -> anyhow::Result<()> { - let workspace_window = cx - .window_handle() - .downcast::() - .ok_or_else(|| anyhow::anyhow!("Window is not a Workspace window"))?; - - let connect_task = workspace.update_in(cx, |workspace, window, cx| { - workspace.toggle_modal(window, cx, |window, cx| { - RemoteConnectionModal::new(&connection_options, Vec::new(), window, cx) - }); - - let prompt = workspace - .active_modal::(cx) - .expect("Modal just created") - .read(cx) - .prompt - .clone(); - - connect( - ConnectionIdentifier::setup(), - connection_options.clone(), - prompt, - window, - cx, - ) - .prompt_err("Failed to connect", window, cx, |_, _, _| None) - })?; - - let session = connect_task.await; - - workspace - .update_in(cx, |workspace, _window, cx| { - if let Some(prompt) = workspace.active_modal::(cx) { - prompt.update(cx, |prompt, cx| prompt.finished(cx)) - } - }) - .ok(); - - let Some(Some(session)) = session else { - return Ok(()); - }; - - let new_project: Entity = cx.update(|_, cx| { - project::Project::remote( - session, - app_state.client.clone(), - app_state.node_runtime.clone(), - app_state.user_store.clone(), - app_state.languages.clone(), - app_state.fs.clone(), - true, - cx, - ) - })?; - - let window_to_use = if replace_current_window { - workspace_window - } else { - let workspace_position = cx - .update(|_, cx| { - workspace::remote_workspace_position_from_db(connection_options.clone(), &paths, cx) - })? - .await - .context("fetching workspace position from db")?; - - let mut options = - cx.update(|_, cx| (app_state.build_window_options)(workspace_position.display, cx))?; - options.window_bounds = workspace_position.window_bounds; - - cx.open_window(options, |window, cx| { - let workspace = cx.new(|cx| { - let mut workspace = - Workspace::new(None, new_project.clone(), app_state.clone(), window, cx); - workspace.centered_layout = workspace_position.centered_layout; - workspace - }); - cx.new(|cx| MultiWorkspace::new(workspace, window, cx)) - })? - }; - - workspace::open_remote_project_with_existing_connection( - connection_options, - new_project, - paths, - app_state, - window_to_use, - None, - cx, - ) - .await?; - - Ok(()) -} - -impl PickerDelegate for WorktreeListDelegate { - type ListItem = ListItem; +impl PickerDelegate for WorktreePickerDelegate { + type ListItem = AnyElement; fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc { - "Select worktree…".into() + "Select a worktree…".into() } fn editor_position(&self) -> PickerEditorPosition { @@ -706,115 +406,276 @@ impl PickerDelegate for WorktreeListDelegate { &mut self, ix: usize, _window: &mut Window, - _: &mut Context>, + _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(WorktreeEntry::Separator)) + } + fn update_matches( &mut self, query: String, window: &mut Window, cx: &mut Context>, ) -> Task<()> { - let Some(all_worktrees) = self.all_worktrees.clone() else { - return Task::ready(()); + let repo_worktrees = self.all_repo_worktrees().to_vec(); + + let normalized_query = query.replace(' ', "-"); + 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: 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()) + } else { + None }; - 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() - .map(|worktree| WorktreeEntry { - worktree, - positions: Vec::new(), - is_new: false, - }) - .collect() - } else { - let candidates = all_worktrees + 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() { + let mut matches = self.build_fixed_entries(); + + if !repo_worktrees.is_empty() { + let main_worktree_path = repo_worktrees .iter() - .enumerate() - .map(|(ix, worktree)| { - StringMatchCandidate::new( - ix, - &worktree.directory_name(main_worktree_path.as_deref()), - ) + .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())) }) - .collect::>(); - fuzzy::match_strings( - &candidates, - &query, - true, - true, - 10000, - &Default::default(), - cx.background_executor().clone(), + }); + + matches.push(WorktreeEntry::Separator); + for worktree in sorted { + matches.push(WorktreeEntry::Worktree { + worktree, + positions: Vec::new(), + }); + } + } + + self.matches = matches; + self.sync_selected_index(false); + return Task::ready(()); + } + + 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()), ) - .await - .into_iter() - .map(|candidate| WorktreeEntry { - worktree: all_worktrees[candidate.candidate_id].clone(), - positions: candidate.positions, - is_new: false, - }) - .collect() - }; + }) + .collect(); + + let executor = cx.background_executor().clone(); + + let task = cx.background_executor().spawn(async move { + fuzzy::match_strings( + &candidates, + &query, + true, + true, + 10000, + &Default::default(), + executor, + ) + .await + }); + + let repo_worktrees_clone = repo_worktrees; + cx.spawn_in(window, async move |picker, cx| { + let fuzzy_matches = task.await; + picker - .update(cx, |picker, _| { - if !query.is_empty() - && !matches.first().is_some_and(|entry| { - entry.worktree.directory_name(main_worktree_path.as_deref()) == query - }) - { - let query = query.replace(' ', "-"); - matches.push(WorktreeEntry { - worktree: GitWorktree { - path: Default::default(), - ref_name: Some(format!("refs/heads/{query}").into()), - sha: Default::default(), - is_main: false, - is_bare: false, - }, - positions: Vec::new(), - is_new: true, - }) + .update_in(cx, |picker, _window, cx| { + let mut new_matches: Vec = Vec::new(); + + for candidate in &fuzzy_matches { + new_matches.push(WorktreeEntry::Worktree { + worktree: repo_worktrees_clone[candidate.candidate_id].clone(), + positions: candidate.positions.clone(), + }); } - let delegate = &mut picker.delegate; - delegate.matches = matches; - if delegate.matches.is_empty() { - delegate.selected_index = 0; - } else { - delegate.selected_index = - core::cmp::min(delegate.selected_index, delegate.matches.len() - 1); + + if !new_matches.is_empty() { + new_matches.push(WorktreeEntry::Separator); } - delegate.last_query = query; + new_matches.push(WorktreeEntry::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(WorktreeEntry::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(); }) } fn confirm(&mut self, secondary: bool, window: &mut Window, cx: &mut Context>) { - let Some(entry) = self.matches.get(self.selected_index()) else { + let Some(entry) = self.matches.get(self.selected_index) else { return; }; - if entry.is_new { - self.create_worktree(&entry.worktree.display_name(), secondary, None, window, cx); - } else { - self.open_worktree(&entry.worktree.path, !secondary, window, cx); + + match entry { + WorktreeEntry::Separator => return, + WorktreeEntry::CreateFromCurrentBranch => { + if self.creation_blocked_reason(cx).is_some() { + return; + } + if let Some(workspace) = self.workspace.upgrade() { + workspace.update(cx, |workspace, cx| { + crate::worktree_service::handle_create_worktree( + workspace, + &CreateWorktree { + worktree_name: None, + branch_target: NewWorktreeBranchTarget::CurrentBranch, + }, + window, + self.focused_dock, + cx, + ); + }); + } + } + WorktreeEntry::CreateFromDefaultBranch { + default_branch_name, + } => { + if self.creation_blocked_reason(cx).is_some() { + return; + } + if let Some(workspace) = self.workspace.upgrade() { + workspace.update(cx, |workspace, cx| { + crate::worktree_service::handle_create_worktree( + workspace, + &CreateWorktree { + worktree_name: None, + branch_target: NewWorktreeBranchTarget::ExistingBranch { + name: default_branch_name.clone(), + }, + }, + window, + self.focused_dock, + cx, + ); + }); + } + } + WorktreeEntry::Worktree { worktree, .. } => { + let is_current = self.project_worktree_paths.contains(&worktree.path); + + if !is_current { + if secondary { + window.dispatch_action( + Box::new(OpenWorktreeInNewWindow { + path: worktree.path.clone(), + }), + cx, + ); + } else { + let main_worktree_path = self + .all_worktrees + .iter() + .find(|wt| wt.is_main) + .map(|wt| wt.path.as_path()); + if let Some(workspace) = self.workspace.upgrade() { + workspace.update(cx, |workspace, cx| { + crate::worktree_service::handle_switch_worktree( + workspace, + &SwitchWorktree { + path: worktree.path.clone(), + display_name: worktree.directory_name(main_worktree_path), + }, + window, + self.focused_dock, + cx, + ); + }); + } + } + } + } + WorktreeEntry::CreateNamed { + name, + from_branch, + disabled_reason: None, + } => { + let branch_target = match from_branch { + Some(branch) => NewWorktreeBranchTarget::ExistingBranch { + name: branch.clone(), + }, + None => NewWorktreeBranchTarget::CurrentBranch, + }; + if let Some(workspace) = self.workspace.upgrade() { + workspace.update(cx, |workspace, cx| { + crate::worktree_service::handle_create_worktree( + workspace, + &CreateWorktree { + worktree_name: Some(name.clone()), + branch_target, + }, + window, + self.focused_dock, + cx, + ); + }); + } + } + WorktreeEntry::CreateNamed { + disabled_reason: Some(_), + .. + } => { + return; + } } cx.emit(DismissEvent); } - fn dismissed(&mut self, _: &mut Window, cx: &mut Context>) { - cx.emit(DismissEvent); - } + fn dismissed(&mut self, _window: &mut Window, _cx: &mut Context>) {} fn render_match( &self, @@ -823,199 +684,249 @@ impl PickerDelegate for WorktreeListDelegate { _window: &mut Window, cx: &mut Context>, ) -> Option { - let entry = &self.matches.get(ix)?; - let path = entry.worktree.path.compact().to_string_lossy().to_string(); - let sha = entry - .worktree - .sha - .clone() - .chars() - .take(7) - .collect::(); - - let (branch_name, sublabel) = if entry.is_new { - ( - Label::new(format!( - "Create Worktree: \"{}\"…", - entry.worktree.display_name() - )) - .truncate() - .into_any_element(), - format!( - "based off {}", - self.base_branch(cx).unwrap_or("the current branch") - ), - ) - } else { - 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() - .copied() - .filter(|&pos| pos < first_line.len()) - .collect(); - - ( - HighlightedLabel::new(first_line.to_owned(), positions) - .truncate() - .into_any_element(), - path, - ) - }; - - let focus_handle = self.focus_handle.clone(); - - let can_delete = entry.can_delete(self.forbidden_deletion_path.as_ref()); - - let delete_button = |entry_ix: usize| { - IconButton::new(("delete-worktree", entry_ix), IconName::Trash) - .icon_size(IconSize::Small) - .tooltip(move |_, cx| { - Tooltip::for_action_in("Delete Worktree", &DeleteWorktree, &focus_handle, cx) - }) - .on_click(cx.listener(move |this, _, window, cx| { - this.delegate.delete_at(entry_ix, window, cx); - })) - }; + let entry = self.matches.get(ix)?; - let is_current = !entry.is_new - && self - .current_worktree_path - .as_ref() - .is_some_and(|current| *current == entry.worktree.path); + match entry { + WorktreeEntry::Separator => Some( + div() + .py(DynamicSpacing::Base04.rems(cx)) + .child(Divider::horizontal()) + .into_any_element(), + ), + WorktreeEntry::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 item = create_new_list_item( + "create-from-current".to_string().into(), + label.into(), + self.creation_blocked_reason(cx), + selected, + ); - let entry_icon = if entry.is_new { - IconName::Plus - } else if is_current { - IconName::Check - } else { - IconName::GitWorktree - }; + Some(item.into_any_element()) + } + WorktreeEntry::CreateFromDefaultBranch { + default_branch_name, + } => { + let label = format!("Create new worktree based on {default_branch_name}"); + + let item = create_new_list_item( + "create-from-main".to_string().into(), + label.into(), + self.creation_blocked_reason(cx), + selected, + ); - Some( - ListItem::new(format!("worktree-menu-{ix}")) - .inset(true) - .spacing(ListItemSpacing::Sparse) - .toggle_state(selected) - .child( - h_flex() - .w_full() - .gap_2p5() + Some(item.into_any_element()) + } + WorktreeEntry::Worktree { + worktree, + positions, + } => { + 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().to_string_lossy().to_string(); + let sha = worktree.sha.chars().take(7).collect::(); + + let is_current = self.project_worktree_paths.contains(&worktree.path); + let can_delete = self.can_delete_worktree(worktree); + + let entry_icon = if is_current { + IconName::Check + } else { + IconName::GitWorktree + }; + + Some( + ListItem::new(SharedString::from(format!("worktree-{ix}"))) + .inset(true) + .spacing(ListItemSpacing::Sparse) + .toggle_state(selected) .child( - Icon::new(entry_icon) - .color(if is_current { - Color::Accent - } else { - Color::Muted - }) - .size(IconSize::Small), - ) - .child(v_flex().w_full().min_w_0().child(branch_name).map(|this| { - if entry.is_new { - this.child( - Label::new(sublabel) - .size(LabelSize::Small) - .color(Color::Muted) - .truncate(), + h_flex() + .w_full() + .gap_2p5() + .child( + Icon::new(entry_icon) + .color(if is_current { + Color::Accent + } else { + Color::Muted + }) + .size(IconSize::Small), ) - } else { - this.child( - h_flex() + .child( + v_flex() .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) - .color(Color::Muted), + HighlightedLabel::new(first_line.to_owned(), positions) + .truncate(), ) .child( - Label::new("•") - .alpha(0.5) - .color(Color::Muted) - .size(LabelSize::Small), - ) - .child( - Label::new(sublabel) - .truncate_start() - .color(Color::Muted) - .size(LabelSize::Small) - .flex_1(), + 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(), + ), + ), + ), + ) + .when(!is_current, |this| { + let open_in_new_window_button = + IconButton::new(("open-new-window", ix), IconName::ArrowUpRight) + .icon_size(IconSize::Small) + .tooltip(Tooltip::text("Open in New Window")) + .on_click(cx.listener(move |picker, _, window, cx| { + let Some(entry) = picker.delegate.matches.get(ix) else { + return; + }; + if let WorktreeEntry::Worktree { worktree, .. } = entry { + window.dispatch_action( + Box::new(OpenWorktreeInNewWindow { + path: worktree.path.clone(), + }), + cx, + ); + cx.emit(DismissEvent); + } + })); + + let focus_handle_delete = self.focus_handle.clone(); + let delete_button = + IconButton::new(("delete-worktree", ix), IconName::Trash) + .icon_size(IconSize::Small) + .tooltip(move |_, cx| { + Tooltip::for_action_in( + "Delete Worktree", + &DeleteWorktree, + &focus_handle_delete, + cx, ) - .into_any_element(), - ) - } - })), + }) + .on_click(cx.listener(move |picker, _, window, cx| { + picker.delegate.delete_worktree(ix, window, cx); + })); + + this.end_slot( + h_flex() + .gap_0p5() + .child(open_in_new_window_button) + .when(can_delete, |this| this.child(delete_button)), + ) + .show_end_slot_on_hover() + }) + .into_any_element(), ) - .when(!entry.is_new && !is_current, |this| { - let focus_handle = self.focus_handle.clone(); - let open_in_new_window_button = - IconButton::new(("open-new-window", ix), IconName::ArrowUpRight) - .icon_size(IconSize::Small) - .tooltip(move |_, cx| { - Tooltip::for_action_in( - "Open in New Window", - &menu::SecondaryConfirm, - &focus_handle, - cx, - ) - }) - .on_click(|_, window, cx| { - window.dispatch_action(menu::SecondaryConfirm.boxed_clone(), cx); - }); - - this.end_slot( - h_flex() - .gap_0p5() - .child(open_in_new_window_button) - .when(can_delete, |this| this.child(delete_button(ix))), - ) - .show_end_slot_on_hover() - }), - ) - } + } + WorktreeEntry::CreateNamed { + name, + from_branch, + disabled_reason, + } => { + 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(), + }; + + let item = create_new_list_item( + element_id.into(), + label.into(), + disabled_reason.clone().map(SharedString::from), + selected, + ); - fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option { - Some("No worktrees found".into()) + Some(item.into_any_element()) + } + } } fn render_footer(&self, _: &mut Window, cx: &mut Context>) -> Option { + if !self.show_footer { + return None; + } + let focus_handle = self.focus_handle.clone(); let selected_entry = self.matches.get(self.selected_index); - let is_creating = selected_entry.is_some_and(|entry| entry.is_new); - let can_delete = selected_entry - .is_some_and(|entry| entry.can_delete(self.forbidden_deletion_path.as_ref())); - let is_current = selected_entry.is_some_and(|entry| { - !entry.is_new - && self - .current_worktree_path - .as_ref() - .is_some_and(|current| *current == entry.worktree.path) + + let is_creating = selected_entry.is_some_and(|e| { + matches!( + e, + WorktreeEntry::CreateFromCurrentBranch + | WorktreeEntry::CreateFromDefaultBranch { .. } + | WorktreeEntry::CreateNamed { .. } + ) + }); + + let is_existing_worktree = + selected_entry.is_some_and(|e| matches!(e, WorktreeEntry::Worktree { .. })); + + let can_delete = selected_entry.is_some_and(|e| { + matches!(e, WorktreeEntry::Worktree { worktree, .. } if self.can_delete_worktree(worktree)) + }); + + let is_current = selected_entry.is_some_and(|e| { + matches!(e, WorktreeEntry::Worktree { worktree, .. } if self.project_worktree_paths.contains(&worktree.path)) }); - let footer_container = h_flex() + let footer = h_flex() .w_full() .p_1p5() .gap_0p5() @@ -1024,44 +935,25 @@ impl PickerDelegate for WorktreeListDelegate { .border_color(cx.theme().colors().border_variant); if is_creating { - let from_default_button = self.default_branch.as_ref().map(|default_branch| { - Button::new( - "worktree-from-default", - format!("Create from: {default_branch}"), - ) - .key_binding( - KeyBinding::for_action_in(&WorktreeFromDefault, &focus_handle, cx) - .map(|kb| kb.size(rems_from_px(12.))), - ) - .on_click(|_, window, cx| { - window.dispatch_action(WorktreeFromDefault.boxed_clone(), cx) - }) - }); - - let current_branch = self.base_branch(cx).unwrap_or("current branch"); - Some( - footer_container - .when_some(from_default_button, |this, button| this.child(button)) + footer .child( - Button::new( - "worktree-from-current", - format!("Create from: {current_branch}"), - ) - .key_binding( - KeyBinding::for_action_in(&menu::Confirm, &focus_handle, cx) - .map(|kb| kb.size(rems_from_px(12.))), - ) - .on_click(|_, window, cx| { - window.dispatch_action(menu::Confirm.boxed_clone(), cx) - }), + Button::new("create-worktree", "Create") + .key_binding( + KeyBinding::for_action_in(&menu::Confirm, &focus_handle, cx) + .map(|kb| kb.size(rems_from_px(12.))), + ) + .on_click(|_, window, cx| { + window.dispatch_action(menu::Confirm.boxed_clone(), cx) + }), ) .into_any(), ) - } else { + } else if is_existing_worktree { Some( - footer_container + footer .when(can_delete, |this| { + let focus_handle = focus_handle.clone(); this.child( Button::new("delete-worktree", "Delete") .key_binding( @@ -1074,6 +966,7 @@ impl PickerDelegate for WorktreeListDelegate { ) }) .when(!is_current, |this| { + let focus_handle = focus_handle.clone(); this.child( Button::new("open-in-new-window", "Open in New Window") .key_binding( @@ -1090,7 +983,7 @@ impl PickerDelegate for WorktreeListDelegate { ) }) .child( - Button::new("open-in-window", "Open") + Button::new("open-worktree", "Open") .key_binding( KeyBinding::for_action_in(&menu::Confirm, &focus_handle, cx) .map(|kb| kb.size(rems_from_px(12.))), @@ -1101,6 +994,142 @@ impl PickerDelegate for WorktreeListDelegate { ) .into_any(), ) + } else { + None } } } + +fn create_new_list_item( + id: SharedString, + label: SharedString, + disabled_tooltip: Option, + selected: bool, +) -> AnyElement { + 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() +} + +pub async fn open_remote_worktree( + connection_options: remote::RemoteConnectionOptions, + paths: Vec, + app_state: Arc, + workspace: gpui::WeakEntity, + cx: &mut gpui::AsyncWindowContext, +) -> anyhow::Result<()> { + let connect_task = workspace.update_in(cx, |workspace, window, cx| { + workspace.toggle_modal(window, cx, |window, cx| { + remote_connection::RemoteConnectionModal::new( + &connection_options, + Vec::new(), + window, + cx, + ) + }); + + let prompt = workspace + .active_modal::(cx) + .expect("Modal just created") + .read(cx) + .prompt + .clone(); + + remote_connection::connect( + remote::remote_client::ConnectionIdentifier::setup(), + connection_options.clone(), + prompt, + window, + cx, + ) + .prompt_err("Failed to connect", window, cx, |_, _, _| None) + })?; + + let session = connect_task.await; + + workspace + .update_in(cx, |workspace, _window, cx| { + if let Some(prompt) = + workspace.active_modal::(cx) + { + prompt.update(cx, |prompt, cx| prompt.finished(cx)) + } + }) + .ok(); + + let Some(Some(session)) = session else { + return Ok(()); + }; + + let new_project = cx.update(|_, cx| { + project::Project::remote( + session, + app_state.client.clone(), + app_state.node_runtime.clone(), + app_state.user_store.clone(), + app_state.languages.clone(), + app_state.fs.clone(), + true, + cx, + ) + })?; + + let workspace_position = cx + .update(|_, cx| { + workspace::remote_workspace_position_from_db(connection_options.clone(), &paths, cx) + })? + .await + .context("fetching workspace position from db")?; + + let mut options = + cx.update(|_, cx| (app_state.build_window_options)(workspace_position.display, cx))?; + options.window_bounds = workspace_position.window_bounds; + + let new_window = cx.open_window(options, |window, cx| { + let workspace = cx.new(|cx| { + let mut workspace = + Workspace::new(None, new_project.clone(), app_state.clone(), window, cx); + workspace.centered_layout = workspace_position.centered_layout; + workspace + }); + cx.new(|cx| MultiWorkspace::new(workspace, window, cx)) + })?; + + workspace::open_remote_project_with_existing_connection( + connection_options, + new_project, + paths, + app_state, + new_window, + None, + None, + cx, + ) + .await?; + + Ok(()) +} diff --git a/crates/git_ui/src/worktree_service.rs b/crates/git_ui/src/worktree_service.rs new file mode 100644 index 0000000000000000000000000000000000000000..c568b007f76377faecde2984abe67ffdc192d70f --- /dev/null +++ b/crates/git_ui/src/worktree_service.rs @@ -0,0 +1,809 @@ +use std::path::PathBuf; +use std::sync::Arc; + +use anyhow::anyhow; +use collections::HashSet; +use fs::Fs; +use gpui::{AsyncWindowContext, Entity, SharedString, WeakEntity}; +use project::Project; +use project::git_store::Repository; +use project::project_settings::ProjectSettings; +use project::trusted_worktrees::{PathTrust, TrustedWorktrees}; +use remote::RemoteConnectionOptions; +use settings::Settings; +use workspace::{MultiWorkspace, OpenMode, PreviousWorkspaceState, Workspace, dock::DockPosition}; +use zed_actions::NewWorktreeBranchTarget; + +use util::ResultExt as _; + +use crate::git_panel::show_error_toast; +use crate::worktree_names; + +/// Whether a worktree operation is creating a new one or switching to an +/// existing one. Controls whether the source workspace's state (dock layout, +/// open files, agent panel draft) is inherited by the destination. +enum WorktreeOperation { + Create, + Switch, +} + +/// Classifies the project's visible worktrees into git-managed repositories +/// and non-git paths. Each unique repository is returned only once. +pub fn classify_worktrees( + project: &Project, + cx: &gpui::App, +) -> (Vec>, Vec) { + let repositories = project.repositories(cx).clone(); + let mut git_repos: Vec> = Vec::new(); + let mut non_git_paths: Vec = Vec::new(); + let mut seen_repo_ids = HashSet::default(); + + for worktree in project.visible_worktrees(cx) { + let wt_path = worktree.read(cx).abs_path(); + + let matching_repo = repositories + .iter() + .filter_map(|(id, repo)| { + let work_dir = repo.read(cx).work_directory_abs_path.clone(); + if wt_path.starts_with(work_dir.as_ref()) { + Some((*id, repo.clone(), work_dir.as_ref().components().count())) + } else { + None + } + }) + .max_by( + |(left_id, _left_repo, left_depth), (right_id, _right_repo, right_depth)| { + left_depth + .cmp(right_depth) + .then_with(|| left_id.cmp(right_id)) + }, + ); + + if let Some((id, repo, _)) = matching_repo { + if seen_repo_ids.insert(id) { + git_repos.push(repo); + } + } else { + non_git_paths.push(wt_path.to_path_buf()); + } + } + + (git_repos, non_git_paths) +} + +/// Resolves a branch target into the ref the new worktree should be based on. +/// Returns `None` for `CurrentBranch`, meaning "use the current HEAD". +pub fn resolve_worktree_branch_target(branch_target: &NewWorktreeBranchTarget) -> Option { + match branch_target { + NewWorktreeBranchTarget::CurrentBranch => None, + NewWorktreeBranchTarget::ExistingBranch { name } => Some(name.clone()), + } +} + +/// Kicks off an async git-worktree creation for each repository. Returns: +/// +/// - `creation_infos`: a vec of `(repo, new_path, receiver)` tuples. +/// - `path_remapping`: `(old_work_dir, new_worktree_path)` pairs for remapping editor tabs. +fn start_worktree_creations( + git_repos: &[Entity], + worktree_name: Option, + existing_worktree_names: &[String], + existing_worktree_paths: &HashSet, + base_ref: Option, + worktree_directory_setting: &str, + rng: &mut impl rand::Rng, + cx: &mut gpui::App, +) -> anyhow::Result<( + Vec<( + Entity, + PathBuf, + futures::channel::oneshot::Receiver>, + )>, + Vec<(PathBuf, PathBuf)>, +)> { + let mut creation_infos = Vec::new(); + let mut path_remapping = Vec::new(); + + let worktree_name = worktree_name.unwrap_or_else(|| { + let existing_refs: Vec<&str> = existing_worktree_names.iter().map(|s| s.as_str()).collect(); + worktree_names::generate_worktree_name(&existing_refs, rng) + .unwrap_or_else(|| "worktree".to_string()) + }); + + for repo in git_repos { + let (work_dir, new_path, receiver) = repo.update(cx, |repo, _cx| { + let new_path = + repo.path_for_new_linked_worktree(&worktree_name, worktree_directory_setting)?; + if existing_worktree_paths.contains(&new_path) { + anyhow::bail!("A worktree already exists at {}", new_path.display()); + } + let target = git::repository::CreateWorktreeTarget::Detached { + base_sha: base_ref.clone(), + }; + let receiver = repo.create_worktree(target, new_path.clone()); + let work_dir = repo.work_directory_abs_path.clone(); + anyhow::Ok((work_dir, new_path, receiver)) + })?; + path_remapping.push((work_dir.to_path_buf(), new_path.clone())); + creation_infos.push((repo.clone(), new_path, receiver)); + } + + Ok((creation_infos, path_remapping)) +} + +/// Waits for every in-flight worktree creation to complete. If any +/// creation fails, all successfully-created worktrees are rolled back +/// (removed) so the project isn't left in a half-migrated state. +pub async fn await_and_rollback_on_failure( + creation_infos: Vec<( + Entity, + PathBuf, + futures::channel::oneshot::Receiver>, + )>, + fs: Arc, + cx: &mut AsyncWindowContext, +) -> anyhow::Result> { + let mut created_paths: Vec = Vec::new(); + let mut repos_and_paths: Vec<(Entity, PathBuf)> = Vec::new(); + let mut first_error: Option = None; + + for (repo, new_path, receiver) in creation_infos { + repos_and_paths.push((repo.clone(), new_path.clone())); + match receiver.await { + Ok(Ok(())) => { + created_paths.push(new_path); + } + Ok(Err(err)) => { + if first_error.is_none() { + first_error = Some(err); + } + } + Err(_canceled) => { + if first_error.is_none() { + first_error = Some(anyhow!("Worktree creation was canceled")); + } + } + } + } + + let Some(err) = first_error else { + return Ok(created_paths); + }; + + // Rollback all attempted worktrees + let mut rollback_futures = Vec::new(); + for (rollback_repo, rollback_path) in &repos_and_paths { + let receiver = cx + .update(|_, cx| { + rollback_repo.update(cx, |repo, _cx| { + repo.remove_worktree(rollback_path.clone(), true) + }) + }) + .ok(); + + rollback_futures.push((rollback_path.clone(), receiver)); + } + + let mut rollback_failures: Vec = Vec::new(); + for (path, receiver_opt) in rollback_futures { + let mut git_remove_failed = false; + + if let Some(receiver) = receiver_opt { + match receiver.await { + Ok(Ok(())) => {} + Ok(Err(rollback_err)) => { + log::error!( + "git worktree remove failed for {}: {rollback_err}", + path.display() + ); + git_remove_failed = true; + } + Err(canceled) => { + log::error!( + "git worktree remove failed for {}: {canceled}", + path.display() + ); + git_remove_failed = true; + } + } + } else { + log::error!( + "failed to dispatch git worktree remove for {}", + path.display() + ); + git_remove_failed = true; + } + + if git_remove_failed { + if let Err(fs_err) = fs + .remove_dir( + &path, + fs::RemoveOptions { + recursive: true, + ignore_if_not_exists: true, + }, + ) + .await + { + let msg = format!("{}: failed to remove directory: {fs_err}", path.display()); + log::error!("{}", msg); + rollback_failures.push(msg); + } + } + } + let mut error_message = format!("Failed to create worktree: {err}"); + if !rollback_failures.is_empty() { + error_message.push_str("\n\nFailed to clean up: "); + error_message.push_str(&rollback_failures.join(", ")); + } + Err(anyhow!(error_message)) +} + +/// Propagates worktree trust from the source workspace to the new workspace. +/// If the source project's worktrees are all trusted, the new worktree paths +/// will also be trusted automatically. +fn maybe_propagate_worktree_trust( + source_workspace: &WeakEntity, + new_workspace: &Entity, + paths: &[PathBuf], + cx: &mut AsyncWindowContext, +) { + cx.update(|_, cx| { + if ProjectSettings::get_global(cx).session.trust_all_worktrees { + return; + } + let Some(trusted_store) = TrustedWorktrees::try_get_global(cx) else { + return; + }; + + let source_is_trusted = source_workspace + .upgrade() + .map(|workspace| { + let source_worktree_store = workspace.read(cx).project().read(cx).worktree_store(); + !trusted_store + .read(cx) + .has_restricted_worktrees(&source_worktree_store, cx) + }) + .unwrap_or(false); + + if !source_is_trusted { + return; + } + + let worktree_store = new_workspace.read(cx).project().read(cx).worktree_store(); + let paths_to_trust: HashSet<_> = paths + .iter() + .filter_map(|path| { + let (worktree, _) = worktree_store.read(cx).find_worktree(path, cx)?; + Some(PathTrust::Worktree(worktree.read(cx).id())) + }) + .collect(); + + if !paths_to_trust.is_empty() { + trusted_store.update(cx, |store, cx| { + store.trust(&worktree_store, paths_to_trust, cx); + }); + } + }) + .ok(); +} + +/// Handles the `CreateWorktree` action generically, without any agent panel involvement. +/// Creates a new git worktree, opens the workspace, restores layout and files. +pub fn handle_create_worktree( + workspace: &mut Workspace, + action: &zed_actions::CreateWorktree, + window: &mut gpui::Window, + fallback_focused_dock: Option, + cx: &mut gpui::Context, +) { + let project = workspace.project().clone(); + + if project.read(cx).repositories(cx).is_empty() { + log::error!("create_worktree: no git repository in the project"); + return; + } + if project.read(cx).is_via_collab() { + log::error!("create_worktree: not supported in collab projects"); + return; + } + + // Guard against concurrent creation + if workspace.active_worktree_creation().label.is_some() { + return; + } + + let previous_state = + workspace.capture_state_for_worktree_switch(window, fallback_focused_dock, cx); + let workspace_handle = workspace.weak_handle(); + let window_handle = window.window_handle().downcast::(); + let remote_connection_options = project.read(cx).remote_connection_options(cx); + + let (git_repos, non_git_paths) = classify_worktrees(project.read(cx), cx); + + if git_repos.is_empty() { + show_error_toast( + cx.entity(), + "worktree create", + anyhow!("No git repositories found in the project"), + cx, + ); + return; + } + + if remote_connection_options.is_some() { + let is_disconnected = project + .read(cx) + .remote_client() + .is_some_and(|client| client.read(cx).is_disconnected()); + if is_disconnected { + show_error_toast( + cx.entity(), + "worktree create", + anyhow!("Cannot create worktree: remote connection is not active"), + cx, + ); + return; + } + } + + let worktree_name = action.worktree_name.clone(); + let branch_target = action.branch_target.clone(); + let display_name: SharedString = worktree_name + .as_deref() + .unwrap_or("worktree") + .to_string() + .into(); + + workspace.set_active_worktree_creation(Some(display_name), false, cx); + + cx.spawn_in(window, async move |_workspace_entity, mut cx| { + let result = do_create_worktree( + git_repos, + non_git_paths, + worktree_name, + branch_target, + previous_state, + workspace_handle.clone(), + window_handle, + remote_connection_options, + &mut cx, + ) + .await; + + if let Err(err) = &result { + log::error!("Failed to create worktree: {err}"); + workspace_handle + .update(cx, |workspace, cx| { + workspace.set_active_worktree_creation(None, false, cx); + show_error_toast(cx.entity(), "worktree create", anyhow!("{err:#}"), cx); + }) + .ok(); + } + + result + }) + .detach_and_log_err(cx); +} + +pub fn handle_switch_worktree( + workspace: &mut Workspace, + action: &zed_actions::SwitchWorktree, + window: &mut gpui::Window, + fallback_focused_dock: Option, + cx: &mut gpui::Context, +) { + let project = workspace.project().clone(); + + if project.read(cx).repositories(cx).is_empty() { + log::error!("switch_to_worktree: no git repository in the project"); + return; + } + if project.read(cx).is_via_collab() { + log::error!("switch_to_worktree: not supported in collab projects"); + return; + } + + // Guard against concurrent creation + if workspace.active_worktree_creation().label.is_some() { + return; + } + + let previous_state = + workspace.capture_state_for_worktree_switch(window, fallback_focused_dock, cx); + let workspace_handle = workspace.weak_handle(); + let window_handle = window.window_handle().downcast::(); + let remote_connection_options = project.read(cx).remote_connection_options(cx); + + let (git_repos, non_git_paths) = classify_worktrees(project.read(cx), cx); + + let git_repo_work_dirs: Vec = git_repos + .iter() + .map(|repo| repo.read(cx).work_directory_abs_path.to_path_buf()) + .collect(); + + let display_name: SharedString = action.display_name.clone().into(); + + workspace.set_active_worktree_creation(Some(display_name), true, cx); + + let worktree_path = action.path.clone(); + + cx.spawn_in(window, async move |_workspace_entity, mut cx| { + let result = do_switch_worktree( + worktree_path, + git_repo_work_dirs, + non_git_paths, + previous_state, + workspace_handle.clone(), + window_handle, + remote_connection_options, + &mut cx, + ) + .await; + + if let Err(err) = &result { + log::error!("Failed to switch worktree: {err}"); + workspace_handle + .update(cx, |workspace, cx| { + workspace.set_active_worktree_creation(None, false, cx); + show_error_toast(cx.entity(), "worktree switch", anyhow!("{err:#}"), cx); + }) + .ok(); + } + + result + }) + .detach_and_log_err(cx); +} + +async fn do_create_worktree( + git_repos: Vec>, + non_git_paths: Vec, + worktree_name: Option, + branch_target: NewWorktreeBranchTarget, + previous_state: PreviousWorkspaceState, + workspace: WeakEntity, + window_handle: Option>, + remote_connection_options: Option, + cx: &mut AsyncWindowContext, +) -> anyhow::Result<()> { + // List existing worktrees from all repos to detect name collisions + let worktree_receivers: Vec<_> = cx.update(|_, cx| { + git_repos + .iter() + .map(|repo| repo.update(cx, |repo, _cx| repo.worktrees())) + .collect() + })?; + let worktree_directory_setting = cx.update(|_, cx| { + ProjectSettings::get_global(cx) + .git + .worktree_directory + .clone() + })?; + + let mut existing_worktree_names = Vec::new(); + let mut existing_worktree_paths = HashSet::default(); + for result in futures::future::join_all(worktree_receivers).await { + match result { + Ok(Ok(worktrees)) => { + for worktree in worktrees { + if let Some(name) = worktree + .path + .parent() + .and_then(|p| p.file_name()) + .and_then(|n| n.to_str()) + { + existing_worktree_names.push(name.to_string()); + } + existing_worktree_paths.insert(worktree.path.clone()); + } + } + Ok(Err(err)) => { + Err::<(), _>(err).log_err(); + } + Err(_) => {} + } + } + + let mut rng = rand::rng(); + + let base_ref = resolve_worktree_branch_target(&branch_target); + + let (creation_infos, path_remapping) = cx.update(|_, cx| { + start_worktree_creations( + &git_repos, + worktree_name, + &existing_worktree_names, + &existing_worktree_paths, + base_ref, + &worktree_directory_setting, + &mut rng, + cx, + ) + })??; + + let fs = cx.update(|_, cx| ::global(cx))?; + + let created_paths = await_and_rollback_on_failure(creation_infos, fs, cx).await?; + + let mut all_paths = created_paths; + let has_non_git = !non_git_paths.is_empty(); + all_paths.extend(non_git_paths.iter().cloned()); + + open_worktree_workspace( + all_paths, + path_remapping, + non_git_paths, + has_non_git, + previous_state, + workspace, + window_handle, + remote_connection_options, + WorktreeOperation::Create, + cx, + ) + .await +} + +async fn do_switch_worktree( + worktree_path: PathBuf, + git_repo_work_dirs: Vec, + non_git_paths: Vec, + previous_state: PreviousWorkspaceState, + workspace: WeakEntity, + window_handle: Option>, + remote_connection_options: Option, + cx: &mut AsyncWindowContext, +) -> anyhow::Result<()> { + 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()); + + open_worktree_workspace( + all_paths, + path_remapping, + non_git_paths, + has_non_git, + previous_state, + workspace, + window_handle, + remote_connection_options, + WorktreeOperation::Switch, + cx, + ) + .await +} + +/// Core workspace opening logic shared by both create and switch flows. +async fn open_worktree_workspace( + all_paths: Vec, + path_remapping: Vec<(PathBuf, PathBuf)>, + non_git_paths: Vec, + has_non_git: bool, + previous_state: PreviousWorkspaceState, + workspace: WeakEntity, + window_handle: Option>, + remote_connection_options: Option, + operation: WorktreeOperation, + cx: &mut AsyncWindowContext, +) -> anyhow::Result<()> { + let window_handle = window_handle + .ok_or_else(|| anyhow!("No window handle available for workspace creation"))?; + + let focused_dock = previous_state.focused_dock; + + let is_creating_new_worktree = matches!(operation, WorktreeOperation::Create); + + let source_for_transfer = if is_creating_new_worktree { + Some(workspace.clone()) + } else { + None + }; + + let (workspace_task, modal_workspace) = + window_handle.update(cx, |multi_workspace, window, cx| { + let path_list = util::path_list::PathList::new(&all_paths); + let active_workspace = multi_workspace.workspace().clone(); + let modal_workspace = active_workspace.clone(); + + let init: Option< + Box< + dyn FnOnce(&mut Workspace, &mut gpui::Window, &mut gpui::Context) + + Send, + >, + > = if is_creating_new_worktree { + let dock_structure = previous_state.dock_structure; + Some(Box::new( + move |workspace: &mut Workspace, + window: &mut gpui::Window, + cx: &mut gpui::Context| { + workspace.set_dock_structure(dock_structure, window, cx); + }, + )) + } else { + None + }; + + let task = multi_workspace.find_or_create_workspace_with_source_workspace( + path_list, + remote_connection_options, + None, + move |connection_options, window, cx| { + remote_connection::connect_with_modal( + &active_workspace, + connection_options, + window, + cx, + ) + }, + &[], + init, + OpenMode::Add, + source_for_transfer.clone(), + window, + cx, + ); + (task, modal_workspace) + })?; + + let result = workspace_task.await; + remote_connection::dismiss_connection_modal(&modal_workspace, cx); + let new_workspace = result?; + + let panels_task = new_workspace.update(cx, |workspace, _cx| workspace.take_panels_task()); + + if let Some(task) = panels_task { + task.await.log_err(); + } + + new_workspace + .update(cx, |workspace, cx| { + workspace.project().read(cx).wait_for_initial_scan(cx) + }) + .await; + + new_workspace + .update(cx, |workspace, cx| { + let repos = workspace + .project() + .read(cx) + .repositories(cx) + .values() + .cloned() + .collect::>(); + + let tasks = repos + .into_iter() + .map(|repo| repo.update(cx, |repo, _| repo.barrier())); + futures::future::join_all(tasks) + }) + .await; + + maybe_propagate_worktree_trust(&workspace, &new_workspace, &all_paths, cx); + + if is_creating_new_worktree { + window_handle.update(cx, |_multi_workspace, window, cx| { + new_workspace.update(cx, |workspace, cx| { + if has_non_git { + struct WorktreeCreationToast; + let toast_id = + workspace::notifications::NotificationId::unique::(); + workspace.show_toast( + workspace::Toast::new( + toast_id, + "Some project folders are not git repositories. \ + They were included as-is without creating a worktree.", + ), + cx, + ); + } + + // Remap every previously-open file path into the new worktree. + let remap_path = |original_path: PathBuf| -> Option { + let best_match = path_remapping + .iter() + .filter_map(|(old_root, new_root)| { + original_path.strip_prefix(old_root).ok().map(|relative| { + (old_root.components().count(), new_root.join(relative)) + }) + }) + .max_by_key(|(depth, _)| *depth); + + if let Some((_, remapped_path)) = best_match { + return Some(remapped_path); + } + + for non_git in &non_git_paths { + if original_path.starts_with(non_git) { + return Some(original_path); + } + } + None + }; + + let remapped_active_path = + previous_state.active_file_path.and_then(|p| remap_path(p)); + + let mut paths_to_open: Vec = Vec::new(); + let mut seen = HashSet::default(); + for path in previous_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 !paths_to_open.is_empty() { + let should_focus_center = focused_dock.is_none(); + let open_task = workspace.open_paths( + paths_to_open, + workspace::OpenOptions { + focus: Some(false), + ..Default::default() + }, + None, + window, + cx, + ); + cx.spawn_in(window, async move |workspace, cx| { + for item in open_task.await.into_iter().flatten() { + item.log_err(); + } + if should_focus_center { + workspace.update_in(cx, |workspace, window, cx| { + workspace.focus_center_pane(window, cx); + })?; + } + anyhow::Ok(()) + }) + .detach_and_log_err(cx); + } + }); + })?; + } + + // Clear the creation status on the SOURCE workspace so its title bar + // stops showing the loading indicator immediately. + workspace + .update(cx, |ws, cx| { + ws.set_active_worktree_creation(None, false, cx); + }) + .ok(); + + window_handle.update(cx, |multi_workspace, window, cx| { + multi_workspace.activate(new_workspace.clone(), source_for_transfer, window, cx); + + new_workspace.update(cx, |workspace, cx| { + workspace.run_create_worktree_tasks(window, cx); + }); + })?; + + if is_creating_new_worktree { + if let Some(dock_position) = focused_dock { + window_handle.update(cx, |_multi_workspace, window, cx| { + new_workspace.update(cx, |workspace, cx| { + let dock = workspace.dock_at_position(dock_position); + if let Some(panel) = dock.read(cx).active_panel() { + panel.panel_focus_handle(cx).focus(window, cx); + } + }); + })?; + } + } + + anyhow::Ok(()) +} diff --git a/crates/migrator/src/migrations.rs b/crates/migrator/src/migrations.rs index 4ed4c0a2632074d08fcbf2591a18290cebcd33e0..087303ea0795ac2484bb55c3d1246ad7dc58f828 100644 --- a/crates/migrator/src/migrations.rs +++ b/crates/migrator/src/migrations.rs @@ -340,3 +340,9 @@ pub(crate) mod m_2026_04_15 { pub(crate) use settings::remove_settings_from_http_context_servers; } + +pub(crate) mod m_2026_04_17 { + mod settings; + + pub(crate) use settings::promote_show_branch_icon_true_to_show_branch_status_icon; +} diff --git a/crates/migrator/src/migrations/m_2026_04_17/settings.rs b/crates/migrator/src/migrations/m_2026_04_17/settings.rs new file mode 100644 index 0000000000000000000000000000000000000000..50a37454cea04ecb3c8d47befe27579902072e01 --- /dev/null +++ b/crates/migrator/src/migrations/m_2026_04_17/settings.rs @@ -0,0 +1,47 @@ +use anyhow::Result; +use serde_json::Value; + +use crate::migrations::migrate_settings; + +const SETTINGS_KEY: &str = "settings"; +const TITLE_BAR_KEY: &str = "title_bar"; +const OLD_KEY: &str = "show_branch_icon"; +const NEW_KEY: &str = "show_branch_status_icon"; + +pub fn promote_show_branch_icon_true_to_show_branch_status_icon(value: &mut Value) -> Result<()> { + migrate_settings(value, &mut migrate_one) +} + +fn migrate_one(object: &mut serde_json::Map) -> Result<()> { + migrate_title_bar_value(object); + + if let Some(settings) = object + .get_mut(SETTINGS_KEY) + .and_then(|value| value.as_object_mut()) + { + migrate_title_bar_value(settings); + } + + Ok(()) +} + +fn migrate_title_bar_value(object: &mut serde_json::Map) { + let Some(title_bar) = object + .get_mut(TITLE_BAR_KEY) + .and_then(|value| value.as_object_mut()) + else { + return; + }; + + let Some(old_value) = title_bar.remove(OLD_KEY) else { + return; + }; + + if title_bar.contains_key(NEW_KEY) { + return; + } + + if old_value == Value::Bool(true) { + title_bar.insert(NEW_KEY.to_string(), Value::Bool(true)); + } +} diff --git a/crates/migrator/src/migrator.rs b/crates/migrator/src/migrator.rs index b4a49130cb0854b2dbe150e814149969456920db..d946b87627eb7020ddae37d69423208539b00931 100644 --- a/crates/migrator/src/migrator.rs +++ b/crates/migrator/src/migrator.rs @@ -251,6 +251,9 @@ pub fn migrate_settings(text: &str) -> Result> { MigrationType::Json(migrations::m_2026_04_01::restructure_profiles_with_settings_key), MigrationType::Json(migrations::m_2026_04_10::rename_web_search_to_search_web), MigrationType::Json(migrations::m_2026_04_15::remove_settings_from_http_context_servers), + MigrationType::Json( + migrations::m_2026_04_17::promote_show_branch_icon_true_to_show_branch_status_icon, + ), ]; run_migrations(text, migrations) } @@ -5041,4 +5044,252 @@ mod tests { ), ); } + + #[test] + fn test_promote_show_branch_icon_true_to_show_branch_status_icon_at_root() { + assert_migrate_settings( + &r#" + { + "title_bar": { + "show_branch_icon": true, + "show_branch_name": true + } + } + "# + .unindent(), + Some( + &r#" + { + "title_bar": { + "show_branch_status_icon": true, + "show_branch_name": true + } + } + "# + .unindent(), + ), + ); + } + + #[test] + fn test_drop_show_branch_icon_false_without_setting_status_icon() { + assert_migrate_settings( + &r#" + { + "title_bar": { + "show_branch_icon": false, + "show_branch_name": true + } + } + "# + .unindent(), + Some( + &r#" + { + "title_bar": { + "show_branch_name": true + } + } + "# + .unindent(), + ), + ); + } + + #[test] + fn test_promote_show_branch_icon_true_to_show_branch_status_icon_in_platform_override() { + assert_migrate_settings( + &r#" + { + "macos": { + "title_bar": { + "show_branch_icon": true, + "show_branch_name": true + } + } + } + "# + .unindent(), + Some( + &r#" + { + "macos": { + "title_bar": { + "show_branch_status_icon": true, + "show_branch_name": true + } + } + } + "# + .unindent(), + ), + ); + } + + #[test] + fn test_promote_show_branch_icon_true_to_show_branch_status_icon_in_release_override() { + assert_migrate_settings( + &r#" + { + "preview": { + "title_bar": { + "show_branch_icon": true, + "show_branch_name": true + } + } + } + "# + .unindent(), + Some( + &r#" + { + "preview": { + "title_bar": { + "show_branch_status_icon": true, + "show_branch_name": true + } + } + } + "# + .unindent(), + ), + ); + } + + #[test] + fn test_promote_show_branch_icon_true_to_show_branch_status_icon_in_profiles() { + assert_migrate_settings( + &r#" + { + "profiles": { + "work": { + "title_bar": { + "show_branch_icon": true, + "show_branch_name": true + } + } + } + } + "# + .unindent(), + Some( + &r#" + { + "profiles": { + "work": { + "settings": { + "title_bar": { + "show_branch_status_icon": true, + "show_branch_name": true + } + } + } + } + } + "# + .unindent(), + ), + ); + } + + #[test] + fn test_promote_show_branch_icon_true_to_show_branch_status_icon_across_all_scopes() { + assert_migrate_settings( + &r#" + { + "title_bar": { + "show_branch_icon": true, + "show_branch_name": true + }, + "macos": { + "title_bar": { + "show_branch_icon": true, + "show_branch_name": true + } + }, + "preview": { + "title_bar": { + "show_branch_icon": true, + "show_branch_name": true + } + }, + "profiles": { + "work": { + "title_bar": { + "show_branch_icon": true, + "show_branch_name": true + } + } + } + } + "# + .unindent(), + Some( + &r#" + { + "title_bar": { + "show_branch_status_icon": true, + "show_branch_name": true + }, + "macos": { + "title_bar": { + "show_branch_status_icon": true, + "show_branch_name": true + } + }, + "preview": { + "title_bar": { + "show_branch_status_icon": true, + "show_branch_name": true + } + }, + "profiles": { + "work": { + "settings": { + "title_bar": { + "show_branch_status_icon": true, + "show_branch_name": true + } + } + } + } + } + "# + .unindent(), + ), + ); + } + + #[test] + fn test_promote_show_branch_icon_true_to_show_branch_status_icon_no_change_when_already_migrated() + { + assert_migrate_settings( + &r#" + { + "title_bar": { + "show_branch_status_icon": true, + "show_branch_name": true + } + } + "# + .unindent(), + None, + ); + + // No title_bar key — should be unchanged + assert_migrate_settings(&r#"{ "theme": "One Dark" }"#.unindent(), None); + + // title_bar without show_branch_icon — should be unchanged + assert_migrate_settings( + &r#" + { + "title_bar": { + "show_branch_name": true + } + } + "# + .unindent(), + None, + ); + } } diff --git a/crates/recent_projects/src/remote_connections.rs b/crates/recent_projects/src/remote_connections.rs index 448115c6988a3e5a5088f708353d7c7d4ca620aa..38c5e8cdb56af9893afa8a1bd095e4e1beadd36c 100644 --- a/crates/recent_projects/src/remote_connections.rs +++ b/crates/recent_projects/src/remote_connections.rs @@ -160,7 +160,7 @@ pub async fn open_remote_project( let open_results = existing_window .update(cx, |multi_workspace, window, cx| { window.activate_window(); - multi_workspace.activate(existing_workspace.clone(), window, cx); + multi_workspace.activate(existing_workspace.clone(), None, window, cx); existing_workspace.update(cx, |workspace, cx| { workspace.open_paths( resolved_paths, diff --git a/crates/recent_projects/src/remote_servers.rs b/crates/recent_projects/src/remote_servers.rs index 0e15abf296e491185f24718cddf72e2532e9e6aa..860903d5d6f03a4564c931713d43c77e745db573 100644 --- a/crates/recent_projects/src/remote_servers.rs +++ b/crates/recent_projects/src/remote_servers.rs @@ -505,7 +505,7 @@ impl ProjectPicker { }?; let items = open_remote_project_with_existing_connection( - connection, project, paths, app_state, window, None, cx, + connection, project, paths, app_state, window, None, None, cx, ) .await .log_err(); diff --git a/crates/settings_content/src/title_bar.rs b/crates/settings_content/src/title_bar.rs index af5e30f361c7603aba72de3b5734ae78ab366171..34b3e82aff168030580215ccc03fee9ec0b65f71 100644 --- a/crates/settings_content/src/title_bar.rs +++ b/crates/settings_content/src/title_bar.rs @@ -81,10 +81,12 @@ impl From for WindowButtonLayoutContent { #[with_fallible_options] #[derive(Clone, PartialEq, Default, Serialize, Deserialize, JsonSchema, MergeFrom, Debug)] pub struct TitleBarSettingsContent { - /// Whether to show the branch icon beside branch switcher in the title bar. + /// Whether to show git status indicators on the branch icon in the title bar. + /// When enabled, the branch icon changes to reflect the current repository + /// status (e.g. modified, added, deleted, or conflict). /// /// Default: false - pub show_branch_icon: Option, + pub show_branch_status_icon: Option, /// Whether to show onboarding banners in the title bar. /// /// Default: true diff --git a/crates/settings_ui/src/page_data.rs b/crates/settings_ui/src/page_data.rs index cf53fa598a281db15509459508effe8109da219c..83b65488995f3810e404bf52d2f4b700dc6881f0 100644 --- a/crates/settings_ui/src/page_data.rs +++ b/crates/settings_ui/src/page_data.rs @@ -3631,22 +3631,22 @@ fn window_and_layout_page() -> SettingsPage { [ SettingsPageItem::SectionHeader("Title Bar"), SettingsPageItem::SettingItem(SettingItem { - title: "Show Branch Icon", - description: "Show the branch icon beside branch switcher in the titlebar.", + title: "Show Branch Status Icon", + description: "Show git status indicators on the branch icon in the titlebar.", field: Box::new(SettingField { - json_path: Some("title_bar.show_branch_icon"), + json_path: Some("title_bar.show_branch_status_icon"), pick: |settings_content| { settings_content .title_bar .as_ref()? - .show_branch_icon + .show_branch_status_icon .as_ref() }, write: |settings_content, value| { settings_content .title_bar .get_or_insert_default() - .show_branch_icon = value; + .show_branch_status_icon = value; }, }), metadata: None, diff --git a/crates/sidebar/src/sidebar.rs b/crates/sidebar/src/sidebar.rs index 08fad745fcfa2e1719f5ba40a67cb589e1118024..21d0c49cddc348071bcba04754cfb7e4edcf26be 100644 --- a/crates/sidebar/src/sidebar.rs +++ b/crates/sidebar/src/sidebar.rs @@ -494,7 +494,7 @@ impl Sidebar { &multi_workspace, window, |this, _multi_workspace, event: &MultiWorkspaceEvent, window, cx| match event { - MultiWorkspaceEvent::ActiveWorkspaceChanged => { + MultiWorkspaceEvent::ActiveWorkspaceChanged { .. } => { this.sync_active_entry_from_active_workspace(cx); this.replace_archived_panel_thread(window, cx); this.update_entries(cx); @@ -1989,6 +1989,7 @@ impl Sidebar { .update(cx, |multi_workspace, cx| { multi_workspace.activate( activate_workspace.clone(), + None, window, cx, ); @@ -2475,7 +2476,7 @@ impl Sidebar { } multi_workspace.update(cx, |multi_workspace, cx| { - multi_workspace.activate(workspace.clone(), window, cx); + multi_workspace.activate(workspace.clone(), None, window, cx); if retain { multi_workspace.retain_active_workspace(cx); } @@ -2515,7 +2516,7 @@ impl Sidebar { let activated = target_window .update(cx, |multi_workspace, window, cx| { window.activate_window(); - multi_workspace.activate(workspace.clone(), window, cx); + multi_workspace.activate(workspace.clone(), None, window, cx); Self::load_agent_thread_in_workspace(&workspace, &metadata, true, window, cx); }) .log_err() @@ -3537,7 +3538,7 @@ impl Sidebar { ) { if let Some(multi_workspace) = self.multi_workspace.upgrade() { multi_workspace.update(cx, |mw, cx| { - mw.activate(workspace.clone(), window, cx); + mw.activate(workspace.clone(), None, window, cx); }); } } @@ -3729,7 +3730,7 @@ impl Sidebar { } => { if let Some(mw) = weak_multi_workspace.upgrade() { mw.update(cx, |mw, cx| { - mw.activate(workspace.clone(), window, cx); + mw.activate(workspace.clone(), None, window, cx); }); } this.active_entry = Some(ActiveEntry { @@ -3748,7 +3749,7 @@ impl Sidebar { } => { if let Some(mw) = weak_multi_workspace.upgrade() { mw.update(cx, |mw, cx| { - mw.activate(workspace.clone(), window, cx); + mw.activate(workspace.clone(), None, window, cx); mw.retain_active_workspace(cx); }); } @@ -3766,7 +3767,7 @@ impl Sidebar { if let Some(mw) = weak_multi_workspace.upgrade() { if let Some(original_ws) = &original_workspace { mw.update(cx, |mw, cx| { - mw.activate(original_ws.clone(), window, cx); + mw.activate(original_ws.clone(), None, window, cx); }); } } @@ -3823,7 +3824,7 @@ impl Sidebar { if let Some((metadata, workspace)) = initial_preview { if let Some(mw) = self.multi_workspace.upgrade() { mw.update(cx, |mw, cx| { - mw.activate(workspace.clone(), window, cx); + mw.activate(workspace.clone(), None, window, cx); }); } self.active_entry = Some(ActiveEntry { @@ -4070,7 +4071,7 @@ impl Sidebar { }; multi_workspace.update(cx, |multi_workspace, cx| { - multi_workspace.activate(workspace.clone(), window, cx); + multi_workspace.activate(workspace.clone(), None, window, cx); }); let draft_id = workspace.update(cx, |workspace, cx| { @@ -4197,7 +4198,7 @@ impl Sidebar { .workspace_for_paths(key.path_list(), key.host().as_ref(), cx) }) { multi_workspace.update(cx, |multi_workspace, cx| { - multi_workspace.activate(workspace, window, cx); + multi_workspace.activate(workspace, None, window, cx); multi_workspace.retain_active_workspace(cx); }); } else { diff --git a/crates/sidebar/src/sidebar_tests.rs b/crates/sidebar/src/sidebar_tests.rs index bd266fb88c3657c6e28c49ccfd364edb919b649a..5e34bb1c8425963883a068015e0e2c129df045f2 100644 --- a/crates/sidebar/src/sidebar_tests.rs +++ b/crates/sidebar/src/sidebar_tests.rs @@ -2098,7 +2098,7 @@ async fn test_confirm_on_historical_thread_activates_workspace(cx: &mut TestAppC // Switch to workspace 1 so we can verify the confirm switches back. multi_workspace.update_in(cx, |mw, window, cx| { let workspace = mw.workspaces().nth(1).unwrap().clone(); - mw.activate(workspace, window, cx); + mw.activate(workspace, None, window, cx); }); cx.run_until_parked(); assert_eq!( @@ -2597,7 +2597,7 @@ async fn test_focused_thread_tracks_user_intent(cx: &mut TestAppContext) { multi_workspace.update_in(cx, |mw, window, cx| { let workspace = mw.workspaces().next().unwrap().clone(); - mw.activate(workspace, window, cx); + mw.activate(workspace, None, window, cx); }); cx.run_until_parked(); @@ -2653,7 +2653,7 @@ async fn test_focused_thread_tracks_user_intent(cx: &mut TestAppContext) { multi_workspace.update_in(cx, |mw, window, cx| { let workspace = mw.workspaces().find(|w| *w == &workspace_b).cloned(); if let Some(workspace) = workspace { - mw.activate(workspace, window, cx); + mw.activate(workspace, None, window, cx); } }); cx.run_until_parked(); @@ -2917,7 +2917,7 @@ async fn test_cmd_n_shows_new_thread_entry_in_absorbed_worktree(cx: &mut TestApp // Switch to the worktree workspace. multi_workspace.update_in(cx, |mw, window, cx| { let workspace = mw.workspaces().nth(1).unwrap().clone(); - mw.activate(workspace, window, cx); + mw.activate(workspace, None, window, cx); }); // Create a non-empty thread in the worktree workspace. @@ -3521,7 +3521,7 @@ async fn test_absorbed_worktree_running_thread_shows_live_status(cx: &mut TestAp // Switch back to the main workspace before setting up the sidebar. multi_workspace.update_in(cx, |mw, window, cx| { let workspace = mw.workspaces().next().unwrap().clone(); - mw.activate(workspace, window, cx); + mw.activate(workspace, None, window, cx); }); // Start a thread in the worktree workspace's panel and keep it @@ -3614,7 +3614,7 @@ async fn test_absorbed_worktree_completion_triggers_notification(cx: &mut TestAp multi_workspace.update_in(cx, |mw, window, cx| { let workspace = mw.workspaces().next().unwrap().clone(); - mw.activate(workspace, window, cx); + mw.activate(workspace, None, window, cx); }); let connection = StubAgentConnection::new(); @@ -3937,7 +3937,7 @@ async fn test_clicking_absorbed_worktree_thread_activates_worktree_workspace( // Activate the main workspace before setting up the sidebar. let main_workspace = multi_workspace.update_in(cx, |mw, window, cx| { let workspace = mw.workspaces().next().unwrap().clone(); - mw.activate(workspace.clone(), window, cx); + mw.activate(workspace.clone(), None, window, cx); workspace }); @@ -4018,7 +4018,7 @@ async fn test_activate_archived_thread_with_saved_paths_activates_matching_works // Ensure workspace A is active. multi_workspace.update_in(cx, |mw, window, cx| { let workspace = mw.workspaces().next().unwrap().clone(); - mw.activate(workspace, window, cx); + mw.activate(workspace, None, window, cx); }); cx.run_until_parked(); assert_eq!( @@ -4088,7 +4088,7 @@ async fn test_activate_archived_thread_cwd_fallback_with_matching_workspace( // Start with workspace A active. multi_workspace.update_in(cx, |mw, window, cx| { let workspace = mw.workspaces().next().unwrap().clone(); - mw.activate(workspace, window, cx); + mw.activate(workspace, None, window, cx); }); cx.run_until_parked(); assert_eq!( @@ -4155,7 +4155,7 @@ async fn test_activate_archived_thread_no_paths_no_cwd_uses_active_workspace( // Activate workspace B (index 1) to make it the active one. multi_workspace.update_in(cx, |mw, window, cx| { let workspace = mw.workspaces().nth(1).unwrap().clone(); - mw.activate(workspace, window, cx); + mw.activate(workspace, None, window, cx); }); cx.run_until_parked(); assert_eq!( @@ -4557,7 +4557,7 @@ async fn test_archive_thread_uses_next_threads_own_workspace(cx: &mut TestAppCon // Activate main workspace so the sidebar tracks the main panel. multi_workspace.update_in(cx, |mw, window, cx| { let workspace = mw.workspaces().next().unwrap().clone(); - mw.activate(workspace, window, cx); + mw.activate(workspace, None, window, cx); }); let main_workspace = @@ -6461,7 +6461,7 @@ async fn test_unarchive_into_inactive_existing_workspace_does_not_leave_active_d cx.run_until_parked(); multi_workspace.update_in(cx, |mw, window, cx| { - mw.activate(workspace_a.clone(), window, cx); + mw.activate(workspace_a.clone(), None, window, cx); }); cx.run_until_parked(); @@ -6853,7 +6853,7 @@ async fn test_switch_to_workspace_with_archived_thread_shows_no_active_entry( let workspace_a = multi_workspace.read_with(cx, |mw, _| mw.workspaces().next().unwrap().clone()); multi_workspace.update_in(cx, |mw, window, cx| { - mw.activate(workspace_a.clone(), window, cx); + mw.activate(workspace_a.clone(), None, window, cx); }); cx.run_until_parked(); @@ -7014,7 +7014,7 @@ async fn test_archive_last_thread_on_linked_worktree_does_not_create_new_thread_ // Activate the linked worktree workspace so the sidebar tracks it. multi_workspace.update_in(cx, |mw, window, cx| { - mw.activate(worktree_workspace.clone(), window, cx); + mw.activate(worktree_workspace.clone(), None, window, cx); }); // Open a thread in the linked worktree panel and send a message @@ -7185,7 +7185,7 @@ async fn test_archive_last_thread_on_linked_worktree_with_no_siblings_leaves_gro // Activate the linked worktree workspace. multi_workspace.update_in(cx, |mw, window, cx| { - mw.activate(worktree_workspace.clone(), window, cx); + mw.activate(worktree_workspace.clone(), None, window, cx); }); // Open a thread on the linked worktree — this is the ONLY thread. @@ -7486,7 +7486,7 @@ async fn test_archive_thread_on_linked_worktree_selects_sibling_thread(cx: &mut // Activate the linked worktree workspace. multi_workspace.update_in(cx, |mw, window, cx| { - mw.activate(worktree_workspace.clone(), window, cx); + mw.activate(worktree_workspace.clone(), None, window, cx); }); // Open a thread on the linked worktree. @@ -7641,7 +7641,7 @@ async fn test_linked_worktree_workspace_reachable_and_dismissable(cx: &mut TestA // Switch back to the main workspace. multi_workspace.update_in(cx, |mw, window, cx| { let main_ws = mw.workspaces().next().unwrap().clone(); - mw.activate(main_ws, window, cx); + mw.activate(main_ws, None, window, cx); }); cx.run_until_parked(); @@ -7850,7 +7850,9 @@ async fn test_transient_workspace_retained(cx: &mut TestAppContext) { ); // Switch to A — B survives. (Switching from one internal workspace, to another) - multi_workspace.update_in(cx, |mw, window, cx| mw.activate(workspace_a, window, cx)); + multi_workspace.update_in(cx, |mw, window, cx| { + mw.activate(workspace_a, None, window, cx) + }); cx.run_until_parked(); assert_eq!( multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()), @@ -8318,7 +8320,7 @@ async fn test_project_header_click_restores_last_viewed(cx: &mut TestAppContext) .clone() }); multi_workspace.update_in(cx, |mw, window, cx| { - mw.activate(workspace_a.clone(), window, cx); + mw.activate(workspace_a.clone(), None, window, cx); }); cx.run_until_parked(); @@ -8406,11 +8408,11 @@ async fn test_activating_workspace_with_draft_does_not_create_extras(cx: &mut Te // Switch away from project-b, then back. multi_workspace.update_in(cx, |mw, window, cx| { - mw.activate(workspace_a.clone(), window, cx); + mw.activate(workspace_a.clone(), None, window, cx); }); cx.run_until_parked(); multi_workspace.update_in(cx, |mw, window, cx| { - mw.activate(workspace_b.clone(), window, cx); + mw.activate(workspace_b.clone(), None, window, cx); }); cx.run_until_parked(); @@ -9146,7 +9148,7 @@ mod property_test { .unwrap_or_else(|| mw.workspace().clone()) }); multi_workspace.update_in(cx, |mw, window, cx| { - mw.activate(workspace, window, cx); + mw.activate(workspace, None, window, cx); }); } Operation::AddLinkedWorktree { @@ -10921,7 +10923,7 @@ async fn test_cmd_click_project_header_returns_to_last_active_linked_worktree_wo // workspaces — what matters for this test is the explicit sequence of // activations below.) multi_workspace.update_in(cx, |mw, window, cx| { - mw.activate(worktree_workspace_a.clone(), window, cx); + mw.activate(worktree_workspace_a.clone(), None, window, cx); }); cx.run_until_parked(); assert_eq!( @@ -10934,7 +10936,7 @@ async fn test_cmd_click_project_header_returns_to_last_active_linked_worktree_wo // workspace remains the linked-worktree one (group B getting activated // records *its own* last-active workspace, not group A's). multi_workspace.update_in(cx, |mw, window, cx| { - mw.activate(workspace_b.clone(), window, cx); + mw.activate(workspace_b.clone(), None, window, cx); }); cx.run_until_parked(); assert_eq!( diff --git a/crates/title_bar/src/title_bar.rs b/crates/title_bar/src/title_bar.rs index e427d5aa7bba2fe682aa548ddf9ced141c6d5478..a3804b450ba6d198b33d3ab8560d8415799ff8e9 100644 --- a/crates/title_bar/src/title_bar.rs +++ b/crates/title_bar/src/title_bar.rs @@ -7,6 +7,7 @@ mod update_version; use crate::application_menu::{ApplicationMenu, show_menus}; use crate::plan_chip::PlanChip; +use git_ui::worktree_picker::WorktreePicker; pub use platform_title_bar::{ self, DraggedWindowTab, MergeAllWindows, MoveTabToNewWindow, PlatformTitleBar, ShowNextWindowTab, ShowPreviousWindowTab, @@ -389,6 +390,16 @@ impl TitleBar { }), ); subscriptions.push(cx.observe(&user_store, |_a, _, cx| cx.notify())); + if let Some(workspace_entity) = workspace.weak_handle().upgrade() { + subscriptions.push(cx.subscribe( + &workspace_entity, + |_, _, event: &workspace::Event, cx| { + if matches!(event, workspace::Event::WorktreeCreationChanged) { + cx.notify(); + } + }, + )); + } subscriptions.push(cx.observe_button_layout_changed(window, |_, _, cx| cx.notify())); if let Some(trusted_worktrees) = TrustedWorktrees::try_get_global(cx) { subscriptions.push(cx.subscribe(&trusted_worktrees, |_, _, _, cx| { @@ -816,12 +827,14 @@ impl TitleBar { repository: Entity, linked_worktree_name: Option, cx: &mut Context, - ) -> Option { + ) -> Option { let workspace = self.workspace.upgrade()?; - let (branch_name, icon_info) = { + let (branch_name, icon_info, is_detached_head) = { let repo = repository.read(cx); + let is_detached_head = repo.branch.is_none(); + let branch_name = repo .branch .as_ref() @@ -851,67 +864,131 @@ impl TitleBar { (IconName::GitBranch, Color::Muted) }; - (branch_name, icon_info) + (branch_name, icon_info, is_detached_head) }; let branch_name = branch_name?; let settings = TitleBarSettings::get_global(cx); let effective_repository = Some(repository); - Some( - PopoverMenu::new("branch-menu") + let worktree_label: SharedString = linked_worktree_name.unwrap_or_else(|| "main".into()); + + let (creation_in_progress, is_switch) = self + .workspace + .upgrade() + .map(|ws| { + let creation = ws.read(cx).active_worktree_creation(); + (creation.label.clone(), creation.is_switch) + }) + .unwrap_or((None, false)); + let is_creating = creation_in_progress.is_some(); + + let display_label: SharedString = if let Some(ref name) = creation_in_progress { + if is_switch { + format!("Loading {}…", name).into() + } else { + format!("Creating {}…", name).into() + } + } else { + worktree_label.clone() + }; + + let worktree_button = { + let project = self.project.clone(); + let workspace_handle = workspace.downgrade(); + PopoverMenu::new("worktree-picker-menu") .menu(move |window, cx| { - Some(git_ui::git_picker::popover( - workspace.downgrade(), - effective_repository.clone(), - git_ui::git_picker::GitPickerTab::Branches, - gpui::rems(34.), - window, - cx, - )) + // When opened from the title bar, focus is on the trigger + // button (not a dock), so `focused_dock` is `None`. That's + // fine — there's no prior dock focus to restore. + Some(cx.new(|cx| { + WorktreePicker::new(project.clone(), workspace_handle.clone(), window, cx) + })) }) .trigger_with_tooltip( - ButtonLike::new("project_branch_trigger") + Button::new("worktree_picker_trigger", display_label) .selected_style(ButtonStyle::Tinted(TintColor::Accent)) - .child( - h_flex() - .gap_0p5() - .when(settings.show_branch_icon, |this| { - let (icon, icon_color) = icon_info; - this.child( - Icon::new(icon).size(IconSize::XSmall).color(icon_color), - ) - }) - .when_some(linked_worktree_name.as_ref(), |this, worktree_name| { - this.child( - Label::new(worktree_name) - .size(LabelSize::Small) - .color(Color::Muted), - ) - .child( - Label::new("/").size(LabelSize::Small).color( - Color::Custom( - cx.theme().colors().text_muted.opacity(0.4), - ), - ), - ) - }) - .child( - Label::new(branch_name) - .size(LabelSize::Small) - .color(Color::Muted), - ), + .label_size(LabelSize::Small) + .color(Color::Muted) + .loading(is_creating) + .start_icon( + Icon::new(IconName::GitWorktree) + .size(IconSize::XSmall) + .color(Color::Muted), ), move |_window, cx| { Tooltip::with_meta( - "Git Switcher", - Some(&zed_actions::git::Branch), - "Worktrees, Branches, and Stashes", + "Worktree", + Some(&zed_actions::git::Worktree), + format!("Currently In Use: {}", worktree_label), cx, ) }, ) - .anchor(gpui::Corner::TopLeft), + .anchor(gpui::Corner::TopLeft) + }; + + let branch_tooltip_label = branch_name.clone(); + let (branch_icon, branch_icon_color) = if settings.show_branch_status_icon { + icon_info + } else { + (IconName::GitBranch, Color::Muted) + }; + + let trigger = if is_detached_head { + Button::new("project_branch_trigger", "Create Branch") + .selected_style(ButtonStyle::Tinted(TintColor::Accent)) + .label_size(LabelSize::Small) + .start_icon( + Icon::new(IconName::GitBranchPlus) + .size(IconSize::XSmall) + .color(Color::Muted), + ) + } else { + Button::new("project_branch_trigger", branch_name) + .selected_style(ButtonStyle::Tinted(TintColor::Accent)) + .label_size(LabelSize::Small) + .color(Color::Muted) + .start_icon( + Icon::new(branch_icon) + .size(IconSize::XSmall) + .color(branch_icon_color), + ) + }; + + let git_picker_button = PopoverMenu::new("branch-menu") + .menu(move |window, cx| { + Some(git_ui::git_picker::popover( + workspace.downgrade(), + effective_repository.clone(), + git_ui::git_picker::GitPickerTab::Branches, + gpui::rems(34.), + window, + cx, + )) + }) + .trigger_with_tooltip(trigger, move |_window, cx| { + let meta = if is_detached_head { + format!("Detached HEAD: {}", branch_tooltip_label) + } else { + format!("Currently Checked Out: {}", branch_tooltip_label) + }; + Tooltip::with_meta("Branch & Stash", Some(&zed_actions::git::Branch), meta, cx) + }) + .anchor(gpui::Corner::TopLeft); + + Some( + h_flex() + .gap_px() + .child(worktree_button) + .child( + Label::new("/") + .size(LabelSize::Small) + .color(Color::Muted) + .alpha(0.25), + ) + .child(git_picker_button) + .into_any_element(), ) } diff --git a/crates/title_bar/src/title_bar_settings.rs b/crates/title_bar/src/title_bar_settings.rs index 61f951ca305d1a0bb53100b883a5e77409adb54f..c81c271b3554fe0f5f65fb572e03d8dbe34de893 100644 --- a/crates/title_bar/src/title_bar_settings.rs +++ b/crates/title_bar/src/title_bar_settings.rs @@ -3,7 +3,7 @@ use settings::{RegisterSetting, Settings, SettingsContent}; #[derive(Copy, Clone, Debug, RegisterSetting)] pub struct TitleBarSettings { - pub show_branch_icon: bool, + pub show_branch_status_icon: bool, pub show_onboarding_banner: bool, pub show_user_picture: bool, pub show_branch_name: bool, @@ -18,7 +18,7 @@ impl Settings for TitleBarSettings { fn from_settings(s: &SettingsContent) -> Self { let content = s.title_bar.clone().unwrap(); TitleBarSettings { - show_branch_icon: content.show_branch_icon.unwrap(), + show_branch_status_icon: content.show_branch_status_icon.unwrap(), show_onboarding_banner: content.show_onboarding_banner.unwrap(), show_user_picture: content.show_user_picture.unwrap(), show_branch_name: content.show_branch_name.unwrap(), diff --git a/crates/workspace/src/multi_workspace.rs b/crates/workspace/src/multi_workspace.rs index 8895df67372676bd5e58417bd902007ee9422d2b..0752dd2e3f3e6d9a01656ad72b6ba5adc67edcc6 100644 --- a/crates/workspace/src/multi_workspace.rs +++ b/crates/workspace/src/multi_workspace.rs @@ -103,7 +103,9 @@ pub fn sidebar_side_context_menu( } pub enum MultiWorkspaceEvent { - ActiveWorkspaceChanged, + ActiveWorkspaceChanged { + source_workspace: Option>, + }, WorkspaceAdded(Entity), WorkspaceRemoved(EntityId), ProjectGroupsChanged, @@ -578,7 +580,7 @@ impl MultiWorkspace { cx.subscribe_in(workspace, window, |this, workspace, event, window, cx| { if let WorkspaceEvent::Activate = event { - this.activate(workspace.clone(), window, cx); + this.activate(workspace.clone(), None, window, cx); } }) .detach(); @@ -730,7 +732,7 @@ impl MultiWorkspace { self.retained_workspaces.push(workspace.clone()); } - self.activate(workspace.clone(), window, cx); + self.activate(workspace.clone(), None, window, cx); cx.emit(MultiWorkspaceEvent::WorkspaceAdded(workspace)); } @@ -1137,19 +1139,52 @@ impl MultiWorkspace { open_mode: OpenMode, window: &mut Window, cx: &mut Context, + ) -> Task>> { + self.find_or_create_workspace_with_source_workspace( + paths, + host, + provisional_project_group_key, + connect_remote, + excluding, + init, + open_mode, + None, + window, + cx, + ) + } + + pub fn find_or_create_workspace_with_source_workspace( + &mut self, + paths: PathList, + host: Option, + provisional_project_group_key: Option, + connect_remote: impl FnOnce( + RemoteConnectionOptions, + &mut Window, + &mut Context, + ) -> Task>>> + + 'static, + excluding: &[Entity], + init: Option) + Send>>, + open_mode: OpenMode, + source_workspace: Option>, + window: &mut Window, + cx: &mut Context, ) -> Task>> { if let Some(workspace) = self.workspace_for_paths(&paths, host.as_ref(), cx) { - self.activate(workspace.clone(), window, cx); + self.activate(workspace.clone(), source_workspace, window, cx); return Task::ready(Ok(workspace)); } let Some(connection_options) = host else { - return self.find_or_create_local_workspace( + return self.find_or_create_local_workspace_with_source_workspace( paths, provisional_project_group_key, excluding, init, open_mode, + source_workspace, window, cx, ); @@ -1215,6 +1250,7 @@ impl MultiWorkspace { app_state, window_handle, provisional_project_group_key, + source_workspace, cx, ) .await?; @@ -1243,10 +1279,33 @@ impl MultiWorkspace { open_mode: OpenMode, window: &mut Window, cx: &mut Context, + ) -> Task>> { + self.find_or_create_local_workspace_with_source_workspace( + path_list, + project_group, + excluding, + init, + open_mode, + None, + window, + cx, + ) + } + + pub fn find_or_create_local_workspace_with_source_workspace( + &mut self, + path_list: PathList, + project_group: Option, + excluding: &[Entity], + init: Option) + Send>>, + open_mode: OpenMode, + source_workspace: Option>, + window: &mut Window, + cx: &mut Context, ) -> Task>> { if let Some(workspace) = self.workspace_for_paths_excluding(&path_list, None, excluding, cx) { - self.activate(workspace.clone(), window, cx); + self.activate(workspace.clone(), source_workspace, window, cx); return Task::ready(Ok(workspace)); } @@ -1291,7 +1350,12 @@ impl MultiWorkspace { cx, ) .inspect(|workspace| { - multi_workspace.activate(workspace.clone(), window, cx); + multi_workspace.activate( + workspace.clone(), + source_workspace.clone(), + window, + cx, + ); }) }) .ok() @@ -1354,6 +1418,7 @@ impl MultiWorkspace { pub fn activate( &mut self, workspace: Entity, + source_workspace: Option>, window: &mut Window, cx: &mut Context, ) { @@ -1386,7 +1451,7 @@ impl MultiWorkspace { self.detach_workspace(&old_active_workspace, cx); } - cx.emit(MultiWorkspaceEvent::ActiveWorkspaceChanged); + cx.emit(MultiWorkspaceEvent::ActiveWorkspaceChanged { source_workspace }); self.serialize(cx); self.focus_active_workspace(window, cx); cx.notify(); @@ -1671,7 +1736,7 @@ impl MultiWorkspace { cx: &mut Context, ) -> Entity { let workspace = cx.new(|cx| Workspace::test_new(project, window, cx)); - self.activate(workspace.clone(), window, cx); + self.activate(workspace.clone(), None, window, cx); workspace } @@ -1702,7 +1767,7 @@ impl MultiWorkspace { cx, ); let new_workspace = cx.new(|cx| Workspace::new(None, project, app_state, window, cx)); - self.activate(new_workspace.clone(), window, cx); + self.activate(new_workspace.clone(), None, window, cx); let weak_workspace = new_workspace.downgrade(); let db = crate::persistence::WorkspaceDb::global(cx); @@ -1827,12 +1892,12 @@ impl MultiWorkspace { !workspaces.contains(&new_active), "fallback workspace must not be one of the workspaces being removed" ); - this.activate(new_active, window, cx); + this.activate(new_active, None, window, cx); })?; } else { this.update_in(cx, |this, window, cx| { if *this.workspace() != original_active { - this.activate(original_active, window, cx); + this.activate(original_active, None, window, cx); } })?; } diff --git a/crates/workspace/src/multi_workspace_tests.rs b/crates/workspace/src/multi_workspace_tests.rs index e5ee718a52876546768f1ee6482e224b4134cf52..37ebf691492a75f1db9a21a7f50d00a443291914 100644 --- a/crates/workspace/src/multi_workspace_tests.rs +++ b/crates/workspace/src/multi_workspace_tests.rs @@ -555,7 +555,7 @@ async fn test_close_workspace_prefers_already_loaded_neighboring_workspace( }); multi_workspace.update_in(cx, |multi_workspace, window, cx| { - multi_workspace.activate(workspace_a.clone(), window, cx); + multi_workspace.activate(workspace_a.clone(), None, window, cx); multi_workspace.test_add_project_group(ProjectGroup { key: project_c_key.clone(), workspaces: Vec::new(), diff --git a/crates/workspace/src/persistence.rs b/crates/workspace/src/persistence.rs index 4dde067c1f74e8eb7570435c587bfba90bea146c..d7b486d736f3fde6a0f543fed0e7e4c9fbcfd177 100644 --- a/crates/workspace/src/persistence.rs +++ b/crates/workspace/src/persistence.rs @@ -2568,7 +2568,7 @@ mod tests { let workspace2 = multi_workspace.update_in(cx, |mw, window, cx| { let workspace = cx.new(|cx| crate::Workspace::test_new(project2.clone(), window, cx)); workspace.update(cx, |ws, _cx| ws.set_random_database_id()); - mw.activate(workspace.clone(), window, cx); + mw.activate(workspace.clone(), None, window, cx); workspace }); @@ -4947,7 +4947,7 @@ mod tests { // Activate workspace B so removing its group exercises the fallback. multi_workspace.update_in(cx, |mw, window, cx| { - mw.activate(workspace_b.clone(), window, cx); + mw.activate(workspace_b.clone(), None, window, cx); }); cx.run_until_parked(); @@ -4976,7 +4976,7 @@ mod tests { let workspace_a = multi_workspace.read_with(cx, |mw, _cx| mw.workspaces().next().unwrap().clone()); multi_workspace.update_in(cx, |mw, window, cx| { - mw.activate(workspace_a.clone(), window, cx); + mw.activate(workspace_a.clone(), None, window, cx); }); cx.run_until_parked(); @@ -5052,7 +5052,7 @@ mod tests { // Activate workspace_a so removing it triggers the fallback path. multi_workspace.update_in(cx, |mw, window, cx| { - mw.activate(workspace_a.clone(), window, cx); + mw.activate(workspace_a.clone(), None, window, cx); }); cx.run_until_parked(); diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index 419588b9f2d6176ffba8645c3e3f553a0e6db039..f87ddfd73be20858d70ff2c0a72be3746d0274e7 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -1109,6 +1109,23 @@ struct GlobalAppState(Arc); impl Global for GlobalAppState {} +/// Tracks worktree creation progress for the workspace. +/// Read by the title bar to show a loading indicator on the worktree button. +#[derive(Default)] +pub struct ActiveWorktreeCreation { + pub label: Option, + pub is_switch: bool, +} + +/// Captured workspace state used when switching between worktrees. +/// Stores the layout and open files so they can be restored in the new workspace. +pub struct PreviousWorkspaceState { + pub dock_structure: DockStructure, + pub open_file_paths: Vec, + pub active_file_path: Option, + pub focused_dock: Option, +} + pub struct WorkspaceStore { workspaces: HashSet<(gpui::AnyWindowHandle, WeakEntity)>, client: Arc, @@ -1269,6 +1286,7 @@ pub enum Event { ModalOpened, Activate, PanelAdded(AnyView), + WorktreeCreationChanged, } #[derive(Debug, Clone)] @@ -1377,6 +1395,7 @@ pub struct Workspace { _panels_task: Option>>, sidebar_focus_handle: Option, multi_workspace: Option>, + active_worktree_creation: ActiveWorktreeCreation, } impl EventEmitter for Workspace {} @@ -1805,6 +1824,7 @@ impl Workspace { removing: false, sidebar_focus_handle: None, multi_workspace, + active_worktree_creation: ActiveWorktreeCreation::default(), open_in_dev_container: false, _dev_container_task: None, } @@ -1947,7 +1967,7 @@ impl Workspace { }); match open_mode { OpenMode::Activate => { - multi_workspace.activate(workspace.clone(), window, cx); + multi_workspace.activate(workspace.clone(), None, window, cx); } OpenMode::Add => { multi_workspace.add(workspace.clone(), &*window, cx); @@ -2178,6 +2198,64 @@ impl Workspace { } } + /// Returns which dock currently has focus, or `None` if focus is in the + /// center pane or elsewhere. Does NOT fall back to any global state. + pub fn focused_dock_position(&self, window: &Window, cx: &App) -> Option { + [ + (DockPosition::Left, &self.left_dock), + (DockPosition::Right, &self.right_dock), + (DockPosition::Bottom, &self.bottom_dock), + ] + .into_iter() + .find(|(_, dock)| { + dock.read(cx).is_open() && dock.focus_handle(cx).contains_focused(window, cx) + }) + .map(|(position, _)| position) + } + + pub fn active_worktree_creation(&self) -> &ActiveWorktreeCreation { + &self.active_worktree_creation + } + + pub fn set_active_worktree_creation( + &mut self, + label: Option, + is_switch: bool, + cx: &mut Context, + ) { + self.active_worktree_creation.label = label; + self.active_worktree_creation.is_switch = is_switch; + cx.emit(Event::WorktreeCreationChanged); + cx.notify(); + } + + /// Captures the current workspace state for restoring after a worktree switch. + /// This includes dock layout, open file paths, and the active file path. + pub fn capture_state_for_worktree_switch( + &self, + window: &Window, + fallback_focused_dock: Option, + cx: &App, + ) -> PreviousWorkspaceState { + let dock_structure = self.capture_dock_state(window, cx); + let open_file_paths = self.open_item_abs_paths(cx); + let active_file_path = self + .active_item(cx) + .and_then(|item| item.project_path(cx)) + .and_then(|pp| self.project().read(cx).absolute_path(&pp, cx)); + + let focused_dock = self + .focused_dock_position(window, cx) + .or(fallback_focused_dock); + + PreviousWorkspaceState { + dock_structure, + open_file_paths, + active_file_path, + focused_dock, + } + } + pub fn open_item_abs_paths(&self, cx: &App) -> Vec { self.items(cx) .filter_map(|item| { @@ -3449,6 +3527,11 @@ impl Workspace { OpenOptions { requesting_window, open_mode, + workspace_matching: if open_mode == OpenMode::NewWindow { + WorkspaceMatching::None + } else { + WorkspaceMatching::default() + }, ..Default::default() }, cx, @@ -9608,7 +9691,7 @@ pub fn open_paths( let open_task = existing .update(cx, |multi_workspace, window, cx| { window.activate_window(); - multi_workspace.activate(target_workspace.clone(), window, cx); + multi_workspace.activate(target_workspace.clone(), None, window, cx); target_workspace.update(cx, |workspace, cx| { if open_in_dev_container { workspace.set_open_in_dev_container(true); @@ -9834,6 +9917,7 @@ pub fn open_remote_project_with_new_connection( app_state, window, None, + None, cx, ) .await @@ -9847,6 +9931,7 @@ pub fn open_remote_project_with_existing_connection( app_state: Arc, window: WindowHandle, provisional_project_group_key: Option, + source_workspace: Option>, cx: &mut AsyncApp, ) -> Task>>>> { cx.spawn(async move |cx| { @@ -9861,6 +9946,7 @@ pub fn open_remote_project_with_existing_connection( app_state, window, provisional_project_group_key, + source_workspace, cx, ) .await @@ -9875,6 +9961,7 @@ async fn open_remote_project_inner( app_state: Arc, window: WindowHandle, provisional_project_group_key: Option, + source_workspace: Option>, cx: &mut AsyncApp, ) -> Result>>> { let db = cx.update(|cx| WorkspaceDb::global(cx)); @@ -9945,7 +10032,7 @@ async fn open_remote_project_inner( cx, ); } else { - multi_workspace.activate(new_workspace.clone(), window, cx); + multi_workspace.activate(new_workspace.clone(), source_workspace, window, cx); } new_workspace })?; @@ -10033,7 +10120,7 @@ pub fn join_in_room_project( { existing_window .update(cx, |multi_workspace, window, cx| { - multi_workspace.activate(target_workspace, window, cx); + multi_workspace.activate(target_workspace, None, window, cx); }) .ok(); existing_window @@ -10973,7 +11060,7 @@ mod tests { // Activate workspace A multi_workspace_handle .update(cx, |mw, window, cx| { - mw.activate(workspace_a.clone(), window, cx); + mw.activate(workspace_a.clone(), None, window, cx); }) .unwrap(); @@ -11058,7 +11145,7 @@ mod tests { // Activate workspace A. multi_workspace_handle .update(cx, |mw, window, cx| { - mw.activate(workspace_a.clone(), window, cx); + mw.activate(workspace_a.clone(), None, window, cx); }) .unwrap(); @@ -11110,7 +11197,7 @@ mod tests { let remove_task = multi_workspace_handle .update(cx, |mw, window, cx| { // First switch back to A. - mw.activate(workspace_a.clone(), window, cx); + mw.activate(workspace_a.clone(), None, window, cx); mw.remove([workspace_b.clone()], |_, _, _| unreachable!(), window, cx) }) .unwrap(); @@ -14836,7 +14923,7 @@ mod tests { multi_workspace_handle .update(cx, |mw, window, cx| { let workspace = mw.workspaces().next().unwrap().clone(); - mw.activate(workspace, window, cx); + mw.activate(workspace, None, window, cx); }) .unwrap(); @@ -14882,7 +14969,7 @@ mod tests { multi_workspace_handle .update(cx, |mw, window, cx| { let workspace = mw.workspaces().nth(1).unwrap().clone(); - mw.activate(workspace, window, cx); + mw.activate(workspace, None, window, cx); }) .unwrap(); cx.run_until_parked(); @@ -14891,7 +14978,7 @@ mod tests { multi_workspace_handle .update(cx, |mw, window, cx| { let workspace = mw.workspaces().next().unwrap().clone(); - mw.activate(workspace, window, cx); + mw.activate(workspace, None, window, cx); }) .unwrap(); cx.run_until_parked(); diff --git a/crates/zed/src/visual_test_runner.rs b/crates/zed/src/visual_test_runner.rs index dd953179f0f22bbe33712e1a053bad8c79fb856f..539885aef07528c50d51801b5236a61011d2643c 100644 --- a/crates/zed/src/visual_test_runner.rs +++ b/crates/zed/src/visual_test_runner.rs @@ -2605,7 +2605,7 @@ fn run_multi_workspace_sidebar_visual_tests( }); cx.new(|cx| { let mut multi_workspace = MultiWorkspace::new(workspace1, window, cx); - multi_workspace.activate(workspace2, window, cx); + multi_workspace.activate(workspace2, None, window, cx); multi_workspace }) }, @@ -2657,7 +2657,7 @@ fn run_multi_workspace_sidebar_visual_tests( multi_workspace_window .update(cx, |multi_workspace, window, cx| { let workspace = multi_workspace.workspaces().next().unwrap().clone(); - multi_workspace.activate(workspace, window, cx); + multi_workspace.activate(workspace, None, window, cx); }) .context("Failed to activate workspace 1")?; @@ -3393,7 +3393,7 @@ fn open_sidebar_test_window( let ws = cx.new(|cx| { Workspace::new(None, project, app_state.clone(), window, cx) }); - mw.activate(ws, window, cx); + mw.activate(ws, None, window, cx); } mw }) diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index edc431d7c145cbfbd61e5b6c4bb46022d1baab00..7d74ca478ab0de25fb7f50e04f0e6733c195e911 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -423,6 +423,38 @@ pub fn initialize_workspace(app_state: Arc, cx: &mut App) { let window_handle = window.window_handle(); let multi_workspace_handle = cx.entity(); + cx.subscribe_in( + &multi_workspace_handle, + window, + |this, _multi_workspace, event: &workspace::MultiWorkspaceEvent, window, cx| { + let workspace::MultiWorkspaceEvent::ActiveWorkspaceChanged { source_workspace } = + event + else { + return; + }; + + let active_workspace = this.workspace().clone(); + let source_workspace = source_workspace.clone(); + active_workspace.update(cx, |workspace, cx| { + if let Some(ref source) = source_workspace { + if let Some(panel) = workspace.panel::(cx) { + panel.update(cx, |panel, cx| { + panel.initialize_from_source_workspace_if_needed( + source.clone(), + window, + cx, + ); + }); + } + } + + ensure_agent_panel_for_workspace(workspace, source_workspace, window, cx) + .detach_and_log_err(cx); + }); + }, + ) + .detach(); + cx.defer(move |cx| { window_handle .update(cx, |_, window, cx| { @@ -735,24 +767,43 @@ fn setup_or_teardown_ai_panel( } } +fn ensure_agent_panel_for_workspace( + workspace: &mut Workspace, + source_workspace: Option>, + window: &mut Window, + cx: &mut Context, +) -> Task> { + let task = setup_or_teardown_ai_panel(workspace, window, cx, move |workspace, cx| { + agent_ui::AgentPanel::load(workspace, cx) + }); + + cx.spawn_in(window, async move |workspace, cx| { + task.await?; + workspace.update_in(cx, |workspace, window, cx| { + if let Some(source_workspace) = source_workspace.clone() + && let Some(panel) = workspace.panel::(cx) + { + panel.update(cx, |panel, cx| { + panel.initialize_from_source_workspace_if_needed(source_workspace, window, cx); + }); + } + }) + }) +} + async fn initialize_agent_panel( workspace_handle: WeakEntity, mut cx: AsyncWindowContext, ) -> anyhow::Result<()> { workspace_handle .update_in(&mut cx, |workspace, window, cx| { - setup_or_teardown_ai_panel(workspace, window, cx, move |workspace, cx| { - agent_ui::AgentPanel::load(workspace, cx) - }) + ensure_agent_panel_for_workspace(workspace, None, window, cx) })? .await?; workspace_handle.update_in(&mut cx, |workspace, window, cx| { cx.observe_global_in::(window, move |workspace, window, cx| { - setup_or_teardown_ai_panel(workspace, window, cx, move |workspace, cx| { - agent_ui::AgentPanel::load(workspace, cx) - }) - .detach_and_log_err(cx); + ensure_agent_panel_for_workspace(workspace, None, window, cx).detach_and_log_err(cx); }) .detach(); @@ -1542,7 +1593,7 @@ fn quit(_: &Quit, cx: &mut App) { for workspace in workspaces { if let Some(should_close) = window .update(cx, |multi_workspace, window, cx| { - multi_workspace.activate(workspace.clone(), window, cx); + multi_workspace.activate(workspace.clone(), None, window, cx); window.activate_window(); workspace.update(cx, |workspace, cx| { workspace.prepare_to_close(CloseIntent::Quit, window, cx) @@ -5113,6 +5164,7 @@ mod tests { "vim", "window", "workspace", + "worktree_picker", "zed", "zed_actions", "zed_predict_onboarding", @@ -5657,10 +5709,10 @@ mod tests { window .update(cx, |multi_workspace, window, cx| { - multi_workspace.activate(workspace2.clone(), window, cx); - multi_workspace.activate(workspace3.clone(), window, cx); + multi_workspace.activate(workspace2.clone(), None, window, cx); + multi_workspace.activate(workspace3.clone(), None, window, cx); // Switch back to workspace1 for test setup - multi_workspace.activate(workspace1.clone(), window, cx); + multi_workspace.activate(workspace1.clone(), None, window, cx); assert_eq!(multi_workspace.workspace(), &workspace1); }) .unwrap(); @@ -5844,8 +5896,8 @@ mod tests { window1 .update(cx, |multi_workspace, window, cx| { - multi_workspace.activate(workspace1_2.clone(), window, cx); - multi_workspace.activate(workspace1_1.clone(), window, cx); + multi_workspace.activate(workspace1_2.clone(), None, window, cx); + multi_workspace.activate(workspace1_1.clone(), None, window, cx); }) .unwrap(); @@ -6164,7 +6216,7 @@ mod tests { window_a .update(cx, |multi_workspace, window, cx| { let workspace = multi_workspace.workspaces().next().unwrap().clone(); - multi_workspace.activate(workspace, window, cx); + multi_workspace.activate(workspace, None, window, cx); }) .unwrap(); @@ -6372,7 +6424,7 @@ mod tests { }) .expect("workspace_a should exist") .clone(); - mw.activate(workspace_a, window, cx); + mw.activate(workspace_a, None, window, cx); }) .unwrap(); cx.run_until_parked(); diff --git a/crates/zed_actions/src/lib.rs b/crates/zed_actions/src/lib.rs index 404a2af27d81ec1b70f3e445c9f1f5eefca49530..c2e59fcddb00d1341d38162f0958b6cd71b832c6 100644 --- a/crates/zed_actions/src/lib.rs +++ b/crates/zed_actions/src/lib.rs @@ -1,6 +1,7 @@ use gpui::{Action, actions}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; +use std::path::PathBuf; // If the zed binary doesn't use anything in this crate, it will be optimized away // and the actions won't initialize. So we just provide an empty initialization function @@ -251,6 +252,49 @@ pub mod workspace { ); } +/// Describes which ref to base a new git worktree on. The worktree is +/// always created in a detached HEAD state; users can opt into creating +/// a branch afterwards from the worktree itself. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case", tag = "kind")] +pub enum NewWorktreeBranchTarget { + /// Create a detached worktree from the current HEAD. + #[default] + CurrentBranch, + /// Create a detached worktree at the tip of an existing branch. + ExistingBranch { name: String }, +} + +/// 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 = git)] +#[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 = git)] +#[serde(deny_unknown_fields)] +pub struct SwitchWorktree { + pub path: PathBuf, + pub display_name: String, +} + +/// Opens an existing worktree in a new window. +/// Dispatched by the worktree picker's "Open in New Window" button. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Action)] +#[action(namespace = git)] +#[serde(deny_unknown_fields)] +pub struct OpenWorktreeInNewWindow { + pub path: PathBuf, +} + pub mod git { use gpui::actions; diff --git a/docs/src/ai/llm-providers.md b/docs/src/ai/llm-providers.md index 7c34d13e9616bbcf9482f6f8a79699ad7e2f96ff..4a522a79ed1d8b7f8336a820e12285d4e46f08c4 100644 --- a/docs/src/ai/llm-providers.md +++ b/docs/src/ai/llm-providers.md @@ -762,7 +762,7 @@ You can also set a custom endpoint for Vercel AI Gateway in your settings file: [Vercel v0](https://v0.app/docs/api/model) is a model for generating full-stack apps, with framework-aware completions for stacks like Next.js and Vercel. It supports text and image inputs and provides fast streaming responses. -The v0 models are [OpenAI-compatible models](/#openai-api-compatible), and Vercel appears as a dedicated provider in the panel's settings view. +The v0 models are [OpenAI-compatible models](#openai-api-compatible), and Vercel appears as a dedicated provider in the panel's settings view. To start using it with Zed, ensure you have first created a [v0 API key](https://v0.dev/chat/settings/keys). Once you have it, paste it directly into the Vercel provider section in the panel's settings view. diff --git a/docs/src/reference/all-settings.md b/docs/src/reference/all-settings.md index 6eada231df3eafe44b86242aa75ba00a286a7be4..f51804dab174d5ac12f7c776b5bc78fa38cb09b2 100644 --- a/docs/src/reference/all-settings.md +++ b/docs/src/reference/all-settings.md @@ -4659,7 +4659,7 @@ Run the {#action theme_selector::Toggle} action in the command palette to see a ```json [settings] { "title_bar": { - "show_branch_icon": false, + "show_branch_status_icon": false, "show_branch_name": true, "show_project_items": true, "show_onboarding_banner": true, @@ -4674,7 +4674,7 @@ Run the {#action theme_selector::Toggle} action in the command palette to see a **Options** -- `show_branch_icon`: Whether to show the branch icon beside branch switcher in the titlebar +- `show_branch_status_icon`: Whether to show git status indicators on the branch icon in the titlebar - `show_branch_name`: Whether to show the branch name button in the titlebar - `show_project_items`: Whether to show the project host and name in the titlebar - `show_onboarding_banner`: Whether to show onboarding banners in the titlebar diff --git a/docs/src/visual-customization.md b/docs/src/visual-customization.md index cabfe01dc6822ef22b48a4b28cea9843842e7644..6140475eb71294b27807236af075fc3338e316eb 100644 --- a/docs/src/visual-customization.md +++ b/docs/src/visual-customization.md @@ -118,7 +118,7 @@ To disable this behavior use: ```json [settings] // Control which items are shown/hidden in the title bar "title_bar": { - "show_branch_icon": false, // Show/hide branch icon beside branch switcher + "show_branch_status_icon": false, // Show git status on branch icon "show_branch_name": true, // Show/hide branch name "show_project_items": true, // Show/hide project host and name "show_onboarding_banner": true, // Show/hide onboarding banners