From a018333d41bfe07102d1d0b68383ff5afac00307 Mon Sep 17 00:00:00 2001 From: Mikayla Maki Date: Mon, 6 Apr 2026 14:42:04 -0700 Subject: [PATCH] Introduce the temporary/retained workspace behavior based on whether the sidebar is open (#53267) Self-Review Checklist: - [ ] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Release Notes: - N/A --- crates/agent_ui/src/agent_panel.rs | 12 +- crates/agent_ui/src/conversation_view.rs | 1 - crates/agent_ui/src/threads_archive_view.rs | 1 - crates/recent_projects/src/recent_projects.rs | 10 +- crates/settings_ui/src/settings_ui.rs | 1 - crates/sidebar/src/sidebar.rs | 53 +-- crates/sidebar/src/sidebar_tests.rs | 425 +++++++++++++----- crates/title_bar/src/title_bar.rs | 2 - crates/workspace/src/multi_workspace.rs | 234 ++++++---- crates/workspace/src/multi_workspace_tests.rs | 24 + crates/workspace/src/persistence.rs | 29 +- crates/workspace/src/workspace.rs | 32 +- crates/zed/src/visual_test_runner.rs | 28 +- crates/zed/src/zed.rs | 68 ++- 14 files changed, 634 insertions(+), 286 deletions(-) diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index 5fd39509df4ec2263e47c7e87b3e4b7852eaf154..41900e71e5d3ad7e5327ee7e04f73cb05eed5a5b 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -5175,7 +5175,7 @@ mod tests { multi_workspace .read_with(cx, |multi_workspace, _cx| { assert_eq!( - multi_workspace.workspaces().len(), + multi_workspace.workspaces().count(), 1, "LocalProject should not create a new workspace" ); @@ -5451,6 +5451,11 @@ mod tests { let multi_workspace = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + multi_workspace + .update(cx, |multi_workspace, _, cx| { + multi_workspace.open_sidebar(cx); + }) + .unwrap(); let workspace = multi_workspace .read_with(cx, |multi_workspace, _cx| { @@ -5538,15 +5543,14 @@ mod tests { .read_with(cx, |multi_workspace, cx| { // There should be more than one workspace now (the original + the new worktree). assert!( - multi_workspace.workspaces().len() > 1, + multi_workspace.workspaces().count() > 1, "expected a new workspace to have been created, found {}", - multi_workspace.workspaces().len(), + multi_workspace.workspaces().count(), ); // Check the newest workspace's panel for the correct agent. let new_workspace = multi_workspace .workspaces() - .iter() .find(|ws| ws.entity_id() != workspace.entity_id()) .expect("should find the new workspace"); let new_panel = new_workspace diff --git a/crates/agent_ui/src/conversation_view.rs b/crates/agent_ui/src/conversation_view.rs index ce125a5d7c901ccb6fc89f405f482cbf52b94f5d..149ed2e2fc0f9b22244e0d69deebf5aa7bb7d4c5 100644 --- a/crates/agent_ui/src/conversation_view.rs +++ b/crates/agent_ui/src/conversation_view.rs @@ -3375,7 +3375,6 @@ pub(crate) mod tests { // 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(); diff --git a/crates/agent_ui/src/threads_archive_view.rs b/crates/agent_ui/src/threads_archive_view.rs index b7afe2c37d0c278a23d9a41a560e45c356e7b4e1..13b2aa1a37cd506c338d13db78bce751882e426a 100644 --- a/crates/agent_ui/src/threads_archive_view.rs +++ b/crates/agent_ui/src/threads_archive_view.rs @@ -353,7 +353,6 @@ impl ThreadsArchiveView { .map(|mw| { mw.read(cx) .workspaces() - .iter() .filter_map(|ws| ws.read(cx).database_id()) .collect() }) diff --git a/crates/recent_projects/src/recent_projects.rs b/crates/recent_projects/src/recent_projects.rs index 24010017ff9fa4eb62a1787332fed70f740ccc2d..e3bfc0dc08c95c0ce57b818e50965433a6c6bc98 100644 --- a/crates/recent_projects/src/recent_projects.rs +++ b/crates/recent_projects/src/recent_projects.rs @@ -357,7 +357,6 @@ pub fn init(cx: &mut App) { .update(cx, |multi_workspace, window, cx| { let sibling_workspace_ids: HashSet = multi_workspace .workspaces() - .iter() .filter_map(|ws| ws.read(cx).database_id()) .collect(); @@ -1113,7 +1112,6 @@ impl PickerDelegate for RecentProjectsDelegate { .update(cx, |multi_workspace, window, cx| { let workspace = multi_workspace .workspaces() - .iter() .find(|ws| ws.read(cx).database_id() == Some(workspace_id)) .cloned(); if let Some(workspace) = workspace { @@ -1932,7 +1930,6 @@ impl RecentProjectsDelegate { .update(cx, |multi_workspace, window, cx| { let workspace = multi_workspace .workspaces() - .iter() .find(|ws| ws.read(cx).database_id() == Some(workspace_id)) .cloned(); if let Some(workspace) = workspace { @@ -2055,6 +2052,11 @@ mod tests { assert_eq!(cx.update(|cx| cx.windows().len()), 1); let multi_workspace = cx.update(|cx| cx.windows()[0].downcast::().unwrap()); + multi_workspace + .update(cx, |multi_workspace, _, cx| { + multi_workspace.open_sidebar(cx); + }) + .unwrap(); multi_workspace .update(cx, |multi_workspace, _, cx| { assert!(!multi_workspace.workspace().read(cx).is_edited()) @@ -2141,7 +2143,7 @@ mod tests { ); assert!( - multi_workspace.workspaces().contains(&dirty_workspace), + multi_workspace.workspaces().any(|w| w == &dirty_workspace), "The dirty workspace should still be present in multi-workspace mode" ); diff --git a/crates/settings_ui/src/settings_ui.rs b/crates/settings_ui/src/settings_ui.rs index 634db0e247fdc370c479df0ed4f6d1f84a5284f6..4c7a98f6c0fa94e659a6db4e00aa28e2b4516e13 100644 --- a/crates/settings_ui/src/settings_ui.rs +++ b/crates/settings_ui/src/settings_ui.rs @@ -3753,7 +3753,6 @@ fn all_projects( .flat_map(|multi_workspace| { multi_workspace .workspaces() - .iter() .map(|workspace| workspace.read(cx).project().clone()) .collect::>() }), diff --git a/crates/sidebar/src/sidebar.rs b/crates/sidebar/src/sidebar.rs index 4d3e282c403d4df27781066c35837f88f3b4cccd..d6589361cd9417c2ac6d9025af92f1e096b341b1 100644 --- a/crates/sidebar/src/sidebar.rs +++ b/crates/sidebar/src/sidebar.rs @@ -434,7 +434,7 @@ impl Sidebar { }) .detach(); - let workspaces = multi_workspace.read(cx).workspaces().to_vec(); + let workspaces: Vec<_> = multi_workspace.read(cx).workspaces().cloned().collect(); cx.defer_in(window, move |this, window, cx| { for workspace in &workspaces { this.subscribe_to_workspace(workspace, window, cx); @@ -673,7 +673,6 @@ impl Sidebar { let mw = self.multi_workspace.upgrade()?; let mw = mw.read(cx); mw.workspaces() - .iter() .find(|ws| ws.read(cx).project_group_key(cx).path_list() == path_list) .cloned() } @@ -716,8 +715,8 @@ impl Sidebar { return; }; let mw = multi_workspace.read(cx); - let workspaces = mw.workspaces().to_vec(); - let active_workspace = mw.workspaces().get(mw.active_workspace_index()).cloned(); + let workspaces: Vec<_> = mw.workspaces().cloned().collect(); + let active_workspace = Some(mw.workspace().clone()); let agent_server_store = workspaces .first() @@ -1993,7 +1992,6 @@ impl Sidebar { let workspace = window.read(cx).ok().and_then(|multi_workspace| { multi_workspace .workspaces() - .iter() .find(|workspace| predicate(workspace, cx)) .cloned() })?; @@ -2010,7 +2008,6 @@ impl Sidebar { multi_workspace .read(cx) .workspaces() - .iter() .find(|workspace| predicate(workspace, cx)) .cloned() }) @@ -2203,12 +2200,10 @@ impl Sidebar { return; } - let active_workspace = self.multi_workspace.upgrade().and_then(|w| { - w.read(cx) - .workspaces() - .get(w.read(cx).active_workspace_index()) - .cloned() - }); + let active_workspace = self + .multi_workspace + .upgrade() + .map(|w| w.read(cx).workspace().clone()); if let Some(workspace) = active_workspace { self.activate_thread_locally(&metadata, &workspace, window, cx); @@ -2343,7 +2338,7 @@ impl Sidebar { return; }; - let workspaces = multi_workspace.read(cx).workspaces().to_vec(); + let workspaces: Vec<_> = multi_workspace.read(cx).workspaces().cloned().collect(); for workspace in workspaces { if let Some(agent_panel) = workspace.read(cx).panel::(cx) { let cancelled = @@ -2936,7 +2931,6 @@ impl Sidebar { .map(|mw| { mw.read(cx) .workspaces() - .iter() .filter_map(|ws| ws.read(cx).database_id()) .collect() }) @@ -3404,12 +3398,9 @@ impl Sidebar { } fn active_workspace(&self, cx: &App) -> Option> { - self.multi_workspace.upgrade().and_then(|w| { - w.read(cx) - .workspaces() - .get(w.read(cx).active_workspace_index()) - .cloned() - }) + self.multi_workspace + .upgrade() + .map(|w| w.read(cx).workspace().clone()) } fn show_thread_import_modal(&mut self, window: &mut Window, cx: &mut Context) { @@ -3517,12 +3508,11 @@ impl Sidebar { } fn show_archive(&mut self, window: &mut Window, cx: &mut Context) { - let Some(active_workspace) = self.multi_workspace.upgrade().and_then(|w| { - w.read(cx) - .workspaces() - .get(w.read(cx).active_workspace_index()) - .cloned() - }) else { + let Some(active_workspace) = self + .multi_workspace + .upgrade() + .map(|w| w.read(cx).workspace().clone()) + else { return; }; let Some(agent_panel) = active_workspace.read(cx).panel::(cx) else { @@ -3824,12 +3814,12 @@ pub fn dump_workspace_info( let multi_workspace = workspace.multi_workspace().and_then(|weak| weak.upgrade()); let workspaces: Vec> = match &multi_workspace { - Some(mw) => mw.read(cx).workspaces().to_vec(), + Some(mw) => mw.read(cx).workspaces().cloned().collect(), None => vec![this_entity.clone()], }; - let active_index = multi_workspace + let active_workspace = multi_workspace .as_ref() - .map(|mw| mw.read(cx).active_workspace_index()); + .map(|mw| mw.read(cx).workspace().clone()); writeln!(output, "MultiWorkspace: {} workspace(s)", workspaces.len()).ok(); @@ -3841,13 +3831,10 @@ pub fn dump_workspace_info( } } - if let Some(index) = active_index { - writeln!(output, "Active workspace index: {index}").ok(); - } writeln!(output).ok(); for (index, ws) in workspaces.iter().enumerate() { - let is_active = active_index == Some(index); + let is_active = active_workspace.as_ref() == Some(ws); writeln!( output, "--- Workspace {index}{} ---", diff --git a/crates/sidebar/src/sidebar_tests.rs b/crates/sidebar/src/sidebar_tests.rs index a50c5dadbdbff77ccadd81dd96196a469e920e87..60881acfe9461f7897d6013831970444b7a65544 100644 --- a/crates/sidebar/src/sidebar_tests.rs +++ b/crates/sidebar/src/sidebar_tests.rs @@ -77,6 +77,18 @@ async fn init_test_project( fn setup_sidebar( multi_workspace: &Entity, cx: &mut gpui::VisualTestContext, +) -> Entity { + let sidebar = setup_sidebar_closed(multi_workspace, cx); + multi_workspace.update_in(cx, |mw, window, cx| { + mw.toggle_sidebar(window, cx); + }); + cx.run_until_parked(); + sidebar +} + +fn setup_sidebar_closed( + multi_workspace: &Entity, + cx: &mut gpui::VisualTestContext, ) -> Entity { let multi_workspace = multi_workspace.clone(); let sidebar = @@ -172,16 +184,7 @@ fn save_thread_metadata( cx.run_until_parked(); } -fn open_and_focus_sidebar(sidebar: &Entity, cx: &mut gpui::VisualTestContext) { - let multi_workspace = sidebar.read_with(cx, |s, _| s.multi_workspace.upgrade()); - if let Some(multi_workspace) = multi_workspace { - multi_workspace.update_in(cx, |mw, window, cx| { - if !mw.sidebar_open() { - mw.toggle_sidebar(window, cx); - } - }); - } - cx.run_until_parked(); +fn focus_sidebar(sidebar: &Entity, cx: &mut gpui::VisualTestContext) { sidebar.update_in(cx, |_, window, cx| { cx.focus_self(window); }); @@ -544,7 +547,7 @@ async fn test_workspace_lifecycle(cx: &mut TestAppContext) { // Remove the second workspace multi_workspace.update_in(cx, |mw, window, cx| { - let workspace = mw.workspaces()[1].clone(); + let workspace = mw.workspaces().nth(1).cloned().unwrap(); mw.remove(&workspace, window, cx); }); cx.run_until_parked(); @@ -604,7 +607,7 @@ async fn test_view_more_batched_expansion(cx: &mut TestAppContext) { assert!(entries.iter().any(|e| e.contains("View More"))); // Focus and navigate to View More, then confirm to expand by one batch - open_and_focus_sidebar(&sidebar, cx); + focus_sidebar(&sidebar, cx); for _ in 0..7 { cx.dispatch_action(SelectNext); } @@ -915,7 +918,7 @@ async fn test_keyboard_select_next_and_previous(cx: &mut TestAppContext) { // Entries: [header, thread3, thread2, thread1] // Focusing the sidebar does not set a selection; select_next/select_previous // handle None gracefully by starting from the first or last entry. - open_and_focus_sidebar(&sidebar, cx); + focus_sidebar(&sidebar, cx); assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None); // First SelectNext from None starts at index 0 @@ -970,7 +973,7 @@ async fn test_keyboard_select_first_and_last(cx: &mut TestAppContext) { multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); cx.run_until_parked(); - open_and_focus_sidebar(&sidebar, cx); + focus_sidebar(&sidebar, cx); // SelectLast jumps to the end cx.dispatch_action(SelectLast); @@ -993,7 +996,7 @@ async fn test_keyboard_focus_in_does_not_set_selection(cx: &mut TestAppContext) // Open the sidebar so it's rendered, then focus it to trigger focus_in. // focus_in no longer sets a default selection. - open_and_focus_sidebar(&sidebar, cx); + focus_sidebar(&sidebar, cx); assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None); // Manually set a selection, blur, then refocus — selection should be preserved @@ -1030,7 +1033,7 @@ async fn test_keyboard_confirm_on_project_header_toggles_collapse(cx: &mut TestA ); // Focus the sidebar and select the header (index 0) - open_and_focus_sidebar(&sidebar, cx); + focus_sidebar(&sidebar, cx); sidebar.update_in(cx, |sidebar, _window, _cx| { sidebar.selection = Some(0); }); @@ -1071,7 +1074,7 @@ async fn test_keyboard_confirm_on_view_more_expands(cx: &mut TestAppContext) { assert!(entries.iter().any(|e| e.contains("View More"))); // Focus sidebar (selection starts at None), then navigate down to the "View More" entry (index 6) - open_and_focus_sidebar(&sidebar, cx); + focus_sidebar(&sidebar, cx); for _ in 0..7 { cx.dispatch_action(SelectNext); } @@ -1105,7 +1108,7 @@ async fn test_keyboard_expand_and_collapse_selected_entry(cx: &mut TestAppContex ); // Focus sidebar and manually select the header (index 0). Press left to collapse. - open_and_focus_sidebar(&sidebar, cx); + focus_sidebar(&sidebar, cx); sidebar.update_in(cx, |sidebar, _window, _cx| { sidebar.selection = Some(0); }); @@ -1144,7 +1147,7 @@ async fn test_keyboard_collapse_from_child_selects_parent(cx: &mut TestAppContex cx.run_until_parked(); // Focus sidebar (selection starts at None), then navigate down to the thread (child) - open_and_focus_sidebar(&sidebar, cx); + focus_sidebar(&sidebar, cx); cx.dispatch_action(SelectNext); cx.dispatch_action(SelectNext); assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1)); @@ -1179,7 +1182,7 @@ async fn test_keyboard_navigation_on_empty_list(cx: &mut TestAppContext) { ); // Focus sidebar — focus_in does not set a selection - open_and_focus_sidebar(&sidebar, cx); + focus_sidebar(&sidebar, cx); assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None); // First SelectNext from None starts at index 0 (header) @@ -1211,7 +1214,7 @@ async fn test_selection_clamps_after_entry_removal(cx: &mut TestAppContext) { cx.run_until_parked(); // Focus sidebar (selection starts at None), navigate down to the thread (index 1) - open_and_focus_sidebar(&sidebar, cx); + focus_sidebar(&sidebar, cx); cx.dispatch_action(SelectNext); cx.dispatch_action(SelectNext); assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1)); @@ -1492,7 +1495,7 @@ async fn test_escape_clears_search_and_restores_full_list(cx: &mut TestAppContex ); // User types a search query to filter down. - open_and_focus_sidebar(&sidebar, cx); + focus_sidebar(&sidebar, cx); type_in_search(&sidebar, "alpha", cx); assert_eq!( visible_entries_as_strings(&sidebar, cx), @@ -1540,8 +1543,9 @@ async fn test_search_only_shows_workspace_headers_with_matches(cx: &mut TestAppC }); cx.run_until_parked(); - let project_b = - multi_workspace.read_with(cx, |mw, cx| mw.workspaces()[1].read(cx).project().clone()); + let project_b = multi_workspace.read_with(cx, |mw, cx| { + mw.workspaces().nth(1).unwrap().read(cx).project().clone() + }); for (id, title, hour) in [ ("b1", "Refactor sidebar layout", 3), @@ -1621,8 +1625,9 @@ async fn test_search_matches_workspace_name(cx: &mut TestAppContext) { }); cx.run_until_parked(); - let project_b = - multi_workspace.read_with(cx, |mw, cx| mw.workspaces()[1].read(cx).project().clone()); + let project_b = multi_workspace.read_with(cx, |mw, cx| { + mw.workspaces().nth(1).unwrap().read(cx).project().clone() + }); for (id, title, hour) in [ ("b1", "Refactor sidebar layout", 3), @@ -1764,7 +1769,7 @@ async fn test_search_finds_threads_inside_collapsed_groups(cx: &mut TestAppConte // User focuses the sidebar and collapses the group using keyboard: // manually select the header, then press SelectParent to collapse. - open_and_focus_sidebar(&sidebar, cx); + focus_sidebar(&sidebar, cx); sidebar.update_in(cx, |sidebar, _window, _cx| { sidebar.selection = Some(0); }); @@ -1807,7 +1812,7 @@ async fn test_search_then_keyboard_navigate_and_confirm(cx: &mut TestAppContext) } cx.run_until_parked(); - open_and_focus_sidebar(&sidebar, cx); + focus_sidebar(&sidebar, cx); // User types "fix" — two threads match. type_in_search(&sidebar, "fix", cx); @@ -1856,6 +1861,13 @@ async fn test_confirm_on_historical_thread_activates_workspace(cx: &mut TestAppC }); cx.run_until_parked(); + let (workspace_0, workspace_1) = multi_workspace.read_with(cx, |mw, _| { + ( + mw.workspaces().next().unwrap().clone(), + mw.workspaces().nth(1).unwrap().clone(), + ) + }); + save_thread_metadata( acp::SessionId::new(Arc::from("hist-1")), "Historical Thread".into(), @@ -1875,13 +1887,13 @@ async fn test_confirm_on_historical_thread_activates_workspace(cx: &mut TestAppC // Switch to workspace 1 so we can verify the confirm switches back. multi_workspace.update_in(cx, |mw, window, cx| { - let workspace = mw.workspaces()[1].clone(); + let workspace = mw.workspaces().nth(1).unwrap().clone(); mw.activate(workspace, window, cx); }); cx.run_until_parked(); assert_eq!( - multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()), - 1 + multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()), + workspace_1 ); // Confirm on the historical (non-live) thread at index 1. @@ -1895,8 +1907,8 @@ async fn test_confirm_on_historical_thread_activates_workspace(cx: &mut TestAppC cx.run_until_parked(); assert_eq!( - multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()), - 0 + multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()), + workspace_0 ); } @@ -2037,7 +2049,8 @@ async fn test_focused_thread_tracks_user_intent(cx: &mut TestAppContext) { let panel_b = add_agent_panel(&workspace_b, cx); cx.run_until_parked(); - let workspace_a = multi_workspace.read_with(cx, |mw, _cx| mw.workspaces()[0].clone()); + let workspace_a = + multi_workspace.read_with(cx, |mw, _cx| mw.workspaces().next().unwrap().clone()); // ── 1. Initial state: focused thread derived from active panel ───── sidebar.read_with(cx, |sidebar, _cx| { @@ -2135,7 +2148,7 @@ async fn test_focused_thread_tracks_user_intent(cx: &mut TestAppContext) { }); multi_workspace.update_in(cx, |mw, window, cx| { - let workspace = mw.workspaces()[0].clone(); + let workspace = mw.workspaces().next().unwrap().clone(); mw.activate(workspace, window, cx); }); cx.run_until_parked(); @@ -2190,8 +2203,8 @@ async fn test_focused_thread_tracks_user_intent(cx: &mut TestAppContext) { // Switching workspaces via the multi_workspace (simulates clicking // a workspace header) should clear focused_thread. multi_workspace.update_in(cx, |mw, window, cx| { - if let Some(index) = mw.workspaces().iter().position(|w| w == &workspace_b) { - let workspace = mw.workspaces()[index].clone(); + let workspace = mw.workspaces().find(|w| *w == &workspace_b).cloned(); + if let Some(workspace) = workspace { mw.activate(workspace, window, cx); } }); @@ -2477,6 +2490,8 @@ async fn test_cmd_n_shows_new_thread_entry_in_absorbed_worktree(cx: &mut TestApp let (multi_workspace, cx) = cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| { mw.test_add_workspace(worktree_project.clone(), window, cx) }); @@ -2485,12 +2500,10 @@ async fn test_cmd_n_shows_new_thread_entry_in_absorbed_worktree(cx: &mut TestApp // Switch to the worktree workspace. multi_workspace.update_in(cx, |mw, window, cx| { - let workspace = mw.workspaces()[1].clone(); + let workspace = mw.workspaces().nth(1).unwrap().clone(); mw.activate(workspace, window, cx); }); - let sidebar = setup_sidebar(&multi_workspace, cx); - // Create a non-empty thread in the worktree workspace. let connection = StubAgentConnection::new(); connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( @@ -3027,6 +3040,8 @@ async fn test_absorbed_worktree_running_thread_shows_live_status(cx: &mut TestAp let (multi_workspace, cx) = cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| { mw.test_add_workspace(worktree_project.clone(), window, cx) }); @@ -3037,12 +3052,10 @@ async fn test_absorbed_worktree_running_thread_shows_live_status(cx: &mut TestAp // Switch back to the main workspace before setting up the sidebar. multi_workspace.update_in(cx, |mw, window, cx| { - let workspace = mw.workspaces()[0].clone(); + let workspace = mw.workspaces().next().unwrap().clone(); mw.activate(workspace, window, cx); }); - let sidebar = setup_sidebar(&multi_workspace, cx); - // Start a thread in the worktree workspace's panel and keep it // generating (don't resolve it). let connection = StubAgentConnection::new(); @@ -3127,6 +3140,8 @@ async fn test_absorbed_worktree_completion_triggers_notification(cx: &mut TestAp let (multi_workspace, cx) = cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| { mw.test_add_workspace(worktree_project.clone(), window, cx) }); @@ -3134,12 +3149,10 @@ async fn test_absorbed_worktree_completion_triggers_notification(cx: &mut TestAp let worktree_panel = add_agent_panel(&worktree_workspace, cx); multi_workspace.update_in(cx, |mw, window, cx| { - let workspace = mw.workspaces()[0].clone(); + let workspace = mw.workspaces().next().unwrap().clone(); mw.activate(workspace, window, cx); }); - let sidebar = setup_sidebar(&multi_workspace, cx); - let connection = StubAgentConnection::new(); open_thread_with_connection(&worktree_panel, connection.clone(), cx); send_message(&worktree_panel, cx); @@ -3231,12 +3244,12 @@ async fn test_clicking_worktree_thread_opens_workspace_when_none_exists(cx: &mut // Only 1 workspace should exist. assert_eq!( - multi_workspace.read_with(cx, |mw, _| mw.workspaces().len()), + multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()), 1, ); // Focus the sidebar and select the worktree thread. - open_and_focus_sidebar(&sidebar, cx); + focus_sidebar(&sidebar, cx); sidebar.update_in(cx, |sidebar, _window, _cx| { sidebar.selection = Some(1); // index 0 is header, 1 is the thread }); @@ -3248,11 +3261,11 @@ async fn test_clicking_worktree_thread_opens_workspace_when_none_exists(cx: &mut // A new workspace should have been created for the worktree path. let new_workspace = multi_workspace.read_with(cx, |mw, _| { assert_eq!( - mw.workspaces().len(), + mw.workspaces().count(), 2, "confirming a worktree thread without a workspace should open one", ); - mw.workspaces()[1].clone() + mw.workspaces().nth(1).unwrap().clone() }); let new_path_list = @@ -3318,7 +3331,7 @@ async fn test_clicking_worktree_thread_does_not_briefly_render_as_separate_proje vec!["v [project]", " WT Thread {wt-feature-a}"], ); - open_and_focus_sidebar(&sidebar, cx); + focus_sidebar(&sidebar, cx); sidebar.update_in(cx, |sidebar, _window, _cx| { sidebar.selection = Some(1); // index 0 is header, 1 is the thread }); @@ -3444,18 +3457,19 @@ async fn test_clicking_absorbed_worktree_thread_activates_worktree_workspace( let (multi_workspace, cx) = cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| { mw.test_add_workspace(worktree_project.clone(), window, cx) }); // Activate the main workspace before setting up the sidebar. - multi_workspace.update_in(cx, |mw, window, cx| { - let workspace = mw.workspaces()[0].clone(); - mw.activate(workspace, window, cx); + let main_workspace = multi_workspace.update_in(cx, |mw, window, cx| { + let workspace = mw.workspaces().next().unwrap().clone(); + mw.activate(workspace.clone(), window, cx); + workspace }); - let sidebar = setup_sidebar(&multi_workspace, cx); - save_named_thread_metadata("thread-main", "Main Thread", &main_project, cx).await; save_named_thread_metadata("thread-wt", "WT Thread", &worktree_project, cx).await; @@ -3475,13 +3489,13 @@ async fn test_clicking_absorbed_worktree_thread_activates_worktree_workspace( .expect("should find the worktree thread entry"); assert_eq!( - multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()), - 0, + multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()), + main_workspace, "main workspace should be active initially" ); // Focus the sidebar and select the absorbed worktree thread. - open_and_focus_sidebar(&sidebar, cx); + focus_sidebar(&sidebar, cx); sidebar.update_in(cx, |sidebar, _window, _cx| { sidebar.selection = Some(wt_thread_index); }); @@ -3491,9 +3505,7 @@ async fn test_clicking_absorbed_worktree_thread_activates_worktree_workspace( cx.run_until_parked(); // The worktree workspace should now be active, not the main one. - let active_workspace = multi_workspace.read_with(cx, |mw, _| { - mw.workspaces()[mw.active_workspace_index()].clone() - }); + let active_workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); assert_eq!( active_workspace, worktree_workspace, "clicking an absorbed worktree thread should activate the worktree workspace" @@ -3520,25 +3532,27 @@ async fn test_activate_archived_thread_with_saved_paths_activates_matching_works let (multi_workspace, cx) = cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx)); - multi_workspace.update_in(cx, |mw, window, cx| { - mw.test_add_workspace(project_b.clone(), window, cx); - }); - let sidebar = setup_sidebar(&multi_workspace, cx); + let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| { + mw.test_add_workspace(project_b.clone(), window, cx) + }); + let workspace_a = + multi_workspace.read_with(cx, |mw, _| mw.workspaces().next().unwrap().clone()); + // Save a thread with path_list pointing to project-b. let session_id = acp::SessionId::new(Arc::from("archived-1")); save_test_thread_metadata(&session_id, &project_b, cx).await; // Ensure workspace A is active. multi_workspace.update_in(cx, |mw, window, cx| { - let workspace = mw.workspaces()[0].clone(); + let workspace = mw.workspaces().next().unwrap().clone(); mw.activate(workspace, window, cx); }); cx.run_until_parked(); assert_eq!( - multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()), - 0 + multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()), + workspace_a ); // Call activate_archived_thread – should resolve saved paths and @@ -3562,8 +3576,8 @@ async fn test_activate_archived_thread_with_saved_paths_activates_matching_works cx.run_until_parked(); assert_eq!( - multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()), - 1, + multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()), + workspace_b, "should have activated the workspace matching the saved path_list" ); } @@ -3588,21 +3602,23 @@ async fn test_activate_archived_thread_cwd_fallback_with_matching_workspace( let (multi_workspace, cx) = cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx)); - multi_workspace.update_in(cx, |mw, window, cx| { - mw.test_add_workspace(project_b, window, cx); - }); - let sidebar = setup_sidebar(&multi_workspace, cx); + let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| { + mw.test_add_workspace(project_b, window, cx) + }); + let workspace_a = + multi_workspace.read_with(cx, |mw, _| mw.workspaces().next().unwrap().clone()); + // Start with workspace A active. multi_workspace.update_in(cx, |mw, window, cx| { - let workspace = mw.workspaces()[0].clone(); + let workspace = mw.workspaces().next().unwrap().clone(); mw.activate(workspace, window, cx); }); cx.run_until_parked(); assert_eq!( - multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()), - 0 + multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()), + workspace_a ); // No thread saved to the store – cwd is the only path hint. @@ -3625,8 +3641,8 @@ async fn test_activate_archived_thread_cwd_fallback_with_matching_workspace( cx.run_until_parked(); assert_eq!( - multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()), - 1, + multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()), + workspace_b, "should have activated the workspace matching the cwd" ); } @@ -3651,21 +3667,21 @@ async fn test_activate_archived_thread_no_paths_no_cwd_uses_active_workspace( let (multi_workspace, cx) = cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx)); - multi_workspace.update_in(cx, |mw, window, cx| { - mw.test_add_workspace(project_b, window, cx); - }); - let sidebar = setup_sidebar(&multi_workspace, cx); + let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| { + mw.test_add_workspace(project_b, window, cx) + }); + // Activate workspace B (index 1) to make it the active one. multi_workspace.update_in(cx, |mw, window, cx| { - let workspace = mw.workspaces()[1].clone(); + let workspace = mw.workspaces().nth(1).unwrap().clone(); mw.activate(workspace, window, cx); }); cx.run_until_parked(); assert_eq!( - multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()), - 1 + multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()), + workspace_b ); // No saved thread, no cwd – should fall back to the active workspace. @@ -3688,8 +3704,8 @@ async fn test_activate_archived_thread_no_paths_no_cwd_uses_active_workspace( cx.run_until_parked(); assert_eq!( - multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()), - 1, + multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()), + workspace_b, "should have stayed on the active workspace when no path info is available" ); } @@ -3719,7 +3735,7 @@ async fn test_activate_archived_thread_saved_paths_opens_new_workspace(cx: &mut let session_id = acp::SessionId::new(Arc::from("archived-new-ws")); assert_eq!( - multi_workspace.read_with(cx, |mw, _| mw.workspaces().len()), + multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()), 1, "should start with one workspace" ); @@ -3743,7 +3759,7 @@ async fn test_activate_archived_thread_saved_paths_opens_new_workspace(cx: &mut cx.run_until_parked(); assert_eq!( - multi_workspace.read_with(cx, |mw, _| mw.workspaces().len()), + multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()), 2, "should have opened a second workspace for the archived thread's saved paths" ); @@ -3768,6 +3784,10 @@ async fn test_activate_archived_thread_reuses_workspace_in_another_window(cx: &m cx.add_window(|window, cx| MultiWorkspace::test_new(project_b, window, cx)); let multi_workspace_a_entity = multi_workspace_a.root(cx).unwrap(); + let multi_workspace_b_entity = multi_workspace_b.root(cx).unwrap(); + + let cx_b = &mut gpui::VisualTestContext::from_window(multi_workspace_b.into(), cx); + let _sidebar_b = setup_sidebar(&multi_workspace_b_entity, cx_b); let cx_a = &mut gpui::VisualTestContext::from_window(multi_workspace_a.into(), cx); let sidebar = setup_sidebar(&multi_workspace_a_entity, cx_a); @@ -3794,14 +3814,14 @@ async fn test_activate_archived_thread_reuses_workspace_in_another_window(cx: &m assert_eq!( multi_workspace_a - .read_with(cx_a, |mw, _| mw.workspaces().len()) + .read_with(cx_a, |mw, _| mw.workspaces().count()) .unwrap(), 1, "should not add the other window's workspace into the current window" ); assert_eq!( multi_workspace_b - .read_with(cx_a, |mw, _| mw.workspaces().len()) + .read_with(cx_a, |mw, _| mw.workspaces().count()) .unwrap(), 1, "should reuse the existing workspace in the other window" @@ -3871,14 +3891,14 @@ async fn test_activate_archived_thread_reuses_workspace_in_another_window_with_t assert_eq!( multi_workspace_a - .read_with(cx_a, |mw, _| mw.workspaces().len()) + .read_with(cx_a, |mw, _| mw.workspaces().count()) .unwrap(), 1, "should not add the other window's workspace into the current window" ); assert_eq!( multi_workspace_b - .read_with(cx_a, |mw, _| mw.workspaces().len()) + .read_with(cx_a, |mw, _| mw.workspaces().count()) .unwrap(), 1, "should reuse the existing workspace in the other window" @@ -3921,6 +3941,10 @@ async fn test_activate_archived_thread_prefers_current_window_for_matching_paths cx.add_window(|window, cx| MultiWorkspace::test_new(project_a, window, cx)); let multi_workspace_a_entity = multi_workspace_a.root(cx).unwrap(); + let multi_workspace_b_entity = multi_workspace_b.root(cx).unwrap(); + + let cx_b = &mut gpui::VisualTestContext::from_window(multi_workspace_b.into(), cx); + let _sidebar_b = setup_sidebar(&multi_workspace_b_entity, cx_b); let cx_a = &mut gpui::VisualTestContext::from_window(multi_workspace_a.into(), cx); let sidebar_a = setup_sidebar(&multi_workspace_a_entity, cx_a); @@ -3958,14 +3982,14 @@ async fn test_activate_archived_thread_prefers_current_window_for_matching_paths }); assert_eq!( multi_workspace_a - .read_with(cx_a, |mw, _| mw.workspaces().len()) + .read_with(cx_a, |mw, _| mw.workspaces().count()) .unwrap(), 1, "current window should continue reusing its existing workspace" ); assert_eq!( multi_workspace_b - .read_with(cx_a, |mw, _| mw.workspaces().len()) + .read_with(cx_a, |mw, _| mw.workspaces().count()) .unwrap(), 1, "other windows should not be activated just because they also match the saved paths" @@ -4029,19 +4053,20 @@ async fn test_archive_thread_uses_next_threads_own_workspace(cx: &mut TestAppCon let (multi_workspace, cx) = cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| { mw.test_add_workspace(worktree_project.clone(), window, cx) }); // Activate main workspace so the sidebar tracks the main panel. multi_workspace.update_in(cx, |mw, window, cx| { - let workspace = mw.workspaces()[0].clone(); + let workspace = mw.workspaces().next().unwrap().clone(); mw.activate(workspace, window, cx); }); - let sidebar = setup_sidebar(&multi_workspace, cx); - - let main_workspace = multi_workspace.read_with(cx, |mw, _| mw.workspaces()[0].clone()); + let main_workspace = + multi_workspace.read_with(cx, |mw, _| mw.workspaces().next().unwrap().clone()); let main_panel = add_agent_panel(&main_workspace, cx); let _worktree_panel = add_agent_panel(&worktree_workspace, cx); @@ -4195,10 +4220,10 @@ async fn test_linked_worktree_threads_not_duplicated_across_groups(cx: &mut Test let (multi_workspace, cx) = cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_only.clone(), window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); multi_workspace.update_in(cx, |mw, window, cx| { mw.test_add_workspace(multi_root.clone(), window, cx); }); - let sidebar = setup_sidebar(&multi_workspace, cx); // Save a thread under the linked worktree path. save_named_thread_metadata("wt-thread", "Worktree Thread", &worktree_project, cx).await; @@ -4313,8 +4338,8 @@ async fn test_thread_switcher_ordering(cx: &mut TestAppContext) { // so all three have last_accessed_at set. // Access order is: A (most recent), B, C (oldest). - // ── 1. Open switcher: threads sorted by last_accessed_at ─────────── - open_and_focus_sidebar(&sidebar, cx); + // ── 1. Open switcher: threads sorted by last_accessed_at ───────────────── + focus_sidebar(&sidebar, cx); sidebar.update_in(cx, |sidebar, window, cx| { sidebar.on_toggle_thread_switcher(&ToggleThreadSwitcher::default(), window, cx); }); @@ -4759,6 +4784,170 @@ async fn test_linked_worktree_workspace_shows_main_worktree_threads(cx: &mut Tes ); } +async fn init_multi_project_test( + paths: &[&str], + cx: &mut TestAppContext, +) -> (Arc, Entity) { + agent_ui::test_support::init_test(cx); + cx.update(|cx| { + cx.update_flags(false, vec!["agent-v2".into()]); + ThreadStore::init_global(cx); + ThreadMetadataStore::init_global(cx); + language_model::LanguageModelRegistry::test(cx); + prompt_store::init(cx); + }); + let fs = FakeFs::new(cx.executor()); + for path in paths { + fs.insert_tree(path, serde_json::json!({ ".git": {}, "src": {} })) + .await; + } + cx.update(|cx| ::set_global(fs.clone(), cx)); + let project = + project::Project::test(fs.clone() as Arc, [paths[0].as_ref()], cx).await; + (fs, project) +} + +async fn add_test_project( + path: &str, + fs: &Arc, + multi_workspace: &Entity, + cx: &mut gpui::VisualTestContext, +) -> Entity { + let project = project::Project::test(fs.clone() as Arc, [path.as_ref()], cx).await; + let workspace = multi_workspace.update_in(cx, |mw, window, cx| { + mw.test_add_workspace(project, window, cx) + }); + cx.run_until_parked(); + workspace +} + +#[gpui::test] +async fn test_transient_workspace_lifecycle(cx: &mut TestAppContext) { + let (fs, project_a) = + init_multi_project_test(&["/project-a", "/project-b", "/project-c"], cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx)); + let _sidebar = setup_sidebar_closed(&multi_workspace, cx); + + // Sidebar starts closed. Initial workspace A is transient. + let workspace_a = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); + assert!(!multi_workspace.read_with(cx, |mw, _| mw.sidebar_open())); + assert_eq!( + multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()), + 1 + ); + assert!(multi_workspace.read_with(cx, |mw, _| mw.workspace() == &workspace_a)); + + // Add B — replaces A as the transient workspace. + let workspace_b = add_test_project("/project-b", &fs, &multi_workspace, cx).await; + assert_eq!( + multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()), + 1 + ); + assert!(multi_workspace.read_with(cx, |mw, _| mw.workspace() == &workspace_b)); + + // Add C — replaces B as the transient workspace. + let workspace_c = add_test_project("/project-c", &fs, &multi_workspace, cx).await; + assert_eq!( + multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()), + 1 + ); + assert!(multi_workspace.read_with(cx, |mw, _| mw.workspace() == &workspace_c)); +} + +#[gpui::test] +async fn test_transient_workspace_retained(cx: &mut TestAppContext) { + let (fs, project_a) = init_multi_project_test( + &["/project-a", "/project-b", "/project-c", "/project-d"], + cx, + ) + .await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx)); + let _sidebar = setup_sidebar(&multi_workspace, cx); + assert!(multi_workspace.read_with(cx, |mw, _| mw.sidebar_open())); + + // Add B — retained since sidebar is open. + let workspace_a = add_test_project("/project-b", &fs, &multi_workspace, cx).await; + assert_eq!( + multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()), + 2 + ); + + // Switch to A — B survives. (Switching from one internal workspace, to another) + multi_workspace.update_in(cx, |mw, window, cx| mw.activate(workspace_a, window, cx)); + cx.run_until_parked(); + assert_eq!( + multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()), + 2 + ); + + // Close sidebar — both A and B remain retained. + multi_workspace.update_in(cx, |mw, window, cx| mw.close_sidebar(window, cx)); + cx.run_until_parked(); + assert_eq!( + multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()), + 2 + ); + + // Add C — added as new transient workspace. (switching from retained, to transient) + let workspace_c = add_test_project("/project-c", &fs, &multi_workspace, cx).await; + assert_eq!( + multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()), + 3 + ); + assert!(multi_workspace.read_with(cx, |mw, _| mw.workspace() == &workspace_c)); + + // Add D — replaces C as the transient workspace (Have retained and transient workspaces, transient workspace is dropped) + let workspace_d = add_test_project("/project-d", &fs, &multi_workspace, cx).await; + assert_eq!( + multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()), + 3 + ); + assert!(multi_workspace.read_with(cx, |mw, _| mw.workspace() == &workspace_d)); +} + +#[gpui::test] +async fn test_transient_workspace_promotion(cx: &mut TestAppContext) { + let (fs, project_a) = + init_multi_project_test(&["/project-a", "/project-b", "/project-c"], cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx)); + setup_sidebar_closed(&multi_workspace, cx); + + // Add B — replaces A as the transient workspace (A is discarded). + let workspace_b = add_test_project("/project-b", &fs, &multi_workspace, cx).await; + assert_eq!( + multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()), + 1 + ); + assert!(multi_workspace.read_with(cx, |mw, _| mw.workspace() == &workspace_b)); + + // Open sidebar — promotes the transient B to retained. + multi_workspace.update_in(cx, |mw, window, cx| { + mw.toggle_sidebar(window, cx); + }); + cx.run_until_parked(); + assert_eq!( + multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()), + 1 + ); + assert!(multi_workspace.read_with(cx, |mw, _| mw.workspaces().any(|w| w == &workspace_b))); + + // Close sidebar — the retained B remains. + multi_workspace.update_in(cx, |mw, window, cx| { + mw.toggle_sidebar(window, cx); + }); + + // Add C — added as new transient workspace. + let workspace_c = add_test_project("/project-c", &fs, &multi_workspace, cx).await; + assert_eq!( + multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()), + 2 + ); + assert!(multi_workspace.read_with(cx, |mw, _| mw.workspace() == &workspace_c)); +} + #[gpui::test] async fn test_legacy_thread_with_canonical_path_opens_main_repo_workspace(cx: &mut TestAppContext) { init_test(cx); @@ -4843,12 +5032,12 @@ async fn test_legacy_thread_with_canonical_path_opens_main_repo_workspace(cx: &m // Verify only 1 workspace before clicking. assert_eq!( - multi_workspace.read_with(cx, |mw, _| mw.workspaces().len()), + multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()), 1, ); // Focus and select the legacy thread, then confirm. - open_and_focus_sidebar(&sidebar, cx); + focus_sidebar(&sidebar, cx); let thread_index = sidebar.read_with(cx, |sidebar, _| { sidebar .contents @@ -5057,7 +5246,12 @@ mod property_test { match operation { Operation::SaveThread { workspace_index } => { let project = multi_workspace.read_with(cx, |mw, cx| { - mw.workspaces()[workspace_index].read(cx).project().clone() + mw.workspaces() + .nth(workspace_index) + .unwrap() + .read(cx) + .project() + .clone() }); save_thread_to_path(state, &project, cx); } @@ -5144,7 +5338,7 @@ mod property_test { } Operation::RemoveWorkspace { index } => { let removed = multi_workspace.update_in(cx, |mw, window, cx| { - let workspace = mw.workspaces()[index].clone(); + let workspace = mw.workspaces().nth(index).unwrap().clone(); mw.remove(&workspace, window, cx) }); if removed { @@ -5158,8 +5352,8 @@ mod property_test { } } Operation::SwitchWorkspace { index } => { - let workspace = - multi_workspace.read_with(cx, |mw, _| mw.workspaces()[index].clone()); + let workspace = multi_workspace + .read_with(cx, |mw, _| mw.workspaces().nth(index).unwrap().clone()); multi_workspace.update_in(cx, |mw, window, cx| { mw.activate(workspace, window, cx); }); @@ -5209,8 +5403,9 @@ mod property_test { .await; // Re-scan the main workspace's project so it discovers the new worktree. - let main_workspace = - multi_workspace.read_with(cx, |mw, _| mw.workspaces()[workspace_index].clone()); + let main_workspace = multi_workspace.read_with(cx, |mw, _| { + mw.workspaces().nth(workspace_index).unwrap().clone() + }); let main_project = main_workspace.read_with(cx, |ws, _| ws.project().clone()); main_project .update(cx, |p, cx| p.git_scans_complete(cx)) @@ -5297,7 +5492,11 @@ mod property_test { let Some(multi_workspace) = sidebar.multi_workspace.upgrade() else { anyhow::bail!("sidebar should still have an associated multi-workspace"); }; - let workspaces = multi_workspace.read(cx).workspaces().to_vec(); + let workspaces = multi_workspace + .read(cx) + .workspaces() + .cloned() + .collect::>(); let thread_store = ThreadMetadataStore::global(cx); let sidebar_thread_ids: HashSet = sidebar diff --git a/crates/title_bar/src/title_bar.rs b/crates/title_bar/src/title_bar.rs index 440249907adb6d29602ad8e950d0fd26a2d1c31d..dfcd933dc20df9a6f6643402719f2ec1143cc7fe 100644 --- a/crates/title_bar/src/title_bar.rs +++ b/crates/title_bar/src/title_bar.rs @@ -740,7 +740,6 @@ impl TitleBar { .map(|mw| { mw.read(cx) .workspaces() - .iter() .filter_map(|ws| ws.read(cx).database_id()) .collect() }) @@ -803,7 +802,6 @@ impl TitleBar { .map(|mw| { mw.read(cx) .workspaces() - .iter() .filter_map(|ws| ws.read(cx).database_id()) .collect() }) diff --git a/crates/workspace/src/multi_workspace.rs b/crates/workspace/src/multi_workspace.rs index a0c5eaabc629073dd9a46ac1b5073ddfbd26bd28..a61ad3576c57ecd8b1811363d6b5607ead737821 100644 --- a/crates/workspace/src/multi_workspace.rs +++ b/crates/workspace/src/multi_workspace.rs @@ -40,10 +40,7 @@ actions!( CloseWorkspaceSidebar, /// Moves focus to or from the workspace sidebar without closing it. FocusWorkspaceSidebar, - /// Switches to the next workspace. - NextWorkspace, - /// Switches to the previous workspace. - PreviousWorkspace, + //TODO: Restore next/previous workspace ] ); @@ -221,10 +218,57 @@ impl SidebarHandle for Entity { } } +/// Tracks which workspace the user is currently looking at. +/// +/// `Persistent` workspaces live in the `workspaces` vec and are shown in the +/// sidebar. `Transient` workspaces exist outside the vec and are discarded +/// when the user switches away. +enum ActiveWorkspace { + /// A persistent workspace, identified by index into the `workspaces` vec. + Persistent(usize), + /// A workspace not in the `workspaces` vec that will be discarded on + /// switch or promoted to persistent when the sidebar is opened. + Transient(Entity), +} + +impl ActiveWorkspace { + fn persistent_index(&self) -> Option { + match self { + Self::Persistent(index) => Some(*index), + Self::Transient(_) => None, + } + } + + fn transient_workspace(&self) -> Option<&Entity> { + match self { + Self::Transient(workspace) => Some(workspace), + Self::Persistent(_) => None, + } + } + + /// Sets the active workspace to transient, returning the previous + /// transient workspace (if any). + fn set_transient(&mut self, workspace: Entity) -> Option> { + match std::mem::replace(self, Self::Transient(workspace)) { + Self::Transient(old) => Some(old), + Self::Persistent(_) => None, + } + } + + /// Sets the active workspace to persistent at the given index, + /// returning the previous transient workspace (if any). + fn set_persistent(&mut self, index: usize) -> Option> { + match std::mem::replace(self, Self::Persistent(index)) { + Self::Transient(workspace) => Some(workspace), + Self::Persistent(_) => None, + } + } +} + pub struct MultiWorkspace { window_id: WindowId, workspaces: Vec>, - active_workspace_index: usize, + active_workspace: ActiveWorkspace, project_group_keys: Vec, sidebar: Option>, sidebar_open: bool, @@ -260,12 +304,15 @@ impl MultiWorkspace { } }); let quit_subscription = cx.on_app_quit(Self::app_will_quit); - let settings_subscription = - cx.observe_global_in::(window, |this, window, cx| { - if DisableAiSettings::get_global(cx).disable_ai && this.sidebar_open { - this.close_sidebar(window, cx); + let settings_subscription = cx.observe_global_in::(window, { + let mut previous_disable_ai = DisableAiSettings::get_global(cx).disable_ai; + move |this, window, cx| { + if DisableAiSettings::get_global(cx).disable_ai != previous_disable_ai { + this.collapse_to_single_workspace(window, cx); + previous_disable_ai = DisableAiSettings::get_global(cx).disable_ai; } - }); + } + }); Self::subscribe_to_workspace(&workspace, window, cx); let weak_self = cx.weak_entity(); workspace.update(cx, |workspace, cx| { @@ -273,9 +320,9 @@ impl MultiWorkspace { }); Self { window_id: window.window_handle().window_id(), - project_group_keys: vec![workspace.read(cx).project_group_key(cx)], - workspaces: vec![workspace], - active_workspace_index: 0, + project_group_keys: Vec::new(), + workspaces: Vec::new(), + active_workspace: ActiveWorkspace::Transient(workspace), sidebar: None, sidebar_open: false, sidebar_overlay: None, @@ -337,7 +384,7 @@ impl MultiWorkspace { return; } - if self.sidebar_open { + if self.sidebar_open() { self.close_sidebar(window, cx); } else { self.open_sidebar(cx); @@ -353,7 +400,7 @@ impl MultiWorkspace { return; } - if self.sidebar_open { + if self.sidebar_open() { self.close_sidebar(window, cx); } } @@ -363,7 +410,7 @@ impl MultiWorkspace { return; } - if self.sidebar_open { + if self.sidebar_open() { let sidebar_is_focused = self .sidebar .as_ref() @@ -388,8 +435,13 @@ impl MultiWorkspace { pub fn open_sidebar(&mut self, cx: &mut Context) { self.sidebar_open = true; + if let ActiveWorkspace::Transient(workspace) = &self.active_workspace { + let workspace = workspace.clone(); + let index = self.promote_transient(workspace, cx); + self.active_workspace = ActiveWorkspace::Persistent(index); + } let sidebar_focus_handle = self.sidebar.as_ref().map(|s| s.focus_handle(cx)); - for workspace in &self.workspaces { + for workspace in self.workspaces.iter() { workspace.update(cx, |workspace, _cx| { workspace.set_sidebar_focus_handle(sidebar_focus_handle.clone()); }); @@ -400,7 +452,7 @@ impl MultiWorkspace { pub fn close_sidebar(&mut self, window: &mut Window, cx: &mut Context) { self.sidebar_open = false; - for workspace in &self.workspaces { + for workspace in self.workspaces.iter() { workspace.update(cx, |workspace, _cx| { workspace.set_sidebar_focus_handle(None); }); @@ -415,7 +467,7 @@ impl MultiWorkspace { pub fn close_window(&mut self, _: &CloseWindow, window: &mut Window, cx: &mut Context) { cx.spawn_in(window, async move |this, cx| { let workspaces = this.update(cx, |multi_workspace, _cx| { - multi_workspace.workspaces().to_vec() + multi_workspace.workspaces().cloned().collect::>() })?; for workspace in workspaces { @@ -657,6 +709,12 @@ impl MultiWorkspace { return Task::ready(Ok(workspace)); } + if let Some(transient) = self.active_workspace.transient_workspace() { + if transient.read(cx).project_group_key(cx).path_list() == &path_list { + return Task::ready(Ok(transient.clone())); + } + } + let paths = path_list.paths().to_vec(); let app_state = self.workspace().read(cx).app_state().clone(); let requesting_window = window.window_handle().downcast::(); @@ -680,25 +738,23 @@ impl MultiWorkspace { } pub fn workspace(&self) -> &Entity { - &self.workspaces[self.active_workspace_index] - } - - pub fn workspaces(&self) -> &[Entity] { - &self.workspaces + match &self.active_workspace { + ActiveWorkspace::Persistent(index) => &self.workspaces[*index], + ActiveWorkspace::Transient(workspace) => workspace, + } } - pub fn active_workspace_index(&self) -> usize { - self.active_workspace_index + pub fn workspaces(&self) -> impl Iterator> { + self.workspaces + .iter() + .chain(self.active_workspace.transient_workspace()) } - /// Adds a workspace to this window without changing which workspace is - /// active. + /// Adds a workspace to this window as persistent without changing which + /// workspace is active. Unlike `activate()`, this always inserts into the + /// persistent list regardless of sidebar state — it's used for system- + /// initiated additions like deserialization and worktree discovery. pub fn add(&mut self, workspace: Entity, window: &Window, cx: &mut Context) { - if !self.multi_workspace_enabled(cx) { - self.set_single_workspace(workspace, cx); - return; - } - self.insert_workspace(workspace, window, cx); } @@ -709,26 +765,74 @@ impl MultiWorkspace { window: &mut Window, cx: &mut Context, ) { - if !self.multi_workspace_enabled(cx) { - self.set_single_workspace(workspace, cx); + // Re-activating the current workspace is a no-op. + if self.workspace() == &workspace { + self.focus_active_workspace(window, cx); return; } - let index = self.insert_workspace(workspace, &*window, cx); - let changed = self.active_workspace_index != index; - self.active_workspace_index = index; - if changed { - cx.emit(MultiWorkspaceEvent::ActiveWorkspaceChanged); - self.serialize(cx); + // Resolve where we're going. + let new_index = if let Some(index) = self.workspaces.iter().position(|w| *w == workspace) { + Some(index) + } else if self.sidebar_open { + Some(self.insert_workspace(workspace.clone(), &*window, cx)) + } else { + None + }; + + // Transition the active workspace. + if let Some(index) = new_index { + if let Some(old) = self.active_workspace.set_persistent(index) { + if self.sidebar_open { + self.promote_transient(old, cx); + } else { + self.detach_workspace(&old, cx); + cx.emit(MultiWorkspaceEvent::WorkspaceRemoved(old.entity_id())); + } + } + } else { + Self::subscribe_to_workspace(&workspace, window, cx); + let weak_self = cx.weak_entity(); + workspace.update(cx, |workspace, cx| { + workspace.set_multi_workspace(weak_self, cx); + }); + if let Some(old) = self.active_workspace.set_transient(workspace) { + self.detach_workspace(&old, cx); + cx.emit(MultiWorkspaceEvent::WorkspaceRemoved(old.entity_id())); + } } + + cx.emit(MultiWorkspaceEvent::ActiveWorkspaceChanged); + self.serialize(cx); self.focus_active_workspace(window, cx); cx.notify(); } - fn set_single_workspace(&mut self, workspace: Entity, cx: &mut Context) { - self.workspaces[0] = workspace; - self.active_workspace_index = 0; - cx.emit(MultiWorkspaceEvent::ActiveWorkspaceChanged); + /// Promotes a former transient workspace into the persistent list. + /// Returns the index of the newly inserted workspace. + fn promote_transient(&mut self, workspace: Entity, cx: &mut Context) -> usize { + let project_group_key = workspace.read(cx).project().read(cx).project_group_key(cx); + self.add_project_group_key(project_group_key); + self.workspaces.push(workspace.clone()); + cx.emit(MultiWorkspaceEvent::WorkspaceAdded(workspace)); + self.workspaces.len() - 1 + } + + /// Collapses to a single transient workspace, discarding all persistent + /// workspaces. Used when multi-workspace is disabled (e.g. disable_ai). + fn collapse_to_single_workspace(&mut self, window: &mut Window, cx: &mut Context) { + if self.sidebar_open { + self.close_sidebar(window, cx); + } + let active = self.workspace().clone(); + for workspace in std::mem::take(&mut self.workspaces) { + if workspace != active { + self.detach_workspace(&workspace, cx); + cx.emit(MultiWorkspaceEvent::WorkspaceRemoved(workspace.entity_id())); + } + } + self.project_group_keys.clear(); + self.active_workspace = ActiveWorkspace::Transient(active); cx.notify(); } @@ -784,7 +888,7 @@ impl MultiWorkspace { } fn sync_sidebar_to_workspace(&self, workspace: &Entity, cx: &mut Context) { - if self.sidebar_open { + if self.sidebar_open() { let sidebar_focus_handle = self.sidebar.as_ref().map(|s| s.focus_handle(cx)); workspace.update(cx, |workspace, _| { workspace.set_sidebar_focus_handle(sidebar_focus_handle); @@ -792,30 +896,6 @@ impl MultiWorkspace { } } - fn cycle_workspace(&mut self, delta: isize, window: &mut Window, cx: &mut Context) { - let count = self.workspaces.len() as isize; - if count <= 1 { - return; - } - let current = self.active_workspace_index as isize; - let next = ((current + delta).rem_euclid(count)) as usize; - let workspace = self.workspaces[next].clone(); - self.activate(workspace, window, cx); - } - - fn next_workspace(&mut self, _: &NextWorkspace, window: &mut Window, cx: &mut Context) { - self.cycle_workspace(1, window, cx); - } - - fn previous_workspace( - &mut self, - _: &PreviousWorkspace, - window: &mut Window, - cx: &mut Context, - ) { - self.cycle_workspace(-1, window, cx); - } - pub(crate) fn serialize(&mut self, cx: &mut Context) { self._serialize_task = Some(cx.spawn(async move |this, cx| { let Some((window_id, state)) = this @@ -1070,7 +1150,7 @@ impl MultiWorkspace { let new_workspace = cx.new(|cx| Workspace::new(None, project, app_state, window, cx)); self.workspaces[0] = new_workspace.clone(); - self.active_workspace_index = 0; + self.active_workspace = ActiveWorkspace::Persistent(0); Self::subscribe_to_workspace(&new_workspace, window, cx); @@ -1090,10 +1170,12 @@ impl MultiWorkspace { } else { let removed_workspace = 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; + if let Some(active_index) = self.active_workspace.persistent_index() { + if active_index >= self.workspaces.len() { + self.active_workspace = ActiveWorkspace::Persistent(self.workspaces.len() - 1); + } else if active_index > index { + self.active_workspace = ActiveWorkspace::Persistent(active_index - 1); + } } self.detach_workspace(&removed_workspace, cx); @@ -1343,8 +1425,6 @@ impl Render for MultiWorkspace { this.focus_sidebar(window, cx); }, )) - .on_action(cx.listener(Self::next_workspace)) - .on_action(cx.listener(Self::previous_workspace)) .on_action(cx.listener(Self::move_active_workspace_to_new_window)) .on_action(cx.listener( |this: &mut Self, action: &ToggleThreadSwitcher, window, cx| { diff --git a/crates/workspace/src/multi_workspace_tests.rs b/crates/workspace/src/multi_workspace_tests.rs index 3083c23f6e3add91b0389a961567fc88e2043678..ab6ca43d5aff482b637add9083b1ad9d388d7993 100644 --- a/crates/workspace/src/multi_workspace_tests.rs +++ b/crates/workspace/src/multi_workspace_tests.rs @@ -99,6 +99,10 @@ async fn test_project_group_keys_initial(cx: &mut TestAppContext) { let (multi_workspace, cx) = cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + multi_workspace.update(cx, |mw, cx| { + mw.open_sidebar(cx); + }); + multi_workspace.read_with(cx, |mw, _cx| { let keys: Vec<&ProjectGroupKey> = mw.project_group_keys().collect(); assert_eq!(keys.len(), 1, "should have exactly one key on creation"); @@ -125,6 +129,10 @@ async fn test_project_group_keys_add_workspace(cx: &mut TestAppContext) { let (multi_workspace, cx) = cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx)); + multi_workspace.update(cx, |mw, cx| { + mw.open_sidebar(cx); + }); + multi_workspace.read_with(cx, |mw, _cx| { assert_eq!(mw.project_group_keys().count(), 1); }); @@ -162,6 +170,10 @@ async fn test_project_group_keys_duplicate_not_added(cx: &mut TestAppContext) { let (multi_workspace, cx) = cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx)); + multi_workspace.update(cx, |mw, cx| { + mw.open_sidebar(cx); + }); + multi_workspace.update_in(cx, |mw, window, cx| { mw.test_add_workspace(project_a2, window, cx); }); @@ -189,6 +201,10 @@ async fn test_project_group_keys_on_worktree_added(cx: &mut TestAppContext) { let (multi_workspace, cx) = cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + multi_workspace.update(cx, |mw, cx| { + mw.open_sidebar(cx); + }); + // Add a second worktree to the same project. let (worktree, _) = project .update(cx, |project, cx| { @@ -232,6 +248,10 @@ async fn test_project_group_keys_on_worktree_removed(cx: &mut TestAppContext) { let (multi_workspace, cx) = cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + multi_workspace.update(cx, |mw, cx| { + mw.open_sidebar(cx); + }); + // Remove one worktree. let worktree_b_id = project.read_with(cx, |project, cx| { project @@ -282,6 +302,10 @@ async fn test_project_group_keys_across_multiple_workspaces_and_worktree_changes let (multi_workspace, cx) = cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx)); + multi_workspace.update(cx, |mw, cx| { + mw.open_sidebar(cx); + }); + multi_workspace.update_in(cx, |mw, window, cx| { mw.test_add_workspace(project_b, window, cx); }); diff --git a/crates/workspace/src/persistence.rs b/crates/workspace/src/persistence.rs index 644ff0282df216e79d6be24918d29b802e50a0e8..2994e9d0f67d73a30838f922c9b6a0b01b21ed14 100644 --- a/crates/workspace/src/persistence.rs +++ b/crates/workspace/src/persistence.rs @@ -2535,6 +2535,10 @@ mod tests { let (multi_workspace, cx) = cx.add_window_view(|window, cx| MultiWorkspace::test_new(project1.clone(), window, cx)); + multi_workspace.update(cx, |mw, cx| { + mw.open_sidebar(cx); + }); + multi_workspace.update_in(cx, |mw, _, cx| { mw.set_random_database_id(cx); }); @@ -2564,7 +2568,7 @@ mod tests { // --- Remove the second workspace (index 1) --- multi_workspace.update_in(cx, |mw, window, cx| { - let ws = mw.workspaces()[1].clone(); + let ws = mw.workspaces().nth(1).unwrap().clone(); mw.remove(&ws, window, cx); }); @@ -4191,6 +4195,10 @@ mod tests { let (multi_workspace, cx) = cx.add_window_view(|window, cx| MultiWorkspace::test_new(project1.clone(), window, cx)); + multi_workspace.update(cx, |mw, cx| { + mw.open_sidebar(cx); + }); + multi_workspace.update_in(cx, |mw, _, cx| { mw.set_random_database_id(cx); }); @@ -4233,7 +4241,7 @@ mod tests { // Remove workspace at index 1 (the second workspace). multi_workspace.update_in(cx, |mw, window, cx| { - let ws = mw.workspaces()[1].clone(); + let ws = mw.workspaces().nth(1).unwrap().clone(); mw.remove(&ws, window, cx); }); @@ -4288,6 +4296,10 @@ mod tests { let (multi_workspace, cx) = cx.add_window_view(|window, cx| MultiWorkspace::test_new(project1.clone(), window, cx)); + multi_workspace.update(cx, |mw, cx| { + mw.open_sidebar(cx); + }); + multi_workspace.update_in(cx, |mw, _, cx| { mw.workspace().update(cx, |ws, _cx| { ws.set_database_id(ws1_id); @@ -4339,7 +4351,7 @@ mod tests { // Remove workspace2 (index 1). multi_workspace.update_in(cx, |mw, window, cx| { - let ws = mw.workspaces()[1].clone(); + let ws = mw.workspaces().nth(1).unwrap().clone(); mw.remove(&ws, window, cx); }); @@ -4385,6 +4397,10 @@ mod tests { let (multi_workspace, cx) = cx.add_window_view(|window, cx| MultiWorkspace::test_new(project1.clone(), window, cx)); + multi_workspace.update(cx, |mw, cx| { + mw.open_sidebar(cx); + }); + multi_workspace.update_in(cx, |mw, _, cx| { mw.set_random_database_id(cx); }); @@ -4418,7 +4434,7 @@ mod tests { // Remove workspace2 — this pushes a task to pending_removal_tasks. multi_workspace.update_in(cx, |mw, window, cx| { - let ws = mw.workspaces()[1].clone(); + let ws = mw.workspaces().nth(1).unwrap().clone(); mw.remove(&ws, window, cx); }); @@ -4427,7 +4443,6 @@ mod tests { let all_tasks = multi_workspace.update_in(cx, |mw, window, cx| { let mut tasks: Vec> = mw .workspaces() - .iter() .map(|workspace| { workspace.update(cx, |workspace, cx| { workspace.flush_serialization(window, cx) @@ -4747,6 +4762,10 @@ mod tests { let (multi_workspace, cx) = cx .add_window_view(|window, cx| MultiWorkspace::test_new(project_2.clone(), window, cx)); + multi_workspace.update(cx, |mw, cx| { + mw.open_sidebar(cx); + }); + multi_workspace.update_in(cx, |mw, window, cx| { mw.test_add_workspace(project_1.clone(), window, cx); }); diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index cc5d1e8635e9194522fea5506fef4084f8133c53..7979ffe828cbf8c4da5a40a29eaa6537f1433c3c 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -32,8 +32,8 @@ pub use crate::notifications::NotificationFrame; pub use dock::Panel; pub use multi_workspace::{ CloseWorkspaceSidebar, DraggedSidebar, FocusWorkspaceSidebar, MultiWorkspace, - MultiWorkspaceEvent, NextWorkspace, PreviousWorkspace, Sidebar, SidebarEvent, SidebarHandle, - SidebarRenderState, SidebarSide, ToggleWorkspaceSidebar, sidebar_side_context_menu, + MultiWorkspaceEvent, Sidebar, SidebarEvent, SidebarHandle, SidebarRenderState, SidebarSide, + ToggleWorkspaceSidebar, sidebar_side_context_menu, }; pub use path_list::{PathList, SerializedPathList}; pub use toast_layer::{ToastAction, ToastLayer, ToastView}; @@ -9079,7 +9079,7 @@ pub fn workspace_windows_for_location( }; multi_workspace.read(cx).is_ok_and(|multi_workspace| { - multi_workspace.workspaces().iter().any(|workspace| { + multi_workspace.workspaces().any(|workspace| { match workspace.read(cx).workspace_location(cx) { WorkspaceLocation::Location(location, _) => { match (&location, serialized_location) { @@ -10741,6 +10741,12 @@ mod tests { cx.add_window(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx)); cx.run_until_parked(); + multi_workspace_handle + .update(cx, |mw, _window, cx| { + mw.open_sidebar(cx); + }) + .unwrap(); + let workspace_a = multi_workspace_handle .read_with(cx, |mw, _| mw.workspace().clone()) .unwrap(); @@ -10754,7 +10760,7 @@ mod tests { // Activate workspace A multi_workspace_handle .update(cx, |mw, window, cx| { - let workspace = mw.workspaces()[0].clone(); + let workspace = mw.workspaces().next().unwrap().clone(); mw.activate(workspace, window, cx); }) .unwrap(); @@ -10776,7 +10782,7 @@ mod tests { // Verify workspace A is active multi_workspace_handle .read_with(cx, |mw, _| { - assert_eq!(mw.active_workspace_index(), 0); + assert_eq!(mw.workspace(), &workspace_a); }) .unwrap(); @@ -10792,8 +10798,8 @@ mod tests { multi_workspace_handle .read_with(cx, |mw, _| { assert_eq!( - mw.active_workspace_index(), - 1, + mw.workspace(), + &workspace_b, "workspace B should be activated when it prompts" ); }) @@ -14511,6 +14517,12 @@ mod tests { cx.add_window(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx)); cx.run_until_parked(); + multi_workspace_handle + .update(cx, |mw, _window, cx| { + mw.open_sidebar(cx); + }) + .unwrap(); + let workspace_a = multi_workspace_handle .read_with(cx, |mw, _| mw.workspace().clone()) .unwrap(); @@ -14524,7 +14536,7 @@ mod tests { // Switch to workspace A multi_workspace_handle .update(cx, |mw, window, cx| { - let workspace = mw.workspaces()[0].clone(); + let workspace = mw.workspaces().next().unwrap().clone(); mw.activate(workspace, window, cx); }) .unwrap(); @@ -14570,7 +14582,7 @@ mod tests { // Switch to workspace B multi_workspace_handle .update(cx, |mw, window, cx| { - let workspace = mw.workspaces()[1].clone(); + let workspace = mw.workspaces().nth(1).unwrap().clone(); mw.activate(workspace, window, cx); }) .unwrap(); @@ -14579,7 +14591,7 @@ mod tests { // Switch back to workspace A multi_workspace_handle .update(cx, |mw, window, cx| { - let workspace = mw.workspaces()[0].clone(); + let workspace = mw.workspaces().next().unwrap().clone(); mw.activate(workspace, window, cx); }) .unwrap(); diff --git a/crates/zed/src/visual_test_runner.rs b/crates/zed/src/visual_test_runner.rs index f1ed73fe89f0980a2705631063dcf4efbbe84bfb..b59123a1a159487f802210f3916e16856daf8e61 100644 --- a/crates/zed/src/visual_test_runner.rs +++ b/crates/zed/src/visual_test_runner.rs @@ -2606,7 +2606,7 @@ fn run_multi_workspace_sidebar_visual_tests( // 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 workspace1 = multi_workspace.workspaces().next().unwrap(); let project = workspace1.read(cx).project().clone(); project.update(cx, |project, cx| { project.find_or_create_worktree(&workspace1_dir, true, cx) @@ -2625,7 +2625,7 @@ fn run_multi_workspace_sidebar_visual_tests( // 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 workspace2 = multi_workspace.workspaces().nth(1).unwrap(); let project = workspace2.read(cx).project().clone(); project.update(cx, |project, cx| { project.find_or_create_worktree(&workspace2_dir, true, cx) @@ -2644,7 +2644,7 @@ fn run_multi_workspace_sidebar_visual_tests( // Switch to workspace 1 so it's highlighted as active (index 0) multi_workspace_window .update(cx, |multi_workspace, window, cx| { - let workspace = multi_workspace.workspaces()[0].clone(); + let workspace = multi_workspace.workspaces().next().unwrap().clone(); multi_workspace.activate(workspace, window, cx); }) .context("Failed to activate workspace 1")?; @@ -2672,7 +2672,7 @@ fn run_multi_workspace_sidebar_visual_tests( let save_tasks = multi_workspace_window .update(cx, |multi_workspace, _window, cx| { let thread_store = agent::ThreadStore::global(cx); - let workspaces = multi_workspace.workspaces().to_vec(); + let workspaces: Vec<_> = multi_workspace.workspaces().cloned().collect(); let mut tasks = Vec::new(); for (index, workspace) in workspaces.iter().enumerate() { @@ -3211,7 +3211,7 @@ edition = "2021" // Add the git project as a worktree let add_worktree_task = workspace_window .update(cx, |multi_workspace, _window, cx| { - let workspace = &multi_workspace.workspaces()[0]; + let workspace = multi_workspace.workspaces().next().unwrap(); let project = workspace.read(cx).project().clone(); project.update(cx, |project, cx| { project.find_or_create_worktree(&project_path, true, cx) @@ -3236,7 +3236,7 @@ edition = "2021" // Open the project panel let (weak_workspace, async_window_cx) = workspace_window .update(cx, |multi_workspace, window, cx| { - let workspace = &multi_workspace.workspaces()[0]; + let workspace = multi_workspace.workspaces().next().unwrap(); (workspace.read(cx).weak_handle(), window.to_async(cx)) }) .context("Failed to get workspace handle")?; @@ -3250,7 +3250,7 @@ edition = "2021" workspace_window .update(cx, |multi_workspace, window, cx| { - let workspace = &multi_workspace.workspaces()[0]; + let workspace = multi_workspace.workspaces().next().unwrap(); workspace.update(cx, |workspace, cx| { workspace.add_panel(project_panel, window, cx); workspace.open_panel::(window, cx); @@ -3263,7 +3263,7 @@ edition = "2021" // Open main.rs in the editor let open_file_task = workspace_window .update(cx, |multi_workspace, window, cx| { - let workspace = &multi_workspace.workspaces()[0]; + let workspace = multi_workspace.workspaces().next().unwrap(); workspace.update(cx, |workspace, cx| { let worktree = workspace.project().read(cx).worktrees(cx).next(); if let Some(worktree) = worktree { @@ -3291,7 +3291,7 @@ edition = "2021" // Load the AgentPanel let (weak_workspace, async_window_cx) = workspace_window .update(cx, |multi_workspace, window, cx| { - let workspace = &multi_workspace.workspaces()[0]; + let workspace = multi_workspace.workspaces().next().unwrap(); (workspace.read(cx).weak_handle(), window.to_async(cx)) }) .context("Failed to get workspace handle for agent panel")?; @@ -3335,7 +3335,7 @@ edition = "2021" workspace_window .update(cx, |multi_workspace, window, cx| { - let workspace = &multi_workspace.workspaces()[0]; + let workspace = multi_workspace.workspaces().next().unwrap(); workspace.update(cx, |workspace, cx| { workspace.add_panel(panel.clone(), window, cx); workspace.open_panel::(window, cx); @@ -3512,7 +3512,7 @@ edition = "2021" .is_none() }); let workspace_count = workspace_window.update(cx, |multi_workspace, _window, _cx| { - multi_workspace.workspaces().len() + multi_workspace.workspaces().count() })?; if workspace_count == 2 && status_cleared { creation_complete = true; @@ -3531,7 +3531,7 @@ edition = "2021" // error state by injecting the stub server, and shrink the panel so the // editor content is visible. workspace_window.update(cx, |multi_workspace, window, cx| { - let new_workspace = &multi_workspace.workspaces()[1]; + let new_workspace = multi_workspace.workspaces().nth(1).unwrap(); new_workspace.update(cx, |workspace, cx| { if let Some(new_panel) = workspace.panel::(cx) { new_panel.update(cx, |panel, cx| { @@ -3544,7 +3544,7 @@ edition = "2021" // Type and send a message so the thread target dropdown disappears. let new_panel = workspace_window.update(cx, |multi_workspace, _window, cx| { - let new_workspace = &multi_workspace.workspaces()[1]; + let new_workspace = multi_workspace.workspaces().nth(1).unwrap(); new_workspace.read(cx).panel::(cx) })?; if let Some(new_panel) = new_panel { @@ -3585,7 +3585,7 @@ edition = "2021" workspace_window .update(cx, |multi_workspace, _window, cx| { - let workspace = &multi_workspace.workspaces()[0]; + let workspace = multi_workspace.workspaces().next().unwrap(); let project = workspace.read(cx).project().clone(); project.update(cx, |project, cx| { let worktree_ids: Vec<_> = diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index ed49236a9da6b69f80c8c981eaddaa16ca69face..03e128415e1aa8390d1b95816755d3644064dada 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -1524,7 +1524,7 @@ fn quit(_: &Quit, cx: &mut App) { let window = *window; let workspaces = window .update(cx, |multi_workspace, _, _| { - multi_workspace.workspaces().to_vec() + multi_workspace.workspaces().cloned().collect::>() }) .log_err(); @@ -2458,7 +2458,6 @@ mod tests { .update(cx, |multi_workspace, window, cx| { let mut tasks = multi_workspace .workspaces() - .iter() .map(|workspace| { workspace.update(cx, |workspace, cx| { workspace.flush_serialization(window, cx) @@ -2610,7 +2609,7 @@ mod tests { cx.run_until_parked(); multi_workspace_1 .update(cx, |multi_workspace, _window, cx| { - assert_eq!(multi_workspace.workspaces().len(), 2); + assert_eq!(multi_workspace.workspaces().count(), 2); assert!(multi_workspace.sidebar_open()); let workspace = multi_workspace.workspace().read(cx); assert_eq!( @@ -5512,6 +5511,11 @@ mod tests { let project = project1.clone(); |window, cx| MultiWorkspace::test_new(project, window, cx) }); + window + .update(cx, |multi_workspace, _, cx| { + multi_workspace.open_sidebar(cx); + }) + .unwrap(); cx.run_until_parked(); assert_eq!(cx.windows().len(), 1, "Should start with 1 window"); @@ -5534,7 +5538,7 @@ mod tests { let workspace1 = window .read_with(cx, |multi_workspace, _| { - multi_workspace.workspaces()[0].clone() + multi_workspace.workspaces().next().unwrap().clone() }) .unwrap(); @@ -5543,8 +5547,8 @@ mod tests { multi_workspace.activate(workspace2.clone(), window, cx); multi_workspace.activate(workspace3.clone(), window, cx); // Switch back to workspace1 for test setup - multi_workspace.activate(workspace1, window, cx); - assert_eq!(multi_workspace.active_workspace_index(), 0); + multi_workspace.activate(workspace1.clone(), window, cx); + assert_eq!(multi_workspace.workspace(), &workspace1); }) .unwrap(); @@ -5553,8 +5557,8 @@ mod tests { // 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); + assert_eq!(multi_workspace.workspaces().count(), 3); + assert_eq!(multi_workspace.workspace(), &workspace1); }) .unwrap(); assert_eq!(cx.windows().len(), 1); @@ -5577,8 +5581,8 @@ mod tests { window .read_with(cx, |multi_workspace, cx| { assert_eq!( - multi_workspace.active_workspace_index(), - 2, + multi_workspace.workspace(), + &workspace3, "Should have switched to workspace 3 which contains /dir3" ); let active_item = multi_workspace @@ -5611,8 +5615,8 @@ mod tests { window .read_with(cx, |multi_workspace, cx| { assert_eq!( - multi_workspace.active_workspace_index(), - 1, + multi_workspace.workspace(), + &workspace2, "Should have switched to workspace 2 which contains /dir2" ); let active_item = multi_workspace @@ -5660,8 +5664,8 @@ mod tests { window .read_with(cx, |multi_workspace, cx| { assert_eq!( - multi_workspace.active_workspace_index(), - 0, + multi_workspace.workspace(), + &workspace1, "Should have switched back to workspace 0 which contains /dir1" ); let active_item = multi_workspace @@ -5711,6 +5715,11 @@ mod tests { let project = project1.clone(); |window, cx| MultiWorkspace::test_new(project, window, cx) }); + window1 + .update(cx, |multi_workspace, _, cx| { + multi_workspace.open_sidebar(cx); + }) + .unwrap(); cx.run_until_parked(); @@ -5737,6 +5746,11 @@ mod tests { let project = project3.clone(); |window, cx| MultiWorkspace::test_new(project, window, cx) }); + window2 + .update(cx, |multi_workspace, _, cx| { + multi_workspace.open_sidebar(cx); + }) + .unwrap(); cx.run_until_parked(); assert_eq!(cx.windows().len(), 2); @@ -5771,7 +5785,7 @@ mod tests { // Verify workspace1_1 is active window1 .read_with(cx, |multi_workspace, _| { - assert_eq!(multi_workspace.active_workspace_index(), 0); + assert_eq!(multi_workspace.workspace(), &workspace1_1); }) .unwrap(); @@ -5837,7 +5851,7 @@ mod tests { // 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); + assert_eq!(multi_workspace.workspace(), &workspace1_1); }) .unwrap(); @@ -5848,8 +5862,8 @@ mod tests { window1 .read_with(cx, |multi_workspace, _| { assert_eq!( - multi_workspace.active_workspace_index(), - 1, + multi_workspace.workspace(), + &workspace1_2, "Case 2: Non-active workspace should be activated when it has dirty item" ); }) @@ -6002,6 +6016,12 @@ mod tests { .await .expect("failed to open first workspace"); + window_a + .update(cx, |multi_workspace, _, cx| { + multi_workspace.open_sidebar(cx); + }) + .unwrap(); + window_a .update(cx, |multi_workspace, window, cx| { multi_workspace.open_project(vec![dir2.into()], OpenMode::Activate, window, cx) @@ -6028,13 +6048,19 @@ mod tests { .await .expect("failed to open third workspace"); + window_b + .update(cx, |multi_workspace, _, cx| { + multi_workspace.open_sidebar(cx); + }) + .unwrap(); + // 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| { - let workspace = multi_workspace.workspaces()[0].clone(); + let workspace = multi_workspace.workspaces().next().unwrap().clone(); multi_workspace.activate(workspace, window, cx); }) .unwrap(); @@ -6150,7 +6176,7 @@ mod tests { ProjectGroupKey::new(None, PathList::new(&[dir2])), ] ); - assert_eq!(mw.workspaces().len(), 1); + assert_eq!(mw.workspaces().count(), 1); }) .unwrap(); @@ -6161,7 +6187,7 @@ mod tests { mw.project_group_keys().cloned().collect::>(), vec![ProjectGroupKey::new(None, PathList::new(&[dir3]))] ); - assert_eq!(mw.workspaces().len(), 1); + assert_eq!(mw.workspaces().count(), 1); }) .unwrap(); }