diff --git a/Cargo.lock b/Cargo.lock index 812f738b96f7e06a45e7a715a2bfcd61f37b0cd5..714eeb9099f96abe88c6d56a6315c755fab35e50 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4942,6 +4942,7 @@ checksum = "abd57806937c9cc163efc8ea3910e00a62e2aeb0b8119f1793a978088f8f6b04" name = "dev_container" version = "0.1.0" dependencies = [ + "fs", "futures 0.3.31", "gpui", "http 1.3.1", @@ -4951,10 +4952,12 @@ dependencies = [ "node_runtime", "paths", "picker", + "project", "serde", "serde_json", "settings", "smol", + "theme", "ui", "util", "workspace", @@ -8492,7 +8495,6 @@ dependencies = [ "fuzzy", "gpui", "language", - "platform_title_bar", "project", "serde_json", "serde_json_lenient", @@ -12380,6 +12382,7 @@ checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" name = "platform_title_bar" version = "0.1.0" dependencies = [ + "feature_flags", "gpui", "settings", "smallvec", @@ -15339,6 +15342,30 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "sidebar" +version = "0.1.0" +dependencies = [ + "acp_thread", + "agent_ui", + "db", + "editor", + "feature_flags", + "fs", + "fuzzy", + "gpui", + "picker", + "project", + "recent_projects", + "serde_json", + "settings", + "theme", + "ui", + "ui_input", + "util", + "workspace", +] + [[package]] name = "signal-hook" version = "0.3.18" @@ -17218,6 +17245,7 @@ dependencies = [ "cloud_api_types", "collections", "db", + "feature_flags", "git_ui", "gpui", "http_client", @@ -21103,6 +21131,7 @@ dependencies = [ "settings_profile_selector", "settings_ui", "shellexpand 2.1.2", + "sidebar", "smol", "snippet_provider", "snippets_ui", diff --git a/Cargo.toml b/Cargo.toml index c2f3912c4e61bb17910d1b10d6e6b682c6328fb0..7cb41534a7e331c4976cd4fe51b551b3a740fb77 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -155,6 +155,7 @@ members = [ "crates/schema_generator", "crates/search", "crates/session", + "crates/sidebar", "crates/settings", "crates/settings_content", "crates/settings_json", @@ -395,6 +396,7 @@ rules_library = { path = "crates/rules_library" } scheduler = { path = "crates/scheduler" } search = { path = "crates/search" } session = { path = "crates/session" } +sidebar = { path = "crates/sidebar" } settings = { path = "crates/settings" } settings_content = { path = "crates/settings_content" } settings_json = { path = "crates/settings_json" } @@ -853,6 +855,7 @@ refineable = { codegen-units = 1 } release_channel = { codegen-units = 1 } reqwest_client = { codegen-units = 1 } session = { codegen-units = 1 } +sidebar = { codegen-units = 1 } snippet = { codegen-units = 1 } snippets_ui = { codegen-units = 1 } story = { codegen-units = 1 } diff --git a/assets/icons/workspace_nav_closed.svg b/assets/icons/workspace_nav_closed.svg new file mode 100644 index 0000000000000000000000000000000000000000..ed1fce52d6826a4d10299f331358ff84e4caa973 --- /dev/null +++ b/assets/icons/workspace_nav_closed.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/icons/workspace_nav_open.svg b/assets/icons/workspace_nav_open.svg new file mode 100644 index 0000000000000000000000000000000000000000..464b6aac73c2aeaa9463a805aabc4559377bbfd3 --- /dev/null +++ b/assets/icons/workspace_nav_open.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index 535774cbc92dc62e59642a689199589186d1639f..feb27bdcb017af2f8606624a863e8f6db483c41f 100644 --- a/assets/keymaps/default-linux.json +++ b/assets/keymaps/default-linux.json @@ -594,6 +594,7 @@ "ctrl-alt-b": "workspace::ToggleRightDock", "ctrl-b": "workspace::ToggleLeftDock", "ctrl-j": "workspace::ToggleBottomDock", + "ctrl-alt-j": "multi_workspace::ToggleWorkspaceSidebar", "ctrl-alt-y": "workspace::ToggleAllDocks", "ctrl-alt-0": "workspace::ResetActiveDockSize", // For 0px parameter, uses UI font size value. @@ -653,6 +654,13 @@ "ctrl-w": "workspace::CloseActiveDock", }, }, + { + "context": "WorkspaceSidebar", + "use_key_equivalents": true, + "bindings": { + "ctrl-n": "multi_workspace::NewWorkspaceInWindow", + }, + }, { "context": "Workspace && debugger_running", "bindings": { diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index 0a40df26168567c62d24dd9cc83ede764edd30f1..74a3d23c7d064b747a559eb6c0fa94430b54c504 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -655,6 +655,7 @@ "cmd-alt-b": "workspace::ToggleRightDock", "cmd-r": "workspace::ToggleRightDock", "cmd-j": "workspace::ToggleBottomDock", + "cmd-alt-j": "multi_workspace::ToggleWorkspaceSidebar", "alt-cmd-y": "workspace::ToggleAllDocks", // For 0px parameter, uses UI font size value. "ctrl-alt-0": "workspace::ResetActiveDockSize", @@ -714,6 +715,13 @@ // "foo-bar": ["task::Spawn", { "task_tag": "MyTag" }], }, }, + { + "context": "WorkspaceSidebar", + "use_key_equivalents": true, + "bindings": { + "cmd-n": "multi_workspace::NewWorkspaceInWindow", + }, + }, { "context": "Workspace && debugger_running", "use_key_equivalents": true, diff --git a/assets/keymaps/default-windows.json b/assets/keymaps/default-windows.json index 7b4c4cd7de7d71859079311a2e8513a25ff2ec79..9ed8dc2e85f9c3b924ab6ad8d38723ea8185729d 100644 --- a/assets/keymaps/default-windows.json +++ b/assets/keymaps/default-windows.json @@ -589,6 +589,7 @@ "ctrl-alt-b": "workspace::ToggleRightDock", "ctrl-b": "workspace::ToggleLeftDock", "ctrl-j": "workspace::ToggleBottomDock", + "ctrl-alt-j": "multi_workspace::ToggleWorkspaceSidebar", "ctrl-shift-y": "workspace::ToggleAllDocks", "alt-r": "workspace::ResetActiveDockSize", // For 0px parameter, uses UI font size value. @@ -657,6 +658,13 @@ "f5": "debugger::Continue", }, }, + { + "context": "WorkspaceSidebar", + "use_key_equivalents": true, + "bindings": { + "ctrl-n": "multi_workspace::NewWorkspaceInWindow", + }, + }, { "context": "ApplicationMenu", "use_key_equivalents": true, diff --git a/crates/agent_ui/src/acp.rs b/crates/agent_ui/src/acp.rs index 904c9a6c7b7e383d09b54f58115be2303ef8754a..f76e64b557e7ee2ec6054bd0fab0afc36b201e2c 100644 --- a/crates/agent_ui/src/acp.rs +++ b/crates/agent_ui/src/acp.rs @@ -5,7 +5,7 @@ mod mode_selector; mod model_selector; mod model_selector_popover; mod thread_history; -mod thread_view; +pub(crate) mod thread_view; pub use mode_selector::ModeSelector; pub use model_selector::AcpModelSelector; diff --git a/crates/agent_ui/src/acp/message_editor.rs b/crates/agent_ui/src/acp/message_editor.rs index 7c9966295483d5c0b0b5586b7d020c98db50f25f..faea22c45e32ba53f22367f89d8abe292f6e0753 100644 --- a/crates/agent_ui/src/acp/message_editor.rs +++ b/crates/agent_ui/src/acp/message_editor.rs @@ -815,8 +815,13 @@ impl MessageEditor { } if self.prompt_capabilities.borrow().image - && let Some(task) = - paste_images_as_context(self.editor.clone(), self.mention_set.clone(), window, cx) + && let Some(task) = paste_images_as_context( + self.editor.clone(), + self.mention_set.clone(), + self.workspace.clone(), + window, + cx, + ) { task.detach(); return; @@ -1084,6 +1089,7 @@ impl MessageEditor { let editor = self.editor.clone(); let mention_set = self.mention_set.clone(); + let workspace = self.workspace.clone(); let paths_receiver = cx.prompt_for_paths(gpui::PathPromptOptions { files: true, @@ -1134,7 +1140,14 @@ impl MessageEditor { images.push(gpui::Image::from_bytes(format, content)); } - crate::mention_set::insert_images_as_context(images, editor, mention_set, cx).await; + crate::mention_set::insert_images_as_context( + images, + editor, + mention_set, + workspace, + cx, + ) + .await; Ok(()) }) .detach_and_log_err(cx); diff --git a/crates/agent_ui/src/acp/thread_view.rs b/crates/agent_ui/src/acp/thread_view.rs index 8bc1cd1647acfb133164f6a2f72e89f62319e868..61c7301fb1e9d9b0fcc2b05144393ffea4248d9b 100644 --- a/crates/agent_ui/src/acp/thread_view.rs +++ b/crates/agent_ui/src/acp/thread_view.rs @@ -57,7 +57,9 @@ use ui::{ }; use util::defer; use util::{ResultExt, size::format_file_size, time::duration_alt_display}; -use workspace::{CollaboratorId, NewTerminal, Toast, Workspace, notifications::NotificationId}; +use workspace::{ + CollaboratorId, MultiWorkspace, NewTerminal, Toast, Workspace, notifications::NotificationId, +}; use zed_actions::agent::{Chat, ToggleModelSelector}; use zed_actions::assistant::OpenRulesLibrary; @@ -1985,9 +1987,30 @@ impl AcpServerView { self.show_notification(caption, icon, window, cx); } + fn agent_is_visible(&self, window: &Window, cx: &App) -> bool { + if window.is_window_active() { + let workspace_is_foreground = window + .root::() + .flatten() + .and_then(|mw| { + let mw = mw.read(cx); + self.workspace.upgrade().map(|ws| mw.workspace() == &ws) + }) + .unwrap_or(true); + + if workspace_is_foreground { + if let Some(workspace) = self.workspace.upgrade() { + return AgentPanel::is_visible(&workspace, cx); + } + } + } + + false + } + fn play_notification_sound(&self, window: &Window, cx: &mut App) { let settings = AgentSettings::get_global(cx); - if settings.play_sound_when_agent_done && !window.is_window_active() { + if settings.play_sound_when_agent_done && !self.agent_is_visible(window, cx) { Audio::play_sound(Sound::AgentDone, cx); } } @@ -2005,14 +2028,7 @@ impl AcpServerView { let settings = AgentSettings::get_global(cx); - let window_is_inactive = !window.is_window_active(); - let panel_is_hidden = self - .workspace - .upgrade() - .map(|workspace| AgentPanel::is_hidden(&workspace, cx)) - .unwrap_or(true); - - let should_notify = window_is_inactive || panel_is_hidden; + let should_notify = !self.agent_is_visible(window, cx); if !should_notify { return; @@ -2075,19 +2091,22 @@ impl AcpServerView { .push(cx.subscribe_in(&pop_up, window, { |this, _, event, window, cx| match event { AgentNotificationEvent::Accepted => { - let handle = window.window_handle(); + let Some(handle) = window.window_handle().downcast::() + else { + log::error!("root view should be a MultiWorkspace"); + return; + }; cx.activate(true); let workspace_handle = this.workspace.clone(); - // If there are multiple Zed windows, activate the correct one. cx.defer(move |cx| { handle - .update(cx, |_view, window, _cx| { + .update(cx, |multi_workspace, window, cx| { window.activate_window(); - if let Some(workspace) = workspace_handle.upgrade() { - workspace.update(_cx, |workspace, cx| { + multi_workspace.activate(workspace.clone(), cx); + workspace.update(cx, |workspace, cx| { workspace.focus_panel::(window, cx); }); } @@ -2112,12 +2131,12 @@ impl AcpServerView { .push({ let pop_up_weak = pop_up.downgrade(); - cx.observe_window_activation(window, move |_, window, cx| { - if window.is_window_active() + cx.observe_window_activation(window, move |this, window, cx| { + if this.agent_is_visible(window, cx) && let Some(pop_up) = pop_up_weak.upgrade() { - pop_up.update(cx, |_, cx| { - cx.emit(AgentNotificationEvent::Dismissed); + pop_up.update(cx, |notification, cx| { + notification.dismiss(cx); }); } }) @@ -2368,6 +2387,7 @@ pub(crate) mod tests { use action_log::ActionLog; use agent::{AgentTool, EditFileTool, FetchTool, TerminalTool, ToolPermissionContext}; use agent_client_protocol::SessionId; + use assistant_text_thread::TextThreadStore; use editor::MultiBufferOffset; use fs::FakeFs; use gpui::{EventEmitter, TestAppContext, VisualTestContext}; @@ -2377,7 +2397,7 @@ pub(crate) mod tests { use std::any::Any; use std::path::Path; use std::rc::Rc; - use workspace::Item; + use workspace::{Item, MultiWorkspace}; use super::*; @@ -2677,6 +2697,138 @@ pub(crate) mod tests { ); } + #[gpui::test] + async fn test_notification_when_workspace_is_background_in_multi_workspace( + cx: &mut TestAppContext, + ) { + init_test(cx); + + // Enable multi-workspace feature flag and init globals needed by AgentPanel + 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); + }); + + let project1 = Project::test(fs.clone(), [], cx).await; + + // Create a MultiWorkspace window with one workspace + let multi_workspace_handle = + cx.add_window(|window, cx| MultiWorkspace::test_new(project1.clone(), window, cx)); + + // Get workspace 1 (the initial workspace) + let workspace1 = multi_workspace_handle + .read_with(cx, |mw, _cx| mw.workspace().clone()) + .unwrap(); + + let cx = &mut VisualTestContext::from_window(multi_workspace_handle.into(), cx); + + workspace1.update_in(cx, |workspace, window, cx| { + let text_thread_store = + cx.new(|cx| TextThreadStore::fake(workspace.project().clone(), cx)); + let panel = + cx.new(|cx| crate::AgentPanel::new(workspace, text_thread_store, None, window, cx)); + workspace.add_panel(panel, window, cx); + + // Open the dock and activate the agent panel so it's visible + workspace.focus_panel::(window, cx); + }); + + cx.run_until_parked(); + + cx.read(|cx| { + assert!( + crate::AgentPanel::is_visible(&workspace1, cx), + "AgentPanel should be visible in workspace1's dock" + ); + }); + + // Set up thread view in workspace 1 + let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx))); + let history = cx.update(|window, cx| cx.new(|cx| AcpThreadHistory::new(None, window, cx))); + + let agent = StubAgentServer::default_response(); + let thread_view = cx.update(|window, cx| { + cx.new(|cx| { + AcpServerView::new( + Rc::new(agent), + None, + None, + workspace1.downgrade(), + project1.clone(), + Some(thread_store), + None, + history, + window, + cx, + ) + }) + }); + cx.run_until_parked(); + + let message_editor = message_editor(&thread_view, cx); + message_editor.update_in(cx, |editor, window, cx| { + editor.set_text("Hello", window, cx); + }); + + // Create a second workspace and switch to it. + // This makes workspace1 the "background" workspace. + let project2 = Project::test(fs, [], cx).await; + multi_workspace_handle + .update(cx, |mw, window, cx| { + let workspace2 = cx.new(|cx| Workspace::test_new(project2, window, cx)); + mw.activate(workspace2, cx); + }) + .unwrap(); + + cx.run_until_parked(); + + // Verify workspace1 is no longer the active workspace + multi_workspace_handle + .read_with(cx, |mw, _cx| { + assert_eq!(mw.active_workspace_index(), 1); + assert_ne!(mw.workspace(), &workspace1); + }) + .unwrap(); + + // Window is active, agent panel is visible in workspace1, but workspace1 + // is in the background. The notification should show because the user + // can't actually see the agent panel. + active_thread(&thread_view, cx).update_in(cx, |view, window, cx| view.send(window, cx)); + + cx.run_until_parked(); + + assert!( + cx.windows() + .iter() + .any(|window| window.downcast::().is_some()), + "Expected notification when workspace is in background within MultiWorkspace" + ); + + // Also verify: clicking "View Panel" should switch to workspace1. + cx.windows() + .iter() + .find_map(|window| window.downcast::()) + .unwrap() + .update(cx, |window, _, cx| window.accept(cx)) + .unwrap(); + + cx.run_until_parked(); + + multi_workspace_handle + .read_with(cx, |mw, _cx| { + assert_eq!( + mw.workspace(), + &workspace1, + "Expected workspace1 to become the active workspace after accepting notification" + ); + }) + .unwrap(); + } + #[gpui::test] async fn test_notification_respects_never_setting(cx: &mut TestAppContext) { init_test(cx); @@ -2839,18 +2991,18 @@ pub(crate) mod tests { } } - struct StubAgentServer { + pub(crate) struct StubAgentServer { connection: C, } impl StubAgentServer { - fn new(connection: C) -> Self { + pub(crate) fn new(connection: C) -> Self { Self { connection } } } impl StubAgentServer { - fn default_response() -> Self { + pub(crate) fn default_response() -> Self { let conn = StubAgentConnection::new(); conn.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( acp::ContentChunk::new("Default response".into()), diff --git a/crates/agent_ui/src/agent_diff.rs b/crates/agent_ui/src/agent_diff.rs index 850822679d2828b96ba6218c4d48e570764d6de6..bb00be46bad837a3b2242e885905709fbe38868c 100644 --- a/crates/agent_ui/src/agent_diff.rs +++ b/crates/agent_ui/src/agent_diff.rs @@ -1352,10 +1352,10 @@ impl AgentDiff { self.update_reviewing_editors(workspace, window, cx); } } - AcpThreadEvent::Stopped - | AcpThreadEvent::Error - | AcpThreadEvent::LoadError(_) - | AcpThreadEvent::Refusal => { + AcpThreadEvent::Stopped => { + self.update_reviewing_editors(workspace, window, cx); + } + AcpThreadEvent::Error | AcpThreadEvent::LoadError(_) | AcpThreadEvent::Refusal => { self.update_reviewing_editors(workspace, window, cx); } AcpThreadEvent::TitleUpdated diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index bd9c31a983b723c222987544561cea82a97bad2b..ba3b9943713db2e0f26b464da21a26237dc13d6e 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -81,10 +81,50 @@ const AGENT_PANEL_KEY: &str = "agent_panel"; const RECENTLY_UPDATED_MENU_LIMIT: usize = 6; const DEFAULT_THREAD_TITLE: &str = "New Thread"; -#[derive(Serialize, Deserialize, Debug)] +fn read_serialized_panel(workspace_id: workspace::WorkspaceId) -> Option { + let scope = KEY_VALUE_STORE.scoped(AGENT_PANEL_KEY); + let key = i64::from(workspace_id).to_string(); + scope + .read(&key) + .log_err() + .flatten() + .and_then(|json| serde_json::from_str::(&json).log_err()) +} + +async fn save_serialized_panel( + workspace_id: workspace::WorkspaceId, + panel: SerializedAgentPanel, +) -> Result<()> { + let scope = KEY_VALUE_STORE.scoped(AGENT_PANEL_KEY); + let key = i64::from(workspace_id).to_string(); + scope.write(key, serde_json::to_string(&panel)?).await?; + Ok(()) +} + +/// Migration: reads the original single-panel format stored under the +/// `"agent_panel"` KVP key before per-workspace keying was introduced. +fn read_legacy_serialized_panel() -> Option { + KEY_VALUE_STORE + .read_kvp(AGENT_PANEL_KEY) + .log_err() + .flatten() + .and_then(|json| serde_json::from_str::(&json).log_err()) +} + +#[derive(Serialize, Deserialize, Debug, Clone)] struct SerializedAgentPanel { width: Option, selected_agent: Option, + #[serde(default)] + last_active_thread: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +struct SerializedActiveThread { + session_id: String, + agent_type: AgentType, + title: Option, + cwd: Option, } pub fn init(cx: &mut App) { @@ -428,6 +468,7 @@ pub struct AgentPanel { focus_handle: FocusHandle, active_view: ActiveView, previous_view: Option, + _active_view_observation: Option, new_thread_menu_handle: PopoverMenuHandle, agent_panel_menu_handle: PopoverMenuHandle, agent_navigation_menu_handle: PopoverMenuHandle, @@ -445,18 +486,44 @@ pub struct AgentPanel { impl AgentPanel { fn serialize(&mut self, cx: &mut Context) { + let workspace_id = self + .workspace + .read_with(cx, |workspace, _| workspace.database_id()) + .ok() + .flatten(); + + let Some(workspace_id) = workspace_id else { + return; + }; + let width = self.width; let selected_agent = self.selected_agent.clone(); + + let last_active_thread = self.active_agent_thread(cx).map(|thread| { + let thread = thread.read(cx); + let title = thread.title(); + SerializedActiveThread { + session_id: thread.session_id().0.to_string(), + agent_type: self.selected_agent.clone(), + title: if title.as_ref() != DEFAULT_THREAD_TITLE { + Some(title.to_string()) + } else { + None + }, + cwd: None, + } + }); + self.pending_serialization = Some(cx.background_spawn(async move { - KEY_VALUE_STORE - .write_kvp( - AGENT_PANEL_KEY.into(), - serde_json::to_string(&SerializedAgentPanel { - width, - selected_agent: Some(selected_agent), - })?, - ) - .await?; + save_serialized_panel( + workspace_id, + SerializedAgentPanel { + width, + selected_agent: Some(selected_agent), + last_active_thread, + }, + ) + .await?; anyhow::Ok(()) })); } @@ -472,16 +539,18 @@ impl AgentPanel { Ok(prompt_store) => prompt_store.await.ok(), Err(_) => None, }; - let serialized_panel = if let Some(panel) = cx - .background_spawn(async move { KEY_VALUE_STORE.read_kvp(AGENT_PANEL_KEY) }) - .await - .log_err() - .flatten() - { - serde_json::from_str::(&panel).log_err() - } else { - None - }; + let workspace_id = workspace + .read_with(cx, |workspace, _| workspace.database_id()) + .ok() + .flatten(); + + let serialized_panel = cx + .background_spawn(async move { + workspace_id + .and_then(read_serialized_panel) + .or_else(read_legacy_serialized_panel) + }) + .await; let slash_commands = Arc::new(SlashCommandWorkingSet::default()); let text_thread_store = workspace @@ -500,15 +569,30 @@ impl AgentPanel { let panel = cx.new(|cx| Self::new(workspace, text_thread_store, prompt_store, window, cx)); - if let Some(serialized_panel) = serialized_panel { + if let Some(serialized_panel) = &serialized_panel { panel.update(cx, |panel, cx| { panel.width = serialized_panel.width.map(|w| w.round()); - if let Some(selected_agent) = serialized_panel.selected_agent { + if let Some(selected_agent) = serialized_panel.selected_agent.clone() { panel.selected_agent = selected_agent; } cx.notify(); }); } + + if let Some(thread_info) = serialized_panel.and_then(|p| p.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); + }); + } panel })?; @@ -516,7 +600,7 @@ impl AgentPanel { }) } - fn new( + pub(crate) fn new( workspace: &Workspace, text_thread_store: Entity, prompt_store: Option>, @@ -646,6 +730,7 @@ impl AgentPanel { focus_handle: cx.focus_handle(), context_server_registry, previous_view: None, + _active_view_observation: None, new_thread_menu_handle: PopoverMenuHandle::default(), agent_panel_menu_handle: PopoverMenuHandle::default(), agent_navigation_menu_handle: PopoverMenuHandle::default(), @@ -714,7 +799,7 @@ impl AgentPanel { &self.context_server_registry } - pub fn is_hidden(workspace: &Entity, cx: &App) -> bool { + pub fn is_visible(workspace: &Entity, cx: &App) -> bool { let workspace_read = workspace.read(cx); workspace_read @@ -722,15 +807,13 @@ impl AgentPanel { .map(|panel| { let panel_id = Entity::entity_id(&panel); - let is_visible = workspace_read.all_docks().iter().any(|dock| { + workspace_read.all_docks().iter().any(|dock| { dock.read(cx) .visible_panel() .is_some_and(|visible_panel| visible_panel.panel_id() == panel_id) - }); - - !is_visible + }) }) - .unwrap_or(true) + .unwrap_or(false) } pub(crate) fn active_thread_view(&self) -> Option<&Entity> { @@ -1023,6 +1106,7 @@ impl AgentPanel { ActiveView::Configuration | ActiveView::History { .. } => { if let Some(previous_view) = self.previous_view.take() { self.active_view = previous_view; + cx.emit(AgentPanelEvent::ActiveViewChanged); match &self.active_view { ActiveView::AgentThread { thread_view } => { @@ -1419,7 +1503,7 @@ impl AgentPanel { } } - pub(crate) fn active_agent_thread(&self, cx: &App) -> Option> { + pub fn active_agent_thread(&self, cx: &App) -> Option> { match &self.active_view { ActiveView::AgentThread { thread_view, .. } => thread_view .read(cx) @@ -1475,9 +1559,21 @@ impl AgentPanel { self.active_view = new_view; } + self._active_view_observation = match &self.active_view { + ActiveView::AgentThread { thread_view } => { + Some(cx.observe(thread_view, |this, _, cx| { + cx.emit(AgentPanelEvent::ActiveViewChanged); + this.serialize(cx); + cx.notify(); + })) + } + _ => None, + }; + if focus { self.focus_handle(cx).focus(window, cx); } + cx.emit(AgentPanelEvent::ActiveViewChanged); } fn populate_recently_updated_menu_section( @@ -1750,7 +1846,12 @@ 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 { @@ -3284,3 +3385,151 @@ impl AgentPanel { self.active_thread_view() } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::acp::thread_view::tests::{StubAgentServer, init_test}; + use assistant_text_thread::TextThreadStore; + use feature_flags::FeatureFlagAppExt; + use fs::FakeFs; + use gpui::{TestAppContext, VisualTestContext}; + use project::Project; + use workspace::{MultiWorkspace, Workspace}; + + #[gpui::test] + async fn test_active_thread_serialize_and_load_round_trip(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); + }); + + // --- Create a MultiWorkspace window with two workspaces --- + let fs = FakeFs::new(cx.executor()); + let project_a = Project::test(fs.clone(), [], cx).await; + let project_b = Project::test(fs, [], cx).await; + + let multi_workspace = + cx.add_window(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx)); + + let workspace_a = multi_workspace + .read_with(cx, |multi_workspace, _cx| { + multi_workspace.workspace().clone() + }) + .unwrap(); + + let workspace_b = multi_workspace + .update(cx, |multi_workspace, window, cx| { + let workspace = cx.new(|cx| Workspace::test_new(project_b.clone(), window, cx)); + multi_workspace.activate(workspace.clone(), cx); + workspace + }) + .unwrap(); + + workspace_a.update(cx, |workspace, _cx| { + workspace.set_random_database_id(); + }); + workspace_b.update(cx, |workspace, _cx| { + workspace.set_random_database_id(); + }); + + let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx); + + // --- Set up workspace A: width=300, with an active thread --- + let panel_a = workspace_a.update_in(cx, |workspace, window, cx| { + let text_thread_store = cx.new(|cx| TextThreadStore::fake(project_a.clone(), cx)); + cx.new(|cx| AgentPanel::new(workspace, text_thread_store, None, window, cx)) + }); + + panel_a.update(cx, |panel, _cx| { + panel.width = Some(px(300.0)); + }); + + panel_a.update_in(cx, |panel, window, cx| { + panel.open_external_thread_with_server( + Rc::new(StubAgentServer::default_response()), + window, + cx, + ); + }); + + cx.run_until_parked(); + + panel_a.read_with(cx, |panel, cx| { + assert!( + panel.active_agent_thread(cx).is_some(), + "workspace A should have an active thread after connection" + ); + }); + + let agent_type_a = panel_a.read_with(cx, |panel, _cx| panel.selected_agent.clone()); + + // --- Set up workspace B: ClaudeCode, width=400, no active thread --- + let panel_b = workspace_b.update_in(cx, |workspace, window, cx| { + let text_thread_store = cx.new(|cx| TextThreadStore::fake(project_b.clone(), cx)); + cx.new(|cx| AgentPanel::new(workspace, text_thread_store, None, window, cx)) + }); + + panel_b.update(cx, |panel, _cx| { + panel.width = Some(px(400.0)); + panel.selected_agent = AgentType::ClaudeCode; + }); + + // --- Serialize both panels --- + panel_a.update(cx, |panel, cx| panel.serialize(cx)); + panel_b.update(cx, |panel, cx| panel.serialize(cx)); + cx.run_until_parked(); + + // --- Load fresh panels for each workspace and verify independent state --- + let prompt_builder = Arc::new(prompt_store::PromptBuilder::new(None).unwrap()); + + let async_cx = cx.update(|window, cx| window.to_async(cx)); + let loaded_a = AgentPanel::load(workspace_a.downgrade(), prompt_builder.clone(), async_cx) + .await + .expect("panel A load should succeed"); + cx.run_until_parked(); + + let async_cx = cx.update(|window, cx| window.to_async(cx)); + let loaded_b = AgentPanel::load(workspace_b.downgrade(), prompt_builder.clone(), async_cx) + .await + .expect("panel B load should succeed"); + cx.run_until_parked(); + + // Workspace A should restore its thread, width, and agent type + loaded_a.read_with(cx, |panel, _cx| { + assert_eq!( + panel.width, + Some(px(300.0)), + "workspace A width should be restored" + ); + assert_eq!( + 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 + loaded_b.read_with(cx, |panel, _cx| { + assert_eq!( + panel.width, + Some(px(400.0)), + "workspace B width should be restored" + ); + assert_eq!( + panel.selected_agent, + AgentType::ClaudeCode, + "workspace B agent type should be restored" + ); + assert!( + panel.active_thread_view().is_none(), + "workspace B should have no active thread" + ); + }); + } +} diff --git a/crates/agent_ui/src/agent_ui.rs b/crates/agent_ui/src/agent_ui.rs index e521cb33b120faaa4672a286e895e9e1ce92a5e4..3bfd58ff4bb2defb8e3d7c11a52fed4575f7b747 100644 --- a/crates/agent_ui/src/agent_ui.rs +++ b/crates/agent_ui/src/agent_ui.rs @@ -49,7 +49,7 @@ use std::any::TypeId; use workspace::Workspace; use crate::agent_configuration::{ConfigureContextServerModal, ManageProfilesModal}; -pub use crate::agent_panel::{AgentPanel, ConcreteAssistantPanelDelegate}; +pub use crate::agent_panel::{AgentPanel, AgentPanelEvent, ConcreteAssistantPanelDelegate}; use crate::agent_registry_ui::AgentRegistryPage; pub use crate::inline_assistant::InlineAssistant; pub use agent_diff::{AgentDiffPane, AgentDiffToolbar}; @@ -418,6 +418,12 @@ fn update_command_palette_filter(cx: &mut App) { filter.hide_action_types(&[TypeId::of::()]); } } + + if agent_v2_enabled { + filter.show_namespace("multi_workspace"); + } else { + filter.hide_namespace("multi_workspace"); + } }); } diff --git a/crates/agent_ui/src/inline_prompt_editor.rs b/crates/agent_ui/src/inline_prompt_editor.rs index 48c597f0431c480ade5810db99c36a890ec65093..fd6b9bc5028c64f68dbed24dc44f3014376d1aff 100644 --- a/crates/agent_ui/src/inline_prompt_editor.rs +++ b/crates/agent_ui/src/inline_prompt_editor.rs @@ -417,8 +417,13 @@ impl PromptEditor { fn paste(&mut self, _: &Paste, window: &mut Window, cx: &mut Context) { if inline_assistant_model_supports_images(cx) - && let Some(task) = - paste_images_as_context(self.editor.clone(), self.mention_set.clone(), window, cx) + && let Some(task) = paste_images_as_context( + self.editor.clone(), + self.mention_set.clone(), + self.workspace.clone(), + window, + cx, + ) { task.detach(); } diff --git a/crates/agent_ui/src/mention_set.rs b/crates/agent_ui/src/mention_set.rs index 6dcd3b3c9558e85c0e688b8a1a21300ed10f9b1f..53c6c8f531aa66ee03412418958d91fa9ccd625b 100644 --- a/crates/agent_ui/src/mention_set.rs +++ b/crates/agent_ui/src/mention_set.rs @@ -297,8 +297,9 @@ impl MentionSet { self.mentions.insert(crease_id, (mention_uri, task.clone())); // Notify the user if we failed to load the mentioned context - cx.spawn_in(window, async move |this, cx| { - let result = task.await.notify_async_err(cx); + let workspace = workspace.downgrade(); + cx.spawn(async move |this, mut cx| { + let result = task.await.notify_workspace_async_err(workspace, &mut cx); drop(tx); if result.is_none() { this.update(cx, |this, cx| { @@ -644,6 +645,7 @@ pub(crate) async fn insert_images_as_context( images: Vec, editor: Entity, mention_set: Entity, + workspace: WeakEntity, cx: &mut gpui::AsyncWindowContext, ) { if images.is_empty() { @@ -723,7 +725,11 @@ pub(crate) async fn insert_images_as_context( mention_set.insert_mention(crease_id, MentionUri::PastedImage, task.clone()) }); - if task.await.notify_async_err(cx).is_none() { + if task + .await + .notify_workspace_async_err(workspace.clone(), cx) + .is_none() + { editor.update(cx, |editor, cx| { editor.edit([(start_anchor..end_anchor, "")], cx); }); @@ -737,11 +743,12 @@ pub(crate) async fn insert_images_as_context( pub(crate) fn paste_images_as_context( editor: Entity, mention_set: Entity, + workspace: WeakEntity, window: &mut Window, cx: &mut App, ) -> Option> { let clipboard = cx.read_from_clipboard()?; - Some(window.spawn(cx, async move |cx| { + Some(window.spawn(cx, async move |mut cx| { use itertools::Itertools; let (mut images, paths) = clipboard .into_entries() @@ -788,7 +795,7 @@ pub(crate) fn paste_images_as_context( }) .ok(); - insert_images_as_context(images, editor, mention_set, cx).await; + insert_images_as_context(images, editor, mention_set, workspace, &mut cx).await; })) } diff --git a/crates/agent_ui/src/ui/agent_notification.rs b/crates/agent_ui/src/ui/agent_notification.rs index 34ca0bb32a82aa23d1b954554ce2dfec436bfe1c..371523f129869786f13d1a220747f4d0d944d1e5 100644 --- a/crates/agent_ui/src/ui/agent_notification.rs +++ b/crates/agent_ui/src/ui/agent_notification.rs @@ -75,6 +75,16 @@ pub enum AgentNotificationEvent { impl EventEmitter for AgentNotification {} +impl AgentNotification { + pub fn accept(&mut self, cx: &mut Context) { + cx.emit(AgentNotificationEvent::Accepted); + } + + pub fn dismiss(&mut self, cx: &mut Context) { + cx.emit(AgentNotificationEvent::Dismissed); + } +} + impl Render for AgentNotification { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let ui_font = theme::setup_ui_font(window, cx); @@ -174,14 +184,14 @@ impl Render for AgentNotification { .style(ButtonStyle::Tinted(ui::TintColor::Accent)) .full_width() .on_click({ - cx.listener(move |_this, _event, _, cx| { - cx.emit(AgentNotificationEvent::Accepted); + cx.listener(move |this, _event, _, cx| { + this.accept(cx); }) }), ) .child(Button::new("dismiss", "Dismiss").full_width().on_click({ - cx.listener(move |_, _event, _, cx| { - cx.emit(AgentNotificationEvent::Dismissed); + cx.listener(move |this, _event, _, cx| { + this.dismiss(cx); }) })), ) diff --git a/crates/collab/tests/integration/channel_guest_tests.rs b/crates/collab/tests/integration/channel_guest_tests.rs index 0d98af2a188ce18cfab5905e5b464c77101dfa00..85d69914a832c65260014f5f5792eb664879f715 100644 --- a/crates/collab/tests/integration/channel_guest_tests.rs +++ b/crates/collab/tests/integration/channel_guest_tests.rs @@ -34,9 +34,11 @@ async fn test_channel_guests( cx_a.executor().run_until_parked(); // Client B joins channel A as a guest - cx_b.update(|cx| workspace::join_channel(channel_id, client_b.app_state.clone(), None, cx)) - .await - .unwrap(); + cx_b.update(|cx| { + workspace::join_channel(channel_id, client_b.app_state.clone(), None, None, cx) + }) + .await + .unwrap(); // b should be following a in the shared project. // B is a guest, @@ -76,9 +78,11 @@ async fn test_channel_guest_promotion(cx_a: &mut TestAppContext, cx_b: &mut Test .await; let project_a = client_a.build_test_project(cx_a).await; - cx_a.update(|cx| workspace::join_channel(channel_id, client_a.app_state.clone(), None, cx)) - .await - .unwrap(); + cx_a.update(|cx| { + workspace::join_channel(channel_id, client_a.app_state.clone(), None, None, cx) + }) + .await + .unwrap(); // Client A shares a project in the channel active_call_a @@ -88,9 +92,11 @@ async fn test_channel_guest_promotion(cx_a: &mut TestAppContext, cx_b: &mut Test cx_a.run_until_parked(); // Client B joins channel A as a guest - cx_b.update(|cx| workspace::join_channel(channel_id, client_b.app_state.clone(), None, cx)) - .await - .unwrap(); + cx_b.update(|cx| { + workspace::join_channel(channel_id, client_b.app_state.clone(), None, None, cx) + }) + .await + .unwrap(); cx_a.run_until_parked(); // client B opens 1.txt as a guest diff --git a/crates/collab/tests/integration/editor_tests.rs b/crates/collab/tests/integration/editor_tests.rs index 1612e32833dd07dd5fa2294d5bb5a90442883f71..a973c9f17ec5488746a9ad6594a3e99fb711c203 100644 --- a/crates/collab/tests/integration/editor_tests.rs +++ b/crates/collab/tests/integration/editor_tests.rs @@ -19,7 +19,8 @@ use fs::Fs; use futures::{SinkExt, StreamExt, channel::mpsc, lock::Mutex}; use git::repository::repo_path; use gpui::{ - App, Rgba, SharedString, TestAppContext, UpdateGlobal, VisualContext, VisualTestContext, + App, AppContext as _, Entity, Rgba, SharedString, TestAppContext, UpdateGlobal, VisualContext, + VisualTestContext, }; use indoc::indoc; use language::{FakeLspAdapter, language_settings::language_settings, rust_lang}; @@ -51,7 +52,7 @@ use std::{ }; use text::Point; use util::{path, rel_path::rel_path, uri}; -use workspace::{CloseIntent, Workspace}; +use workspace::{CloseIntent, MultiWorkspace, Workspace}; #[gpui::test(iterations = 10)] async fn test_host_disconnect( @@ -95,34 +96,46 @@ async fn test_host_disconnect( assert!(worktree_a.read_with(cx_a, |tree, _| tree.has_update_observer())); - let workspace_b = cx_b.add_window(|window, cx| { - Workspace::new( - None, - project_b.clone(), - client_b.app_state.clone(), - window, - cx, - ) + let window_b = cx_b.add_window(|window, cx| { + let workspace = cx.new(|cx| { + Workspace::new( + None, + project_b.clone(), + client_b.app_state.clone(), + window, + cx, + ) + }); + MultiWorkspace::new(workspace, cx) }); - let cx_b = &mut VisualTestContext::from_window(*workspace_b, cx_b); - let workspace_b_view = workspace_b.root(cx_b).unwrap(); + let cx_b = &mut VisualTestContext::from_window(*window_b, cx_b); + let workspace_b = window_b + .root(cx_b) + .unwrap() + .read_with(cx_b, |multi_workspace, _| { + multi_workspace.workspace().clone() + }); - let editor_b = workspace_b - .update(cx_b, |workspace, window, cx| { + let editor_b: Entity = workspace_b + .update_in(cx_b, |workspace, window, cx| { workspace.open_path((worktree_id, rel_path("b.txt")), None, true, window, cx) }) - .unwrap() .await .unwrap() .downcast::() .unwrap(); //TODO: focus - assert!(cx_b.update_window_entity(&editor_b, |editor, window, _| editor.is_focused(window))); - editor_b.update_in(cx_b, |editor, window, cx| editor.insert("X", window, cx)); + assert!( + cx_b.update_window_entity(&editor_b, |editor: &mut Editor, window, _| editor + .is_focused(window)) + ); + editor_b.update_in(cx_b, |editor: &mut Editor, window, cx| { + editor.insert("X", window, cx) + }); cx_b.update(|_, cx| { - assert!(workspace_b_view.read(cx).is_edited()); + assert!(workspace_b.read(cx).is_edited()); }); // Drop client A's connection. Collaborators should disappear and the project should not be shown as shared. @@ -140,19 +153,16 @@ async fn test_host_disconnect( assert!(worktree_a.read_with(cx_a, |tree, _| !tree.has_update_observer())); // Ensure client B's edited state is reset and that the whole window is blurred. - workspace_b - .update(cx_b, |workspace, _, cx| { - assert!(workspace.active_modal::(cx).is_some()); - assert!(!workspace.is_edited()); - }) - .unwrap(); + workspace_b.update(cx_b, |workspace, cx| { + assert!(workspace.active_modal::(cx).is_some()); + assert!(!workspace.is_edited()); + }); // Ensure client B is not prompted to save edits when closing window after disconnecting. - let can_close = workspace_b - .update(cx_b, |workspace, window, cx| { + let can_close: bool = workspace_b + .update_in(cx_b, |workspace, window, cx| { workspace.prepare_to_close(CloseIntent::Quit, window, cx) }) - .unwrap() .await .unwrap(); assert!(can_close); diff --git a/crates/collab/tests/integration/following_tests.rs b/crates/collab/tests/integration/following_tests.rs index 295105ecbd9f8663469276fe4d0d197708a4254e..6bdb06a6c5a0ffb95bc75a026a26c4797030f8ce 100644 --- a/crates/collab/tests/integration/following_tests.rs +++ b/crates/collab/tests/integration/following_tests.rs @@ -17,7 +17,7 @@ use serde_json::json; use settings::SettingsStore; use text::{Point, ToPoint}; use util::{path, rel_path::rel_path, test::sample_text}; -use workspace::{CollaboratorId, SplitDirection, Workspace, item::ItemHandle as _}; +use workspace::{CollaboratorId, MultiWorkspace, SplitDirection, Workspace, item::ItemHandle as _}; use super::TestClient; @@ -1555,9 +1555,9 @@ async fn test_following_across_workspaces(cx_a: &mut TestAppContext, cx_b: &mut let mut cx_b2 = VisualTestContext::from_window(window_b_project_a, cx_b); let workspace_b_project_a = window_b_project_a - .downcast::() + .downcast::() .unwrap() - .root(cx_b) + .read_with(cx_b, |mw, _| mw.workspace().clone()) .unwrap(); // assert that b is following a in project a in w.rs @@ -1657,9 +1657,9 @@ async fn test_following_across_workspaces(cx_a: &mut TestAppContext, cx_b: &mut .unwrap(); let cx_a2 = &mut VisualTestContext::from_window(window_a_project_b, cx_a); let workspace_a_project_b = window_a_project_b - .downcast::() + .downcast::() .unwrap() - .root(cx_a) + .read_with(cx_a, |mw, _| mw.workspace().clone()) .unwrap(); executor.run_until_parked(); @@ -2144,7 +2144,7 @@ pub(crate) async fn join_channel( client: &TestClient, cx: &mut TestAppContext, ) -> anyhow::Result<()> { - cx.update(|cx| workspace::join_channel(channel_id, client.app_state.clone(), None, cx)) + cx.update(|cx| workspace::join_channel(channel_id, client.app_state.clone(), None, None, cx)) .await } diff --git a/crates/collab/tests/integration/git_tests.rs b/crates/collab/tests/integration/git_tests.rs index 1378fcf95c63c883ee8dd424dc10ac67ccd774bd..63cee5886d5096cb0e3fbee3886b90f66c675bfa 100644 --- a/crates/collab/tests/integration/git_tests.rs +++ b/crates/collab/tests/integration/git_tests.rs @@ -3,11 +3,11 @@ use std::path::Path; use call::ActiveCall; use git::status::{FileStatus, StatusCode, TrackedStatus}; use git_ui::project_diff::ProjectDiff; -use gpui::{TestAppContext, VisualTestContext}; +use gpui::{AppContext as _, TestAppContext, VisualTestContext}; use project::ProjectPath; use serde_json::json; use util::{path, rel_path::rel_path}; -use workspace::Workspace; +use workspace::{MultiWorkspace, Workspace}; // use crate::TestServer; @@ -57,17 +57,25 @@ async fn test_project_diff(cx_a: &mut TestAppContext, cx_b: &mut TestAppContext) cx_b.update(editor::init); cx_b.update(git_ui::init); let project_b = client_b.join_remote_project(project_id, cx_b).await; - let workspace_b = cx_b.add_window(|window, cx| { - Workspace::new( - None, - project_b.clone(), - client_b.app_state.clone(), - window, - cx, - ) + let window_b = cx_b.add_window(|window, cx| { + let workspace = cx.new(|cx| { + Workspace::new( + None, + project_b.clone(), + client_b.app_state.clone(), + window, + cx, + ) + }); + MultiWorkspace::new(workspace, cx) }); - let cx_b = &mut VisualTestContext::from_window(*workspace_b, cx_b); - let workspace_b = workspace_b.root(cx_b).unwrap(); + let cx_b = &mut VisualTestContext::from_window(*window_b, cx_b); + let workspace_b = window_b + .root(cx_b) + .unwrap() + .read_with(cx_b, |multi_workspace, _| { + multi_workspace.workspace().clone() + }); cx_b.update(|window, cx| { window diff --git a/crates/collab/tests/integration/remote_editing_collaboration_tests.rs b/crates/collab/tests/integration/remote_editing_collaboration_tests.rs index 1f4dd0d353234f61675b5beefd2226c3d684c062..c6daedff803b6f5cada32750f90dd1adca5aeda6 100644 --- a/crates/collab/tests/integration/remote_editing_collaboration_tests.rs +++ b/crates/collab/tests/integration/remote_editing_collaboration_tests.rs @@ -8,7 +8,9 @@ use editor::{Editor, EditorMode, MultiBuffer}; use extension::ExtensionHostProxy; use fs::{FakeFs, Fs as _, RemoveOptions}; use futures::StreamExt as _; -use gpui::{AppContext as _, BackgroundExecutor, TestAppContext, UpdateGlobal as _, VisualContext}; +use gpui::{ + AppContext as _, BackgroundExecutor, TestAppContext, UpdateGlobal as _, VisualContext as _, +}; use http_client::BlockedHttpClient; use language::{ FakeLspAdapter, Language, LanguageConfig, LanguageMatcher, LanguageRegistry, @@ -663,7 +665,7 @@ async fn test_remote_server_debugger( let workspace_window = cx_a .window_handle() - .downcast::() + .downcast::() .unwrap(); let session = debugger_ui::tests::start_debug_session(&workspace_window, cx_a, |_| {}).unwrap(); @@ -671,13 +673,16 @@ async fn test_remote_server_debugger( debug_panel.update(cx_a, |debug_panel, cx| { assert_eq!( debug_panel.active_session().unwrap().read(cx).session(cx), - session + session.clone() ) }); - session.update(cx_a, |session, _| { - assert_eq!(session.binary().unwrap().command.as_deref(), Some("mock")); - }); + session.update( + cx_a, + |session: &mut project::debugger::session::Session, _| { + assert_eq!(session.binary().unwrap().command.as_deref(), Some("mock")); + }, + ); let shutdown_session = workspace.update(cx_a, |workspace, cx| { workspace.project().update(cx, |project, cx| { @@ -772,7 +777,7 @@ async fn test_slow_adapter_startup_retries( let workspace_window = cx_a .window_handle() - .downcast::() + .downcast::() .unwrap(); let count = Arc::new(AtomicUsize::new(0)); @@ -804,7 +809,10 @@ async fn test_slow_adapter_startup_retries( .unwrap(); cx_a.run_until_parked(); - let client = session.update(cx_a, |session, _| session.adapter_client().unwrap()); + let client = session.update( + cx_a, + |session: &mut project::debugger::session::Session, _| session.adapter_client().unwrap(), + ); client .fake_event(dap::messages::Events::Stopped(dap::StoppedEvent { reason: dap::StoppedEventReason::Pause, diff --git a/crates/collab/tests/integration/test_server.rs b/crates/collab/tests/integration/test_server.rs index f28d247f67a149ef6d489b9bc6ab7b43eb77350f..0a2ec25cde361259344493d3532afeb2050aea71 100644 --- a/crates/collab/tests/integration/test_server.rs +++ b/crates/collab/tests/integration/test_server.rs @@ -45,7 +45,7 @@ use std::{ }, }; use util::path; -use workspace::{Workspace, WorkspaceStore}; +use workspace::{MultiWorkspace, Workspace, WorkspaceStore}; use livekit_client::test::TestServer as LivekitTestServer; @@ -843,7 +843,7 @@ impl TestClient { channel_id: ChannelId, cx: &'a mut TestAppContext, ) -> (Entity, &'a mut VisualTestContext) { - cx.update(|cx| workspace::join_channel(channel_id, self.app_state.clone(), None, cx)) + cx.update(|cx| workspace::join_channel(channel_id, self.app_state.clone(), None, None, cx)) .await .unwrap(); cx.run_until_parked(); @@ -897,10 +897,19 @@ impl TestClient { project: &Entity, cx: &'a mut TestAppContext, ) -> (Entity, &'a mut VisualTestContext) { - cx.add_window_view(|window, cx| { + let app_state = self.app_state.clone(); + let project = project.clone(); + let window = cx.add_window(|window, cx| { window.activate_window(); - Workspace::new(None, project.clone(), self.app_state.clone(), window, cx) - }) + let workspace = cx.new(|cx| Workspace::new(None, project, app_state, window, cx)); + MultiWorkspace::new(workspace, cx) + }); + let cx = VisualTestContext::from_window(*window, cx).into_mut(); + cx.run_until_parked(); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + (workspace, cx) } pub async fn build_test_workspace<'a>( @@ -908,19 +917,33 @@ impl TestClient { cx: &'a mut TestAppContext, ) -> (Entity, &'a mut VisualTestContext) { let project = self.build_test_project(cx).await; - cx.add_window_view(|window, cx| { + let app_state = self.app_state.clone(); + let window = cx.add_window(|window, cx| { window.activate_window(); - Workspace::new(None, project.clone(), self.app_state.clone(), window, cx) - }) + let workspace = cx.new(|cx| Workspace::new(None, project, app_state, window, cx)); + MultiWorkspace::new(workspace, cx) + }); + let cx = VisualTestContext::from_window(*window, cx).into_mut(); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + (workspace, cx) } pub fn active_workspace<'a>( &'a self, cx: &'a mut TestAppContext, ) -> (Entity, &'a mut VisualTestContext) { - let window = cx.update(|cx| cx.active_window().unwrap().downcast::().unwrap()); + let window = cx.update(|cx| { + cx.active_window() + .unwrap() + .downcast::() + .unwrap() + }); - let entity = window.root(cx).unwrap(); + let entity = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); let cx = VisualTestContext::from_window(*window.deref(), cx).into_mut(); // it might be nice to try and cleanup these at the end of each test. (entity, cx) @@ -931,8 +954,15 @@ pub fn open_channel_notes( channel_id: ChannelId, cx: &mut VisualTestContext, ) -> Task>> { - let window = cx.update(|_, cx| cx.active_window().unwrap().downcast::().unwrap()); - let entity = window.root(cx).unwrap(); + let window = cx.update(|_, cx| { + cx.active_window() + .unwrap() + .downcast::() + .unwrap() + }); + let entity = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); cx.update(|window, cx| ChannelView::open(channel_id, None, entity.clone(), window, cx)) } diff --git a/crates/collab_ui/src/collab_panel.rs b/crates/collab_ui/src/collab_panel.rs index 663d64d56d3e9832a6a92c2916fa62d22afd23e6..c0a68efdc7107800a0abfd5c522e5b0ed541a964 100644 --- a/crates/collab_ui/src/collab_panel.rs +++ b/crates/collab_ui/src/collab_panel.rs @@ -36,7 +36,8 @@ use ui::{ }; use util::{ResultExt, TryFutureExt, maybe}; use workspace::{ - CopyRoomId, Deafen, LeaveCall, Mute, OpenChannelNotes, ScreenShare, ShareProject, Workspace, + CopyRoomId, Deafen, LeaveCall, MultiWorkspace, Mute, OpenChannelNotes, ScreenShare, + ShareProject, Workspace, dock::{DockPosition, Panel, PanelEvent}, notifications::{DetachAndPromptErr, NotifyResultExt}, }; @@ -120,6 +121,7 @@ pub fn init(cx: &mut App) { if let Some(room) = ActiveCall::global(cx).read(cx).room() { let romo_id_fut = room.read(cx).room_id(); + let workspace_handle = cx.weak_entity(); cx.spawn(async move |workspace, cx| { let room_id = romo_id_fut.await.context("Failed to get livekit room")?; workspace.update(cx, |workspace, cx| { @@ -134,7 +136,7 @@ pub fn init(cx: &mut App) { ); }) }) - .detach_and_notify_err(window, cx); + .detach_and_notify_err(workspace_handle, window, cx); } else { workspace.show_error(&"There’s no active call; join one first.", cx); } @@ -2177,12 +2179,13 @@ impl CollabPanel { &["Remove", "Cancel"], cx, ); - cx.spawn_in(window, async move |this, cx| { + let workspace = self.workspace.clone(); + cx.spawn_in(window, async move |this, mut cx| { if answer.await? == 0 { channel_store .update(cx, |channels, _| channels.remove_channel(channel_id)) .await - .notify_async_err(cx); + .notify_workspace_async_err(workspace, &mut cx); this.update_in(cx, |_, window, cx| cx.focus_self(window)) .ok(); } @@ -2211,12 +2214,13 @@ impl CollabPanel { &["Remove", "Cancel"], cx, ); - cx.spawn_in(window, async move |_, cx| { + let workspace = self.workspace.clone(); + cx.spawn_in(window, async move |_, mut cx| { if answer.await? == 0 { user_store .update(cx, |store, cx| store.remove_contact(user_id, cx)) .await - .notify_async_err(cx); + .notify_workspace_async_err(workspace, &mut cx); } anyhow::Ok(()) }) @@ -2267,13 +2271,15 @@ impl CollabPanel { let Some(workspace) = self.workspace.upgrade() else { return; }; - let Some(handle) = window.window_handle().downcast::() else { + + let Some(handle) = window.window_handle().downcast::() else { return; }; workspace::join_channel( channel_id, workspace.read(cx).app_state().clone(), Some(handle), + Some(self.workspace.clone()), cx, ) .detach_and_prompt_err("Failed to join channel", window, cx, |_, _, _| None) @@ -2316,12 +2322,13 @@ impl CollabPanel { .full_width() .on_click(cx.listener(|this, _, window, cx| { let client = this.client.clone(); - cx.spawn_in(window, async move |_, cx| { + let workspace = this.workspace.clone(); + cx.spawn_in(window, async move |_, mut cx| { client - .connect(true, cx) + .connect(true, &mut cx) .await .into_response() - .notify_async_err(cx); + .notify_workspace_async_err(workspace, &mut cx); }) .detach() })), diff --git a/crates/db/src/kvp.rs b/crates/db/src/kvp.rs index 8ea877b35bfaf57bb258e7e179fa5b71f2b518ea..438adcdf44921aa1d2590694608c139e9174d788 100644 --- a/crates/db/src/kvp.rs +++ b/crates/db/src/kvp.rs @@ -1,3 +1,4 @@ +use anyhow::Context as _; use gpui::App; use sqlez_macros::sql; use util::ResultExt as _; @@ -13,12 +14,22 @@ pub struct KeyValueStore(crate::sqlez::thread_safe_connection::ThreadSafeConnect impl Domain for KeyValueStore { const NAME: &str = stringify!(KeyValueStore); - const MIGRATIONS: &[&str] = &[sql!( - CREATE TABLE IF NOT EXISTS kv_store( - key TEXT PRIMARY KEY, - value TEXT NOT NULL - ) STRICT; - )]; + const MIGRATIONS: &[&str] = &[ + sql!( + CREATE TABLE IF NOT EXISTS kv_store( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ) STRICT; + ), + sql!( + CREATE TABLE IF NOT EXISTS scoped_kv_store( + namespace TEXT NOT NULL, + key TEXT NOT NULL, + value TEXT NOT NULL, + PRIMARY KEY(namespace, key) + ) STRICT; + ), + ]; } crate::static_connection!(KEY_VALUE_STORE, KeyValueStore, []); @@ -69,6 +80,64 @@ impl KeyValueStore { DELETE FROM kv_store WHERE key = (?) } } + + pub fn scoped<'a>(&'a self, namespace: &'a str) -> ScopedKeyValueStore<'a> { + ScopedKeyValueStore { + store: self, + namespace, + } + } +} + +pub struct ScopedKeyValueStore<'a> { + store: &'a KeyValueStore, + namespace: &'a str, +} + +impl ScopedKeyValueStore<'_> { + pub fn read(&self, key: &str) -> anyhow::Result> { + self.store.select_row_bound::<(&str, &str), String>( + "SELECT value FROM scoped_kv_store WHERE namespace = (?) AND key = (?)", + )?((self.namespace, key)) + .context("Failed to read from scoped_kv_store") + } + + pub async fn write(&self, key: String, value: String) -> anyhow::Result<()> { + let namespace = self.namespace.to_owned(); + self.store + .write(move |connection| { + connection.exec_bound::<(&str, &str, &str)>( + "INSERT OR REPLACE INTO scoped_kv_store(namespace, key, value) VALUES ((?), (?), (?))", + )?((&namespace, &key, &value)) + .context("Failed to write to scoped_kv_store") + }) + .await + } + + pub async fn delete(&self, key: String) -> anyhow::Result<()> { + let namespace = self.namespace.to_owned(); + self.store + .write(move |connection| { + connection.exec_bound::<(&str, &str)>( + "DELETE FROM scoped_kv_store WHERE namespace = (?) AND key = (?)", + )?((&namespace, &key)) + .context("Failed to delete from scoped_kv_store") + }) + .await + } + + pub async fn delete_all(&self) -> anyhow::Result<()> { + let namespace = self.namespace.to_owned(); + self.store + .write(move |connection| { + connection + .exec_bound::<&str>("DELETE FROM scoped_kv_store WHERE namespace = (?)")?( + &namespace, + ) + .context("Failed to delete_all from scoped_kv_store") + }) + .await + } } #[cfg(test)] @@ -99,6 +168,52 @@ mod tests { db.delete_kvp("key-1".to_string()).await.unwrap(); assert_eq!(db.read_kvp("key-1").unwrap(), None); } + + #[gpui::test] + async fn test_scoped_kvp() { + let db = KeyValueStore::open_test_db("test_scoped_kvp").await; + + let scope_a = db.scoped("namespace-a"); + let scope_b = db.scoped("namespace-b"); + + // Reading a missing key returns None + assert_eq!(scope_a.read("key-1").unwrap(), None); + + // Writing and reading back a key works + scope_a + .write("key-1".to_string(), "value-a1".to_string()) + .await + .unwrap(); + assert_eq!(scope_a.read("key-1").unwrap(), Some("value-a1".to_string())); + + // Two namespaces with the same key don't collide + scope_b + .write("key-1".to_string(), "value-b1".to_string()) + .await + .unwrap(); + assert_eq!(scope_a.read("key-1").unwrap(), Some("value-a1".to_string())); + assert_eq!(scope_b.read("key-1").unwrap(), Some("value-b1".to_string())); + + // delete removes a single key without affecting others in the namespace + scope_a + .write("key-2".to_string(), "value-a2".to_string()) + .await + .unwrap(); + scope_a.delete("key-1".to_string()).await.unwrap(); + assert_eq!(scope_a.read("key-1").unwrap(), None); + assert_eq!(scope_a.read("key-2").unwrap(), Some("value-a2".to_string())); + assert_eq!(scope_b.read("key-1").unwrap(), Some("value-b1".to_string())); + + // delete_all removes all keys in a namespace without affecting other namespaces + scope_a + .write("key-3".to_string(), "value-a3".to_string()) + .await + .unwrap(); + scope_a.delete_all().await.unwrap(); + assert_eq!(scope_a.read("key-2").unwrap(), None); + assert_eq!(scope_a.read("key-3").unwrap(), None); + assert_eq!(scope_b.read("key-1").unwrap(), Some("value-b1".to_string())); + } } pub struct GlobalKeyValueStore(ThreadSafeConnection); diff --git a/crates/debugger_ui/src/tests.rs b/crates/debugger_ui/src/tests.rs index 5aaf3538e86054346d82b1db10aa02d8e5aa34f1..c183f8941c3f30cb43ffaa638eae4e6b387e226d 100644 --- a/crates/debugger_ui/src/tests.rs +++ b/crates/debugger_ui/src/tests.rs @@ -8,7 +8,7 @@ use project::{Project, debugger::session::Session}; use settings::SettingsStore; use task::SharedTaskContext; use terminal_view::terminal_panel::TerminalPanel; -use workspace::Workspace; +use workspace::MultiWorkspace; use crate::{debugger_panel::DebugPanel, session::DebugSession}; @@ -52,14 +52,16 @@ pub fn init_test(cx: &mut gpui::TestAppContext) { pub async fn init_test_workspace( project: &Entity, cx: &mut TestAppContext, -) -> WindowHandle { +) -> WindowHandle { let workspace_handle = - cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); + cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); let debugger_panel = workspace_handle - .update(cx, |_, window, cx| { - cx.spawn_in(window, async move |this, cx| { - DebugPanel::load(this, cx).await + .update(cx, |multi, window, cx| { + multi.workspace().update(cx, |_workspace, cx| { + cx.spawn_in(window, async move |this, cx| { + DebugPanel::load(this, cx).await + }) }) }) .unwrap() @@ -67,9 +69,10 @@ pub async fn init_test_workspace( .expect("Failed to load debug panel"); let terminal_panel = workspace_handle - .update(cx, |_, window, cx| { - cx.spawn_in(window, async |this, cx| { - TerminalPanel::load(this, cx.clone()).await + .update(cx, |multi, window, cx| { + let weak_workspace = multi.workspace().downgrade(); + cx.spawn_in(window, async move |_, cx| { + TerminalPanel::load(weak_workspace, cx.clone()).await }) }) .unwrap() @@ -77,9 +80,11 @@ pub async fn init_test_workspace( .expect("Failed to load terminal panel"); workspace_handle - .update(cx, |workspace, window, cx| { - workspace.add_panel(debugger_panel, window, cx); - workspace.add_panel(terminal_panel, window, cx); + .update(cx, |multi, window, cx| { + multi.workspace().update(cx, |workspace, cx| { + workspace.add_panel(debugger_panel, window, cx); + workspace.add_panel(terminal_panel, window, cx); + }); }) .unwrap(); workspace_handle @@ -87,39 +92,45 @@ pub async fn init_test_workspace( #[track_caller] pub fn active_debug_session_panel( - workspace: WindowHandle, + workspace: WindowHandle, cx: &mut TestAppContext, ) -> Entity { workspace - .update(cx, |workspace, _window, cx| { - let debug_panel = workspace.panel::(cx).unwrap(); - debug_panel - .update(cx, |this, _| this.active_session()) - .unwrap() + .update(cx, |multi, _window, cx| { + multi.workspace().update(cx, |workspace, cx| { + let debug_panel = workspace.panel::(cx).unwrap(); + debug_panel + .update(cx, |this, _| this.active_session()) + .unwrap() + }) }) .unwrap() } pub fn start_debug_session_with) + 'static>( - workspace: &WindowHandle, + workspace: &WindowHandle, cx: &mut gpui::TestAppContext, config: DebugTaskDefinition, configure: T, ) -> Result> { let _subscription = project::debugger::test::intercept_debug_sessions(cx, configure); - workspace.update(cx, |workspace, window, cx| { - workspace.start_debug_session( - config.to_scenario(), - SharedTaskContext::default(), - None, - None, - window, - cx, - ) + workspace.update(cx, |multi, window, cx| { + multi.workspace().update(cx, |workspace, cx| { + workspace.start_debug_session( + config.to_scenario(), + SharedTaskContext::default(), + None, + None, + window, + cx, + ) + }) })?; cx.run_until_parked(); let session = workspace.read_with(cx, |workspace, cx| { workspace + .workspace() + .read(cx) .panel::(cx) .and_then(|panel| panel.read(cx).active_session()) .map(|session| session.read(cx).running_state().read(cx).session()) @@ -131,7 +142,7 @@ pub fn start_debug_session_with) + 'static>( } pub fn start_debug_session) + 'static>( - workspace: &WindowHandle, + workspace: &WindowHandle, cx: &mut gpui::TestAppContext, configure: T, ) -> Result> { diff --git a/crates/debugger_ui/src/tests/attach_modal.rs b/crates/debugger_ui/src/tests/attach_modal.rs index b05ee591f3ac0ca2e138e25928552d93c4426152..4e8839f82f4de69fd1851ef50ff0d55ad09d0aa9 100644 --- a/crates/debugger_ui/src/tests/attach_modal.rs +++ b/crates/debugger_ui/src/tests/attach_modal.rs @@ -60,7 +60,13 @@ async fn test_direct_attach_to_process(executor: BackgroundExecutor, cx: &mut Te // assert we didn't show the attach modal workspace .update(cx, |workspace, _window, cx| { - assert!(workspace.active_modal::(cx).is_none()); + assert!( + workspace + .workspace() + .read(cx) + .active_modal::(cx) + .is_none() + ); }) .unwrap(); } @@ -97,9 +103,9 @@ async fn test_show_attach_modal_and_select_process( }); }); let attach_modal = workspace - .update(cx, |workspace, window, cx| { - let workspace_handle = cx.weak_entity(); - workspace.toggle_modal(window, cx, |window, cx| { + .update(cx, |multi, window, cx| { + let workspace_handle = multi.workspace().downgrade(); + multi.toggle_modal(window, cx, |window, cx| { AttachModal::with_processes( workspace_handle, vec![ @@ -133,7 +139,7 @@ async fn test_show_attach_modal_and_select_process( ) }); - workspace.active_modal::(cx).unwrap() + multi.active_modal::(cx).unwrap() }) .unwrap(); @@ -208,24 +214,26 @@ async fn test_attach_with_pick_pid_variable(executor: BackgroundExecutor, cx: &m let pick_pid_placeholder = task::VariableName::PickProcessId.template_value(); workspace - .update(cx, |workspace, window, cx| { - workspace.start_debug_session( - DebugTaskDefinition { - adapter: FakeAdapter::ADAPTER_NAME.into(), - label: "attach with picker".into(), - config: json!({ - "request": "attach", - "process_id": pick_pid_placeholder, - }), - tcp_connection: None, - } - .to_scenario(), - SharedTaskContext::default(), - None, - None, - window, - cx, - ) + .update(cx, |multi, window, cx| { + multi.workspace().update(cx, |workspace, cx| { + workspace.start_debug_session( + DebugTaskDefinition { + adapter: FakeAdapter::ADAPTER_NAME.into(), + label: "attach with picker".into(), + config: json!({ + "request": "attach", + "process_id": pick_pid_placeholder, + }), + tcp_connection: None, + } + .to_scenario(), + SharedTaskContext::default(), + None, + None, + window, + cx, + ); + }) }) .unwrap(); diff --git a/crates/debugger_ui/src/tests/new_process_modal.rs b/crates/debugger_ui/src/tests/new_process_modal.rs index 7be2b8798e38108eaa05508002624b98c1595b3f..54c38d8b1cec8d043748338830d643d63479e533 100644 --- a/crates/debugger_ui/src/tests/new_process_modal.rs +++ b/crates/debugger_ui/src/tests/new_process_modal.rs @@ -145,15 +145,17 @@ async fn test_debug_session_substitutes_variables_and_relativizes_paths( }; workspace - .update(cx, |workspace, window, cx| { - workspace.start_debug_session( - scenario, - task_context.clone(), - None, - None, - window, - cx, - ) + .update(cx, |multi, window, cx| { + multi.workspace().update(cx, |workspace, cx| { + workspace.start_debug_session( + scenario, + task_context.clone(), + None, + None, + window, + cx, + ); + }) }) .unwrap(); @@ -182,8 +184,10 @@ async fn test_save_debug_scenario_to_file(executor: BackgroundExecutor, cx: &mut let cx = &mut VisualTestContext::from_window(*workspace, cx); workspace - .update(cx, |workspace, window, cx| { - NewProcessModal::show(workspace, window, NewProcessMode::Debug, None, cx); + .update(cx, |multi, window, cx| { + multi.workspace().update(cx, |workspace, cx| { + NewProcessModal::show(workspace, window, NewProcessMode::Debug, None, cx); + }); }) .unwrap(); @@ -324,8 +328,10 @@ async fn test_debug_modal_subtitles_with_multiple_worktrees( let cx = &mut VisualTestContext::from_window(*workspace, cx); workspace - .update(cx, |workspace, window, cx| { - NewProcessModal::show(workspace, window, NewProcessMode::Debug, None, cx); + .update(cx, |multi, window, cx| { + multi.workspace().update(cx, |workspace, cx| { + NewProcessModal::show(workspace, window, NewProcessMode::Debug, None, cx); + }); }) .unwrap(); diff --git a/crates/debugger_ui/src/tests/stack_frame_list.rs b/crates/debugger_ui/src/tests/stack_frame_list.rs index 372bfa8f5f4eb1da13a59057d077a53a71fa2cea..1f5ac5dea4a19af338feceaa2ee51fd9322fa9a5 100644 --- a/crates/debugger_ui/src/tests/stack_frame_list.rs +++ b/crates/debugger_ui/src/tests/stack_frame_list.rs @@ -1113,8 +1113,8 @@ async fn test_stack_frame_filter_persistence( let workspace = init_test_workspace(&project, cx).await; let cx = &mut VisualTestContext::from_window(*workspace, cx); workspace - .update(cx, |workspace, _, _| { - workspace.set_random_database_id(); + .update(cx, |workspace, _, cx| { + workspace.set_random_database_id(cx); }) .unwrap(); @@ -1211,7 +1211,7 @@ async fn test_stack_frame_filter_persistence( cx.run_until_parked(); let workspace_id = workspace - .update(cx, |workspace, _window, _cx| workspace.database_id()) + .update(cx, |workspace, _window, cx| workspace.database_id(cx)) .ok() .flatten() .expect("workspace id has to be some for this test to work properly"); diff --git a/crates/dev_container/Cargo.toml b/crates/dev_container/Cargo.toml index 31f0466d45e84569b3e2609742d5ba2d1ac59568..113ee22e5ac9956d0af7c1da1ef43403b81c7636 100644 --- a/crates/dev_container/Cargo.toml +++ b/crates/dev_container/Cargo.toml @@ -23,7 +23,12 @@ util.workspace = true workspace.workspace = true [dev-dependencies] +fs = { workspace = true, features = ["test-support"] } gpui = { workspace = true, features = ["test-support"] } +project = { workspace = true, features = ["test-support"] } +settings = { workspace = true, features = ["test-support"] } +theme.workspace = true +workspace = { workspace = true, features = ["test-support"] } [lints] workspace = true diff --git a/crates/dev_container/src/devcontainer_api.rs b/crates/dev_container/src/devcontainer_api.rs index bdba805ade04598d7fba23bbd717d8ec2d584c4f..f6c6f6a7520ec66d786c6b1ae8eb5ee0d41e9ec5 100644 --- a/crates/dev_container/src/devcontainer_api.rs +++ b/crates/dev_container/src/devcontainer_api.rs @@ -2,18 +2,16 @@ use std::{ collections::{HashMap, HashSet}, fmt::Display, path::{Path, PathBuf}, - sync::Arc, }; -use gpui::AsyncWindowContext; use node_runtime::NodeRuntime; use serde::Deserialize; -use settings::{DevContainerConnection, Settings as _}; +use settings::DevContainerConnection; use smol::{fs, process::Command}; use util::rel_path::RelPath; use workspace::Workspace; -use crate::{DevContainerFeature, DevContainerSettings, DevContainerTemplate}; +use crate::{DevContainerContext, DevContainerFeature, DevContainerTemplate}; /// Represents a discovered devcontainer configuration #[derive(Debug, Clone, PartialEq, Eq)] @@ -59,6 +57,31 @@ pub(crate) struct DevContainerConfigurationOutput { configuration: DevContainerConfiguration, } +pub(crate) struct DevContainerCli { + pub path: PathBuf, + node_runtime_path: Option, +} + +impl DevContainerCli { + fn command(&self, use_podman: bool) -> Command { + let mut command = if let Some(node_runtime_path) = &self.node_runtime_path { + let mut command = util::command::new_smol_command( + node_runtime_path.as_os_str().display().to_string(), + ); + command.arg(self.path.display().to_string()); + command + } else { + util::command::new_smol_command(self.path.display().to_string()) + }; + + if use_podman { + command.arg("--docker-path"); + command.arg("podman"); + } + command + } +} + #[derive(Debug, Clone, PartialEq, Eq)] pub enum DevContainerError { DockerNotAvailable, @@ -99,58 +122,6 @@ impl Display for DevContainerError { } } -pub(crate) async fn read_devcontainer_configuration_for_project( - cx: &mut AsyncWindowContext, - node_runtime: &NodeRuntime, -) -> Result { - let (path_to_devcontainer_cli, found_in_path) = ensure_devcontainer_cli(&node_runtime).await?; - - let Some(directory) = project_directory(cx) else { - return Err(DevContainerError::NotInValidProject); - }; - - devcontainer_read_configuration( - &path_to_devcontainer_cli, - found_in_path, - node_runtime, - &directory, - None, - use_podman(cx), - ) - .await -} - -pub(crate) async fn apply_dev_container_template( - template: &DevContainerTemplate, - options_selected: &HashMap, - features_selected: &HashSet, - cx: &mut AsyncWindowContext, - node_runtime: &NodeRuntime, -) -> Result { - let (path_to_devcontainer_cli, found_in_path) = ensure_devcontainer_cli(&node_runtime).await?; - - let Some(directory) = project_directory(cx) else { - return Err(DevContainerError::NotInValidProject); - }; - - devcontainer_template_apply( - template, - options_selected, - features_selected, - &path_to_devcontainer_cli, - found_in_path, - node_runtime, - &directory, - false, // devcontainer template apply does not use --docker-path option - ) - .await -} - -fn use_podman(cx: &mut AsyncWindowContext) -> bool { - cx.update(|_, cx| DevContainerSettings::get_global(cx).use_podman) - .unwrap_or(false) -} - /// Finds all available devcontainer configurations in the project. /// /// This function scans for: @@ -158,160 +129,124 @@ fn use_podman(cx: &mut AsyncWindowContext) -> bool { /// 2. `.devcontainer//devcontainer.json` (named configurations) /// /// Returns a list of found configurations, or an empty list if none are found. -pub fn find_devcontainer_configs(cx: &mut AsyncWindowContext) -> Vec { - let Some(workspace) = cx.window_handle().downcast::() else { - log::debug!("find_devcontainer_configs: No workspace found"); - return Vec::new(); - }; - - let Ok(configs) = workspace.update(cx, |workspace, _, cx| { - let project = workspace.project().read(cx); +pub fn find_devcontainer_configs(workspace: &Workspace, cx: &gpui::App) -> Vec { + let project = workspace.project().read(cx); - let worktree = project - .visible_worktrees(cx) - .find_map(|tree| tree.read(cx).root_entry()?.is_dir().then_some(tree)); + let worktree = project + .visible_worktrees(cx) + .find_map(|tree| tree.read(cx).root_entry()?.is_dir().then_some(tree)); - let Some(worktree) = worktree else { - log::debug!("find_devcontainer_configs: No worktree found"); - return Vec::new(); - }; + let Some(worktree) = worktree else { + log::debug!("find_devcontainer_configs: No worktree found"); + return Vec::new(); + }; - let worktree = worktree.read(cx); - let mut configs = Vec::new(); + let worktree = worktree.read(cx); + let mut configs = Vec::new(); - let devcontainer_path = RelPath::unix(".devcontainer").expect("valid path"); + let devcontainer_path = RelPath::unix(".devcontainer").expect("valid path"); - let Some(devcontainer_entry) = worktree.entry_for_path(devcontainer_path) else { - log::debug!("find_devcontainer_configs: .devcontainer directory not found in worktree"); - return Vec::new(); - }; + let Some(devcontainer_entry) = worktree.entry_for_path(devcontainer_path) else { + log::debug!("find_devcontainer_configs: .devcontainer directory not found in worktree"); + return Vec::new(); + }; - if !devcontainer_entry.is_dir() { - log::debug!("find_devcontainer_configs: .devcontainer is not a directory"); - return Vec::new(); - } + if !devcontainer_entry.is_dir() { + log::debug!("find_devcontainer_configs: .devcontainer is not a directory"); + return Vec::new(); + } - log::debug!("find_devcontainer_configs: Scanning .devcontainer directory"); - let devcontainer_json_path = - RelPath::unix(".devcontainer/devcontainer.json").expect("valid path"); - for entry in worktree.child_entries(devcontainer_path) { - log::debug!( - "find_devcontainer_configs: Found entry: {:?}, is_file: {}, is_dir: {}", - entry.path.as_unix_str(), - entry.is_file(), - entry.is_dir() - ); + log::debug!("find_devcontainer_configs: Scanning .devcontainer directory"); + let devcontainer_json_path = + RelPath::unix(".devcontainer/devcontainer.json").expect("valid path"); + for entry in worktree.child_entries(devcontainer_path) { + log::debug!( + "find_devcontainer_configs: Found entry: {:?}, is_file: {}, is_dir: {}", + entry.path.as_unix_str(), + entry.is_file(), + entry.is_dir() + ); - if entry.is_file() && entry.path.as_ref() == devcontainer_json_path { - log::debug!("find_devcontainer_configs: Found default devcontainer.json"); - configs.push(DevContainerConfig::default_config()); - } else if entry.is_dir() { - let subfolder_name = entry - .path - .file_name() - .map(|n| n.to_string()) - .unwrap_or_default(); - - let config_json_path = format!("{}/devcontainer.json", entry.path.as_unix_str()); - if let Ok(rel_config_path) = RelPath::unix(&config_json_path) { - if worktree.entry_for_path(rel_config_path).is_some() { - log::debug!( - "find_devcontainer_configs: Found config in subfolder: {}", - subfolder_name - ); - configs.push(DevContainerConfig { - name: subfolder_name, - config_path: PathBuf::from(&config_json_path), - }); - } else { - log::debug!( - "find_devcontainer_configs: Subfolder {} has no devcontainer.json", - subfolder_name - ); - } + if entry.is_file() && entry.path.as_ref() == devcontainer_json_path { + log::debug!("find_devcontainer_configs: Found default devcontainer.json"); + configs.push(DevContainerConfig::default_config()); + } else if entry.is_dir() { + let subfolder_name = entry + .path + .file_name() + .map(|n| n.to_string()) + .unwrap_or_default(); + + let config_json_path = format!("{}/devcontainer.json", entry.path.as_unix_str()); + if let Ok(rel_config_path) = RelPath::unix(&config_json_path) { + if worktree.entry_for_path(rel_config_path).is_some() { + log::debug!( + "find_devcontainer_configs: Found config in subfolder: {}", + subfolder_name + ); + configs.push(DevContainerConfig { + name: subfolder_name, + config_path: PathBuf::from(&config_json_path), + }); + } else { + log::debug!( + "find_devcontainer_configs: Subfolder {} has no devcontainer.json", + subfolder_name + ); } } } + } - log::info!( - "find_devcontainer_configs: Found {} configurations", - configs.len() - ); - - configs.sort_by(|a, b| { - if a.name == "default" { - std::cmp::Ordering::Less - } else if b.name == "default" { - std::cmp::Ordering::Greater - } else { - a.name.cmp(&b.name) - } - }); + log::info!( + "find_devcontainer_configs: Found {} configurations", + configs.len() + ); - configs - }) else { - log::debug!("find_devcontainer_configs: Failed to update workspace"); - return Vec::new(); - }; + configs.sort_by(|a, b| { + if a.name == "default" { + std::cmp::Ordering::Less + } else if b.name == "default" { + std::cmp::Ordering::Greater + } else { + a.name.cmp(&b.name) + } + }); configs } pub async fn start_dev_container_with_config( - cx: &mut AsyncWindowContext, - node_runtime: NodeRuntime, + context: DevContainerContext, config: Option, ) -> Result<(DevContainerConnection, String), DevContainerError> { - let use_podman = use_podman(cx); - check_for_docker(use_podman).await?; + check_for_docker(context.use_podman).await?; + let cli = ensure_devcontainer_cli(&context.node_runtime).await?; + let config_path = config.map(|c| context.project_directory.join(&c.config_path)); - let (path_to_devcontainer_cli, found_in_path) = ensure_devcontainer_cli(&node_runtime).await?; - - let Some(directory) = project_directory(cx) else { - return Err(DevContainerError::NotInValidProject); - }; - - let config_path = config.map(|c| directory.join(&c.config_path)); - - match devcontainer_up( - &path_to_devcontainer_cli, - found_in_path, - &node_runtime, - directory.clone(), - config_path.clone(), - use_podman, - ) - .await - { + match devcontainer_up(&context, &cli, config_path.as_deref()).await { Ok(DevContainerUp { container_id, remote_workspace_folder, remote_user, .. }) => { - let project_name = match devcontainer_read_configuration( - &path_to_devcontainer_cli, - found_in_path, - &node_runtime, - &directory, - config_path.as_ref(), - use_podman, - ) - .await - { - Ok(DevContainerConfigurationOutput { - configuration: - DevContainerConfiguration { - name: Some(project_name), - }, - }) => project_name, - _ => get_backup_project_name(&remote_workspace_folder, &container_id), - }; + let project_name = + match read_devcontainer_configuration(&context, &cli, config_path.as_deref()).await + { + Ok(DevContainerConfigurationOutput { + configuration: + DevContainerConfiguration { + name: Some(project_name), + }, + }) => project_name, + _ => get_backup_project_name(&remote_workspace_folder, &container_id), + }; let connection = DevContainerConnection { name: project_name, - container_id: container_id, - use_podman, + container_id, + use_podman: context.use_podman, remote_user, }; @@ -355,9 +290,9 @@ async fn check_for_docker(use_podman: bool) -> Result<(), DevContainerError> { } } -async fn ensure_devcontainer_cli( +pub(crate) async fn ensure_devcontainer_cli( node_runtime: &NodeRuntime, -) -> Result<(PathBuf, bool), DevContainerError> { +) -> Result { let mut command = util::command::new_smol_command(&dev_container_cli()); command.arg("--version"); @@ -395,7 +330,10 @@ async fn ensure_devcontainer_cli( Ok(output) => { if output.status.success() { log::info!("Found devcontainer CLI in Data dir"); - return Ok((datadir_cli_path.clone(), false)); + return Ok(DevContainerCli { + path: datadir_cli_path.clone(), + node_runtime_path: Some(node_runtime_path.clone()), + }); } else { log::error!( "Could not run devcontainer CLI from data_dir. Will try once more to install. Output: {:?}", @@ -435,32 +373,29 @@ async fn ensure_devcontainer_cli( ); Err(DevContainerError::DevContainerCliNotAvailable) } else { - Ok((datadir_cli_path, false)) + Ok(DevContainerCli { + path: datadir_cli_path, + node_runtime_path: Some(node_runtime_path), + }) } } else { log::info!("Found devcontainer cli on $PATH, using it"); - Ok((PathBuf::from(&dev_container_cli()), true)) + Ok(DevContainerCli { + path: PathBuf::from(&dev_container_cli()), + node_runtime_path: None, + }) } } async fn devcontainer_up( - path_to_cli: &PathBuf, - found_in_path: bool, - node_runtime: &NodeRuntime, - path: Arc, - config_path: Option, - use_podman: bool, + context: &DevContainerContext, + cli: &DevContainerCli, + config_path: Option<&Path>, ) -> Result { - let Ok(node_runtime_path) = node_runtime.binary_path().await else { - log::error!("Unable to find node runtime path"); - return Err(DevContainerError::NodeRuntimeNotAvailable); - }; - - let mut command = - devcontainer_cli_command(path_to_cli, found_in_path, &node_runtime_path, use_podman); + let mut command = cli.command(context.use_podman); command.arg("up"); command.arg("--workspace-folder"); - command.arg(path.display().to_string()); + command.arg(context.project_directory.display().to_string()); if let Some(config) = config_path { command.arg("--config"); @@ -493,24 +428,15 @@ async fn devcontainer_up( } } -async fn devcontainer_read_configuration( - path_to_cli: &PathBuf, - found_in_path: bool, - node_runtime: &NodeRuntime, - path: &Arc, - config_path: Option<&PathBuf>, - use_podman: bool, +pub(crate) async fn read_devcontainer_configuration( + context: &DevContainerContext, + cli: &DevContainerCli, + config_path: Option<&Path>, ) -> Result { - let Ok(node_runtime_path) = node_runtime.binary_path().await else { - log::error!("Unable to find node runtime path"); - return Err(DevContainerError::NodeRuntimeNotAvailable); - }; - - let mut command = - devcontainer_cli_command(path_to_cli, found_in_path, &node_runtime_path, use_podman); + let mut command = cli.command(context.use_podman); command.arg("read-configuration"); command.arg("--workspace-folder"); - command.arg(path.display().to_string()); + command.arg(context.project_directory.display().to_string()); if let Some(config) = config_path { command.arg("--config"); @@ -540,23 +466,14 @@ async fn devcontainer_read_configuration( } } -async fn devcontainer_template_apply( +pub(crate) async fn apply_dev_container_template( template: &DevContainerTemplate, template_options: &HashMap, features_selected: &HashSet, - path_to_cli: &PathBuf, - found_in_path: bool, - node_runtime: &NodeRuntime, - path: &Arc, - use_podman: bool, + context: &DevContainerContext, + cli: &DevContainerCli, ) -> Result { - let Ok(node_runtime_path) = node_runtime.binary_path().await else { - log::error!("Unable to find node runtime path"); - return Err(DevContainerError::NodeRuntimeNotAvailable); - }; - - let mut command = - devcontainer_cli_command(path_to_cli, found_in_path, &node_runtime_path, use_podman); + let mut command = cli.command(context.use_podman); let Ok(serialized_options) = serde_json::to_string(template_options) else { log::error!("Unable to serialize options for {:?}", template_options); @@ -566,7 +483,7 @@ async fn devcontainer_template_apply( command.arg("templates"); command.arg("apply"); command.arg("--workspace-folder"); - command.arg(path.display().to_string()); + command.arg(context.project_directory.display().to_string()); command.arg("--template-id"); command.arg(format!( "{}/{}", @@ -630,28 +547,6 @@ fn parse_json_from_cli(raw: &str) -> Result Command { - let mut command = if found_in_path { - util::command::new_smol_command(path_to_cli.display().to_string()) - } else { - let mut command = - util::command::new_smol_command(node_runtime_path.as_os_str().display().to_string()); - command.arg(path_to_cli.display().to_string()); - command - }; - - if use_podman { - command.arg("--docker-path"); - command.arg("podman"); - } - command -} - fn get_backup_project_name(remote_workspace_folder: &str, container_id: &str) -> String { Path::new(remote_workspace_folder) .file_name() @@ -660,22 +555,6 @@ fn get_backup_project_name(remote_workspace_folder: &str, container_id: &str) -> .unwrap_or_else(|| container_id.to_string()) } -fn project_directory(cx: &mut AsyncWindowContext) -> Option> { - let Some(workspace) = cx.window_handle().downcast::() else { - return None; - }; - - match workspace.update(cx, |workspace, _, cx| { - workspace.project().read(cx).active_project_directory(cx) - }) { - Ok(dir) => dir, - Err(e) => { - log::error!("Error getting project directory from workspace: {:?}", e); - None - } - } -} - fn template_features_to_json(features_selected: &HashSet) -> String { let features_map = features_selected .iter() @@ -701,7 +580,160 @@ fn template_features_to_json(features_selected: &HashSet) - #[cfg(test)] mod tests { - use crate::devcontainer_api::{DevContainerUp, parse_json_from_cli}; + use crate::devcontainer_api::{DevContainerUp, find_devcontainer_configs, parse_json_from_cli}; + use fs::FakeFs; + use gpui::TestAppContext; + use project::Project; + use serde_json::json; + use settings::SettingsStore; + use workspace::Workspace; + + fn init_test(cx: &mut TestAppContext) { + cx.update(|cx| { + let settings_store = SettingsStore::test(cx); + cx.set_global(settings_store); + theme::init(theme::LoadThemes::JustBase, cx); + }); + } + + #[gpui::test] + async fn test_find_devcontainer_configs_no_devcontainer_dir(cx: &mut TestAppContext) { + init_test(cx); + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + "/project", + json!({ + "src": { "main.rs": "fn main() {}" } + }), + ) + .await; + + let project = Project::test(fs, ["/project".as_ref()], cx).await; + let (workspace, cx) = + cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx)); + + let configs = cx.read(|cx| find_devcontainer_configs(workspace.read(cx), cx)); + assert!( + configs.is_empty(), + "Expected no configs when .devcontainer dir is absent, got: {configs:?}" + ); + } + + #[gpui::test] + async fn test_find_devcontainer_configs_single_default(cx: &mut TestAppContext) { + init_test(cx); + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + "/project", + json!({ + ".devcontainer": { + "devcontainer.json": r#"{"image": "ubuntu"}"# + } + }), + ) + .await; + + let project = Project::test(fs, ["/project".as_ref()], cx).await; + let (workspace, cx) = + cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx)); + + let configs = cx.read(|cx| find_devcontainer_configs(workspace.read(cx), cx)); + assert_eq!( + configs.len(), + 1, + "Expected exactly one config, got: {configs:?}" + ); + assert_eq!(configs[0].name, "default"); + assert_eq!( + configs[0].config_path.to_str().unwrap(), + ".devcontainer/devcontainer.json" + ); + } + + #[gpui::test] + async fn test_find_devcontainer_configs_multiple_subfolders(cx: &mut TestAppContext) { + init_test(cx); + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + "/project", + json!({ + ".devcontainer": { + "python": { "devcontainer.json": r#"{}"# }, + "node": { "devcontainer.json": r#"{}"# } + } + }), + ) + .await; + + let project = Project::test(fs, ["/project".as_ref()], cx).await; + let (workspace, cx) = + cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx)); + + let configs = cx.read(|cx| find_devcontainer_configs(workspace.read(cx), cx)); + assert_eq!(configs.len(), 2, "Expected two configs, got: {configs:?}"); + assert_eq!(configs[0].name, "node"); + assert_eq!(configs[1].name, "python"); + } + + #[gpui::test] + async fn test_find_devcontainer_configs_default_plus_subfolders(cx: &mut TestAppContext) { + init_test(cx); + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + "/project", + json!({ + ".devcontainer": { + "devcontainer.json": r#"{}"#, + "python": { "devcontainer.json": r#"{}"# }, + "node": { "devcontainer.json": r#"{}"# } + } + }), + ) + .await; + + let project = Project::test(fs, ["/project".as_ref()], cx).await; + let (workspace, cx) = + cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx)); + + let configs = cx.read(|cx| find_devcontainer_configs(workspace.read(cx), cx)); + assert_eq!(configs.len(), 3, "Expected three configs, got: {configs:?}"); + assert_eq!( + configs[0].name, "default", + "Default config should be sorted first" + ); + assert_eq!(configs[1].name, "node"); + assert_eq!(configs[2].name, "python"); + } + + #[gpui::test] + async fn test_find_devcontainer_configs_subfolder_without_json(cx: &mut TestAppContext) { + init_test(cx); + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + "/project", + json!({ + ".devcontainer": { + "devcontainer.json": r#"{}"#, + "has_config": { "devcontainer.json": r#"{}"# }, + "no_config": { "README.md": "not a devcontainer" } + } + }), + ) + .await; + + let project = Project::test(fs, ["/project".as_ref()], cx).await; + let (workspace, cx) = + cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx)); + + let configs = cx.read(|cx| find_devcontainer_configs(workspace.read(cx), cx)); + assert_eq!( + configs.len(), + 2, + "Subfolder without devcontainer.json should be skipped, got: {configs:?}" + ); + assert_eq!(configs[0].name, "default"); + assert_eq!(configs[1].name, "has_config"); + } #[test] fn should_parse_from_devcontainer_json() { diff --git a/crates/dev_container/src/lib.rs b/crates/dev_container/src/lib.rs index 699285e074f325bea78a240c1a2a696cfc578929..2a260f8895cb40b235851e0e800809dc5ae10200 100644 --- a/crates/dev_container/src/lib.rs +++ b/crates/dev_container/src/lib.rs @@ -1,3 +1,5 @@ +use std::path::Path; + use gpui::AppContext; use gpui::Entity; use gpui::Task; @@ -41,7 +43,8 @@ use http_client::{AsyncBody, HttpClient}; mod devcontainer_api; -use devcontainer_api::read_devcontainer_configuration_for_project; +use devcontainer_api::ensure_devcontainer_cli; +use devcontainer_api::read_devcontainer_configuration; use crate::devcontainer_api::DevContainerError; use crate::devcontainer_api::apply_dev_container_template; @@ -50,11 +53,34 @@ pub use devcontainer_api::{ DevContainerConfig, find_devcontainer_configs, start_dev_container_with_config, }; +pub struct DevContainerContext { + pub project_directory: Arc, + pub use_podman: bool, + pub node_runtime: node_runtime::NodeRuntime, +} + +impl DevContainerContext { + pub fn from_workspace(workspace: &Workspace, cx: &App) -> Option { + let project_directory = workspace.project().read(cx).active_project_directory(cx)?; + let use_podman = DevContainerSettings::get_global(cx).use_podman; + let node_runtime = workspace.app_state().node_runtime.clone(); + Some(Self { + project_directory, + use_podman, + node_runtime, + }) + } +} + #[derive(RegisterSetting)] struct DevContainerSettings { use_podman: bool, } +pub fn use_podman(cx: &App) -> bool { + DevContainerSettings::get_global(cx).use_podman +} + impl Settings for DevContainerSettings { fn from_settings(content: &settings::SettingsContent) -> Self { Self { @@ -1419,22 +1445,41 @@ fn dispatch_apply_templates( cx: &mut Context, ) { cx.spawn_in(window, async move |this, cx| { - if let Some(tree_id) = workspace.update(cx, |workspace, cx| { - let project = workspace.project().clone(); - let worktree = project.read(cx).visible_worktrees(cx).find_map(|tree| { - tree.read(cx) - .root_entry()? - .is_dir() - .then_some(tree.read(cx)) - }); - worktree.map(|w| w.id()) - }) { - let node_runtime = workspace.read_with(cx, |workspace, _| { - workspace.app_state().node_runtime.clone() - }); + let Some((tree_id, context)) = workspace.update(cx, |workspace, cx| { + let worktree = workspace + .project() + .read(cx) + .visible_worktrees(cx) + .find_map(|tree| { + tree.read(cx) + .root_entry()? + .is_dir() + .then_some(tree.read(cx)) + }); + let tree_id = worktree.map(|w| w.id())?; + let context = DevContainerContext::from_workspace(workspace, cx)?; + Some((tree_id, context)) + }) else { + return; + }; + let Ok(cli) = ensure_devcontainer_cli(&context.node_runtime).await else { + this.update_in(cx, |this, window, cx| { + this.accept_message( + DevContainerMessage::FailedToWriteTemplate( + DevContainerError::DevContainerCliNotAvailable, + ), + window, + cx, + ); + }) + .log_err(); + return; + }; + + { if check_for_existing - && read_devcontainer_configuration_for_project(cx, &node_runtime) + && read_devcontainer_configuration(&context, &cli, None) .await .is_ok() { @@ -1453,8 +1498,8 @@ fn dispatch_apply_templates( &template_entry.template, &template_entry.options_selected, &template_entry.features_selected, - cx, - &node_runtime, + &context, + &cli, ) .await { @@ -1496,8 +1541,6 @@ fn dispatch_apply_templates( this.dismiss(&menu::Cancel, window, cx); }) .ok(); - } else { - return; } }) .detach(); diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 3ff0ee300f70f9546f6be0289d946f35c026095e..4ac8f39e325626ae40b06567e8cdc87b61274f5c 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -3105,6 +3105,24 @@ impl Editor { self.workspace.as_ref()?.0.upgrade() } + /// Detaches a task and shows an error notification in the workspace if available, + /// otherwise just logs the error. + pub fn detach_and_notify_err( + &self, + task: Task>, + window: &mut Window, + cx: &mut App, + ) where + E: std::fmt::Debug + std::fmt::Display + 'static, + R: 'static, + { + if let Some(workspace) = self.workspace() { + task.detach_and_notify_err(workspace.downgrade(), window, cx); + } else { + task.detach_and_log_err(cx); + } + } + /// Returns the workspace serialization ID if this editor should be serialized. fn workspace_serialization_id(&self, _cx: &App) -> Option { self.workspace @@ -11461,8 +11479,8 @@ impl Editor { let Some(project) = self.project.clone() else { return; }; - self.reload(project, window, cx) - .detach_and_notify_err(window, cx); + let task = self.reload(project, window, cx); + self.detach_and_notify_err(task, window, cx); } pub fn restore_file( diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index d1fd99f09217dc736c12c3f8902fc2bdb777e03d..340610faa984d993728416d4b026bd67e2809003 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -99,7 +99,6 @@ use workspace::{ CollaboratorId, ItemHandle, ItemSettings, OpenInTerminal, OpenTerminal, RevealInProjectPanel, Workspace, item::{BreadcrumbText, Item, ItemBufferKind}, - notifications::NotifyTaskExt, }; /// Determines what kinds of highlights should be applied to a lines background. @@ -541,21 +540,21 @@ impl EditorElement { register_action(editor, window, |editor, action, window, cx| { if let Some(task) = editor.format(action, window, cx) { - task.detach_and_notify_err(window, cx); + editor.detach_and_notify_err(task, window, cx); } else { cx.propagate(); } }); register_action(editor, window, |editor, action, window, cx| { if let Some(task) = editor.format_selections(action, window, cx) { - task.detach_and_notify_err(window, cx); + editor.detach_and_notify_err(task, window, cx); } else { cx.propagate(); } }); register_action(editor, window, |editor, action, window, cx| { if let Some(task) = editor.organize_imports(action, window, cx) { - task.detach_and_notify_err(window, cx); + editor.detach_and_notify_err(task, window, cx); } else { cx.propagate(); } @@ -565,49 +564,49 @@ impl EditorElement { register_action(editor, window, Editor::show_character_palette); register_action(editor, window, |editor, action, window, cx| { if let Some(task) = editor.confirm_completion(action, window, cx) { - task.detach_and_notify_err(window, cx); + editor.detach_and_notify_err(task, window, cx); } else { cx.propagate(); } }); register_action(editor, window, |editor, action, window, cx| { if let Some(task) = editor.confirm_completion_replace(action, window, cx) { - task.detach_and_notify_err(window, cx); + editor.detach_and_notify_err(task, window, cx); } else { cx.propagate(); } }); register_action(editor, window, |editor, action, window, cx| { if let Some(task) = editor.confirm_completion_insert(action, window, cx) { - task.detach_and_notify_err(window, cx); + editor.detach_and_notify_err(task, window, cx); } else { cx.propagate(); } }); register_action(editor, window, |editor, action, window, cx| { if let Some(task) = editor.compose_completion(action, window, cx) { - task.detach_and_notify_err(window, cx); + editor.detach_and_notify_err(task, window, cx); } else { cx.propagate(); } }); register_action(editor, window, |editor, action, window, cx| { if let Some(task) = editor.confirm_code_action(action, window, cx) { - task.detach_and_notify_err(window, cx); + editor.detach_and_notify_err(task, window, cx); } else { cx.propagate(); } }); register_action(editor, window, |editor, action, window, cx| { if let Some(task) = editor.rename(action, window, cx) { - task.detach_and_notify_err(window, cx); + editor.detach_and_notify_err(task, window, cx); } else { cx.propagate(); } }); register_action(editor, window, |editor, action, window, cx| { if let Some(task) = editor.confirm_rename(action, window, cx) { - task.detach_and_notify_err(window, cx); + editor.detach_and_notify_err(task, window, cx); } else { cx.propagate(); } diff --git a/crates/feature_flags/src/flags.rs b/crates/feature_flags/src/flags.rs index 43f4898da40e4a3f8edfb973ad2a4c5a1c79a1fe..2ecc4eb0d277fa94885a125bdce1e6fb5028370f 100644 --- a/crates/feature_flags/src/flags.rs +++ b/crates/feature_flags/src/flags.rs @@ -16,6 +16,10 @@ pub struct AgentV2FeatureFlag; impl FeatureFlag for AgentV2FeatureFlag { const NAME: &'static str = "agent-v2"; + + fn enabled_for_staff() -> bool { + true + } } pub struct AcpBetaFeatureFlag; diff --git a/crates/file_finder/src/file_finder.rs b/crates/file_finder/src/file_finder.rs index 73533c57f156cdfba04ca736eeed5b0d23d2ee8f..71fcfba76363f7d6a3d6d5d37d8f87f3b6a6cdfb 100644 --- a/crates/file_finder/src/file_finder.rs +++ b/crates/file_finder/src/file_finder.rs @@ -1566,9 +1566,12 @@ impl PickerDelegate for FileFinderDelegate { .unwrap_or(0) .saturating_sub(1); let finder = self.file_finder.clone(); + let workspace = self.workspace.clone(); - cx.spawn_in(window, async move |_, cx| { - let item = open_task.await.notify_async_err(cx)?; + cx.spawn_in(window, async move |_, mut cx| { + let item = open_task + .await + .notify_workspace_async_err(workspace, &mut cx)?; if let Some(row) = row && let Some(active_editor) = item.downcast::() { diff --git a/crates/file_finder/src/file_finder_tests.rs b/crates/file_finder/src/file_finder_tests.rs index 1b1421e8b8978a55d89db746b894486888342a65..7a3dd2342e988790c2ca1bb1d3664e18148f74e3 100644 --- a/crates/file_finder/src/file_finder_tests.rs +++ b/crates/file_finder/src/file_finder_tests.rs @@ -9,7 +9,9 @@ use project::{FS_WATCH_LATENCY, RemoveOptions}; use serde_json::json; use settings::SettingsStore; use util::{path, rel_path::rel_path}; -use workspace::{AppState, CloseActiveItem, OpenOptions, ToggleFileFinder, Workspace, open_paths}; +use workspace::{ + AppState, CloseActiveItem, MultiWorkspace, OpenOptions, ToggleFileFinder, Workspace, open_paths, +}; #[ctor::ctor] fn init_logger() { @@ -2534,8 +2536,14 @@ async fn test_search_results_refreshed_on_standalone_file_creation(cx: &mut gpui .await; let project = Project::test(app_state.fs.clone(), ["/src".as_ref()], cx).await; - let (workspace, cx) = - cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let window = cx.add_window({ + let project = project.clone(); + |window, cx| MultiWorkspace::test_new(project, window, cx) + }); + let cx = VisualTestContext::from_window(*window, cx).into_mut(); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); cx.update(|_, cx| { open_paths( diff --git a/crates/git_ui/src/commit_view.rs b/crates/git_ui/src/commit_view.rs index 79f581777485b08952b95f2097f2e7083de35c98..7eee1ce7640784fd37efe69b5f6f92b7cbc438ec 100644 --- a/crates/git_ui/src/commit_view.rs +++ b/crates/git_ui/src/commit_view.rs @@ -774,7 +774,7 @@ impl CommitView { callback(repo, &sha, stash, commit_view_entity, workspace_weak, cx).await?; anyhow::Ok(()) }) - .detach_and_notify_err(window, cx); + .detach_and_notify_err(workspace.weak_handle(), window, cx); } async fn close_commit_view( diff --git a/crates/git_ui/src/file_diff_view.rs b/crates/git_ui/src/file_diff_view.rs index 1967254db664bf4a1f52c25295a7860fdbba82da..229ebb5f31baa9735e7fc2fe9455d13cb17365f1 100644 --- a/crates/git_ui/src/file_diff_view.rs +++ b/crates/git_ui/src/file_diff_view.rs @@ -6,7 +6,7 @@ use editor::{Editor, EditorEvent, MultiBuffer}; use futures::{FutureExt, select_biased}; use gpui::{ AnyElement, App, AppContext as _, AsyncApp, Context, Entity, EventEmitter, FocusHandle, - Focusable, IntoElement, Render, Task, Window, + Focusable, IntoElement, Render, Task, WeakEntity, Window, }; use language::{Buffer, LanguageRegistry}; use project::Project; @@ -39,11 +39,10 @@ impl FileDiffView { pub fn open( old_path: PathBuf, new_path: PathBuf, - workspace: &Workspace, + workspace: WeakEntity, window: &mut Window, cx: &mut App, ) -> Task>> { - let workspace = workspace.weak_handle(); window.spawn(cx, async move |cx| { let project = workspace.update(cx, |workspace, _| workspace.project().clone())?; let old_buffer = project @@ -406,7 +405,7 @@ mod tests { FileDiffView::open( path!("/test/old_file.txt").into(), path!("/test/new_file.txt").into(), - workspace, + workspace.weak_handle(), window, cx, ) @@ -540,7 +539,7 @@ mod tests { FileDiffView::open( PathBuf::from(path!("/test/old_file.txt")), PathBuf::from(path!("/test/new_file.txt")), - workspace, + workspace.weak_handle(), window, cx, ) diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs index a4d26bc5a5a995f7bbc7af7df1cfef18dfe0b3d8..614e395f35bbb13e4f92d5669cc06a79eb284a0d 100644 --- a/crates/git_ui/src/git_panel.rs +++ b/crates/git_ui/src/git_panel.rs @@ -1274,10 +1274,11 @@ impl GitPanel { }) .ok()?; + let workspace = self.workspace.clone(); cx.spawn_in(window, async move |_, mut cx| { let item = open_task .await - .notify_async_err(&mut cx) + .notify_workspace_async_err(workspace, &mut cx) .ok_or_else(|| anyhow::anyhow!("Failed to open file"))?; if let Some(active_editor) = item.downcast::() { if let Some(diff_task) = diff --git a/crates/git_ui/src/project_diff.rs b/crates/git_ui/src/project_diff.rs index ccda40c98a50c05907534e2f853f7678cb52fb9e..34c429b6549f400d33a46bf4f743fa6ed04e1db6 100644 --- a/crates/git_ui/src/project_diff.rs +++ b/crates/git_ui/src/project_diff.rs @@ -124,6 +124,7 @@ impl ProjectDiff { return; } let workspace = cx.entity(); + let workspace_weak = workspace.downgrade(); window .spawn(cx, async move |cx| { let this = cx @@ -138,7 +139,7 @@ impl ProjectDiff { .ok(); anyhow::Ok(()) }) - .detach_and_notify_err(window, cx); + .detach_and_notify_err(workspace_weak, window, cx); } pub fn deploy_at( diff --git a/crates/git_ui/src/worktree_picker.rs b/crates/git_ui/src/worktree_picker.rs index f65eb80e582e20121d4aab2a6b2471784ade45a5..a14336e0058ea64b8a78deae78d98df9c34d3dd9 100644 --- a/crates/git_ui/src/worktree_picker.rs +++ b/crates/git_ui/src/worktree_picker.rs @@ -4,8 +4,8 @@ use fuzzy::StringMatchCandidate; use git::repository::Worktree as GitWorktree; use gpui::{ - Action, App, AsyncApp, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, - InteractiveElement, IntoElement, Modifiers, ModifiersChangedEvent, ParentElement, + Action, App, AsyncWindowContext, Context, DismissEvent, Entity, EventEmitter, FocusHandle, + Focusable, InteractiveElement, IntoElement, Modifiers, ModifiersChangedEvent, ParentElement, PathPromptOptions, Render, SharedString, Styled, Subscription, Task, WeakEntity, Window, actions, rems, }; @@ -20,7 +20,7 @@ use remote_connection::{RemoteConnectionModal, connect}; use std::{path::PathBuf, sync::Arc}; use ui::{HighlightedLabel, KeyBinding, ListItem, ListItemSpacing, prelude::*}; use util::ResultExt; -use workspace::{ModalView, Workspace, notifications::DetachAndPromptErr}; +use workspace::{ModalView, MultiWorkspace, Workspace, notifications::DetachAndPromptErr}; actions!(git, [WorktreeFromDefault, WorktreeFromDefaultOnWindow]); @@ -289,7 +289,6 @@ impl WorktreeListDelegate { }; let branch = worktree_branch.to_string(); - let window_handle = window.window_handle(); let workspace = self.workspace.clone(); cx.spawn_in(window, async move |_, cx| { let Some(paths) = worktree_path.await? else { @@ -355,7 +354,7 @@ impl WorktreeListDelegate { connection_options, vec![new_worktree_path], app_state, - window_handle, + workspace.clone(), replace_current_window, cx, ) @@ -407,13 +406,12 @@ impl WorktreeListDelegate { |e, _, _| Some(e.to_string()), ); } else if let Some(connection_options) = connection_options { - let window_handle = window.window_handle(); cx.spawn_in(window, async move |_, cx| { open_remote_worktree( connection_options, vec![path], app_state, - window_handle, + workspace, replace_current_window, cx, ) @@ -441,15 +439,16 @@ async fn open_remote_worktree( connection_options: RemoteConnectionOptions, paths: Vec, app_state: Arc, - window: gpui::AnyWindowHandle, + workspace: WeakEntity, replace_current_window: bool, - cx: &mut AsyncApp, + cx: &mut AsyncWindowContext, ) -> anyhow::Result<()> { - let workspace_window = window - .downcast::() + let workspace_window = cx + .window_handle() + .downcast::() .ok_or_else(|| anyhow::anyhow!("Window is not a Workspace window"))?; - let connect_task = workspace_window.update(cx, |workspace, window, cx| { + let connect_task = workspace.update_in(cx, |workspace, window, cx| { workspace.toggle_modal(window, cx, |window, cx| { RemoteConnectionModal::new(&connection_options, Vec::new(), window, cx) }); @@ -473,17 +472,19 @@ async fn open_remote_worktree( let session = connect_task.await; - workspace_window.update(cx, |workspace, _window, cx| { - if let Some(prompt) = workspace.active_modal::(cx) { - prompt.update(cx, |prompt, cx| prompt.finished(cx)) - } - })?; + workspace + .update_in(cx, |workspace, _window, cx| { + if let Some(prompt) = workspace.active_modal::(cx) { + prompt.update(cx, |prompt, cx| prompt.finished(cx)) + } + }) + .ok(); let Some(Some(session)) = session else { return Ok(()); }; - let new_project: Entity = cx.update(|cx| { + let new_project: Entity = cx.update(|_, cx| { project::Project::remote( session, app_state.client.clone(), @@ -494,29 +495,30 @@ async fn open_remote_worktree( true, cx, ) - }); + })?; let window_to_use = if replace_current_window { workspace_window } else { let workspace_position = cx - .update(|cx| { + .update(|_, cx| { workspace::remote_workspace_position_from_db(connection_options.clone(), &paths, cx) - }) + })? .await .context("fetching workspace position from db")?; let mut options = - cx.update(|cx| (app_state.build_window_options)(workspace_position.display, cx)); + cx.update(|_, cx| (app_state.build_window_options)(workspace_position.display, cx))?; options.window_bounds = workspace_position.window_bounds; cx.open_window(options, |window, cx| { - cx.new(|cx| { + let workspace = cx.new(|cx| { let mut workspace = Workspace::new(None, new_project.clone(), app_state.clone(), window, cx); workspace.centered_layout = workspace_position.centered_layout; workspace - }) + }); + cx.new(|cx| MultiWorkspace::new(workspace, cx)) })? }; diff --git a/crates/icons/src/icons.rs b/crates/icons/src/icons.rs index 42a8360f4738e3e7440f22ffc77a19c1588fb4a0..054f95ef549697420e4a600d363e716424401a29 100644 --- a/crates/icons/src/icons.rs +++ b/crates/icons/src/icons.rs @@ -265,6 +265,8 @@ pub enum IconName { UserRoundPen, Warning, WholeWord, + WorkspaceNavClosed, + WorkspaceNavOpen, XCircle, XCircleFilled, ZedAgent, diff --git a/crates/inspector_ui/Cargo.toml b/crates/inspector_ui/Cargo.toml index b74b20191c2668e59b0ad44d3a8ccce165c5cba7..53d2f74b9c663496da083152ead17d479f5030eb 100644 --- a/crates/inspector_ui/Cargo.toml +++ b/crates/inspector_ui/Cargo.toml @@ -18,7 +18,6 @@ editor.workspace = true fuzzy.workspace = true gpui.workspace = true language.workspace = true -platform_title_bar.workspace = true project.workspace = true serde_json.workspace = true serde_json_lenient.workspace = true diff --git a/crates/inspector_ui/src/inspector.rs b/crates/inspector_ui/src/inspector.rs index be5bb14ff84da92b7e64baa588a20de345c2442f..f1f3ed8d38e4f0947741a0eeb72481e225904929 100644 --- a/crates/inspector_ui/src/inspector.rs +++ b/crates/inspector_ui/src/inspector.rs @@ -1,8 +1,7 @@ use anyhow::{Context as _, anyhow}; use gpui::{App, DivInspectorState, Inspector, InspectorElementId, IntoElement, Window}; -use platform_title_bar::PlatformTitleBar; use std::{cell::OnceCell, path::Path, sync::Arc}; -use ui::{Label, Tooltip, prelude::*}; +use ui::{Label, Tooltip, prelude::*, utils::platform_title_bar_height}; use util::{ResultExt as _, command::new_smol_command}; use workspace::AppState; @@ -61,7 +60,7 @@ fn render_inspector( let ui_font = theme::setup_ui_font(window, cx); let colors = cx.theme().colors(); let inspector_id = inspector.active_element_id(); - let toolbar_height = PlatformTitleBar::height(window); + let toolbar_height = platform_title_bar_height(window); v_flex() .size_full() diff --git a/crates/journal/src/journal.rs b/crates/journal/src/journal.rs index f43949c0051f56559388203e387a540b8c593467..ba97bcf66a77659fb3196ba45ebb3f831452e008 100644 --- a/crates/journal/src/journal.rs +++ b/crates/journal/src/journal.rs @@ -118,17 +118,20 @@ pub fn new_journal_entry(workspace: &Workspace, window: &mut Window, cx: &mut Ap })? .await?; new_workspace - .update(cx, |workspace, window, cx| { - workspace.open_paths( - vec![entry_path], - workspace::OpenOptions { - visible: Some(OpenVisible::All), - ..Default::default() - }, - None, - window, - cx, - ) + .update(cx, |multi_workspace, window, cx| { + let workspace = multi_workspace.workspace().clone(); + workspace.update(cx, |workspace, cx| { + workspace.open_paths( + vec![entry_path], + workspace::OpenOptions { + visible: Some(OpenVisible::All), + ..Default::default() + }, + None, + window, + cx, + ) + }) })? .await } else { diff --git a/crates/keymap_editor/src/keymap_editor.rs b/crates/keymap_editor/src/keymap_editor.rs index de22ae01b503fde9aabdd99be5253d7c4e3f1b71..ff3389a4d4a10bc8472d0931d18ffa5be839c631 100644 --- a/crates/keymap_editor/src/keymap_editor.rs +++ b/crates/keymap_editor/src/keymap_editor.rs @@ -1319,7 +1319,7 @@ impl KeymapEditor { cx.spawn(async move |_, _| { remove_keybinding(to_remove, &fs, keyboard_mapper.as_ref()).await }) - .detach_and_notify_err(window, cx); + .detach_and_notify_err(self.workspace.clone(), window, cx); } fn copy_context_to_clipboard( diff --git a/crates/miniprofiler_ui/src/miniprofiler_ui.rs b/crates/miniprofiler_ui/src/miniprofiler_ui.rs index cf916c0b5415d9643c9609715d66f77a98ba7222..697027570a46afc550fd4f96d6a204e7e8c23f27 100644 --- a/crates/miniprofiler_ui/src/miniprofiler_ui.rs +++ b/crates/miniprofiler_ui/src/miniprofiler_ui.rs @@ -8,7 +8,7 @@ use std::{ use gpui::{ App, AppContext, ClipboardItem, Context, Div, Entity, Hsla, InteractiveElement, ParentElement as _, Render, SerializedTaskTiming, SharedString, StatefulInteractiveElement, - Styled, Task, TaskTiming, TitlebarOptions, UniformListScrollHandle, WindowBounds, WindowHandle, + Styled, Task, TaskTiming, TitlebarOptions, UniformListScrollHandle, WeakEntity, WindowBounds, WindowOptions, div, prelude::FluentBuilder, px, relative, size, uniform_list, }; use util::ResultExt; @@ -22,13 +22,10 @@ use workspace::{ use zed_actions::OpenPerformanceProfiler; pub fn init(startup_time: Instant, cx: &mut App) { - cx.observe_new(move |workspace: &mut workspace::Workspace, _, _| { - workspace.register_action(move |workspace, _: &OpenPerformanceProfiler, window, cx| { - let window_handle = window - .window_handle() - .downcast::() - .expect("Workspaces are root Windows"); - open_performance_profiler(startup_time, workspace, window_handle, cx); + cx.observe_new(move |workspace: &mut workspace::Workspace, _, cx| { + let workspace_handle = cx.entity().downgrade(); + workspace.register_action(move |_workspace, _: &OpenPerformanceProfiler, window, cx| { + open_performance_profiler(startup_time, workspace_handle.clone(), window, cx); }); }) .detach(); @@ -36,8 +33,8 @@ pub fn init(startup_time: Instant, cx: &mut App) { fn open_performance_profiler( startup_time: Instant, - _workspace: &mut workspace::Workspace, - workspace_handle: WindowHandle, + workspace_handle: WeakEntity, + _window: &mut gpui::Window, cx: &mut App, ) { let existing_window = cx @@ -48,7 +45,7 @@ fn open_performance_profiler( if let Some(existing_window) = existing_window { existing_window .update(cx, |profiler_window, window, _cx| { - profiler_window.workspace = Some(workspace_handle); + profiler_window.workspace = Some(workspace_handle.clone()); window.activate_window(); }) .log_err(); @@ -97,14 +94,14 @@ pub struct ProfilerWindow { include_self_timings: ToggleState, autoscroll: bool, scroll_handle: UniformListScrollHandle, - workspace: Option>, + workspace: Option>, _refresh: Option>, } impl ProfilerWindow { pub fn new( startup_time: Instant, - workspace_handle: Option>, + workspace_handle: Option>, cx: &mut App, ) -> Entity { let entity = cx.new(|cx| ProfilerWindow { @@ -280,7 +277,7 @@ impl Render for ProfilerWindow { Button::new("export-data", "Save") .style(ButtonStyle::Filled) .on_click(cx.listener(|this, _, _window, cx| { - let Some(workspace) = this.workspace else { + let Some(workspace) = this.workspace.as_ref() else { return; }; @@ -297,7 +294,7 @@ impl Render for ProfilerWindow { .log_err() .flatten() .and_then(|p| p.parent().map(|p| p.to_owned())) - .unwrap_or_else(|| PathBuf::default()); + .unwrap_or_else(PathBuf::default); let path = cx.prompt_for_new_path( &active_path, diff --git a/crates/onboarding/src/onboarding.rs b/crates/onboarding/src/onboarding.rs index 495a55411fc936d476dfa0d443e155d1fa7faecd..866df0e9d91e75b3522f957f54d05db7614c7e5a 100644 --- a/crates/onboarding/src/onboarding.rs +++ b/crates/onboarding/src/onboarding.rs @@ -238,15 +238,16 @@ impl Onboarding { go_to_welcome_page(cx); } - fn handle_sign_in(_: &SignIn, window: &mut Window, cx: &mut App) { + fn handle_sign_in(&mut self, _: &SignIn, window: &mut Window, cx: &mut Context) { let client = Client::global(cx); + let workspace = self.workspace.clone(); window - .spawn(cx, async move |cx| { + .spawn(cx, async move |mut cx| { client - .sign_in_with_optional_connect(true, cx) + .sign_in_with_optional_connect(true, &cx) .await - .notify_async_err(cx); + .notify_workspace_async_err(workspace, &mut cx); }) .detach(); } @@ -274,7 +275,7 @@ impl Render for Onboarding { .size_full() .bg(cx.theme().colors().editor_background) .on_action(Self::on_finish) - .on_action(Self::handle_sign_in) + .on_action(cx.listener(Self::handle_sign_in)) .on_action(Self::handle_open_account) .on_action(cx.listener(|_, _: &menu::SelectNext, window, cx| { window.focus_next(cx); diff --git a/crates/outline/src/outline.rs b/crates/outline/src/outline.rs index 9e6cc045a76204c71bd5812d002a873cfc5dd461..6c5edb996c8ed87e1c42c899271b28c9b0cca80b 100644 --- a/crates/outline/src/outline.rs +++ b/crates/outline/src/outline.rs @@ -20,7 +20,7 @@ use settings::Settings; use theme::{ActiveTheme, ThemeSettings}; use ui::{ListItem, ListItemSpacing, prelude::*}; use util::ResultExt; -use workspace::{DismissDecision, ModalView, Workspace}; +use workspace::{DismissDecision, ModalView}; pub fn init(cx: &mut App) { cx.observe_new(OutlineView::register).detach(); @@ -48,7 +48,8 @@ pub fn toggle( .snapshot(cx) .outline(Some(cx.theme().syntax())); - let workspace = window.root::().flatten(); + let workspace = editor.read(cx).workspace(); + if let Some((workspace, outline)) = workspace.zip(outline) { workspace.update(cx, |workspace, cx| { workspace.toggle_modal(window, cx, |window, cx| { diff --git a/crates/platform_title_bar/Cargo.toml b/crates/platform_title_bar/Cargo.toml index a8db1e37f206b90ca1cc18f933d5ab20ff45cdf1..2f1f6d2cd9297136077780aafdc75d22ecf6b845 100644 --- a/crates/platform_title_bar/Cargo.toml +++ b/crates/platform_title_bar/Cargo.toml @@ -13,6 +13,7 @@ path = "src/platform_title_bar.rs" doctest = false [dependencies] +feature_flags.workspace = true gpui.workspace = true settings.workspace = true smallvec.workspace = true diff --git a/crates/platform_title_bar/src/platform_title_bar.rs b/crates/platform_title_bar/src/platform_title_bar.rs index d53e8ae86cdba32b33e6959032667f9748de871e..8d63da7448f07b3f4d4901a341dd5184b4352da5 100644 --- a/crates/platform_title_bar/src/platform_title_bar.rs +++ b/crates/platform_title_bar/src/platform_title_bar.rs @@ -1,16 +1,21 @@ mod platforms; mod system_window_tabs; +use feature_flags::{AgentV2FeatureFlag, FeatureFlagAppExt}; use gpui::{ - AnyElement, Context, Decorations, Entity, Hsla, InteractiveElement, IntoElement, MouseButton, - ParentElement, Pixels, StatefulInteractiveElement, Styled, Window, WindowControlArea, div, px, + AnyElement, App, Context, Decorations, Entity, Hsla, InteractiveElement, IntoElement, + MouseButton, ParentElement, StatefulInteractiveElement, Styled, Window, WindowControlArea, div, + px, }; use smallvec::SmallVec; use std::mem; -use ui::prelude::*; +use ui::{ + prelude::*, + utils::{TRAFFIC_LIGHT_PADDING, platform_title_bar_height}, +}; use crate::{ - platforms::{platform_linux, platform_mac, platform_windows}, + platforms::{platform_linux, platform_windows}, system_window_tabs::SystemWindowTabs, }; @@ -24,6 +29,8 @@ pub struct PlatformTitleBar { children: SmallVec<[AnyElement; 2]>, should_move: bool, system_window_tabs: Entity, + workspace_sidebar_open: bool, + sidebar_has_notifications: bool, } impl PlatformTitleBar { @@ -37,20 +44,11 @@ impl PlatformTitleBar { children: SmallVec::new(), should_move: false, system_window_tabs, + workspace_sidebar_open: false, + sidebar_has_notifications: false, } } - #[cfg(not(target_os = "windows"))] - pub fn height(window: &mut Window) -> Pixels { - (1.75 * window.rem_size()).max(px(34.)) - } - - #[cfg(target_os = "windows")] - pub fn height(_window: &mut Window) -> Pixels { - // todo(windows) instead of hard coded size report the actual size to the Windows platform API - px(32.) - } - pub fn title_bar_color(&self, window: &mut Window, cx: &mut Context) -> Hsla { if cfg!(any(target_os = "linux", target_os = "freebsd")) { if window.is_window_active() && !self.should_move { @@ -73,17 +71,46 @@ impl PlatformTitleBar { pub fn init(cx: &mut App) { SystemWindowTabs::init(cx); } + + pub fn is_workspace_sidebar_open(&self) -> bool { + self.workspace_sidebar_open + } + + pub fn set_workspace_sidebar_open(&mut self, open: bool, cx: &mut Context) { + self.workspace_sidebar_open = open; + cx.notify(); + } + + pub fn sidebar_has_notifications(&self) -> bool { + self.sidebar_has_notifications + } + + pub fn set_sidebar_has_notifications( + &mut self, + has_notifications: bool, + cx: &mut Context, + ) { + self.sidebar_has_notifications = has_notifications; + cx.notify(); + } + + pub fn is_multi_workspace_enabled(cx: &App) -> bool { + cx.has_flag::() + } } impl Render for PlatformTitleBar { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let supported_controls = window.window_controls(); let decorations = window.window_decorations(); - let height = Self::height(window); + let height = platform_title_bar_height(window); let titlebar_color = self.title_bar_color(window, cx); let close_action = Box::new(workspace::CloseWindow); let children = mem::take(&mut self.children); + let is_multiworkspace_sidebar_open = + PlatformTitleBar::is_multi_workspace_enabled(cx) && self.is_workspace_sidebar_open(); + let title_bar = h_flex() .window_control_area(WindowControlArea::Drag) .w_full() @@ -132,8 +159,10 @@ impl Render for PlatformTitleBar { .map(|this| { if window.is_fullscreen() { this.pl_2() - } else if self.platform_style == PlatformStyle::Mac { - this.pl(px(platform_mac::TRAFFIC_LIGHT_PADDING)) + } else if self.platform_style == PlatformStyle::Mac + && !is_multiworkspace_sidebar_open + { + this.pl(px(TRAFFIC_LIGHT_PADDING)) } else { this.pl_2() } diff --git a/crates/platform_title_bar/src/platforms.rs b/crates/platform_title_bar/src/platforms.rs index 67e87d45ea5d290077af1326e613c6819e0f41dc..26e9c4b4f044eff172d165e3851279fa07c3a269 100644 --- a/crates/platform_title_bar/src/platforms.rs +++ b/crates/platform_title_bar/src/platforms.rs @@ -1,3 +1,2 @@ pub mod platform_linux; -pub mod platform_mac; pub mod platform_windows; diff --git a/crates/platform_title_bar/src/platforms/platform_mac.rs b/crates/platform_title_bar/src/platforms/platform_mac.rs deleted file mode 100644 index 5e8e4e5087054e59f66527915ae97e352a9ff525..0000000000000000000000000000000000000000 --- a/crates/platform_title_bar/src/platforms/platform_mac.rs +++ /dev/null @@ -1,10 +0,0 @@ -// Use pixels here instead of a rem-based size because the macOS traffic -// lights are a static size, and don't scale with the rest of the UI. -// -// Magic number: There is one extra pixel of padding on the left side due to -// the 1px border around the window on macOS apps. -#[cfg(macos_sdk_26)] -pub const TRAFFIC_LIGHT_PADDING: f32 = 78.; - -#[cfg(not(macos_sdk_26))] -pub const TRAFFIC_LIGHT_PADDING: f32 = 71.; diff --git a/crates/project_panel/src/project_panel.rs b/crates/project_panel/src/project_panel.rs index d2d451154ad9a25136f299c7c46d59d225522020..b7476f459dfb4d96807beca4096e1a6591235319 100644 --- a/crates/project_panel/src/project_panel.rs +++ b/crates/project_panel/src/project_panel.rs @@ -756,7 +756,11 @@ impl ProjectPanel { { match project_panel.confirm_edit(false, window, cx) { Some(task) => { - task.detach_and_notify_err(window, cx); + task.detach_and_notify_err( + project_panel.workspace.clone(), + window, + cx, + ); } None => { project_panel.discard_edit_state(window, cx); @@ -1631,7 +1635,7 @@ impl ProjectPanel { fn confirm(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context) { if let Some(task) = self.confirm_edit(true, window, cx) { - task.detach_and_notify_err(window, cx); + task.detach_and_notify_err(self.workspace.clone(), window, cx); } } @@ -3002,20 +3006,25 @@ impl ProjectPanel { } let item_count = paste_tasks.len(); + let workspace = self.workspace.clone(); - cx.spawn_in(window, async move |project_panel, cx| { + cx.spawn_in(window, async move |project_panel, mut cx| { let mut last_succeed = None; for task in paste_tasks { match task { PasteTask::Rename(task) => { - if let Some(CreatedEntry::Included(entry)) = - task.await.notify_async_err(cx) + if let Some(CreatedEntry::Included(entry)) = task + .await + .notify_workspace_async_err(workspace.clone(), &mut cx) { last_succeed = Some(entry); } } PasteTask::Copy(task) => { - if let Some(Some(entry)) = task.await.notify_async_err(cx) { + if let Some(Some(entry)) = task + .await + .notify_workspace_async_err(workspace.clone(), &mut cx) + { last_succeed = Some(entry); } } @@ -3357,7 +3366,7 @@ impl ProjectPanel { if let Some((file_path1, file_path2)) = selected_files { self.workspace .update(cx, |workspace, cx| { - FileDiffView::open(file_path1, file_path2, workspace, window, cx) + FileDiffView::open(file_path1, file_path2, workspace.weak_handle(), window, cx) .detach_and_log_err(cx); }) .ok(); diff --git a/crates/recent_projects/Cargo.toml b/crates/recent_projects/Cargo.toml index 9ef3250e315d00bf4b6f669b9a5313ea3251a5fe..7b605084d6213ef17ffac83d782bb02bc4213e27 100644 --- a/crates/recent_projects/Cargo.toml +++ b/crates/recent_projects/Cargo.toml @@ -23,6 +23,7 @@ db.workspace = true dev_container.workspace = true editor.workspace = true extension_host.workspace = true +fs.workspace = true futures.workspace = true fuzzy.workspace = true gpui.workspace = true @@ -66,6 +67,7 @@ language = { workspace = true, features = ["test-support"] } project = { workspace = true, features = ["test-support"] } release_channel.workspace = true remote = { workspace = true, features = ["test-support"] } +remote_connection = { workspace = true, features = ["test-support"] } remote_server.workspace = true serde_json.workspace = true settings = { workspace = true, features = ["test-support"] } diff --git a/crates/recent_projects/src/disconnected_overlay.rs b/crates/recent_projects/src/disconnected_overlay.rs index f45673c38dbad1abab717b3f9f1081a2ffae4bd2..82ff0699054e5614b8078d3223d5e9282e5034b5 100644 --- a/crates/recent_projects/src/disconnected_overlay.rs +++ b/crates/recent_projects/src/disconnected_overlay.rs @@ -7,7 +7,9 @@ use ui::{ HeadlineSize, IconName, IconPosition, InteractiveElement, IntoElement, Label, Modal, ModalFooter, ModalHeader, ParentElement, Section, Styled, StyledExt, Window, div, h_flex, rems, }; -use workspace::{ModalView, OpenOptions, Workspace, notifications::DetachAndPromptErr}; +use workspace::{ + ModalView, MultiWorkspace, OpenOptions, Workspace, notifications::DetachAndPromptErr, +}; use crate::open_remote_project; @@ -109,7 +111,7 @@ impl DisconnectedOverlay { return; }; - let Some(window_handle) = window.window_handle().downcast::() else { + let Some(window_handle) = window.window_handle().downcast::() else { return; }; diff --git a/crates/recent_projects/src/recent_projects.rs b/crates/recent_projects/src/recent_projects.rs index 7f67627ad4d6841419292e58b5b41a5fd5ef7d5b..c0a22b43e37a55ac5a3380b1d5e903ea5b06b80e 100644 --- a/crates/recent_projects/src/recent_projects.rs +++ b/crates/recent_projects/src/recent_projects.rs @@ -4,7 +4,9 @@ mod remote_connections; mod remote_servers; mod ssh_config; -use std::path::PathBuf; +use std::{path::PathBuf, sync::Arc}; + +use fs::Fs; #[cfg(target_os = "windows")] mod wsl_picker; @@ -27,11 +29,11 @@ use picker::{ pub use remote_connections::RemoteSettings; pub use remote_servers::RemoteServerProjects; use settings::Settings; -use std::{path::Path, sync::Arc}; +use std::path::Path; use ui::{KeyBinding, ListItem, ListItemSpacing, Tooltip, prelude::*, tooltip_container}; use util::{ResultExt, paths::PathExt}; use workspace::{ - CloseIntent, HistoryManager, ModalView, OpenOptions, PathList, SerializedWorkspaceLocation, + HistoryManager, ModalView, MultiWorkspace, OpenOptions, PathList, SerializedWorkspaceLocation, WORKSPACE_DB, Workspace, WorkspaceId, notifications::DetachAndPromptErr, with_active_or_new_workspace, }; @@ -48,9 +50,10 @@ pub struct RecentProjectEntry { pub async fn get_recent_projects( current_workspace_id: Option, limit: Option, + fs: Arc, ) -> Vec { let workspaces = WORKSPACE_DB - .recent_workspaces_on_disk() + .recent_workspaces_on_disk(fs.as_ref()) .await .unwrap_or_default(); @@ -176,7 +179,7 @@ pub fn init(cx: &mut App) { let fs = workspace.project().read(cx).fs().clone(); add_wsl_distro(fs, &open_wsl.distro, cx); let open_options = OpenOptions { - replace_window: window.window_handle().downcast::(), + replace_window: window.window_handle().downcast::(), ..Default::default() }; @@ -232,10 +235,8 @@ pub fn init(cx: &mut App) { cx.on_action(|_: &OpenDevContainer, cx| { with_active_or_new_workspace(cx, move |workspace, window, cx| { - let is_local = workspace.project().read(cx).is_local(); - - cx.spawn_in(window, async move |_, cx| { - if !is_local { + if !workspace.project().read(cx).is_local() { + cx.spawn_in(window, async move |_, cx| { cx.prompt( gpui::PromptLevel::Critical, "Cannot open Dev Container from remote project", @@ -244,21 +245,16 @@ pub fn init(cx: &mut App) { ) .await .ok(); - return; - } - - cx.update(|_, cx| { - with_active_or_new_workspace(cx, move |workspace, window, cx| { - let fs = workspace.project().read(cx).fs().clone(); - let handle = cx.entity().downgrade(); - workspace.toggle_modal(window, cx, |window, cx| { - RemoteServerProjects::new_dev_container(fs, window, handle, cx) - }); - }); }) - .log_err(); - }) - .detach(); + .detach(); + return; + } + + let fs = workspace.project().read(cx).fs().clone(); + let handle = cx.entity().downgrade(); + workspace.toggle_modal(window, cx, |window, cx| { + RemoteServerProjects::new_dev_container(fs, window, handle, cx) + }); }); }); @@ -334,6 +330,7 @@ impl ModalView for RecentProjects {} impl RecentProjects { fn new( delegate: RecentProjectsDelegate, + fs: Option>, rem_width: f32, window: &mut Window, cx: &mut Context, @@ -350,8 +347,9 @@ impl RecentProjects { // We do not want to block the UI on a potentially lengthy call to DB, so we're gonna swap // out workspace locations once the future runs to completion. cx.spawn_in(window, async move |this, cx| { + let Some(fs) = fs else { return }; let workspaces = WORKSPACE_DB - .recent_workspaces_on_disk() + .recent_workspaces_on_disk(fs.as_ref()) .await .log_err() .unwrap_or_default(); @@ -361,7 +359,7 @@ impl RecentProjects { picker.update_matches(picker.query(cx), window, cx) }) }) - .ok() + .ok(); }) .detach(); Self { @@ -379,10 +377,11 @@ impl RecentProjects { cx: &mut Context, ) { let weak = cx.entity().downgrade(); + let fs = Some(workspace.app_state().fs.clone()); workspace.toggle_modal(window, cx, |window, cx| { let delegate = RecentProjectsDelegate::new(weak, create_new_window, true, focus_handle); - Self::new(delegate, 34., window, cx) + Self::new(delegate, fs, 34., window, cx) }) } @@ -393,10 +392,13 @@ impl RecentProjects { window: &mut Window, cx: &mut App, ) -> Entity { + let fs = workspace + .upgrade() + .map(|ws| ws.read(cx).app_state().fs.clone()); cx.new(|cx| { let delegate = RecentProjectsDelegate::new(workspace, create_new_window, true, focus_handle); - let list = Self::new(delegate, 34., window, cx); + let list = Self::new(delegate, fs, 34., window, cx); list.picker.focus_handle(cx).focus(window, cx); list }) @@ -580,27 +582,21 @@ impl PickerDelegate for RecentProjectsDelegate { SerializedWorkspaceLocation::Local => { let paths = candidate_workspace_paths.paths().to_vec(); if replace_current_window { - cx.spawn_in(window, async move |workspace, cx| { - let continue_replacing = workspace - .update_in(cx, |workspace, window, cx| { - workspace.prepare_to_close( - CloseIntent::ReplaceWindow, - window, - cx, - ) - })? - .await?; - if continue_replacing { - workspace - .update_in(cx, |workspace, window, cx| { - workspace - .open_workspace_for_paths(true, paths, window, cx) - })? - .await - } else { - Ok(()) - } - }) + if let Some(handle) = + window.window_handle().downcast::() + { + cx.defer(move |cx| { + if let Some(task) = handle + .update(cx, |multi_workspace, window, cx| { + multi_workspace.open_project(paths, window, cx) + }) + .log_err() + { + task.detach_and_log_err(cx); + } + }); + } + return; } else { workspace.open_workspace_for_paths(false, paths, window, cx) } @@ -609,7 +605,7 @@ impl PickerDelegate for RecentProjectsDelegate { let app_state = workspace.app_state().clone(); let replace_window = if replace_current_window { - window.window_handle().downcast::() + window.window_handle().downcast::() } else { None }; @@ -884,10 +880,18 @@ impl RecentProjectsDelegate { ) { if let Some(selected_match) = self.matches.get(ix) { let (workspace_id, _, _) = self.workspaces[selected_match.candidate_id]; + let fs = self + .workspace + .upgrade() + .map(|ws| ws.read(cx).app_state().fs.clone()); cx.spawn_in(window, async move |this, cx| { - let _ = WORKSPACE_DB.delete_workspace_by_id(workspace_id).await; + WORKSPACE_DB + .delete_workspace_by_id(workspace_id) + .await + .log_err(); + let Some(fs) = fs else { return }; let workspaces = WORKSPACE_DB - .recent_workspaces_on_disk() + .recent_workspaces_on_disk(fs.as_ref()) .await .unwrap_or_default(); this.update_in(cx, move |picker, window, cx| { @@ -904,6 +908,7 @@ impl RecentProjectsDelegate { .update(cx, |this, cx| this.delete_history(workspace_id, cx)); } }) + .ok(); }) .detach(); } @@ -951,7 +956,7 @@ mod tests { use super::*; #[gpui::test] - async fn test_prompts_on_dirty_before_submit(cx: &mut TestAppContext) { + async fn test_dirty_workspace_survives_when_opening_recent_project(cx: &mut TestAppContext) { let app_state = init_test(cx); cx.update(|cx| { @@ -975,6 +980,11 @@ mod tests { }), ) .await; + app_state + .fs + .as_fake() + .insert_tree(path!("/test/path"), json!({})) + .await; cx.update(|cx| { open_paths( &[PathBuf::from(path!("/dir/main.ts"))], @@ -987,31 +997,40 @@ mod tests { .unwrap(); assert_eq!(cx.update(|cx| cx.windows().len()), 1); - let workspace = cx.update(|cx| cx.windows()[0].downcast::().unwrap()); - workspace - .update(cx, |workspace, _, _| assert!(!workspace.is_edited())) + let multi_workspace = cx.update(|cx| cx.windows()[0].downcast::().unwrap()); + multi_workspace + .update(cx, |multi_workspace, _, cx| { + assert!(!multi_workspace.workspace().read(cx).is_edited()) + }) .unwrap(); - let editor = workspace - .read_with(cx, |workspace, cx| { - workspace + let editor = multi_workspace + .read_with(cx, |multi_workspace, cx| { + multi_workspace + .workspace() + .read(cx) .active_item(cx) .unwrap() .downcast::() .unwrap() }) .unwrap(); - workspace + multi_workspace .update(cx, |_, window, cx| { editor.update(cx, |editor, cx| editor.insert("EDIT", window, cx)); }) .unwrap(); - workspace - .update(cx, |workspace, _, _| assert!(workspace.is_edited(), "After inserting more text into the editor without saving, we should have a dirty project")) + multi_workspace + .update(cx, |multi_workspace, _, cx| { + assert!( + multi_workspace.workspace().read(cx).is_edited(), + "After inserting more text into the editor without saving, we should have a dirty project" + ) + }) .unwrap(); - let recent_projects_picker = open_recent_projects(&workspace, cx); - workspace + let recent_projects_picker = open_recent_projects(&multi_workspace, cx); + multi_workspace .update(cx, |_, _, cx| { recent_projects_picker.update(cx, |picker, cx| { assert_eq!(picker.query(cx), ""); @@ -1035,47 +1054,64 @@ mod tests { !cx.has_pending_prompt(), "Should have no pending prompt on dirty project before opening the new recent project" ); - cx.dispatch_action(*workspace, menu::Confirm); - workspace - .update(cx, |workspace, _, cx| { + let dirty_workspace = multi_workspace + .read_with(cx, |multi_workspace, _cx| { + multi_workspace.workspace().clone() + }) + .unwrap(); + + cx.dispatch_action(*multi_workspace, menu::Confirm); + cx.run_until_parked(); + + multi_workspace + .update(cx, |multi_workspace, _, cx| { assert!( - workspace.active_modal::(cx).is_none(), + multi_workspace + .workspace() + .read(cx) + .active_modal::(cx) + .is_none(), "Should remove the modal after selecting new recent project" - ) + ); + + assert!( + multi_workspace.workspaces().len() >= 2, + "Should have at least 2 workspaces: the dirty one and the newly opened one" + ); + + assert!( + multi_workspace.workspaces().contains(&dirty_workspace), + "The original dirty workspace should still be present" + ); + + assert!( + dirty_workspace.read(cx).is_edited(), + "The original workspace should still be dirty" + ); }) .unwrap(); - assert!( - cx.has_pending_prompt(), - "Dirty workspace should prompt before opening the new recent project" - ); - cx.simulate_prompt_answer("Cancel"); + assert!( !cx.has_pending_prompt(), - "Should have no pending prompt after cancelling" + "No save prompt in multi-workspace mode — dirty workspace survives in background" ); - workspace - .update(cx, |workspace, _, _| { - assert!( - workspace.is_edited(), - "Should be in the same dirty project after cancelling" - ) - }) - .unwrap(); } fn open_recent_projects( - workspace: &WindowHandle, + multi_workspace: &WindowHandle, cx: &mut TestAppContext, ) -> Entity> { cx.dispatch_action( - (*workspace).into(), + (*multi_workspace).into(), OpenRecent { create_new_window: false, }, ); - workspace - .update(cx, |workspace, _, cx| { - workspace + multi_workspace + .update(cx, |multi_workspace, _, cx| { + multi_workspace + .workspace() + .read(cx) .active_modal::(cx) .unwrap() .read(cx) diff --git a/crates/recent_projects/src/remote_connections.rs b/crates/recent_projects/src/remote_connections.rs index 9d6199786cb6f251a792fa32e8caccd9351d00d3..52304c211a4d38ef1e408093d1fbdc3c8f07c1bf 100644 --- a/crates/recent_projects/src/remote_connections.rs +++ b/crates/recent_projects/src/remote_connections.rs @@ -19,7 +19,7 @@ use remote::{ pub use settings::SshConnection; use settings::{DevContainerConnection, ExtendingVec, RegisterSetting, Settings, WslConnection}; use util::paths::PathWithPosition; -use workspace::{AppState, Workspace}; +use workspace::{AppState, MultiWorkspace, Workspace}; pub use remote_connection::{ RemoteClientDelegate, RemoteConnectionModal, RemoteConnectionPrompt, SshConnectionHeader, @@ -131,8 +131,11 @@ pub async fn open_remote_project( cx: &mut AsyncApp, ) -> Result<()> { let created_new_window = open_options.replace_window.is_none(); - let window = if let Some(window) = open_options.replace_window { - window + let (window, initial_workspace) = if let Some(window) = open_options.replace_window { + let workspace = window.update(cx, |multi_workspace, _, _| { + multi_workspace.workspace().clone() + })?; + (window, workspace) } else { let workspace_position = cx .update(|cx| { @@ -145,7 +148,7 @@ pub async fn open_remote_project( cx.update(|cx| (app_state.build_window_options)(workspace_position.display, cx)); options.window_bounds = workspace_position.window_bounds; - cx.open_window(options, |window, cx| { + let window = cx.open_window(options, |window, cx| { let project = project::Project::local( app_state.client.clone(), app_state.node_runtime.clone(), @@ -159,12 +162,17 @@ pub async fn open_remote_project( }, cx, ); - cx.new(|cx| { + let workspace = cx.new(|cx| { let mut workspace = Workspace::new(None, project, app_state.clone(), window, cx); workspace.centered_layout = workspace_position.centered_layout; workspace - }) - })? + }); + cx.new(|cx| MultiWorkspace::new(workspace, cx)) + })?; + let workspace = window.update(cx, |multi_workspace, _, _cx| { + multi_workspace.workspace().clone() + })?; + (window, workspace) }; loop { @@ -172,35 +180,38 @@ pub async fn open_remote_project( let delegate = window.update(cx, { let paths = paths.clone(); let connection_options = connection_options.clone(); - move |workspace, window, cx| { + let initial_workspace = initial_workspace.clone(); + move |_multi_workspace: &mut MultiWorkspace, window, cx| { window.activate_window(); - workspace.hide_modal(window, cx); - workspace.toggle_modal(window, cx, |window, cx| { - RemoteConnectionModal::new(&connection_options, paths, window, cx) - }); - - let ui = workspace - .active_modal::(cx)? - .read(cx) - .prompt - .clone(); - - ui.update(cx, |ui, _cx| { - ui.set_cancellation_tx(cancel_tx); - }); - - Some(Arc::new(RemoteClientDelegate::new( - window.window_handle(), - ui.downgrade(), - if let RemoteConnectionOptions::Ssh(options) = &connection_options { - options - .password - .as_deref() - .and_then(|pw| EncryptedPassword::try_from(pw).ok()) - } else { - None - }, - ))) + initial_workspace.update(cx, |workspace, cx| { + workspace.hide_modal(window, cx); + workspace.toggle_modal(window, cx, |window, cx| { + RemoteConnectionModal::new(&connection_options, paths, window, cx) + }); + + let ui = workspace + .active_modal::(cx)? + .read(cx) + .prompt + .clone(); + + ui.update(cx, |ui, _cx| { + ui.set_cancellation_tx(cancel_tx); + }); + + Some(Arc::new(RemoteClientDelegate::new( + window.window_handle(), + ui.downgrade(), + if let RemoteConnectionOptions::Ssh(options) = &connection_options { + options + .password + .as_deref() + .and_then(|pw| EncryptedPassword::try_from(pw).ok()) + } else { + None + }, + ))) + }) } })?; @@ -209,13 +220,11 @@ pub async fn open_remote_project( let connection = remote::connect(connection_options.clone(), delegate.clone(), cx); let connection = select! { _ = cancel_rx => { - window - .update(cx, |workspace, _, cx| { - if let Some(ui) = workspace.active_modal::(cx) { - ui.update(cx, |modal, cx| modal.finished(cx)) - } - }) - .ok(); + initial_workspace.update(cx, |workspace, cx| { + if let Some(ui) = workspace.active_modal::(cx) { + ui.update(cx, |modal, cx| modal.finished(cx)) + } + }); break; }, @@ -224,13 +233,11 @@ pub async fn open_remote_project( let remote_connection = match connection { Ok(connection) => connection, Err(e) => { - window - .update(cx, |workspace, _, cx| { - if let Some(ui) = workspace.active_modal::(cx) { - ui.update(cx, |modal, cx| modal.finished(cx)) - } - }) - .ok(); + initial_workspace.update(cx, |workspace, cx| { + if let Some(ui) = workspace.active_modal::(cx) { + ui.update(cx, |modal, cx| modal.finished(cx)) + } + }); log::error!("Failed to open project: {e:#}"); let response = window .update(cx, |_, window, cx| { @@ -284,13 +291,11 @@ pub async fn open_remote_project( }) .await; - window - .update(cx, |workspace, _, cx| { - if let Some(ui) = workspace.active_modal::(cx) { - ui.update(cx, |modal, cx| modal.finished(cx)) - } - }) - .ok(); + initial_workspace.update(cx, |workspace, cx| { + if let Some(ui) = workspace.active_modal::(cx) { + ui.update(cx, |modal, cx| modal.finished(cx)) + } + }); match opened_items { Err(e) => { @@ -320,20 +325,20 @@ pub async fn open_remote_project( continue; } - window - .update(cx, |workspace, window, cx| { - if created_new_window { - window.remove_window(); - } - trusted_worktrees::track_worktree_trust( - workspace.project().read(cx).worktree_store(), - None, - None, - None, - cx, - ); - }) - .ok(); + if created_new_window { + window + .update(cx, |_, window, _| window.remove_window()) + .ok(); + } + initial_workspace.update(cx, |workspace, cx| { + trusted_worktrees::track_worktree_trust( + workspace.project().read(cx).worktree_store(), + None, + None, + None, + cx, + ); + }); } Ok(items) => { @@ -366,14 +371,20 @@ pub async fn open_remote_project( break; } + // Register the remote client with extensions. We use `multi_workspace.workspace()` here + // (not `initial_workspace`) because `open_remote_project_inner` activated the new remote + // workspace, so the active workspace is now the one with the remote project. window - .update(cx, |workspace, _, cx| { - if let Some(client) = workspace.project().read(cx).remote_client() { - if let Some(extension_store) = ExtensionStore::try_global(cx) { - extension_store - .update(cx, |store, cx| store.register_remote_client(client, cx)); + .update(cx, |multi_workspace: &mut MultiWorkspace, _, cx| { + let workspace = multi_workspace.workspace().clone(); + workspace.update(cx, |workspace, cx| { + if let Some(client) = workspace.project().read(cx).remote_client() { + if let Some(extension_store) = ExtensionStore::try_global(cx) { + extension_store + .update(cx, |store, cx| store.register_remote_client(client, cx)); + } } - } + }); }) .ok(); Ok(()) @@ -500,12 +511,16 @@ mod tests { let windows = cx.update(|cx| cx.windows().len()); assert_eq!(windows, 1, "Should have opened a window"); - let workspace_handle = cx.update(|cx| cx.windows()[0].downcast::().unwrap()); + let multi_workspace_handle = + cx.update(|cx| cx.windows()[0].downcast::().unwrap()); - workspace_handle - .update(cx, |workspace, _, cx| { - let project = workspace.project().read(cx); - assert!(project.is_remote(), "Project should be a remote project"); + multi_workspace_handle + .update(cx, |multi_workspace, _, cx| { + let workspace = multi_workspace.workspace().clone(); + workspace.update(cx, |workspace, cx| { + let project = workspace.project().read(cx); + assert!(project.is_remote(), "Project should be a remote project"); + }); }) .unwrap(); } diff --git a/crates/recent_projects/src/remote_servers.rs b/crates/recent_projects/src/remote_servers.rs index 5b719940f958ac0e4ecb6e186052e3e09987f80e..921b19686ab49bb4704fa72b376fc8370b8f354b 100644 --- a/crates/recent_projects/src/remote_servers.rs +++ b/crates/recent_projects/src/remote_servers.rs @@ -6,7 +6,8 @@ use crate::{ ssh_config::parse_ssh_config_hosts, }; use dev_container::{ - DevContainerConfig, find_devcontainer_configs, start_dev_container_with_config, + DevContainerConfig, DevContainerContext, find_devcontainer_configs, + start_dev_container_with_config, }; use editor::Editor; @@ -51,7 +52,7 @@ use util::{ rel_path::RelPath, }; use workspace::{ - ModalView, OpenLog, OpenOptions, Toast, Workspace, + ModalView, MultiWorkspace, OpenLog, OpenOptions, Toast, Workspace, notifications::{DetachAndPromptErr, NotificationId}, open_remote_project_with_existing_connection, }; @@ -478,10 +479,11 @@ impl ProjectPicker { .log_err()?; let window = cx .open_window(options, |window, cx| { - cx.new(|cx| { + let workspace = cx.new(|cx| { telemetry::event!("SSH Project Created"); Workspace::new(None, project.clone(), app_state.clone(), window, cx) - }) + }); + cx.new(|cx| MultiWorkspace::new(workspace, cx)) }) .log_err()?; @@ -808,11 +810,18 @@ impl RemoteServerProjects { workspace: WeakEntity, cx: &mut Context, ) -> Self { - let this = Self::new_inner( - Mode::CreateRemoteDevContainer(CreateRemoteDevContainer::new( - DevContainerCreationProgress::Creating, - cx, - )), + let configs = workspace + .read_with(cx, |workspace, cx| find_devcontainer_configs(workspace, cx)) + .unwrap_or_default(); + + let initial_mode = if configs.len() > 1 { + DevContainerCreationProgress::SelectingConfig + } else { + DevContainerCreationProgress::Creating + }; + + let mut this = Self::new_inner( + Mode::CreateRemoteDevContainer(CreateRemoteDevContainer::new(initial_mode, cx)), false, fs, window, @@ -820,35 +829,15 @@ impl RemoteServerProjects { cx, ); - // Spawn a task to scan for configs and then start the container - cx.spawn_in(window, async move |entity, cx| { - let configs = find_devcontainer_configs(cx); - - entity - .update_in(cx, |this, window, cx| { - if configs.len() > 1 { - // Multiple configs found - show selection UI - let delegate = DevContainerPickerDelegate::new(configs, cx.weak_entity()); - this.dev_container_picker = Some( - cx.new(|cx| Picker::uniform_list(delegate, window, cx).modal(false)), - ); - - let state = CreateRemoteDevContainer::new( - DevContainerCreationProgress::SelectingConfig, - cx, - ); - this.mode = Mode::CreateRemoteDevContainer(state); - cx.notify(); - } else { - // Single or no config - proceed with opening - let config = configs.into_iter().next(); - this.open_dev_container(config, window, cx); - this.view_in_progress_dev_container(window, cx); - } - }) - .log_err(); - }) - .detach(); + if configs.len() > 1 { + let delegate = DevContainerPickerDelegate::new(configs, cx.weak_entity()); + this.dev_container_picker = + Some(cx.new(|cx| Picker::uniform_list(delegate, window, cx).modal(false))); + } else { + let config = configs.into_iter().next(); + this.open_dev_container(config, window, cx); + this.view_in_progress_dev_container(window, cx); + } this } @@ -1551,7 +1540,9 @@ impl RemoteServerProjects { let replace_window = match (create_new_window, secondary_confirm) { (true, false) | (false, true) => None, - (true, true) | (false, false) => window.window_handle().downcast::(), + (true, true) | (false, false) => { + window.window_handle().downcast::() + } }; cx.spawn_in(window, async move |_, cx| { @@ -1803,25 +1794,25 @@ impl RemoteServerProjects { } fn init_dev_container_mode(&mut self, window: &mut Window, cx: &mut Context) { - cx.spawn_in(window, async move |entity, cx| { - let configs = find_devcontainer_configs(cx); + let configs = self + .workspace + .read_with(cx, |workspace, cx| find_devcontainer_configs(workspace, cx)) + .unwrap_or_default(); - entity - .update_in(cx, |this, window, cx| { - let delegate = DevContainerPickerDelegate::new(configs, cx.weak_entity()); - this.dev_container_picker = - Some(cx.new(|cx| Picker::uniform_list(delegate, window, cx).modal(false))); + if configs.len() > 1 { + let delegate = DevContainerPickerDelegate::new(configs, cx.weak_entity()); + self.dev_container_picker = + Some(cx.new(|cx| Picker::uniform_list(delegate, window, cx).modal(false))); - let state = CreateRemoteDevContainer::new( - DevContainerCreationProgress::SelectingConfig, - cx, - ); - this.mode = Mode::CreateRemoteDevContainer(state); - cx.notify(); - }) - .log_err(); - }) - .detach(); + let state = + CreateRemoteDevContainer::new(DevContainerCreationProgress::SelectingConfig, cx); + self.mode = Mode::CreateRemoteDevContainer(state); + cx.notify(); + } else { + let config = configs.into_iter().next(); + self.open_dev_container(config, window, cx); + self.view_in_progress_dev_container(window, cx); + } } fn open_dev_container( @@ -1830,21 +1821,25 @@ impl RemoteServerProjects { window: &mut Window, cx: &mut Context, ) { - let Some(app_state) = self + let Some((app_state, context)) = self .workspace - .read_with(cx, |workspace, _| workspace.app_state().clone()) + .read_with(cx, |workspace, cx| { + let app_state = workspace.app_state().clone(); + let context = DevContainerContext::from_workspace(workspace, cx)?; + Some((app_state, context)) + }) .log_err() + .flatten() else { + log::error!("No active project directory for Dev Container"); return; }; - let replace_window = window.window_handle().downcast::(); + let replace_window = window.window_handle().downcast::(); cx.spawn_in(window, async move |entity, cx| { let (connection, starting_dir) = - match start_dev_container_with_config(cx, app_state.node_runtime.clone(), config) - .await - { + match start_dev_container_with_config(context, config).await { Ok((c, s)) => (Connection::DevContainer(c), s), Err(e) => { log::error!("Failed to start dev container: {:?}", e); diff --git a/crates/recent_projects/src/wsl_picker.rs b/crates/recent_projects/src/wsl_picker.rs index e386b723fa43777e496565c11b8308f16031d837..7f2a69eb68cb93742d98f438f75f74c95bf3f7d5 100644 --- a/crates/recent_projects/src/wsl_picker.rs +++ b/crates/recent_projects/src/wsl_picker.rs @@ -8,7 +8,7 @@ use ui::{ Render, Styled, StyledExt, Toggleable, Window, div, h_flex, rems, v_flex, }; use util::ResultExt as _; -use workspace::{ModalView, Workspace}; +use workspace::{ModalView, MultiWorkspace}; use crate::open_remote_project; @@ -249,7 +249,7 @@ impl WslOpenModal { false => !secondary, }; let replace_window = match replace_current_window { - true => window.window_handle().downcast::(), + true => window.window_handle().downcast::(), false => None, }; diff --git a/crates/rules_library/src/rules_library.rs b/crates/rules_library/src/rules_library.rs index 30a986add52ec935aeb5752d9d2b2fc214d60a84..9a96f711de12b2891e84691c691fbc7460a35c35 100644 --- a/crates/rules_library/src/rules_library.rs +++ b/crates/rules_library/src/rules_library.rs @@ -24,7 +24,7 @@ use theme::ThemeSettings; use ui::{Divider, ListItem, ListItemSpacing, ListSubHeader, Tooltip, prelude::*}; use ui_input::ErasedEditor; use util::{ResultExt, TryFutureExt}; -use workspace::{Workspace, WorkspaceSettings, client_side_decorations}; +use workspace::{MultiWorkspace, Workspace, WorkspaceSettings, client_side_decorations}; use zed_actions::assistant::InlineAssist; use prompt_store::*; @@ -968,12 +968,14 @@ impl RulesLibrary { .assist(rule_editor, initial_prompt, window, cx); } else { for window in cx.windows() { - if let Some(workspace) = window.downcast::() { - let panel = workspace - .update(cx, |workspace, window, cx| { + if let Some(multi_workspace) = window.downcast::() { + let panel = multi_workspace + .update(cx, |multi_workspace, window, cx| { window.activate_window(); - self.inline_assist_delegate - .focus_agent_panel(workspace, window, cx) + multi_workspace.workspace().update(cx, |workspace, cx| { + self.inline_assist_delegate + .focus_agent_panel(workspace, window, cx) + }) }) .ok(); if panel == Some(true) { diff --git a/crates/session/src/session.rs b/crates/session/src/session.rs index 687c9e20b7a4b92131d77470509a7e5f0b7193ce..de6be034f9732f2c24dd860ebccd0c677d4fc623 100644 --- a/crates/session/src/session.rs +++ b/crates/session/src/session.rs @@ -47,6 +47,15 @@ impl Session { } } + #[cfg(any(test, feature = "test-support"))] + pub fn test_with_old_session(old_session_id: String) -> Self { + Self { + session_id: uuid::Uuid::new_v4().to_string(), + old_session_id: Some(old_session_id), + old_window_ids: None, + } + } + pub fn id(&self) -> &str { &self.session_id } @@ -109,6 +118,11 @@ impl AppSession { self.session.old_session_id.as_deref() } + #[cfg(any(test, feature = "test-support"))] + pub fn replace_session_for_test(&mut self, session: Session) { + self.session = session; + } + pub fn last_session_window_stack(&self) -> Option> { self.session.old_window_ids.clone() } diff --git a/crates/settings_profile_selector/src/settings_profile_selector.rs b/crates/settings_profile_selector/src/settings_profile_selector.rs index 42d714283a4e1ed569bd03a5386ab16988a8014a..7ca91e3767efb6b550af7887e70a0187fed6daad 100644 --- a/crates/settings_profile_selector/src/settings_profile_selector.rs +++ b/crates/settings_profile_selector/src/settings_profile_selector.rs @@ -287,7 +287,7 @@ mod tests { use serde_json::json; use settings::Settings; use theme::{self, ThemeSettings}; - use workspace::{self, AppState}; + use workspace::{self, AppState, MultiWorkspace}; use zed_actions::settings_profile_selector; async fn init_test( @@ -320,8 +320,11 @@ mod tests { let fs = FakeFs::new(cx.executor()); let project = Project::test(fs, ["/test".as_ref()], cx).await; - let (workspace, cx) = - cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let cx = VisualTestContext::from_window(*window, cx).into_mut(); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); cx.update(|_, cx| { assert!(!cx.has_global::()); diff --git a/crates/settings_ui/src/settings_ui.rs b/crates/settings_ui/src/settings_ui.rs index d7327650fc636ca33c2bf35bd9f60d5ddcd78e49..25609e5eb9d14f18b1c63597f3765d61c1e3145d 100644 --- a/crates/settings_ui/src/settings_ui.rs +++ b/crates/settings_ui/src/settings_ui.rs @@ -40,7 +40,9 @@ use ui::{ }; use util::{ResultExt as _, paths::PathStyle, rel_path::RelPath}; -use workspace::{AppState, OpenOptions, OpenVisible, Workspace, client_side_decorations}; +use workspace::{ + AppState, MultiWorkspace, OpenOptions, OpenVisible, Workspace, client_side_decorations, +}; use zed_actions::{OpenProjectSettings, OpenSettings, OpenSettingsAt}; use crate::components::{ @@ -394,7 +396,7 @@ pub fn init(cx: &mut App) { |workspace, OpenSettingsAt { path }: &OpenSettingsAt, window, cx| { let window_handle = window .window_handle() - .downcast::() + .downcast::() .expect("Workspaces are root Windows"); open_settings_editor(workspace, Some(&path), false, window_handle, cx); }, @@ -402,14 +404,14 @@ pub fn init(cx: &mut App) { .register_action(|workspace, _: &OpenSettings, window, cx| { let window_handle = window .window_handle() - .downcast::() + .downcast::() .expect("Workspaces are root Windows"); open_settings_editor(workspace, None, false, window_handle, cx); }) .register_action(|workspace, _: &OpenProjectSettings, window, cx| { let window_handle = window .window_handle() - .downcast::() + .downcast::() .expect("Workspaces are root Windows"); open_settings_editor(workspace, None, true, window_handle, cx); }); @@ -547,7 +549,7 @@ pub fn open_settings_editor( _workspace: &mut Workspace, path: Option<&str>, open_project_settings: bool, - workspace_handle: WindowHandle, + workspace_handle: WindowHandle, cx: &mut App, ) { telemetry::event!("Settings Viewed"); @@ -715,7 +717,7 @@ fn active_language_mut() -> Option>, - original_window: Option>, + original_window: Option>, files: Vec<(SettingsUiFile, FocusHandle)>, worktree_root_dirs: HashMap, current_file: SettingsUiFile, @@ -1447,7 +1449,7 @@ impl SettingsUiFile { impl SettingsWindow { fn new( - original_window: Option>, + original_window: Option>, window: &mut Window, cx: &mut Context, ) -> Self { @@ -1518,34 +1520,21 @@ impl SettingsWindow { .detach(); if let Some(app_state) = AppState::global(cx).upgrade() { - for project in app_state + let workspaces: Vec> = app_state .workspace_store .read(cx) .workspaces() - .iter() - .filter_map(|space| { - space - .read(cx) - .ok() - .map(|workspace| workspace.project().clone()) - }) - .collect::>() - { + .filter_map(|weak| weak.upgrade()) + .collect(); + + for workspace in workspaces { + let project = workspace.read(cx).project().clone(); cx.observe_release_in(&project, window, |this, _, window, cx| { this.fetch_files(window, cx) }) .detach(); cx.subscribe_in(&project, window, Self::handle_project_event) .detach(); - } - - for workspace in app_state - .workspace_store - .read(cx) - .workspaces() - .iter() - .filter_map(|space| space.entity(cx).ok()) - { cx.observe_release_in(&workspace, window, |this, _, window, cx| { this.fetch_files(window, cx) }) @@ -3320,56 +3309,19 @@ impl SettingsWindow { return; }; original_window - .update(cx, |workspace, window, cx| { - workspace - .with_local_or_wsl_workspace(window, cx, |workspace, window, cx| { - let project = workspace.project().clone(); - - cx.spawn_in(window, async move |workspace, cx| { - let (config_dir, settings_file) = - project.update(cx, |project, cx| { - ( - project.try_windows_path_to_wsl( - paths::config_dir().as_path(), - cx, - ), - project.try_windows_path_to_wsl( - paths::settings_file().as_path(), - cx, - ), - ) - }); - let config_dir = config_dir.await?; - let settings_file = settings_file.await?; - project - .update(cx, |project, cx| { - project.find_or_create_worktree(&config_dir, false, cx) - }) - .await - .ok(); - workspace - .update_in(cx, |workspace, window, cx| { - workspace.open_paths( - vec![settings_file], - OpenOptions { - visible: Some(OpenVisible::None), - ..Default::default() - }, - None, - window, - cx, - ) - })? - .await; - - workspace.update_in(cx, |_, window, cx| { - window.activate_window(); - cx.notify(); - }) - }) - .detach(); - }) - .detach(); + .update(cx, |multi_workspace, window, cx| { + multi_workspace + .workspace() + .clone() + .update(cx, |workspace, cx| { + workspace + .with_local_or_wsl_workspace( + window, + cx, + open_user_settings_in_workspace, + ) + .detach(); + }); }) .ok(); @@ -3381,22 +3333,22 @@ impl SettingsWindow { return; }; - let Some((worktree, corresponding_workspace)) = app_state + let Some((workspace_window, worktree, corresponding_workspace)) = app_state .workspace_store .read(cx) - .workspaces() - .iter() - .find_map(|workspace| { + .workspaces_with_windows() + .filter_map(|(window_handle, weak)| { + let workspace = weak.upgrade()?; + let window = window_handle.downcast::()?; + Some((window, workspace)) + }) + .find_map(|(window, workspace): (_, Entity)| { workspace - .read_with(cx, |workspace, cx| { - workspace - .project() - .read(cx) - .worktree_for_id(*worktree_id, cx) - }) - .ok() - .flatten() - .zip(Some(*workspace)) + .read(cx) + .project() + .read(cx) + .worktree_for_id(*worktree_id, cx) + .map(|worktree| (window, worktree, workspace)) }) else { log::error!( @@ -3424,14 +3376,15 @@ impl SettingsWindow { // TODO: move zed::open_local_file() APIs to this crate, and // re-implement the "initial_contents" behavior - corresponding_workspace + let workspace_weak = corresponding_workspace.downgrade(); + workspace_window .update(cx, |_, window, cx| { - cx.spawn_in(window, async move |workspace, cx| { + cx.spawn_in(window, async move |_, cx| { if let Some(create_task) = create_task { create_task.await.ok()?; }; - workspace + workspace_weak .update_in(cx, |workspace, window, cx| { workspace.open_path( (worktree_id, settings_path.clone()), @@ -3445,7 +3398,7 @@ impl SettingsWindow { .await .log_err()?; - workspace + workspace_weak .update_in(cx, |_, window, cx| { window.activate_window(); cx.notify(); @@ -3752,7 +3705,7 @@ impl Render for SettingsWindow { } fn all_projects( - window: Option<&WindowHandle>, + window: Option<&WindowHandle>, cx: &App, ) -> impl Iterator> { let mut seen_project_ids = std::collections::HashSet::new(); @@ -3763,10 +3716,19 @@ fn all_projects( .workspace_store .read(cx) .workspaces() - .iter() - .filter_map(|workspace| Some(workspace.read(cx).ok()?.project().clone())) + .filter_map(|weak| weak.upgrade()) + .map(|workspace: Entity| workspace.read(cx).project().clone()) .chain( - window.and_then(|workspace| Some(workspace.read(cx).ok()?.project().clone())), + window + .and_then(|handle| handle.read(cx).ok()) + .into_iter() + .flat_map(|multi_workspace| { + multi_workspace + .workspaces() + .iter() + .map(|workspace| workspace.read(cx).project().clone()) + .collect::>() + }), ) .filter(move |project| seen_project_ids.insert(project.entity_id())) }) @@ -3774,6 +3736,51 @@ fn all_projects( .flatten() } +fn open_user_settings_in_workspace( + workspace: &mut Workspace, + window: &mut Window, + cx: &mut Context, +) { + let project = workspace.project().clone(); + + cx.spawn_in(window, async move |workspace, cx| { + let (config_dir, settings_file) = project.update(cx, |project, cx| { + ( + project.try_windows_path_to_wsl(paths::config_dir().as_path(), cx), + project.try_windows_path_to_wsl(paths::settings_file().as_path(), cx), + ) + }); + let config_dir = config_dir.await?; + let settings_file = settings_file.await?; + project + .update(cx, |project, cx| { + project.find_or_create_worktree(&config_dir, false, cx) + }) + .await + .ok(); + workspace + .update_in(cx, |workspace, window, cx| { + workspace.open_paths( + vec![settings_file], + OpenOptions { + visible: Some(OpenVisible::None), + ..Default::default() + }, + None, + window, + cx, + ) + })? + .await; + + workspace.update_in(cx, |_, window, cx| { + window.activate_window(); + cx.notify(); + }) + }) + .detach(); +} + fn update_settings_file( file: SettingsUiFile, file_name: Option<&'static str>, @@ -4754,29 +4761,33 @@ pub mod test { .await .expect("Failed to create worktree_c"); - let (_workspace1, cx) = cx.add_window_view(|window, cx| { - Workspace::new( - Default::default(), - project1.clone(), - app_state.clone(), - window, - cx, - ) + let (_multi_workspace1, cx) = cx.add_window_view(|window, cx| { + let workspace = cx.new(|cx| { + Workspace::new( + Default::default(), + project1.clone(), + app_state.clone(), + window, + cx, + ) + }); + MultiWorkspace::new(workspace, cx) }); - let _workspace1_handle = cx.window_handle().downcast::().unwrap(); - - let (_workspace2, cx) = cx.add_window_view(|window, cx| { - Workspace::new( - Default::default(), - project2.clone(), - app_state.clone(), - window, - cx, - ) + let (_multi_workspace2, cx) = cx.add_window_view(|window, cx| { + let workspace = cx.new(|cx| { + Workspace::new( + Default::default(), + project2.clone(), + app_state.clone(), + window, + cx, + ) + }); + MultiWorkspace::new(workspace, cx) }); - let workspace2_handle = cx.window_handle().downcast::().unwrap(); + let workspace2_handle = cx.window_handle().downcast::().unwrap(); cx.run_until_parked(); @@ -4895,17 +4906,20 @@ pub mod test { .await .expect("Failed to create worktree_a"); - let (_workspace1, cx) = cx.add_window_view(|window, cx| { - Workspace::new( - Default::default(), - project1.clone(), - app_state.clone(), - window, - cx, - ) + let (_multi_workspace1, cx) = cx.add_window_view(|window, cx| { + let workspace = cx.new(|cx| { + Workspace::new( + Default::default(), + project1.clone(), + app_state.clone(), + window, + cx, + ) + }); + MultiWorkspace::new(workspace, cx) }); - let workspace1_handle = cx.window_handle().downcast::().unwrap(); + let workspace1_handle = cx.window_handle().downcast::().unwrap(); cx.run_until_parked(); @@ -4942,14 +4956,17 @@ pub mod test { .await .expect("Failed to create worktree_b"); - let (_workspace2, cx) = cx.add_window_view(|window, cx| { - Workspace::new( - Default::default(), - project2.clone(), - app_state.clone(), - window, - cx, - ) + let (_multi_workspace2, cx) = cx.add_window_view(|window, cx| { + let workspace = cx.new(|cx| { + Workspace::new( + Default::default(), + project2.clone(), + app_state.clone(), + window, + cx, + ) + }); + MultiWorkspace::new(workspace, cx) }); cx.run_until_parked(); diff --git a/crates/sidebar/Cargo.toml b/crates/sidebar/Cargo.toml new file mode 100644 index 0000000000000000000000000000000000000000..da4f29da8208540a483049687bae5a9715b2c710 --- /dev/null +++ b/crates/sidebar/Cargo.toml @@ -0,0 +1,43 @@ +[package] +name = "sidebar" +version = "0.1.0" +edition.workspace = true +publish.workspace = true +license = "GPL-3.0-or-later" + +[lints] +workspace = true + +[lib] +path = "src/sidebar.rs" + +[features] +default = [] +test-support = [] + +[dependencies] +acp_thread.workspace = true +agent_ui.workspace = true +db.workspace = true +fs.workspace = true +fuzzy.workspace = true +serde_json.workspace = true +gpui.workspace = true +picker.workspace = true +project.workspace = true +recent_projects.workspace = true +theme.workspace = true +ui.workspace = true +ui_input.workspace = true +util.workspace = true +workspace.workspace = true + +[dev-dependencies] +editor.workspace = true +feature_flags.workspace = true +fs = { workspace = true, features = ["test-support"] } +gpui = { workspace = true, features = ["test-support"] } +project = { workspace = true, features = ["test-support"] } +recent_projects = { workspace = true, features = ["test-support"] } +settings = { workspace = true, features = ["test-support"] } +workspace = { workspace = true, features = ["test-support"] } diff --git a/crates/sidebar/LICENSE-GPL b/crates/sidebar/LICENSE-GPL new file mode 120000 index 0000000000000000000000000000000000000000..89e542f750cd3860a0598eff0dc34b56d7336dc4 --- /dev/null +++ b/crates/sidebar/LICENSE-GPL @@ -0,0 +1 @@ +../../LICENSE-GPL \ No newline at end of file diff --git a/crates/sidebar/src/sidebar.rs b/crates/sidebar/src/sidebar.rs new file mode 100644 index 0000000000000000000000000000000000000000..16a03e76a1a1b393fe6814c6943af662e5bf7869 --- /dev/null +++ b/crates/sidebar/src/sidebar.rs @@ -0,0 +1,1304 @@ +use acp_thread::ThreadStatus; +use agent_ui::{AgentPanel, AgentPanelEvent}; +use db::kvp::KEY_VALUE_STORE; +use fs::Fs; +use fuzzy::StringMatchCandidate; +use gpui::{ + App, Context, Entity, EventEmitter, FocusHandle, Focusable, Pixels, Render, SharedString, + Subscription, Task, Window, px, +}; +use picker::{Picker, PickerDelegate}; +use project::Event as ProjectEvent; +use recent_projects::{RecentProjectEntry, get_recent_projects}; + +use std::collections::{HashMap, HashSet}; + +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use theme::ActiveTheme; +use ui::utils::TRAFFIC_LIGHT_PADDING; +use ui::{CommonAnimationExt, Divider, HighlightedLabel, ListItem, Tab, Tooltip, prelude::*}; +use ui_input::ErasedEditor; +use util::ResultExt as _; +use workspace::{ + MultiWorkspace, NewWorkspaceInWindow, Sidebar as WorkspaceSidebar, SidebarEvent, + ToggleWorkspaceSidebar, Workspace, +}; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum AgentThreadStatus { + Running, + Completed, +} + +#[derive(Clone, Debug)] +struct AgentThreadInfo { + title: SharedString, + status: AgentThreadStatus, +} + +const LAST_THREAD_TITLES_KEY: &str = "sidebar-last-thread-titles"; + +const DEFAULT_WIDTH: Pixels = px(320.0); +const MIN_WIDTH: Pixels = px(200.0); +const MAX_WIDTH: Pixels = px(800.0); +const MAX_MATCHES: usize = 100; + +#[derive(Clone)] +struct WorkspaceThreadEntry { + index: usize, + worktree_label: SharedString, + full_path: SharedString, + thread_info: Option, +} + +impl WorkspaceThreadEntry { + fn new( + index: usize, + workspace: &Entity, + persisted_titles: &HashMap, + cx: &App, + ) -> Self { + let workspace_ref = workspace.read(cx); + + let worktrees: Vec<_> = workspace_ref + .worktrees(cx) + .map(|worktree| worktree.read(cx).abs_path()) + .collect(); + + let worktree_names: Vec = worktrees + .iter() + .filter_map(|path| { + path.file_name() + .map(|name| name.to_string_lossy().to_string()) + }) + .collect(); + + let worktree_label: SharedString = if worktree_names.is_empty() { + format!("Workspace {}", index + 1).into() + } else { + worktree_names.join(", ").into() + }; + + let full_path: SharedString = worktrees + .iter() + .map(|path| path.to_string_lossy().to_string()) + .collect::>() + .join("\n") + .into(); + + let thread_info = Self::thread_info(workspace, cx).or_else(|| { + if worktrees.is_empty() { + return None; + } + let path_key = sorted_paths_key(&worktrees); + let title = persisted_titles.get(&path_key)?; + Some(AgentThreadInfo { + title: SharedString::from(title.clone()), + status: AgentThreadStatus::Completed, + }) + }); + + Self { + index, + worktree_label, + full_path, + thread_info, + } + } + + fn thread_info(workspace: &Entity, cx: &App) -> Option { + let agent_panel = workspace.read(cx).panel::(cx)?; + let thread = agent_panel.read(cx).active_agent_thread(cx)?; + let thread_ref = thread.read(cx); + let title = thread_ref.title(); + let status = match thread_ref.status() { + ThreadStatus::Generating => AgentThreadStatus::Running, + ThreadStatus::Idle => AgentThreadStatus::Completed, + }; + Some(AgentThreadInfo { title, status }) + } +} + +#[derive(Clone)] +enum SidebarEntry { + Separator(SharedString), + WorkspaceThread(WorkspaceThreadEntry), + RecentProject(RecentProjectEntry), +} + +impl SidebarEntry { + fn searchable_text(&self) -> &str { + match self { + SidebarEntry::Separator(_) => "", + SidebarEntry::WorkspaceThread(entry) => entry.worktree_label.as_ref(), + SidebarEntry::RecentProject(entry) => entry.name.as_ref(), + } + } +} + +#[derive(Clone)] +struct SidebarMatch { + entry: SidebarEntry, + positions: Vec, +} + +struct WorkspacePickerDelegate { + multi_workspace: Entity, + entries: Vec, + active_workspace_index: usize, + workspace_thread_count: usize, + /// All recent projects including what's filtered out of entries + /// used to add unopened projects to entries on rebuild + recent_projects: Vec, + recent_project_thread_titles: HashMap, + matches: Vec, + selected_index: usize, + query: String, + notified_workspaces: HashSet, +} + +impl WorkspacePickerDelegate { + fn new(multi_workspace: Entity) -> Self { + Self { + multi_workspace, + entries: Vec::new(), + active_workspace_index: 0, + workspace_thread_count: 0, + recent_projects: Vec::new(), + recent_project_thread_titles: HashMap::new(), + matches: Vec::new(), + selected_index: 0, + query: String::new(), + notified_workspaces: HashSet::new(), + } + } + + fn set_entries( + &mut self, + workspace_threads: Vec, + active_workspace_index: usize, + cx: &App, + ) { + let old_statuses: HashMap = self + .entries + .iter() + .filter_map(|entry| match entry { + SidebarEntry::WorkspaceThread(thread) => thread + .thread_info + .as_ref() + .map(|info| (thread.index, info.status.clone())), + _ => None, + }) + .collect(); + + for thread in &workspace_threads { + if let Some(info) = &thread.thread_info { + if info.status == AgentThreadStatus::Completed + && thread.index != active_workspace_index + { + if old_statuses.get(&thread.index) == Some(&AgentThreadStatus::Running) { + self.notified_workspaces.insert(thread.index); + } + } + } + } + + if self.active_workspace_index != active_workspace_index { + self.notified_workspaces.remove(&active_workspace_index); + } + self.active_workspace_index = active_workspace_index; + self.workspace_thread_count = workspace_threads.len(); + self.rebuild_entries(workspace_threads, cx); + } + + fn set_recent_projects(&mut self, recent_projects: Vec, cx: &App) { + self.recent_project_thread_titles.clear(); + if let Some(map) = read_thread_title_map() { + for entry in &recent_projects { + let path_key = sorted_paths_key(&entry.paths); + if let Some(title) = map.get(&path_key) { + self.recent_project_thread_titles + .insert(entry.full_path.clone(), title.clone().into()); + } + } + } + + self.recent_projects = recent_projects; + + let workspace_threads: Vec = self + .entries + .iter() + .filter_map(|entry| match entry { + SidebarEntry::WorkspaceThread(thread) => Some(thread.clone()), + _ => None, + }) + .collect(); + self.rebuild_entries(workspace_threads, cx); + } + + fn open_workspace_path_sets(&self, cx: &App) -> Vec>> { + self.multi_workspace + .read(cx) + .workspaces() + .iter() + .map(|workspace| { + let mut paths = workspace.read(cx).root_paths(cx); + paths.sort(); + paths + }) + .collect() + } + + fn rebuild_entries(&mut self, workspace_threads: Vec, cx: &App) { + let open_path_sets = self.open_workspace_path_sets(cx); + + self.entries.clear(); + + if !workspace_threads.is_empty() { + self.entries + .push(SidebarEntry::Separator("Active Workspaces".into())); + for thread in workspace_threads { + self.entries.push(SidebarEntry::WorkspaceThread(thread)); + } + } + + let recent: Vec<_> = self + .recent_projects + .iter() + .filter(|project| { + let mut project_paths: Vec<&Path> = + project.paths.iter().map(|p| p.as_path()).collect(); + project_paths.sort(); + !open_path_sets.iter().any(|open_paths| { + open_paths.len() == project_paths.len() + && open_paths + .iter() + .zip(&project_paths) + .all(|(a, b)| a.as_ref() == *b) + }) + }) + .cloned() + .collect(); + + if !recent.is_empty() { + self.entries + .push(SidebarEntry::Separator("Recent Projects".into())); + for project in recent { + self.entries.push(SidebarEntry::RecentProject(project)); + } + } + } + + fn open_recent_project(paths: Vec, window: &mut Window, cx: &mut App) { + let Some(handle) = window.window_handle().downcast::() else { + return; + }; + + cx.defer(move |cx| { + if let Some(task) = handle + .update(cx, |multi_workspace, window, cx| { + multi_workspace.open_project(paths, window, cx) + }) + .log_err() + { + task.detach_and_log_err(cx); + } + }); + } +} + +impl PickerDelegate for WorkspacePickerDelegate { + type ListItem = AnyElement; + + fn match_count(&self) -> usize { + self.matches.len() + } + + fn selected_index(&self) -> usize { + self.selected_index + } + + fn set_selected_index( + &mut self, + ix: usize, + _window: &mut Window, + _cx: &mut Context>, + ) { + self.selected_index = ix; + } + + fn can_select( + &mut self, + ix: usize, + _window: &mut Window, + _cx: &mut Context>, + ) -> bool { + match self.matches.get(ix) { + Some(SidebarMatch { + entry: SidebarEntry::Separator(_), + .. + }) => false, + _ => true, + } + } + + fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc { + "Search…".into() + } + + fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option { + if self.query.is_empty() { + None + } else { + Some("No threads match your search.".into()) + } + } + + fn update_matches( + &mut self, + query: String, + window: &mut Window, + cx: &mut Context>, + ) -> Task<()> { + self.query = query.clone(); + let entries = self.entries.clone(); + + if query.is_empty() { + self.matches = entries + .into_iter() + .map(|entry| SidebarMatch { + entry, + positions: Vec::new(), + }) + .collect(); + + let separator_offset = if self.workspace_thread_count > 0 { + 1 + } else { + 0 + }; + self.selected_index = (self.active_workspace_index + separator_offset) + .min(self.matches.len().saturating_sub(1)); + return Task::ready(()); + } + + let executor = cx.background_executor().clone(); + cx.spawn_in(window, async move |picker, cx| { + let matches = cx + .background_spawn(async move { + let data_entries: Vec<(usize, &SidebarEntry)> = entries + .iter() + .enumerate() + .filter(|(_, entry)| !matches!(entry, SidebarEntry::Separator(_))) + .collect(); + + let candidates: Vec = data_entries + .iter() + .enumerate() + .map(|(candidate_index, (_, entry))| { + StringMatchCandidate::new(candidate_index, entry.searchable_text()) + }) + .collect(); + + let search_matches = fuzzy::match_strings( + &candidates, + &query, + false, + true, + MAX_MATCHES, + &Default::default(), + executor, + ) + .await; + + let mut workspace_matches = Vec::new(); + let mut project_matches = Vec::new(); + + for search_match in search_matches { + let (original_index, _) = data_entries[search_match.candidate_id]; + let entry = entries[original_index].clone(); + let sidebar_match = SidebarMatch { + positions: search_match.positions, + entry: entry.clone(), + }; + match entry { + SidebarEntry::WorkspaceThread(_) => { + workspace_matches.push(sidebar_match) + } + SidebarEntry::RecentProject(_) => project_matches.push(sidebar_match), + SidebarEntry::Separator(_) => {} + } + } + + let mut result = Vec::new(); + if !workspace_matches.is_empty() { + result.push(SidebarMatch { + entry: SidebarEntry::Separator("Active Workspaces".into()), + positions: Vec::new(), + }); + result.extend(workspace_matches); + } + if !project_matches.is_empty() { + result.push(SidebarMatch { + entry: SidebarEntry::Separator("Recent Projects".into()), + positions: Vec::new(), + }); + result.extend(project_matches); + } + result + }) + .await; + + picker + .update_in(cx, |picker, _window, _cx| { + picker.delegate.matches = matches; + if picker.delegate.matches.is_empty() { + picker.delegate.selected_index = 0; + } else { + let first_selectable = picker + .delegate + .matches + .iter() + .position(|m| !matches!(m.entry, SidebarEntry::Separator(_))) + .unwrap_or(0); + picker.delegate.selected_index = first_selectable; + } + }) + .log_err(); + }) + } + + fn confirm(&mut self, _secondary: bool, window: &mut Window, cx: &mut Context>) { + let Some(selected_match) = self.matches.get(self.selected_index) else { + return; + }; + + match &selected_match.entry { + SidebarEntry::Separator(_) => {} + SidebarEntry::WorkspaceThread(thread_entry) => { + let target_index = thread_entry.index; + self.multi_workspace.update(cx, |multi_workspace, cx| { + multi_workspace.activate_index(target_index, window, cx); + }); + } + SidebarEntry::RecentProject(project_entry) => { + let paths = project_entry.paths.clone(); + Self::open_recent_project(paths, window, cx); + } + } + } + + fn dismissed(&mut self, _window: &mut Window, _cx: &mut Context>) {} + + fn render_match( + &self, + index: usize, + selected: bool, + _window: &mut Window, + _cx: &mut Context>, + ) -> Option { + let match_entry = self.matches.get(index)?; + let SidebarMatch { entry, positions } = match_entry; + + fn render_title(text: SharedString, positions: &[usize]) -> AnyElement { + if positions.is_empty() { + div() + .p_0p5() + .child(Label::new(text).truncate()) + .into_any_element() + } else { + div() + .p_0p5() + .child(HighlightedLabel::new(text, positions.to_vec()).truncate()) + .into_any_element() + } + } + + fn render_thread_status_icon( + workspace_index: usize, + status: &AgentThreadStatus, + has_notification: bool, + ) -> AnyElement { + match status { + AgentThreadStatus::Running => Icon::new(IconName::LoadCircle) + .size(IconSize::XSmall) + .color(Color::Muted) + .with_keyed_rotate_animation( + SharedString::from(format!("workspace-{}-spinner", workspace_index)), + 3, + ) + .into_any_element(), + AgentThreadStatus::Completed => { + let color = if has_notification { + Color::Accent + } else { + Color::Muted + }; + Icon::new(IconName::Check) + .size(IconSize::XSmall) + .color(color) + .into_any_element() + } + } + } + + fn render_project_row( + title: AnyElement, + thread_subtitle: Option, + status_icon: Option, + cx: &App, + ) -> Div { + h_flex() + .items_start() + .gap(DynamicSpacing::Base06.rems(cx)) + .child( + div().pt(px(4.0)).child( + Icon::new(IconName::Folder) + .color(Color::Muted) + .size(IconSize::XSmall), + ), + ) + .child(v_flex().overflow_hidden().child(title).when_some( + thread_subtitle, + |this, subtitle| { + this.child( + h_flex() + .gap_1() + .items_center() + .px_0p5() + .when_some(status_icon, |this, icon| this.child(icon)) + .child( + Label::new(subtitle) + .size(LabelSize::Small) + .color(Color::Muted) + .truncate(), + ), + ) + }, + )) + } + + match entry { + SidebarEntry::Separator(title) => Some( + div() + .px_0p5() + .when(index > 0, |this| this.mt_1().child(Divider::horizontal())) + .child( + ListItem::new("section_header").selectable(false).child( + Label::new(title.clone()) + .size(LabelSize::XSmall) + .color(Color::Muted) + .when(index > 0, |this| this.mt_1p5()) + .mb_1(), + ), + ) + .into_any_element(), + ), + SidebarEntry::WorkspaceThread(thread_entry) => { + let worktree_label = thread_entry.worktree_label.clone(); + let full_path = thread_entry.full_path.clone(); + let title = render_title(worktree_label.clone(), positions); + let thread_info = thread_entry.thread_info.clone(); + let workspace_index = thread_entry.index; + let multi_workspace = self.multi_workspace.clone(); + let workspace_count = self.multi_workspace.read(_cx).workspaces().len(); + + let close_button = if workspace_count > 1 { + Some( + IconButton::new( + SharedString::from(format!("close-workspace-{}", workspace_index)), + IconName::Close, + ) + .icon_size(IconSize::XSmall) + .icon_color(Color::Muted) + .tooltip(Tooltip::text("Close Workspace")) + .on_click({ + let multi_workspace = multi_workspace; + move |_, window, cx| { + multi_workspace.update(cx, |mw, cx| { + mw.remove_workspace(workspace_index, window, cx); + }); + } + }), + ) + } else { + None + }; + + let has_notification = self.notified_workspaces.contains(&workspace_index); + let (thread_subtitle, status_icon) = match thread_info { + Some(info) => ( + Some(info.title), + Some(render_thread_status_icon( + workspace_index, + &info.status, + has_notification, + )), + ), + None => (None, None), + }; + + Some( + ListItem::new(("workspace-item", thread_entry.index)) + .toggle_state(selected) + .when_some(close_button, |item, button| item.end_hover_slot(button)) + .child(render_project_row(title, thread_subtitle, status_icon, _cx)) + .when(!full_path.is_empty(), |item| { + item.tooltip(move |_, cx| { + Tooltip::with_meta( + worktree_label.clone(), + None, + full_path.clone(), + cx, + ) + }) + }) + .into_any_element(), + ) + } + SidebarEntry::RecentProject(project_entry) => { + let name = project_entry.name.clone(); + let full_path = project_entry.full_path.clone(); + let title = render_title(name.clone(), positions); + let item_id: SharedString = + format!("recent-project-{:?}", project_entry.workspace_id).into(); + let thread_title = self + .recent_project_thread_titles + .get(&project_entry.full_path) + .cloned(); + + Some( + ListItem::new(item_id) + .toggle_state(selected) + .child(render_project_row(title, thread_title, None, _cx)) + .tooltip(move |_, cx| { + Tooltip::with_meta(name.clone(), None, full_path.clone(), cx) + }) + .into_any_element(), + ) + } + } + } + + fn render_editor( + &self, + editor: &Arc, + window: &mut Window, + cx: &mut Context>, + ) -> Div { + h_flex() + .h(Tab::container_height(cx)) + .w_full() + .px_2() + .gap_2() + .justify_between() + .border_b_1() + .border_color(cx.theme().colors().border) + .child( + Icon::new(IconName::MagnifyingGlass) + .color(Color::Muted) + .size(IconSize::Small), + ) + .child(editor.render(window, cx)) + } +} + +pub struct Sidebar { + multi_workspace: Entity, + width: Pixels, + picker: Entity>, + _subscription: Subscription, + _project_subscriptions: Vec, + _agent_panel_subscriptions: Vec, + _thread_subscriptions: Vec, + #[cfg(any(test, feature = "test-support"))] + test_thread_infos: HashMap, + #[cfg(any(test, feature = "test-support"))] + test_recent_project_thread_titles: HashMap, + _fetch_recent_projects: Task<()>, +} + +impl EventEmitter for Sidebar {} + +impl Sidebar { + pub fn new( + multi_workspace: Entity, + window: &mut Window, + cx: &mut Context, + ) -> Self { + let delegate = WorkspacePickerDelegate::new(multi_workspace.clone()); + let picker = cx.new(|cx| { + Picker::list(delegate, window, cx) + .max_height(None) + .show_scrollbar(true) + .modal(false) + }); + + let subscription = cx.observe_in( + &multi_workspace, + window, + |this, multi_workspace, window, cx| { + this.queue_refresh(multi_workspace, window, cx); + }, + ); + + let fetch_recent_projects = { + let picker = picker.downgrade(); + let fs = ::global(cx); + cx.spawn_in(window, async move |_this, cx| { + let projects = get_recent_projects(None, None, fs).await; + + cx.update(|window, cx| { + if let Some(picker) = picker.upgrade() { + picker.update(cx, |picker, cx| { + picker.delegate.set_recent_projects(projects, cx); + let query = picker.query(cx); + picker.update_matches(query, window, cx); + }); + } + }) + .log_err(); + }) + }; + + let mut this = Self { + multi_workspace, + width: DEFAULT_WIDTH, + picker, + _subscription: subscription, + _project_subscriptions: Vec::new(), + _agent_panel_subscriptions: Vec::new(), + _thread_subscriptions: Vec::new(), + #[cfg(any(test, feature = "test-support"))] + test_thread_infos: HashMap::new(), + #[cfg(any(test, feature = "test-support"))] + test_recent_project_thread_titles: HashMap::new(), + _fetch_recent_projects: fetch_recent_projects, + }; + this.queue_refresh(this.multi_workspace.clone(), window, cx); + this + } + + fn subscribe_to_projects( + &mut self, + window: &mut Window, + cx: &mut Context, + ) -> Vec { + let projects: Vec<_> = self + .multi_workspace + .read(cx) + .workspaces() + .iter() + .map(|w| w.read(cx).project().clone()) + .collect(); + + projects + .iter() + .map(|project| { + cx.subscribe_in( + project, + window, + |this, _project, event, window, cx| match event { + ProjectEvent::WorktreeAdded(_) + | ProjectEvent::WorktreeRemoved(_) + | ProjectEvent::WorktreeOrderChanged => { + this.queue_refresh(this.multi_workspace.clone(), window, cx); + } + _ => {} + }, + ) + }) + .collect() + } + + fn build_workspace_thread_entries( + &self, + multi_workspace: &MultiWorkspace, + cx: &App, + ) -> (Vec, usize) { + let persisted_titles = read_thread_title_map().unwrap_or_default(); + + #[allow(unused_mut)] + let mut entries: Vec = multi_workspace + .workspaces() + .iter() + .enumerate() + .map(|(index, workspace)| { + WorkspaceThreadEntry::new(index, workspace, &persisted_titles, cx) + }) + .collect(); + + #[cfg(any(test, feature = "test-support"))] + for (index, info) in &self.test_thread_infos { + if let Some(entry) = entries.get_mut(*index) { + entry.thread_info = Some(info.clone()); + } + } + + (entries, multi_workspace.active_workspace_index()) + } + + #[cfg(any(test, feature = "test-support"))] + pub fn set_test_recent_projects( + &self, + projects: Vec, + cx: &mut Context, + ) { + self.picker.update(cx, |picker, _cx| { + picker.delegate.recent_projects = projects; + }); + } + + #[cfg(any(test, feature = "test-support"))] + pub fn set_test_thread_info( + &mut self, + index: usize, + title: SharedString, + status: AgentThreadStatus, + ) { + self.test_thread_infos + .insert(index, AgentThreadInfo { title, status }); + } + + #[cfg(any(test, feature = "test-support"))] + pub fn set_test_recent_project_thread_title( + &mut self, + full_path: SharedString, + title: SharedString, + cx: &mut Context, + ) { + self.test_recent_project_thread_titles + .insert(full_path.clone(), title.clone()); + self.picker.update(cx, |picker, _cx| { + picker + .delegate + .recent_project_thread_titles + .insert(full_path, title); + }); + } + + fn subscribe_to_agent_panels( + &mut self, + window: &mut Window, + cx: &mut Context, + ) -> Vec { + let workspaces: Vec<_> = self.multi_workspace.read(cx).workspaces().to_vec(); + + workspaces + .iter() + .map(|workspace| { + if let Some(agent_panel) = workspace.read(cx).panel::(cx) { + cx.subscribe_in( + &agent_panel, + window, + |this, _, _event: &AgentPanelEvent, window, cx| { + this.queue_refresh(this.multi_workspace.clone(), window, cx); + }, + ) + } else { + // Panel hasn't loaded yet — observe the workspace so we + // re-subscribe once the panel appears on its dock. + cx.observe_in(workspace, window, |this, _, window, cx| { + this.queue_refresh(this.multi_workspace.clone(), window, cx); + }) + } + }) + .collect() + } + + fn subscribe_to_threads( + &mut self, + window: &mut Window, + cx: &mut Context, + ) -> Vec { + let workspaces: Vec<_> = self.multi_workspace.read(cx).workspaces().to_vec(); + + workspaces + .iter() + .filter_map(|workspace| { + let agent_panel = workspace.read(cx).panel::(cx)?; + let thread = agent_panel.read(cx).active_agent_thread(cx)?; + Some(cx.observe_in(&thread, window, |this, _, window, cx| { + this.queue_refresh(this.multi_workspace.clone(), window, cx); + })) + }) + .collect() + } + + fn persist_thread_titles( + &self, + entries: &[WorkspaceThreadEntry], + multi_workspace: &Entity, + cx: &mut Context, + ) { + let mut map = read_thread_title_map().unwrap_or_default(); + let workspaces = multi_workspace.read(cx).workspaces().to_vec(); + let mut changed = false; + + for (workspace, entry) in workspaces.iter().zip(entries.iter()) { + if let Some(ref info) = entry.thread_info { + let paths: Vec<_> = workspace + .read(cx) + .worktrees(cx) + .map(|wt| wt.read(cx).abs_path()) + .collect(); + if paths.is_empty() { + continue; + } + let path_key = sorted_paths_key(&paths); + let title = info.title.to_string(); + if map.get(&path_key) != Some(&title) { + map.insert(path_key, title); + changed = true; + } + } + } + + if changed { + if let Some(json) = serde_json::to_string(&map).log_err() { + cx.background_spawn(async move { + KEY_VALUE_STORE + .write_kvp(LAST_THREAD_TITLES_KEY.into(), json) + .await + .log_err(); + }) + .detach(); + } + } + } + + fn queue_refresh( + &mut self, + multi_workspace: Entity, + window: &mut Window, + cx: &mut Context, + ) { + cx.defer_in(window, move |this, window, cx| { + this._project_subscriptions = this.subscribe_to_projects(window, cx); + this._agent_panel_subscriptions = this.subscribe_to_agent_panels(window, cx); + this._thread_subscriptions = this.subscribe_to_threads(window, cx); + let (entries, active_index) = multi_workspace.read_with(cx, |multi_workspace, cx| { + this.build_workspace_thread_entries(multi_workspace, cx) + }); + + this.persist_thread_titles(&entries, &multi_workspace, cx); + + let had_notifications = !this.picker.read(cx).delegate.notified_workspaces.is_empty(); + this.picker.update(cx, |picker, cx| { + picker.delegate.set_entries(entries, active_index, cx); + let query = picker.query(cx); + picker.update_matches(query, window, cx); + }); + let has_notifications = !this.picker.read(cx).delegate.notified_workspaces.is_empty(); + if had_notifications != has_notifications { + multi_workspace.update(cx, |_, cx| cx.notify()); + } + }); + } +} + +impl WorkspaceSidebar for Sidebar { + fn width(&self, _cx: &App) -> Pixels { + self.width + } + + fn set_width(&mut self, width: Option, cx: &mut Context) { + self.width = width.unwrap_or(DEFAULT_WIDTH).clamp(MIN_WIDTH, MAX_WIDTH); + cx.notify(); + } + + fn has_notifications(&self, cx: &App) -> bool { + !self.picker.read(cx).delegate.notified_workspaces.is_empty() + } +} + +impl Focusable for Sidebar { + fn focus_handle(&self, cx: &App) -> FocusHandle { + self.picker.read(cx).focus_handle(cx) + } +} + +fn sorted_paths_key>(paths: &[P]) -> String { + let mut sorted: Vec = paths + .iter() + .map(|p| p.as_ref().to_string_lossy().to_string()) + .collect(); + sorted.sort(); + sorted.join("\n") +} + +fn read_thread_title_map() -> Option> { + let json = KEY_VALUE_STORE + .read_kvp(LAST_THREAD_TITLES_KEY) + .log_err() + .flatten()?; + serde_json::from_str(&json).log_err() +} + +impl Render for Sidebar { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let titlebar_height = ui::utils::platform_title_bar_height(window); + let ui_font = theme::setup_ui_font(window, cx); + + v_flex() + .id("workspace-sidebar") + .key_context("WorkspaceSidebar") + .font(ui_font) + .h_full() + .w(self.width) + .bg(cx.theme().colors().surface_background) + .border_r_1() + .border_color(cx.theme().colors().border) + .child( + h_flex() + .flex_none() + .h(titlebar_height) + .w_full() + .mt_px() + .pb_px() + .pr_2() + .when(cfg!(target_os = "macos"), |this| { + this.pl(px(TRAFFIC_LIGHT_PADDING)) + }) + .justify_between() + .border_b_1() + .border_color(cx.theme().colors().border) + .child( + IconButton::new("close-sidebar", IconName::WorkspaceNavOpen) + .icon_size(IconSize::Small) + .tooltip(|_window, cx| { + Tooltip::for_action("Close Sidebar", &ToggleWorkspaceSidebar, cx) + }) + .on_click(cx.listener(|_this, _, _window, cx| { + cx.emit(SidebarEvent::Close); + })), + ) + .child( + IconButton::new("new-workspace", IconName::Plus) + .icon_size(IconSize::Small) + .tooltip(|_window, cx| { + Tooltip::for_action("New Workspace", &NewWorkspaceInWindow, cx) + }) + .on_click(cx.listener(|this, _, window, cx| { + this.multi_workspace.update(cx, |multi_workspace, cx| { + multi_workspace.create_workspace(window, cx); + }); + })), + ), + ) + .child(self.picker.clone()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use feature_flags::FeatureFlagAppExt as _; + use fs::FakeFs; + use gpui::TestAppContext; + use settings::SettingsStore; + + fn init_test(cx: &mut TestAppContext) { + cx.update(|cx| { + let settings_store = SettingsStore::test(cx); + cx.set_global(settings_store); + theme::init(theme::LoadThemes::JustBase, cx); + editor::init(cx); + cx.update_flags(false, vec!["agent-v2".into()]); + }); + } + + fn set_thread_info_and_refresh( + sidebar: &Entity, + multi_workspace: &Entity, + index: usize, + title: &str, + status: AgentThreadStatus, + cx: &mut gpui::VisualTestContext, + ) { + sidebar.update_in(cx, |s, _window, _cx| { + s.set_test_thread_info(index, SharedString::from(title.to_string()), status.clone()); + }); + multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); + cx.run_until_parked(); + } + + fn has_notifications(sidebar: &Entity, cx: &mut gpui::VisualTestContext) -> bool { + sidebar.read_with(cx, |s, cx| s.has_notifications(cx)) + } + + #[gpui::test] + async fn test_notification_on_running_to_completed_transition(cx: &mut TestAppContext) { + init_test(cx); + let fs = FakeFs::new(cx.executor()); + cx.update(|cx| ::set_global(fs.clone(), cx)); + let project = project::Project::test(fs, [], cx).await; + + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + + let sidebar = multi_workspace.update_in(cx, |_mw, window, cx| { + let mw_handle = cx.entity(); + cx.new(|cx| Sidebar::new(mw_handle, window, cx)) + }); + multi_workspace.update_in(cx, |mw, window, cx| { + mw.register_sidebar(sidebar.clone(), window, cx); + }); + cx.run_until_parked(); + + // Create a second workspace and switch to it so workspace 0 is background. + multi_workspace.update_in(cx, |mw, window, cx| { + mw.create_workspace(window, cx); + }); + cx.run_until_parked(); + multi_workspace.update_in(cx, |mw, window, cx| { + mw.activate_index(1, window, cx); + }); + cx.run_until_parked(); + + assert!( + !has_notifications(&sidebar, cx), + "should have no notifications initially" + ); + + set_thread_info_and_refresh( + &sidebar, + &multi_workspace, + 0, + "Test Thread", + AgentThreadStatus::Running, + cx, + ); + + assert!( + !has_notifications(&sidebar, cx), + "Running status alone should not create a notification" + ); + + set_thread_info_and_refresh( + &sidebar, + &multi_workspace, + 0, + "Test Thread", + AgentThreadStatus::Completed, + cx, + ); + + assert!( + has_notifications(&sidebar, cx), + "Running → Completed transition should create a notification" + ); + } + + #[gpui::test] + async fn test_no_notification_for_active_workspace(cx: &mut TestAppContext) { + init_test(cx); + let fs = FakeFs::new(cx.executor()); + cx.update(|cx| ::set_global(fs.clone(), cx)); + let project = project::Project::test(fs, [], cx).await; + + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + + let sidebar = multi_workspace.update_in(cx, |_mw, window, cx| { + let mw_handle = cx.entity(); + cx.new(|cx| Sidebar::new(mw_handle, window, cx)) + }); + multi_workspace.update_in(cx, |mw, window, cx| { + mw.register_sidebar(sidebar.clone(), window, cx); + }); + cx.run_until_parked(); + + // Workspace 0 is the active workspace — thread completes while + // the user is already looking at it. + set_thread_info_and_refresh( + &sidebar, + &multi_workspace, + 0, + "Test Thread", + AgentThreadStatus::Running, + cx, + ); + set_thread_info_and_refresh( + &sidebar, + &multi_workspace, + 0, + "Test Thread", + AgentThreadStatus::Completed, + cx, + ); + + assert!( + !has_notifications(&sidebar, cx), + "should not notify for the workspace the user is already looking at" + ); + } + + #[gpui::test] + async fn test_notification_cleared_on_workspace_activation(cx: &mut TestAppContext) { + init_test(cx); + let fs = FakeFs::new(cx.executor()); + cx.update(|cx| ::set_global(fs.clone(), cx)); + let project = project::Project::test(fs, [], cx).await; + + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + + let sidebar = multi_workspace.update_in(cx, |_mw, window, cx| { + let mw_handle = cx.entity(); + cx.new(|cx| Sidebar::new(mw_handle, window, cx)) + }); + multi_workspace.update_in(cx, |mw, window, cx| { + mw.register_sidebar(sidebar.clone(), window, cx); + }); + cx.run_until_parked(); + + // Create a second workspace so we can switch away and back. + multi_workspace.update_in(cx, |mw, window, cx| { + mw.create_workspace(window, cx); + }); + cx.run_until_parked(); + + // Switch to workspace 1 so workspace 0 becomes a background workspace. + multi_workspace.update_in(cx, |mw, window, cx| { + mw.activate_index(1, window, cx); + }); + cx.run_until_parked(); + + // Thread on workspace 0 transitions Running → Completed while + // the user is looking at workspace 1. + set_thread_info_and_refresh( + &sidebar, + &multi_workspace, + 0, + "Test Thread", + AgentThreadStatus::Running, + cx, + ); + set_thread_info_and_refresh( + &sidebar, + &multi_workspace, + 0, + "Test Thread", + AgentThreadStatus::Completed, + cx, + ); + + assert!( + has_notifications(&sidebar, cx), + "background workspace completion should create a notification" + ); + + // Switching back to workspace 0 should clear the notification. + multi_workspace.update_in(cx, |mw, window, cx| { + mw.activate_index(0, window, cx); + }); + cx.run_until_parked(); + + assert!( + !has_notifications(&sidebar, cx), + "notification should be cleared when workspace becomes active" + ); + } +} diff --git a/crates/title_bar/Cargo.toml b/crates/title_bar/Cargo.toml index 40c6ba6ae60ef06cab84c8be35150f0bccc748f8..a980070d6b9fc6abef72542ea82d1f4482a44c00 100644 --- a/crates/title_bar/Cargo.toml +++ b/crates/title_bar/Cargo.toml @@ -38,6 +38,7 @@ chrono.workspace = true client.workspace = true cloud_api_types.workspace = true db.workspace = true +feature_flags.workspace = true git_ui.workspace = true gpui = { workspace = true, features = ["screen-capture"] } menu.workspace = true diff --git a/crates/title_bar/src/project_dropdown.rs b/crates/title_bar/src/project_dropdown.rs index a0927918c7493c1da711fcab3fa0af546bc4a0e5..1f4c6376c4fb6d3f366fdf8c4008c347004a763f 100644 --- a/crates/title_bar/src/project_dropdown.rs +++ b/crates/title_bar/src/project_dropdown.rs @@ -11,7 +11,8 @@ use project::{Project, Worktree, git_store::Repository}; use recent_projects::{RecentProjectEntry, delete_recent_project, get_recent_projects}; use settings::WorktreeId; use ui::{ContextMenu, DocumentationAside, DocumentationSide, Tooltip, prelude::*}; -use workspace::{CloseIntent, Workspace}; +use util::ResultExt as _; +use workspace::{MultiWorkspace, Workspace}; actions!(project_dropdown, [RemoveSelectedFolder]); @@ -66,8 +67,12 @@ impl ProjectDropdown { let recent_projects_for_fetch = recent_projects.clone(); let menu_shell_for_fetch = menu_shell.clone(); let workspace_for_fetch = workspace.clone(); + let fs = workspace + .upgrade() + .map(|ws| ws.read(cx).app_state().fs.clone()); cx.spawn_in(window, async move |_this, cx| { + let Some(fs) = fs else { return }; let current_workspace_id = cx .update(|_, cx| { workspace_for_fetch @@ -77,7 +82,7 @@ impl ProjectDropdown { .ok() .flatten(); - let projects = get_recent_projects(current_workspace_id, None).await; + let projects = get_recent_projects(current_workspace_id, None, fs).await; cx.update(|window, cx| { *recent_projects_for_fetch.borrow_mut() = projects; @@ -88,7 +93,7 @@ impl ProjectDropdown { }); } }) - .ok() + .ok(); }) .detach(); @@ -396,36 +401,31 @@ impl ProjectDropdown { window: &mut Window, cx: &mut App, ) { - let Some(workspace) = workspace.upgrade() else { - return; - }; + if create_new_window { + let Some(workspace) = workspace.upgrade() else { + return; + }; + workspace.update(cx, |workspace, cx| { + workspace + .open_workspace_for_paths(false, paths, window, cx) + .detach_and_log_err(cx); + }); + } else { + let Some(handle) = window.window_handle().downcast::() else { + return; + }; - workspace.update(cx, |workspace, cx| { - if create_new_window { - workspace.open_workspace_for_paths(false, paths, window, cx) - } else { - cx.spawn_in(window, { - let paths = paths.clone(); - async move |workspace, cx| { - let continue_replacing = workspace - .update_in(cx, |workspace, window, cx| { - workspace.prepare_to_close(CloseIntent::ReplaceWindow, window, cx) - })? - .await?; - if continue_replacing { - workspace - .update_in(cx, |workspace, window, cx| { - workspace.open_workspace_for_paths(true, paths, window, cx) - })? - .await - } else { - Ok(()) - } - } - }) - } - .detach_and_log_err(cx); - }); + cx.defer(move |cx| { + if let Some(task) = handle + .update(cx, |multi_workspace, window, cx| { + multi_workspace.open_project(paths, window, cx) + }) + .log_err() + { + task.detach_and_log_err(cx); + } + }); + } } /// Get all projects sorted alphabetically with their branch info. diff --git a/crates/title_bar/src/title_bar.rs b/crates/title_bar/src/title_bar.rs index 38fc531a76ee1bbdac2b821e7f0d270919219ac6..67f30a840126dd202ccd146adf384c88b055cd15 100644 --- a/crates/title_bar/src/title_bar.rs +++ b/crates/title_bar/src/title_bar.rs @@ -22,6 +22,7 @@ use auto_update::AutoUpdateStatus; use call::ActiveCall; use client::{Client, UserStore, zed_urls}; use cloud_api_types::Plan; +use feature_flags::{AgentV2FeatureFlag, FeatureFlagAppExt}; use gpui::{ Action, AnyElement, App, Context, Corner, Element, Entity, FocusHandle, Focusable, InteractiveElement, IntoElement, MouseButton, ParentElement, Render, @@ -38,10 +39,13 @@ use theme::ActiveTheme; use title_bar_settings::TitleBarSettings; use ui::{ Avatar, ButtonLike, Chip, ContextMenu, IconWithIndicator, Indicator, PopoverMenu, - PopoverMenuHandle, TintColor, Tooltip, prelude::*, + PopoverMenuHandle, TintColor, Tooltip, prelude::*, utils::platform_title_bar_height, }; use util::ResultExt; -use workspace::{SwitchProject, ToggleWorktreeSecurity, Workspace, notifications::NotifyResultExt}; +use workspace::{ + MultiWorkspace, SwitchProject, ToggleWorkspaceSidebar, ToggleWorktreeSecurity, Workspace, + notifications::NotifyResultExt, +}; use zed_actions::OpenRemote; pub use onboarding_banner::restore_banner; @@ -158,7 +162,7 @@ impl Render for TitleBar { children.push( h_flex() - .gap_1() + .gap_0p5() .map(|title_bar| { let mut render_project_items = title_bar_settings.show_branch_name || title_bar_settings.show_project_items; @@ -171,6 +175,7 @@ impl Render for TitleBar { title_bar.child(menu) }, ) + .children(self.render_workspace_sidebar_toggle(window, cx)) .children(self.render_restricted_mode(cx)) .when(render_project_items, |title_bar| { title_bar @@ -232,7 +237,7 @@ impl Render for TitleBar { ); }); - let height = PlatformTitleBar::height(window); + let height = platform_title_bar_height(window); let title_bar_color = self.platform_titlebar.update(cx, |platform_titlebar, cx| { platform_titlebar.title_bar_color(window, cx) }); @@ -340,6 +345,48 @@ impl TitleBar { let platform_titlebar = cx.new(|cx| PlatformTitleBar::new(id, cx)); + // Set up observer to sync sidebar state from MultiWorkspace to PlatformTitleBar. + { + let platform_titlebar = platform_titlebar.clone(); + let window_handle = window.window_handle(); + cx.spawn(async move |this: WeakEntity, cx| { + let Some(multi_workspace_handle) = window_handle.downcast::() + else { + return; + }; + + let _ = cx.update(|cx| { + let Ok(multi_workspace) = multi_workspace_handle.entity(cx) else { + return; + }; + + let is_open = multi_workspace.read(cx).is_sidebar_open(); + let has_notifications = multi_workspace.read(cx).sidebar_has_notifications(cx); + platform_titlebar.update(cx, |titlebar, cx| { + titlebar.set_workspace_sidebar_open(is_open, cx); + titlebar.set_sidebar_has_notifications(has_notifications, cx); + }); + + let platform_titlebar = platform_titlebar.clone(); + let subscription = cx.observe(&multi_workspace, move |mw, cx| { + let is_open = mw.read(cx).is_sidebar_open(); + let has_notifications = mw.read(cx).sidebar_has_notifications(cx); + platform_titlebar.update(cx, |titlebar, cx| { + titlebar.set_workspace_sidebar_open(is_open, cx); + titlebar.set_sidebar_has_notifications(has_notifications, cx); + }); + }); + + if let Some(this) = this.upgrade() { + this.update(cx, |this, _| { + this._subscriptions.push(subscription); + }); + } + }); + }) + .detach(); + } + Self { platform_titlebar, application_menu, @@ -627,6 +674,41 @@ impl TitleBar { ) } + fn render_workspace_sidebar_toggle( + &self, + _window: &mut Window, + cx: &mut Context, + ) -> Option { + if !cx.has_flag::() { + return None; + } + + let is_sidebar_open = self.platform_titlebar.read(cx).is_workspace_sidebar_open(); + + if is_sidebar_open { + return None; + } + + let has_notifications = self.platform_titlebar.read(cx).sidebar_has_notifications(); + + Some( + IconButton::new("toggle-workspace-sidebar", IconName::WorkspaceNavClosed) + .icon_size(IconSize::Small) + .when(has_notifications, |button| { + button + .indicator(Indicator::dot().color(Color::Accent)) + .indicator_border_color(Some(cx.theme().colors().title_bar_background)) + }) + .tooltip(move |_, cx| { + Tooltip::for_action("Open Workspace Sidebar", &ToggleWorkspaceSidebar, cx) + }) + .on_click(|_, window, cx| { + window.dispatch_action(ToggleWorkspaceSidebar.boxed_clone(), cx); + }) + .into_any_element(), + ) + } + pub fn render_project_name(&self, cx: &mut Context) -> impl IntoElement { let workspace = self.workspace.clone(); @@ -911,16 +993,18 @@ impl TitleBar { pub fn render_sign_in_button(&mut self, _: &mut Context) -> Button { let client = self.client.clone(); + let workspace = self.workspace.clone(); Button::new("sign_in", "Sign In") .label_size(LabelSize::Small) .on_click(move |_, window, cx| { let client = client.clone(); + let workspace = workspace.clone(); window - .spawn(cx, async move |cx| { + .spawn(cx, async move |mut cx| { client .sign_in_with_optional_connect(true, cx) .await - .notify_async_err(cx); + .notify_workspace_async_err(workspace, &mut cx); }) .detach(); }) diff --git a/crates/ui/src/components/thread_item.rs b/crates/ui/src/components/thread_item.rs index a4f6a8a53348d78563900c2a53b30e95588c2aac..b3cf0ca8e8aa807764c7078c42093022197ebf72 100644 --- a/crates/ui/src/components/thread_item.rs +++ b/crates/ui/src/components/thread_item.rs @@ -1,5 +1,6 @@ use crate::{ - Chip, DecoratedIcon, DiffStat, IconDecoration, IconDecorationKind, SpinnerLabel, prelude::*, + DecoratedIcon, DiffStat, HighlightedLabel, IconDecoration, IconDecorationKind, SpinnerLabel, + prelude::*, }; use gpui::{ClickEvent, SharedString}; @@ -8,6 +9,7 @@ pub struct ThreadItem { id: ElementId, icon: IconName, title: SharedString, + highlight_positions: Vec, timestamp: SharedString, running: bool, generation_done: bool, @@ -24,6 +26,7 @@ impl ThreadItem { id: id.into(), icon: IconName::ZedAgent, title: title.into(), + highlight_positions: Vec::new(), timestamp: "".into(), running: false, generation_done: false, @@ -75,6 +78,11 @@ impl ThreadItem { self } + pub fn highlight_positions(mut self, positions: Vec) -> Self { + self.highlight_positions = positions; + self + } + pub fn on_click( mut self, handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, @@ -112,7 +120,17 @@ impl RenderOnce for ThreadItem { agent_icon.into_any_element() }; - let has_no_changes = self.added.is_none() && self.removed.is_none(); + // let has_no_changes = self.added.is_none() && self.removed.is_none(); + + let title = self.title; + let highlight_positions = self.highlight_positions; + let title_label = if highlight_positions.is_empty() { + Label::new(title).truncate().into_any_element() + } else { + HighlightedLabel::new(title, highlight_positions) + .truncate() + .into_any_element() + }; v_flex() .id(self.id.clone()) @@ -127,7 +145,7 @@ impl RenderOnce for ThreadItem { .w_full() .gap_1p5() .child(icon) - .child(Label::new(self.title).truncate()) + .child(title_label) .when(self.running, |this| { this.child(icon_container().child(SpinnerLabel::new().color(Color::Accent))) }), @@ -137,26 +155,32 @@ impl RenderOnce for ThreadItem { .gap_1p5() .child(icon_container()) // Icon Spacing .when_some(self.worktree, |this, name| { - this.child(Chip::new(name).label_size(LabelSize::XSmall)) + this.child(Label::new(name).size(LabelSize::Small).color(Color::Muted)) }) - .child( - Label::new(self.timestamp) - .size(LabelSize::Small) - .color(Color::Muted), - ) .child( Label::new("•") .size(LabelSize::Small) .color(Color::Muted) .alpha(0.5), ) - .when(has_no_changes, |this| { - this.child( - Label::new("No Changes") - .size(LabelSize::Small) - .color(Color::Muted), - ) - }) + .child( + Label::new(self.timestamp) + .size(LabelSize::Small) + .color(Color::Muted), + ) + // .child( + // Label::new("•") + // .size(LabelSize::Small) + // .color(Color::Muted) + // .alpha(0.5), + // ) + // .when(has_no_changes, |this| { + // this.child( + // Label::new("No Changes") + // .size(LabelSize::Small) + // .color(Color::Muted), + // ) + // }) .when(self.added.is_some() || self.removed.is_some(), |this| { this.child(DiffStat::new( self.id, diff --git a/crates/ui/src/utils.rs b/crates/ui/src/utils.rs index cd7d8eb497328baed356692e1d88d0286568d344..b73915162f9e6be937af7323e95fb9d6a82d6c52 100644 --- a/crates/ui/src/utils.rs +++ b/crates/ui/src/utils.rs @@ -5,6 +5,7 @@ use theme::ActiveTheme; mod apca_contrast; mod color_contrast; +mod constants; mod corner_solver; mod format_distance; mod search_input; @@ -12,6 +13,7 @@ mod with_rem_size; pub use apca_contrast::*; pub use color_contrast::*; +pub use constants::*; pub use corner_solver::{CornerSolver, inner_corner_radius}; pub use format_distance::*; pub use search_input::*; diff --git a/crates/ui/src/utils/constants.rs b/crates/ui/src/utils/constants.rs new file mode 100644 index 0000000000000000000000000000000000000000..823155889f7b4c370ea7998ec7f09340f94ef2a5 --- /dev/null +++ b/crates/ui/src/utils/constants.rs @@ -0,0 +1,27 @@ +use gpui::{Pixels, Window, px}; + +// Use pixels here instead of a rem-based size because the macOS traffic +// lights are a static size, and don't scale with the rest of the UI. +// +// Magic number: There is one extra pixel of padding on the left side due to +// the 1px border around the window on macOS apps. +#[cfg(macos_sdk_26)] +pub const TRAFFIC_LIGHT_PADDING: f32 = 78.; + +#[cfg(not(macos_sdk_26))] +pub const TRAFFIC_LIGHT_PADDING: f32 = 71.; + +/// Returns the platform-appropriate title bar height. +/// +/// On Windows, this returns a fixed height of 32px. +/// On other platforms, it scales with the window's rem size (1.75x) with a minimum of 34px. +#[cfg(not(target_os = "windows"))] +pub fn platform_title_bar_height(window: &Window) -> Pixels { + (1.75 * window.rem_size()).max(px(34.)) +} + +#[cfg(target_os = "windows")] +pub fn platform_title_bar_height(_window: &Window) -> Pixels { + // todo(windows) instead of hard coded size report the actual size to the Windows platform API + px(32.) +} diff --git a/crates/vim/src/state.rs b/crates/vim/src/state.rs index 1075a1144355083bd410b3aee4d015031f946a4e..9546c822ef68f1515745e67a4ec82fca684a6a94 100644 --- a/crates/vim/src/state.rs +++ b/crates/vim/src/state.rs @@ -36,7 +36,7 @@ use ui::{ use util::ResultExt; use util::rel_path::RelPath; use workspace::searchable::Direction; -use workspace::{Workspace, WorkspaceDb, WorkspaceId}; +use workspace::{MultiWorkspace, Workspace, WorkspaceDb, WorkspaceId}; #[derive(Clone, Copy, Default, Debug, PartialEq, Serialize, Deserialize)] pub enum Mode { @@ -731,12 +731,16 @@ impl VimGlobals { }); GlobalCommandPaletteInterceptor::set(cx, command_interceptor); for window in cx.windows() { - if let Some(workspace) = window.downcast::() { - workspace - .update(cx, |workspace, _, cx| { - Vim::update_globals(cx, |globals, cx| { - globals.register_workspace(workspace, cx) - }); + if let Some(multi_workspace) = window.downcast::() { + multi_workspace + .update(cx, |multi_workspace, _, cx| { + for workspace in multi_workspace.workspaces() { + workspace.update(cx, |workspace, cx| { + Vim::update_globals(cx, |globals, cx| { + globals.register_workspace(workspace, cx) + }); + }); + } }) .ok(); } diff --git a/crates/workspace/Cargo.toml b/crates/workspace/Cargo.toml index 6f4aced4259acdc986e2a3f14aee191fe497c23b..b8296d13af4275b6eef8fccc654be5c813a9ef61 100644 --- a/crates/workspace/Cargo.toml +++ b/crates/workspace/Cargo.toml @@ -79,6 +79,7 @@ db = { workspace = true, features = ["test-support"] } fs = { workspace = true, features = ["test-support"] } gpui = { workspace = true, features = ["test-support"] } project = { workspace = true, features = ["test-support"] } +remote = { workspace = true, features = ["test-support"] } session = { workspace = true, features = ["test-support"] } settings = { workspace = true, features = ["test-support"] } http_client = { workspace = true, features = ["test-support"] } diff --git a/crates/workspace/src/history_manager.rs b/crates/workspace/src/history_manager.rs index ee5dc550b2fba6d449cb87a0da3ca8b909da1970..ebb4792ef0d6a49c05e2d98f166f7e8260a2ae0a 100644 --- a/crates/workspace/src/history_manager.rs +++ b/crates/workspace/src/history_manager.rs @@ -1,5 +1,6 @@ -use std::path::PathBuf; +use std::{path::PathBuf, sync::Arc}; +use fs::Fs; use gpui::{AppContext, Entity, Global, MenuItem}; use smallvec::SmallVec; use ui::{App, Context}; @@ -9,10 +10,10 @@ use crate::{ NewWindow, SerializedWorkspaceLocation, WORKSPACE_DB, WorkspaceId, path_list::PathList, }; -pub fn init(cx: &mut App) { +pub fn init(fs: Arc, cx: &mut App) { let manager = cx.new(|_| HistoryManager::new()); HistoryManager::set_global(manager.clone(), cx); - HistoryManager::init(manager, cx); + HistoryManager::init(manager, fs, cx); } pub struct HistoryManager { @@ -38,10 +39,10 @@ impl HistoryManager { } } - fn init(this: Entity, cx: &App) { + fn init(this: Entity, fs: Arc, cx: &App) { cx.spawn(async move |cx| { let recent_folders = WORKSPACE_DB - .recent_workspaces_on_disk() + .recent_workspaces_on_disk(fs.as_ref()) .await .unwrap_or_default() .into_iter() diff --git a/crates/workspace/src/multi_workspace.rs b/crates/workspace/src/multi_workspace.rs new file mode 100644 index 0000000000000000000000000000000000000000..71d8d6cb0a5823b12027a00c7eec2b6ee7622953 --- /dev/null +++ b/crates/workspace/src/multi_workspace.rs @@ -0,0 +1,513 @@ +use anyhow::Result; +use feature_flags::{AgentV2FeatureFlag, FeatureFlagAppExt}; +use gpui::{ + AnyView, App, Context, DragMoveEvent, Entity, EntityId, EventEmitter, Focusable, ManagedView, + MouseButton, Pixels, Render, Subscription, Task, Window, actions, deferred, px, +}; +use project::Project; +use std::path::PathBuf; +use ui::prelude::*; + +const SIDEBAR_RESIZE_HANDLE_SIZE: Pixels = px(6.0); + +use crate::{ + DockPosition, Item, ModalView, Panel, Workspace, WorkspaceId, client_side_decorations, +}; + +actions!( + multi_workspace, + [ + /// Creates a new workspace within the current window. + NewWorkspaceInWindow, + /// Switches to the next workspace within the current window. + NextWorkspaceInWindow, + /// Switches to the previous workspace within the current window. + PreviousWorkspaceInWindow, + /// Toggles the workspace switcher sidebar. + ToggleWorkspaceSidebar, + ] +); + +pub enum SidebarEvent { + Open, + Close, +} + +pub trait Sidebar: EventEmitter + Focusable + Render + Sized { + fn width(&self, cx: &App) -> Pixels; + fn set_width(&mut self, width: Option, cx: &mut Context); + fn has_notifications(&self, cx: &App) -> bool; +} + +pub trait SidebarHandle: 'static + Send + Sync { + fn width(&self, cx: &App) -> Pixels; + fn set_width(&self, width: Option, cx: &mut App); + fn focus(&self, window: &mut Window, cx: &mut App); + fn has_notifications(&self, cx: &App) -> bool; + fn to_any(&self) -> AnyView; + fn entity_id(&self) -> EntityId; +} + +#[derive(Clone)] +pub struct DraggedSidebar; + +impl Render for DraggedSidebar { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + gpui::Empty + } +} + +impl SidebarHandle for Entity { + fn width(&self, cx: &App) -> Pixels { + self.read(cx).width(cx) + } + + fn set_width(&self, width: Option, cx: &mut App) { + self.update(cx, |this, cx| this.set_width(width, cx)) + } + + fn focus(&self, window: &mut Window, cx: &mut App) { + let handle = self.read(cx).focus_handle(cx); + window.focus(&handle, cx); + } + + fn has_notifications(&self, cx: &App) -> bool { + self.read(cx).has_notifications(cx) + } + + fn to_any(&self) -> AnyView { + self.clone().into() + } + + fn entity_id(&self) -> EntityId { + Entity::entity_id(self) + } +} + +pub struct MultiWorkspace { + workspaces: Vec>, + active_workspace_index: usize, + sidebar: Option>, + sidebar_open: bool, + _sidebar_subscription: Option, +} + +impl MultiWorkspace { + pub fn new(workspace: Entity, _cx: &mut Context) -> Self { + Self { + workspaces: vec![workspace], + active_workspace_index: 0, + sidebar: None, + sidebar_open: false, + _sidebar_subscription: None, + } + } + + pub fn register_sidebar( + &mut self, + sidebar: Entity, + window: &mut Window, + cx: &mut Context, + ) { + let subscription = + cx.subscribe_in(&sidebar, window, |this, _, event, window, cx| match event { + SidebarEvent::Open => this.toggle_sidebar(window, cx), + SidebarEvent::Close => { + this.close_sidebar(window, cx); + } + }); + self.sidebar = Some(Box::new(sidebar)); + self._sidebar_subscription = Some(subscription); + } + + pub fn sidebar(&self) -> Option<&dyn SidebarHandle> { + self.sidebar.as_deref() + } + + pub fn sidebar_open(&self) -> bool { + self.sidebar_open && self.sidebar.is_some() + } + + pub fn sidebar_has_notifications(&self, cx: &App) -> bool { + self.sidebar + .as_ref() + .map_or(false, |s| s.has_notifications(cx)) + } + + pub(crate) fn multi_workspace_enabled(&self, cx: &App) -> bool { + cx.has_flag::() + } + + pub fn toggle_sidebar(&mut self, window: &mut Window, cx: &mut Context) { + if !self.multi_workspace_enabled(cx) { + return; + } + + if self.sidebar_open { + self.close_sidebar(window, cx); + let pane = self.workspace().read(cx).active_pane().clone(); + window.focus(&pane.read(cx).focus_handle(cx), cx); + } else { + self.open_sidebar(window, cx); + if let Some(sidebar) = &self.sidebar { + sidebar.focus(window, cx); + } + } + } + + pub fn open_sidebar(&mut self, window: &mut Window, cx: &mut Context) { + self.sidebar_open = true; + self.serialize(window, cx); + cx.notify(); + } + + fn close_sidebar(&mut self, window: &mut Window, cx: &mut Context) { + self.sidebar_open = false; + self.serialize(window, cx); + cx.notify(); + } + + pub fn is_sidebar_open(&self) -> bool { + self.sidebar_open + } + + pub fn workspace(&self) -> &Entity { + &self.workspaces[self.active_workspace_index] + } + + pub fn workspaces(&self) -> &[Entity] { + &self.workspaces + } + + pub fn active_workspace_index(&self) -> usize { + self.active_workspace_index + } + + pub fn activate(&mut self, workspace: Entity, cx: &mut Context) { + if !self.multi_workspace_enabled(cx) { + self.workspaces[0] = workspace; + self.active_workspace_index = 0; + cx.notify(); + return; + } + + let index = self.add_workspace(workspace, cx); + if self.active_workspace_index != index { + self.active_workspace_index = index; + cx.notify(); + } + } + + /// Adds a workspace to this window without changing which workspace is active. + /// Returns the index of the workspace (existing or newly inserted). + pub fn add_workspace(&mut self, workspace: Entity, cx: &mut Context) -> usize { + if let Some(index) = self.workspaces.iter().position(|w| *w == workspace) { + index + } else { + self.workspaces.push(workspace); + cx.notify(); + self.workspaces.len() - 1 + } + } + + pub fn activate_index(&mut self, index: usize, window: &mut Window, cx: &mut Context) { + debug_assert!( + index < self.workspaces.len(), + "workspace index out of bounds" + ); + self.active_workspace_index = index; + self.serialize(window, cx); + self.focus_active_workspace(window, cx); + cx.notify(); + } + + pub fn activate_next_workspace(&mut self, window: &mut Window, cx: &mut Context) { + if self.workspaces.len() > 1 { + let next_index = (self.active_workspace_index + 1) % self.workspaces.len(); + self.activate_index(next_index, window, cx); + } + } + + pub fn activate_previous_workspace(&mut self, window: &mut Window, cx: &mut Context) { + if self.workspaces.len() > 1 { + let prev_index = if self.active_workspace_index == 0 { + self.workspaces.len() - 1 + } else { + self.active_workspace_index - 1 + }; + self.activate_index(prev_index, window, cx); + } + } + + fn serialize(&self, window: &mut Window, cx: &mut App) { + let window_id = window.window_handle().window_id(); + let state = crate::persistence::model::MultiWorkspaceState { + active_workspace_id: self.workspace().read(cx).database_id(), + sidebar_open: self.sidebar_open, + }; + cx.background_spawn(async move { + crate::persistence::write_multi_workspace_state(window_id, state).await; + }) + .detach(); + } + + fn focus_active_workspace(&self, window: &mut Window, cx: &mut App) { + let pane = self.workspace().read(cx).active_pane().clone(); + let focus_handle = pane.read(cx).focus_handle(cx); + window.focus(&focus_handle, cx); + } + + pub fn panel(&self, cx: &App) -> Option> { + self.workspace().read(cx).panel::(cx) + } + + pub fn active_modal(&self, cx: &App) -> Option> { + self.workspace().read(cx).active_modal::(cx) + } + + pub fn add_panel( + &mut self, + panel: Entity, + window: &mut Window, + cx: &mut Context, + ) { + self.workspace().update(cx, |workspace, cx| { + workspace.add_panel(panel, window, cx); + }); + } + + pub fn focus_panel( + &mut self, + window: &mut Window, + cx: &mut Context, + ) -> Option> { + self.workspace() + .update(cx, |workspace, cx| workspace.focus_panel::(window, cx)) + } + + pub fn toggle_modal( + &mut self, + window: &mut Window, + cx: &mut Context, + build: B, + ) where + B: FnOnce(&mut Window, &mut gpui::Context) -> V, + { + self.workspace().update(cx, |workspace, cx| { + workspace.toggle_modal(window, cx, build); + }); + } + + pub fn toggle_dock( + &mut self, + dock_side: DockPosition, + window: &mut Window, + cx: &mut Context, + ) { + self.workspace().update(cx, |workspace, cx| { + workspace.toggle_dock(dock_side, window, cx); + }); + } + + pub fn active_item_as(&self, cx: &App) -> Option> { + self.workspace().read(cx).active_item_as::(cx) + } + + pub fn items_of_type<'a, T: Item>( + &'a self, + cx: &'a App, + ) -> impl 'a + Iterator> { + self.workspace().read(cx).items_of_type::(cx) + } + + pub fn database_id(&self, cx: &App) -> Option { + self.workspace().read(cx).database_id() + } + + #[cfg(any(test, feature = "test-support"))] + pub fn set_random_database_id(&mut self, cx: &mut Context) { + self.workspace().update(cx, |workspace, _cx| { + workspace.set_random_database_id(); + }); + } + + #[cfg(any(test, feature = "test-support"))] + pub fn test_new(project: Entity, window: &mut Window, cx: &mut Context) -> Self { + let workspace = cx.new(|cx| Workspace::test_new(project, window, cx)); + Self::new(workspace, cx) + } + + pub fn create_workspace(&mut self, window: &mut Window, cx: &mut Context) { + if !self.multi_workspace_enabled(cx) { + return; + } + let app_state = self.workspace().read(cx).app_state().clone(); + let 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::default(), + cx, + ); + let new_workspace = cx.new(|cx| Workspace::new(None, project, app_state, window, cx)); + self.activate(new_workspace, cx); + self.focus_active_workspace(window, cx); + } + + pub fn remove_workspace(&mut self, index: usize, window: &mut Window, cx: &mut Context) { + if self.workspaces.len() <= 1 || index >= self.workspaces.len() { + return; + } + + self.workspaces.remove(index); + + if self.active_workspace_index >= self.workspaces.len() { + self.active_workspace_index = self.workspaces.len() - 1; + } else if self.active_workspace_index > index { + self.active_workspace_index -= 1; + } + + self.focus_active_workspace(window, cx); + cx.notify(); + } + + pub fn open_project( + &mut self, + paths: Vec, + window: &mut Window, + cx: &mut Context, + ) -> Task> { + let workspace = self.workspace().clone(); + + if self.multi_workspace_enabled(cx) { + workspace.update(cx, |workspace, cx| { + workspace.open_workspace_for_paths(true, paths, window, cx) + }) + } else { + cx.spawn_in(window, async move |_this, cx| { + let should_continue = workspace + .update_in(cx, |workspace, window, cx| { + workspace.prepare_to_close(crate::CloseIntent::ReplaceWindow, window, cx) + })? + .await?; + if should_continue { + workspace + .update_in(cx, |workspace, window, cx| { + workspace.open_workspace_for_paths(true, paths, window, cx) + })? + .await + } else { + Ok(()) + } + }) + } + } +} + +impl Render for MultiWorkspace { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let multi_workspace_enabled = self.multi_workspace_enabled(cx); + + let sidebar: Option = if multi_workspace_enabled && self.sidebar_open { + self.sidebar.as_ref().map(|sidebar_handle| { + let weak = cx.weak_entity(); + + let sidebar_width = sidebar_handle.width(cx); + let resize_handle = deferred( + div() + .id("sidebar-resize-handle") + .absolute() + .right(-SIDEBAR_RESIZE_HANDLE_SIZE / 2.) + .top(px(0.)) + .h_full() + .w(SIDEBAR_RESIZE_HANDLE_SIZE) + .cursor_col_resize() + .on_drag(DraggedSidebar, |dragged, _, _, cx| { + cx.stop_propagation(); + cx.new(|_| dragged.clone()) + }) + .on_mouse_down(MouseButton::Left, |_, _, cx| { + cx.stop_propagation(); + }) + .on_mouse_up(MouseButton::Left, move |event, _, cx| { + if event.click_count == 2 { + weak.update(cx, |this, cx| { + if let Some(sidebar) = this.sidebar.as_mut() { + sidebar.set_width(None, cx); + } + }) + .ok(); + cx.stop_propagation(); + } + }) + .occlude(), + ); + + div() + .id("sidebar-container") + .relative() + .h_full() + .w(sidebar_width) + .flex_shrink_0() + .child(sidebar_handle.to_any()) + .child(resize_handle) + .into_any_element() + }) + } else { + None + }; + + client_side_decorations( + h_flex() + .key_context("Workspace") + .size_full() + .on_action( + cx.listener(|this: &mut Self, _: &NewWorkspaceInWindow, window, cx| { + this.create_workspace(window, cx); + }), + ) + .on_action( + cx.listener(|this: &mut Self, _: &NextWorkspaceInWindow, window, cx| { + this.activate_next_workspace(window, cx); + }), + ) + .on_action(cx.listener( + |this: &mut Self, _: &PreviousWorkspaceInWindow, window, cx| { + this.activate_previous_workspace(window, cx); + }, + )) + .on_action(cx.listener( + |this: &mut Self, _: &ToggleWorkspaceSidebar, window, cx| { + this.toggle_sidebar(window, cx); + }, + )) + .when( + self.sidebar_open() && self.multi_workspace_enabled(cx), + |this| { + this.on_drag_move(cx.listener( + |this: &mut Self, e: &DragMoveEvent, _window, cx| { + if let Some(sidebar) = &this.sidebar { + let new_width = e.event.position.x; + sidebar.set_width(Some(new_width), cx); + } + }, + )) + .children(sidebar) + }, + ) + .child( + div() + .flex() + .flex_1() + .size_full() + .overflow_hidden() + .child(self.workspace().clone()), + ), + window, + cx, + ) + } +} diff --git a/crates/workspace/src/notifications.rs b/crates/workspace/src/notifications.rs index 10437743df39b22722638357976d5a8d6224eaf8..84f479b77e4f0274e0775353d3a7cd5579768f1c 100644 --- a/crates/workspace/src/notifications.rs +++ b/crates/workspace/src/notifications.rs @@ -1,9 +1,9 @@ -use crate::{SuppressNotification, Toast, Workspace}; +use crate::{MultiWorkspace, SuppressNotification, Toast, Workspace}; use anyhow::Context as _; use gpui::{ - AnyEntity, AnyView, App, AppContext as _, AsyncWindowContext, ClickEvent, Context, + AnyEntity, AnyView, App, AppContext as _, AsyncApp, AsyncWindowContext, ClickEvent, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, PromptLevel, Render, ScrollHandle, - Task, TextStyleRefinement, UnderlineStyle, svg, + Task, TextStyleRefinement, UnderlineStyle, WeakEntity, svg, }; use markdown::{Markdown, MarkdownElement, MarkdownStyle}; use parking_lot::Mutex; @@ -1037,14 +1037,18 @@ pub fn show_app_notification( .insert(id.clone(), build_notification.clone()); for window in cx.windows() { - if let Some(workspace_window) = window.downcast::() { - workspace_window - .update(cx, |workspace, _window, cx| { - workspace.show_notification_without_handling_dismiss_events( - &id, - cx, - |cx| build_notification(cx), - ); + if let Some(multi_workspace) = window.downcast::() { + multi_workspace + .update(cx, |multi_workspace, _window, cx| { + for workspace in multi_workspace.workspaces() { + workspace.update(cx, |workspace, cx| { + workspace.show_notification_without_handling_dismiss_events( + &id, + cx, + |cx| build_notification(cx), + ); + }); + } }) .ok(); // Doesn't matter if the windows are dropped } @@ -1058,11 +1062,15 @@ pub fn dismiss_app_notification(id: &NotificationId, cx: &mut App) { cx.defer(move |cx| { GLOBAL_APP_NOTIFICATIONS.lock().remove(&id); for window in cx.windows() { - if let Some(workspace_window) = window.downcast::() { + if let Some(multi_workspace) = window.downcast::() { let id = id.clone(); - workspace_window - .update(cx, |workspace, _window, cx| { - workspace.dismiss_notification(&id, cx) + multi_workspace + .update(cx, |multi_workspace, _window, cx| { + for workspace in multi_workspace.workspaces() { + workspace.update(cx, |workspace, cx| { + workspace.dismiss_notification(&id, cx) + }); + } }) .ok(); } @@ -1076,7 +1084,11 @@ pub trait NotifyResultExt { fn notify_err(self, workspace: &mut Workspace, cx: &mut Context) -> Option; - fn notify_async_err(self, cx: &mut AsyncWindowContext) -> Option; + fn notify_workspace_async_err( + self, + workspace: WeakEntity, + cx: &mut AsyncApp, + ) -> Option; /// Notifies the active workspace if there is one, otherwise notifies all workspaces. fn notify_app_err(self, cx: &mut App) -> Option; @@ -1099,17 +1111,18 @@ where } } - fn notify_async_err(self, cx: &mut AsyncWindowContext) -> Option { + fn notify_workspace_async_err( + self, + workspace: WeakEntity, + cx: &mut AsyncApp, + ) -> Option { match self { Ok(value) => Some(value), Err(err) => { log::error!("{err:?}"); - cx.update_root(|view, _, cx| { - if let Ok(workspace) = view.downcast::() { - workspace.update(cx, |workspace, cx| workspace.show_error(&err, cx)) - } - }) - .ok(); + workspace + .update(cx, |workspace, cx| workspace.show_error(&err, cx)) + .ok(); None } } @@ -1137,7 +1150,12 @@ where } pub trait NotifyTaskExt { - fn detach_and_notify_err(self, window: &mut Window, cx: &mut App); + fn detach_and_notify_err( + self, + workspace: WeakEntity, + window: &mut Window, + cx: &mut App, + ); } impl NotifyTaskExt for Task> @@ -1145,9 +1163,16 @@ where E: std::fmt::Debug + std::fmt::Display + Sized + 'static, R: 'static, { - fn detach_and_notify_err(self, window: &mut Window, cx: &mut App) { + fn detach_and_notify_err( + self, + workspace: WeakEntity, + window: &mut Window, + cx: &mut App, + ) { window - .spawn(cx, async move |cx| self.await.notify_async_err(cx)) + .spawn(cx, async move |mut cx| { + self.await.notify_workspace_async_err(workspace, &mut cx) + }) .detach(); } } diff --git a/crates/workspace/src/pane.rs b/crates/workspace/src/pane.rs index f025131758760d6c4db5250e2bbdb12a50d4ee01..3658895d10cf887f629acacaec7ba409d9fa7cf1 100644 --- a/crates/workspace/src/pane.rs +++ b/crates/workspace/src/pane.rs @@ -3881,9 +3881,10 @@ impl Pane { .path_for_entry(project_entry_id, cx) { let load_path_task = workspace.load_path(project_path.clone(), window, cx); - cx.spawn_in(window, async move |workspace, cx| { - if let Some((project_entry_id, build_item)) = - load_path_task.await.notify_async_err(cx) + cx.spawn_in(window, async move |workspace, mut cx| { + if let Some((project_entry_id, build_item)) = load_path_task + .await + .notify_workspace_async_err(workspace.clone(), &mut cx) { let (to_pane, new_item_handle) = workspace .update_in(cx, |workspace, window, cx| { diff --git a/crates/workspace/src/persistence.rs b/crates/workspace/src/persistence.rs index 08ea880fd573b608400613d54bfec47b3984f260..785f3fd9f32aa3bb8d12d6d4dd87cfb6bfe3a1e7 100644 --- a/crates/workspace/src/persistence.rs +++ b/crates/workspace/src/persistence.rs @@ -8,6 +8,8 @@ use std::{ sync::Arc, }; +use fs::Fs; + use anyhow::{Context as _, Result, bail}; use collections::{HashMap, HashSet, IndexSet}; use db::{ @@ -48,7 +50,7 @@ use model::{ SerializedPaneGroup, SerializedWorkspace, }; -use self::model::{DockStructure, SerializedWorkspaceLocation}; +use self::model::{DockStructure, SerializedWorkspaceLocation, SessionWorkspace}; // https://www.sqlite.org/limits.html // > <..> the maximum value of a host parameter number is SQLITE_MAX_VARIABLE_NUMBER, @@ -281,6 +283,64 @@ impl From for WindowBounds { } } +fn multi_workspace_states() -> db::kvp::ScopedKeyValueStore<'static> { + KEY_VALUE_STORE.scoped("multi_workspace_state") +} + +fn read_multi_workspace_state(window_id: WindowId) -> model::MultiWorkspaceState { + multi_workspace_states() + .read(&window_id.as_u64().to_string()) + .log_err() + .flatten() + .and_then(|json| serde_json::from_str(&json).ok()) + .unwrap_or_default() +} + +pub async fn write_multi_workspace_state(window_id: WindowId, state: model::MultiWorkspaceState) { + if let Ok(json_str) = serde_json::to_string(&state) { + multi_workspace_states() + .write(window_id.as_u64().to_string(), json_str) + .await + .log_err(); + } +} + +pub fn read_serialized_multi_workspaces( + session_workspaces: Vec, +) -> Vec { + let mut window_groups: Vec> = Vec::new(); + let mut window_id_to_group: HashMap = HashMap::default(); + + for session_workspace in session_workspaces { + match session_workspace.window_id { + Some(window_id) => { + let group_index = *window_id_to_group.entry(window_id).or_insert_with(|| { + window_groups.push(Vec::new()); + window_groups.len() - 1 + }); + window_groups[group_index].push(session_workspace); + } + None => { + window_groups.push(vec![session_workspace]); + } + } + } + + window_groups + .into_iter() + .map(|group| { + let window_id = group.first().and_then(|sw| sw.window_id); + let state = window_id + .map(read_multi_workspace_state) + .unwrap_or_default(); + model::SerializedMultiWorkspace { + workspaces: group, + state, + } + }) + .collect() +} + const DEFAULT_DOCK_STATE_KEY: &str = "default_dock_state"; pub fn read_default_dock_state() -> Option { @@ -1708,10 +1768,26 @@ impl WorkspaceDb { } } + async fn all_paths_exist_with_a_directory(paths: &[PathBuf], fs: &dyn Fs) -> bool { + let mut any_dir = false; + for path in paths { + match fs.metadata(path).await.ok().flatten() { + None => return false, + Some(meta) => { + if meta.is_dir { + any_dir = true; + } + } + } + } + any_dir + } + // Returns the recent locations which are still valid on disk and deletes ones which no longer // exist. pub async fn recent_workspaces_on_disk( &self, + fs: &dyn Fs, ) -> Result> { let mut result = Vec::new(); let mut delete_tasks = Vec::new(); @@ -1744,11 +1820,8 @@ impl WorkspaceDb { // If a local workspace points to WSL, this check will cause us to wait for the // WSL VM and file server to boot up. This can block for many seconds. // Supported scenarios use remote workspaces. - if !has_wsl_path && paths.paths().iter().all(|path| path.exists()) { - // Only show directories in recent projects - if paths.paths().iter().any(|path| path.is_dir()) { - result.push((id, SerializedWorkspaceLocation::Local, paths)); - } + if !has_wsl_path && Self::all_paths_exist_with_a_directory(paths.paths(), fs).await { + result.push((id, SerializedWorkspaceLocation::Local, paths)); } else { delete_tasks.push(self.delete_workspace_by_id(id)); } @@ -1760,65 +1833,67 @@ impl WorkspaceDb { pub async fn last_workspace( &self, + fs: &dyn Fs, ) -> Result> { - Ok(self.recent_workspaces_on_disk().await?.into_iter().next()) + Ok(self.recent_workspaces_on_disk(fs).await?.into_iter().next()) } // Returns the locations of the workspaces that were still opened when the last // session was closed (i.e. when Zed was quit). // If `last_session_window_order` is provided, the returned locations are ordered // according to that. - pub fn last_session_workspace_locations( + pub async fn last_session_workspace_locations( &self, last_session_id: &str, last_session_window_stack: Option>, - ) -> Result> { + fs: &dyn Fs, + ) -> Result> { let mut workspaces = Vec::new(); for (workspace_id, paths, window_id, remote_connection_id) in self.session_workspaces(last_session_id.to_owned())? { + let window_id = window_id.map(WindowId::from); + if let Some(remote_connection_id) = remote_connection_id { - workspaces.push(( + workspaces.push(SessionWorkspace { workspace_id, - SerializedWorkspaceLocation::Remote( + location: SerializedWorkspaceLocation::Remote( self.remote_connection(remote_connection_id)?, ), paths, - window_id.map(WindowId::from), - )); + window_id, + }); } else if paths.is_empty() { // Empty workspace with items (drafts, files) - include for restoration - workspaces.push(( - workspace_id, - SerializedWorkspaceLocation::Local, - paths, - window_id.map(WindowId::from), - )); - } else if paths.paths().iter().all(|path| path.exists()) - && paths.paths().iter().any(|path| path.is_dir()) - { - workspaces.push(( + workspaces.push(SessionWorkspace { workspace_id, - SerializedWorkspaceLocation::Local, + location: SerializedWorkspaceLocation::Local, paths, - window_id.map(WindowId::from), - )); + window_id, + }); + } else { + if Self::all_paths_exist_with_a_directory(paths.paths(), fs).await { + workspaces.push(SessionWorkspace { + workspace_id, + location: SerializedWorkspaceLocation::Local, + paths, + window_id, + }); + } } } if let Some(stack) = last_session_window_stack { - workspaces.sort_by_key(|(_, _, _, window_id)| { - window_id + workspaces.sort_by_key(|workspace| { + workspace + .window_id .and_then(|id| stack.iter().position(|&order_id| order_id == id)) .unwrap_or(usize::MAX) }); } - Ok(workspaces - .into_iter() - .map(|(workspace_id, location, paths, _)| (workspace_id, location, paths)) - .collect::>()) + Ok(workspaces) } fn get_center_pane_group(&self, workspace_id: WorkspaceId) -> Result { @@ -2272,11 +2347,12 @@ pub fn delete_unloaded_items( mod tests { use super::*; use crate::persistence::model::{ - SerializedItem, SerializedPane, SerializedPaneGroup, SerializedWorkspace, + SerializedItem, SerializedPane, SerializedPaneGroup, SerializedWorkspace, SessionWorkspace, }; use gpui; use pretty_assertions::assert_eq; use remote::SshConnectionOptions; + use serde_json::json; use std::{thread, time::Duration}; #[gpui::test] @@ -3040,12 +3116,18 @@ mod tests { } #[gpui::test] - async fn test_last_session_workspace_locations() { + async fn test_last_session_workspace_locations(cx: &mut gpui::TestAppContext) { let dir1 = tempfile::TempDir::with_prefix("dir1").unwrap(); let dir2 = tempfile::TempDir::with_prefix("dir2").unwrap(); let dir3 = tempfile::TempDir::with_prefix("dir3").unwrap(); let dir4 = tempfile::TempDir::with_prefix("dir4").unwrap(); + let fs = fs::FakeFs::new(cx.executor()); + fs.insert_tree(dir1.path(), json!({})).await; + fs.insert_tree(dir2.path(), json!({})).await; + fs.insert_tree(dir3.path(), json!({})).await; + fs.insert_tree(dir4.path(), json!({})).await; + let db = WorkspaceDb::open_test_db("test_serializing_workspaces_last_session_workspaces").await; @@ -3088,47 +3170,55 @@ mod tests { ])); let locations = db - .last_session_workspace_locations("one-session", stack) + .last_session_workspace_locations("one-session", stack, fs.as_ref()) + .await .unwrap(); assert_eq!( locations, [ - ( - WorkspaceId(4), - SerializedWorkspaceLocation::Local, - PathList::new(&[dir4.path()]) - ), - ( - WorkspaceId(3), - SerializedWorkspaceLocation::Local, - PathList::new(&[dir3.path()]) - ), - ( - WorkspaceId(2), - SerializedWorkspaceLocation::Local, - PathList::new(&[dir2.path()]) - ), - ( - WorkspaceId(1), - SerializedWorkspaceLocation::Local, - PathList::new(&[dir1.path()]) - ), - ( - WorkspaceId(5), - SerializedWorkspaceLocation::Local, - PathList::new(&[dir1.path(), dir2.path(), dir3.path()]) - ), - ( - WorkspaceId(6), - SerializedWorkspaceLocation::Local, - PathList::new(&[dir4.path(), dir3.path(), dir2.path()]) - ), + SessionWorkspace { + workspace_id: WorkspaceId(4), + location: SerializedWorkspaceLocation::Local, + paths: PathList::new(&[dir4.path()]), + window_id: Some(WindowId::from(2u64)), + }, + SessionWorkspace { + workspace_id: WorkspaceId(3), + location: SerializedWorkspaceLocation::Local, + paths: PathList::new(&[dir3.path()]), + window_id: Some(WindowId::from(8u64)), + }, + SessionWorkspace { + workspace_id: WorkspaceId(2), + location: SerializedWorkspaceLocation::Local, + paths: PathList::new(&[dir2.path()]), + window_id: Some(WindowId::from(5u64)), + }, + SessionWorkspace { + workspace_id: WorkspaceId(1), + location: SerializedWorkspaceLocation::Local, + paths: PathList::new(&[dir1.path()]), + window_id: Some(WindowId::from(9u64)), + }, + SessionWorkspace { + workspace_id: WorkspaceId(5), + location: SerializedWorkspaceLocation::Local, + paths: PathList::new(&[dir1.path(), dir2.path(), dir3.path()]), + window_id: Some(WindowId::from(3u64)), + }, + SessionWorkspace { + workspace_id: WorkspaceId(6), + location: SerializedWorkspaceLocation::Local, + paths: PathList::new(&[dir4.path(), dir3.path(), dir2.path()]), + window_id: Some(WindowId::from(4u64)), + }, ] ); } #[gpui::test] - async fn test_last_session_workspace_locations_remote() { + async fn test_last_session_workspace_locations_remote(cx: &mut gpui::TestAppContext) { + let fs = fs::FakeFs::new(cx.executor()); let db = WorkspaceDb::open_test_db("test_serializing_workspaces_last_session_workspaces_remote") .await; @@ -3190,40 +3280,45 @@ mod tests { ])); let have = db - .last_session_workspace_locations("one-session", stack) + .last_session_workspace_locations("one-session", stack, fs.as_ref()) + .await .unwrap(); assert_eq!(have.len(), 4); assert_eq!( have[0], - ( - WorkspaceId(4), - SerializedWorkspaceLocation::Remote(remote_connections[3].clone()), - PathList::default() - ) + SessionWorkspace { + workspace_id: WorkspaceId(4), + location: SerializedWorkspaceLocation::Remote(remote_connections[3].clone()), + paths: PathList::default(), + window_id: Some(WindowId::from(2u64)), + } ); assert_eq!( have[1], - ( - WorkspaceId(3), - SerializedWorkspaceLocation::Remote(remote_connections[2].clone()), - PathList::default() - ) + SessionWorkspace { + workspace_id: WorkspaceId(3), + location: SerializedWorkspaceLocation::Remote(remote_connections[2].clone()), + paths: PathList::default(), + window_id: Some(WindowId::from(8u64)), + } ); assert_eq!( have[2], - ( - WorkspaceId(2), - SerializedWorkspaceLocation::Remote(remote_connections[1].clone()), - PathList::default() - ) + SessionWorkspace { + workspace_id: WorkspaceId(2), + location: SerializedWorkspaceLocation::Remote(remote_connections[1].clone()), + paths: PathList::default(), + window_id: Some(WindowId::from(5u64)), + } ); assert_eq!( have[3], - ( - WorkspaceId(1), - SerializedWorkspaceLocation::Remote(remote_connections[0].clone()), - PathList::default() - ) + SessionWorkspace { + workspace_id: WorkspaceId(1), + location: SerializedWorkspaceLocation::Remote(remote_connections[0].clone()), + paths: PathList::default(), + window_id: Some(WindowId::from(9u64)), + } ); } @@ -3555,4 +3650,192 @@ mod tests { assert!(retrieved.display.is_some()); assert_eq!(retrieved.display.unwrap(), display_uuid); } + + #[gpui::test] + async fn test_last_session_workspace_locations_groups_by_window_id( + cx: &mut gpui::TestAppContext, + ) { + let dir1 = tempfile::TempDir::with_prefix("dir1").unwrap(); + let dir2 = tempfile::TempDir::with_prefix("dir2").unwrap(); + let dir3 = tempfile::TempDir::with_prefix("dir3").unwrap(); + let dir4 = tempfile::TempDir::with_prefix("dir4").unwrap(); + let dir5 = tempfile::TempDir::with_prefix("dir5").unwrap(); + + let fs = fs::FakeFs::new(cx.executor()); + fs.insert_tree(dir1.path(), json!({})).await; + fs.insert_tree(dir2.path(), json!({})).await; + fs.insert_tree(dir3.path(), json!({})).await; + fs.insert_tree(dir4.path(), json!({})).await; + fs.insert_tree(dir5.path(), json!({})).await; + + let db = + WorkspaceDb::open_test_db("test_last_session_workspace_locations_groups_by_window_id") + .await; + + // Simulate two MultiWorkspace windows each containing two workspaces, + // plus one single-workspace window: + // Window 10: workspace 1, workspace 2 + // Window 20: workspace 3, workspace 4 + // Window 30: workspace 5 (only one) + // + // On session restore, the caller should be able to group these by + // window_id to reconstruct the MultiWorkspace windows. + let workspaces_data: Vec<(i64, &Path, u64)> = vec![ + (1, dir1.path(), 10), + (2, dir2.path(), 10), + (3, dir3.path(), 20), + (4, dir4.path(), 20), + (5, dir5.path(), 30), + ]; + + for (id, dir, window_id) in &workspaces_data { + db.save_workspace(SerializedWorkspace { + id: WorkspaceId(*id), + paths: PathList::new(&[*dir]), + location: SerializedWorkspaceLocation::Local, + center_group: Default::default(), + window_bounds: Default::default(), + display: Default::default(), + docks: Default::default(), + centered_layout: false, + session_id: Some("test-session".to_owned()), + breakpoints: Default::default(), + window_id: Some(*window_id), + user_toolchains: Default::default(), + }) + .await; + } + + let locations = db + .last_session_workspace_locations("test-session", None, fs.as_ref()) + .await + .unwrap(); + + // All 5 workspaces should be returned with their window_ids. + assert_eq!(locations.len(), 5); + + // Every entry should have a window_id so the caller can group them. + for session_workspace in &locations { + assert!( + session_workspace.window_id.is_some(), + "workspace {:?} missing window_id", + session_workspace.workspace_id + ); + } + + // Group by window_id, simulating what the restoration code should do. + let mut by_window: HashMap> = HashMap::default(); + for session_workspace in &locations { + if let Some(window_id) = session_workspace.window_id { + by_window + .entry(window_id) + .or_default() + .push(session_workspace.workspace_id); + } + } + + // Should produce 3 windows, not 5. + assert_eq!( + by_window.len(), + 3, + "Expected 3 window groups, got {}: {:?}", + by_window.len(), + by_window + ); + + // Window 10 should contain workspaces 1 and 2. + let window_10 = by_window.get(&WindowId::from(10u64)).unwrap(); + assert_eq!(window_10.len(), 2); + assert!(window_10.contains(&WorkspaceId(1))); + assert!(window_10.contains(&WorkspaceId(2))); + + // Window 20 should contain workspaces 3 and 4. + let window_20 = by_window.get(&WindowId::from(20u64)).unwrap(); + assert_eq!(window_20.len(), 2); + assert!(window_20.contains(&WorkspaceId(3))); + assert!(window_20.contains(&WorkspaceId(4))); + + // Window 30 should contain only workspace 5. + let window_30 = by_window.get(&WindowId::from(30u64)).unwrap(); + assert_eq!(window_30.len(), 1); + assert!(window_30.contains(&WorkspaceId(5))); + } + + #[gpui::test] + async fn test_read_serialized_multi_workspaces_with_state() { + use crate::persistence::model::MultiWorkspaceState; + + // Write multi-workspace state for two windows via the scoped KVP. + let window_10 = WindowId::from(10u64); + let window_20 = WindowId::from(20u64); + + write_multi_workspace_state( + window_10, + MultiWorkspaceState { + active_workspace_id: Some(WorkspaceId(2)), + sidebar_open: true, + }, + ) + .await; + + write_multi_workspace_state( + window_20, + MultiWorkspaceState { + active_workspace_id: Some(WorkspaceId(3)), + sidebar_open: false, + }, + ) + .await; + + // Build session workspaces: two in window 10, one in window 20, one with no window. + let session_workspaces = vec![ + SessionWorkspace { + workspace_id: WorkspaceId(1), + location: SerializedWorkspaceLocation::Local, + paths: PathList::new(&["/a"]), + window_id: Some(window_10), + }, + SessionWorkspace { + workspace_id: WorkspaceId(2), + location: SerializedWorkspaceLocation::Local, + paths: PathList::new(&["/b"]), + window_id: Some(window_10), + }, + SessionWorkspace { + workspace_id: WorkspaceId(3), + location: SerializedWorkspaceLocation::Local, + paths: PathList::new(&["/c"]), + window_id: Some(window_20), + }, + SessionWorkspace { + workspace_id: WorkspaceId(4), + location: SerializedWorkspaceLocation::Local, + paths: PathList::new(&["/d"]), + window_id: None, + }, + ]; + + let results = read_serialized_multi_workspaces(session_workspaces); + + // Should produce 3 groups: window 10, window 20, and the orphan. + assert_eq!(results.len(), 3); + + // Window 10 group: 2 workspaces, active_workspace_id = 2, sidebar open. + let group_10 = &results[0]; + assert_eq!(group_10.workspaces.len(), 2); + assert_eq!(group_10.state.active_workspace_id, Some(WorkspaceId(2))); + assert_eq!(group_10.state.sidebar_open, true); + + // Window 20 group: 1 workspace, active_workspace_id = 3, sidebar closed. + let group_20 = &results[1]; + assert_eq!(group_20.workspaces.len(), 1); + assert_eq!(group_20.state.active_workspace_id, Some(WorkspaceId(3))); + assert_eq!(group_20.state.sidebar_open, false); + + // Orphan group: no window_id, so state is default. + let group_none = &results[2]; + assert_eq!(group_none.workspaces.len(), 1); + assert_eq!(group_none.state.active_workspace_id, None); + assert_eq!(group_none.state.sidebar_open, false); + } } diff --git a/crates/workspace/src/persistence/model.rs b/crates/workspace/src/persistence/model.rs index 417896c584a1906f5d2f712a864cb2807c69af0a..cdb646ec3b8248bdd0b5784424ed7b8df8ac0ee8 100644 --- a/crates/workspace/src/persistence/model.rs +++ b/crates/workspace/src/persistence/model.rs @@ -10,7 +10,7 @@ use db::sqlez::{ bindable::{Bind, Column, StaticColumnCount}, statement::Statement, }; -use gpui::{AsyncWindowContext, Entity, WeakEntity}; +use gpui::{AsyncWindowContext, Entity, WeakEntity, WindowId}; use language::{Toolchain, ToolchainScope}; use project::{Project, debugger::breakpoint_store::SourceBreakpoint}; @@ -49,6 +49,32 @@ impl SerializedWorkspaceLocation { } } +/// A workspace entry from a previous session, containing all the info needed +/// to restore it including which window it belonged to (for MultiWorkspace grouping). +#[derive(Debug, PartialEq, Clone)] +pub struct SessionWorkspace { + pub workspace_id: WorkspaceId, + pub location: SerializedWorkspaceLocation, + pub paths: PathList, + pub window_id: Option, +} + +/// Per-window state for a MultiWorkspace, persisted to KVP. +#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)] +pub struct MultiWorkspaceState { + pub active_workspace_id: Option, + pub sidebar_open: bool, +} + +/// The serialized state of a single MultiWorkspace window from a previous session: +/// all workspaces that shared the window, which one was active, and whether the +/// sidebar was open. +#[derive(Debug, Clone)] +pub struct SerializedMultiWorkspace { + pub workspaces: Vec, + pub state: MultiWorkspaceState, +} + #[derive(Debug, PartialEq, Clone)] pub(crate) struct SerializedWorkspace { pub(crate) id: WorkspaceId, diff --git a/crates/workspace/src/welcome.rs b/crates/workspace/src/welcome.rs index 071bf0826798d382329c9f6e3ced86388e4c0b3e..301f7884dac909f01db1baa2b883253dd7ee3890 100644 --- a/crates/workspace/src/welcome.rs +++ b/crates/workspace/src/welcome.rs @@ -114,7 +114,9 @@ impl RenderOnce for SectionButton { .size(rems_from_px(12.)), ), ) - .on_click(move |_, window, cx| window.dispatch_action(self.action.boxed_clone(), cx)) + .on_click(move |_, window, cx| { + self.focus_handle.dispatch_action(&*self.action, window, cx) + }) } } @@ -225,9 +227,13 @@ impl WelcomePage { .detach(); if fallback_to_recent_projects { + let fs = workspace + .upgrade() + .map(|ws| ws.read(cx).app_state().fs.clone()); cx.spawn_in(window, async move |this: WeakEntity, cx| { + let Some(fs) = fs else { return }; let workspaces = WORKSPACE_DB - .recent_workspaces_on_disk() + .recent_workspaces_on_disk(fs.as_ref()) .await .log_err() .unwrap_or_default(); @@ -267,21 +273,18 @@ impl WelcomePage { ) { if let Some(recent_workspaces) = &self.recent_workspaces { if let Some((_workspace_id, location, paths)) = recent_workspaces.get(action.index) { - let paths = paths.clone(); - let location = location.clone(); let is_local = matches!(location, SerializedWorkspaceLocation::Local); - let workspace = self.workspace.clone(); if is_local { + let paths = paths.clone(); let paths = paths.paths().to_vec(); - cx.spawn_in(window, async move |_, cx| { - let _ = workspace.update_in(cx, |workspace, window, cx| { + self.workspace + .update(cx, |workspace, cx| { workspace .open_workspace_for_paths(true, paths, window, cx) - .detach(); - }); - }) - .detach(); + .detach_and_log_err(cx); + }) + .log_err(); } else { use zed_actions::OpenRecent; window.dispatch_action(OpenRecent::default().boxed_clone(), cx); diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index 8dff340e264abd583c471b95d96c90e14486a2c5..5879143443adb753becbcb9b25aa1d965184baf4 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -3,6 +3,7 @@ pub mod history_manager; pub mod invalid_item_view; pub mod item; mod modal_layer; +mod multi_workspace; pub mod notifications; pub mod pane; pub mod pane_group; @@ -22,6 +23,10 @@ mod workspace_settings; pub use crate::notifications::NotificationFrame; pub use dock::Panel; +pub use multi_workspace::{ + DraggedSidebar, MultiWorkspace, NewWorkspaceInWindow, NextWorkspaceInWindow, + PreviousWorkspaceInWindow, Sidebar, SidebarEvent, SidebarHandle, ToggleWorkspaceSidebar, +}; pub use path_list::PathList; pub use toast_layer::{ToastAction, ToastLayer, ToastView}; @@ -71,7 +76,8 @@ pub use pane_group::{ use persistence::{DB, SerializedWindowBounds, model::SerializedWorkspace}; pub use persistence::{ DB as WORKSPACE_DB, WorkspaceDb, delete_unloaded_items, - model::{ItemId, SerializedWorkspaceLocation}, + model::{ItemId, SerializedMultiWorkspace, SerializedWorkspaceLocation, SessionWorkspace}, + read_serialized_multi_workspaces, }; use postage::stream::Stream; use project::{ @@ -562,9 +568,27 @@ pub struct OpenTerminal { pub local: bool, } -#[derive(Clone, Copy, Debug, Default, Hash, PartialEq, Eq, PartialOrd, Ord)] +#[derive( + Clone, + Copy, + Debug, + Default, + Hash, + PartialEq, + Eq, + PartialOrd, + Ord, + serde::Serialize, + serde::Deserialize, +)] pub struct WorkspaceId(i64); +impl WorkspaceId { + pub fn from_i64(value: i64) -> Self { + Self(value) + } +} + impl StaticColumnCount for WorkspaceId {} impl Bind for WorkspaceId { fn bind(&self, statement: &Statement, start_index: i32) -> Result { @@ -599,11 +623,14 @@ fn prompt_and_open_paths(app_state: Arc, options: PathPromptOptions, c cx.update(|cx| { if let Some(workspace_window) = cx .active_window() - .and_then(|window| window.downcast::()) + .and_then(|window| window.downcast::()) { workspace_window - .update(cx, |workspace, _, cx| { - workspace.show_portal_error(err.to_string(), cx); + .update(cx, |multi_workspace, _, cx| { + let workspace = multi_workspace.workspace().clone(); + workspace.update(cx, |workspace, cx| { + workspace.show_portal_error(err.to_string(), cx); + }); }) .ok(); } @@ -618,7 +645,7 @@ pub fn init(app_state: Arc, cx: &mut App) { component::init(); theme_preview::init(cx); toast_layer::init(cx); - history_manager::init(cx); + history_manager::init(app_state.fs.clone(), cx); cx.on_action(|_: &CloseWindow, cx| Workspace::close_global(cx)) .on_action(|_: &Reload, cx| reload(cx)) @@ -969,7 +996,7 @@ struct GlobalAppState(Weak); impl Global for GlobalAppState {} pub struct WorkspaceStore { - workspaces: HashSet>, + workspaces: HashSet<(gpui::AnyWindowHandle, WeakEntity)>, client: Arc, _subscriptions: Vec, } @@ -1455,9 +1482,11 @@ impl Workspace { cx.emit(Event::PaneAdded(center_pane.clone())); - let window_handle = window.window_handle().downcast::().unwrap(); + let any_window_handle = window.window_handle(); app_state.workspace_store.update(cx, |store, _| { - store.workspaces.insert(window_handle); + store + .workspaces + .insert((any_window_handle, weak_handle.clone())); }); let mut current_user = app_state.user_store.read(cx).watch_current_user(); @@ -1582,10 +1611,13 @@ impl Workspace { GlobalTheme::reload_theme(cx); GlobalTheme::reload_icon_theme(cx); }), - cx.on_release(move |this, cx| { - this.app_state.workspace_store.update(cx, move |store, _| { - store.workspaces.remove(&window_handle); - }) + cx.on_release({ + let weak_handle = weak_handle.clone(); + move |this, cx| { + this.app_state.workspace_store.update(cx, move |store, _| { + store.workspaces.retain(|(_, weak)| weak != &weak_handle); + }) + } }), ]; @@ -1659,13 +1691,13 @@ impl Workspace { pub fn new_local( abs_paths: Vec, app_state: Arc, - requesting_window: Option>, + requesting_window: Option>, env: Option>, init: Option) + Send>>, cx: &mut App, ) -> Task< anyhow::Result<( - WindowHandle, + WindowHandle, Vec>>>, )>, > { @@ -1763,71 +1795,23 @@ impl Workspace { }); } - let window = if let Some(window) = requesting_window { - let centered_layout = serialized_workspace - .as_ref() - .map(|w| w.centered_layout) - .unwrap_or(false); - - cx.update_window(window.into(), |_, window, cx| { - window.replace_root(cx, |window, cx| { - let mut workspace = Workspace::new( - Some(workspace_id), - project_handle.clone(), - app_state.clone(), - window, - cx, - ); - - workspace.centered_layout = centered_layout; - - // Call init callback to add items before window renders - if let Some(init) = init { - init(&mut workspace, window, cx); - } - - workspace - }); - })?; - window - } else { - let window_bounds_override = window_bounds_env_override(); - - let (window_bounds, display) = if let Some(bounds) = window_bounds_override { - (Some(WindowBounds::Windowed(bounds)), None) - } else if let Some(workspace) = serialized_workspace.as_ref() - && let Some(display) = workspace.display - && let Some(bounds) = workspace.window_bounds.as_ref() - { - // Reopening an existing workspace - restore its saved bounds - (Some(bounds.0), Some(display)) - } else if let Some((display, bounds)) = persistence::read_default_window_bounds() { - // New or empty workspace - use the last known window bounds - (Some(bounds), Some(display)) - } else { - // New window - let GPUI's default_bounds() handle cascading - (None, None) - }; + let (window, workspace): (WindowHandle, Entity) = + if let Some(window) = requesting_window { + let centered_layout = serialized_workspace + .as_ref() + .map(|w| w.centered_layout) + .unwrap_or(false); - // Use the serialized workspace to construct the new window - let mut options = cx.update(|cx| (app_state.build_window_options)(display, cx)); - options.window_bounds = window_bounds; - let centered_layout = serialized_workspace - .as_ref() - .map(|w| w.centered_layout) - .unwrap_or(false); - cx.open_window(options, { - let app_state = app_state.clone(); - let project_handle = project_handle.clone(); - move |window, cx| { - cx.new(|cx| { + let workspace = window.update(cx, |multi_workspace, window, cx| { + let workspace = cx.new(|cx| { let mut workspace = Workspace::new( Some(workspace_id), - project_handle, - app_state, + project_handle.clone(), + app_state.clone(), window, cx, ); + workspace.centered_layout = centered_layout; // Call init callback to add items before window renders @@ -1836,10 +1820,69 @@ impl Workspace { } workspace - }) - } - })? - }; + }); + multi_workspace.activate(workspace.clone(), cx); + workspace + })?; + (window, workspace) + } else { + let window_bounds_override = window_bounds_env_override(); + + let (window_bounds, display) = if let Some(bounds) = window_bounds_override { + (Some(WindowBounds::Windowed(bounds)), None) + } else if let Some(workspace) = serialized_workspace.as_ref() + && let Some(display) = workspace.display + && let Some(bounds) = workspace.window_bounds.as_ref() + { + // Reopening an existing workspace - restore its saved bounds + (Some(bounds.0), Some(display)) + } else if let Some((display, bounds)) = + persistence::read_default_window_bounds() + { + // New or empty workspace - use the last known window bounds + (Some(bounds), Some(display)) + } else { + // New window - let GPUI's default_bounds() handle cascading + (None, None) + }; + + // Use the serialized workspace to construct the new window + let mut options = cx.update(|cx| (app_state.build_window_options)(display, cx)); + options.window_bounds = window_bounds; + let centered_layout = serialized_workspace + .as_ref() + .map(|w| w.centered_layout) + .unwrap_or(false); + let window = cx.open_window(options, { + let app_state = app_state.clone(); + let project_handle = project_handle.clone(); + move |window, cx| { + let workspace = cx.new(|cx| { + let mut workspace = Workspace::new( + Some(workspace_id), + project_handle, + app_state, + window, + cx, + ); + workspace.centered_layout = centered_layout; + + // Call init callback to add items before window renders + if let Some(init) = init { + init(&mut workspace, window, cx); + } + + workspace + }); + cx.new(|cx| MultiWorkspace::new(workspace, cx)) + } + })?; + let workspace = + window.update(cx, |multi_workspace: &mut MultiWorkspace, _, _cx| { + multi_workspace.workspace().clone() + })?; + (window, workspace) + }; notify_if_database_failed(window, cx); // Check if this is an empty workspace (no paths to open) @@ -1852,8 +1895,10 @@ impl Workspace { .unwrap_or(false); let opened_items = window - .update(cx, |_workspace, window, cx| { - open_items(serialized_workspace, project_paths, window, cx) + .update(cx, |_, window, cx| { + workspace.update(cx, |_workspace: &mut Workspace, cx| { + open_items(serialized_workspace, project_paths, window, cx) + }) })? .await .unwrap_or_default(); @@ -1865,29 +1910,30 @@ impl Workspace { if is_empty_workspace && !serialized_workspace_has_paths { if let Some(default_docks) = persistence::read_default_dock_state() { window - .update(cx, |workspace, window, cx| { - for (dock, serialized_dock) in [ - (&mut workspace.right_dock, default_docks.right), - (&mut workspace.left_dock, default_docks.left), - (&mut workspace.bottom_dock, default_docks.bottom), - ] - .iter_mut() - { - dock.update(cx, |dock, cx| { - dock.serialized_dock = Some(serialized_dock.clone()); - dock.restore_state(window, cx); - }); - } - cx.notify(); + .update(cx, |_, window, cx| { + workspace.update(cx, |workspace, cx| { + for (dock, serialized_dock) in [ + (&workspace.right_dock, &default_docks.right), + (&workspace.left_dock, &default_docks.left), + (&workspace.bottom_dock, &default_docks.bottom), + ] { + dock.update(cx, |dock, cx| { + dock.serialized_dock = Some(serialized_dock.clone()); + dock.restore_state(window, cx); + }); + } + cx.notify(); + }); }) .log_err(); } } window - .update(cx, |workspace, window, cx| { - window.activate_window(); - workspace.update_history(cx); + .update(cx, |_, _window, cx| { + workspace.update(cx, |this: &mut Workspace, cx| { + this.update_history(cx); + }); }) .log_err(); Ok((window, opened_items)) @@ -2493,8 +2539,11 @@ impl Workspace { let env = self.project.read(cx).cli_environment(cx); let task = Self::new_local(Vec::new(), self.app_state.clone(), None, env, None, cx); cx.spawn_in(window, async move |_vh, cx| { - let (workspace, _) = task.await?; - workspace.update(cx, callback) + let (multi_workspace_window, _) = task.await?; + multi_workspace_window.update(cx, |multi_workspace, window, cx| { + let workspace = multi_workspace.workspace().clone(); + workspace.update(cx, |workspace, cx| callback(workspace, window, cx)) + }) }) } } @@ -2520,8 +2569,11 @@ impl Workspace { let env = self.project.read(cx).cli_environment(cx); let task = Self::new_local(Vec::new(), self.app_state.clone(), None, env, None, cx); cx.spawn_in(window, async move |_vh, cx| { - let (workspace, _) = task.await?; - workspace.update(cx, callback) + let (multi_workspace_window, _) = task.await?; + multi_workspace_window.update(cx, |multi_workspace, window, cx| { + let workspace = multi_workspace.workspace().clone(); + workspace.update(cx, |workspace, cx| callback(workspace, window, cx)) + }) }) } } @@ -2623,7 +2675,7 @@ impl Workspace { let workspace_count = cx.update(|_window, cx| { cx.windows() .iter() - .filter(|window| window.downcast::().is_some()) + .filter(|window| window.downcast::().is_some()) .count() })?; @@ -2636,10 +2688,12 @@ impl Workspace { let remaining_workspaces = cx.update(|_window, cx| { cx.windows() .iter() - .filter_map(|window| window.downcast::()) - .filter_map(|workspace| { - workspace - .update(cx, |workspace, _, _| workspace.removing) + .filter_map(|window| window.downcast::()) + .filter_map(|multi_workspace| { + multi_workspace + .update(cx, |multi_workspace, _, cx| { + multi_workspace.workspace().read(cx).removing + }) .ok() }) .filter(|removing| !removing) @@ -2675,13 +2729,18 @@ impl Workspace { } if close_intent == CloseIntent::ReplaceWindow { _ = active_call.update(cx, |this, cx| { - let workspace = cx + let multi_workspace = cx .windows() .iter() - .filter_map(|window| window.downcast::()) + .filter_map(|window| window.downcast::()) .next() .unwrap(); - let project = workspace.read(cx)?.project.clone(); + let project = multi_workspace + .read(cx)? + .workspace() + .read(cx) + .project + .clone(); if project.read(cx).is_shared() { this.unshare_project(project, cx)?; } @@ -2889,7 +2948,7 @@ impl Workspace { window: &mut Window, cx: &mut Context, ) -> Task> { - let window_handle = window.window_handle().downcast::(); + let window_handle = window.window_handle().downcast::(); let is_remote = self.project.read(cx).is_via_collab(); let has_worktree = self.project.read(cx).worktrees(cx).next().is_some(); let has_dirty_items = self.items(cx).any(|item| item.is_dirty(cx)); @@ -5074,21 +5133,27 @@ impl Workspace { self.update_window_edited(window, cx); return; } - if let Some(window_handle) = window.window_handle().downcast::() { - let s = item.on_release( - cx, - Box::new(move |cx| { - window_handle - .update(cx, |this, window, cx| { - this.dirty_items.remove(&item_id); - this.update_window_edited(window, cx) + + let workspace = self.weak_handle(); + let Some(window_handle) = window.window_handle().downcast::() else { + return; + }; + let on_release_callback = Box::new(move |cx: &mut App| { + window_handle + .update(cx, |_, window, cx| { + workspace + .update(cx, |workspace, cx| { + workspace.dirty_items.remove(&item_id); + workspace.update_window_edited(window, cx) }) .ok(); - }), - ); - self.dirty_items.insert(item_id, s); - self.update_window_edited(window, cx); - } + }) + .ok(); + }); + + let s = item.on_release(cx, on_release_callback); + self.dirty_items.insert(item_id, s); + self.update_window_edited(window, cx); } fn render_notifications(&self, _window: &mut Window, _cx: &mut Context) -> Option
{ @@ -7042,27 +7107,30 @@ enum ActivateInDirectionTarget { Dock(Entity), } -fn notify_if_database_failed(workspace: WindowHandle, cx: &mut AsyncApp) { - workspace - .update(cx, |workspace, _, cx| { - if (*db::ALL_FILE_DB_FAILED).load(std::sync::atomic::Ordering::Acquire) { - struct DatabaseFailedNotification; - - workspace.show_notification( - NotificationId::unique::(), - cx, - |cx| { - cx.new(|cx| { - MessageNotification::new("Failed to load the database file.", cx) - .primary_message("File an Issue") - .primary_icon(IconName::Plus) - .primary_on_click(|window, cx| { - window.dispatch_action(Box::new(FileBugReport), cx) - }) - }) - }, - ); - } +fn notify_if_database_failed(window: WindowHandle, cx: &mut AsyncApp) { + window + .update(cx, |multi_workspace, _, cx| { + let workspace = multi_workspace.workspace().clone(); + workspace.update(cx, |workspace, cx| { + if (*db::ALL_FILE_DB_FAILED).load(std::sync::atomic::Ordering::Acquire) { + struct DatabaseFailedNotification; + + workspace.show_notification( + NotificationId::unique::(), + cx, + |cx| { + cx.new(|cx| { + MessageNotification::new("Failed to load the database file.", cx) + .primary_message("File an Issue") + .primary_icon(IconName::Plus) + .primary_on_click(|window, cx| { + window.dispatch_action(Box::new(FileBugReport), cx) + }) + }) + }, + ); + } + }); }) .log_err(); } @@ -7216,15 +7284,14 @@ impl Render for Workspace { .collect::>(); let bottom_dock_layout = WorkspaceSettings::get_global(cx).bottom_dock_layout; - client_side_decorations( - self.actions(div(), window, cx) - .key_context(context) - .relative() - .size_full() - .flex() - .flex_col() - .font(ui_font) - .gap_0() + self.actions(div(), window, cx) + .key_context(context) + .relative() + .size_full() + .flex() + .flex_col() + .font(ui_font) + .gap_0() .justify_start() .items_start() .text_color(colors.text) @@ -7707,10 +7774,7 @@ impl Render for Workspace { }) .child(self.modal_layer.clone()) .child(self.toast_layer.clone()), - ), - window, - cx, - ) + ) } } @@ -7755,16 +7819,22 @@ impl WorkspaceStore { }; let mut response = proto::FollowResponse::default(); - this.workspaces.retain(|workspace| { - workspace - .update(cx, |workspace, window, cx| { - let handler_response = - workspace.handle_follow(follower.project_id, window, cx); - if let Some(active_view) = handler_response.active_view - && workspace.project.read(cx).remote_id() == follower.project_id - { - response.active_view = Some(active_view) - } + + this.workspaces.retain(|(window_handle, weak_workspace)| { + let Some(workspace) = weak_workspace.upgrade() else { + return false; + }; + window_handle + .update(cx, |_, window, cx| { + workspace.update(cx, |workspace, cx| { + let handler_response = + workspace.handle_follow(follower.project_id, window, cx); + if let Some(active_view) = handler_response.active_view + && workspace.project.read(cx).remote_id() == follower.project_id + { + response.active_view = Some(active_view) + } + }); }) .is_ok() }); @@ -7782,14 +7852,24 @@ impl WorkspaceStore { let update = envelope.payload; this.update(&mut cx, |this, cx| { - this.workspaces.retain(|workspace| { - workspace - .update(cx, |workspace, window, cx| { - let project_id = workspace.project.read(cx).remote_id(); - if update.project_id != project_id && update.project_id.is_some() { - return; - } - workspace.handle_update_followers(leader_id, update.clone(), window, cx); + this.workspaces.retain(|(window_handle, weak_workspace)| { + let Some(workspace) = weak_workspace.upgrade() else { + return false; + }; + window_handle + .update(cx, |_, window, cx| { + workspace.update(cx, |workspace, cx| { + let project_id = workspace.project.read(cx).remote_id(); + if update.project_id != project_id && update.project_id.is_some() { + return; + } + workspace.handle_update_followers( + leader_id, + update.clone(), + window, + cx, + ); + }); }) .is_ok() }); @@ -7797,8 +7877,14 @@ impl WorkspaceStore { }) } - pub fn workspaces(&self) -> &HashSet> { - &self.workspaces + pub fn workspaces(&self) -> impl Iterator> { + self.workspaces.iter().map(|(_, weak)| weak) + } + + pub fn workspaces_with_windows( + &self, + ) -> impl Iterator)> { + self.workspaces.iter().map(|(window, weak)| (*window, weak)) } } @@ -7850,19 +7936,119 @@ impl WorkspaceHandle for Entity { } } -pub async fn last_opened_workspace_location() --> Option<(WorkspaceId, SerializedWorkspaceLocation, PathList)> { - DB.last_workspace().await.log_err().flatten() +pub async fn last_opened_workspace_location( + fs: &dyn fs::Fs, +) -> Option<(WorkspaceId, SerializedWorkspaceLocation, PathList)> { + DB.last_workspace(fs).await.log_err().flatten() } -pub fn last_session_workspace_locations( +pub async fn last_session_workspace_locations( last_session_id: &str, last_session_window_stack: Option>, -) -> Option> { - DB.last_session_workspace_locations(last_session_id, last_session_window_stack) + fs: &dyn fs::Fs, +) -> Option> { + DB.last_session_workspace_locations(last_session_id, last_session_window_stack, fs) + .await .log_err() } +pub async fn restore_multiworkspace( + multi_workspace: SerializedMultiWorkspace, + app_state: Arc, + cx: &mut AsyncApp, +) -> anyhow::Result> { + let SerializedMultiWorkspace { workspaces, state } = multi_workspace; + let mut group_iter = workspaces.into_iter(); + let first = group_iter + .next() + .context("window group must not be empty")?; + + let window_handle = if first.paths.is_empty() { + cx.update(|cx| open_workspace_by_id(first.workspace_id, app_state.clone(), None, cx)) + .await? + } else { + let (window, _items) = cx + .update(|cx| { + Workspace::new_local( + first.paths.paths().to_vec(), + app_state.clone(), + None, + None, + None, + cx, + ) + }) + .await?; + window + }; + + for session_workspace in group_iter { + if session_workspace.paths.is_empty() { + cx.update(|cx| { + open_workspace_by_id( + session_workspace.workspace_id, + app_state.clone(), + Some(window_handle), + cx, + ) + }) + .await?; + } else { + cx.update(|cx| { + Workspace::new_local( + session_workspace.paths.paths().to_vec(), + app_state.clone(), + Some(window_handle), + None, + None, + cx, + ) + }) + .await?; + } + } + + if let Some(target_id) = state.active_workspace_id { + window_handle + .update(cx, |multi_workspace, window, cx| { + let target_index = multi_workspace + .workspaces() + .iter() + .position(|ws| ws.read(cx).database_id() == Some(target_id)); + if let Some(index) = target_index { + multi_workspace.activate_index(index, window, cx); + } else if !multi_workspace.workspaces().is_empty() { + multi_workspace.activate_index(0, window, cx); + } + }) + .ok(); + } else { + window_handle + .update(cx, |multi_workspace, window, cx| { + if !multi_workspace.workspaces().is_empty() { + multi_workspace.activate_index(0, window, cx); + } + }) + .ok(); + } + + if state.sidebar_open { + window_handle + .update(cx, |multi_workspace, window, cx| { + multi_workspace.open_sidebar(window, cx); + }) + .ok(); + } + + window_handle + .update(cx, |_, window, _cx| { + window.activate_window(); + }) + .ok(); + + Ok(window_handle) +} + actions!( collab, [ @@ -7902,7 +8088,8 @@ actions!( async fn join_channel_internal( channel_id: ChannelId, app_state: &Arc, - requesting_window: Option>, + requesting_window: Option>, + requesting_workspace: Option>, active_call: &Entity, cx: &mut AsyncApp, ) -> Result { @@ -7938,8 +8125,8 @@ async fn join_channel_internal( } if should_prompt { - if let Some(workspace) = requesting_window { - let answer = workspace + if let Some(multi_workspace) = requesting_window { + let answer = multi_workspace .update(cx, |_, window, cx| { window.prompt( PromptLevel::Warning, @@ -8008,9 +8195,9 @@ async fn join_channel_internal( // If you are the first to join a channel, see if you should share your project. if room.remote_participants().is_empty() && !room.local_participant_is_guest() - && let Some(workspace) = requesting_window + && let Some(workspace) = requesting_workspace.as_ref().and_then(|w| w.upgrade()) { - let project = workspace.update(cx, |workspace, _, cx| { + let project = workspace.update(cx, |workspace, cx| { let project = workspace.project.read(cx); if !CallSettings::get_global(cx).share_on_join { @@ -8029,7 +8216,7 @@ async fn join_channel_internal( None } }); - if let Ok(Some(project)) = project { + if let Some(project) = project { return Some(cx.spawn(async move |room, cx| { room.update(cx, |room, cx| room.share_project(project, cx))? .await?; @@ -8050,14 +8237,21 @@ async fn join_channel_internal( pub fn join_channel( channel_id: ChannelId, app_state: Arc, - requesting_window: Option>, + requesting_window: Option>, + requesting_workspace: Option>, cx: &mut App, ) -> Task> { let active_call = ActiveCall::global(cx); cx.spawn(async move |cx| { - let result = - join_channel_internal(channel_id, &app_state, requesting_window, &active_call, cx) - .await; + let result = join_channel_internal( + channel_id, + &app_state, + requesting_window, + requesting_workspace, + &active_call, + cx, + ) + .await; // join channel succeeded, and opened a window if matches!(result, Ok(true)) { @@ -8081,6 +8275,12 @@ pub fn join_channel( }) .await?; + window_handle + .update(cx, |_, window, _cx| { + window.activate_window(); + }) + .ok(); + if result.is_ok() { cx.update(|cx| { cx.dispatch_action(&OpenChannelNotes); @@ -8135,10 +8335,10 @@ pub fn join_channel( }) } -pub async fn get_any_active_workspace( +pub async fn get_any_active_multi_workspace( app_state: Arc, mut cx: AsyncApp, -) -> anyhow::Result> { +) -> anyhow::Result> { // find an existing workspace to focus and show call controls let active_window = activate_any_workspace_window(&mut cx); if active_window.is_none() { @@ -8148,17 +8348,17 @@ pub async fn get_any_active_workspace( activate_any_workspace_window(&mut cx).context("could not open zed") } -fn activate_any_workspace_window(cx: &mut AsyncApp) -> Option> { +fn activate_any_workspace_window(cx: &mut AsyncApp) -> Option> { cx.update(|cx| { if let Some(workspace_window) = cx .active_window() - .and_then(|window| window.downcast::()) + .and_then(|window| window.downcast::()) { return Some(workspace_window); } for window in cx.windows() { - if let Some(workspace_window) = window.downcast::() { + if let Some(workspace_window) = window.downcast::() { workspace_window .update(cx, |_, window, _| window.activate_window()) .ok(); @@ -8169,14 +8369,17 @@ fn activate_any_workspace_window(cx: &mut AsyncApp) -> Option Vec> { +pub fn local_workspace_windows(cx: &App) -> Vec> { cx.windows() .into_iter() - .filter_map(|window| window.downcast::()) - .filter(|workspace| { - workspace - .read(cx) - .is_ok_and(|workspace| workspace.project.read(cx).is_local()) + .filter_map(|window| window.downcast::()) + .filter(|multi_workspace| { + multi_workspace.read(cx).is_ok_and(|multi_workspace| { + multi_workspace + .workspaces() + .iter() + .any(|workspace| workspace.read(cx).project.read(cx).is_local()) + }) }) .collect() } @@ -8187,7 +8390,7 @@ pub struct OpenOptions { pub focus: Option, pub open_new_workspace: Option, pub prefer_focused_window: bool, - pub replace_window: Option>, + pub replace_window: Option>, pub env: Option>, } @@ -8195,8 +8398,9 @@ pub struct OpenOptions { pub fn open_workspace_by_id( workspace_id: WorkspaceId, app_state: Arc, + requesting_window: Option>, cx: &mut App, -) -> Task>> { +) -> Task>> { let project_handle = Project::local( app_state.client.clone(), app_state.node_runtime.clone(), @@ -8216,52 +8420,87 @@ pub fn open_workspace_by_id( .workspace_for_id(workspace_id) .with_context(|| format!("Workspace {workspace_id:?} not found"))?; - let window_bounds_override = window_bounds_env_override(); - - let (window_bounds, display) = if let Some(bounds) = window_bounds_override { - (Some(WindowBounds::Windowed(bounds)), None) - } else if let Some(display) = serialized_workspace.display - && let Some(bounds) = serialized_workspace.window_bounds.as_ref() - { - (Some(bounds.0), Some(display)) - } else if let Some((display, bounds)) = persistence::read_default_window_bounds() { - (Some(bounds), Some(display)) - } else { - (None, None) - }; - - let options = cx.update(|cx| { - let mut options = (app_state.build_window_options)(display, cx); - options.window_bounds = window_bounds; - options - }); let centered_layout = serialized_workspace.centered_layout; - let window = cx.open_window(options, { - let app_state = app_state.clone(); - let project_handle = project_handle.clone(); - move |window, cx| { - cx.new(|cx| { - let mut workspace = - Workspace::new(Some(workspace_id), project_handle, app_state, window, cx); + let (window, workspace) = if let Some(window) = requesting_window { + let workspace = window.update(cx, |multi_workspace, window, cx| { + let workspace = cx.new(|cx| { + let mut workspace = Workspace::new( + Some(workspace_id), + project_handle.clone(), + app_state.clone(), + window, + cx, + ); workspace.centered_layout = centered_layout; workspace - }) - } - })?; + }); + multi_workspace.add_workspace(workspace.clone(), cx); + workspace + })?; + (window, workspace) + } else { + let window_bounds_override = window_bounds_env_override(); + + let (window_bounds, display) = if let Some(bounds) = window_bounds_override { + (Some(WindowBounds::Windowed(bounds)), None) + } else if let Some(display) = serialized_workspace.display + && let Some(bounds) = serialized_workspace.window_bounds.as_ref() + { + (Some(bounds.0), Some(display)) + } else if let Some((display, bounds)) = persistence::read_default_window_bounds() { + (Some(bounds), Some(display)) + } else { + (None, None) + }; + + let options = cx.update(|cx| { + let mut options = (app_state.build_window_options)(display, cx); + options.window_bounds = window_bounds; + options + }); + + let window = cx.open_window(options, { + let app_state = app_state.clone(); + let project_handle = project_handle.clone(); + move |window, cx| { + let workspace = cx.new(|cx| { + let mut workspace = Workspace::new( + Some(workspace_id), + project_handle, + app_state, + window, + cx, + ); + workspace.centered_layout = centered_layout; + workspace + }); + cx.new(|cx| MultiWorkspace::new(workspace, cx)) + } + })?; + + let workspace = window.update(cx, |multi_workspace: &mut MultiWorkspace, _, _cx| { + multi_workspace.workspace().clone() + })?; + + (window, workspace) + }; notify_if_database_failed(window, cx); // Restore items from the serialized workspace window - .update(cx, |_workspace, window, cx| { - open_items(Some(serialized_workspace), vec![], window, cx) + .update(cx, |_, window, cx| { + workspace.update(cx, |_workspace, cx| { + open_items(Some(serialized_workspace), vec![], window, cx) + }) })? .await?; - window.update(cx, |workspace, window, cx| { - window.activate_window(); - workspace.serialize_workspace(window, cx); + window.update(cx, |_, window, cx| { + workspace.update(cx, |workspace, cx| { + workspace.serialize_workspace(window, cx); + }); })?; Ok(window) @@ -8276,12 +8515,12 @@ pub fn open_paths( cx: &mut App, ) -> Task< anyhow::Result<( - WindowHandle, + WindowHandle, Vec>>>, )>, > { let abs_paths = abs_paths.to_vec(); - let mut existing = None; + let mut existing: Option<(WindowHandle, Entity)> = None; let mut best_match = None; let mut open_visible = OpenVisible::All; #[cfg(target_os = "windows")] @@ -8300,20 +8539,22 @@ pub fn open_paths( cx.update(|cx| { for window in local_workspace_windows(cx) { - if let Ok(workspace) = window.read(cx) { - let m = workspace.project.read(cx).visibility_for_paths( - &abs_paths, - &all_metadatas, - open_options.open_new_workspace == None, - cx, - ); - if m > best_match { - existing = Some(window); - best_match = m; - } else if best_match.is_none() - && open_options.open_new_workspace == Some(false) - { - existing = Some(window) + if let Ok(multi_workspace) = window.read(cx) { + for workspace in multi_workspace.workspaces() { + let m = workspace.read(cx).project.read(cx).visibility_for_paths( + &abs_paths, + &all_metadatas, + open_options.open_new_workspace == None, + cx, + ); + if m > best_match { + existing = Some((window, workspace.clone())); + best_match = m; + } else if best_match.is_none() + && open_options.open_new_workspace == Some(false) + { + existing = Some((window, workspace.clone())) + } } } } @@ -8326,95 +8567,118 @@ pub fn open_paths( cx.update(|cx| { if let Some(window) = cx .active_window() - .and_then(|window| window.downcast::()) - && let Ok(workspace) = window.read(cx) + .and_then(|window| window.downcast::()) + && let Ok(multi_workspace) = window.read(cx) { - let project = workspace.project().read(cx); + let active_workspace = multi_workspace.workspace().clone(); + let project = active_workspace.read(cx).project().read(cx); if project.is_local() && !project.is_via_collab() { - existing = Some(window); + existing = Some((window, active_workspace)); open_visible = OpenVisible::None; return; } } - for window in local_workspace_windows(cx) { - if let Ok(workspace) = window.read(cx) { - let project = workspace.project().read(cx); - if project.is_via_collab() { - continue; + 'outer: for window in local_workspace_windows(cx) { + if let Ok(multi_workspace) = window.read(cx) { + for workspace in multi_workspace.workspaces() { + let project = workspace.read(cx).project().read(cx); + if project.is_via_collab() { + continue; + } + existing = Some((window, workspace.clone())); + open_visible = OpenVisible::None; + break 'outer; } - existing = Some(window); - open_visible = OpenVisible::None; - break; } } }); } } - let result = if let Some(existing) = existing { + let result = if let Some((existing, target_workspace)) = existing { let open_task = existing - .update(cx, |workspace, window, cx| { + .update(cx, |multi_workspace, window, cx| { window.activate_window(); - workspace.open_paths( - abs_paths, - OpenOptions { - visible: Some(open_visible), - ..Default::default() - }, - None, - window, - cx, - ) + multi_workspace.activate(target_workspace.clone(), cx); + target_workspace.update(cx, |workspace, cx| { + workspace.open_paths( + abs_paths, + OpenOptions { + visible: Some(open_visible), + ..Default::default() + }, + None, + window, + cx, + ) + }) })? .await; - _ = existing.update(cx, |workspace, _, cx| { - for item in open_task.iter().flatten() { - if let Err(e) = item { - workspace.show_error(&e, cx); + _ = existing.update(cx, |multi_workspace, _, cx| { + let workspace = multi_workspace.workspace().clone(); + workspace.update(cx, |workspace, cx| { + for item in open_task.iter().flatten() { + if let Err(e) = item { + workspace.show_error(&e, cx); + } } - } + }); }); Ok((existing, open_task)) } else { - cx.update(move |cx| { - Workspace::new_local( - abs_paths, - app_state.clone(), - open_options.replace_window, - open_options.env, - None, - cx, - ) - }) - .await + let result = cx + .update(move |cx| { + Workspace::new_local( + abs_paths, + app_state.clone(), + open_options.replace_window, + open_options.env, + None, + cx, + ) + }) + .await; + + if let Ok((ref window_handle, _)) = result { + window_handle + .update(cx, |_, window, _cx| { + window.activate_window(); + }) + .log_err(); + } + + result }; #[cfg(target_os = "windows")] if let Some(util::paths::WslPath{distro, path}) = wsl_path - && let Ok((workspace, _)) = &result + && let Ok((multi_workspace_window, _)) = &result { - workspace - .update(cx, move |workspace, _window, cx| { + multi_workspace_window + .update(cx, move |multi_workspace, _window, cx| { struct OpenInWsl; - workspace.show_notification(NotificationId::unique::(), cx, move |cx| { - let display_path = util::markdown::MarkdownInlineCode(&path.to_string_lossy()); - let msg = format!("{display_path} is inside a WSL filesystem, some features may not work unless you open it with WSL remote"); - cx.new(move |cx| { - MessageNotification::new(msg, cx) - .primary_message("Open in WSL") - .primary_icon(IconName::FolderOpen) - .primary_on_click(move |window, cx| { - window.dispatch_action(Box::new(remote::OpenWslPath { - distro: remote::WslConnectionOptions { - distro_name: distro.clone(), - user: None, - }, - paths: vec![path.clone().into()], - }), cx) - }) - }) + let workspace = multi_workspace.workspace().clone(); + workspace.update(cx, |workspace, cx| { + workspace.show_notification(NotificationId::unique::(), cx, move |cx| { + let display_path = util::markdown::MarkdownInlineCode(&path.to_string_lossy()); + let msg = format!("{display_path} is inside a WSL filesystem, some features may not work unless you open it with WSL remote"); + cx.new(move |cx| { + MessageNotification::new(msg, cx) + .primary_message("Open in WSL") + .primary_icon(IconName::FolderOpen) + .primary_on_click(move |window, cx| { + window.dispatch_action(Box::new(remote::OpenWslPath { + distro: remote::WslConnectionOptions { + distro_name: distro.clone(), + user: None, + }, + paths: vec![path.clone().into()], + }), cx) + }) + }) + }); }); }) .unwrap(); @@ -8437,9 +8701,13 @@ pub fn open_new( Some(Box::new(init)), cx, ); - cx.spawn(async move |_cx| { - let (_workspace, _opened_paths) = task.await?; - // Init callback is called synchronously during workspace creation + cx.spawn(async move |cx| { + let (window, _opened_paths) = task.await?; + window + .update(cx, |_, window, _cx| { + window.activate_window(); + }) + .ok(); Ok(()) }) } @@ -8491,7 +8759,7 @@ pub fn create_and_open_local_file( } pub fn open_remote_project_with_new_connection( - window: WindowHandle, + window: WindowHandle, remote_connection: Arc, cancel_rx: oneshot::Receiver<()>, delegate: Arc, @@ -8551,7 +8819,7 @@ pub fn open_remote_project_with_existing_connection( project: Entity, paths: Vec, app_state: Arc, - window: WindowHandle, + window: WindowHandle, cx: &mut AsyncApp, ) -> Task>>>> { cx.spawn(async move |cx| { @@ -8577,7 +8845,7 @@ async fn open_remote_project_inner( workspace_id: WorkspaceId, serialized_workspace: Option, app_state: Arc, - window: WindowHandle, + window: WindowHandle, cx: &mut AsyncApp, ) -> Result>>> { let toolchains = DB.toolchains(workspace_id).await?; @@ -8622,21 +8890,10 @@ async fn open_remote_project_inner( return Err(project_path_errors.pop().context("no paths given")?); } - if let Some(detach_session_task) = window - .update(cx, |_workspace, window, cx| { - cx.spawn_in(window, async move |this, cx| { - this.update_in(cx, |this, window, cx| this.remove_from_session(window, cx)) - }) - }) - .ok() - { - detach_session_task.await.ok(); - } - - cx.update_window(window.into(), |_, window, cx| { - window.replace_root(cx, |window, cx| { - telemetry::event!("SSH Project Opened"); + let workspace = window.update(cx, |multi_workspace, window, cx| { + telemetry::event!("SSH Project Opened"); + let new_workspace = cx.new(|cx| { let mut workspace = Workspace::new(Some(workspace_id), project, app_state.clone(), window, cx); workspace.update_history(cx); @@ -8647,16 +8904,21 @@ async fn open_remote_project_inner( workspace }); + + multi_workspace.activate(new_workspace.clone(), cx); + new_workspace })?; let items = window .update(cx, |_, window, cx| { window.activate_window(); - open_items(serialized_workspace, project_paths_to_open, window, cx) + workspace.update(cx, |_workspace, cx| { + open_items(serialized_workspace, project_paths_to_open, window, cx) + }) })? .await?; - window.update(cx, |workspace, _, cx| { + workspace.update(cx, |workspace, cx| { for error in project_path_errors { if error.error_code() == proto::ErrorCode::DevServerProjectPathDoesNotExist { if let Some(path) = error.error_tag("path") { @@ -8666,7 +8928,7 @@ async fn open_remote_project_inner( workspace.show_error(&error, cx) } } - })?; + }); Ok(items.into_iter().map(|item| item?.ok()).collect()) } @@ -8704,24 +8966,37 @@ pub fn join_in_room_project( ) -> Task> { let windows = cx.windows(); cx.spawn(async move |cx| { - let existing_workspace = windows.into_iter().find_map(|window_handle| { + let existing_window_and_workspace: Option<( + WindowHandle, + Entity, + )> = windows.into_iter().find_map(|window_handle| { window_handle - .downcast::() + .downcast::() .and_then(|window_handle| { window_handle - .update(cx, |workspace, _window, cx| { - if workspace.project().read(cx).remote_id() == Some(project_id) { - Some(window_handle) - } else { - None + .update(cx, |multi_workspace, _window, cx| { + for workspace in multi_workspace.workspaces() { + if workspace.read(cx).project().read(cx).remote_id() + == Some(project_id) + { + return Some((window_handle, workspace.clone())); + } } + None }) .unwrap_or(None) }) }); - let workspace = if let Some(existing_workspace) = existing_workspace { - existing_workspace + let multi_workspace_window = if let Some((existing_window, target_workspace)) = + existing_window_and_workspace + { + existing_window + .update(cx, |multi_workspace, _, cx| { + multi_workspace.activate(target_workspace, cx); + }) + .ok(); + existing_window } else { let active_call = cx.update(|cx| ActiveCall::global(cx)); let room = active_call @@ -8743,39 +9018,44 @@ pub fn join_in_room_project( let mut options = (app_state.build_window_options)(None, cx); options.window_bounds = window_bounds_override.map(WindowBounds::Windowed); cx.open_window(options, |window, cx| { - cx.new(|cx| { + let workspace = cx.new(|cx| { Workspace::new(Default::default(), project, app_state.clone(), window, cx) - }) + }); + cx.new(|cx| MultiWorkspace::new(workspace, cx)) }) })? }; - workspace.update(cx, |workspace, window, cx| { + multi_workspace_window.update(cx, |multi_workspace, window, cx| { cx.activate(true); window.activate_window(); - if let Some(room) = ActiveCall::global(cx).read(cx).room().cloned() { - let follow_peer_id = room - .read(cx) - .remote_participants() - .iter() - .find(|(_, participant)| participant.user.id == follow_user_id) - .map(|(_, p)| p.peer_id) - .or_else(|| { - // If we couldn't follow the given user, follow the host instead. - let collaborator = workspace - .project() - .read(cx) - .collaborators() - .values() - .find(|collaborator| collaborator.is_host)?; - Some(collaborator.peer_id) - }); + // We set the active workspace above, so this is the correct workspace. + let workspace = multi_workspace.workspace().clone(); + workspace.update(cx, |workspace, cx| { + if let Some(room) = ActiveCall::global(cx).read(cx).room().cloned() { + let follow_peer_id = room + .read(cx) + .remote_participants() + .iter() + .find(|(_, participant)| participant.user.id == follow_user_id) + .map(|(_, p)| p.peer_id) + .or_else(|| { + // If we couldn't follow the given user, follow the host instead. + let collaborator = workspace + .project() + .read(cx) + .collaborators() + .values() + .find(|collaborator| collaborator.is_host)?; + Some(collaborator.peer_id) + }); - if let Some(follow_peer_id) = follow_peer_id { - workspace.follow(follow_peer_id, window, cx); + if let Some(follow_peer_id) = follow_peer_id { + workspace.follow(follow_peer_id, window, cx); + } } - } + }); })?; anyhow::Ok(()) @@ -8787,7 +9067,7 @@ pub fn reload(cx: &mut App) { let mut workspace_windows = cx .windows() .into_iter() - .filter_map(|window| window.downcast::()) + .filter_map(|window| window.downcast::()) .collect::>(); // If multiple windows have unsaved changes, and need a save prompt, @@ -8819,8 +9099,11 @@ pub fn reload(cx: &mut App) { // If the user cancels any save prompt, then keep the app open. for window in workspace_windows { - if let Ok(should_close) = window.update(cx, |workspace, window, cx| { - workspace.prepare_to_close(CloseIntent::Quit, window, cx) + if let Ok(should_close) = window.update(cx, |multi_workspace, window, cx| { + let workspace = multi_workspace.workspace().clone(); + workspace.update(cx, |workspace, cx| { + workspace.prepare_to_close(CloseIntent::Quit, window, cx) + }) }) && !should_close.await? { return anyhow::Ok(()); @@ -9293,11 +9576,17 @@ pub fn with_active_or_new_workspace( cx: &mut App, f: impl FnOnce(&mut Workspace, &mut Window, &mut Context) + Send + 'static, ) { - match cx.active_window().and_then(|w| w.downcast::()) { - Some(workspace) => { + match cx + .active_window() + .and_then(|w| w.downcast::()) + { + Some(multi_workspace) => { cx.defer(move |cx| { - workspace - .update(cx, |workspace, window, cx| f(workspace, window, cx)) + multi_workspace + .update(cx, |multi_workspace, window, cx| { + let workspace = multi_workspace.workspace().clone(); + workspace.update(cx, |workspace, cx| f(workspace, window, cx)); + }) .log_err(); }); } diff --git a/crates/zed/Cargo.toml b/crates/zed/Cargo.toml index 052aba46b67a32c7607c60a049c3eb3eba3f06dd..066f52ab6220280d657f7264e21f8d1f4869134f 100644 --- a/crates/zed/Cargo.toml +++ b/crates/zed/Cargo.toml @@ -49,6 +49,7 @@ visual-tests = [ "language_model/test-support", "fs/test-support", "recent_projects/test-support", + "sidebar/test-support", "title_bar/test-support", ] @@ -187,6 +188,7 @@ settings.workspace = true settings_profile_selector.workspace = true settings_ui.workspace = true shellexpand.workspace = true +sidebar.workspace = true smol.workspace = true snippet_provider.workspace = true snippets_ui.workspace = true diff --git a/crates/zed/src/main.rs b/crates/zed/src/main.rs index 9a2cea33882b1a9d8434bf0f3e2cb1dba8471007..6dc23fa4c585fa58502c794d8d2480ce6a1b278d 100644 --- a/crates/zed/src/main.rs +++ b/crates/zed/src/main.rs @@ -54,8 +54,8 @@ use theme::{ActiveTheme, GlobalTheme, ThemeRegistry}; use util::{ResultExt, TryFutureExt, maybe}; use uuid::Uuid; use workspace::{ - AppState, PathList, SerializedWorkspaceLocation, Toast, Workspace, WorkspaceId, - WorkspaceSettings, WorkspaceStore, notifications::NotificationId, + AppState, MultiWorkspace, SerializedWorkspaceLocation, SessionWorkspace, Toast, + WorkspaceSettings, WorkspaceStore, notifications::NotificationId, restore_multiworkspace, }; use zed::{ OpenListener, OpenRequest, RawOpenRequest, app_menus, build_window_options, @@ -511,15 +511,13 @@ fn main() { let workspace_store = workspace_store.clone(); Arc::new(move |cx: &mut App| { workspace_store.update(cx, |workspace_store, cx| { - workspace_store + Ok(workspace_store .workspaces() - .iter() - .map(|workspace| { - workspace.update(cx, |workspace, _, cx| { - workspace.project().read(cx).lsp_store() - }) + .filter_map(|weak| weak.upgrade()) + .map(|workspace: gpui::Entity| { + workspace.read(cx).project().read(cx).lsp_store() }) - .collect() + .collect()) }) }) }), @@ -849,7 +847,7 @@ fn handle_open_request(request: OpenRequest, app_state: Arc, cx: &mut OpenRequestKind::Extension { extension_id } => { cx.spawn(async move |cx| { let workspace = - workspace::get_any_active_workspace(app_state, cx.clone()).await?; + workspace::get_any_active_multi_workspace(app_state, cx.clone()).await?; workspace.update(cx, |_, window, cx| { window.dispatch_action( Box::new(zed_actions::Extensions { @@ -864,31 +862,40 @@ fn handle_open_request(request: OpenRequest, app_state: Arc, cx: &mut } OpenRequestKind::AgentPanel { initial_prompt } => { cx.spawn(async move |cx| { - let workspace = - workspace::get_any_active_workspace(app_state, cx.clone()).await?; - workspace.update(cx, |workspace, window, cx| { - if let Some(panel) = workspace.focus_panel::(window, cx) { - panel.update(cx, |panel, cx| { - panel.new_external_thread_with_text(initial_prompt, window, cx); - }); - } + let multi_workspace = + workspace::get_any_active_multi_workspace(app_state, cx.clone()).await?; + + multi_workspace.update(cx, |multi_workspace, window, cx| { + multi_workspace.workspace().update(cx, |workspace, cx| { + if let Some(panel) = workspace.focus_panel::(window, cx) { + panel.update(cx, |panel, cx| { + panel.new_external_thread_with_text(initial_prompt, window, cx); + }); + } + }); }) }) .detach_and_log_err(cx); } OpenRequestKind::SharedAgentThread { session_id } => { cx.spawn(async move |cx| { + let multi_workspace = + workspace::get_any_active_multi_workspace(app_state.clone(), cx.clone()) + .await?; + let workspace = - workspace::get_any_active_workspace(app_state.clone(), cx.clone()).await?; + multi_workspace.read_with(cx, |mw, _| mw.workspace().clone())?; let (client, thread_store) = - workspace.update(cx, |workspace, _window, cx| { - let client = workspace.project().read(cx).client(); - let thread_store: Option> = workspace - .panel::(cx) - .map(|panel| panel.read(cx).thread_store().clone()); - (client, thread_store) - })?; + multi_workspace.update(cx, |_, _window, cx| { + workspace.update(cx, |workspace, cx| { + let client = workspace.project().read(cx).client(); + let thread_store: Option> = workspace + .panel::(cx) + .map(|panel| panel.read(cx).thread_store().clone()); + anyhow::Ok((client, thread_store)) + }) + })??; let Some(thread_store): Option> = thread_store else { anyhow::bail!("Agent panel not available"); @@ -921,25 +928,27 @@ fn handle_open_request(request: OpenRequest, app_state: Arc, cx: &mut meta: None, }; - workspace.update(cx, |workspace, window, cx| { - if let Some(panel) = workspace.panel::(cx) { - panel.update(cx, |panel, cx| { - panel.open_thread(thread_metadata, window, cx); - }); - panel.focus_handle(cx).focus(window, cx); - } - })?; + let sharer_username = response.sharer_username.clone(); - workspace.update(cx, |workspace, _window, cx| { - struct ImportedThreadToast; - workspace.show_toast( - Toast::new( - NotificationId::unique::(), - format!("Imported shared thread from {}", response.sharer_username), - ) - .autohide(), - cx, - ); + multi_workspace.update(cx, |_, window, cx| { + workspace.update(cx, |workspace, cx| { + if let Some(panel) = workspace.panel::(cx) { + panel.update(cx, |panel, cx| { + panel.open_thread(thread_metadata, window, cx); + }); + panel.focus_handle(cx).focus(window, cx); + } + + struct ImportedThreadToast; + workspace.show_toast( + Toast::new( + NotificationId::unique::(), + format!("Imported shared thread from {}", sharer_username), + ) + .autohide(), + cx, + ); + }); })?; anyhow::Ok(()) @@ -1014,7 +1023,7 @@ fn handle_open_request(request: OpenRequest, app_state: Arc, cx: &mut // [ languages $(language) tab_size] cx.spawn(async move |cx| { let workspace = - workspace::get_any_active_workspace(app_state, cx.clone()).await?; + workspace::get_any_active_multi_workspace(app_state, cx.clone()).await?; workspace.update(cx, |_, window, cx| match setting_path { None => window.dispatch_action(Box::new(zed_actions::OpenSettings), cx), @@ -1076,23 +1085,29 @@ fn handle_open_request(request: OpenRequest, app_state: Arc, cx: &mut .await?; workspace - .update(cx, |workspace, window, cx| { - let Some(repo) = workspace.project().read(cx).active_repository(cx) - else { - log::error!("no active repository found for commit view"); - return Err(anyhow::anyhow!("no active repository found")); - }; - - git_ui::commit_view::CommitView::open( - sha, - repo.downgrade(), - workspace.weak_handle(), - None, - None, - window, - cx, - ); - Ok(()) + .update(cx, |multi_workspace, window, cx| { + multi_workspace + .workspace() + .clone() + .update(cx, |workspace, cx| { + let Some(repo) = + workspace.project().read(cx).active_repository(cx) + else { + log::error!("no active repository found for commit view"); + return Err(anyhow::anyhow!("no active repository found")); + }; + + git_ui::commit_view::CommitView::open( + sha, + repo.downgrade(), + workspace.weak_handle(), + None, + None, + window, + cx, + ); + Ok(()) + }) }) .log_err(); @@ -1162,6 +1177,7 @@ fn handle_open_request(request: OpenRequest, app_state: Arc, cx: &mut client::ChannelId(channel_id), app_state.clone(), None, + None, cx, ) }) @@ -1169,8 +1185,9 @@ fn handle_open_request(request: OpenRequest, app_state: Arc, cx: &mut } let workspace_window = - workspace::get_any_active_workspace(app_state, cx.clone()).await?; - let workspace = workspace_window.entity(cx)?; + workspace::get_any_active_multi_workspace(app_state, cx.clone()).await?; + + let workspace = workspace_window.read_with(cx, |mw, _| mw.workspace().clone())?; let mut promises = Vec::new(); for (channel_id, heading) in request.open_channel_notes { @@ -1260,78 +1277,53 @@ async fn installation_id() -> Result { Ok(IdType::New(installation_id)) } -async fn restore_or_create_workspace(app_state: Arc, cx: &mut AsyncApp) -> Result<()> { - if let Some(locations) = restorable_workspace_locations(cx, &app_state).await { - let use_system_window_tabs = - cx.update(|cx| WorkspaceSettings::get_global(cx).use_system_window_tabs); +pub(crate) async fn restore_or_create_workspace( + app_state: Arc, + cx: &mut AsyncApp, +) -> Result<()> { + if let Some((multi_workspaces, remote_workspaces)) = restorable_workspaces(cx, &app_state).await + { let mut results: Vec> = Vec::new(); let mut tasks = Vec::new(); - for (index, (workspace_id, location, paths)) in locations.into_iter().enumerate() { - match location { - SerializedWorkspaceLocation::Local if paths.is_empty() => { - // Restore empty workspace by ID (has items like drafts but no folders) - let app_state = app_state.clone(); - let task = cx.spawn(async move |cx| { - let open_task = cx.update(|cx| { - workspace::open_workspace_by_id(workspace_id, app_state, cx) - }); - open_task.await.map(|_| ()) - }); + let mut local_results = Vec::new(); + for multi_workspace in multi_workspaces { + local_results + .push(restore_multiworkspace(multi_workspace, app_state.clone(), cx).await); + } - if use_system_window_tabs && index == 0 { - results.push(task.await); - } else { - tasks.push(task); - } - } - SerializedWorkspaceLocation::Local => { - let app_state = app_state.clone(); - let task = cx.spawn(async move |cx| { - let open_task = cx.update(|cx| { - workspace::open_paths( - &paths.paths(), - app_state, - workspace::OpenOptions::default(), - cx, - ) - }); - open_task.await.map(|_| ()) - }); - - // If we're using system window tabs and this is the first workspace, - // wait for it to finish so that the other windows can be added as tabs. - if use_system_window_tabs && index == 0 { - results.push(task.await); - } else { - tasks.push(task); - } - } - SerializedWorkspaceLocation::Remote(mut connection_options) => { - let app_state = app_state.clone(); - if let RemoteConnectionOptions::Ssh(options) = &mut connection_options { - cx.update(|cx| { - RemoteSettings::get_global(cx) - .fill_connection_options_from_settings(options) - }); - } - let task = cx.spawn(async move |cx| { - recent_projects::open_remote_project( - connection_options, - paths.paths().into_iter().map(PathBuf::from).collect(), - app_state, - workspace::OpenOptions::default(), - cx, - ) - .await - .map_err(|e| anyhow::anyhow!(e)) - }); - tasks.push(task); - } + for result in local_results { + results.push(result.map(|_| ())); + } + + for session_workspace in remote_workspaces { + let app_state = app_state.clone(); + let SerializedWorkspaceLocation::Remote(mut connection_options) = + session_workspace.location + else { + continue; + }; + let paths = session_workspace.paths; + if let RemoteConnectionOptions::Ssh(options) = &mut connection_options { + cx.update(|cx| { + RemoteSettings::get_global(cx).fill_connection_options_from_settings(options) + }); } + let task = cx.spawn(async move |cx| { + recent_projects::open_remote_project( + connection_options, + paths.paths().iter().map(PathBuf::from).collect(), + app_state, + workspace::OpenOptions::default(), + cx, + ) + .await + .map_err(|e| anyhow::anyhow!(e)) + }); + tasks.push(task); } - // Wait for all workspaces to open concurrently + // Wait for all window groups and remote workspaces to open concurrently results.extend(future::join_all(tasks).await); // Show notifications for any errors that occurred @@ -1356,12 +1348,16 @@ async fn restore_or_create_workspace(app_state: Arc, cx: &mut AsyncApp // Try to find an active workspace to show the toast let toast_shown = cx.update(|cx| { if let Some(window) = cx.active_window() - && let Some(workspace) = window.downcast::() + && let Some(multi_workspace) = window.downcast::() { - workspace - .update(cx, |workspace, _, cx| { - workspace - .show_toast(Toast::new(NotificationId::unique::<()>(), message), cx) + multi_workspace + .update(cx, |multi_workspace, _, cx| { + multi_workspace.workspace().update(cx, |workspace, cx| { + workspace.show_toast( + Toast::new(NotificationId::unique::<()>(), message), + cx, + ) + }); }) .ok(); return true; @@ -1402,10 +1398,25 @@ async fn restore_or_create_workspace(app_state: Arc, cx: &mut AsyncApp Ok(()) } +async fn restorable_workspaces( + cx: &mut AsyncApp, + app_state: &Arc, +) -> Option<( + Vec, + Vec, +)> { + let locations = restorable_workspace_locations(cx, app_state).await?; + let (remote_workspaces, local_workspaces) = locations + .into_iter() + .partition(|sw| matches!(sw.location, SerializedWorkspaceLocation::Remote(_))); + let multi_workspaces = workspace::read_serialized_multi_workspaces(local_workspaces); + Some((multi_workspaces, remote_workspaces)) +} + pub(crate) async fn restorable_workspace_locations( cx: &mut AsyncApp, app_state: &Arc, -) -> Option> { +) -> Option> { let mut restore_behavior = cx.update(|cx| WorkspaceSettings::get(None, cx).restore_on_startup); let session_handle = app_state.session.clone(); @@ -1429,9 +1440,16 @@ pub(crate) async fn restorable_workspace_locations( match restore_behavior { workspace::RestoreOnStartupBehavior::LastWorkspace => { - workspace::last_opened_workspace_location() + workspace::last_opened_workspace_location(app_state.fs.as_ref()) .await - .map(|location| vec![location]) + .map(|(workspace_id, location, paths)| { + vec![SessionWorkspace { + workspace_id, + location, + paths, + window_id: None, + }] + }) } workspace::RestoreOnStartupBehavior::LastSession => { if let Some(last_session_id) = last_session_id { @@ -1440,7 +1458,9 @@ pub(crate) async fn restorable_workspace_locations( let mut locations = workspace::last_session_workspace_locations( &last_session_id, last_session_window_stack, + app_state.fs.as_ref(), ) + .await .filter(|locations| !locations.is_empty()); // Since last_session_window_order returns the windows ordered front-to-back diff --git a/crates/zed/src/visual_test_runner.rs b/crates/zed/src/visual_test_runner.rs index e713c7b440263734fb1202f15ada029f7a3e2cab..68e6dde49ccfe55a582a293030bc368e32f1c67a 100644 --- a/crates/zed/src/visual_test_runner.rs +++ b/crates/zed/src/visual_test_runner.rs @@ -59,6 +59,7 @@ use { }, image::RgbaImage, project_panel::ProjectPanel, + recent_projects::RecentProjectEntry, settings::{NotifyWhenAgentWaiting, Settings as _}, settings_ui::SettingsWindow, std::{ @@ -70,7 +71,7 @@ use { }, util::ResultExt as _, watch, - workspace::{AppState, Workspace}, + workspace::{AppState, MultiWorkspace, Workspace, WorkspaceId}, zed_actions::OpenSettingsAt, }; @@ -435,7 +436,24 @@ fn run_visual_tests(project_path: PathBuf, update_baseline: bool) -> Result<()> } } - // Run Test 3: Agent Thread View tests + // Run Test 3: Multi-workspace sidebar visual tests + println!("\n--- Test 3: multi_workspace_sidebar ---"); + match run_multi_workspace_sidebar_visual_tests(app_state.clone(), &mut cx, update_baseline) { + Ok(TestResult::Passed) => { + println!("✓ multi_workspace_sidebar: PASSED"); + passed += 1; + } + Ok(TestResult::BaselineUpdated(_)) => { + println!("✓ multi_workspace_sidebar: Baselines updated"); + updated += 1; + } + Err(e) => { + eprintln!("✗ multi_workspace_sidebar: FAILED - {}", e); + failed += 1; + } + } + + // Run Test 4: Agent Thread View tests #[cfg(feature = "visual-tests")] { println!("\n--- Test 3: agent_thread_with_image (collapsed + expanded) ---"); @@ -2781,3 +2799,300 @@ fn run_tool_permissions_visual_tests( // Return success - we're just capturing screenshots, not comparing baselines Ok(TestResult::Passed) } + +#[cfg(target_os = "macos")] +fn run_multi_workspace_sidebar_visual_tests( + app_state: Arc, + cx: &mut VisualTestAppContext, + update_baseline: bool, +) -> Result { + // Create temporary directories to act as worktrees for active workspaces + let temp_dir = tempfile::tempdir()?; + let temp_path = temp_dir.keep(); + let canonical_temp = temp_path.canonicalize()?; + + let workspace1_dir = canonical_temp.join("private-test-remote"); + let workspace2_dir = canonical_temp.join("zed"); + std::fs::create_dir_all(&workspace1_dir)?; + std::fs::create_dir_all(&workspace2_dir)?; + + // Create directories for recent projects (they must exist on disk for display) + let recent1_dir = canonical_temp.join("tiny-project"); + let recent2_dir = canonical_temp.join("font-kit"); + let recent3_dir = canonical_temp.join("ideas"); + let recent4_dir = canonical_temp.join("tmp"); + std::fs::create_dir_all(&recent1_dir)?; + std::fs::create_dir_all(&recent2_dir)?; + std::fs::create_dir_all(&recent3_dir)?; + std::fs::create_dir_all(&recent4_dir)?; + + // Enable the agent-v2 feature flag so multi-workspace is active + cx.update(|cx| { + cx.update_flags(true, vec!["agent-v2".to_string()]); + }); + + // Create both projects upfront so we can build both workspaces during + // window creation, before the MultiWorkspace entity exists. + // This avoids a re-entrant read panic that occurs when Workspace::new + // tries to access the window root (MultiWorkspace) while it's being updated. + let project1 = 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, + ) + }); + + let project2 = 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, + ) + }); + + let window_size = size(px(1280.0), px(800.0)); + let bounds = Bounds { + origin: point(px(0.0), px(0.0)), + size: window_size, + }; + + // Open a MultiWorkspace window with both workspaces created at construction time + let multi_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 workspace1 = cx.new(|cx| { + Workspace::new(None, project1.clone(), app_state.clone(), window, cx) + }); + let workspace2 = cx.new(|cx| { + Workspace::new(None, project2.clone(), app_state.clone(), window, cx) + }); + cx.new(|cx| { + let mut multi_workspace = MultiWorkspace::new(workspace1, cx); + multi_workspace.activate(workspace2, cx); + multi_workspace + }) + }, + ) + }) + .context("Failed to open MultiWorkspace window")?; + + cx.run_until_parked(); + + // Add worktree to workspace 1 (index 0) so it shows as "private-test-remote" + let add_worktree1_task = multi_workspace_window + .update(cx, |multi_workspace, _window, cx| { + let workspace1 = &multi_workspace.workspaces()[0]; + let project = workspace1.read(cx).project().clone(); + project.update(cx, |project, cx| { + project.find_or_create_worktree(&workspace1_dir, true, cx) + }) + }) + .context("Failed to start adding worktree 1")?; + + cx.background_executor.allow_parking(); + cx.foreground_executor + .block_test(add_worktree1_task) + .context("Failed to add worktree 1")?; + cx.background_executor.forbid_parking(); + + cx.run_until_parked(); + + // Add worktree to workspace 2 (index 1) so it shows as "zed" + let add_worktree2_task = multi_workspace_window + .update(cx, |multi_workspace, _window, cx| { + let workspace2 = &multi_workspace.workspaces()[1]; + let project = workspace2.read(cx).project().clone(); + project.update(cx, |project, cx| { + project.find_or_create_worktree(&workspace2_dir, true, cx) + }) + }) + .context("Failed to start adding worktree 2")?; + + cx.background_executor.allow_parking(); + cx.foreground_executor + .block_test(add_worktree2_task) + .context("Failed to add worktree 2")?; + cx.background_executor.forbid_parking(); + + cx.run_until_parked(); + + // Switch to workspace 1 so it's highlighted as active (index 0) + multi_workspace_window + .update(cx, |multi_workspace, window, cx| { + multi_workspace.activate_index(0, window, cx); + }) + .context("Failed to activate workspace 1")?; + + cx.run_until_parked(); + + // Create the sidebar and register it on the MultiWorkspace + let sidebar = multi_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")?; + + multi_workspace_window + .update(cx, |multi_workspace, window, cx| { + multi_workspace.register_sidebar(sidebar.clone(), window, cx); + }) + .context("Failed to register sidebar")?; + + cx.run_until_parked(); + + // Inject recent project entries into the sidebar. + // We update the sidebar entity directly (not through the MultiWorkspace window update) + // to avoid a re-entrant read panic: rebuild_entries reads MultiWorkspace, so we can't + // be inside a MultiWorkspace update when that happens. + cx.update(|cx| { + sidebar.update(cx, |sidebar, cx| { + let recent_projects = vec![ + RecentProjectEntry { + name: "tiny-project".into(), + full_path: recent1_dir.to_string_lossy().to_string().into(), + paths: vec![recent1_dir.clone()], + workspace_id: WorkspaceId::default(), + }, + RecentProjectEntry { + name: "font-kit".into(), + full_path: recent2_dir.to_string_lossy().to_string().into(), + paths: vec![recent2_dir.clone()], + workspace_id: WorkspaceId::default(), + }, + RecentProjectEntry { + name: "ideas".into(), + full_path: recent3_dir.to_string_lossy().to_string().into(), + paths: vec![recent3_dir.clone()], + workspace_id: WorkspaceId::default(), + }, + RecentProjectEntry { + name: "tmp".into(), + full_path: recent4_dir.to_string_lossy().to_string().into(), + paths: vec![recent4_dir.clone()], + workspace_id: WorkspaceId::default(), + }, + ]; + sidebar.set_test_recent_projects(recent_projects, cx); + }); + }); + + // Set thread info directly on the sidebar for visual testing + cx.update(|cx| { + sidebar.update(cx, |sidebar, _cx| { + sidebar.set_test_thread_info( + 0, + "Refine thread view scrolling behavior".into(), + sidebar::AgentThreadStatus::Completed, + ); + sidebar.set_test_thread_info( + 1, + "Add line numbers option to FileEditBlock".into(), + sidebar::AgentThreadStatus::Running, + ); + }); + }); + + // Set last-worked-on thread titles on some recent projects for visual testing + cx.update(|cx| { + sidebar.update(cx, |sidebar, cx| { + sidebar.set_test_recent_project_thread_title( + recent1_dir.to_string_lossy().to_string().into(), + "Fix flaky test in CI pipeline".into(), + cx, + ); + sidebar.set_test_recent_project_thread_title( + recent2_dir.to_string_lossy().to_string().into(), + "Upgrade font rendering engine".into(), + cx, + ); + }); + }); + + cx.run_until_parked(); + + // Open the sidebar + multi_workspace_window + .update(cx, |multi_workspace, window, cx| { + multi_workspace.toggle_sidebar(window, cx); + }) + .context("Failed to toggle sidebar")?; + + // Let rendering settle + for _ in 0..10 { + cx.advance_clock(Duration::from_millis(100)); + cx.run_until_parked(); + } + + // Refresh the window + cx.update_window(multi_workspace_window.into(), |_, window, _cx| { + window.refresh(); + })?; + + cx.run_until_parked(); + + // Capture: sidebar open with active workspaces and recent projects + let test_result = run_visual_test( + "multi_workspace_sidebar_open", + multi_workspace_window.into(), + cx, + update_baseline, + )?; + + // Clean up worktrees + multi_workspace_window + .update(cx, |multi_workspace, _window, cx| { + for workspace in multi_workspace.workspaces() { + 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(); + + // Close the window + cx.update_window(multi_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(); + } + + Ok(test_result) +} diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index 6e80f13cec5bebe67062aed2a2f722af4269b2e1..380594c3ddc06e05f150f8ce3b9babe8e8a3a0d7 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -68,6 +68,7 @@ use settings::{ initial_local_debug_tasks_content, initial_project_settings_content, initial_tasks_content, update_settings_file, }; +use sidebar::Sidebar; use std::time::Duration; use std::{ borrow::Cow, @@ -88,9 +89,9 @@ use workspace::notifications::{ }; use workspace::utility_pane::utility_slot_for_dock_position; use workspace::{ - AppState, NewFile, NewWindow, OpenLog, Panel, Toast, Workspace, WorkspaceSettings, - create_and_open_local_file, notifications::simple_message_notification::MessageNotification, - open_new, + AppState, MultiWorkspace, NewFile, NewWindow, OpenLog, Panel, Toast, Workspace, + WorkspaceSettings, create_and_open_local_file, + notifications::simple_message_notification::MessageNotification, open_new, }; use workspace::{ CloseIntent, CloseProject, CloseWindow, NotificationFrame, RestoreBanner, @@ -370,6 +371,16 @@ pub fn initialize_workspace( }) .detach(); + cx.observe_new(|multi_workspace: &mut MultiWorkspace, window, cx| { + let Some(window) = window else { + return; + }; + let multi_workspace_handle = cx.entity(); + let sidebar = cx.new(|cx| Sidebar::new(multi_workspace_handle, window, cx)); + multi_workspace.register_sidebar(sidebar, window, cx); + }) + .detach(); + cx.observe_new(move |workspace: &mut Workspace, window, cx| { let Some(window) = window else { return; @@ -1152,7 +1163,7 @@ fn register_actions( .register_action({ let app_state = Arc::downgrade(&app_state); move |_, _: &CloseProject, window, cx| { - let Some(window_handle) = window.window_handle().downcast::() else { + let Some(window_handle) = window.window_handle().downcast::() else { return; }; if let Some(app_state) = app_state.upgrade() { @@ -1248,6 +1259,7 @@ fn initialize_pane( window: &mut Window, cx: &mut Context, ) { + let workspace_handle = cx.weak_entity(); pane.update(cx, |pane, cx| { pane.toolbar().update(cx, |toolbar, cx| { let multibuffer_hint = cx.new(|_| MultibufferHint::new()); @@ -1280,11 +1292,12 @@ fn initialize_pane( toolbar.add_item(telemetry_log_item, window, cx); let syntax_tree_item = cx.new(|_| language_tools::SyntaxTreeToolbarItemView::new()); toolbar.add_item(syntax_tree_item, window, cx); + let migration_banner = + cx.new(|inner_cx| MigrationBanner::new(workspace_handle.clone(), inner_cx)); + toolbar.add_item(migration_banner, window, cx); let highlights_tree_item = cx.new(|_| language_tools::HighlightsTreeToolbarItemView::new()); toolbar.add_item(highlights_tree_item, window, cx); - let migration_banner = cx.new(|cx| MigrationBanner::new(workspace, cx)); - toolbar.add_item(migration_banner, window, cx); let project_diff_toolbar = cx.new(|cx| ProjectDiffToolbar::new(workspace, cx)); toolbar.add_item(project_diff_toolbar, window, cx); let branch_diff_toolbar = cx.new(BranchDiffToolbar::new); @@ -1359,10 +1372,10 @@ fn quit(_: &Quit, cx: &mut App) { let should_confirm = WorkspaceSettings::get_global(cx).confirm_quit; cx.spawn(async move |cx| { - let mut workspace_windows: Vec> = cx.update(|cx| { + let mut workspace_windows: Vec> = cx.update(|cx| { cx.windows() .into_iter() - .filter_map(|window| window.downcast::()) + .filter_map(|window| window.downcast::()) .collect::>() }); @@ -1372,8 +1385,8 @@ fn quit(_: &Quit, cx: &mut App) { workspace_windows.sort_by_key(|window| window.is_active(cx) == Some(false)); }); - if should_confirm && let Some(workspace) = workspace_windows.first() { - let answer = workspace + if should_confirm && let Some(multi_workspace) = workspace_windows.first() { + let answer = multi_workspace .update(cx, |_, window, cx| { window.prompt( PromptLevel::Info, @@ -1397,14 +1410,30 @@ fn quit(_: &Quit, cx: &mut App) { // If the user cancels any save prompt, then keep the app open. for window in workspace_windows { - if let Some(should_close) = window - .update(cx, |workspace, window, cx| { - workspace.prepare_to_close(CloseIntent::Quit, window, cx) + let workspaces = window + .update(cx, |multi_workspace, _, _| { + multi_workspace.workspaces().to_vec() }) - .log_err() - { - if !should_close.await? { - return Ok(()); + .log_err(); + + let Some(workspaces) = workspaces else { + continue; + }; + + for workspace in workspaces { + if let Some(should_close) = window + .update(cx, |multi_workspace, window, cx| { + multi_workspace.activate(workspace.clone(), cx); + window.activate_window(); + workspace.update(cx, |workspace, cx| { + workspace.prepare_to_close(CloseIntent::Quit, window, cx) + }) + }) + .log_err() + { + if !should_close.await? { + return Ok(()); + } } } } @@ -2356,6 +2385,7 @@ mod tests { use settings::{SaturatingBool, SettingsStore, watch_config_file}; use std::{ path::{Path, PathBuf}, + sync::Arc, time::Duration, }; use theme::ThemeRegistry; @@ -2363,6 +2393,7 @@ mod tests { path, rel_path::{RelPath, rel_path}, }; + use workspace::MultiWorkspace; use workspace::{ NewFile, OpenOptions, OpenVisible, SERIALIZATION_THROTTLE_TIME, SaveIntent, SplitDirection, WorkspaceHandle, @@ -2398,10 +2429,12 @@ mod tests { .unwrap(); assert_eq!(cx.read(|cx| cx.windows().len()), 1); - let workspace = cx.windows()[0].downcast::().unwrap(); - workspace - .update(cx, |workspace, _, cx| { - assert!(workspace.active_item_as::(cx).is_some()) + let multi_workspace = cx.windows()[0].downcast::().unwrap(); + multi_workspace + .update(cx, |multi_workspace, _, cx| { + multi_workspace.workspace().update(cx, |workspace, cx| { + assert!(workspace.active_item_as::(cx).is_some()) + }); }) .unwrap(); } @@ -2409,6 +2442,10 @@ mod tests { #[gpui::test] async fn test_open_paths_action(cx: &mut TestAppContext) { let app_state = init_test(cx); + cx.update(|cx| { + use feature_flags::FeatureFlagAppExt as _; + cx.update_flags(false, vec!["agent-v2".to_string()]); + }); app_state .fs .as_fake() @@ -2462,21 +2499,23 @@ mod tests { .await .unwrap(); assert_eq!(cx.read(|cx| cx.windows().len()), 1); - let workspace_1 = cx - .read(|cx| cx.windows()[0].downcast::()) + let multi_workspace_1 = cx + .read(|cx| cx.windows()[0].downcast::()) .unwrap(); cx.run_until_parked(); - workspace_1 - .update(cx, |workspace, window, cx| { - assert_eq!(workspace.worktrees(cx).count(), 2); - assert!(workspace.left_dock().read(cx).is_open()); - assert!( - workspace - .active_pane() - .read(cx) - .focus_handle(cx) - .is_focused(window) - ); + multi_workspace_1 + .update(cx, |multi_workspace, window, cx| { + multi_workspace.workspace().update(cx, |workspace, cx| { + assert_eq!(workspace.worktrees(cx).count(), 2); + assert!(workspace.left_dock().read(cx).is_open()); + assert!( + workspace + .active_pane() + .read(cx) + .focus_handle(cx) + .is_focused(window) + ); + }); }) .unwrap(); @@ -2494,7 +2533,7 @@ mod tests { // Replace existing windows let window = cx - .update(|cx| cx.windows()[0].downcast::()) + .update(|cx| cx.windows()[0].downcast::()) .unwrap(); cx.update(|cx| { open_paths( @@ -2511,11 +2550,12 @@ mod tests { .unwrap(); cx.background_executor.run_until_parked(); assert_eq!(cx.read(|cx| cx.windows().len()), 2); - let workspace_1 = cx - .update(|cx| cx.windows()[0].downcast::()) + let multi_workspace_1 = cx + .update(|cx| cx.windows()[0].downcast::()) .unwrap(); - workspace_1 - .update(cx, |workspace, window, cx| { + multi_workspace_1 + .update(cx, |multi_workspace, window, cx| { + let workspace = multi_workspace.workspace().read(cx); assert_eq!( workspace .worktrees(cx) @@ -2687,17 +2727,21 @@ mod tests { assert_eq!(cx.update(|cx| cx.windows().len()), 1); // When opening the workspace, the window is not in a edited state. - let window = cx.update(|cx| cx.windows()[0].downcast::().unwrap()); + let window = cx.update(|cx| cx.windows()[0].downcast::().unwrap()); - let window_is_edited = |window: WindowHandle, cx: &mut TestAppContext| { - cx.update(|cx| window.read(cx).unwrap().is_edited()) + let window_is_edited = |window: WindowHandle, cx: &mut TestAppContext| { + cx.update(|cx| window.read(cx).unwrap().workspace().read(cx).is_edited()) }; let pane = window - .read_with(cx, |workspace, _| workspace.active_pane().clone()) + .read_with(cx, |multi_workspace, cx| { + multi_workspace.workspace().read(cx).active_pane().clone() + }) .unwrap(); let editor = window - .read_with(cx, |workspace, cx| { - workspace + .read_with(cx, |multi_workspace, cx| { + multi_workspace + .workspace() + .read(cx) .active_item(cx) .unwrap() .downcast::() @@ -2770,22 +2814,26 @@ mod tests { executor.run_until_parked(); window - .update(cx, |workspace, _, cx| { - let editor = workspace - .active_item(cx) - .unwrap() - .downcast::() - .unwrap(); + .update(cx, |multi_workspace, _, cx| { + multi_workspace.workspace().update(cx, |workspace, cx| { + let editor = workspace + .active_item(cx) + .unwrap() + .downcast::() + .unwrap(); - editor.update(cx, |editor, cx| { - assert_eq!(editor.text(cx), "hey"); + editor.update(cx, |editor, cx| { + assert_eq!(editor.text(cx), "hey"); + }); }); }) .unwrap(); let editor = window - .read_with(cx, |workspace, cx| { - workspace + .read_with(cx, |multi_workspace, cx| { + multi_workspace + .workspace() + .read(cx) .active_item(cx) .unwrap() .downcast::() @@ -2838,15 +2886,17 @@ mod tests { assert_eq!(cx.update(|cx| cx.windows().len()), 1); // When opening the workspace, the window is not in a edited state. - let window = cx.update(|cx| cx.windows()[0].downcast::().unwrap()); + let window = cx.update(|cx| cx.windows()[0].downcast::().unwrap()); - let window_is_edited = |window: WindowHandle, cx: &mut TestAppContext| { - cx.update(|cx| window.read(cx).unwrap().is_edited()) + let window_is_edited = |window: WindowHandle, cx: &mut TestAppContext| { + cx.update(|cx| window.read(cx).unwrap().workspace().read(cx).is_edited()) }; let editor = window - .read_with(cx, |workspace, cx| { - workspace + .read_with(cx, |multi_workspace, cx| { + multi_workspace + .workspace() + .read(cx) .active_item(cx) .unwrap() .downcast::() @@ -2893,22 +2943,27 @@ mod tests { cx.run_until_parked(); // When opening the workspace, the window is not in a edited state. - let window = cx.update(|cx| cx.active_window().unwrap().downcast::().unwrap()); + let window = cx.update(|cx| { + cx.active_window() + .unwrap() + .downcast::() + .unwrap() + }); assert!(window_is_edited(window, cx)); window - .update(cx, |workspace, _, cx| { - let editor = workspace - .active_item(cx) - .unwrap() - .downcast::() - .unwrap(); - editor.update(cx, |editor, cx| { - assert_eq!(editor.text(cx), "EDIThey"); - assert!(editor.is_dirty(cx)); + .update(cx, |multi_workspace, _, cx| { + multi_workspace.workspace().update(cx, |workspace, cx| { + let editor = workspace + .active_item(cx) + .unwrap() + .downcast::() + .unwrap(); + editor.update(cx, |editor, cx| { + assert_eq!(editor.text(cx), "EDIThey"); + assert!(editor.is_dirty(cx)); + }); }); - - editor }) .unwrap(); } @@ -2930,36 +2985,40 @@ mod tests { .unwrap(); cx.run_until_parked(); - let workspace = cx - .update(|cx| cx.windows().first().unwrap().downcast::()) + let multi_workspace = cx + .update(|cx| cx.windows().first().unwrap().downcast::()) .unwrap(); - let editor = workspace - .update(cx, |workspace, _, cx| { - let editor = workspace - .active_item(cx) - .unwrap() - .downcast::() - .unwrap(); - editor.update(cx, |editor, cx| { - assert!(editor.text(cx).is_empty()); - assert!(!editor.is_dirty(cx)); - }); + let editor = multi_workspace + .update(cx, |multi_workspace, _, cx| { + multi_workspace.workspace().update(cx, |workspace, cx| { + let editor = workspace + .active_item(cx) + .unwrap() + .downcast::() + .unwrap(); + editor.update(cx, |editor, cx| { + assert!(editor.text(cx).is_empty()); + assert!(!editor.is_dirty(cx)); + }); - editor + editor + }) }) .unwrap(); - let save_task = workspace - .update(cx, |workspace, window, cx| { - workspace.save_active_item(SaveIntent::Save, window, cx) + let save_task = multi_workspace + .update(cx, |multi_workspace, window, cx| { + multi_workspace.workspace().update(cx, |workspace, cx| { + workspace.save_active_item(SaveIntent::Save, window, cx) + }) }) .unwrap(); app_state.fs.create_dir(Path::new("/root")).await.unwrap(); cx.background_executor.run_until_parked(); cx.simulate_new_path_selection(|_| Some(PathBuf::from("/root/the-new-name"))); save_task.await.unwrap(); - workspace + multi_workspace .update(cx, |_, _, cx| { editor.update(cx, |editor, cx| { assert!(!editor.is_dirty(cx)); @@ -3140,8 +3199,10 @@ mod tests { .unwrap(); cx.run_until_parked(); assert_eq!(cx.update(|cx| cx.windows().len()), 1); - let window = cx.update(|cx| cx.windows()[0].downcast::().unwrap()); - let workspace = window.root(cx).unwrap(); + let window = cx.update(|cx| cx.windows()[0].downcast::().unwrap()); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); #[track_caller] fn assert_project_panel_selection( @@ -3176,17 +3237,19 @@ mod tests { // Open a file within an existing worktree. window - .update(cx, |workspace, window, cx| { - workspace.open_paths( - vec![path!("/dir1/a.txt").into()], - OpenOptions { - visible: Some(OpenVisible::All), - ..Default::default() - }, - None, - window, - cx, - ) + .update(cx, |multi_workspace, window, cx| { + multi_workspace.workspace().update(cx, |workspace, cx| { + workspace.open_paths( + vec![path!("/dir1/a.txt").into()], + OpenOptions { + visible: Some(OpenVisible::All), + ..Default::default() + }, + None, + window, + cx, + ) + }) }) .unwrap() .await; @@ -3215,17 +3278,19 @@ mod tests { // Open a file outside of any existing worktree. window - .update(cx, |workspace, window, cx| { - workspace.open_paths( - vec![path!("/dir2/b.txt").into()], - OpenOptions { - visible: Some(OpenVisible::All), - ..Default::default() - }, - None, - window, - cx, - ) + .update(cx, |multi_workspace, window, cx| { + multi_workspace.workspace().update(cx, |workspace, cx| { + workspace.open_paths( + vec![path!("/dir2/b.txt").into()], + OpenOptions { + visible: Some(OpenVisible::All), + ..Default::default() + }, + None, + window, + cx, + ) + }) }) .unwrap() .await; @@ -3265,17 +3330,19 @@ mod tests { // Ensure opening a directory and one of its children only adds one worktree. window - .update(cx, |workspace, window, cx| { - workspace.open_paths( - vec![path!("/dir3").into(), path!("/dir3/c.txt").into()], - OpenOptions { - visible: Some(OpenVisible::All), - ..Default::default() - }, - None, - window, - cx, - ) + .update(cx, |multi_workspace, window, cx| { + multi_workspace.workspace().update(cx, |workspace, cx| { + workspace.open_paths( + vec![path!("/dir3").into(), path!("/dir3/c.txt").into()], + OpenOptions { + visible: Some(OpenVisible::All), + ..Default::default() + }, + None, + window, + cx, + ) + }) }) .unwrap() .await; @@ -3315,17 +3382,19 @@ mod tests { // Ensure opening invisibly a file outside an existing worktree adds a new, invisible worktree. window - .update(cx, |workspace, window, cx| { - workspace.open_paths( - vec![path!("/d.txt").into()], - OpenOptions { - visible: Some(OpenVisible::None), - ..Default::default() - }, - None, - window, - cx, - ) + .update(cx, |multi_workspace, window, cx| { + multi_workspace.workspace().update(cx, |workspace, cx| { + workspace.open_paths( + vec![path!("/d.txt").into()], + OpenOptions { + visible: Some(OpenVisible::None), + ..Default::default() + }, + None, + window, + cx, + ) + }) }) .unwrap() .await; @@ -3419,8 +3488,13 @@ mod tests { let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await; project.update(cx, |project, _cx| project.languages().add(markdown_lang())); - let window = cx.add_window(|window, cx| Workspace::test_new(project, window, cx)); - let workspace = window.root(cx).unwrap(); + let window = cx.add_window({ + let project = project.clone(); + |window, cx| MultiWorkspace::test_new(project, window, cx) + }); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); let initial_entries = cx.read(|cx| workspace.file_project_paths(cx)); let paths_to_open = [ @@ -3441,7 +3515,9 @@ mod tests { .unwrap(); assert_eq!( - opened_workspace.root(cx).unwrap().entity_id(), + opened_workspace + .read_with(cx, |mw, _| mw.workspace().entity_id()) + .unwrap(), workspace.entity_id(), "Excluded files in subfolders of a workspace root should be opened in the workspace" ); @@ -4864,6 +4940,7 @@ mod tests { "lsp_tool", "markdown", "menu", + "multi_workspace", "new_process_modal", "notebook", "notification_panel", @@ -4951,7 +5028,7 @@ mod tests { cx.update(init); let project = Project::test(app_state.fs.clone(), [], cx).await; - let _window = cx.add_window(|window, cx| Workspace::test_new(project, window, cx)); + let _window = cx.add_window(|window, cx| MultiWorkspace::test_new(project, window, cx)); cx.update(|cx| { cx.dispatch_action(&OpenDefaultSettings); @@ -4960,10 +5037,12 @@ mod tests { assert_eq!(cx.read(|cx| cx.windows().len()), 1); - let workspace = cx.windows()[0].downcast::().unwrap(); - let active_editor = workspace - .update(cx, |workspace, _, cx| { - workspace.active_item_as::(cx) + let multi_workspace = cx.windows()[0].downcast::().unwrap(); + let active_editor = multi_workspace + .update(cx, |multi_workspace, _, cx| { + multi_workspace + .workspace() + .update(cx, |workspace, cx| workspace.active_item_as::(cx)) }) .unwrap(); assert!( @@ -5267,16 +5346,22 @@ mod tests { .await; let project_a = Project::test(app_state.fs.clone(), [path!("/dir").as_ref()], cx).await; - let window_a = - cx.add_window(|window, cx| Workspace::test_new(project_a.clone(), window, cx)); + let window_a = cx.add_window({ + let project = project_a.clone(); + |window, cx| MultiWorkspace::test_new(project, window, cx) + }); let project_b = Project::test(app_state.fs.clone(), [path!("/dir").as_ref()], cx).await; - let window_b = - cx.add_window(|window, cx| Workspace::test_new(project_b.clone(), window, cx)); + let window_b = cx.add_window({ + let project = project_b.clone(); + |window, cx| MultiWorkspace::test_new(project, window, cx) + }); let project_c = Project::test(app_state.fs.clone(), [path!("/dir").as_ref()], cx).await; - let window_c = - cx.add_window(|window, cx| Workspace::test_new(project_c.clone(), window, cx)); + let window_c = cx.add_window({ + let project = project_c.clone(); + |window, cx| MultiWorkspace::test_new(project, window, cx) + }); for window in [window_a, window_b, window_c] { let _ = cx.update_window(*window, |_, window, _| { @@ -5297,8 +5382,8 @@ mod tests { cx.update_window(*window, |_, window, _| assert!(window.is_window_active())) .unwrap(); - let _ = window.read_with(cx, |workspace, cx| { - let pane = workspace.active_pane().read(cx); + let _ = window.read_with(cx, |multi_workspace, cx| { + let pane = multi_workspace.workspace().read(cx).active_pane().read(cx); let project_path = pane.active_item().unwrap().project_path(cx).unwrap(); assert_eq!( @@ -5308,4 +5393,707 @@ mod tests { }); } } + + #[gpui::test] + async fn test_open_paths_switches_to_best_workspace(cx: &mut TestAppContext) { + let app_state = init_test(cx); + cx.update(|cx| { + use feature_flags::FeatureFlagAppExt as _; + cx.update_flags(false, vec!["agent-v2".to_string()]); + }); + + app_state + .fs + .as_fake() + .insert_tree( + path!("/"), + json!({ + "dir1": { + "a.txt": "content a" + }, + "dir2": { + "b.txt": "content b" + }, + "dir3": { + "c.txt": "content c" + } + }), + ) + .await; + + // Create a window with workspace 0 containing /dir1 + let project1 = Project::test(app_state.fs.clone(), [path!("/dir1").as_ref()], cx).await; + + let window = cx.add_window({ + let project = project1.clone(); + |window, cx| MultiWorkspace::test_new(project, window, cx) + }); + + cx.run_until_parked(); + assert_eq!(cx.windows().len(), 1, "Should start with 1 window"); + + // Create workspace 2 with /dir2 + let project2 = Project::test(app_state.fs.clone(), [path!("/dir2").as_ref()], cx).await; + let workspace2 = window + .update(cx, |_, window, cx| { + cx.new(|cx| Workspace::test_new(project2.clone(), window, cx)) + }) + .unwrap(); + + // Create workspace 3 with /dir3 + let project3 = Project::test(app_state.fs.clone(), [path!("/dir3").as_ref()], cx).await; + let workspace3 = window + .update(cx, |_, window, cx| { + cx.new(|cx| Workspace::test_new(project3.clone(), window, cx)) + }) + .unwrap(); + + let workspace1 = window + .read_with(cx, |multi_workspace, _| multi_workspace.workspace().clone()) + .unwrap(); + + window + .update(cx, |multi_workspace, _, cx| { + multi_workspace.activate(workspace2.clone(), cx); + multi_workspace.activate(workspace3.clone(), cx); + // Switch back to workspace1 for test setup + multi_workspace.activate(workspace1, cx); + assert_eq!(multi_workspace.active_workspace_index(), 0); + }) + .unwrap(); + + cx.run_until_parked(); + + // Verify setup: 3 workspaces, workspace 0 active, still 1 window + window + .read_with(cx, |multi_workspace, _| { + assert_eq!(multi_workspace.workspaces().len(), 3); + assert_eq!(multi_workspace.active_workspace_index(), 0); + }) + .unwrap(); + assert_eq!(cx.windows().len(), 1); + + // Open a file in /dir3 - should switch to workspace 3 (not just "the other one") + cx.update(|cx| { + open_paths( + &[PathBuf::from(path!("/dir3/c.txt"))], + app_state.clone(), + OpenOptions::default(), + cx, + ) + }) + .await + .unwrap(); + + cx.run_until_parked(); + + // Verify workspace 2 is active and file opened there + window + .read_with(cx, |multi_workspace, cx| { + assert_eq!( + multi_workspace.active_workspace_index(), + 2, + "Should have switched to workspace 3 which contains /dir3" + ); + let active_item = multi_workspace + .workspace() + .read(cx) + .active_pane() + .read(cx) + .active_item() + .expect("Should have an active item"); + assert_eq!(active_item.tab_content_text(0, cx), "c.txt"); + }) + .unwrap(); + assert_eq!(cx.windows().len(), 1, "Should reuse existing window"); + + // Open a file in /dir2 - should switch to workspace 2 + cx.update(|cx| { + open_paths( + &[PathBuf::from(path!("/dir2/b.txt"))], + app_state.clone(), + OpenOptions::default(), + cx, + ) + }) + .await + .unwrap(); + + cx.run_until_parked(); + + // Verify workspace 1 is active and file opened there + window + .read_with(cx, |multi_workspace, cx| { + assert_eq!( + multi_workspace.active_workspace_index(), + 1, + "Should have switched to workspace 2 which contains /dir2" + ); + let active_item = multi_workspace + .workspace() + .read(cx) + .active_pane() + .read(cx) + .active_item() + .expect("Should have an active item"); + assert_eq!(active_item.tab_content_text(0, cx), "b.txt"); + }) + .unwrap(); + + // Verify c.txt is still in workspace 3 (file opened in correct workspace, not active one) + workspace3.read_with(cx, |workspace, cx| { + let active_item = workspace + .active_pane() + .read(cx) + .active_item() + .expect("Workspace 2 should have an active item"); + assert_eq!( + active_item.tab_content_text(0, cx), + "c.txt", + "c.txt should have been opened in workspace 3, not the active workspace" + ); + }); + + assert_eq!(cx.windows().len(), 1, "Should still have only 1 window"); + + // Open a file in /dir1 - should switch back to workspace 0 + cx.update(|cx| { + open_paths( + &[PathBuf::from(path!("/dir1/a.txt"))], + app_state.clone(), + OpenOptions::default(), + cx, + ) + }) + .await + .unwrap(); + + cx.run_until_parked(); + + // Verify workspace 0 is active and file opened there + window + .read_with(cx, |multi_workspace, cx| { + assert_eq!( + multi_workspace.active_workspace_index(), + 0, + "Should have switched back to workspace 0 which contains /dir1" + ); + let active_item = multi_workspace + .workspace() + .read(cx) + .active_pane() + .read(cx) + .active_item() + .expect("Should have an active item"); + assert_eq!(active_item.tab_content_text(0, cx), "a.txt"); + }) + .unwrap(); + assert_eq!(cx.windows().len(), 1, "Should still have only 1 window"); + } + + #[gpui::test] + async fn test_quit_checks_all_workspaces_for_dirty_items(cx: &mut TestAppContext) { + let app_state = init_test(cx); + cx.update(init); + cx.update(|cx| { + use feature_flags::FeatureFlagAppExt as _; + cx.update_flags(false, vec!["agent-v2".to_string()]); + }); + + app_state + .fs + .as_fake() + .insert_tree( + path!("/"), + json!({ + "dir1": { + "a.txt": "content a" + }, + "dir2": { + "b.txt": "content b" + }, + "dir3": { + "c.txt": "content c" + } + }), + ) + .await; + + // === Setup Window 1 with two workspaces === + let project1 = Project::test(app_state.fs.clone(), [path!("/dir1").as_ref()], cx).await; + let window1 = cx.add_window({ + let project = project1.clone(); + |window, cx| MultiWorkspace::test_new(project, window, cx) + }); + + cx.run_until_parked(); + + let project2 = Project::test(app_state.fs.clone(), [path!("/dir2").as_ref()], cx).await; + let workspace1_1 = window1 + .read_with(cx, |multi_workspace, _| multi_workspace.workspace().clone()) + .unwrap(); + let workspace1_2 = window1 + .update(cx, |_, window, cx| { + cx.new(|cx| Workspace::test_new(project2.clone(), window, cx)) + }) + .unwrap(); + + window1 + .update(cx, |multi_workspace, _, cx| { + multi_workspace.activate(workspace1_2.clone(), cx); + multi_workspace.activate(workspace1_1.clone(), cx); + }) + .unwrap(); + + // === Setup Window 2 with one workspace === + let project3 = Project::test(app_state.fs.clone(), [path!("/dir3").as_ref()], cx).await; + let window2 = cx.add_window({ + let project = project3.clone(); + |window, cx| MultiWorkspace::test_new(project, window, cx) + }); + + cx.run_until_parked(); + assert_eq!(cx.windows().len(), 2); + + // === Case 1: Active workspace has dirty item, quit can be cancelled === + let worktree1_id = project1.update(cx, |project, cx| { + project.worktrees(cx).next().unwrap().read(cx).id() + }); + + let editor1 = window1 + .update(cx, |_, window, cx| { + workspace1_1.update(cx, |workspace, cx| { + workspace.open_path((worktree1_id, rel_path("a.txt")), None, true, window, cx) + }) + }) + .unwrap() + .await + .unwrap() + .downcast::() + .unwrap(); + + window1 + .update(cx, |_, window, cx| { + editor1.update(cx, |editor, cx| { + editor.insert("dirty in active workspace", window, cx); + }); + }) + .unwrap(); + + cx.run_until_parked(); + + // Verify workspace1_1 is active + window1 + .read_with(cx, |multi_workspace, _| { + assert_eq!(multi_workspace.active_workspace_index(), 0); + }) + .unwrap(); + + cx.dispatch_action(*window1, Quit); + cx.run_until_parked(); + + assert!( + cx.has_pending_prompt(), + "Case 1: Should prompt to save dirty item in active workspace" + ); + + cx.simulate_prompt_answer("Cancel"); + cx.run_until_parked(); + + assert_eq!( + cx.windows().len(), + 2, + "Case 1: Windows should still exist after cancelling quit" + ); + + // Clean up Case 1: Close the dirty item without saving + let close_task = window1 + .update(cx, |_, window, cx| { + workspace1_1.update(cx, |workspace, cx| { + workspace.active_pane().update(cx, |pane, cx| { + pane.close_active_item(&Default::default(), window, cx) + }) + }) + }) + .unwrap(); + cx.run_until_parked(); + cx.simulate_prompt_answer("Don't Save"); + close_task.await.ok(); + cx.run_until_parked(); + + // === Case 2: Non-active workspace (same window) has dirty item === + let worktree2_id = project2.update(cx, |project, cx| { + project.worktrees(cx).next().unwrap().read(cx).id() + }); + + let editor2 = window1 + .update(cx, |_, window, cx| { + workspace1_2.update(cx, |workspace, cx| { + workspace.open_path((worktree2_id, rel_path("b.txt")), None, true, window, cx) + }) + }) + .unwrap() + .await + .unwrap() + .downcast::() + .unwrap(); + + window1 + .update(cx, |_, window, cx| { + editor2.update(cx, |editor, cx| { + editor.insert("dirty in non-active workspace", window, cx); + }); + }) + .unwrap(); + + cx.run_until_parked(); + + // Verify workspace1_1 is still active (not workspace1_2 with dirty item) + window1 + .read_with(cx, |multi_workspace, _| { + assert_eq!(multi_workspace.active_workspace_index(), 0); + }) + .unwrap(); + + cx.dispatch_action(*window1, Quit); + cx.run_until_parked(); + + // Verify the non-active workspace got activated to show the dirty item + window1 + .read_with(cx, |multi_workspace, _| { + assert_eq!( + multi_workspace.active_workspace_index(), + 1, + "Case 2: Non-active workspace should be activated when it has dirty item" + ); + }) + .unwrap(); + + assert!( + cx.has_pending_prompt(), + "Case 2: Should prompt to save dirty item in non-active workspace" + ); + + cx.simulate_prompt_answer("Cancel"); + cx.run_until_parked(); + + assert_eq!( + cx.windows().len(), + 2, + "Case 2: Windows should still exist after cancelling quit" + ); + + // Clean up Case 2: Close the dirty item without saving + let close_task = window1 + .update(cx, |_, window, cx| { + workspace1_2.update(cx, |workspace, cx| { + workspace.active_pane().update(cx, |pane, cx| { + pane.close_active_item(&Default::default(), window, cx) + }) + }) + }) + .unwrap(); + cx.run_until_parked(); + cx.simulate_prompt_answer("Don't Save"); + close_task.await.ok(); + cx.run_until_parked(); + + // === Case 3: Non-active window has dirty item === + let workspace3 = window2 + .read_with(cx, |multi_workspace, _| multi_workspace.workspace().clone()) + .unwrap(); + + let worktree3_id = project3.update(cx, |project, cx| { + project.worktrees(cx).next().unwrap().read(cx).id() + }); + + let editor3 = window2 + .update(cx, |_, window, cx| { + workspace3.update(cx, |workspace, cx| { + workspace.open_path((worktree3_id, rel_path("c.txt")), None, true, window, cx) + }) + }) + .unwrap() + .await + .unwrap() + .downcast::() + .unwrap(); + + window2 + .update(cx, |_, window, cx| { + editor3.update(cx, |editor, cx| { + editor.insert("dirty in other window", window, cx); + }); + }) + .unwrap(); + + cx.run_until_parked(); + + // Activate window1 explicitly (editing in window2 may have activated it) + window1 + .update(cx, |_, window, _| window.activate_window()) + .unwrap(); + cx.run_until_parked(); + + // Verify window2 is not active (window1 should still be active) + assert_eq!( + cx.update(|cx| window2.is_active(cx)), + Some(false), + "Case 3: window2 should not be active before quit" + ); + + // Dispatch quit from window1 (window2 has the dirty item) + cx.dispatch_action(*window1, Quit); + cx.run_until_parked(); + + // Verify window2 is now active (quit handler activated it to show dirty item) + assert_eq!( + cx.update(|cx| window2.is_active(cx)), + Some(true), + "Case 3: window2 should be activated when it has dirty item" + ); + + assert!( + cx.has_pending_prompt(), + "Case 3: Should prompt to save dirty item in non-active window" + ); + + cx.simulate_prompt_answer("Cancel"); + cx.run_until_parked(); + + assert_eq!( + cx.windows().len(), + 2, + "Case 3: Windows should still exist after cancelling quit" + ); + } + + #[gpui::test] + async fn test_multi_workspace_session_restore(cx: &mut TestAppContext) { + use collections::HashMap; + use session::Session; + use workspace::{Workspace, WorkspaceId}; + + let app_state = init_test(cx); + + cx.update(|cx| { + use feature_flags::FeatureFlagAppExt as _; + cx.update_flags(false, vec!["agent-v2".to_string()]); + }); + + let dir1 = path!("/dir1"); + let dir2 = path!("/dir2"); + let dir3 = path!("/dir3"); + + let fs = app_state.fs.clone(); + let fake_fs = fs.as_fake(); + fake_fs.insert_tree(dir1, json!({})).await; + fake_fs.insert_tree(dir2, json!({})).await; + fake_fs.insert_tree(dir3, json!({})).await; + + let session_id = cx.read(|cx| app_state.session.read(cx).id().to_owned()); + + // --- Create 3 workspaces in 2 windows --- + // + // Window A: workspace for dir1, workspace for dir2 + // Window B: workspace for dir3 + let (window_a, _) = cx + .update(|cx| { + Workspace::new_local(vec![dir1.into()], app_state.clone(), None, None, None, cx) + }) + .await + .expect("failed to open first workspace"); + + window_a + .update(cx, |multi_workspace, window, cx| { + multi_workspace.open_project(vec![dir2.into()], window, cx) + }) + .unwrap() + .await + .expect("failed to open second workspace into window A"); + cx.run_until_parked(); + + let (window_b, _) = cx + .update(|cx| { + Workspace::new_local(vec![dir3.into()], app_state.clone(), None, None, None, cx) + }) + .await + .expect("failed to open third workspace"); + + // Currently dir2 is active because it was added last. + // So, switch window_a's active workspace to dir1 (index 0). + // This sets up a non-trivial assertion: after restore, dir1 should + // still be active rather than whichever workspace happened to restore last. + window_a + .update(cx, |multi_workspace, window, cx| { + multi_workspace.activate_index(0, window, cx); + }) + .unwrap(); + + // --- Flush serialization --- + cx.executor().advance_clock(SERIALIZATION_THROTTLE_TIME); + cx.run_until_parked(); + + // Verify all workspaces retained their session_ids. + let locations = workspace::last_session_workspace_locations(&session_id, None, fs.as_ref()) + .await + .expect("expected session workspace locations"); + assert_eq!( + locations.len(), + 3, + "all 3 workspaces should have session_ids in the DB" + ); + + // Close the original windows. + window_a + .update(cx, |_, window, _| window.remove_window()) + .unwrap(); + window_b + .update(cx, |_, window, _| window.remove_window()) + .unwrap(); + cx.run_until_parked(); + + // Simulate a new session launch: replace the session so that + // `last_session_id()` returns the ID used during workspace creation. + // `restore_on_startup` defaults to `LastSession`, which is what we need. + cx.update(|cx| { + app_state.session.update(cx, |app_session, _cx| { + app_session + .replace_session_for_test(Session::test_with_old_session(session_id.clone())); + }); + }); + + // --- Read back from DB and verify grouping --- + let locations = workspace::last_session_workspace_locations(&session_id, None, fs.as_ref()) + .await + .expect("expected session workspace locations"); + + assert_eq!(locations.len(), 3, "expected 3 session workspaces"); + + let mut groups_by_window: HashMap> = HashMap::default(); + for session_workspace in &locations { + if let Some(window_id) = session_workspace.window_id { + groups_by_window + .entry(window_id) + .or_default() + .push(session_workspace.workspace_id); + } + } + assert_eq!( + groups_by_window.len(), + 2, + "expected 2 window groups, got {groups_by_window:?}" + ); + assert!( + groups_by_window.values().any(|g| g.len() == 2), + "expected one group with 2 workspaces" + ); + assert!( + groups_by_window.values().any(|g| g.len() == 1), + "expected one group with 1 workspace" + ); + + let mut async_cx = cx.to_async(); + crate::restore_or_create_workspace(app_state.clone(), &mut async_cx) + .await + .expect("failed to restore workspaces"); + cx.run_until_parked(); + + // --- Verify the restored windows --- + let restored_windows: Vec> = cx.read(|cx| { + cx.windows() + .into_iter() + .filter_map(|window| window.downcast::()) + .collect() + }); + + assert_eq!( + restored_windows.len(), + 2, + "expected 2 restored windows, got {}", + restored_windows.len() + ); + + let workspace_counts: Vec = restored_windows + .iter() + .map(|window| { + window + .read_with(cx, |multi_workspace, _| multi_workspace.workspaces().len()) + .unwrap() + }) + .collect(); + let mut sorted_counts = workspace_counts.clone(); + sorted_counts.sort(); + assert_eq!( + sorted_counts, + vec![1, 2], + "expected one window with 1 workspace and one with 2, got {workspace_counts:?}" + ); + + let dir1_path: Arc = Path::new(dir1).into(); + let dir2_path: Arc = Path::new(dir2).into(); + let dir3_path: Arc = Path::new(dir3).into(); + + let all_restored_paths: Vec>>> = restored_windows + .iter() + .map(|window| { + window + .read_with(cx, |multi_workspace, cx| { + multi_workspace + .workspaces() + .iter() + .map(|ws| ws.read(cx).root_paths(cx)) + .collect() + }) + .unwrap() + }) + .collect(); + + let two_ws_window = all_restored_paths + .iter() + .find(|paths| paths.len() == 2) + .expect("expected a window with 2 workspaces"); + assert!( + two_ws_window.iter().any(|p| p.contains(&dir1_path)), + "2-workspace window should contain dir1, got {two_ws_window:?}" + ); + assert!( + two_ws_window.iter().any(|p| p.contains(&dir2_path)), + "2-workspace window should contain dir2, got {two_ws_window:?}" + ); + + let one_ws_window = all_restored_paths + .iter() + .find(|paths| paths.len() == 1) + .expect("expected a window with 1 workspace"); + assert!( + one_ws_window[0].contains(&dir3_path), + "1-workspace window should contain dir3, got {one_ws_window:?}" + ); + + // --- Verify the active workspace is preserved --- + for window in &restored_windows { + let (active_paths, workspace_count) = window + .read_with(cx, |multi_workspace, cx| { + let active = multi_workspace.workspace(); + ( + active.read(cx).root_paths(cx), + multi_workspace.workspaces().len(), + ) + }) + .unwrap(); + + if workspace_count == 2 { + assert!( + active_paths.contains(&dir1_path), + "2-workspace window should have dir1 active, got {active_paths:?}" + ); + } else { + assert!( + active_paths.contains(&dir3_path), + "1-workspace window should have dir3 active, got {active_paths:?}" + ); + } + } + } } diff --git a/crates/zed/src/zed/migrate.rs b/crates/zed/src/zed/migrate.rs index 2452f17d04007364861e9a262b492155daec0c55..f8bec397f1cf54fe37962c6a318a816a3158423e 100644 --- a/crates/zed/src/zed/migrate.rs +++ b/crates/zed/src/zed/migrate.rs @@ -1,6 +1,7 @@ use anyhow::{Context as _, Result}; use editor::Editor; use fs::Fs; +use gpui::WeakEntity; use migrator::{migrate_keymap, migrate_settings}; use settings::{KeymapFile, Settings, SettingsStore}; use util::ResultExt; @@ -22,6 +23,7 @@ pub enum MigrationType { } pub struct MigrationBanner { + workspace: WeakEntity, migration_type: Option, should_migrate_task: Option>, markdown: Option>, @@ -54,7 +56,7 @@ struct GlobalMigrationNotification(Entity); impl Global for GlobalMigrationNotification {} impl MigrationBanner { - pub fn new(_: &Workspace, cx: &mut Context) -> Self { + pub fn new(workspace: WeakEntity, cx: &mut Context) -> Self { if let Some(notifier) = MigrationNotification::try_global(cx) { cx.subscribe( ¬ifier, @@ -65,6 +67,7 @@ impl MigrationBanner { .detach(); } Self { + workspace, migration_type: None, should_migrate_task: None, markdown: None, @@ -235,22 +238,22 @@ impl Render for MigrationBanner { ), ) .child( - Button::new("backup-and-migrate", "Backup and Update").on_click( + Button::new("backup-and-migrate", "Backup and Update").on_click({ + let workspace = self.workspace.clone(); move |_, window, cx| { let fs = ::global(cx); - match migration_type { + let task = match migration_type { Some(MigrationType::Keymap) => { cx.background_spawn(write_keymap_migration(fs.clone())) - .detach_and_notify_err(window, cx); } Some(MigrationType::Settings) => { cx.background_spawn(write_settings_migration(fs.clone())) - .detach_and_notify_err(window, cx); } None => unreachable!(), - } - }, - ), + }; + task.detach_and_notify_err(workspace.clone(), window, cx); + } + }), ) .into_any_element() } diff --git a/crates/zed/src/zed/open_listener.rs b/crates/zed/src/zed/open_listener.rs index 86d35d558dc024931a901c479f26e502de381ca7..2fdefff246a9cfd32bd27797451a545d2ab5e565 100644 --- a/crates/zed/src/zed/open_listener.rs +++ b/crates/zed/src/zed/open_listener.rs @@ -1,5 +1,5 @@ use crate::handle_open_request; -use crate::restorable_workspace_locations; +use crate::restore_or_create_workspace; use anyhow::{Context as _, Result, anyhow}; use cli::{CliRequest, CliResponse, ipc::IpcSender}; use cli::{IpcHandshake, ipc}; @@ -30,7 +30,7 @@ use util::ResultExt; use util::paths::PathWithPosition; use workspace::PathList; use workspace::item::ItemHandle; -use workspace::{AppState, OpenOptions, SerializedWorkspaceLocation, Workspace}; +use workspace::{AppState, MultiWorkspace, OpenOptions, SerializedWorkspaceLocation}; #[derive(Default, Debug)] pub struct OpenRequest { @@ -337,7 +337,7 @@ pub async fn open_paths_with_positions( open_options: workspace::OpenOptions, cx: &mut AsyncApp, ) -> Result<( - WindowHandle, + WindowHandle, Vec>>>, )> { let mut caret_positions = HashMap::default(); @@ -357,24 +357,29 @@ pub async fn open_paths_with_positions( }) .collect::>(); - let (workspace, mut items) = cx + let (multi_workspace, mut items) = cx .update(|cx| workspace::open_paths(&paths, app_state, open_options, cx)) .await?; if diff_all && !diff_paths.is_empty() { - if let Ok(diff_view) = workspace.update(cx, |workspace, window, cx| { - MultiDiffView::open(diff_paths.to_vec(), workspace, window, cx) + if let Ok(diff_view) = multi_workspace.update(cx, |multi_workspace, window, cx| { + multi_workspace.workspace().update(cx, |workspace, cx| { + MultiDiffView::open(diff_paths.to_vec(), workspace, window, cx) + }) }) { if let Some(diff_view) = diff_view.await.log_err() { items.push(Some(Ok(Box::new(diff_view)))); } } } else { + let workspace_weak = multi_workspace.read_with(cx, |multi_workspace, _cx| { + multi_workspace.workspace().downgrade() + })?; for diff_pair in diff_paths { let old_path = Path::new(&diff_pair[0]).canonicalize()?; let new_path = Path::new(&diff_pair[1]).canonicalize()?; - if let Ok(diff_view) = workspace.update(cx, |workspace, window, cx| { - FileDiffView::open(old_path, new_path, workspace, window, cx) + if let Ok(diff_view) = multi_workspace.update(cx, |_multi_workspace, window, cx| { + FileDiffView::open(old_path, new_path, workspace_weak.clone(), window, cx) }) { if let Some(diff_view) = diff_view.await.log_err() { items.push(Some(Ok(Box::new(diff_view)))) @@ -395,7 +400,7 @@ pub async fn open_paths_with_positions( continue; }; if let Some(active_editor) = item.downcast::() { - workspace + multi_workspace .update(cx, |_, window, cx| { active_editor.update(cx, |editor, cx| { editor.go_to_singleton_buffer_point(point, window, cx); @@ -405,7 +410,7 @@ pub async fn open_paths_with_positions( } } - Ok((workspace, items)) + Ok((multi_workspace, items)) } pub async fn handle_cli_connection( @@ -488,20 +493,13 @@ async fn open_workspaces( env: Option>, cx: &mut AsyncApp, ) -> Result<()> { + if paths.is_empty() && diff_paths.is_empty() && open_new_workspace != Some(true) { + return restore_or_create_workspace(app_state, cx).await; + } + let grouped_locations: Vec<(SerializedWorkspaceLocation, PathList)> = if paths.is_empty() && diff_paths.is_empty() { - if open_new_workspace == Some(true) { - Vec::new() - } else { - // The workspace_id from the database is not used; - // open_paths will assign a new WorkspaceId when opening the workspace. - restorable_workspace_locations(cx, &app_state) - .await - .unwrap_or_default() - .into_iter() - .map(|(_workspace_id, location, paths)| (location, paths)) - .collect() - } + Vec::new() } else { vec![( SerializedWorkspaceLocation::Local, @@ -755,7 +753,7 @@ mod tests { use serde_json::json; use std::{sync::Arc, task::Poll}; use util::path; - use workspace::{AppState, Workspace}; + use workspace::{AppState, MultiWorkspace}; #[gpui::test] fn test_parse_ssh_url(cx: &mut TestAppContext) { @@ -891,10 +889,12 @@ mod tests { open_workspace_file(path!("/root/dir1"), None, app_state.clone(), cx).await; assert_eq!(cx.windows().len(), 1); - let workspace = cx.windows()[0].downcast::().unwrap(); - workspace - .update(cx, |workspace, _, cx| { - assert!(workspace.active_item_as::(cx).is_none()) + let multi_workspace = cx.windows()[0].downcast::().unwrap(); + multi_workspace + .update(cx, |multi_workspace, _, cx| { + multi_workspace.workspace().update(cx, |workspace, cx| { + assert!(workspace.active_item_as::(cx).is_none()) + }); }) .unwrap(); @@ -902,9 +902,11 @@ mod tests { open_workspace_file(path!("/root/dir1/file1.txt"), None, app_state.clone(), cx).await; assert_eq!(cx.windows().len(), 1); - workspace - .update(cx, |workspace, _, cx| { - assert!(workspace.active_item_as::(cx).is_some()); + multi_workspace + .update(cx, |multi_workspace, _, cx| { + multi_workspace.workspace().update(cx, |workspace, cx| { + assert!(workspace.active_item_as::(cx).is_some()); + }); }) .unwrap(); @@ -919,12 +921,14 @@ mod tests { assert_eq!(cx.windows().len(), 2); - let workspace_2 = cx.windows()[1].downcast::().unwrap(); - workspace_2 - .update(cx, |workspace, _, cx| { - assert!(workspace.active_item_as::(cx).is_some()); - let items = workspace.items(cx).collect::>(); - assert_eq!(items.len(), 1, "Workspace should have two items"); + let multi_workspace_2 = cx.windows()[1].downcast::().unwrap(); + multi_workspace_2 + .update(cx, |multi_workspace, _, cx| { + multi_workspace.workspace().update(cx, |workspace, cx| { + assert!(workspace.active_item_as::(cx).is_some()); + let items = workspace.items(cx).collect::>(); + assert_eq!(items.len(), 1, "Workspace should have two items"); + }); }) .unwrap(); } @@ -1000,10 +1004,12 @@ mod tests { open_workspace_file(path!("/root/file5.txt"), None, app_state.clone(), cx).await; assert_eq!(cx.windows().len(), 1); - let workspace_1 = cx.windows()[0].downcast::().unwrap(); - workspace_1 - .update(cx, |workspace, _, cx| { - assert!(workspace.active_item_as::(cx).is_some()) + let multi_workspace_1 = cx.windows()[0].downcast::().unwrap(); + multi_workspace_1 + .update(cx, |multi_workspace, _, cx| { + multi_workspace.workspace().update(cx, |workspace, cx| { + assert!(workspace.active_item_as::(cx).is_some()) + }); }) .unwrap(); @@ -1012,10 +1018,12 @@ mod tests { open_workspace_file(path!("/root/file6.txt"), Some(false), app_state.clone(), cx).await; assert_eq!(cx.windows().len(), 1); - workspace_1 - .update(cx, |workspace, _, cx| { - let items = workspace.items(cx).collect::>(); - assert_eq!(items.len(), 2, "Workspace should have two items"); + multi_workspace_1 + .update(cx, |multi_workspace, _, cx| { + multi_workspace.workspace().update(cx, |workspace, cx| { + let items = workspace.items(cx).collect::>(); + assert_eq!(items.len(), 2, "Workspace should have two items"); + }); }) .unwrap(); @@ -1024,11 +1032,13 @@ mod tests { open_workspace_file(path!("/root/file7.txt"), Some(true), app_state.clone(), cx).await; assert_eq!(cx.windows().len(), 2); - let workspace_2 = cx.windows()[1].downcast::().unwrap(); - workspace_2 - .update(cx, |workspace, _, cx| { - let items = workspace.items(cx).collect::>(); - assert_eq!(items.len(), 1, "Workspace should have two items"); + let multi_workspace_2 = cx.windows()[1].downcast::().unwrap(); + multi_workspace_2 + .update(cx, |multi_workspace, _, cx| { + multi_workspace.workspace().update(cx, |workspace, cx| { + let items = workspace.items(cx).collect::>(); + assert_eq!(items.len(), 1, "Workspace should have two items"); + }); }) .unwrap(); }