diff --git a/Cargo.lock b/Cargo.lock index 99347bd08f0d5b3ae13ab352612e3876a3cf6a11..96caec077edd4bdf8c02a3e1ff1fc10340d2b9b0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -368,6 +368,7 @@ dependencies = [ "fs", "futures 0.3.31", "fuzzy", + "git", "gpui", "gpui_tokio", "html_to_markdown", diff --git a/crates/agent_ui/Cargo.toml b/crates/agent_ui/Cargo.toml index 2a31781054fd29b30a3c8119e87491edbfb1e658..3e46e14b53c46a2aec3ac9552246a10ffc2aeee9 100644 --- a/crates/agent_ui/Cargo.toml +++ b/crates/agent_ui/Cargo.toml @@ -58,6 +58,7 @@ feature_flags.workspace = true file_icons.workspace = true fs.workspace = true futures.workspace = true +git.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 7097e5be156eb33382a1a0f47c1b4256c84ce9b1..c5c1c345318b6f88c59ba2886507324e83d36ad3 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -1,6 +1,6 @@ use std::{ ops::Range, - path::Path, + path::{Path, PathBuf}, rc::Rc, sync::{ Arc, @@ -22,15 +22,18 @@ use project::{ use serde::{Deserialize, Serialize}; use settings::{LanguageModelProviderSetting, LanguageModelSelection}; +use feature_flags::{AgentGitWorktreesFeatureFlag, AgentV2FeatureFlag, FeatureFlagAppExt as _}; use zed_actions::agent::{OpenClaudeAgentOnboardingModal, ReauthenticateAgent, ReviewBranchDiff}; +use crate::ManageProfiles; use crate::ui::{AcpOnboardingModal, ClaudeCodeOnboardingModal}; use crate::{ AddContextServer, AgentDiffPane, ConnectionView, CopyThreadToClipboard, Follow, InlineAssistant, LoadThreadFromClipboard, NewTextThread, NewThread, OpenActiveThreadAsMarkdown, - OpenAgentDiff, OpenHistory, ResetTrialEndUpsell, ResetTrialUpsell, ToggleNavigationMenu, - ToggleNewThreadMenu, ToggleOptionsMenu, + OpenAgentDiff, OpenHistory, ResetTrialEndUpsell, ResetTrialUpsell, StartThreadIn, + ToggleNavigationMenu, ToggleNewThreadMenu, ToggleOptionsMenu, agent_configuration::{AgentConfiguration, AssistantConfigurationEvent}, + connection_view::{AcpThreadViewEvent, ThreadView}, slash_command::SlashCommandCompletionProvider, text_thread_editor::{AgentPanelDelegate, TextThreadEditor, make_lsp_adapter_delegate}, ui::EndTrialUpsell, @@ -42,7 +45,6 @@ use crate::{ ExpandMessageEditor, ThreadHistory, ThreadHistoryEvent, text_thread_history::{TextThreadHistory, TextThreadHistoryEvent}, }; -use crate::{ManageProfiles, connection_view::ThreadView}; use agent_settings::AgentSettings; use ai_onboarding::AgentPanelOnboarding; use anyhow::{Result, anyhow}; @@ -54,6 +56,7 @@ use editor::{Anchor, AnchorRangeExt as _, Editor, EditorEvent, MultiBuffer}; use extension::ExtensionEvents; use extension_host::ExtensionStore; use fs::Fs; +use git::repository::validate_worktree_directory; use gpui::{ Action, Animation, AnimationExt, AnyElement, App, AsyncWindowContext, ClipboardItem, Corner, DismissEvent, Entity, EventEmitter, ExternalPaths, FocusHandle, Focusable, KeyContext, Pixels, @@ -61,15 +64,17 @@ use gpui::{ }; use language::LanguageRegistry; use language_model::{ConfigurationError, LanguageModelRegistry}; +use project::project_settings::ProjectSettings; use project::{Project, ProjectPath, Worktree}; use prompt_store::{PromptBuilder, PromptStore, UserPromptId}; +use rand::Rng as _; use rules_library::{RulesLibrary, open_rules_library}; use search::{BufferSearchBar, buffer_search}; use settings::{Settings, update_settings_file}; use theme::ThemeSettings; use ui::{ - Callout, ContextMenu, ContextMenuEntry, KeyBinding, PopoverMenu, PopoverMenuHandle, Tab, - Tooltip, prelude::*, utils::WithRemSize, + Button, Callout, ContextMenu, ContextMenuEntry, DocumentationSide, KeyBinding, PopoverMenu, + PopoverMenuHandle, SpinnerLabel, Tab, Tooltip, prelude::*, utils::WithRemSize, }; use util::ResultExt as _; use workspace::{ @@ -123,6 +128,8 @@ struct SerializedAgentPanel { selected_agent: Option, #[serde(default)] last_active_thread: Option, + #[serde(default)] + start_thread_in: Option, } #[derive(Serialize, Deserialize, Debug, Clone)] @@ -324,6 +331,13 @@ pub fn init(cx: &mut App) { cx, ); }); + }) + .register_action(|workspace, action: &StartThreadIn, _window, cx| { + if let Some(panel) = workspace.panel::(cx) { + panel.update(cx, |panel, cx| { + panel.set_start_thread_in(action, cx); + }); + } }); }, ) @@ -371,6 +385,10 @@ pub enum AgentType { } impl AgentType { + pub fn is_native(&self) -> bool { + matches!(self, Self::NativeAgent) + } + fn label(&self) -> SharedString { match self { Self::NativeAgent | Self::TextThread => "Zed Agent".into(), @@ -395,6 +413,29 @@ impl From for AgentType { } } +impl StartThreadIn { + fn label(&self) -> SharedString { + match self { + Self::LocalProject => "Local Project".into(), + Self::NewWorktree => "New Worktree".into(), + } + } + + fn icon(&self) -> IconName { + match self { + Self::LocalProject => IconName::Screen, + Self::NewWorktree => IconName::GitBranchPlus, + } + } +} + +#[derive(Clone, Debug)] +#[allow(dead_code)] +pub enum WorktreeCreationStatus { + Creating, + Error(SharedString), +} + impl ActiveView { pub fn which_font_size_used(&self) -> WhichFontSize { match self { @@ -515,6 +556,7 @@ pub struct AgentPanel { previous_view: Option, _active_view_observation: Option, 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>, @@ -525,6 +567,10 @@ pub struct AgentPanel { pending_serialization: Option>>, onboarding: Entity, selected_agent: AgentType, + start_thread_in: StartThreadIn, + worktree_creation_status: Option, + _thread_view_subscription: Option, + _worktree_creation_task: Option>, show_trust_workspace_message: bool, last_configuration_error_telemetry: Option, on_boarding_upsell_dismissed: AtomicBool, @@ -538,6 +584,7 @@ impl AgentPanel { let width = self.width; let selected_agent = self.selected_agent.clone(); + let start_thread_in = Some(self.start_thread_in); let last_active_thread = self.active_agent_thread(cx).map(|thread| { let thread = thread.read(cx); @@ -561,6 +608,7 @@ impl AgentPanel { width, selected_agent: Some(selected_agent), last_active_thread, + start_thread_in, }, ) .await?; @@ -605,6 +653,37 @@ impl AgentPanel { })? .await?; + let last_active_thread = if let Some(thread_info) = serialized_panel + .as_ref() + .and_then(|p| p.last_active_thread.clone()) + { + if thread_info.agent_type.is_native() { + let session_id = acp::SessionId::new(thread_info.session_id.clone()); + let load_result = cx.update(|_window, cx| { + let thread_store = ThreadStore::global(cx); + thread_store.update(cx, |store, cx| store.load_thread(session_id, cx)) + }); + let thread_exists = if let Ok(task) = load_result { + task.await.ok().flatten().is_some() + } else { + false + }; + if thread_exists { + Some(thread_info) + } else { + log::warn!( + "last active thread {} not found in database, skipping restoration", + thread_info.session_id + ); + None + } + } else { + Some(thread_info) + } + } else { + None + }; + let panel = workspace.update_in(cx, |workspace, window, cx| { let panel = cx.new(|cx| Self::new(workspace, text_thread_store, prompt_store, window, cx)); @@ -615,44 +694,45 @@ impl AgentPanel { if let Some(selected_agent) = serialized_panel.selected_agent.clone() { panel.selected_agent = selected_agent; } + if let Some(start_thread_in) = serialized_panel.start_thread_in { + let is_worktree_flag_enabled = + cx.has_flag::(); + let is_valid = match &start_thread_in { + StartThreadIn::LocalProject => true, + StartThreadIn::NewWorktree => { + let project = panel.project.read(cx); + is_worktree_flag_enabled && !project.is_via_collab() + } + }; + if is_valid { + panel.start_thread_in = start_thread_in; + } else { + log::info!( + "deserialized start_thread_in {:?} is no longer valid, falling back to LocalProject", + start_thread_in, + ); + } + } cx.notify(); }); } - panel - })?; - - if let Some(thread_info) = serialized_panel.and_then(|p| p.last_active_thread) { - let session_id = acp::SessionId::new(thread_info.session_id.clone()); - let load_task = panel.update(cx, |panel, cx| { - let thread_store = panel.thread_store.clone(); - thread_store.update(cx, |store, cx| store.load_thread(session_id, cx)) - }); - let thread_exists = load_task - .await - .map(|thread: Option| thread.is_some()) - .unwrap_or(false); - - if thread_exists { - panel.update_in(cx, |panel, window, cx| { - panel.selected_agent = thread_info.agent_type.clone(); - let session_info = AgentSessionInfo { - session_id: acp::SessionId::new(thread_info.session_id), - cwd: thread_info.cwd, - title: thread_info.title.map(SharedString::from), - updated_at: None, - meta: None, - }; + if let Some(thread_info) = last_active_thread { + let agent_type = thread_info.agent_type.clone(); + let session_info = AgentSessionInfo { + session_id: acp::SessionId::new(thread_info.session_id), + cwd: thread_info.cwd, + title: thread_info.title.map(SharedString::from), + updated_at: None, + meta: None, + }; + panel.update(cx, |panel, cx| { + panel.selected_agent = agent_type; panel.load_agent_thread(session_info, window, cx); - })?; - } else { - log::error!( - "could not restore last active thread: \ - no thread found in database with ID {:?}", - thread_info.session_id - ); + }); } - } + panel + })?; Ok(panel) }) @@ -800,6 +880,7 @@ impl AgentPanel { previous_view: None, _active_view_observation: None, 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, @@ -813,6 +894,10 @@ impl AgentPanel { text_thread_history, thread_store, selected_agent: AgentType::default(), + start_thread_in: StartThreadIn::default(), + worktree_creation_status: None, + _thread_view_subscription: None, + _worktree_creation_task: None, show_trust_workspace_message: false, last_configuration_error_telemetry: None, on_boarding_upsell_dismissed: AtomicBool::new(OnboardingUpsell::dismissed()), @@ -1044,7 +1129,7 @@ impl AgentPanel { let server = ext_agent.server(fs, thread_store); this.update_in(cx, |agent_panel, window, cx| { - agent_panel._external_thread( + agent_panel.create_external_thread( server, resume_thread, initial_content, @@ -1618,15 +1703,28 @@ impl AgentPanel { self.active_view = new_view; } + // Subscribe to the active ThreadView's events (e.g. FirstSendRequested) + // so the panel can intercept the first send for worktree creation. + // Re-subscribe whenever the ConnectionView changes, since the inner + // ThreadView may have been replaced (e.g. navigating between threads). self._active_view_observation = match &self.active_view { ActiveView::AgentThread { server_view } => { - Some(cx.observe(server_view, |this, _, cx| { - cx.emit(AgentPanelEvent::ActiveViewChanged); - this.serialize(cx); - cx.notify(); - })) + self._thread_view_subscription = + Self::subscribe_to_active_thread_view(server_view, window, cx); + Some( + cx.observe_in(server_view, window, |this, server_view, window, cx| { + this._thread_view_subscription = + Self::subscribe_to_active_thread_view(&server_view, window, cx); + cx.emit(AgentPanelEvent::ActiveViewChanged); + this.serialize(cx); + cx.notify(); + }), + ) + } + _ => { + self._thread_view_subscription = None; + None } - _ => None, }; let is_in_agent_history = matches!( @@ -1740,6 +1838,56 @@ impl AgentPanel { self.selected_agent.clone() } + fn subscribe_to_active_thread_view( + server_view: &Entity, + window: &mut Window, + cx: &mut Context, + ) -> Option { + server_view.read(cx).active_thread().cloned().map(|tv| { + cx.subscribe_in( + &tv, + window, + |this, view, event: &AcpThreadViewEvent, window, cx| match event { + AcpThreadViewEvent::FirstSendRequested { content } => { + this.handle_first_send_requested(view.clone(), content.clone(), window, cx); + } + }, + ) + }) + } + + pub fn start_thread_in(&self) -> &StartThreadIn { + &self.start_thread_in + } + + fn set_start_thread_in(&mut self, action: &StartThreadIn, cx: &mut Context) { + if matches!(action, StartThreadIn::NewWorktree) + && !cx.has_flag::() + { + return; + } + + let new_target = match *action { + StartThreadIn::LocalProject => StartThreadIn::LocalProject, + StartThreadIn::NewWorktree => { + if !self.project_has_git_repository(cx) { + log::error!( + "set_start_thread_in: cannot use NewWorktree without a git repository" + ); + return; + } + if self.project.read(cx).is_via_collab() { + log::error!("set_start_thread_in: cannot use NewWorktree in a collab project"); + return; + } + StartThreadIn::NewWorktree + } + }; + self.start_thread_in = new_target; + self.serialize(cx); + cx.notify(); + } + fn selected_external_agent(&self) -> Option { match &self.selected_agent { AgentType::NativeAgent => Some(ExternalAgent::NativeAgent), @@ -1830,7 +1978,7 @@ impl AgentPanel { self.external_thread(Some(agent), Some(thread), None, window, cx); } - fn _external_thread( + pub(crate) fn create_external_thread( &mut self, server: Rc, resume_thread: Option, @@ -1869,140 +2017,616 @@ impl AgentPanel { self.set_active_view(ActiveView::AgentThread { server_view }, true, window, cx); } -} -impl Focusable for AgentPanel { - fn focus_handle(&self, cx: &App) -> FocusHandle { - match &self.active_view { - ActiveView::Uninitialized => self.focus_handle.clone(), - ActiveView::AgentThread { server_view, .. } => server_view.focus_handle(cx), - ActiveView::History { kind } => match kind { - HistoryKind::AgentThreads => self.acp_history.focus_handle(cx), - HistoryKind::TextThreads => self.text_thread_history.focus_handle(cx), - }, - ActiveView::TextThread { - text_thread_editor, .. - } => text_thread_editor.focus_handle(cx), - ActiveView::Configuration => { - if let Some(configuration) = self.configuration.as_ref() { - configuration.focus_handle(cx) - } else { - self.focus_handle.clone() - } - } + fn active_thread_has_messages(&self, cx: &App) -> bool { + self.active_agent_thread(cx) + .is_some_and(|thread| !thread.read(cx).entries().is_empty()) + } + + fn handle_first_send_requested( + &mut self, + thread_view: Entity, + content: Vec, + window: &mut Window, + cx: &mut Context, + ) { + if self.start_thread_in == StartThreadIn::NewWorktree { + self.handle_worktree_creation_requested(content, window, cx); + } else { + cx.defer_in(window, move |_this, window, cx| { + thread_view.update(cx, |thread_view, cx| { + let editor = thread_view.message_editor.clone(); + thread_view.send_impl(editor, window, cx); + }); + }); } } -} -fn agent_panel_dock_position(cx: &App) -> DockPosition { - AgentSettings::get_global(cx).dock.into() -} + fn generate_agent_branch_name() -> String { + let mut rng = rand::rng(); + let id: String = (0..8) + .map(|_| { + let idx: u8 = rng.random_range(0..36); + if idx < 10 { + (b'0' + idx) as char + } else { + (b'a' + idx - 10) as char + } + }) + .collect(); + format!("agent-{id}") + } -pub enum AgentPanelEvent { - ActiveViewChanged, -} + /// 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()) + || work_dir.starts_with(wt_path.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)) + }, + ); -impl EventEmitter for AgentPanel {} -impl EventEmitter for AgentPanel {} + 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()); + } + } -impl Panel for AgentPanel { - fn persistent_name() -> &'static str { - "AgentPanel" + (git_repos, non_git_paths) } - 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], + branch_name: &str, + worktree_directory_setting: &str, + 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(); + + for repo in git_repos { + let (work_dir, new_path, receiver) = repo.update(cx, |repo, _cx| { + let original_repo = repo.original_repo_abs_path.clone(); + let directory = + validate_worktree_directory(&original_repo, worktree_directory_setting)?; + let new_path = directory.join(branch_name); + let receiver = repo.create_worktree(branch_name.to_string(), directory, None); + 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)) } - fn position_is_valid(&self, position: DockPosition) -> bool { - position != DockPosition::Bottom - } + /// 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>, + )>, + 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 { + match receiver.await { + Ok(Ok(())) => { + created_paths.push(new_path.clone()); + repos_and_paths.push((repo, 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 set_position(&mut self, position: DockPosition, _: &mut Window, cx: &mut Context) { - settings::update_settings_file(self.fs.clone(), cx, move |settings, _| { - settings - .agent - .get_or_insert_default() - .set_dock(position.into()); - }); - } + let Some(err) = first_error else { + return Ok(created_paths); + }; - fn size(&self, window: &Window, cx: &App) -> Pixels { - let settings = AgentSettings::get_global(cx); - match self.position(window, cx) { - DockPosition::Left | DockPosition::Right => { - self.width.unwrap_or(settings.default_width) + // Rollback all successfully created worktrees + let mut rollback_receivers = Vec::new(); + for (rollback_repo, rollback_path) in &repos_and_paths { + if let Ok(receiver) = cx.update(|_, cx| { + rollback_repo.update(cx, |repo, _cx| { + repo.remove_worktree(rollback_path.clone(), true) + }) + }) { + rollback_receivers.push((rollback_path.clone(), receiver)); } - DockPosition::Bottom => self.height.unwrap_or(settings.default_height), } - } - - fn set_size(&mut self, size: Option, window: &mut Window, cx: &mut Context) { - match self.position(window, cx) { - DockPosition::Left | DockPosition::Right => self.width = size, - DockPosition::Bottom => self.height = size, + let mut rollback_failures: Vec = Vec::new(); + for (path, receiver) in rollback_receivers { + match receiver.await { + Ok(Ok(())) => {} + Ok(Err(rollback_err)) => { + log::error!( + "failed to rollback worktree at {}: {rollback_err}", + path.display() + ); + rollback_failures.push(format!("{}: {rollback_err}", path.display())); + } + Err(rollback_err) => { + log::error!( + "failed to rollback worktree at {}: {rollback_err}", + path.display() + ); + rollback_failures.push(format!("{}: {rollback_err}", path.display())); + } + } } - self.serialize(cx); - cx.notify(); + 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)) } - fn set_active(&mut self, active: bool, window: &mut Window, cx: &mut Context) { - if active && matches!(self.active_view, ActiveView::Uninitialized) { + fn set_worktree_creation_error( + &mut self, + message: SharedString, + window: &mut Window, + cx: &mut Context, + ) { + self.worktree_creation_status = Some(WorktreeCreationStatus::Error(message)); + if matches!(self.active_view, ActiveView::Uninitialized) { let selected_agent = self.selected_agent.clone(); self.new_agent_thread(selected_agent, window, cx); } + cx.notify(); } - fn remote_id() -> Option { - Some(proto::PanelId::AssistantPanel) - } + fn handle_worktree_creation_requested( + &mut self, + content: Vec, + window: &mut Window, + cx: &mut Context, + ) { + if matches!( + self.worktree_creation_status, + Some(WorktreeCreationStatus::Creating) + ) { + return; + } - fn icon(&self, _window: &Window, cx: &App) -> Option { - (self.enabled(cx) && AgentSettings::get_global(cx).button).then_some(IconName::ZedAssistant) - } + self.worktree_creation_status = Some(WorktreeCreationStatus::Creating); + cx.notify(); - fn icon_tooltip(&self, _window: &Window, _cx: &App) -> Option<&'static str> { - Some("Agent Panel") - } + let branch_name = Self::generate_agent_branch_name(); - fn toggle_action(&self) -> Box { - Box::new(ToggleFocus) - } + let (git_repos, non_git_paths) = self.classify_worktrees(cx); - fn activation_priority(&self) -> u32 { - 3 - } + if git_repos.is_empty() { + self.set_worktree_creation_error( + "No git repositories found in the project".into(), + window, + cx, + ); + return; + } - fn enabled(&self, cx: &App) -> bool { - AgentSettings::get_global(cx).enabled(cx) - } + let worktree_directory_setting = ProjectSettings::get_global(cx) + .git + .worktree_directory + .clone(); - fn is_zoomed(&self, _window: &Window, _cx: &App) -> bool { - self.zoomed - } + let (creation_infos, path_remapping) = match Self::start_worktree_creations( + &git_repos, + &branch_name, + &worktree_directory_setting, + cx, + ) { + Ok(result) => result, + Err(err) => { + self.set_worktree_creation_error( + format!("Failed to validate worktree directory: {err}").into(), + window, + cx, + ); + return; + } + }; - fn set_zoomed(&mut self, zoomed: bool, _window: &mut Window, cx: &mut Context) { - self.zoomed = zoomed; - cx.notify(); - } -} + let (dock_structure, open_file_paths) = self + .workspace + .upgrade() + .map(|workspace| { + let dock_structure = workspace.read(cx).capture_dock_state(window, cx); + let open_file_paths = workspace.read(cx).open_item_abs_paths(cx); + (dock_structure, open_file_paths) + }) + .unwrap_or_default(); -impl AgentPanel { - fn render_title_view(&self, _window: &mut Window, cx: &Context) -> AnyElement { - const LOADING_SUMMARY_PLACEHOLDER: &str = "Loading Summary…"; + let workspace = self.workspace.clone(); + let window_handle = window + .window_handle() + .downcast::(); - let content = match &self.active_view { - ActiveView::AgentThread { server_view } => { - let is_generating_title = server_view - .read(cx) - .as_native_thread(cx) - .map_or(false, |t| t.read(cx).is_generating_title()); + let task = cx.spawn_in(window, async move |this, cx| { + let created_paths = match Self::await_and_rollback_on_failure(creation_infos, 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(title_editor) = server_view + let mut all_paths = created_paths; + let has_non_git = !non_git_paths.is_empty(); + all_paths.extend(non_git_paths.iter().cloned()); + + let app_state = match workspace.upgrade() { + Some(workspace) => cx.update(|_, cx| workspace.read(cx).app_state().clone())?, + 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::setup_new_workspace( + this, + all_paths, + app_state, + window_handle, + dock_structure, + open_file_paths, + path_remapping, + non_git_paths, + has_non_git, + content, + 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.foreground_executor().spawn(async move { + task.await.log_err(); + })); + } + + async fn setup_new_workspace( + this: WeakEntity, + all_paths: Vec, + app_state: Arc, + window_handle: Option>, + dock_structure: workspace::DockStructure, + open_file_paths: Vec, + path_remapping: Vec<(PathBuf, PathBuf)>, + non_git_paths: Vec, + has_non_git: bool, + content: Vec, + cx: &mut AsyncWindowContext, + ) -> Result<()> { + let init: Option< + Box) + Send>, + > = Some(Box::new(move |workspace, window, cx| { + workspace.set_dock_structure(dock_structure, window, cx); + })); + + let (new_window_handle, _) = cx + .update(|_window, cx| { + Workspace::new_local(all_paths, app_state, window_handle, None, init, false, cx) + })? + .await?; + + let new_workspace = new_window_handle.update(cx, |multi_workspace, _window, _cx| { + let workspaces = multi_workspace.workspaces(); + workspaces.last().cloned() + })?; + + let Some(new_workspace) = new_workspace else { + anyhow::bail!("New workspace was not added to MultiWorkspace"); + }; + + let panels_task = new_window_handle.update(cx, |_, _, cx| { + new_workspace.update(cx, |workspace, _cx| workspace.take_panels_task()) + })?; + if let Some(task) = panels_task { + task.await.log_err(); + } + + let initial_content = AgentInitialContent::ContentBlock { + blocks: content, + auto_submit: true, + }; + + new_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, + ); + } + + let remapped_paths: Vec = open_file_paths + .iter() + .filter_map(|original_path| { + 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.clone()); + } + } + None + }) + .collect(); + + if !remapped_paths.is_empty() { + workspace + .open_paths( + remapped_paths, + workspace::OpenOptions::default(), + None, + window, + cx, + ) + .detach(); + } + + workspace.focus_panel::(window, cx); + if let Some(panel) = workspace.panel::(cx) { + panel.update(cx, |panel, cx| { + panel.external_thread(None, None, Some(initial_content), window, cx); + }); + } + }); + })?; + + new_window_handle.update(cx, |multi_workspace, _window, cx| { + multi_workspace.activate(new_workspace.clone(), cx); + })?; + + this.update_in(cx, |this, _window, cx| { + this.worktree_creation_status = None; + cx.notify(); + })?; + + anyhow::Ok(()) + } +} + +impl Focusable for AgentPanel { + fn focus_handle(&self, cx: &App) -> FocusHandle { + match &self.active_view { + ActiveView::Uninitialized => self.focus_handle.clone(), + ActiveView::AgentThread { server_view, .. } => server_view.focus_handle(cx), + ActiveView::History { kind } => match kind { + HistoryKind::AgentThreads => self.acp_history.focus_handle(cx), + HistoryKind::TextThreads => self.text_thread_history.focus_handle(cx), + }, + ActiveView::TextThread { + text_thread_editor, .. + } => text_thread_editor.focus_handle(cx), + ActiveView::Configuration => { + if let Some(configuration) = self.configuration.as_ref() { + 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, +} + +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 set_position(&mut self, position: DockPosition, _: &mut Window, cx: &mut Context) { + settings::update_settings_file(self.fs.clone(), cx, move |settings, _| { + settings + .agent + .get_or_insert_default() + .set_dock(position.into()); + }); + } + + fn size(&self, window: &Window, cx: &App) -> Pixels { + let settings = AgentSettings::get_global(cx); + match self.position(window, cx) { + DockPosition::Left | DockPosition::Right => { + self.width.unwrap_or(settings.default_width) + } + DockPosition::Bottom => self.height.unwrap_or(settings.default_height), + } + } + + fn set_size(&mut self, size: Option, window: &mut Window, cx: &mut Context) { + match self.position(window, cx) { + DockPosition::Left | DockPosition::Right => self.width = size, + DockPosition::Bottom => self.height = size, + } + self.serialize(cx); + cx.notify(); + } + + fn set_active(&mut self, active: bool, window: &mut Window, cx: &mut Context) { + if active + && matches!(self.active_view, ActiveView::Uninitialized) + && !matches!( + self.worktree_creation_status, + Some(WorktreeCreationStatus::Creating) + ) + { + let selected_agent = self.selected_agent.clone(); + self.new_agent_thread(selected_agent, window, cx); + } + } + + 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 { + 3 + } + + fn enabled(&self, cx: &App) -> bool { + AgentSettings::get_global(cx).enabled(cx) + } + + 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 render_title_view(&self, _window: &mut Window, cx: &Context) -> AnyElement { + const LOADING_SUMMARY_PLACEHOLDER: &str = "Loading Summary…"; + + let content = match &self.active_view { + ActiveView::AgentThread { server_view } => { + let is_generating_title = server_view + .read(cx) + .as_native_thread(cx) + .map_or(false, |t| t.read(cx).is_generating_title()); + + if let Some(title_editor) = server_view .read(cx) .parent_thread(cx) .map(|r| r.read(cx).title_editor.clone()) @@ -2331,6 +2955,99 @@ impl AgentPanel { }) } + fn project_has_git_repository(&self, cx: &App) -> bool { + !self.project.read(cx).repositories(cx).is_empty() + } + + fn render_start_thread_in_selector(&self, cx: &mut Context) -> impl IntoElement { + let has_git_repo = self.project_has_git_repository(cx); + let is_via_collab = self.project.read(cx).is_via_collab(); + + let is_creating = matches!( + self.worktree_creation_status, + Some(WorktreeCreationStatus::Creating) + ); + + let current_target = self.start_thread_in; + let trigger_label = self.start_thread_in.label(); + + let icon = if self.start_thread_in_menu_handle.is_deployed() { + IconName::ChevronUp + } else { + IconName::ChevronDown + }; + + let trigger_button = Button::new("thread-target-trigger", trigger_label) + .label_size(LabelSize::Small) + .color(Color::Muted) + .icon(icon) + .icon_size(IconSize::XSmall) + .icon_position(IconPosition::End) + .icon_color(Color::Muted) + .disabled(is_creating); + + let dock_position = AgentSettings::get_global(cx).dock; + let documentation_side = match dock_position { + settings::DockPosition::Left => DocumentationSide::Right, + settings::DockPosition::Bottom | settings::DockPosition::Right => { + DocumentationSide::Left + } + }; + + PopoverMenu::new("thread-target-selector") + .trigger(trigger_button) + .anchor(gpui::Corner::BottomRight) + .with_handle(self.start_thread_in_menu_handle.clone()) + .menu(move |window, cx| { + let current_target = current_target; + Some(ContextMenu::build(window, cx, move |menu, _window, _cx| { + let is_local_selected = current_target == StartThreadIn::LocalProject; + let is_new_worktree_selected = current_target == StartThreadIn::NewWorktree; + + let new_worktree_disabled = !has_git_repo || is_via_collab; + + menu.header("Start Thread In…") + .item( + ContextMenuEntry::new("Local Project") + .icon(StartThreadIn::LocalProject.icon()) + .icon_color(Color::Muted) + .toggleable(IconPosition::End, is_local_selected) + .handler(|window, cx| { + window + .dispatch_action(Box::new(StartThreadIn::LocalProject), cx); + }), + ) + .item({ + let entry = ContextMenuEntry::new("New Worktree") + .icon(StartThreadIn::NewWorktree.icon()) + .icon_color(Color::Muted) + .toggleable(IconPosition::End, is_new_worktree_selected) + .disabled(new_worktree_disabled) + .handler(|window, cx| { + window + .dispatch_action(Box::new(StartThreadIn::NewWorktree), cx); + }); + + if new_worktree_disabled { + entry.documentation_aside(documentation_side, move |_| { + let reason = if !has_git_repo { + "No git repository found in this project." + } else { + "Not available for remote/collab projects yet." + }; + Label::new(reason) + .color(Color::Muted) + .size(LabelSize::Small) + .into_any_element() + }) + } else { + entry + } + }) + })) + }) + } + 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 focus_handle = self.focus_handle(cx); @@ -2718,6 +3435,7 @@ impl AgentPanel { }; let show_history_menu = self.history_kind_for_selected_agent(cx).is_some(); + let has_v2_flag = cx.has_flag::(); h_flex() .id("agent-panel-toolbar") @@ -2748,6 +3466,12 @@ impl AgentPanel { .gap(DynamicSpacing::Base02.rems(cx)) .pl(DynamicSpacing::Base04.rems(cx)) .pr(DynamicSpacing::Base06.rems(cx)) + .when( + has_v2_flag + && cx.has_flag::() + && !self.active_thread_has_messages(cx), + |this| this.child(self.render_start_thread_in_selector(cx)), + ) .child(new_thread_menu) .when(show_history_menu, |this| { this.child(self.render_recent_entries_menu( @@ -2760,6 +3484,51 @@ impl AgentPanel { ) } + fn render_worktree_creation_status(&self, cx: &mut Context) -> Option { + let status = self.worktree_creation_status.as_ref()?; + match status { + WorktreeCreationStatus::Creating => Some( + h_flex() + .w_full() + .px(DynamicSpacing::Base06.rems(cx)) + .py(DynamicSpacing::Base02.rems(cx)) + .gap_2() + .bg(cx.theme().colors().surface_background) + .border_b_1() + .border_color(cx.theme().colors().border) + .child(SpinnerLabel::new().size(LabelSize::Small)) + .child( + Label::new("Creating worktree…") + .color(Color::Muted) + .size(LabelSize::Small), + ) + .into_any_element(), + ), + WorktreeCreationStatus::Error(message) => Some( + h_flex() + .w_full() + .px(DynamicSpacing::Base06.rems(cx)) + .py(DynamicSpacing::Base02.rems(cx)) + .gap_2() + .bg(cx.theme().colors().surface_background) + .border_b_1() + .border_color(cx.theme().colors().border) + .child( + Icon::new(IconName::Warning) + .size(IconSize::Small) + .color(Color::Warning), + ) + .child( + Label::new(message.clone()) + .color(Color::Warning) + .size(LabelSize::Small) + .truncate(), + ) + .into_any_element(), + ), + } + } + fn should_render_trial_end_upsell(&self, cx: &mut Context) -> bool { if TrialEndUpsell::dismissed() { return false; @@ -3191,6 +3960,7 @@ impl Render for AgentPanel { } })) .child(self.render_toolbar(window, cx)) + .children(self.render_worktree_creation_status(cx)) .children(self.render_workspace_trust_message(cx)) .children(self.render_onboarding(window, cx)) .map(|parent| { @@ -3456,7 +4226,7 @@ impl AgentPanel { name: server.name(), }; - self._external_thread( + self.create_external_thread( server, None, None, workspace, project, ext_agent, window, cx, ); } @@ -3468,6 +4238,61 @@ impl AgentPanel { pub fn active_thread_view_for_tests(&self) -> Option<&Entity> { self.active_thread_view() } + + /// Sets the start_thread_in value directly, bypassing validation. + /// + /// This is a test-only helper for visual tests that need to show specific + /// start_thread_in states without requiring a real git repository. + pub fn set_start_thread_in_for_tests(&mut self, target: StartThreadIn, cx: &mut Context) { + self.start_thread_in = target; + cx.notify(); + } + + /// Returns the current worktree creation status. + /// + /// This is a test-only helper for visual tests. + pub fn worktree_creation_status_for_tests(&self) -> Option<&WorktreeCreationStatus> { + self.worktree_creation_status.as_ref() + } + + /// Sets the worktree creation status directly. + /// + /// This is a test-only helper for visual tests that need to show the + /// "Creating worktree…" spinner or error banners. + pub fn set_worktree_creation_status_for_tests( + &mut self, + status: Option, + cx: &mut Context, + ) { + self.worktree_creation_status = status; + cx.notify(); + } + + /// Opens the history view. + /// + /// 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); + } } #[cfg(test)] @@ -3479,6 +4304,7 @@ mod tests { use fs::FakeFs; use gpui::{TestAppContext, VisualTestContext}; use project::Project; + use serde_json::json; use workspace::MultiWorkspace; #[gpui::test] @@ -3581,9 +4407,7 @@ mod tests { .expect("panel B load should succeed"); cx.run_until_parked(); - // Workspace A should restore width and agent type, but the thread - // should NOT be restored because the stub agent never persisted it - // to the database (the load-side validation skips missing threads). + // Workspace A should restore its thread, width, and agent type loaded_a.read_with(cx, |panel, _cx| { assert_eq!( panel.width, @@ -3594,6 +4418,10 @@ mod tests { panel.selected_agent, agent_type_a, "workspace A agent type should be restored" ); + assert!( + panel.active_thread_view().is_some(), + "workspace A should have its active thread restored" + ); }); // Workspace B should restore its own width and agent type, with no thread @@ -3663,4 +4491,383 @@ mod tests { cx.run_until_parked(); } + + #[gpui::test] + async fn test_thread_target_local_project(cx: &mut TestAppContext) { + init_test(cx); + cx.update(|cx| { + cx.update_flags(true, vec!["agent-v2".to_string()]); + agent::ThreadStore::init_global(cx); + language_model::LanguageModelRegistry::test(cx); + }); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + "/project", + json!({ + ".git": {}, + "src": { + "main.rs": "fn main() {}" + } + }), + ) + .await; + fs.set_branch_name(Path::new("/project/.git"), Some("main")); + + let project = Project::test(fs.clone(), [Path::new("/project")], cx).await; + + let multi_workspace = + cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + + let workspace = multi_workspace + .read_with(cx, |multi_workspace, _cx| { + multi_workspace.workspace().clone() + }) + .unwrap(); + + workspace.update(cx, |workspace, _cx| { + workspace.set_random_database_id(); + }); + + let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx); + + // Wait for the project to discover the git repository. + cx.run_until_parked(); + + let panel = workspace.update_in(cx, |workspace, window, cx| { + let text_thread_store = cx.new(|cx| TextThreadStore::fake(project.clone(), cx)); + let panel = + cx.new(|cx| AgentPanel::new(workspace, text_thread_store, None, window, cx)); + workspace.add_panel(panel.clone(), window, cx); + panel + }); + + cx.run_until_parked(); + + // Default thread target should be LocalProject. + panel.read_with(cx, |panel, _cx| { + assert_eq!( + *panel.start_thread_in(), + StartThreadIn::LocalProject, + "default thread target should be LocalProject" + ); + }); + + // Start a new thread with the default LocalProject target. + // Use StubAgentServer so the thread connects immediately in tests. + panel.update_in(cx, |panel, window, cx| { + panel.open_external_thread_with_server( + Rc::new(StubAgentServer::default_response()), + window, + cx, + ); + }); + + cx.run_until_parked(); + + // MultiWorkspace should still have exactly one workspace (no worktree created). + multi_workspace + .read_with(cx, |multi_workspace, _cx| { + assert_eq!( + multi_workspace.workspaces().len(), + 1, + "LocalProject should not create a new workspace" + ); + }) + .unwrap(); + + // The thread should be active in the panel. + panel.read_with(cx, |panel, cx| { + assert!( + panel.active_agent_thread(cx).is_some(), + "a thread should be running in the current workspace" + ); + }); + + // The thread target should still be LocalProject (unchanged). + panel.read_with(cx, |panel, _cx| { + assert_eq!( + *panel.start_thread_in(), + StartThreadIn::LocalProject, + "thread target should remain LocalProject" + ); + }); + + // No worktree creation status should be set. + panel.read_with(cx, |panel, _cx| { + assert!( + panel.worktree_creation_status.is_none(), + "no worktree creation should have occurred" + ); + }); + } + + #[gpui::test] + async fn test_thread_target_serialization_round_trip(cx: &mut TestAppContext) { + init_test(cx); + cx.update(|cx| { + cx.update_flags( + true, + vec!["agent-v2".to_string(), "agent-git-worktrees".to_string()], + ); + agent::ThreadStore::init_global(cx); + language_model::LanguageModelRegistry::test(cx); + }); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + "/project", + json!({ + ".git": {}, + "src": { + "main.rs": "fn main() {}" + } + }), + ) + .await; + fs.set_branch_name(Path::new("/project/.git"), Some("main")); + + let project = Project::test(fs.clone(), [Path::new("/project")], cx).await; + + let multi_workspace = + cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + + let workspace = multi_workspace + .read_with(cx, |multi_workspace, _cx| { + multi_workspace.workspace().clone() + }) + .unwrap(); + + workspace.update(cx, |workspace, _cx| { + workspace.set_random_database_id(); + }); + + let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx); + + // Wait for the project to discover the git repository. + cx.run_until_parked(); + + let panel = workspace.update_in(cx, |workspace, window, cx| { + let text_thread_store = cx.new(|cx| TextThreadStore::fake(project.clone(), cx)); + let panel = + cx.new(|cx| AgentPanel::new(workspace, text_thread_store, None, window, cx)); + workspace.add_panel(panel.clone(), window, cx); + panel + }); + + cx.run_until_parked(); + + // Default should be LocalProject. + panel.read_with(cx, |panel, _cx| { + assert_eq!(*panel.start_thread_in(), StartThreadIn::LocalProject); + }); + + // Change thread target to NewWorktree. + panel.update(cx, |panel, cx| { + panel.set_start_thread_in(&StartThreadIn::NewWorktree, cx); + }); + + panel.read_with(cx, |panel, _cx| { + assert_eq!( + *panel.start_thread_in(), + StartThreadIn::NewWorktree, + "thread target should be NewWorktree after set_thread_target" + ); + }); + + // Let serialization complete. + cx.run_until_parked(); + + // Load a fresh panel from the serialized data. + let prompt_builder = Arc::new(prompt_store::PromptBuilder::new(None).unwrap()); + let async_cx = cx.update(|window, cx| window.to_async(cx)); + let loaded_panel = + AgentPanel::load(workspace.downgrade(), prompt_builder.clone(), async_cx) + .await + .expect("panel load should succeed"); + cx.run_until_parked(); + + loaded_panel.read_with(cx, |panel, _cx| { + assert_eq!( + *panel.start_thread_in(), + StartThreadIn::NewWorktree, + "thread target should survive serialization round-trip" + ); + }); + } + + #[gpui::test] + async fn test_thread_target_deserialization_falls_back_when_worktree_flag_disabled( + cx: &mut TestAppContext, + ) { + init_test(cx); + cx.update(|cx| { + cx.update_flags( + true, + vec!["agent-v2".to_string(), "agent-git-worktrees".to_string()], + ); + agent::ThreadStore::init_global(cx); + language_model::LanguageModelRegistry::test(cx); + }); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + "/project", + json!({ + ".git": {}, + "src": { + "main.rs": "fn main() {}" + } + }), + ) + .await; + fs.set_branch_name(Path::new("/project/.git"), Some("main")); + + let project = Project::test(fs.clone(), [Path::new("/project")], cx).await; + + let multi_workspace = + cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + + let workspace = multi_workspace + .read_with(cx, |multi_workspace, _cx| { + multi_workspace.workspace().clone() + }) + .unwrap(); + + workspace.update(cx, |workspace, _cx| { + workspace.set_random_database_id(); + }); + + let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx); + + // Wait for the project to discover the git repository. + cx.run_until_parked(); + + let panel = workspace.update_in(cx, |workspace, window, cx| { + let text_thread_store = cx.new(|cx| TextThreadStore::fake(project.clone(), cx)); + let panel = + cx.new(|cx| AgentPanel::new(workspace, text_thread_store, None, window, cx)); + workspace.add_panel(panel.clone(), window, cx); + panel + }); + + cx.run_until_parked(); + + panel.update(cx, |panel, cx| { + panel.set_start_thread_in(&StartThreadIn::NewWorktree, cx); + }); + + panel.read_with(cx, |panel, _cx| { + assert_eq!( + *panel.start_thread_in(), + StartThreadIn::NewWorktree, + "thread target should be NewWorktree before reload" + ); + }); + + // Let serialization complete. + cx.run_until_parked(); + + // Disable worktree flag and reload panel from serialized data. + cx.update(|_, cx| { + cx.update_flags(true, vec!["agent-v2".to_string()]); + }); + + let prompt_builder = Arc::new(prompt_store::PromptBuilder::new(None).unwrap()); + let async_cx = cx.update(|window, cx| window.to_async(cx)); + let loaded_panel = + AgentPanel::load(workspace.downgrade(), prompt_builder.clone(), async_cx) + .await + .expect("panel load should succeed"); + cx.run_until_parked(); + + loaded_panel.read_with(cx, |panel, _cx| { + assert_eq!( + *panel.start_thread_in(), + StartThreadIn::LocalProject, + "thread target should fall back to LocalProject when worktree flag is disabled" + ); + }); + } + + #[gpui::test] + async fn test_set_active_blocked_during_worktree_creation(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + cx.update(|cx| { + 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; + + let multi_workspace = + cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + + let workspace = multi_workspace + .read_with(cx, |multi_workspace, _cx| { + multi_workspace.workspace().clone() + }) + .unwrap(); + + let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx); + + let panel = workspace.update_in(cx, |workspace, window, cx| { + let text_thread_store = cx.new(|cx| TextThreadStore::fake(project.clone(), cx)); + let panel = + cx.new(|cx| AgentPanel::new(workspace, text_thread_store, None, window, cx)); + workspace.add_panel(panel.clone(), window, cx); + panel + }); + + cx.run_until_parked(); + + // Simulate worktree creation in progress and reset to Uninitialized + panel.update_in(cx, |panel, window, cx| { + panel.worktree_creation_status = Some(WorktreeCreationStatus::Creating); + panel.active_view = ActiveView::Uninitialized; + Panel::set_active(panel, true, window, cx); + assert!( + matches!(panel.active_view, ActiveView::Uninitialized), + "set_active should not create a thread while worktree is being created" + ); + }); + + // Clear the creation status and use open_external_thread_with_server + // (which bypasses new_agent_thread) to verify the panel can transition + // out of Uninitialized. We can't call set_active directly because + // new_agent_thread requires full agent server infrastructure. + panel.update_in(cx, |panel, window, cx| { + panel.worktree_creation_status = None; + panel.active_view = ActiveView::Uninitialized; + panel.open_external_thread_with_server( + Rc::new(StubAgentServer::default_response()), + window, + cx, + ); + }); + + cx.run_until_parked(); + + panel.read_with(cx, |panel, _cx| { + assert!( + !matches!(panel.active_view, ActiveView::Uninitialized), + "panel should transition out of Uninitialized once worktree creation is cleared" + ); + }); + } } diff --git a/crates/agent_ui/src/agent_ui.rs b/crates/agent_ui/src/agent_ui.rs index ad778ca496f7815d0155f98187c8fad3e81365eb..58a8edca779daa50862549058a0068e2ddb7c5bf 100644 --- a/crates/agent_ui/src/agent_ui.rs +++ b/crates/agent_ui/src/agent_ui.rs @@ -55,7 +55,9 @@ use std::any::TypeId; use workspace::Workspace; use crate::agent_configuration::{ConfigureContextServerModal, ManageProfilesModal}; -pub use crate::agent_panel::{AgentPanel, AgentPanelEvent, ConcreteAssistantPanelDelegate}; +pub use crate::agent_panel::{ + AgentPanel, AgentPanelEvent, ConcreteAssistantPanelDelegate, WorktreeCreationStatus, +}; use crate::agent_registry_ui::AgentRegistryPage; pub use crate::inline_assistant::InlineAssistant; pub use agent_diff::{AgentDiffPane, AgentDiffToolbar}; @@ -222,6 +224,18 @@ impl ExternalAgent { } } +/// Sets where new threads will run. +#[derive( + Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Action, +)] +#[action(namespace = agent)] +#[serde(rename_all = "snake_case", tag = "kind")] +pub enum StartThreadIn { + #[default] + LocalProject, + NewWorktree, +} + /// Content to initialize new external agent with. pub enum AgentInitialContent { ThreadSummary(acp_thread::AgentSessionInfo), diff --git a/crates/agent_ui/src/connection_view.rs b/crates/agent_ui/src/connection_view.rs index bc58120a964b7cb10eb4c779eb24fa8507030bc6..835ff611288c2bf6867a885ed2be8c6a66679cdb 100644 --- a/crates/agent_ui/src/connection_view.rs +++ b/crates/agent_ui/src/connection_view.rs @@ -26,10 +26,10 @@ use fs::Fs; use futures::FutureExt as _; use gpui::{ Action, Animation, AnimationExt, AnyView, App, ClickEvent, ClipboardItem, CursorStyle, - ElementId, Empty, Entity, FocusHandle, Focusable, Hsla, ListOffset, ListState, ObjectFit, - PlatformDisplay, ScrollHandle, SharedString, Subscription, Task, TextStyle, WeakEntity, Window, - WindowHandle, div, ease_in_out, img, linear_color_stop, linear_gradient, list, point, - pulsating_between, + ElementId, Empty, Entity, EventEmitter, FocusHandle, Focusable, Hsla, ListOffset, ListState, + ObjectFit, PlatformDisplay, ScrollHandle, SharedString, Subscription, Task, TextStyle, + WeakEntity, Window, WindowHandle, div, ease_in_out, img, linear_color_stop, linear_gradient, + list, point, pulsating_between, }; use language::Buffer; use language_model::LanguageModelRegistry; @@ -295,6 +295,12 @@ impl Conversation { } } +pub enum AcpServerViewEvent { + ActiveThreadChanged, +} + +impl EventEmitter for ConnectionView {} + pub struct ConnectionView { agent: Rc, agent_server_store: Entity, @@ -386,6 +392,7 @@ impl ConnectionView { if let Some(view) = self.active_thread() { view.focus_handle(cx).focus(window, cx); } + cx.emit(AcpServerViewEvent::ActiveThreadChanged); cx.notify(); } } @@ -524,6 +531,7 @@ impl ConnectionView { } self.server_state = state; + cx.emit(AcpServerViewEvent::ActiveThreadChanged); cx.notify(); } diff --git a/crates/agent_ui/src/connection_view/thread_view.rs b/crates/agent_ui/src/connection_view/thread_view.rs index 2544305bc8f8666b897d11285ffa7711f3af8794..8ce4da360664774342c4167f7c8dfbce914b647e 100644 --- a/crates/agent_ui/src/connection_view/thread_view.rs +++ b/crates/agent_ui/src/connection_view/thread_view.rs @@ -1,6 +1,8 @@ use acp_thread::ContentBlock; use cloud_api_types::{SubmitAgentThreadFeedbackBody, SubmitAgentThreadFeedbackCommentsBody}; use editor::actions::OpenExcerpts; + +use crate::StartThreadIn; use gpui::{Corner, List}; use language_model::{LanguageModelEffortLevel, Speed}; use settings::update_settings_file; @@ -191,6 +193,12 @@ impl DiffStats { } } +pub enum AcpThreadViewEvent { + FirstSendRequested { content: Vec }, +} + +impl EventEmitter for ThreadView {} + pub struct ThreadView { pub id: acp::SessionId, pub parent_id: Option, @@ -518,6 +526,24 @@ impl ThreadView { .thread(acp_thread.session_id(), cx) } + /// Resolves the message editor's contents into content blocks. For profiles + /// that do not enable any tools, directory mentions are expanded to inline + /// file contents since the agent can't read files on its own. + fn resolve_message_contents( + &self, + message_editor: &Entity, + cx: &mut App, + ) -> Task, Vec>)>> { + let expand = self.as_native_thread(cx).is_some_and(|thread| { + let thread = thread.read(cx); + AgentSettings::get_global(cx) + .profiles + .get(thread.profile()) + .is_some_and(|profile| profile.tools.is_empty()) + }); + message_editor.update(cx, |message_editor, cx| message_editor.contents(expand, cx)) + } + pub fn current_model_id(&self, cx: &App) -> Option { let selector = self.model_selector.as_ref()?; let model = selector.read(cx).active_model(cx)?; @@ -731,6 +757,46 @@ impl ThreadView { } let message_editor = self.message_editor.clone(); + + // Intercept the first send so the agent panel can capture the full + // content blocks — needed for "Start thread in New Worktree", + // which must create a workspace before sending the message there. + let intercept_first_send = self.thread.read(cx).entries().is_empty() + && !message_editor.read(cx).is_empty(cx) + && self + .workspace + .upgrade() + .and_then(|workspace| workspace.read(cx).panel::(cx)) + .is_some_and(|panel| { + panel.read(cx).start_thread_in() == &StartThreadIn::NewWorktree + }); + + if intercept_first_send { + let content_task = self.resolve_message_contents(&message_editor, cx); + + cx.spawn(async move |this, cx| match content_task.await { + Ok((content, _tracked_buffers)) => { + if content.is_empty() { + return; + } + + this.update(cx, |_, cx| { + cx.emit(AcpThreadViewEvent::FirstSendRequested { content }); + }) + .ok(); + } + Err(error) => { + this.update(cx, |this, cx| { + this.handle_thread_error(error, cx); + }) + .ok(); + } + }) + .detach(); + + return; + } + let is_editor_empty = message_editor.read(cx).is_empty(cx); let is_generating = thread.read(cx).status() != ThreadStatus::Idle; @@ -794,18 +860,7 @@ impl ThreadView { window: &mut Window, cx: &mut Context, ) { - let full_mention_content = self.as_native_thread(cx).is_some_and(|thread| { - // Include full contents when using minimal profile - let thread = thread.read(cx); - AgentSettings::get_global(cx) - .profiles - .get(thread.profile()) - .is_some_and(|profile| profile.tools.is_empty()) - }); - - let contents = message_editor.update(cx, |message_editor, cx| { - message_editor.contents(full_mention_content, cx) - }); + let contents = self.resolve_message_contents(&message_editor, cx); self.thread_error.take(); self.thread_feedback.clear(); @@ -1140,21 +1195,11 @@ impl ThreadView { let is_idle = self.thread.read(cx).status() == acp_thread::ThreadStatus::Idle; if is_idle { - self.send_impl(message_editor.clone(), window, cx); + self.send_impl(message_editor, window, cx); return; } - let full_mention_content = self.as_native_thread(cx).is_some_and(|thread| { - let thread = thread.read(cx); - AgentSettings::get_global(cx) - .profiles - .get(thread.profile()) - .is_some_and(|profile| profile.tools.is_empty()) - }); - - let contents = message_editor.update(cx, |message_editor, cx| { - message_editor.contents(full_mention_content, cx) - }); + let contents = self.resolve_message_contents(&message_editor, cx); cx.spawn_in(window, async move |this, cx| { let (content, tracked_buffers) = contents.await?; diff --git a/crates/collab/src/db/queries/projects.rs b/crates/collab/src/db/queries/projects.rs index ed6325c62173358c8deac2dcd6289ce0b8ae5e71..fa3f99e1483e8a5d8410378493556b189eff78f1 100644 --- a/crates/collab/src/db/queries/projects.rs +++ b/crates/collab/src/db/queries/projects.rs @@ -1002,7 +1002,7 @@ impl Database { repositories.push(proto::UpdateRepository { project_id: db_repository_entry.project_id.0 as u64, id: db_repository_entry.id as u64, - abs_path: db_repository_entry.abs_path, + abs_path: db_repository_entry.abs_path.clone(), entry_ids, updated_statuses, removed_statuses: Vec::new(), @@ -1015,6 +1015,7 @@ impl Database { stash_entries: Vec::new(), remote_upstream_url: db_repository_entry.remote_upstream_url.clone(), remote_origin_url: db_repository_entry.remote_origin_url.clone(), + original_repo_abs_path: Some(db_repository_entry.abs_path), }); } } diff --git a/crates/collab/src/db/queries/rooms.rs b/crates/collab/src/db/queries/rooms.rs index d8fca0306f5b2ae5668a735db578061275192b58..7c007a570a0cb25c5302495d7342882eec0e1942 100644 --- a/crates/collab/src/db/queries/rooms.rs +++ b/crates/collab/src/db/queries/rooms.rs @@ -791,13 +791,14 @@ impl Database { head_commit_details, project_id: project_id.to_proto(), id: db_repository.id as u64, - abs_path: db_repository.abs_path, + abs_path: db_repository.abs_path.clone(), scan_id: db_repository.scan_id as u64, is_last_update: true, merge_message: db_repository.merge_message, stash_entries: Vec::new(), remote_upstream_url: db_repository.remote_upstream_url.clone(), remote_origin_url: db_repository.remote_origin_url.clone(), + original_repo_abs_path: Some(db_repository.abs_path), }); } } diff --git a/crates/feature_flags/src/flags.rs b/crates/feature_flags/src/flags.rs index eab9f8c1036a83451fc3201f97cfb1cc8c885043..c8524022d9d8295900638a09c528dfc3fdb85afd 100644 --- a/crates/feature_flags/src/flags.rs +++ b/crates/feature_flags/src/flags.rs @@ -37,6 +37,16 @@ impl FeatureFlag for AgentSharingFeatureFlag { const NAME: &'static str = "agent-sharing"; } +pub struct AgentGitWorktreesFeatureFlag; + +impl FeatureFlag for AgentGitWorktreesFeatureFlag { + const NAME: &'static str = "agent-git-worktrees"; + + fn enabled_for_staff() -> bool { + false + } +} + pub struct DiffReviewFeatureFlag; impl FeatureFlag for DiffReviewFeatureFlag { diff --git a/crates/git/src/repository.rs b/crates/git/src/repository.rs index ba77199d75f624c0dd44ad0b2ba4eec812d9a711..bd07555d05b759a33080b9ae9f166145c3d26d14 100644 --- a/crates/git/src/repository.rs +++ b/crates/git/src/repository.rs @@ -55,6 +55,26 @@ pub const GRAPH_CHUNK_SIZE: usize = 1000; /// Default value for the `git.worktree_directory` setting. pub const DEFAULT_WORKTREE_DIRECTORY: &str = "../worktrees"; +/// Given the git common directory (from `commondir()`), derive the original +/// repository's working directory. +/// +/// For a standard checkout, `common_dir` is `/.git`, so the parent +/// is the working directory. For a git worktree, `common_dir` is the **main** +/// repo's `.git` directory, so the parent is the original repo's working directory. +/// +/// Falls back to returning `common_dir` itself if it doesn't end with `.git` +/// (e.g. bare repos or unusual layouts). +pub fn original_repo_path_from_common_dir(common_dir: &Path) -> PathBuf { + if common_dir.file_name() == Some(OsStr::new(".git")) { + common_dir + .parent() + .map(|p| p.to_path_buf()) + .unwrap_or_else(|| common_dir.to_path_buf()) + } else { + common_dir.to_path_buf() + } +} + /// Resolves the configured worktree directory to an absolute path. /// /// `worktree_directory_setting` is the raw string from the user setting @@ -4272,6 +4292,34 @@ mod tests { ); } + #[test] + fn test_original_repo_path_from_common_dir() { + // Normal repo: common_dir is /.git + assert_eq!( + original_repo_path_from_common_dir(Path::new("/code/zed5/.git")), + PathBuf::from("/code/zed5") + ); + + // Worktree: common_dir is the main repo's .git + // (same result — that's the point, it always traces back to the original) + assert_eq!( + original_repo_path_from_common_dir(Path::new("/code/zed5/.git")), + PathBuf::from("/code/zed5") + ); + + // Bare repo: no .git suffix, returns as-is + assert_eq!( + original_repo_path_from_common_dir(Path::new("/code/zed5.git")), + PathBuf::from("/code/zed5.git") + ); + + // Root-level .git directory + assert_eq!( + original_repo_path_from_common_dir(Path::new("/.git")), + PathBuf::from("/") + ); + } + #[test] fn test_validate_worktree_directory() { let work_dir = Path::new("/code/my-project"); diff --git a/crates/git_ui/src/worktree_picker.rs b/crates/git_ui/src/worktree_picker.rs index f2826a2b543a73c5341653c42bbb5f1540213b2a..9f70c29da86ee52668984f92b247331524fc5936 100644 --- a/crates/git_ui/src/worktree_picker.rs +++ b/crates/git_ui/src/worktree_picker.rs @@ -275,9 +275,9 @@ impl WorktreeListDelegate { .git .worktree_directory .clone(); - let work_dir = repo.work_directory_abs_path.clone(); + let original_repo = repo.original_repo_abs_path.clone(); let directory = - validate_worktree_directory(&work_dir, &worktree_directory_setting)?; + validate_worktree_directory(&original_repo, &worktree_directory_setting)?; let new_worktree_path = directory.join(&branch); let receiver = repo.create_worktree(branch.clone(), directory, commit); anyhow::Ok((receiver, new_worktree_path)) diff --git a/crates/project/src/git_store.rs b/crates/project/src/git_store.rs index ae776966a770ccadcffdbf9b140ed10d4871b317..487e7f5f9699382ce4930141f7a0c7c50a1d23b8 100644 --- a/crates/project/src/git_store.rs +++ b/crates/project/src/git_store.rs @@ -266,6 +266,11 @@ pub struct RepositorySnapshot { pub id: RepositoryId, pub statuses_by_path: SumTree, pub work_directory_abs_path: Arc, + /// The working directory of the original repository. For a normal + /// checkout this equals `work_directory_abs_path`. For a git worktree + /// checkout, this is the original repo's working directory — used to + /// anchor new worktree creation so they don't nest. + pub original_repo_abs_path: Arc, pub path_style: PathStyle, pub branch: Option, pub head_commit: Option, @@ -1505,16 +1510,19 @@ impl GitStore { new_work_directory_abs_path: Some(work_directory_abs_path), dot_git_abs_path: Some(dot_git_abs_path), repository_dir_abs_path: Some(_repository_dir_abs_path), - common_dir_abs_path: Some(_common_dir_abs_path), + common_dir_abs_path: Some(common_dir_abs_path), .. } = update { + let original_repo_abs_path: Arc = + git::repository::original_repo_path_from_common_dir(common_dir_abs_path).into(); let id = RepositoryId(next_repository_id.fetch_add(1, atomic::Ordering::Release)); let git_store = cx.weak_entity(); let repo = cx.new(|cx| { let mut repo = Repository::local( id, work_directory_abs_path.clone(), + original_repo_abs_path.clone(), dot_git_abs_path.clone(), project_environment.downgrade(), fs.clone(), @@ -1840,6 +1848,11 @@ impl GitStore { let id = RepositoryId::from_proto(update.id); let client = this.upstream_client().context("no upstream client")?; + let original_repo_abs_path: Option> = update + .original_repo_abs_path + .as_deref() + .map(|p| Path::new(p).into()); + let mut repo_subscription = None; let repo = this.repositories.entry(id).or_insert_with(|| { let git_store = cx.weak_entity(); @@ -1847,6 +1860,7 @@ impl GitStore { Repository::remote( id, Path::new(&update.abs_path).into(), + original_repo_abs_path.clone(), path_style, ProjectId(update.project_id), client, @@ -3481,10 +3495,17 @@ impl RepositoryId { } impl RepositorySnapshot { - fn empty(id: RepositoryId, work_directory_abs_path: Arc, path_style: PathStyle) -> Self { + fn empty( + id: RepositoryId, + work_directory_abs_path: Arc, + original_repo_abs_path: Option>, + path_style: PathStyle, + ) -> Self { Self { id, statuses_by_path: Default::default(), + original_repo_abs_path: original_repo_abs_path + .unwrap_or_else(|| work_directory_abs_path.clone()), work_directory_abs_path, branch: None, head_commit: None, @@ -3528,6 +3549,9 @@ impl RepositorySnapshot { .collect(), remote_upstream_url: self.remote_upstream_url.clone(), remote_origin_url: self.remote_origin_url.clone(), + original_repo_abs_path: Some( + self.original_repo_abs_path.to_string_lossy().into_owned(), + ), } } @@ -3599,6 +3623,9 @@ impl RepositorySnapshot { .collect(), remote_upstream_url: self.remote_upstream_url.clone(), remote_origin_url: self.remote_origin_url.clone(), + original_repo_abs_path: Some( + self.original_repo_abs_path.to_string_lossy().into_owned(), + ), } } @@ -3757,14 +3784,19 @@ impl Repository { fn local( id: RepositoryId, work_directory_abs_path: Arc, + original_repo_abs_path: Arc, dot_git_abs_path: Arc, project_environment: WeakEntity, fs: Arc, git_store: WeakEntity, cx: &mut Context, ) -> Self { - let snapshot = - RepositorySnapshot::empty(id, work_directory_abs_path.clone(), PathStyle::local()); + let snapshot = RepositorySnapshot::empty( + id, + work_directory_abs_path.clone(), + Some(original_repo_abs_path), + PathStyle::local(), + ); let state = cx .spawn(async move |_, cx| { LocalRepositoryState::new( @@ -3818,13 +3850,19 @@ impl Repository { fn remote( id: RepositoryId, work_directory_abs_path: Arc, + original_repo_abs_path: Option>, path_style: PathStyle, project_id: ProjectId, client: AnyProtoClient, git_store: WeakEntity, cx: &mut Context, ) -> Self { - let snapshot = RepositorySnapshot::empty(id, work_directory_abs_path, path_style); + let snapshot = RepositorySnapshot::empty( + id, + work_directory_abs_path, + original_repo_abs_path, + path_style, + ); let repository_state = RemoteRepositoryState { project_id, client }; let job_sender = Self::spawn_remote_git_worker(repository_state.clone(), cx); let repository_state = Task::ready(Ok(RepositoryState::Remote(repository_state))).shared(); @@ -5650,6 +5688,24 @@ impl Repository { ) } + pub fn remove_worktree(&mut self, path: PathBuf, force: bool) -> oneshot::Receiver> { + self.send_job( + Some("git worktree remove".into()), + move |repo, _cx| async move { + match repo { + RepositoryState::Local(LocalRepositoryState { backend, .. }) => { + backend.remove_worktree(path, force).await + } + RepositoryState::Remote(_) => { + anyhow::bail!( + "Removing worktrees on remote repositories is not yet supported" + ) + } + } + }, + ) + } + pub fn default_branch( &mut self, include_remote_name: bool, @@ -5988,6 +6044,10 @@ impl Repository { update: proto::UpdateRepository, cx: &mut Context, ) -> Result<()> { + if let Some(main_path) = &update.original_repo_abs_path { + self.snapshot.original_repo_abs_path = Path::new(main_path.as_str()).into(); + } + let new_branch = update.branch_summary.as_ref().map(proto_to_branch); let new_head_commit = update .head_commit_details @@ -6784,6 +6844,7 @@ async fn compute_snapshot( id, statuses_by_path, work_directory_abs_path, + original_repo_abs_path: prev_snapshot.original_repo_abs_path, path_style: prev_snapshot.path_style, scan_id: prev_snapshot.scan_id + 1, branch, diff --git a/crates/proto/proto/git.proto b/crates/proto/proto/git.proto index 86f3d4c328af06e1a3f4f7cc406ac84272577cd0..6cb3acfcd878c8f970c4e99789939424a3835709 100644 --- a/crates/proto/proto/git.proto +++ b/crates/proto/proto/git.proto @@ -125,6 +125,7 @@ message UpdateRepository { repeated StashEntry stash_entries = 13; optional string remote_upstream_url = 14; optional string remote_origin_url = 15; + optional string original_repo_abs_path = 16; } message RemoveRepository { diff --git a/crates/workspace/src/persistence/model.rs b/crates/workspace/src/persistence/model.rs index cdb646ec3b8248bdd0b5784424ed7b8df8ac0ee8..0971ebd0ddc9265ccf9ea10da7745ba59914db30 100644 --- a/crates/workspace/src/persistence/model.rs +++ b/crates/workspace/src/persistence/model.rs @@ -93,9 +93,9 @@ pub(crate) struct SerializedWorkspace { #[derive(Debug, PartialEq, Clone, Default, Serialize, Deserialize)] pub struct DockStructure { - pub(crate) left: DockData, - pub(crate) right: DockData, - pub(crate) bottom: DockData, + pub left: DockData, + pub right: DockData, + pub bottom: DockData, } impl RemoteConnectionKind { @@ -143,9 +143,9 @@ impl Bind for DockStructure { #[derive(Debug, PartialEq, Clone, Default, Serialize, Deserialize)] pub struct DockData { - pub(crate) visible: bool, - pub(crate) active_panel: Option, - pub(crate) zoom: bool, + pub visible: bool, + pub active_panel: Option, + pub zoom: bool, } impl Column for DockData { diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index b62f6b5eb60eafb7177f7883b825a208e7c81d62..3839b4446e7399536a12e7951c004cce81d5c4e6 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -79,7 +79,10 @@ pub use pane_group::{ use persistence::{DB, SerializedWindowBounds, model::SerializedWorkspace}; pub use persistence::{ DB as WORKSPACE_DB, WorkspaceDb, delete_unloaded_items, - model::{ItemId, SerializedMultiWorkspace, SerializedWorkspaceLocation, SessionWorkspace}, + model::{ + DockStructure, ItemId, SerializedMultiWorkspace, SerializedWorkspaceLocation, + SessionWorkspace, + }, read_serialized_multi_workspaces, }; use postage::stream::Stream; @@ -149,7 +152,7 @@ use crate::{item::ItemBufferKind, notifications::NotificationId}; use crate::{ persistence::{ SerializedAxis, - model::{DockData, DockStructure, SerializedItem, SerializedPane, SerializedPaneGroup}, + model::{DockData, SerializedItem, SerializedPane, SerializedPaneGroup}, }, security_modal::SecurityModal, }; @@ -628,7 +631,7 @@ fn prompt_and_open_paths(app_state: Arc, options: PathPromptOptions, c }) .ok(); } else { - let task = Workspace::new_local(Vec::new(), app_state.clone(), None, None, None, cx); + let task = Workspace::new_local(Vec::new(), app_state.clone(), None, None, None, true, cx); cx.spawn(async move |cx| { let (window, _) = task.await?; window.update(cx, |multi_workspace, window, cx| { @@ -1290,6 +1293,7 @@ pub struct Workspace { scheduled_tasks: Vec>, last_open_dock_positions: Vec, removing: bool, + _panels_task: Option>>, } impl EventEmitter for Workspace {} @@ -1660,6 +1664,7 @@ impl Workspace { left_dock, bottom_dock, right_dock, + _panels_task: None, project: project.clone(), follower_states: Default::default(), last_leaders_by_pane: Default::default(), @@ -1703,6 +1708,7 @@ impl Workspace { requesting_window: Option>, env: Option>, init: Option) + Send>>, + activate: bool, cx: &mut App, ) -> Task< anyhow::Result<( @@ -1830,7 +1836,11 @@ impl Workspace { workspace }); - multi_workspace.activate(workspace.clone(), cx); + if activate { + multi_workspace.activate(workspace.clone(), cx); + } else { + multi_workspace.add_workspace(workspace.clone(), cx); + } workspace })?; (window, workspace) @@ -1984,6 +1994,76 @@ impl Workspace { [&self.left_dock, &self.bottom_dock, &self.right_dock] } + pub fn capture_dock_state(&self, _window: &Window, cx: &App) -> DockStructure { + let left_dock = self.left_dock.read(cx); + let left_visible = left_dock.is_open(); + let left_active_panel = left_dock + .active_panel() + .map(|panel| panel.persistent_name().to_string()); + // `zoomed_position` is kept in sync with individual panel zoom state + // by the dock code in `Dock::new` and `Dock::add_panel`. + let left_dock_zoom = self.zoomed_position == Some(DockPosition::Left); + + let right_dock = self.right_dock.read(cx); + let right_visible = right_dock.is_open(); + let right_active_panel = right_dock + .active_panel() + .map(|panel| panel.persistent_name().to_string()); + let right_dock_zoom = self.zoomed_position == Some(DockPosition::Right); + + let bottom_dock = self.bottom_dock.read(cx); + let bottom_visible = bottom_dock.is_open(); + let bottom_active_panel = bottom_dock + .active_panel() + .map(|panel| panel.persistent_name().to_string()); + let bottom_dock_zoom = self.zoomed_position == Some(DockPosition::Bottom); + + DockStructure { + left: DockData { + visible: left_visible, + active_panel: left_active_panel, + zoom: left_dock_zoom, + }, + right: DockData { + visible: right_visible, + active_panel: right_active_panel, + zoom: right_dock_zoom, + }, + bottom: DockData { + visible: bottom_visible, + active_panel: bottom_active_panel, + zoom: bottom_dock_zoom, + }, + } + } + + pub fn set_dock_structure( + &self, + docks: DockStructure, + window: &mut Window, + cx: &mut Context, + ) { + for (dock, data) in [ + (&self.left_dock, docks.left), + (&self.bottom_dock, docks.bottom), + (&self.right_dock, docks.right), + ] { + dock.update(cx, |dock, cx| { + dock.serialized_dock = Some(data); + dock.restore_state(window, cx); + }); + } + } + + pub fn open_item_abs_paths(&self, cx: &App) -> Vec { + self.items(cx) + .filter_map(|item| { + let project_path = item.project_path(cx)?; + self.project.read(cx).absolute_path(&project_path, cx) + }) + .collect() + } + pub fn dock_at_position(&self, position: DockPosition) -> &Entity { match position { DockPosition::Left => &self.left_dock, @@ -2043,6 +2123,14 @@ impl Workspace { &self.app_state } + pub fn set_panels_task(&mut self, task: Task>) { + self._panels_task = Some(task); + } + + pub fn take_panels_task(&mut self) -> Option>> { + self._panels_task.take() + } + pub fn user_store(&self) -> &Entity { &self.app_state.user_store } @@ -2548,7 +2636,15 @@ impl Workspace { Task::ready(Ok(callback(self, window, cx))) } else { let env = self.project.read(cx).cli_environment(cx); - let task = Self::new_local(Vec::new(), self.app_state.clone(), None, env, None, cx); + let task = Self::new_local( + Vec::new(), + self.app_state.clone(), + None, + env, + None, + true, + cx, + ); cx.spawn_in(window, async move |_vh, cx| { let (multi_workspace_window, _) = task.await?; multi_workspace_window.update(cx, |multi_workspace, window, cx| { @@ -2578,7 +2674,15 @@ impl Workspace { Task::ready(Ok(callback(self, window, cx))) } else { let env = self.project.read(cx).cli_environment(cx); - let task = Self::new_local(Vec::new(), self.app_state.clone(), None, env, None, cx); + let task = Self::new_local( + Vec::new(), + self.app_state.clone(), + None, + env, + None, + true, + cx, + ); cx.spawn_in(window, async move |_vh, cx| { let (multi_workspace_window, _) = task.await?; multi_workspace_window.update(cx, |multi_workspace, window, cx| { @@ -6012,53 +6116,7 @@ impl Workspace { window: &mut Window, cx: &mut App, ) -> DockStructure { - let left_dock = this.left_dock.read(cx); - let left_visible = left_dock.is_open(); - let left_active_panel = left_dock - .active_panel() - .map(|panel| panel.persistent_name().to_string()); - let left_dock_zoom = left_dock - .active_panel() - .map(|panel| panel.is_zoomed(window, cx)) - .unwrap_or(false); - - let right_dock = this.right_dock.read(cx); - let right_visible = right_dock.is_open(); - let right_active_panel = right_dock - .active_panel() - .map(|panel| panel.persistent_name().to_string()); - let right_dock_zoom = right_dock - .active_panel() - .map(|panel| panel.is_zoomed(window, cx)) - .unwrap_or(false); - - let bottom_dock = this.bottom_dock.read(cx); - let bottom_visible = bottom_dock.is_open(); - let bottom_active_panel = bottom_dock - .active_panel() - .map(|panel| panel.persistent_name().to_string()); - let bottom_dock_zoom = bottom_dock - .active_panel() - .map(|panel| panel.is_zoomed(window, cx)) - .unwrap_or(false); - - DockStructure { - left: DockData { - visible: left_visible, - active_panel: left_active_panel, - zoom: left_dock_zoom, - }, - right: DockData { - visible: right_visible, - active_panel: right_active_panel, - zoom: right_dock_zoom, - }, - bottom: DockData { - visible: bottom_visible, - active_panel: bottom_active_panel, - zoom: bottom_dock_zoom, - }, - } + this.capture_dock_state(window, cx) } match self.workspace_location(cx) { @@ -8087,6 +8145,7 @@ pub async fn restore_multiworkspace( None, None, None, + true, cx, ) }) @@ -8116,6 +8175,7 @@ pub async fn restore_multiworkspace( Some(window_handle), None, None, + true, cx, ) }) @@ -8385,6 +8445,7 @@ pub fn join_channel( requesting_window, None, None, + true, cx, ) }) @@ -8457,7 +8518,7 @@ pub async fn get_any_active_multi_workspace( // find an existing workspace to focus and show call controls let active_window = activate_any_workspace_window(&mut cx); if active_window.is_none() { - cx.update(|cx| Workspace::new_local(vec![], app_state.clone(), None, None, None, cx)) + cx.update(|cx| Workspace::new_local(vec![], app_state.clone(), None, None, None, true, cx)) .await?; } activate_any_workspace_window(&mut cx).context("could not open zed") @@ -8845,6 +8906,7 @@ pub fn open_paths( open_options.replace_window, open_options.env, None, + true, cx, ) }) @@ -8908,6 +8970,7 @@ pub fn open_new( open_options.replace_window, open_options.env, Some(Box::new(init)), + true, cx, ); cx.spawn(async move |cx| { diff --git a/crates/zed/src/visual_test_runner.rs b/crates/zed/src/visual_test_runner.rs index 0ae98d510aa34b05f7fa1766176f21ea353394d9..df673f0b4869af8fa55b0e83af10553df8afb4d8 100644 --- a/crates/zed/src/visual_test_runner.rs +++ b/crates/zed/src/visual_test_runner.rs @@ -71,7 +71,7 @@ use { time::Duration, }, util::ResultExt as _, - workspace::{AppState, MultiWorkspace, Workspace, WorkspaceId}, + workspace::{AppState, MultiWorkspace, Panel as _, Workspace, WorkspaceId}, zed_actions::OpenSettingsAt, }; @@ -548,6 +548,27 @@ fn run_visual_tests(project_path: PathBuf, update_baseline: bool) -> Result<()> } } + // Run Test 11: Thread target selector visual tests + #[cfg(feature = "visual-tests")] + { + println!("\n--- Test 11: start_thread_in_selector (6 variants) ---"); + match run_start_thread_in_selector_visual_tests(app_state.clone(), &mut cx, update_baseline) + { + Ok(TestResult::Passed) => { + println!("✓ start_thread_in_selector: PASSED"); + passed += 1; + } + Ok(TestResult::BaselineUpdated(_)) => { + println!("✓ start_thread_in_selector: Baselines updated"); + updated += 1; + } + Err(e) => { + eprintln!("✗ start_thread_in_selector: FAILED - {}", e); + failed += 1; + } + } + } + // Run Test 9: Tool Permissions Settings UI visual test println!("\n--- Test 9: tool_permissions_settings ---"); match run_tool_permissions_visual_tests(app_state.clone(), &mut cx, update_baseline) { @@ -3066,3 +3087,629 @@ fn run_error_wrapping_visual_tests( Ok(test_result) } + +#[cfg(all(target_os = "macos", feature = "visual-tests"))] +/// Runs a git command in the given directory and returns an error with +/// stderr/stdout context if the command fails (non-zero exit status). +fn run_git_command(args: &[&str], dir: &std::path::Path) -> Result<()> { + let output = std::process::Command::new("git") + .args(args) + .current_dir(dir) + .output() + .with_context(|| format!("failed to spawn `git {}`", args.join(" ")))?; + + if !output.status.success() { + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + anyhow::bail!( + "`git {}` failed (exit {})\nstdout: {}\nstderr: {}", + args.join(" "), + output.status, + stdout.trim(), + stderr.trim(), + ); + } + Ok(()) +} + +#[cfg(all(target_os = "macos", feature = "visual-tests"))] +fn run_start_thread_in_selector_visual_tests( + app_state: Arc, + cx: &mut VisualTestAppContext, + update_baseline: bool, +) -> Result { + use agent_ui::{AgentPanel, StartThreadIn, WorktreeCreationStatus}; + + // Enable feature flags so the thread target selector renders + cx.update(|cx| { + cx.update_flags( + true, + vec!["agent-v2".to_string(), "agent-git-worktrees".to_string()], + ); + }); + + // Create a temp directory with a real git repo so "New Worktree" is enabled + let temp_dir = tempfile::tempdir()?; + let temp_path = temp_dir.keep(); + let canonical_temp = temp_path.canonicalize()?; + let project_path = canonical_temp.join("project"); + std::fs::create_dir_all(&project_path)?; + + // Initialize git repo + run_git_command(&["init"], &project_path)?; + run_git_command(&["config", "user.email", "test@test.com"], &project_path)?; + run_git_command(&["config", "user.name", "Test User"], &project_path)?; + + // Create source files + let src_dir = project_path.join("src"); + std::fs::create_dir_all(&src_dir)?; + std::fs::write( + src_dir.join("main.rs"), + r#"fn main() { + println!("Hello, world!"); + + let x = 42; + let y = x * 2; + + if y > 50 { + println!("y is greater than 50"); + } else { + println!("y is not greater than 50"); + } + + for i in 0..10 { + println!("i = {}", i); + } +} + +fn helper_function(a: i32, b: i32) -> i32 { + a + b +} +"#, + )?; + + std::fs::write( + project_path.join("Cargo.toml"), + r#"[package] +name = "test_project" +version = "0.1.0" +edition = "2021" +"#, + )?; + + // Commit so git status is clean + run_git_command(&["add", "."], &project_path)?; + run_git_command(&["commit", "-m", "Initial commit"], &project_path)?; + + let project = cx.update(|cx| { + project::Project::local( + app_state.client.clone(), + app_state.node_runtime.clone(), + app_state.user_store.clone(), + app_state.languages.clone(), + app_state.fs.clone(), + None, + project::LocalProjectFlags { + init_worktree_trust: false, + ..Default::default() + }, + cx, + ) + }); + + // Use a wide window so we see project panel + editor + agent panel + let window_size = size(px(1280.0), px(800.0)); + let bounds = Bounds { + origin: point(px(0.0), px(0.0)), + size: window_size, + }; + + let workspace_window: WindowHandle = cx + .update(|cx| { + cx.open_window( + WindowOptions { + window_bounds: Some(WindowBounds::Windowed(bounds)), + focus: false, + show: false, + ..Default::default() + }, + |window, cx| { + let workspace = cx.new(|cx| { + Workspace::new(None, project.clone(), app_state.clone(), window, cx) + }); + cx.new(|cx| MultiWorkspace::new(workspace, window, cx)) + }, + ) + }) + .context("Failed to open thread target selector test window")?; + + cx.run_until_parked(); + + // Create and register the workspace sidebar + let sidebar = workspace_window + .update(cx, |_multi_workspace, window, cx| { + let multi_workspace_handle = cx.entity(); + cx.new(|cx| sidebar::Sidebar::new(multi_workspace_handle, window, cx)) + }) + .context("Failed to create sidebar")?; + + workspace_window + .update(cx, |multi_workspace, window, cx| { + multi_workspace.register_sidebar(sidebar.clone(), window, cx); + }) + .context("Failed to register sidebar")?; + + // Open the sidebar + workspace_window + .update(cx, |multi_workspace, window, cx| { + multi_workspace.toggle_sidebar(window, cx); + }) + .context("Failed to toggle sidebar")?; + + cx.run_until_parked(); + + // Add the git project as a worktree + let add_worktree_task = workspace_window + .update(cx, |multi_workspace, _window, cx| { + let workspace = &multi_workspace.workspaces()[0]; + let project = workspace.read(cx).project().clone(); + project.update(cx, |project, cx| { + project.find_or_create_worktree(&project_path, true, cx) + }) + }) + .context("Failed to start adding worktree")?; + + cx.background_executor.allow_parking(); + cx.foreground_executor + .block_test(add_worktree_task) + .context("Failed to add worktree")?; + cx.background_executor.forbid_parking(); + + cx.run_until_parked(); + + // Wait for worktree scan and git status + for _ in 0..5 { + cx.advance_clock(Duration::from_millis(100)); + cx.run_until_parked(); + } + + // Open the project panel + let (weak_workspace, async_window_cx) = workspace_window + .update(cx, |multi_workspace, window, cx| { + let workspace = &multi_workspace.workspaces()[0]; + (workspace.read(cx).weak_handle(), window.to_async(cx)) + }) + .context("Failed to get workspace handle")?; + + cx.background_executor.allow_parking(); + let project_panel = cx + .foreground_executor + .block_test(ProjectPanel::load(weak_workspace, async_window_cx)) + .context("Failed to load project panel")?; + cx.background_executor.forbid_parking(); + + workspace_window + .update(cx, |multi_workspace, window, cx| { + let workspace = &multi_workspace.workspaces()[0]; + workspace.update(cx, |workspace, cx| { + workspace.add_panel(project_panel, window, cx); + workspace.open_panel::(window, cx); + }); + }) + .context("Failed to add project panel")?; + + cx.run_until_parked(); + + // Open main.rs in the editor + let open_file_task = workspace_window + .update(cx, |multi_workspace, window, cx| { + let workspace = &multi_workspace.workspaces()[0]; + workspace.update(cx, |workspace, cx| { + let worktree = workspace.project().read(cx).worktrees(cx).next(); + if let Some(worktree) = worktree { + let worktree_id = worktree.read(cx).id(); + let rel_path: std::sync::Arc = + util::rel_path::rel_path("src/main.rs").into(); + let project_path: project::ProjectPath = (worktree_id, rel_path).into(); + Some(workspace.open_path(project_path, None, true, window, cx)) + } else { + None + } + }) + }) + .log_err() + .flatten(); + + if let Some(task) = open_file_task { + cx.background_executor.allow_parking(); + cx.foreground_executor.block_test(task).log_err(); + cx.background_executor.forbid_parking(); + } + + cx.run_until_parked(); + + // Load the AgentPanel + let (weak_workspace, async_window_cx) = workspace_window + .update(cx, |multi_workspace, window, cx| { + let workspace = &multi_workspace.workspaces()[0]; + (workspace.read(cx).weak_handle(), window.to_async(cx)) + }) + .context("Failed to get workspace handle for agent panel")?; + + let prompt_builder = + cx.update(|cx| prompt_store::PromptBuilder::load(app_state.fs.clone(), false, cx)); + + // Register an observer so that workspaces created by the worktree creation + // flow get AgentPanel and ProjectPanel loaded automatically. Without this, + // `workspace.panel::(cx)` returns None in the new workspace and + // the creation flow's `focus_panel::` call is a no-op. + let _workspace_observer = cx.update({ + let prompt_builder = prompt_builder.clone(); + |cx| { + cx.observe_new(move |workspace: &mut Workspace, window, cx| { + let Some(window) = window else { return }; + let prompt_builder = prompt_builder.clone(); + let panels_task = cx.spawn_in(window, async move |workspace_handle, cx| { + let project_panel = ProjectPanel::load(workspace_handle.clone(), cx.clone()); + let agent_panel = + AgentPanel::load(workspace_handle.clone(), prompt_builder, cx.clone()); + if let Ok(panel) = project_panel.await { + workspace_handle + .update_in(cx, |workspace, window, cx| { + workspace.add_panel(panel, window, cx); + }) + .log_err(); + } + if let Ok(panel) = agent_panel.await { + workspace_handle + .update_in(cx, |workspace, window, cx| { + workspace.add_panel(panel, window, cx); + }) + .log_err(); + } + anyhow::Ok(()) + }); + workspace.set_panels_task(panels_task); + }) + } + }); + + cx.background_executor.allow_parking(); + let panel = cx + .foreground_executor + .block_test(AgentPanel::load( + weak_workspace, + prompt_builder, + async_window_cx, + )) + .context("Failed to load AgentPanel")?; + cx.background_executor.forbid_parking(); + + workspace_window + .update(cx, |multi_workspace, window, cx| { + let workspace = &multi_workspace.workspaces()[0]; + workspace.update(cx, |workspace, cx| { + workspace.add_panel(panel.clone(), window, cx); + workspace.open_panel::(window, cx); + }); + }) + .context("Failed to add and open AgentPanel")?; + + cx.run_until_parked(); + + // Inject the stub server and open a thread so the toolbar is visible + let connection = StubAgentConnection::new(); + let stub_agent: Rc = Rc::new(StubAgentServer::new(connection)); + + cx.update_window(workspace_window.into(), |_, window, cx| { + panel.update(cx, |panel, cx| { + panel.open_external_thread_with_server(stub_agent.clone(), window, cx); + }); + })?; + + cx.run_until_parked(); + + // ---- Screenshot 1: Default "Local Project" selector (dropdown closed) ---- + cx.update_window(workspace_window.into(), |_, window, _cx| { + window.refresh(); + })?; + cx.run_until_parked(); + + let result_default = run_visual_test( + "start_thread_in_selector_default", + workspace_window.into(), + cx, + update_baseline, + ); + + // ---- Screenshot 2: Dropdown open showing menu entries ---- + cx.update_window(workspace_window.into(), |_, window, cx| { + panel.update(cx, |panel, cx| { + panel.open_start_thread_in_menu_for_tests(window, cx); + }); + })?; + cx.run_until_parked(); + + cx.update_window(workspace_window.into(), |_, window, _cx| { + window.refresh(); + })?; + cx.run_until_parked(); + + let result_open_dropdown = run_visual_test( + "start_thread_in_selector_open", + workspace_window.into(), + cx, + update_baseline, + ); + + // ---- Screenshot 3: "New Worktree" selected (dropdown closed, label changed) ---- + // First dismiss the dropdown, then change the target so the toolbar label is visible + cx.update_window(workspace_window.into(), |_, _window, cx| { + panel.update(cx, |panel, cx| { + panel.close_start_thread_in_menu_for_tests(cx); + }); + })?; + cx.run_until_parked(); + + cx.update_window(workspace_window.into(), |_, _window, cx| { + panel.update(cx, |panel, cx| { + panel.set_start_thread_in_for_tests(StartThreadIn::NewWorktree, cx); + }); + })?; + cx.run_until_parked(); + + cx.update_window(workspace_window.into(), |_, window, _cx| { + window.refresh(); + })?; + cx.run_until_parked(); + + let result_new_worktree = run_visual_test( + "start_thread_in_selector_new_worktree", + workspace_window.into(), + cx, + update_baseline, + ); + + // ---- Screenshot 4: "Creating worktree…" status banner ---- + cx.update_window(workspace_window.into(), |_, _window, cx| { + panel.update(cx, |panel, cx| { + panel + .set_worktree_creation_status_for_tests(Some(WorktreeCreationStatus::Creating), cx); + }); + })?; + cx.run_until_parked(); + + cx.update_window(workspace_window.into(), |_, window, _cx| { + window.refresh(); + })?; + cx.run_until_parked(); + + let result_creating = run_visual_test( + "worktree_creation_status_creating", + workspace_window.into(), + cx, + update_baseline, + ); + + // ---- Screenshot 5: Error status banner ---- + cx.update_window(workspace_window.into(), |_, _window, cx| { + panel.update(cx, |panel, cx| { + panel.set_worktree_creation_status_for_tests( + Some(WorktreeCreationStatus::Error( + "Failed to create worktree: branch already exists".into(), + )), + cx, + ); + }); + })?; + cx.run_until_parked(); + + cx.update_window(workspace_window.into(), |_, window, _cx| { + window.refresh(); + })?; + cx.run_until_parked(); + + let result_error = run_visual_test( + "worktree_creation_status_error", + workspace_window.into(), + cx, + update_baseline, + ); + + // ---- Screenshot 6: Worktree creation succeeded ---- + // Clear the error status and re-select New Worktree to ensure a clean state. + cx.update_window(workspace_window.into(), |_, _window, cx| { + panel.update(cx, |panel, cx| { + panel.set_worktree_creation_status_for_tests(None, cx); + }); + })?; + cx.run_until_parked(); + + cx.update_window(workspace_window.into(), |_, window, cx| { + window.dispatch_action(Box::new(StartThreadIn::NewWorktree), cx); + })?; + cx.run_until_parked(); + + // Insert a message into the active thread's message editor and submit. + let thread_view = cx + .read(|cx| panel.read(cx).as_active_thread_view(cx)) + .ok_or_else(|| anyhow::anyhow!("No active thread view"))?; + + cx.update_window(workspace_window.into(), |_, window, cx| { + let message_editor = thread_view.read(cx).message_editor.clone(); + message_editor.update(cx, |message_editor, cx| { + message_editor.set_message( + vec![acp::ContentBlock::Text(acp::TextContent::new( + "Add a CLI flag to set the log level".to_string(), + ))], + window, + cx, + ); + message_editor.send(cx); + }); + })?; + cx.run_until_parked(); + + // Wait for the full worktree creation flow to complete. The creation status + // is cleared to `None` at the very end of the async task, after panels are + // loaded, the agent panel is focused, and the new workspace is activated. + cx.background_executor.allow_parking(); + let mut creation_complete = false; + for _ in 0..120 { + cx.run_until_parked(); + let status_cleared = cx.read(|cx| { + panel + .read(cx) + .worktree_creation_status_for_tests() + .is_none() + }); + let workspace_count = workspace_window.update(cx, |multi_workspace, _window, _cx| { + multi_workspace.workspaces().len() + })?; + if workspace_count == 2 && status_cleared { + creation_complete = true; + break; + } + cx.advance_clock(Duration::from_millis(100)); + } + cx.background_executor.forbid_parking(); + + if !creation_complete { + return Err(anyhow::anyhow!("Worktree creation did not complete")); + } + + // The creation flow called `external_thread` on the new workspace's agent + // panel, which tried to launch a real agent binary and failed. Replace the + // error state by injecting the stub server, and shrink the panel so the + // editor content is visible. + workspace_window.update(cx, |multi_workspace, window, cx| { + let new_workspace = &multi_workspace.workspaces()[1]; + new_workspace.update(cx, |workspace, cx| { + if let Some(new_panel) = workspace.panel::(cx) { + new_panel.update(cx, |panel, cx| { + panel.set_size(Some(px(480.0)), window, cx); + panel.open_external_thread_with_server(stub_agent.clone(), window, cx); + }); + } + }); + })?; + cx.run_until_parked(); + + // Type and send a message so the thread target dropdown disappears. + let new_panel = workspace_window.update(cx, |multi_workspace, _window, cx| { + let new_workspace = &multi_workspace.workspaces()[1]; + new_workspace.read(cx).panel::(cx) + })?; + if let Some(new_panel) = new_panel { + let new_thread_view = cx.read(|cx| new_panel.read(cx).as_active_thread_view(cx)); + if let Some(new_thread_view) = new_thread_view { + cx.update_window(workspace_window.into(), |_, window, cx| { + let message_editor = new_thread_view.read(cx).message_editor.clone(); + message_editor.update(cx, |editor, cx| { + editor.set_message( + vec![acp::ContentBlock::Text(acp::TextContent::new( + "Add a CLI flag to set the log level".to_string(), + ))], + window, + cx, + ); + editor.send(cx); + }); + })?; + cx.run_until_parked(); + } + } + + cx.update_window(workspace_window.into(), |_, window, _cx| { + window.refresh(); + })?; + cx.run_until_parked(); + + let result_succeeded = run_visual_test( + "worktree_creation_succeeded", + workspace_window.into(), + cx, + update_baseline, + ); + + // Clean up — drop the workspace observer first so no new panels are + // registered on workspaces created during teardown. + drop(_workspace_observer); + + workspace_window + .update(cx, |multi_workspace, _window, cx| { + let workspace = &multi_workspace.workspaces()[0]; + let project = workspace.read(cx).project().clone(); + project.update(cx, |project, cx| { + let worktree_ids: Vec<_> = + project.worktrees(cx).map(|wt| wt.read(cx).id()).collect(); + for id in worktree_ids { + project.remove_worktree(id, cx); + } + }); + }) + .log_err(); + + cx.run_until_parked(); + + cx.update_window(workspace_window.into(), |_, window, _cx| { + window.remove_window(); + }) + .log_err(); + + cx.run_until_parked(); + + for _ in 0..15 { + cx.advance_clock(Duration::from_millis(100)); + cx.run_until_parked(); + } + + // Delete the preserved temp directory so visual-test runs don't + // accumulate filesystem artifacts. + if let Err(err) = std::fs::remove_dir_all(&temp_path) { + log::warn!( + "failed to clean up visual-test temp dir {}: {err}", + temp_path.display() + ); + } + + // Reset feature flags + cx.update(|cx| { + cx.update_flags(false, vec![]); + }); + + let results = [ + ("default", result_default), + ("open_dropdown", result_open_dropdown), + ("new_worktree", result_new_worktree), + ("creating", result_creating), + ("error", result_error), + ("succeeded", result_succeeded), + ]; + + let mut has_baseline_update = None; + let mut failures = Vec::new(); + + for (name, result) in &results { + match result { + Ok(TestResult::Passed) => {} + Ok(TestResult::BaselineUpdated(p)) => { + has_baseline_update = Some(p.clone()); + } + Err(e) => { + failures.push(format!("{}: {}", name, e)); + } + } + } + + if !failures.is_empty() { + Err(anyhow::anyhow!( + "start_thread_in_selector failures: {}", + failures.join("; ") + )) + } else if let Some(p) = has_baseline_update { + Ok(TestResult::BaselineUpdated(p)) + } else { + Ok(TestResult::Passed) + } +} diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index a0a6e424d46790ad49c860377c5d1e711aae6b61..17832bdd1833cabb42af2195f9d9aab1a6bf3fab 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -496,7 +496,8 @@ pub fn initialize_workspace( status_bar.add_right_item(image_info, window, cx); }); - initialize_panels(prompt_builder.clone(), window, cx); + let panels_task = initialize_panels(prompt_builder.clone(), window, cx); + workspace.set_panels_task(panels_task); register_actions(app_state.clone(), workspace, window, cx); workspace.focus_handle(cx).focus(window, cx); @@ -620,7 +621,7 @@ fn initialize_panels( prompt_builder: Arc, window: &mut Window, cx: &mut Context, -) { +) -> Task> { cx.spawn_in(window, async move |workspace_handle, cx| { let project_panel = ProjectPanel::load(workspace_handle.clone(), cx.clone()); let outline_panel = OutlinePanel::load(workspace_handle.clone(), cx.clone()); @@ -662,7 +663,6 @@ fn initialize_panels( anyhow::Ok(()) }) - .detach(); } fn setup_or_teardown_ai_panel( @@ -1103,7 +1103,7 @@ fn register_actions( ); }, ) - .detach(); + .detach_and_log_err(cx); } } }) @@ -5808,7 +5808,15 @@ mod tests { // Window B: workspace for dir3 let (window_a, _) = cx .update(|cx| { - Workspace::new_local(vec![dir1.into()], app_state.clone(), None, None, None, cx) + Workspace::new_local( + vec![dir1.into()], + app_state.clone(), + None, + None, + None, + true, + cx, + ) }) .await .expect("failed to open first workspace"); @@ -5824,7 +5832,15 @@ mod tests { let (window_b, _) = cx .update(|cx| { - Workspace::new_local(vec![dir3.into()], app_state.clone(), None, None, None, cx) + Workspace::new_local( + vec![dir3.into()], + app_state.clone(), + None, + None, + None, + true, + cx, + ) }) .await .expect("failed to open third workspace");