1use anyhow::Result;
2use fs::Fs;
3
4use gpui::PathPromptOptions;
5use gpui::{
6 AnyView, App, Context, DragMoveEvent, Entity, EntityId, EventEmitter, FocusHandle, Focusable,
7 ManagedView, MouseButton, Pixels, Render, Subscription, Task, Tiling, Window, WindowId,
8 actions, deferred, px,
9};
10pub use project::ProjectGroupKey;
11use project::{DirectoryLister, DisableAiSettings, Project};
12use release_channel::ReleaseChannel;
13use remote::RemoteConnectionOptions;
14use settings::Settings;
15pub use settings::SidebarSide;
16use std::future::Future;
17use std::path::Path;
18use std::path::PathBuf;
19use ui::prelude::*;
20use util::ResultExt;
21use util::path_list::PathList;
22use zed_actions::agents_sidebar::ToggleThreadSwitcher;
23
24use agent_settings::AgentSettings;
25use settings::SidebarDockPosition;
26use ui::{ContextMenu, right_click_menu};
27
28const SIDEBAR_RESIZE_HANDLE_SIZE: Pixels = px(6.0);
29
30use crate::open_remote_project_with_existing_connection;
31use crate::{
32 CloseIntent, CloseWindow, DockPosition, Event as WorkspaceEvent, Item, ModalView, OpenMode,
33 Panel, Workspace, WorkspaceId, client_side_decorations,
34 persistence::model::MultiWorkspaceState,
35};
36
37actions!(
38 multi_workspace,
39 [
40 /// Toggles the workspace switcher sidebar.
41 ToggleWorkspaceSidebar,
42 /// Closes the workspace sidebar.
43 CloseWorkspaceSidebar,
44 /// Moves focus to or from the workspace sidebar without closing it.
45 FocusWorkspaceSidebar,
46 /// Activates the next project in the sidebar.
47 NextProject,
48 /// Activates the previous project in the sidebar.
49 PreviousProject,
50 /// Activates the next thread in sidebar order.
51 NextThread,
52 /// Activates the previous thread in sidebar order.
53 PreviousThread,
54 /// Expands the thread list for the current project to show more threads.
55 ShowMoreThreads,
56 /// Collapses the thread list for the current project to show fewer threads.
57 ShowFewerThreads,
58 /// Creates a new thread in the current workspace.
59 NewThread,
60 /// Moves the active project to a new window.
61 MoveProjectToNewWindow,
62 ]
63);
64
65#[derive(Default)]
66pub struct SidebarRenderState {
67 pub open: bool,
68 pub side: SidebarSide,
69}
70
71pub fn sidebar_side_context_menu(
72 id: impl Into<ElementId>,
73 cx: &App,
74) -> ui::RightClickMenu<ContextMenu> {
75 let current_position = AgentSettings::get_global(cx).sidebar_side;
76 right_click_menu(id).menu(move |window, cx| {
77 let fs = <dyn fs::Fs>::global(cx);
78 ContextMenu::build(window, cx, move |mut menu, _, _cx| {
79 let positions: [(SidebarDockPosition, &str); 2] = [
80 (SidebarDockPosition::Left, "Left"),
81 (SidebarDockPosition::Right, "Right"),
82 ];
83 for (position, label) in positions {
84 let fs = fs.clone();
85 menu = menu.toggleable_entry(
86 label,
87 position == current_position,
88 IconPosition::Start,
89 None,
90 move |_window, cx| {
91 settings::update_settings_file(fs.clone(), cx, move |settings, _cx| {
92 settings
93 .agent
94 .get_or_insert_default()
95 .set_sidebar_side(position);
96 });
97 },
98 );
99 }
100 menu
101 })
102 })
103}
104
105pub enum MultiWorkspaceEvent {
106 ActiveWorkspaceChanged,
107 WorkspaceAdded(Entity<Workspace>),
108 WorkspaceRemoved(EntityId),
109 ProjectGroupKeyUpdated {
110 old_key: ProjectGroupKey,
111 new_key: ProjectGroupKey,
112 },
113}
114
115pub enum SidebarEvent {
116 SerializeNeeded,
117}
118
119pub trait Sidebar: Focusable + Render + EventEmitter<SidebarEvent> + Sized {
120 fn width(&self, cx: &App) -> Pixels;
121 fn set_width(&mut self, width: Option<Pixels>, cx: &mut Context<Self>);
122 fn has_notifications(&self, cx: &App) -> bool;
123 fn side(&self, _cx: &App) -> SidebarSide;
124
125 fn is_threads_list_view_active(&self) -> bool {
126 true
127 }
128 /// Makes focus reset back to the search editor upon toggling the sidebar from outside
129 fn prepare_for_focus(&mut self, _window: &mut Window, _cx: &mut Context<Self>) {}
130 /// Opens or cycles the thread switcher popup.
131 fn toggle_thread_switcher(
132 &mut self,
133 _select_last: bool,
134 _window: &mut Window,
135 _cx: &mut Context<Self>,
136 ) {
137 }
138
139 /// Activates the next or previous project.
140 fn cycle_project(&mut self, _forward: bool, _window: &mut Window, _cx: &mut Context<Self>) {}
141
142 /// Activates the next or previous thread in sidebar order.
143 fn cycle_thread(&mut self, _forward: bool, _window: &mut Window, _cx: &mut Context<Self>) {}
144
145 /// Return an opaque JSON blob of sidebar-specific state to persist.
146 fn serialized_state(&self, _cx: &App) -> Option<String> {
147 None
148 }
149
150 /// Restore sidebar state from a previously-serialized blob.
151 fn restore_serialized_state(
152 &mut self,
153 _state: &str,
154 _window: &mut Window,
155 _cx: &mut Context<Self>,
156 ) {
157 }
158}
159
160pub trait SidebarHandle: 'static + Send + Sync {
161 fn width(&self, cx: &App) -> Pixels;
162 fn set_width(&self, width: Option<Pixels>, cx: &mut App);
163 fn focus_handle(&self, cx: &App) -> FocusHandle;
164 fn focus(&self, window: &mut Window, cx: &mut App);
165 fn prepare_for_focus(&self, window: &mut Window, cx: &mut App);
166 fn has_notifications(&self, cx: &App) -> bool;
167 fn to_any(&self) -> AnyView;
168 fn entity_id(&self) -> EntityId;
169 fn toggle_thread_switcher(&self, select_last: bool, window: &mut Window, cx: &mut App);
170 fn cycle_project(&self, forward: bool, window: &mut Window, cx: &mut App);
171 fn cycle_thread(&self, forward: bool, window: &mut Window, cx: &mut App);
172
173 fn is_threads_list_view_active(&self, cx: &App) -> bool;
174
175 fn side(&self, cx: &App) -> SidebarSide;
176 fn serialized_state(&self, cx: &App) -> Option<String>;
177 fn restore_serialized_state(&self, state: &str, window: &mut Window, cx: &mut App);
178}
179
180#[derive(Clone)]
181pub struct DraggedSidebar;
182
183impl Render for DraggedSidebar {
184 fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
185 gpui::Empty
186 }
187}
188
189impl<T: Sidebar> SidebarHandle for Entity<T> {
190 fn width(&self, cx: &App) -> Pixels {
191 self.read(cx).width(cx)
192 }
193
194 fn set_width(&self, width: Option<Pixels>, cx: &mut App) {
195 self.update(cx, |this, cx| this.set_width(width, cx))
196 }
197
198 fn focus_handle(&self, cx: &App) -> FocusHandle {
199 self.read(cx).focus_handle(cx)
200 }
201
202 fn focus(&self, window: &mut Window, cx: &mut App) {
203 let handle = self.read(cx).focus_handle(cx);
204 window.focus(&handle, cx);
205 }
206
207 fn prepare_for_focus(&self, window: &mut Window, cx: &mut App) {
208 self.update(cx, |this, cx| this.prepare_for_focus(window, cx));
209 }
210
211 fn has_notifications(&self, cx: &App) -> bool {
212 self.read(cx).has_notifications(cx)
213 }
214
215 fn to_any(&self) -> AnyView {
216 self.clone().into()
217 }
218
219 fn entity_id(&self) -> EntityId {
220 Entity::entity_id(self)
221 }
222
223 fn toggle_thread_switcher(&self, select_last: bool, window: &mut Window, cx: &mut App) {
224 let entity = self.clone();
225 window.defer(cx, move |window, cx| {
226 entity.update(cx, |this, cx| {
227 this.toggle_thread_switcher(select_last, window, cx);
228 });
229 });
230 }
231
232 fn cycle_project(&self, forward: bool, window: &mut Window, cx: &mut App) {
233 let entity = self.clone();
234 window.defer(cx, move |window, cx| {
235 entity.update(cx, |this, cx| {
236 this.cycle_project(forward, window, cx);
237 });
238 });
239 }
240
241 fn cycle_thread(&self, forward: bool, window: &mut Window, cx: &mut App) {
242 let entity = self.clone();
243 window.defer(cx, move |window, cx| {
244 entity.update(cx, |this, cx| {
245 this.cycle_thread(forward, window, cx);
246 });
247 });
248 }
249
250 fn is_threads_list_view_active(&self, cx: &App) -> bool {
251 self.read(cx).is_threads_list_view_active()
252 }
253
254 fn side(&self, cx: &App) -> SidebarSide {
255 self.read(cx).side(cx)
256 }
257
258 fn serialized_state(&self, cx: &App) -> Option<String> {
259 self.read(cx).serialized_state(cx)
260 }
261
262 fn restore_serialized_state(&self, state: &str, window: &mut Window, cx: &mut App) {
263 self.update(cx, |this, cx| {
264 this.restore_serialized_state(state, window, cx)
265 })
266 }
267}
268
269#[derive(Clone)]
270pub struct ProjectGroup {
271 pub key: ProjectGroupKey,
272 pub workspaces: Vec<Entity<Workspace>>,
273 pub expanded: bool,
274 pub visible_thread_count: Option<usize>,
275}
276
277pub struct SerializedProjectGroupState {
278 pub key: ProjectGroupKey,
279 pub expanded: bool,
280 pub visible_thread_count: Option<usize>,
281}
282
283#[derive(Clone)]
284pub struct ProjectGroupState {
285 pub key: ProjectGroupKey,
286 pub expanded: bool,
287 pub visible_thread_count: Option<usize>,
288}
289
290pub struct MultiWorkspace {
291 window_id: WindowId,
292 retained_workspaces: Vec<Entity<Workspace>>,
293 project_groups: Vec<ProjectGroupState>,
294 active_workspace: Entity<Workspace>,
295 sidebar: Option<Box<dyn SidebarHandle>>,
296 sidebar_open: bool,
297 sidebar_overlay: Option<AnyView>,
298 pending_removal_tasks: Vec<Task<()>>,
299 _serialize_task: Option<Task<()>>,
300 _subscriptions: Vec<Subscription>,
301 previous_focus_handle: Option<FocusHandle>,
302}
303
304impl EventEmitter<MultiWorkspaceEvent> for MultiWorkspace {}
305
306impl MultiWorkspace {
307 pub fn sidebar_side(&self, cx: &App) -> SidebarSide {
308 self.sidebar
309 .as_ref()
310 .map_or(SidebarSide::Left, |s| s.side(cx))
311 }
312
313 pub fn sidebar_render_state(&self, cx: &App) -> SidebarRenderState {
314 SidebarRenderState {
315 open: self.sidebar_open() && self.multi_workspace_enabled(cx),
316 side: self.sidebar_side(cx),
317 }
318 }
319
320 pub fn new(workspace: Entity<Workspace>, window: &mut Window, cx: &mut Context<Self>) -> Self {
321 let release_subscription = cx.on_release(|this: &mut MultiWorkspace, _cx| {
322 if let Some(task) = this._serialize_task.take() {
323 task.detach();
324 }
325 for task in std::mem::take(&mut this.pending_removal_tasks) {
326 task.detach();
327 }
328 });
329 let quit_subscription = cx.on_app_quit(Self::app_will_quit);
330 let settings_subscription = cx.observe_global_in::<settings::SettingsStore>(window, {
331 let mut previous_disable_ai = DisableAiSettings::get_global(cx).disable_ai;
332 move |this, window, cx| {
333 if DisableAiSettings::get_global(cx).disable_ai != previous_disable_ai {
334 this.collapse_to_single_workspace(window, cx);
335 previous_disable_ai = DisableAiSettings::get_global(cx).disable_ai;
336 }
337 }
338 });
339 Self::subscribe_to_workspace(&workspace, window, cx);
340 let weak_self = cx.weak_entity();
341 workspace.update(cx, |workspace, cx| {
342 workspace.set_multi_workspace(weak_self, cx);
343 });
344 Self {
345 window_id: window.window_handle().window_id(),
346 retained_workspaces: Vec::new(),
347 project_groups: Vec::new(),
348 active_workspace: workspace,
349 sidebar: None,
350 sidebar_open: false,
351 sidebar_overlay: None,
352 pending_removal_tasks: Vec::new(),
353 _serialize_task: None,
354 _subscriptions: vec![
355 release_subscription,
356 quit_subscription,
357 settings_subscription,
358 ],
359 previous_focus_handle: None,
360 }
361 }
362
363 pub fn register_sidebar<T: Sidebar>(&mut self, sidebar: Entity<T>, cx: &mut Context<Self>) {
364 self._subscriptions
365 .push(cx.observe(&sidebar, |_this, _, cx| {
366 cx.notify();
367 }));
368 self._subscriptions
369 .push(cx.subscribe(&sidebar, |this, _, event, cx| match event {
370 SidebarEvent::SerializeNeeded => {
371 this.serialize(cx);
372 }
373 }));
374 self.sidebar = Some(Box::new(sidebar));
375 }
376
377 pub fn sidebar(&self) -> Option<&dyn SidebarHandle> {
378 self.sidebar.as_deref()
379 }
380
381 pub fn set_sidebar_overlay(&mut self, overlay: Option<AnyView>, cx: &mut Context<Self>) {
382 self.sidebar_overlay = overlay;
383 cx.notify();
384 }
385
386 pub fn sidebar_open(&self) -> bool {
387 self.sidebar_open
388 }
389
390 pub fn sidebar_has_notifications(&self, cx: &App) -> bool {
391 self.sidebar
392 .as_ref()
393 .map_or(false, |s| s.has_notifications(cx))
394 }
395
396 pub fn is_threads_list_view_active(&self, cx: &App) -> bool {
397 self.sidebar
398 .as_ref()
399 .map_or(false, |s| s.is_threads_list_view_active(cx))
400 }
401
402 pub fn multi_workspace_enabled(&self, cx: &App) -> bool {
403 !matches!(ReleaseChannel::try_global(cx), Some(ReleaseChannel::Stable))
404 && !DisableAiSettings::get_global(cx).disable_ai
405 }
406
407 pub fn toggle_sidebar(&mut self, window: &mut Window, cx: &mut Context<Self>) {
408 if !self.multi_workspace_enabled(cx) {
409 return;
410 }
411
412 if self.sidebar_open() {
413 self.close_sidebar(window, cx);
414 } else {
415 self.previous_focus_handle = window.focused(cx);
416 self.open_sidebar(cx);
417 if let Some(sidebar) = &self.sidebar {
418 sidebar.prepare_for_focus(window, cx);
419 sidebar.focus(window, cx);
420 }
421 }
422 }
423
424 pub fn close_sidebar_action(&mut self, window: &mut Window, cx: &mut Context<Self>) {
425 if !self.multi_workspace_enabled(cx) {
426 return;
427 }
428
429 if self.sidebar_open() {
430 self.close_sidebar(window, cx);
431 }
432 }
433
434 pub fn focus_sidebar(&mut self, window: &mut Window, cx: &mut Context<Self>) {
435 if !self.multi_workspace_enabled(cx) {
436 return;
437 }
438
439 if self.sidebar_open() {
440 let sidebar_is_focused = self
441 .sidebar
442 .as_ref()
443 .is_some_and(|s| s.focus_handle(cx).contains_focused(window, cx));
444
445 if sidebar_is_focused {
446 self.restore_previous_focus(false, window, cx);
447 } else {
448 self.previous_focus_handle = window.focused(cx);
449 if let Some(sidebar) = &self.sidebar {
450 sidebar.prepare_for_focus(window, cx);
451 sidebar.focus(window, cx);
452 }
453 }
454 } else {
455 self.previous_focus_handle = window.focused(cx);
456 self.open_sidebar(cx);
457 if let Some(sidebar) = &self.sidebar {
458 sidebar.prepare_for_focus(window, cx);
459 sidebar.focus(window, cx);
460 }
461 }
462 }
463
464 pub fn open_sidebar(&mut self, cx: &mut Context<Self>) {
465 self.sidebar_open = true;
466 self.retain_active_workspace(cx);
467 let sidebar_focus_handle = self.sidebar.as_ref().map(|s| s.focus_handle(cx));
468 for workspace in self.retained_workspaces.clone() {
469 workspace.update(cx, |workspace, _cx| {
470 workspace.set_sidebar_focus_handle(sidebar_focus_handle.clone());
471 });
472 }
473 self.serialize(cx);
474 cx.notify();
475 }
476
477 pub fn close_sidebar(&mut self, window: &mut Window, cx: &mut Context<Self>) {
478 self.sidebar_open = false;
479 for workspace in self.retained_workspaces.clone() {
480 workspace.update(cx, |workspace, _cx| {
481 workspace.set_sidebar_focus_handle(None);
482 });
483 }
484 let sidebar_has_focus = self
485 .sidebar
486 .as_ref()
487 .is_some_and(|s| s.focus_handle(cx).contains_focused(window, cx));
488 if sidebar_has_focus {
489 self.restore_previous_focus(true, window, cx);
490 } else {
491 self.previous_focus_handle.take();
492 }
493 self.serialize(cx);
494 cx.notify();
495 }
496
497 fn restore_previous_focus(&mut self, clear: bool, window: &mut Window, cx: &mut Context<Self>) {
498 let focus_handle = if clear {
499 self.previous_focus_handle.take()
500 } else {
501 self.previous_focus_handle.clone()
502 };
503
504 if let Some(previous_focus) = focus_handle {
505 previous_focus.focus(window, cx);
506 } else {
507 let pane = self.workspace().read(cx).active_pane().clone();
508 window.focus(&pane.read(cx).focus_handle(cx), cx);
509 }
510 }
511
512 pub fn close_window(&mut self, _: &CloseWindow, window: &mut Window, cx: &mut Context<Self>) {
513 cx.spawn_in(window, async move |this, cx| {
514 let workspaces = this.update(cx, |multi_workspace, _cx| {
515 multi_workspace.workspaces().cloned().collect::<Vec<_>>()
516 })?;
517
518 for workspace in workspaces {
519 let should_continue = workspace
520 .update_in(cx, |workspace, window, cx| {
521 workspace.prepare_to_close(CloseIntent::CloseWindow, window, cx)
522 })?
523 .await?;
524 if !should_continue {
525 return anyhow::Ok(());
526 }
527 }
528
529 cx.update(|window, _cx| {
530 window.remove_window();
531 })?;
532
533 anyhow::Ok(())
534 })
535 .detach_and_log_err(cx);
536 }
537
538 fn subscribe_to_workspace(
539 workspace: &Entity<Workspace>,
540 window: &Window,
541 cx: &mut Context<Self>,
542 ) {
543 let project = workspace.read(cx).project().clone();
544 cx.subscribe_in(&project, window, {
545 let workspace = workspace.downgrade();
546 move |this, _project, event, _window, cx| match event {
547 project::Event::WorktreePathsChanged { old_worktree_paths } => {
548 if let Some(workspace) = workspace.upgrade() {
549 let host = workspace
550 .read(cx)
551 .project()
552 .read(cx)
553 .remote_connection_options(cx);
554 let old_key =
555 ProjectGroupKey::from_worktree_paths(old_worktree_paths, host);
556 this.handle_project_group_key_change(&workspace, &old_key, cx);
557 }
558 }
559 _ => {}
560 }
561 })
562 .detach();
563
564 cx.subscribe_in(workspace, window, |this, workspace, event, window, cx| {
565 if let WorkspaceEvent::Activate = event {
566 this.activate(workspace.clone(), window, cx);
567 }
568 })
569 .detach();
570 }
571
572 fn handle_project_group_key_change(
573 &mut self,
574 workspace: &Entity<Workspace>,
575 old_key: &ProjectGroupKey,
576 cx: &mut Context<Self>,
577 ) {
578 if !self.is_workspace_retained(workspace) {
579 return;
580 }
581
582 let new_key = workspace.read(cx).project_group_key(cx);
583 if new_key.path_list().paths().is_empty() {
584 return;
585 }
586
587 // Re-key the group without emitting ProjectGroupKeyUpdated —
588 // the Project already emitted WorktreePathsChanged which the
589 // sidebar handles for thread migration.
590 self.rekey_project_group(old_key, &new_key, cx);
591 self.serialize(cx);
592 cx.notify();
593 }
594
595 pub fn is_workspace_retained(&self, workspace: &Entity<Workspace>) -> bool {
596 self.retained_workspaces
597 .iter()
598 .any(|retained| retained == workspace)
599 }
600
601 pub fn active_workspace_is_retained(&self) -> bool {
602 self.is_workspace_retained(&self.active_workspace)
603 }
604
605 pub fn retained_workspaces(&self) -> &[Entity<Workspace>] {
606 &self.retained_workspaces
607 }
608
609 /// Ensures a project group exists for `key`, creating one if needed.
610 fn ensure_project_group_state(&mut self, key: ProjectGroupKey) {
611 if key.path_list().paths().is_empty() {
612 return;
613 }
614
615 if self.project_groups.iter().any(|group| group.key == key) {
616 return;
617 }
618
619 self.project_groups.insert(
620 0,
621 ProjectGroupState {
622 key,
623 expanded: true,
624 visible_thread_count: None,
625 },
626 );
627 }
628
629 /// Transitions a project group from `old_key` to `new_key`.
630 ///
631 /// On collision (both keys have groups), the active workspace's
632 /// Re-keys a project group from `old_key` to `new_key`, handling
633 /// collisions. When two groups collide, the active workspace's
634 /// group always wins. Otherwise the old key's state is preserved
635 /// — it represents the group the user or system just acted on.
636 /// The losing group is removed, and the winner is re-keyed in
637 /// place to preserve sidebar order.
638 fn rekey_project_group(
639 &mut self,
640 old_key: &ProjectGroupKey,
641 new_key: &ProjectGroupKey,
642 cx: &App,
643 ) {
644 if old_key == new_key {
645 return;
646 }
647
648 if new_key.path_list().paths().is_empty() {
649 return;
650 }
651
652 let old_key_exists = self.project_groups.iter().any(|g| g.key == *old_key);
653 let new_key_exists = self.project_groups.iter().any(|g| g.key == *new_key);
654
655 if !old_key_exists {
656 self.ensure_project_group_state(new_key.clone());
657 return;
658 }
659
660 if new_key_exists {
661 let active_key = self.active_workspace.read(cx).project_group_key(cx);
662 if active_key == *new_key {
663 self.project_groups.retain(|g| g.key != *old_key);
664 } else {
665 self.project_groups.retain(|g| g.key != *new_key);
666 if let Some(group) = self.project_groups.iter_mut().find(|g| g.key == *old_key) {
667 group.key = new_key.clone();
668 }
669 }
670 } else {
671 if let Some(group) = self.project_groups.iter_mut().find(|g| g.key == *old_key) {
672 group.key = new_key.clone();
673 }
674 }
675
676 // If another retained workspace still has the old key (e.g. a
677 // linked worktree workspace), re-create the old group so it
678 // remains reachable in the sidebar.
679 let other_workspace_needs_old_key = self
680 .retained_workspaces
681 .iter()
682 .any(|ws| ws.read(cx).project_group_key(cx) == *old_key);
683 if other_workspace_needs_old_key {
684 self.ensure_project_group_state(old_key.clone());
685 }
686 }
687
688 /// Re-keys a project group and emits `ProjectGroupKeyUpdated` so
689 /// the sidebar can migrate thread metadata. Used for direct group
690 /// manipulation (add/remove folder) where no Project event fires.
691 fn update_project_group_key(
692 &mut self,
693 old_key: &ProjectGroupKey,
694 new_key: &ProjectGroupKey,
695 cx: &mut Context<Self>,
696 ) {
697 self.rekey_project_group(old_key, new_key, cx);
698
699 if old_key != new_key && !new_key.path_list().paths().is_empty() {
700 cx.emit(MultiWorkspaceEvent::ProjectGroupKeyUpdated {
701 old_key: old_key.clone(),
702 new_key: new_key.clone(),
703 });
704 }
705 }
706
707 pub(crate) fn retain_workspace(
708 &mut self,
709 workspace: Entity<Workspace>,
710 key: ProjectGroupKey,
711 cx: &mut Context<Self>,
712 ) {
713 self.ensure_project_group_state(key);
714 if self.is_workspace_retained(&workspace) {
715 return;
716 }
717
718 self.retained_workspaces.push(workspace.clone());
719 cx.emit(MultiWorkspaceEvent::WorkspaceAdded(workspace));
720 }
721
722 fn register_workspace(
723 &mut self,
724 workspace: &Entity<Workspace>,
725 window: &Window,
726 cx: &mut Context<Self>,
727 ) {
728 Self::subscribe_to_workspace(workspace, window, cx);
729 self.sync_sidebar_to_workspace(workspace, cx);
730 let weak_self = cx.weak_entity();
731 workspace.update(cx, |workspace, cx| {
732 workspace.set_multi_workspace(weak_self, cx);
733 });
734 }
735
736 pub fn project_group_key_for_workspace(
737 &self,
738 workspace: &Entity<Workspace>,
739 cx: &App,
740 ) -> ProjectGroupKey {
741 workspace.read(cx).project_group_key(cx)
742 }
743
744 pub fn restore_project_groups(
745 &mut self,
746 groups: Vec<SerializedProjectGroupState>,
747 _cx: &mut Context<Self>,
748 ) {
749 let mut restored: Vec<ProjectGroupState> = Vec::new();
750 for SerializedProjectGroupState {
751 key,
752 expanded,
753 visible_thread_count,
754 } in groups
755 {
756 if key.path_list().paths().is_empty() {
757 continue;
758 }
759 if restored.iter().any(|group| group.key == key) {
760 continue;
761 }
762 restored.push(ProjectGroupState {
763 key,
764 expanded,
765 visible_thread_count,
766 });
767 }
768 for existing in std::mem::take(&mut self.project_groups) {
769 if !restored.iter().any(|group| group.key == existing.key) {
770 restored.push(existing);
771 }
772 }
773 self.project_groups = restored;
774 }
775
776 pub fn project_group_keys(&self) -> Vec<ProjectGroupKey> {
777 self.project_groups
778 .iter()
779 .map(|group| group.key.clone())
780 .collect()
781 }
782
783 fn derived_project_groups(&self, cx: &App) -> Vec<ProjectGroup> {
784 self.project_groups
785 .iter()
786 .map(|group| ProjectGroup {
787 key: group.key.clone(),
788 workspaces: self
789 .retained_workspaces
790 .iter()
791 .filter(|workspace| workspace.read(cx).project_group_key(cx) == group.key)
792 .cloned()
793 .collect(),
794 expanded: group.expanded,
795 visible_thread_count: group.visible_thread_count,
796 })
797 .collect()
798 }
799
800 pub fn project_groups(&self, cx: &App) -> Vec<ProjectGroup> {
801 self.derived_project_groups(cx)
802 }
803
804 pub fn group_state_by_key(&self, key: &ProjectGroupKey) -> Option<&ProjectGroupState> {
805 self.project_groups.iter().find(|group| group.key == *key)
806 }
807
808 pub fn group_state_by_key_mut(
809 &mut self,
810 key: &ProjectGroupKey,
811 ) -> Option<&mut ProjectGroupState> {
812 self.project_groups
813 .iter_mut()
814 .find(|group| group.key == *key)
815 }
816
817 pub fn set_all_groups_expanded(&mut self, expanded: bool) {
818 for group in &mut self.project_groups {
819 group.expanded = expanded;
820 }
821 }
822
823 pub fn set_all_groups_visible_thread_count(&mut self, count: Option<usize>) {
824 for group in &mut self.project_groups {
825 group.visible_thread_count = count;
826 }
827 }
828
829 pub fn workspaces_for_project_group(
830 &self,
831 key: &ProjectGroupKey,
832 cx: &App,
833 ) -> Option<Vec<Entity<Workspace>>> {
834 let has_group = self.project_groups.iter().any(|group| group.key == *key)
835 || self
836 .retained_workspaces
837 .iter()
838 .any(|workspace| workspace.read(cx).project_group_key(cx) == *key);
839
840 has_group.then(|| {
841 self.retained_workspaces
842 .iter()
843 .filter(|workspace| workspace.read(cx).project_group_key(cx) == *key)
844 .cloned()
845 .collect()
846 })
847 }
848
849 pub fn remove_folder_from_project_group(
850 &mut self,
851 group_key: &ProjectGroupKey,
852 path: &Path,
853 cx: &mut Context<Self>,
854 ) {
855 let workspaces = self
856 .workspaces_for_project_group(group_key, cx)
857 .unwrap_or_default();
858
859 let Some(group) = self
860 .project_groups
861 .iter()
862 .find(|group| group.key == *group_key)
863 else {
864 return;
865 };
866
867 let new_path_list = group.key.path_list().without_path(path);
868 if new_path_list.is_empty() {
869 return;
870 }
871
872 let new_key = ProjectGroupKey::new(group.key.host(), new_path_list);
873 self.update_project_group_key(group_key, &new_key, cx);
874
875 for workspace in workspaces {
876 let project = workspace.read(cx).project().clone();
877 project.update(cx, |project, cx| {
878 project.remove_worktree_for_main_worktree_path(path, cx);
879 });
880 }
881
882 self.serialize(cx);
883 cx.notify();
884 }
885
886 pub fn prompt_to_add_folders_to_project_group(
887 &mut self,
888 group_key: ProjectGroupKey,
889 window: &mut Window,
890 cx: &mut Context<Self>,
891 ) {
892 let paths = self.workspace().update(cx, |workspace, cx| {
893 workspace.prompt_for_open_path(
894 PathPromptOptions {
895 files: false,
896 directories: true,
897 multiple: true,
898 prompt: None,
899 },
900 DirectoryLister::Project(workspace.project().clone()),
901 window,
902 cx,
903 )
904 });
905
906 cx.spawn_in(window, async move |this, cx| {
907 if let Some(new_paths) = paths.await.ok().flatten() {
908 if !new_paths.is_empty() {
909 this.update(cx, |multi_workspace, cx| {
910 multi_workspace.add_folders_to_project_group(&group_key, new_paths, cx);
911 })?;
912 }
913 }
914 anyhow::Ok(())
915 })
916 .detach_and_log_err(cx);
917 }
918
919 pub fn add_folders_to_project_group(
920 &mut self,
921 group_key: &ProjectGroupKey,
922 new_paths: Vec<PathBuf>,
923 cx: &mut Context<Self>,
924 ) {
925 let workspaces = self
926 .workspaces_for_project_group(group_key, cx)
927 .unwrap_or_default();
928
929 let Some(group) = self
930 .project_groups
931 .iter()
932 .find(|group| group.key == *group_key)
933 else {
934 return;
935 };
936
937 let existing_paths = group.key.path_list().paths();
938 let new_paths: Vec<PathBuf> = new_paths
939 .into_iter()
940 .filter(|p| !existing_paths.contains(p))
941 .collect();
942
943 if new_paths.is_empty() {
944 return;
945 }
946
947 let mut all_paths: Vec<PathBuf> = existing_paths.to_vec();
948 all_paths.extend(new_paths.iter().cloned());
949 let new_path_list = PathList::new(&all_paths);
950 let new_key = ProjectGroupKey::new(group.key.host(), new_path_list);
951
952 self.update_project_group_key(group_key, &new_key, cx);
953
954 for workspace in workspaces {
955 let project = workspace.read(cx).project().clone();
956 for path in &new_paths {
957 project
958 .update(cx, |project, cx| {
959 project.find_or_create_worktree(path, true, cx)
960 })
961 .detach_and_log_err(cx);
962 }
963 }
964
965 self.serialize(cx);
966 cx.notify();
967 }
968
969 pub fn remove_project_group(
970 &mut self,
971 group_key: &ProjectGroupKey,
972 window: &mut Window,
973 cx: &mut Context<Self>,
974 ) -> Task<Result<bool>> {
975 let pos = self
976 .project_groups
977 .iter()
978 .position(|group| group.key == *group_key);
979 let workspaces = self
980 .workspaces_for_project_group(group_key, cx)
981 .unwrap_or_default();
982
983 // Compute the neighbor while the group is still in the list.
984 let neighbor_key = pos.and_then(|pos| {
985 self.project_groups
986 .get(pos + 1)
987 .or_else(|| pos.checked_sub(1).and_then(|i| self.project_groups.get(i)))
988 .map(|group| group.key.clone())
989 });
990
991 // Now remove the group.
992 self.project_groups.retain(|group| group.key != *group_key);
993
994 let excluded_workspaces = workspaces.clone();
995 self.remove(
996 workspaces,
997 move |this, window, cx| {
998 if let Some(neighbor_key) = neighbor_key {
999 return this.find_or_create_local_workspace(
1000 neighbor_key.path_list().clone(),
1001 Some(neighbor_key.clone()),
1002 &excluded_workspaces,
1003 window,
1004 cx,
1005 );
1006 }
1007
1008 // No other project groups remain — create an empty workspace.
1009 let app_state = this.workspace().read(cx).app_state().clone();
1010 let project = Project::local(
1011 app_state.client.clone(),
1012 app_state.node_runtime.clone(),
1013 app_state.user_store.clone(),
1014 app_state.languages.clone(),
1015 app_state.fs.clone(),
1016 None,
1017 project::LocalProjectFlags::default(),
1018 cx,
1019 );
1020 let new_workspace =
1021 cx.new(|cx| Workspace::new(None, project, app_state, window, cx));
1022 Task::ready(Ok(new_workspace))
1023 },
1024 window,
1025 cx,
1026 )
1027 }
1028
1029 /// Goes through sqlite: serialize -> close -> open new window
1030 /// This avoids issues with pending tasks having the wrong window
1031 pub fn open_project_group_in_new_window(
1032 &mut self,
1033 key: &ProjectGroupKey,
1034 window: &mut Window,
1035 cx: &mut Context<Self>,
1036 ) -> Task<Result<()>> {
1037 let paths: Vec<PathBuf> = key.path_list().ordered_paths().cloned().collect();
1038 if paths.is_empty() {
1039 return Task::ready(Ok(()));
1040 }
1041
1042 let app_state = self.workspace().read(cx).app_state().clone();
1043
1044 let workspaces: Vec<_> = self
1045 .workspaces_for_project_group(key, cx)
1046 .unwrap_or_default();
1047 let mut serialization_tasks = Vec::new();
1048 for workspace in &workspaces {
1049 serialization_tasks.push(workspace.update(cx, |workspace, inner_cx| {
1050 workspace.flush_serialization(window, inner_cx)
1051 }));
1052 }
1053
1054 let remove_task = self.remove_project_group(key, window, cx);
1055
1056 cx.spawn(async move |_this, cx| {
1057 futures::future::join_all(serialization_tasks).await;
1058
1059 let removed = remove_task.await?;
1060 if !removed {
1061 return Ok(());
1062 }
1063
1064 cx.update(|cx| {
1065 Workspace::new_local(paths, app_state, None, None, None, OpenMode::NewWindow, cx)
1066 })
1067 .await?;
1068
1069 Ok(())
1070 })
1071 }
1072
1073 /// Finds an existing workspace whose root paths and host exactly match.
1074 pub fn workspace_for_paths(
1075 &self,
1076 path_list: &PathList,
1077 host: Option<&RemoteConnectionOptions>,
1078 cx: &App,
1079 ) -> Option<Entity<Workspace>> {
1080 self.workspace_for_paths_excluding(path_list, host, &[], cx)
1081 }
1082
1083 fn workspace_for_paths_excluding(
1084 &self,
1085 path_list: &PathList,
1086 host: Option<&RemoteConnectionOptions>,
1087 excluding: &[Entity<Workspace>],
1088 cx: &App,
1089 ) -> Option<Entity<Workspace>> {
1090 for workspace in self.workspaces() {
1091 if excluding.contains(workspace) {
1092 continue;
1093 }
1094 let root_paths = PathList::new(&workspace.read(cx).root_paths(cx));
1095 let key = workspace.read(cx).project_group_key(cx);
1096 let host_matches = key.host().as_ref() == host;
1097 let paths_match = root_paths == *path_list;
1098 if host_matches && paths_match {
1099 return Some(workspace.clone());
1100 }
1101 }
1102
1103 None
1104 }
1105
1106 /// Finds an existing workspace whose paths match, or creates a new one.
1107 ///
1108 /// For local projects (`host` is `None`), this delegates to
1109 /// [`Self::find_or_create_local_workspace`]. For remote projects, it
1110 /// tries an exact path match and, if no existing workspace is found,
1111 /// calls `connect_remote` to establish a connection and creates a new
1112 /// remote workspace.
1113 ///
1114 /// The `connect_remote` closure is responsible for any user-facing
1115 /// connection UI (e.g. password prompts). It receives the connection
1116 /// options and should return a [`Task`] that resolves to the
1117 /// [`RemoteClient`] session, or `None` if the connection was
1118 /// cancelled.
1119 pub fn find_or_create_workspace(
1120 &mut self,
1121 paths: PathList,
1122 host: Option<RemoteConnectionOptions>,
1123 provisional_project_group_key: Option<ProjectGroupKey>,
1124 connect_remote: impl FnOnce(
1125 RemoteConnectionOptions,
1126 &mut Window,
1127 &mut Context<Self>,
1128 ) -> Task<Result<Option<Entity<remote::RemoteClient>>>>
1129 + 'static,
1130 excluding: &[Entity<Workspace>],
1131 window: &mut Window,
1132 cx: &mut Context<Self>,
1133 ) -> Task<Result<Entity<Workspace>>> {
1134 if let Some(workspace) = self.workspace_for_paths(&paths, host.as_ref(), cx) {
1135 self.activate(workspace.clone(), window, cx);
1136 return Task::ready(Ok(workspace));
1137 }
1138
1139 let Some(connection_options) = host else {
1140 return self.find_or_create_local_workspace(
1141 paths,
1142 provisional_project_group_key,
1143 excluding,
1144 window,
1145 cx,
1146 );
1147 };
1148
1149 let app_state = self.workspace().read(cx).app_state().clone();
1150 let window_handle = window.window_handle().downcast::<MultiWorkspace>();
1151 let connect_task = connect_remote(connection_options.clone(), window, cx);
1152 let paths_vec = paths.paths().to_vec();
1153
1154 cx.spawn(async move |_this, cx| {
1155 let session = connect_task
1156 .await?
1157 .ok_or_else(|| anyhow::anyhow!("Remote connection was cancelled"))?;
1158
1159 let new_project = cx.update(|cx| {
1160 Project::remote(
1161 session,
1162 app_state.client.clone(),
1163 app_state.node_runtime.clone(),
1164 app_state.user_store.clone(),
1165 app_state.languages.clone(),
1166 app_state.fs.clone(),
1167 true,
1168 cx,
1169 )
1170 });
1171
1172 let window_handle =
1173 window_handle.ok_or_else(|| anyhow::anyhow!("Window is not a MultiWorkspace"))?;
1174
1175 open_remote_project_with_existing_connection(
1176 connection_options,
1177 new_project,
1178 paths_vec,
1179 app_state,
1180 window_handle,
1181 provisional_project_group_key,
1182 cx,
1183 )
1184 .await?;
1185
1186 window_handle.update(cx, |multi_workspace, window, cx| {
1187 let workspace = multi_workspace.workspace().clone();
1188 multi_workspace.add(workspace.clone(), window, cx);
1189 workspace
1190 })
1191 })
1192 }
1193
1194 /// Finds an existing workspace in this multi-workspace whose paths match,
1195 /// or creates a new one (deserializing its saved state from the database).
1196 /// Never searches other windows or matches workspaces with a superset of
1197 /// the requested paths.
1198 ///
1199 /// `excluding` lists workspaces that should be skipped during the search
1200 /// (e.g. workspaces that are about to be removed).
1201 pub fn find_or_create_local_workspace(
1202 &mut self,
1203 path_list: PathList,
1204 project_group: Option<ProjectGroupKey>,
1205 excluding: &[Entity<Workspace>],
1206 window: &mut Window,
1207 cx: &mut Context<Self>,
1208 ) -> Task<Result<Entity<Workspace>>> {
1209 if let Some(workspace) = self.workspace_for_paths_excluding(&path_list, None, excluding, cx)
1210 {
1211 self.activate(workspace.clone(), window, cx);
1212 return Task::ready(Ok(workspace));
1213 }
1214
1215 let paths = path_list.paths().to_vec();
1216 let app_state = self.workspace().read(cx).app_state().clone();
1217 let requesting_window = window.window_handle().downcast::<MultiWorkspace>();
1218 let fs = <dyn Fs>::global(cx);
1219 let excluding = excluding.to_vec();
1220
1221 cx.spawn(async move |_this, cx| {
1222 let effective_path_list = if let Some(project_group) = project_group {
1223 let metadata_tasks: Vec<_> = paths
1224 .iter()
1225 .map(|path| fs.metadata(path.as_path()))
1226 .collect();
1227 let metadata_results = futures::future::join_all(metadata_tasks).await;
1228 // Only fall back when every path is definitely absent; real
1229 // filesystem errors should not be treated as "missing".
1230 let all_paths_missing = !paths.is_empty()
1231 && metadata_results
1232 .into_iter()
1233 // Ok(None) means the path is definitely absent
1234 .all(|result| matches!(result, Ok(None)));
1235
1236 if all_paths_missing {
1237 project_group.path_list().clone()
1238 } else {
1239 PathList::new(&paths)
1240 }
1241 } else {
1242 PathList::new(&paths)
1243 };
1244
1245 if let Some(requesting_window) = requesting_window
1246 && let Some(workspace) = requesting_window
1247 .update(cx, |multi_workspace, window, cx| {
1248 multi_workspace
1249 .workspace_for_paths_excluding(
1250 &effective_path_list,
1251 None,
1252 &excluding,
1253 cx,
1254 )
1255 .inspect(|workspace| {
1256 multi_workspace.activate(workspace.clone(), window, cx);
1257 })
1258 })
1259 .ok()
1260 .flatten()
1261 {
1262 return Ok(workspace);
1263 }
1264
1265 let result = cx
1266 .update(|cx| {
1267 Workspace::new_local(
1268 effective_path_list.paths().to_vec(),
1269 app_state,
1270 requesting_window,
1271 None,
1272 None,
1273 OpenMode::Activate,
1274 cx,
1275 )
1276 })
1277 .await?;
1278 Ok(result.workspace)
1279 })
1280 }
1281
1282 pub fn workspace(&self) -> &Entity<Workspace> {
1283 &self.active_workspace
1284 }
1285
1286 pub fn workspaces(&self) -> impl Iterator<Item = &Entity<Workspace>> {
1287 let active_is_retained = self.is_workspace_retained(&self.active_workspace);
1288 self.retained_workspaces
1289 .iter()
1290 .chain(std::iter::once(&self.active_workspace).filter(move |_| !active_is_retained))
1291 }
1292
1293 /// Adds a workspace to this window as persistent without changing which
1294 /// workspace is active. Unlike `activate()`, this always inserts into the
1295 /// persistent list regardless of sidebar state — it's used for system-
1296 /// initiated additions like deserialization and worktree discovery.
1297 pub fn add(&mut self, workspace: Entity<Workspace>, window: &Window, cx: &mut Context<Self>) {
1298 if self.is_workspace_retained(&workspace) {
1299 return;
1300 }
1301
1302 if workspace != self.active_workspace {
1303 self.register_workspace(&workspace, window, cx);
1304 }
1305
1306 let key = workspace.read(cx).project_group_key(cx);
1307 self.retain_workspace(workspace, key, cx);
1308 cx.notify();
1309 }
1310
1311 /// Ensures the workspace is in the multiworkspace and makes it the active one.
1312 pub fn activate(
1313 &mut self,
1314 workspace: Entity<Workspace>,
1315 window: &mut Window,
1316 cx: &mut Context<Self>,
1317 ) {
1318 if self.workspace() == &workspace {
1319 self.focus_active_workspace(window, cx);
1320 return;
1321 }
1322
1323 let old_active_workspace = self.active_workspace.clone();
1324 let old_active_was_retained = self.active_workspace_is_retained();
1325 let workspace_was_retained = self.is_workspace_retained(&workspace);
1326
1327 if !workspace_was_retained {
1328 self.register_workspace(&workspace, window, cx);
1329
1330 if self.sidebar_open {
1331 let key = workspace.read(cx).project_group_key(cx);
1332 self.retain_workspace(workspace.clone(), key, cx);
1333 }
1334 }
1335
1336 self.active_workspace = workspace;
1337
1338 if !self.sidebar_open && !old_active_was_retained {
1339 self.detach_workspace(&old_active_workspace, cx);
1340 }
1341
1342 cx.emit(MultiWorkspaceEvent::ActiveWorkspaceChanged);
1343 self.serialize(cx);
1344 self.focus_active_workspace(window, cx);
1345 cx.notify();
1346 }
1347
1348 /// Promotes the currently active workspace to persistent if it is
1349 /// transient, so it is retained across workspace switches even when
1350 /// the sidebar is closed. No-op if the workspace is already persistent.
1351 pub fn retain_active_workspace(&mut self, cx: &mut Context<Self>) {
1352 let workspace = self.active_workspace.clone();
1353 if self.is_workspace_retained(&workspace) {
1354 return;
1355 }
1356
1357 let key = workspace.read(cx).project_group_key(cx);
1358 self.retain_workspace(workspace, key, cx);
1359 self.serialize(cx);
1360 cx.notify();
1361 }
1362
1363 /// Collapses to a single workspace, discarding all groups.
1364 /// Used when multi-workspace is disabled (e.g. disable_ai).
1365 fn collapse_to_single_workspace(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1366 if self.sidebar_open {
1367 self.close_sidebar(window, cx);
1368 }
1369
1370 let active_workspace = self.active_workspace.clone();
1371 for workspace in self.retained_workspaces.clone() {
1372 if workspace != active_workspace {
1373 self.detach_workspace(&workspace, cx);
1374 }
1375 }
1376
1377 self.retained_workspaces.clear();
1378 self.project_groups.clear();
1379 cx.notify();
1380 }
1381
1382 /// Detaches a workspace: clears session state, DB binding, cached
1383 /// group key, and emits `WorkspaceRemoved`. The DB row is preserved
1384 /// so the workspace still appears in the recent-projects list.
1385 fn detach_workspace(&mut self, workspace: &Entity<Workspace>, cx: &mut Context<Self>) {
1386 self.retained_workspaces
1387 .retain(|retained| retained != workspace);
1388 cx.emit(MultiWorkspaceEvent::WorkspaceRemoved(workspace.entity_id()));
1389 workspace.update(cx, |workspace, _cx| {
1390 workspace.session_id.take();
1391 workspace._schedule_serialize_workspace.take();
1392 workspace._serialize_workspace_task.take();
1393 });
1394
1395 if let Some(workspace_id) = workspace.read(cx).database_id() {
1396 let db = crate::persistence::WorkspaceDb::global(cx);
1397 self.pending_removal_tasks.retain(|task| !task.is_ready());
1398 self.pending_removal_tasks
1399 .push(cx.background_spawn(async move {
1400 db.set_session_binding(workspace_id, None, None)
1401 .await
1402 .log_err();
1403 }));
1404 }
1405 }
1406
1407 fn sync_sidebar_to_workspace(&self, workspace: &Entity<Workspace>, cx: &mut Context<Self>) {
1408 if self.sidebar_open() {
1409 let sidebar_focus_handle = self.sidebar.as_ref().map(|s| s.focus_handle(cx));
1410 workspace.update(cx, |workspace, _| {
1411 workspace.set_sidebar_focus_handle(sidebar_focus_handle);
1412 });
1413 }
1414 }
1415
1416 pub fn serialize(&mut self, cx: &mut Context<Self>) {
1417 self._serialize_task = Some(cx.spawn(async move |this, cx| {
1418 let Some((window_id, state)) = this
1419 .read_with(cx, |this, cx| {
1420 let state = MultiWorkspaceState {
1421 active_workspace_id: this.workspace().read(cx).database_id(),
1422 project_groups: this
1423 .project_groups
1424 .iter()
1425 .map(|group| {
1426 crate::persistence::model::SerializedProjectGroup::from_group(
1427 &group.key,
1428 group.expanded,
1429 group.visible_thread_count,
1430 )
1431 })
1432 .collect::<Vec<_>>(),
1433 sidebar_open: this.sidebar_open,
1434 sidebar_state: this.sidebar.as_ref().and_then(|s| s.serialized_state(cx)),
1435 };
1436 (this.window_id, state)
1437 })
1438 .ok()
1439 else {
1440 return;
1441 };
1442 let kvp = cx.update(|cx| db::kvp::KeyValueStore::global(cx));
1443 crate::persistence::write_multi_workspace_state(&kvp, window_id, state).await;
1444 }));
1445 }
1446
1447 /// Returns the in-flight serialization task (if any) so the caller can
1448 /// await it. Used by the quit handler to ensure pending DB writes
1449 /// complete before the process exits.
1450 pub fn flush_serialization(&mut self) -> Task<()> {
1451 self._serialize_task.take().unwrap_or(Task::ready(()))
1452 }
1453
1454 fn app_will_quit(&mut self, _cx: &mut Context<Self>) -> impl Future<Output = ()> + use<> {
1455 let mut tasks: Vec<Task<()>> = Vec::new();
1456 if let Some(task) = self._serialize_task.take() {
1457 tasks.push(task);
1458 }
1459 tasks.extend(std::mem::take(&mut self.pending_removal_tasks));
1460
1461 async move {
1462 futures::future::join_all(tasks).await;
1463 }
1464 }
1465
1466 pub fn focus_active_workspace(&self, window: &mut Window, cx: &mut App) {
1467 // If a dock panel is zoomed, focus it instead of the center pane.
1468 // Otherwise, focusing the center pane triggers dismiss_zoomed_items_to_reveal
1469 // which closes the zoomed dock.
1470 let focus_handle = {
1471 let workspace = self.workspace().read(cx);
1472 let mut target = None;
1473 for dock in workspace.all_docks() {
1474 let dock = dock.read(cx);
1475 if dock.is_open() {
1476 if let Some(panel) = dock.active_panel() {
1477 if panel.is_zoomed(window, cx) {
1478 target = Some(panel.panel_focus_handle(cx));
1479 break;
1480 }
1481 }
1482 }
1483 }
1484 target.unwrap_or_else(|| {
1485 let pane = workspace.active_pane().clone();
1486 pane.read(cx).focus_handle(cx)
1487 })
1488 };
1489 window.focus(&focus_handle, cx);
1490 }
1491
1492 pub fn panel<T: Panel>(&self, cx: &App) -> Option<Entity<T>> {
1493 self.workspace().read(cx).panel::<T>(cx)
1494 }
1495
1496 pub fn active_modal<V: ManagedView + 'static>(&self, cx: &App) -> Option<Entity<V>> {
1497 self.workspace().read(cx).active_modal::<V>(cx)
1498 }
1499
1500 pub fn add_panel<T: Panel>(
1501 &mut self,
1502 panel: Entity<T>,
1503 window: &mut Window,
1504 cx: &mut Context<Self>,
1505 ) {
1506 self.workspace().update(cx, |workspace, cx| {
1507 workspace.add_panel(panel, window, cx);
1508 });
1509 }
1510
1511 pub fn focus_panel<T: Panel>(
1512 &mut self,
1513 window: &mut Window,
1514 cx: &mut Context<Self>,
1515 ) -> Option<Entity<T>> {
1516 self.workspace()
1517 .update(cx, |workspace, cx| workspace.focus_panel::<T>(window, cx))
1518 }
1519
1520 // used in a test
1521 pub fn toggle_modal<V: ModalView, B>(
1522 &mut self,
1523 window: &mut Window,
1524 cx: &mut Context<Self>,
1525 build: B,
1526 ) where
1527 B: FnOnce(&mut Window, &mut gpui::Context<V>) -> V,
1528 {
1529 self.workspace().update(cx, |workspace, cx| {
1530 workspace.toggle_modal(window, cx, build);
1531 });
1532 }
1533
1534 pub fn toggle_dock(
1535 &mut self,
1536 dock_side: DockPosition,
1537 window: &mut Window,
1538 cx: &mut Context<Self>,
1539 ) {
1540 self.workspace().update(cx, |workspace, cx| {
1541 workspace.toggle_dock(dock_side, window, cx);
1542 });
1543 }
1544
1545 pub fn active_item_as<I: 'static>(&self, cx: &App) -> Option<Entity<I>> {
1546 self.workspace().read(cx).active_item_as::<I>(cx)
1547 }
1548
1549 pub fn items_of_type<'a, T: Item>(
1550 &'a self,
1551 cx: &'a App,
1552 ) -> impl 'a + Iterator<Item = Entity<T>> {
1553 self.workspace().read(cx).items_of_type::<T>(cx)
1554 }
1555
1556 pub fn database_id(&self, cx: &App) -> Option<WorkspaceId> {
1557 self.workspace().read(cx).database_id()
1558 }
1559
1560 pub fn take_pending_removal_tasks(&mut self) -> Vec<Task<()>> {
1561 let tasks: Vec<Task<()>> = std::mem::take(&mut self.pending_removal_tasks)
1562 .into_iter()
1563 .filter(|task| !task.is_ready())
1564 .collect();
1565 tasks
1566 }
1567
1568 #[cfg(any(test, feature = "test-support"))]
1569 pub fn test_expand_all_groups(&mut self) {
1570 self.set_all_groups_expanded(true);
1571 self.set_all_groups_visible_thread_count(Some(10_000));
1572 }
1573
1574 #[cfg(any(test, feature = "test-support"))]
1575 pub fn assert_project_group_key_integrity(&self, cx: &App) -> anyhow::Result<()> {
1576 let mut retained_ids: collections::HashSet<EntityId> = Default::default();
1577 for workspace in &self.retained_workspaces {
1578 anyhow::ensure!(
1579 retained_ids.insert(workspace.entity_id()),
1580 "workspace {:?} is retained more than once",
1581 workspace.entity_id(),
1582 );
1583
1584 let live_key = workspace.read(cx).project_group_key(cx);
1585 anyhow::ensure!(
1586 self.project_groups
1587 .iter()
1588 .any(|group| group.key == live_key),
1589 "workspace {:?} has live key {:?} but no project-group metadata",
1590 workspace.entity_id(),
1591 live_key,
1592 );
1593 }
1594 Ok(())
1595 }
1596
1597 #[cfg(any(test, feature = "test-support"))]
1598 pub fn set_random_database_id(&mut self, cx: &mut Context<Self>) {
1599 self.workspace().update(cx, |workspace, _cx| {
1600 workspace.set_random_database_id();
1601 });
1602 }
1603
1604 #[cfg(any(test, feature = "test-support"))]
1605 pub fn test_new(project: Entity<Project>, window: &mut Window, cx: &mut Context<Self>) -> Self {
1606 let workspace = cx.new(|cx| Workspace::test_new(project, window, cx));
1607 Self::new(workspace, window, cx)
1608 }
1609
1610 #[cfg(any(test, feature = "test-support"))]
1611 pub fn test_add_workspace(
1612 &mut self,
1613 project: Entity<Project>,
1614 window: &mut Window,
1615 cx: &mut Context<Self>,
1616 ) -> Entity<Workspace> {
1617 let workspace = cx.new(|cx| Workspace::test_new(project, window, cx));
1618 self.activate(workspace.clone(), window, cx);
1619 workspace
1620 }
1621
1622 #[cfg(any(test, feature = "test-support"))]
1623 pub fn test_add_project_group(&mut self, group: ProjectGroup) {
1624 self.project_groups.push(ProjectGroupState {
1625 key: group.key,
1626 expanded: group.expanded,
1627 visible_thread_count: group.visible_thread_count,
1628 });
1629 }
1630
1631 #[cfg(any(test, feature = "test-support"))]
1632 pub fn create_test_workspace(
1633 &mut self,
1634 window: &mut Window,
1635 cx: &mut Context<Self>,
1636 ) -> Task<()> {
1637 let app_state = self.workspace().read(cx).app_state().clone();
1638 let project = Project::local(
1639 app_state.client.clone(),
1640 app_state.node_runtime.clone(),
1641 app_state.user_store.clone(),
1642 app_state.languages.clone(),
1643 app_state.fs.clone(),
1644 None,
1645 project::LocalProjectFlags::default(),
1646 cx,
1647 );
1648 let new_workspace = cx.new(|cx| Workspace::new(None, project, app_state, window, cx));
1649 self.activate(new_workspace.clone(), window, cx);
1650
1651 let weak_workspace = new_workspace.downgrade();
1652 let db = crate::persistence::WorkspaceDb::global(cx);
1653 cx.spawn_in(window, async move |this, cx| {
1654 let workspace_id = db.next_id().await.unwrap();
1655 let workspace = weak_workspace.upgrade().unwrap();
1656 let task: Task<()> = this
1657 .update_in(cx, |this, window, cx| {
1658 let session_id = workspace.read(cx).session_id();
1659 let window_id = window.window_handle().window_id().as_u64();
1660 workspace.update(cx, |workspace, _cx| {
1661 workspace.set_database_id(workspace_id);
1662 });
1663 this.serialize(cx);
1664 let db = db.clone();
1665 cx.background_spawn(async move {
1666 db.set_session_binding(workspace_id, session_id, Some(window_id))
1667 .await
1668 .log_err();
1669 })
1670 })
1671 .unwrap();
1672 task.await
1673 })
1674 }
1675
1676 /// Assigns random database IDs to all retained workspaces, flushes
1677 /// workspace serialization (SQLite) and multi-workspace state (KVP),
1678 /// and writes session bindings so the serialized data can be read
1679 /// back by `last_session_workspace_locations` +
1680 /// `read_serialized_multi_workspaces`.
1681 #[cfg(any(test, feature = "test-support"))]
1682 pub fn flush_all_serialization(
1683 &mut self,
1684 window: &mut Window,
1685 cx: &mut Context<Self>,
1686 ) -> Vec<Task<()>> {
1687 for workspace in self.workspaces() {
1688 workspace.update(cx, |ws, _cx| {
1689 if ws.database_id().is_none() {
1690 ws.set_random_database_id();
1691 }
1692 });
1693 }
1694
1695 let session_id = self.workspace().read(cx).session_id();
1696 let window_id_u64 = window.window_handle().window_id().as_u64();
1697
1698 let mut tasks: Vec<Task<()>> = Vec::new();
1699 for workspace in self.workspaces() {
1700 tasks.push(workspace.update(cx, |ws, cx| ws.flush_serialization(window, cx)));
1701 if let Some(db_id) = workspace.read(cx).database_id() {
1702 let db = crate::persistence::WorkspaceDb::global(cx);
1703 let session_id = session_id.clone();
1704 tasks.push(cx.background_spawn(async move {
1705 db.set_session_binding(db_id, session_id, Some(window_id_u64))
1706 .await
1707 .log_err();
1708 }));
1709 }
1710 }
1711 self.serialize(cx);
1712 tasks
1713 }
1714
1715 /// Removes one or more workspaces from this multi-workspace.
1716 ///
1717 /// If the active workspace is among those being removed,
1718 /// `fallback_workspace` is called **synchronously before the removal
1719 /// begins** to produce a `Task` that resolves to the workspace that
1720 /// should become active. The fallback must not be one of the
1721 /// workspaces being removed.
1722 ///
1723 /// Returns `true` if any workspaces were actually removed.
1724 pub fn remove(
1725 &mut self,
1726 workspaces: impl IntoIterator<Item = Entity<Workspace>>,
1727 fallback_workspace: impl FnOnce(
1728 &mut Self,
1729 &mut Window,
1730 &mut Context<Self>,
1731 ) -> Task<Result<Entity<Workspace>>>,
1732 window: &mut Window,
1733 cx: &mut Context<Self>,
1734 ) -> Task<Result<bool>> {
1735 let workspaces: Vec<_> = workspaces.into_iter().collect();
1736
1737 if workspaces.is_empty() {
1738 return Task::ready(Ok(false));
1739 }
1740
1741 let removing_active = workspaces.iter().any(|ws| ws == self.workspace());
1742 let original_active = self.workspace().clone();
1743
1744 let fallback_task = removing_active.then(|| fallback_workspace(self, window, cx));
1745
1746 cx.spawn_in(window, async move |this, cx| {
1747 // Prompt each workspace for unsaved changes. If any workspace
1748 // has dirty buffers, save_all_internal will emit Activate to
1749 // bring it into view before showing the save dialog.
1750 for workspace in &workspaces {
1751 let should_continue = workspace
1752 .update_in(cx, |workspace, window, cx| {
1753 workspace.save_all_internal(crate::SaveIntent::Close, window, cx)
1754 })?
1755 .await?;
1756
1757 if !should_continue {
1758 return Ok(false);
1759 }
1760 }
1761
1762 // If we're removing the active workspace, await the
1763 // fallback and switch to it before tearing anything down.
1764 // Otherwise restore the original active workspace in case
1765 // prompting switched away from it.
1766 if let Some(fallback_task) = fallback_task {
1767 let new_active = fallback_task.await?;
1768
1769 this.update_in(cx, |this, window, cx| {
1770 assert!(
1771 !workspaces.contains(&new_active),
1772 "fallback workspace must not be one of the workspaces being removed"
1773 );
1774 this.activate(new_active, window, cx);
1775 })?;
1776 } else {
1777 this.update_in(cx, |this, window, cx| {
1778 if *this.workspace() != original_active {
1779 this.activate(original_active, window, cx);
1780 }
1781 })?;
1782 }
1783
1784 // Actually remove the workspaces.
1785 this.update_in(cx, |this, _, cx| {
1786 let mut removed_any = false;
1787
1788 for workspace in &workspaces {
1789 let was_retained = this.is_workspace_retained(workspace);
1790 if was_retained {
1791 this.detach_workspace(workspace, cx);
1792 removed_any = true;
1793 }
1794 }
1795
1796 if removed_any {
1797 this.serialize(cx);
1798 cx.notify();
1799 }
1800
1801 Ok(removed_any)
1802 })?
1803 })
1804 }
1805
1806 pub fn open_project(
1807 &mut self,
1808 paths: Vec<PathBuf>,
1809 open_mode: OpenMode,
1810 window: &mut Window,
1811 cx: &mut Context<Self>,
1812 ) -> Task<Result<Entity<Workspace>>> {
1813 if self.multi_workspace_enabled(cx) {
1814 self.find_or_create_local_workspace(PathList::new(&paths), None, &[], window, cx)
1815 } else {
1816 let workspace = self.workspace().clone();
1817 cx.spawn_in(window, async move |_this, cx| {
1818 let should_continue = workspace
1819 .update_in(cx, |workspace, window, cx| {
1820 workspace.prepare_to_close(crate::CloseIntent::ReplaceWindow, window, cx)
1821 })?
1822 .await?;
1823 if should_continue {
1824 workspace
1825 .update_in(cx, |workspace, window, cx| {
1826 workspace.open_workspace_for_paths(open_mode, paths, window, cx)
1827 })?
1828 .await
1829 } else {
1830 Ok(workspace)
1831 }
1832 })
1833 }
1834 }
1835}
1836
1837impl Render for MultiWorkspace {
1838 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1839 let multi_workspace_enabled = self.multi_workspace_enabled(cx);
1840 let sidebar_side = self.sidebar_side(cx);
1841 let sidebar_on_right = sidebar_side == SidebarSide::Right;
1842
1843 let sidebar: Option<AnyElement> = if multi_workspace_enabled && self.sidebar_open() {
1844 self.sidebar.as_ref().map(|sidebar_handle| {
1845 let weak = cx.weak_entity();
1846
1847 let sidebar_width = sidebar_handle.width(cx);
1848 let resize_handle = deferred(
1849 div()
1850 .id("sidebar-resize-handle")
1851 .absolute()
1852 .when(!sidebar_on_right, |el| {
1853 el.right(-SIDEBAR_RESIZE_HANDLE_SIZE / 2.)
1854 })
1855 .when(sidebar_on_right, |el| {
1856 el.left(-SIDEBAR_RESIZE_HANDLE_SIZE / 2.)
1857 })
1858 .top(px(0.))
1859 .h_full()
1860 .w(SIDEBAR_RESIZE_HANDLE_SIZE)
1861 .cursor_col_resize()
1862 .on_drag(DraggedSidebar, |dragged, _, _, cx| {
1863 cx.stop_propagation();
1864 cx.new(|_| dragged.clone())
1865 })
1866 .on_mouse_down(MouseButton::Left, |_, _, cx| {
1867 cx.stop_propagation();
1868 })
1869 .on_mouse_up(MouseButton::Left, move |event, _, cx| {
1870 if event.click_count == 2 {
1871 weak.update(cx, |this, cx| {
1872 if let Some(sidebar) = this.sidebar.as_mut() {
1873 sidebar.set_width(None, cx);
1874 }
1875 this.serialize(cx);
1876 })
1877 .ok();
1878 cx.stop_propagation();
1879 } else {
1880 weak.update(cx, |this, cx| {
1881 this.serialize(cx);
1882 })
1883 .ok();
1884 }
1885 })
1886 .occlude(),
1887 );
1888
1889 div()
1890 .id("sidebar-container")
1891 .relative()
1892 .h_full()
1893 .w(sidebar_width)
1894 .flex_shrink_0()
1895 .child(sidebar_handle.to_any())
1896 .child(resize_handle)
1897 .into_any_element()
1898 })
1899 } else {
1900 None
1901 };
1902
1903 let (left_sidebar, right_sidebar) = if sidebar_on_right {
1904 (None, sidebar)
1905 } else {
1906 (sidebar, None)
1907 };
1908
1909 let ui_font = theme_settings::setup_ui_font(window, cx);
1910 let text_color = cx.theme().colors().text;
1911
1912 let workspace = self.workspace().clone();
1913 let workspace_key_context = workspace.update(cx, |workspace, cx| workspace.key_context(cx));
1914 let root = workspace.update(cx, |workspace, cx| workspace.actions(h_flex(), window, cx));
1915
1916 client_side_decorations(
1917 root.key_context(workspace_key_context)
1918 .relative()
1919 .size_full()
1920 .font(ui_font)
1921 .text_color(text_color)
1922 .on_action(cx.listener(Self::close_window))
1923 .when(self.multi_workspace_enabled(cx), |this| {
1924 this.on_action(cx.listener(
1925 |this: &mut Self, _: &ToggleWorkspaceSidebar, window, cx| {
1926 this.toggle_sidebar(window, cx);
1927 },
1928 ))
1929 .on_action(cx.listener(
1930 |this: &mut Self, _: &CloseWorkspaceSidebar, window, cx| {
1931 this.close_sidebar_action(window, cx);
1932 },
1933 ))
1934 .on_action(cx.listener(
1935 |this: &mut Self, _: &FocusWorkspaceSidebar, window, cx| {
1936 this.focus_sidebar(window, cx);
1937 },
1938 ))
1939 .on_action(cx.listener(
1940 |this: &mut Self, action: &ToggleThreadSwitcher, window, cx| {
1941 if let Some(sidebar) = &this.sidebar {
1942 sidebar.toggle_thread_switcher(action.select_last, window, cx);
1943 }
1944 },
1945 ))
1946 .on_action(cx.listener(|this: &mut Self, _: &NextProject, window, cx| {
1947 if let Some(sidebar) = &this.sidebar {
1948 sidebar.cycle_project(true, window, cx);
1949 }
1950 }))
1951 .on_action(
1952 cx.listener(|this: &mut Self, _: &PreviousProject, window, cx| {
1953 if let Some(sidebar) = &this.sidebar {
1954 sidebar.cycle_project(false, window, cx);
1955 }
1956 }),
1957 )
1958 .on_action(cx.listener(|this: &mut Self, _: &NextThread, window, cx| {
1959 if let Some(sidebar) = &this.sidebar {
1960 sidebar.cycle_thread(true, window, cx);
1961 }
1962 }))
1963 .on_action(
1964 cx.listener(|this: &mut Self, _: &PreviousThread, window, cx| {
1965 if let Some(sidebar) = &this.sidebar {
1966 sidebar.cycle_thread(false, window, cx);
1967 }
1968 }),
1969 )
1970 .when(self.project_group_keys().len() >= 2, |el| {
1971 el.on_action(cx.listener(
1972 |this: &mut Self, _: &MoveProjectToNewWindow, window, cx| {
1973 let key =
1974 this.project_group_key_for_workspace(this.workspace(), cx);
1975 this.open_project_group_in_new_window(&key, window, cx)
1976 .detach_and_log_err(cx);
1977 },
1978 ))
1979 })
1980 })
1981 .when(
1982 self.sidebar_open() && self.multi_workspace_enabled(cx),
1983 |this| {
1984 this.on_drag_move(cx.listener(
1985 move |this: &mut Self,
1986 e: &DragMoveEvent<DraggedSidebar>,
1987 window,
1988 cx| {
1989 if let Some(sidebar) = &this.sidebar {
1990 let new_width = if sidebar_on_right {
1991 window.bounds().size.width - e.event.position.x
1992 } else {
1993 e.event.position.x
1994 };
1995 sidebar.set_width(Some(new_width), cx);
1996 }
1997 },
1998 ))
1999 },
2000 )
2001 .children(left_sidebar)
2002 .child(
2003 div()
2004 .flex()
2005 .flex_1()
2006 .size_full()
2007 .overflow_hidden()
2008 .child(self.workspace().clone()),
2009 )
2010 .children(right_sidebar)
2011 .child(self.workspace().read(cx).modal_layer.clone())
2012 .children(self.sidebar_overlay.as_ref().map(|view| {
2013 deferred(div().absolute().size_full().inset_0().occlude().child(
2014 v_flex().h(px(0.0)).top_20().items_center().child(
2015 h_flex().occlude().child(view.clone()).on_mouse_down(
2016 MouseButton::Left,
2017 |_, _, cx| {
2018 cx.stop_propagation();
2019 },
2020 ),
2021 ),
2022 ))
2023 .with_priority(2)
2024 })),
2025 window,
2026 cx,
2027 Tiling {
2028 left: !sidebar_on_right && multi_workspace_enabled && self.sidebar_open(),
2029 right: sidebar_on_right && multi_workspace_enabled && self.sidebar_open(),
2030 ..Tiling::default()
2031 },
2032 )
2033 }
2034}