From 2107d41ae45304dd8d5177931e37f5492456d4e4 Mon Sep 17 00:00:00 2001 From: Eric Holk Date: Mon, 20 Apr 2026 20:15:32 -0700 Subject: [PATCH] Move the worktree picker to the title bar + make it always visible (#54183) (cherry-pick to preview) (#54378) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cherry-pick of #54183 to `v0.233.x`. This PR makes Zed only have one worktree picker, as opposed to a flavor of it in the title bar and another in the agent panel. It then moves it to the title bar, making it always present, so that its trigger is separate from the branch picker (which now contains only two views: branches and stashes). For the worktree picker, I'm mostly favoring the behavior we've introduced in the agent-panel-flavored version. It also updates the title bar settings migration to use the JSON `migrate_settings` helper instead of a shallow Tree-sitter rewrite, so old `show_branch_icon = true` values are promoted to `show_branch_status_icon = true` across root, platform, release-channel, and profile settings scopes. ### Conflicts resolved - `crates/migrator/src/migrations.rs` and `crates/migrator/src/migrator.rs` — kept both the existing `m_2026_04_15` (HTTP context servers) migration already on `v0.233.x` and the new `m_2026_04_17` (branch icon) migration from this cherry-pick, in order. - `crates/agent_ui/src/agent_panel.rs` — dropped the now-removed `CreateWorktree`, `SwitchWorktree`, `NewWorktreeBranchTarget`, and `ToggleWorktreeSelector` imports while preserving `ShowThreadMetadata` / `ShowAllSidebarThreadMetadata` that landed via the omnibus cherry-pick (#54368). Removed the top-level `anyhow!` import (only used inside `#[cfg(test)]`, which has its own import); kept `chrono` since it's still used by the metadata UI. Release Notes: - Improved migration of the title bar branch status icon setting. Co-authored-by: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Co-authored-by: Nathan Sobo Co-authored-by: Zed Zippy <234243425+zed-zippy[bot]@users.noreply.github.com> Co-authored-by: Ben Brandt --- Cargo.lock | 2 + assets/keymaps/default-linux.json | 23 +- assets/keymaps/default-macos.json | 23 +- assets/keymaps/default-windows.json | 23 +- assets/settings/default.json | 4 +- crates/agent_ui/Cargo.toml | 1 + crates/agent_ui/src/agent_panel.rs | 2757 ++++------------- crates/agent_ui/src/agent_ui.rs | 51 +- crates/agent_ui/src/conversation_view.rs | 7 +- crates/agent_ui/src/thread_worktree_picker.rs | 1036 ------- crates/call/src/call_impl/mod.rs | 2 +- crates/git_ui/Cargo.toml | 2 + crates/git_ui/src/branch_picker.rs | 12 +- crates/git_ui/src/git_picker.rs | 160 +- crates/git_ui/src/git_ui.rs | 62 +- crates/git_ui/src/stash_picker.rs | 12 +- .../src/worktree_names.rs | 0 crates/git_ui/src/worktree_picker.rs | 1685 +++++----- crates/git_ui/src/worktree_service.rs | 809 +++++ crates/migrator/src/migrations.rs | 6 + .../src/migrations/m_2026_04_17/settings.rs | 47 + crates/migrator/src/migrator.rs | 251 ++ .../recent_projects/src/remote_connections.rs | 2 +- crates/recent_projects/src/remote_servers.rs | 2 +- crates/settings_content/src/title_bar.rs | 6 +- crates/settings_ui/src/page_data.rs | 10 +- crates/sidebar/src/sidebar.rs | 21 +- crates/sidebar/src/sidebar_tests.rs | 50 +- crates/title_bar/src/title_bar.rs | 169 +- crates/title_bar/src/title_bar_settings.rs | 4 +- crates/workspace/src/multi_workspace.rs | 89 +- crates/workspace/src/multi_workspace_tests.rs | 2 +- crates/workspace/src/persistence.rs | 8 +- crates/workspace/src/workspace.rs | 107 +- crates/zed/src/visual_test_runner.rs | 6 +- crates/zed/src/zed.rs | 82 +- crates/zed_actions/src/lib.rs | 44 + docs/src/ai/llm-providers.md | 2 +- docs/src/reference/all-settings.md | 4 +- docs/src/visual-customization.md | 2 +- 40 files changed, 3131 insertions(+), 4454 deletions(-) delete mode 100644 crates/agent_ui/src/thread_worktree_picker.rs rename crates/{agent_ui => git_ui}/src/worktree_names.rs (100%) create mode 100644 crates/git_ui/src/worktree_service.rs create mode 100644 crates/migrator/src/migrations/m_2026_04_17/settings.rs 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