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 let weak_self = cx.weak_entity();
730 workspace.update(cx, |workspace, cx| {
731 workspace.set_multi_workspace(weak_self, cx);
732 });
733
734 let entity = cx.entity();
735 cx.defer({
736 let workspace = workspace.clone();
737 move |cx| {
738 entity.update(cx, |this, cx| {
739 this.sync_sidebar_to_workspace(&workspace, cx);
740 })
741 }
742 });
743 }
744
745 pub fn project_group_key_for_workspace(
746 &self,
747 workspace: &Entity<Workspace>,
748 cx: &App,
749 ) -> ProjectGroupKey {
750 workspace.read(cx).project_group_key(cx)
751 }
752
753 pub fn restore_project_groups(
754 &mut self,
755 groups: Vec<SerializedProjectGroupState>,
756 _cx: &mut Context<Self>,
757 ) {
758 let mut restored: Vec<ProjectGroupState> = Vec::new();
759 for SerializedProjectGroupState {
760 key,
761 expanded,
762 visible_thread_count,
763 } in groups
764 {
765 if key.path_list().paths().is_empty() {
766 continue;
767 }
768 if restored.iter().any(|group| group.key == key) {
769 continue;
770 }
771 restored.push(ProjectGroupState {
772 key,
773 expanded,
774 visible_thread_count,
775 });
776 }
777 for existing in std::mem::take(&mut self.project_groups) {
778 if !restored.iter().any(|group| group.key == existing.key) {
779 restored.push(existing);
780 }
781 }
782 self.project_groups = restored;
783 }
784
785 pub fn project_group_keys(&self) -> Vec<ProjectGroupKey> {
786 self.project_groups
787 .iter()
788 .map(|group| group.key.clone())
789 .collect()
790 }
791
792 fn derived_project_groups(&self, cx: &App) -> Vec<ProjectGroup> {
793 self.project_groups
794 .iter()
795 .map(|group| ProjectGroup {
796 key: group.key.clone(),
797 workspaces: self
798 .retained_workspaces
799 .iter()
800 .filter(|workspace| workspace.read(cx).project_group_key(cx) == group.key)
801 .cloned()
802 .collect(),
803 expanded: group.expanded,
804 visible_thread_count: group.visible_thread_count,
805 })
806 .collect()
807 }
808
809 pub fn project_groups(&self, cx: &App) -> Vec<ProjectGroup> {
810 self.derived_project_groups(cx)
811 }
812
813 pub fn group_state_by_key(&self, key: &ProjectGroupKey) -> Option<&ProjectGroupState> {
814 self.project_groups.iter().find(|group| group.key == *key)
815 }
816
817 pub fn group_state_by_key_mut(
818 &mut self,
819 key: &ProjectGroupKey,
820 ) -> Option<&mut ProjectGroupState> {
821 self.project_groups
822 .iter_mut()
823 .find(|group| group.key == *key)
824 }
825
826 pub fn set_all_groups_expanded(&mut self, expanded: bool) {
827 for group in &mut self.project_groups {
828 group.expanded = expanded;
829 }
830 }
831
832 pub fn set_all_groups_visible_thread_count(&mut self, count: Option<usize>) {
833 for group in &mut self.project_groups {
834 group.visible_thread_count = count;
835 }
836 }
837
838 pub fn workspaces_for_project_group(
839 &self,
840 key: &ProjectGroupKey,
841 cx: &App,
842 ) -> Option<Vec<Entity<Workspace>>> {
843 let has_group = self.project_groups.iter().any(|group| group.key == *key)
844 || self
845 .retained_workspaces
846 .iter()
847 .any(|workspace| workspace.read(cx).project_group_key(cx) == *key);
848
849 has_group.then(|| {
850 self.retained_workspaces
851 .iter()
852 .filter(|workspace| workspace.read(cx).project_group_key(cx) == *key)
853 .cloned()
854 .collect()
855 })
856 }
857
858 pub fn remove_folder_from_project_group(
859 &mut self,
860 group_key: &ProjectGroupKey,
861 path: &Path,
862 cx: &mut Context<Self>,
863 ) {
864 let workspaces = self
865 .workspaces_for_project_group(group_key, cx)
866 .unwrap_or_default();
867
868 let Some(group) = self
869 .project_groups
870 .iter()
871 .find(|group| group.key == *group_key)
872 else {
873 return;
874 };
875
876 let new_path_list = group.key.path_list().without_path(path);
877 if new_path_list.is_empty() {
878 return;
879 }
880
881 let new_key = ProjectGroupKey::new(group.key.host(), new_path_list);
882 self.update_project_group_key(group_key, &new_key, cx);
883
884 for workspace in workspaces {
885 let project = workspace.read(cx).project().clone();
886 project.update(cx, |project, cx| {
887 project.remove_worktree_for_main_worktree_path(path, cx);
888 });
889 }
890
891 self.serialize(cx);
892 cx.notify();
893 }
894
895 pub fn prompt_to_add_folders_to_project_group(
896 &mut self,
897 group_key: ProjectGroupKey,
898 window: &mut Window,
899 cx: &mut Context<Self>,
900 ) {
901 let paths = self.workspace().update(cx, |workspace, cx| {
902 workspace.prompt_for_open_path(
903 PathPromptOptions {
904 files: false,
905 directories: true,
906 multiple: true,
907 prompt: None,
908 },
909 DirectoryLister::Project(workspace.project().clone()),
910 window,
911 cx,
912 )
913 });
914
915 cx.spawn_in(window, async move |this, cx| {
916 if let Some(new_paths) = paths.await.ok().flatten() {
917 if !new_paths.is_empty() {
918 this.update(cx, |multi_workspace, cx| {
919 multi_workspace.add_folders_to_project_group(&group_key, new_paths, cx);
920 })?;
921 }
922 }
923 anyhow::Ok(())
924 })
925 .detach_and_log_err(cx);
926 }
927
928 pub fn add_folders_to_project_group(
929 &mut self,
930 group_key: &ProjectGroupKey,
931 new_paths: Vec<PathBuf>,
932 cx: &mut Context<Self>,
933 ) {
934 let workspaces = self
935 .workspaces_for_project_group(group_key, cx)
936 .unwrap_or_default();
937
938 let Some(group) = self
939 .project_groups
940 .iter()
941 .find(|group| group.key == *group_key)
942 else {
943 return;
944 };
945
946 let existing_paths = group.key.path_list().paths();
947 let new_paths: Vec<PathBuf> = new_paths
948 .into_iter()
949 .filter(|p| !existing_paths.contains(p))
950 .collect();
951
952 if new_paths.is_empty() {
953 return;
954 }
955
956 let mut all_paths: Vec<PathBuf> = existing_paths.to_vec();
957 all_paths.extend(new_paths.iter().cloned());
958 let new_path_list = PathList::new(&all_paths);
959 let new_key = ProjectGroupKey::new(group.key.host(), new_path_list);
960
961 self.update_project_group_key(group_key, &new_key, cx);
962
963 for workspace in workspaces {
964 let project = workspace.read(cx).project().clone();
965 for path in &new_paths {
966 project
967 .update(cx, |project, cx| {
968 project.find_or_create_worktree(path, true, cx)
969 })
970 .detach_and_log_err(cx);
971 }
972 }
973
974 self.serialize(cx);
975 cx.notify();
976 }
977
978 pub fn remove_project_group(
979 &mut self,
980 group_key: &ProjectGroupKey,
981 window: &mut Window,
982 cx: &mut Context<Self>,
983 ) -> Task<Result<bool>> {
984 let pos = self
985 .project_groups
986 .iter()
987 .position(|group| group.key == *group_key);
988 let workspaces = self
989 .workspaces_for_project_group(group_key, cx)
990 .unwrap_or_default();
991
992 // Compute the neighbor while the group is still in the list.
993 let neighbor_key = pos.and_then(|pos| {
994 self.project_groups
995 .get(pos + 1)
996 .or_else(|| pos.checked_sub(1).and_then(|i| self.project_groups.get(i)))
997 .map(|group| group.key.clone())
998 });
999
1000 // Now remove the group.
1001 self.project_groups.retain(|group| group.key != *group_key);
1002
1003 let excluded_workspaces = workspaces.clone();
1004 self.remove(
1005 workspaces,
1006 move |this, window, cx| {
1007 if let Some(neighbor_key) = neighbor_key {
1008 return this.find_or_create_local_workspace(
1009 neighbor_key.path_list().clone(),
1010 Some(neighbor_key.clone()),
1011 &excluded_workspaces,
1012 window,
1013 cx,
1014 );
1015 }
1016
1017 // No other project groups remain — create an empty workspace.
1018 let app_state = this.workspace().read(cx).app_state().clone();
1019 let project = Project::local(
1020 app_state.client.clone(),
1021 app_state.node_runtime.clone(),
1022 app_state.user_store.clone(),
1023 app_state.languages.clone(),
1024 app_state.fs.clone(),
1025 None,
1026 project::LocalProjectFlags::default(),
1027 cx,
1028 );
1029 let new_workspace =
1030 cx.new(|cx| Workspace::new(None, project, app_state, window, cx));
1031 Task::ready(Ok(new_workspace))
1032 },
1033 window,
1034 cx,
1035 )
1036 }
1037
1038 /// Goes through sqlite: serialize -> close -> open new window
1039 /// This avoids issues with pending tasks having the wrong window
1040 pub fn open_project_group_in_new_window(
1041 &mut self,
1042 key: &ProjectGroupKey,
1043 window: &mut Window,
1044 cx: &mut Context<Self>,
1045 ) -> Task<Result<()>> {
1046 let paths: Vec<PathBuf> = key.path_list().ordered_paths().cloned().collect();
1047 if paths.is_empty() {
1048 return Task::ready(Ok(()));
1049 }
1050
1051 let app_state = self.workspace().read(cx).app_state().clone();
1052
1053 let workspaces: Vec<_> = self
1054 .workspaces_for_project_group(key, cx)
1055 .unwrap_or_default();
1056 let mut serialization_tasks = Vec::new();
1057 for workspace in &workspaces {
1058 serialization_tasks.push(workspace.update(cx, |workspace, inner_cx| {
1059 workspace.flush_serialization(window, inner_cx)
1060 }));
1061 }
1062
1063 let remove_task = self.remove_project_group(key, window, cx);
1064
1065 cx.spawn(async move |_this, cx| {
1066 futures::future::join_all(serialization_tasks).await;
1067
1068 let removed = remove_task.await?;
1069 if !removed {
1070 return Ok(());
1071 }
1072
1073 cx.update(|cx| {
1074 Workspace::new_local(paths, app_state, None, None, None, OpenMode::NewWindow, cx)
1075 })
1076 .await?;
1077
1078 Ok(())
1079 })
1080 }
1081
1082 /// Finds an existing workspace whose root paths and host exactly match.
1083 pub fn workspace_for_paths(
1084 &self,
1085 path_list: &PathList,
1086 host: Option<&RemoteConnectionOptions>,
1087 cx: &App,
1088 ) -> Option<Entity<Workspace>> {
1089 self.workspace_for_paths_excluding(path_list, host, &[], cx)
1090 }
1091
1092 fn workspace_for_paths_excluding(
1093 &self,
1094 path_list: &PathList,
1095 host: Option<&RemoteConnectionOptions>,
1096 excluding: &[Entity<Workspace>],
1097 cx: &App,
1098 ) -> Option<Entity<Workspace>> {
1099 for workspace in self.workspaces() {
1100 if excluding.contains(workspace) {
1101 continue;
1102 }
1103 let root_paths = PathList::new(&workspace.read(cx).root_paths(cx));
1104 let key = workspace.read(cx).project_group_key(cx);
1105 let host_matches = key.host().as_ref() == host;
1106 let paths_match = root_paths == *path_list;
1107 if host_matches && paths_match {
1108 return Some(workspace.clone());
1109 }
1110 }
1111
1112 None
1113 }
1114
1115 /// Finds an existing workspace whose paths match, or creates a new one.
1116 ///
1117 /// For local projects (`host` is `None`), this delegates to
1118 /// [`Self::find_or_create_local_workspace`]. For remote projects, it
1119 /// tries an exact path match and, if no existing workspace is found,
1120 /// calls `connect_remote` to establish a connection and creates a new
1121 /// remote workspace.
1122 ///
1123 /// The `connect_remote` closure is responsible for any user-facing
1124 /// connection UI (e.g. password prompts). It receives the connection
1125 /// options and should return a [`Task`] that resolves to the
1126 /// [`RemoteClient`] session, or `None` if the connection was
1127 /// cancelled.
1128 pub fn find_or_create_workspace(
1129 &mut self,
1130 paths: PathList,
1131 host: Option<RemoteConnectionOptions>,
1132 provisional_project_group_key: Option<ProjectGroupKey>,
1133 connect_remote: impl FnOnce(
1134 RemoteConnectionOptions,
1135 &mut Window,
1136 &mut Context<Self>,
1137 ) -> Task<Result<Option<Entity<remote::RemoteClient>>>>
1138 + 'static,
1139 excluding: &[Entity<Workspace>],
1140 window: &mut Window,
1141 cx: &mut Context<Self>,
1142 ) -> Task<Result<Entity<Workspace>>> {
1143 if let Some(workspace) = self.workspace_for_paths(&paths, host.as_ref(), cx) {
1144 self.activate(workspace.clone(), window, cx);
1145 return Task::ready(Ok(workspace));
1146 }
1147
1148 let Some(connection_options) = host else {
1149 return self.find_or_create_local_workspace(
1150 paths,
1151 provisional_project_group_key,
1152 excluding,
1153 window,
1154 cx,
1155 );
1156 };
1157
1158 let app_state = self.workspace().read(cx).app_state().clone();
1159 let window_handle = window.window_handle().downcast::<MultiWorkspace>();
1160 let connect_task = connect_remote(connection_options.clone(), window, cx);
1161 let paths_vec = paths.paths().to_vec();
1162
1163 cx.spawn(async move |_this, cx| {
1164 let session = connect_task
1165 .await?
1166 .ok_or_else(|| anyhow::anyhow!("Remote connection was cancelled"))?;
1167
1168 let new_project = cx.update(|cx| {
1169 Project::remote(
1170 session,
1171 app_state.client.clone(),
1172 app_state.node_runtime.clone(),
1173 app_state.user_store.clone(),
1174 app_state.languages.clone(),
1175 app_state.fs.clone(),
1176 true,
1177 cx,
1178 )
1179 });
1180
1181 let window_handle =
1182 window_handle.ok_or_else(|| anyhow::anyhow!("Window is not a MultiWorkspace"))?;
1183
1184 open_remote_project_with_existing_connection(
1185 connection_options,
1186 new_project,
1187 paths_vec,
1188 app_state,
1189 window_handle,
1190 provisional_project_group_key,
1191 cx,
1192 )
1193 .await?;
1194
1195 window_handle.update(cx, |multi_workspace, window, cx| {
1196 let workspace = multi_workspace.workspace().clone();
1197 multi_workspace.add(workspace.clone(), window, cx);
1198 workspace
1199 })
1200 })
1201 }
1202
1203 /// Finds an existing workspace in this multi-workspace whose paths match,
1204 /// or creates a new one (deserializing its saved state from the database).
1205 /// Never searches other windows or matches workspaces with a superset of
1206 /// the requested paths.
1207 ///
1208 /// `excluding` lists workspaces that should be skipped during the search
1209 /// (e.g. workspaces that are about to be removed).
1210 pub fn find_or_create_local_workspace(
1211 &mut self,
1212 path_list: PathList,
1213 project_group: Option<ProjectGroupKey>,
1214 excluding: &[Entity<Workspace>],
1215 window: &mut Window,
1216 cx: &mut Context<Self>,
1217 ) -> Task<Result<Entity<Workspace>>> {
1218 if let Some(workspace) = self.workspace_for_paths_excluding(&path_list, None, excluding, cx)
1219 {
1220 self.activate(workspace.clone(), window, cx);
1221 return Task::ready(Ok(workspace));
1222 }
1223
1224 let paths = path_list.paths().to_vec();
1225 let app_state = self.workspace().read(cx).app_state().clone();
1226 let requesting_window = window.window_handle().downcast::<MultiWorkspace>();
1227 let fs = <dyn Fs>::global(cx);
1228 let excluding = excluding.to_vec();
1229
1230 cx.spawn(async move |_this, cx| {
1231 let effective_path_list = if let Some(project_group) = project_group {
1232 let metadata_tasks: Vec<_> = paths
1233 .iter()
1234 .map(|path| fs.metadata(path.as_path()))
1235 .collect();
1236 let metadata_results = futures::future::join_all(metadata_tasks).await;
1237 // Only fall back when every path is definitely absent; real
1238 // filesystem errors should not be treated as "missing".
1239 let all_paths_missing = !paths.is_empty()
1240 && metadata_results
1241 .into_iter()
1242 // Ok(None) means the path is definitely absent
1243 .all(|result| matches!(result, Ok(None)));
1244
1245 if all_paths_missing {
1246 project_group.path_list().clone()
1247 } else {
1248 PathList::new(&paths)
1249 }
1250 } else {
1251 PathList::new(&paths)
1252 };
1253
1254 if let Some(requesting_window) = requesting_window
1255 && let Some(workspace) = requesting_window
1256 .update(cx, |multi_workspace, window, cx| {
1257 multi_workspace
1258 .workspace_for_paths_excluding(
1259 &effective_path_list,
1260 None,
1261 &excluding,
1262 cx,
1263 )
1264 .inspect(|workspace| {
1265 multi_workspace.activate(workspace.clone(), window, cx);
1266 })
1267 })
1268 .ok()
1269 .flatten()
1270 {
1271 return Ok(workspace);
1272 }
1273
1274 let result = cx
1275 .update(|cx| {
1276 Workspace::new_local(
1277 effective_path_list.paths().to_vec(),
1278 app_state,
1279 requesting_window,
1280 None,
1281 None,
1282 OpenMode::Activate,
1283 cx,
1284 )
1285 })
1286 .await?;
1287 Ok(result.workspace)
1288 })
1289 }
1290
1291 pub fn workspace(&self) -> &Entity<Workspace> {
1292 &self.active_workspace
1293 }
1294
1295 pub fn workspaces(&self) -> impl Iterator<Item = &Entity<Workspace>> {
1296 let active_is_retained = self.is_workspace_retained(&self.active_workspace);
1297 self.retained_workspaces
1298 .iter()
1299 .chain(std::iter::once(&self.active_workspace).filter(move |_| !active_is_retained))
1300 }
1301
1302 /// Adds a workspace to this window as persistent without changing which
1303 /// workspace is active. Unlike `activate()`, this always inserts into the
1304 /// persistent list regardless of sidebar state — it's used for system-
1305 /// initiated additions like deserialization and worktree discovery.
1306 pub fn add(&mut self, workspace: Entity<Workspace>, window: &Window, cx: &mut Context<Self>) {
1307 if self.is_workspace_retained(&workspace) {
1308 return;
1309 }
1310
1311 if workspace != self.active_workspace {
1312 self.register_workspace(&workspace, window, cx);
1313 }
1314
1315 let key = workspace.read(cx).project_group_key(cx);
1316 self.retain_workspace(workspace, key, cx);
1317 cx.notify();
1318 }
1319
1320 /// Ensures the workspace is in the multiworkspace and makes it the active one.
1321 pub fn activate(
1322 &mut self,
1323 workspace: Entity<Workspace>,
1324 window: &mut Window,
1325 cx: &mut Context<Self>,
1326 ) {
1327 if self.workspace() == &workspace {
1328 self.focus_active_workspace(window, cx);
1329 return;
1330 }
1331
1332 let old_active_workspace = self.active_workspace.clone();
1333 let old_active_was_retained = self.active_workspace_is_retained();
1334 let workspace_was_retained = self.is_workspace_retained(&workspace);
1335
1336 if !workspace_was_retained {
1337 self.register_workspace(&workspace, window, cx);
1338
1339 if self.sidebar_open {
1340 let key = workspace.read(cx).project_group_key(cx);
1341 self.retain_workspace(workspace.clone(), key, cx);
1342 }
1343 }
1344
1345 self.active_workspace = workspace;
1346
1347 if !self.sidebar_open && !old_active_was_retained {
1348 self.detach_workspace(&old_active_workspace, cx);
1349 }
1350
1351 cx.emit(MultiWorkspaceEvent::ActiveWorkspaceChanged);
1352 self.serialize(cx);
1353 self.focus_active_workspace(window, cx);
1354 cx.notify();
1355 }
1356
1357 /// Promotes the currently active workspace to persistent if it is
1358 /// transient, so it is retained across workspace switches even when
1359 /// the sidebar is closed. No-op if the workspace is already persistent.
1360 pub fn retain_active_workspace(&mut self, cx: &mut Context<Self>) {
1361 let workspace = self.active_workspace.clone();
1362 if self.is_workspace_retained(&workspace) {
1363 return;
1364 }
1365
1366 let key = workspace.read(cx).project_group_key(cx);
1367 self.retain_workspace(workspace, key, cx);
1368 self.serialize(cx);
1369 cx.notify();
1370 }
1371
1372 /// Collapses to a single workspace, discarding all groups.
1373 /// Used when multi-workspace is disabled (e.g. disable_ai).
1374 fn collapse_to_single_workspace(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1375 if self.sidebar_open {
1376 self.close_sidebar(window, cx);
1377 }
1378
1379 let active_workspace = self.active_workspace.clone();
1380 for workspace in self.retained_workspaces.clone() {
1381 if workspace != active_workspace {
1382 self.detach_workspace(&workspace, cx);
1383 }
1384 }
1385
1386 self.retained_workspaces.clear();
1387 self.project_groups.clear();
1388 cx.notify();
1389 }
1390
1391 /// Detaches a workspace: clears session state, DB binding, cached
1392 /// group key, and emits `WorkspaceRemoved`. The DB row is preserved
1393 /// so the workspace still appears in the recent-projects list.
1394 fn detach_workspace(&mut self, workspace: &Entity<Workspace>, cx: &mut Context<Self>) {
1395 self.retained_workspaces
1396 .retain(|retained| retained != workspace);
1397 cx.emit(MultiWorkspaceEvent::WorkspaceRemoved(workspace.entity_id()));
1398 workspace.update(cx, |workspace, _cx| {
1399 workspace.session_id.take();
1400 workspace._schedule_serialize_workspace.take();
1401 workspace._serialize_workspace_task.take();
1402 });
1403
1404 if let Some(workspace_id) = workspace.read(cx).database_id() {
1405 let db = crate::persistence::WorkspaceDb::global(cx);
1406 self.pending_removal_tasks.retain(|task| !task.is_ready());
1407 self.pending_removal_tasks
1408 .push(cx.background_spawn(async move {
1409 db.set_session_binding(workspace_id, None, None)
1410 .await
1411 .log_err();
1412 }));
1413 }
1414 }
1415
1416 fn sync_sidebar_to_workspace(&self, workspace: &Entity<Workspace>, cx: &mut Context<Self>) {
1417 if self.sidebar_open() {
1418 let sidebar_focus_handle = self.sidebar.as_ref().map(|s| s.focus_handle(cx));
1419 workspace.update(cx, |workspace, _| {
1420 workspace.set_sidebar_focus_handle(sidebar_focus_handle);
1421 });
1422 }
1423 }
1424
1425 pub fn serialize(&mut self, cx: &mut Context<Self>) {
1426 self._serialize_task = Some(cx.spawn(async move |this, cx| {
1427 let Some((window_id, state)) = this
1428 .read_with(cx, |this, cx| {
1429 let state = MultiWorkspaceState {
1430 active_workspace_id: this.workspace().read(cx).database_id(),
1431 project_groups: this
1432 .project_groups
1433 .iter()
1434 .map(|group| {
1435 crate::persistence::model::SerializedProjectGroup::from_group(
1436 &group.key,
1437 group.expanded,
1438 group.visible_thread_count,
1439 )
1440 })
1441 .collect::<Vec<_>>(),
1442 sidebar_open: this.sidebar_open,
1443 sidebar_state: this.sidebar.as_ref().and_then(|s| s.serialized_state(cx)),
1444 };
1445 (this.window_id, state)
1446 })
1447 .ok()
1448 else {
1449 return;
1450 };
1451 let kvp = cx.update(|cx| db::kvp::KeyValueStore::global(cx));
1452 crate::persistence::write_multi_workspace_state(&kvp, window_id, state).await;
1453 }));
1454 }
1455
1456 /// Returns the in-flight serialization task (if any) so the caller can
1457 /// await it. Used by the quit handler to ensure pending DB writes
1458 /// complete before the process exits.
1459 pub fn flush_serialization(&mut self) -> Task<()> {
1460 self._serialize_task.take().unwrap_or(Task::ready(()))
1461 }
1462
1463 fn app_will_quit(&mut self, _cx: &mut Context<Self>) -> impl Future<Output = ()> + use<> {
1464 let mut tasks: Vec<Task<()>> = Vec::new();
1465 if let Some(task) = self._serialize_task.take() {
1466 tasks.push(task);
1467 }
1468 tasks.extend(std::mem::take(&mut self.pending_removal_tasks));
1469
1470 async move {
1471 futures::future::join_all(tasks).await;
1472 }
1473 }
1474
1475 pub fn focus_active_workspace(&self, window: &mut Window, cx: &mut App) {
1476 // If a dock panel is zoomed, focus it instead of the center pane.
1477 // Otherwise, focusing the center pane triggers dismiss_zoomed_items_to_reveal
1478 // which closes the zoomed dock.
1479 let focus_handle = {
1480 let workspace = self.workspace().read(cx);
1481 let mut target = None;
1482 for dock in workspace.all_docks() {
1483 let dock = dock.read(cx);
1484 if dock.is_open() {
1485 if let Some(panel) = dock.active_panel() {
1486 if panel.is_zoomed(window, cx) {
1487 target = Some(panel.panel_focus_handle(cx));
1488 break;
1489 }
1490 }
1491 }
1492 }
1493 target.unwrap_or_else(|| {
1494 let pane = workspace.active_pane().clone();
1495 pane.read(cx).focus_handle(cx)
1496 })
1497 };
1498 window.focus(&focus_handle, cx);
1499 }
1500
1501 pub fn panel<T: Panel>(&self, cx: &App) -> Option<Entity<T>> {
1502 self.workspace().read(cx).panel::<T>(cx)
1503 }
1504
1505 pub fn active_modal<V: ManagedView + 'static>(&self, cx: &App) -> Option<Entity<V>> {
1506 self.workspace().read(cx).active_modal::<V>(cx)
1507 }
1508
1509 pub fn add_panel<T: Panel>(
1510 &mut self,
1511 panel: Entity<T>,
1512 window: &mut Window,
1513 cx: &mut Context<Self>,
1514 ) {
1515 self.workspace().update(cx, |workspace, cx| {
1516 workspace.add_panel(panel, window, cx);
1517 });
1518 }
1519
1520 pub fn focus_panel<T: Panel>(
1521 &mut self,
1522 window: &mut Window,
1523 cx: &mut Context<Self>,
1524 ) -> Option<Entity<T>> {
1525 self.workspace()
1526 .update(cx, |workspace, cx| workspace.focus_panel::<T>(window, cx))
1527 }
1528
1529 // used in a test
1530 pub fn toggle_modal<V: ModalView, B>(
1531 &mut self,
1532 window: &mut Window,
1533 cx: &mut Context<Self>,
1534 build: B,
1535 ) where
1536 B: FnOnce(&mut Window, &mut gpui::Context<V>) -> V,
1537 {
1538 self.workspace().update(cx, |workspace, cx| {
1539 workspace.toggle_modal(window, cx, build);
1540 });
1541 }
1542
1543 pub fn toggle_dock(
1544 &mut self,
1545 dock_side: DockPosition,
1546 window: &mut Window,
1547 cx: &mut Context<Self>,
1548 ) {
1549 self.workspace().update(cx, |workspace, cx| {
1550 workspace.toggle_dock(dock_side, window, cx);
1551 });
1552 }
1553
1554 pub fn active_item_as<I: 'static>(&self, cx: &App) -> Option<Entity<I>> {
1555 self.workspace().read(cx).active_item_as::<I>(cx)
1556 }
1557
1558 pub fn items_of_type<'a, T: Item>(
1559 &'a self,
1560 cx: &'a App,
1561 ) -> impl 'a + Iterator<Item = Entity<T>> {
1562 self.workspace().read(cx).items_of_type::<T>(cx)
1563 }
1564
1565 pub fn database_id(&self, cx: &App) -> Option<WorkspaceId> {
1566 self.workspace().read(cx).database_id()
1567 }
1568
1569 pub fn take_pending_removal_tasks(&mut self) -> Vec<Task<()>> {
1570 let tasks: Vec<Task<()>> = std::mem::take(&mut self.pending_removal_tasks)
1571 .into_iter()
1572 .filter(|task| !task.is_ready())
1573 .collect();
1574 tasks
1575 }
1576
1577 #[cfg(any(test, feature = "test-support"))]
1578 pub fn test_expand_all_groups(&mut self) {
1579 self.set_all_groups_expanded(true);
1580 self.set_all_groups_visible_thread_count(Some(10_000));
1581 }
1582
1583 #[cfg(any(test, feature = "test-support"))]
1584 pub fn assert_project_group_key_integrity(&self, cx: &App) -> anyhow::Result<()> {
1585 let mut retained_ids: collections::HashSet<EntityId> = Default::default();
1586 for workspace in &self.retained_workspaces {
1587 anyhow::ensure!(
1588 retained_ids.insert(workspace.entity_id()),
1589 "workspace {:?} is retained more than once",
1590 workspace.entity_id(),
1591 );
1592
1593 let live_key = workspace.read(cx).project_group_key(cx);
1594 anyhow::ensure!(
1595 self.project_groups
1596 .iter()
1597 .any(|group| group.key == live_key),
1598 "workspace {:?} has live key {:?} but no project-group metadata",
1599 workspace.entity_id(),
1600 live_key,
1601 );
1602 }
1603 Ok(())
1604 }
1605
1606 #[cfg(any(test, feature = "test-support"))]
1607 pub fn set_random_database_id(&mut self, cx: &mut Context<Self>) {
1608 self.workspace().update(cx, |workspace, _cx| {
1609 workspace.set_random_database_id();
1610 });
1611 }
1612
1613 #[cfg(any(test, feature = "test-support"))]
1614 pub fn test_new(project: Entity<Project>, window: &mut Window, cx: &mut Context<Self>) -> Self {
1615 let workspace = cx.new(|cx| Workspace::test_new(project, window, cx));
1616 Self::new(workspace, window, cx)
1617 }
1618
1619 #[cfg(any(test, feature = "test-support"))]
1620 pub fn test_add_workspace(
1621 &mut self,
1622 project: Entity<Project>,
1623 window: &mut Window,
1624 cx: &mut Context<Self>,
1625 ) -> Entity<Workspace> {
1626 let workspace = cx.new(|cx| Workspace::test_new(project, window, cx));
1627 self.activate(workspace.clone(), window, cx);
1628 workspace
1629 }
1630
1631 #[cfg(any(test, feature = "test-support"))]
1632 pub fn test_add_project_group(&mut self, group: ProjectGroup) {
1633 self.project_groups.push(ProjectGroupState {
1634 key: group.key,
1635 expanded: group.expanded,
1636 visible_thread_count: group.visible_thread_count,
1637 });
1638 }
1639
1640 #[cfg(any(test, feature = "test-support"))]
1641 pub fn create_test_workspace(
1642 &mut self,
1643 window: &mut Window,
1644 cx: &mut Context<Self>,
1645 ) -> Task<()> {
1646 let app_state = self.workspace().read(cx).app_state().clone();
1647 let project = Project::local(
1648 app_state.client.clone(),
1649 app_state.node_runtime.clone(),
1650 app_state.user_store.clone(),
1651 app_state.languages.clone(),
1652 app_state.fs.clone(),
1653 None,
1654 project::LocalProjectFlags::default(),
1655 cx,
1656 );
1657 let new_workspace = cx.new(|cx| Workspace::new(None, project, app_state, window, cx));
1658 self.activate(new_workspace.clone(), window, cx);
1659
1660 let weak_workspace = new_workspace.downgrade();
1661 let db = crate::persistence::WorkspaceDb::global(cx);
1662 cx.spawn_in(window, async move |this, cx| {
1663 let workspace_id = db.next_id().await.unwrap();
1664 let workspace = weak_workspace.upgrade().unwrap();
1665 let task: Task<()> = this
1666 .update_in(cx, |this, window, cx| {
1667 let session_id = workspace.read(cx).session_id();
1668 let window_id = window.window_handle().window_id().as_u64();
1669 workspace.update(cx, |workspace, _cx| {
1670 workspace.set_database_id(workspace_id);
1671 });
1672 this.serialize(cx);
1673 let db = db.clone();
1674 cx.background_spawn(async move {
1675 db.set_session_binding(workspace_id, session_id, Some(window_id))
1676 .await
1677 .log_err();
1678 })
1679 })
1680 .unwrap();
1681 task.await
1682 })
1683 }
1684
1685 /// Assigns random database IDs to all retained workspaces, flushes
1686 /// workspace serialization (SQLite) and multi-workspace state (KVP),
1687 /// and writes session bindings so the serialized data can be read
1688 /// back by `last_session_workspace_locations` +
1689 /// `read_serialized_multi_workspaces`.
1690 #[cfg(any(test, feature = "test-support"))]
1691 pub fn flush_all_serialization(
1692 &mut self,
1693 window: &mut Window,
1694 cx: &mut Context<Self>,
1695 ) -> Vec<Task<()>> {
1696 for workspace in self.workspaces() {
1697 workspace.update(cx, |ws, _cx| {
1698 if ws.database_id().is_none() {
1699 ws.set_random_database_id();
1700 }
1701 });
1702 }
1703
1704 let session_id = self.workspace().read(cx).session_id();
1705 let window_id_u64 = window.window_handle().window_id().as_u64();
1706
1707 let mut tasks: Vec<Task<()>> = Vec::new();
1708 for workspace in self.workspaces() {
1709 tasks.push(workspace.update(cx, |ws, cx| ws.flush_serialization(window, cx)));
1710 if let Some(db_id) = workspace.read(cx).database_id() {
1711 let db = crate::persistence::WorkspaceDb::global(cx);
1712 let session_id = session_id.clone();
1713 tasks.push(cx.background_spawn(async move {
1714 db.set_session_binding(db_id, session_id, Some(window_id_u64))
1715 .await
1716 .log_err();
1717 }));
1718 }
1719 }
1720 self.serialize(cx);
1721 tasks
1722 }
1723
1724 /// Removes one or more workspaces from this multi-workspace.
1725 ///
1726 /// If the active workspace is among those being removed,
1727 /// `fallback_workspace` is called **synchronously before the removal
1728 /// begins** to produce a `Task` that resolves to the workspace that
1729 /// should become active. The fallback must not be one of the
1730 /// workspaces being removed.
1731 ///
1732 /// Returns `true` if any workspaces were actually removed.
1733 pub fn remove(
1734 &mut self,
1735 workspaces: impl IntoIterator<Item = Entity<Workspace>>,
1736 fallback_workspace: impl FnOnce(
1737 &mut Self,
1738 &mut Window,
1739 &mut Context<Self>,
1740 ) -> Task<Result<Entity<Workspace>>>,
1741 window: &mut Window,
1742 cx: &mut Context<Self>,
1743 ) -> Task<Result<bool>> {
1744 let workspaces: Vec<_> = workspaces.into_iter().collect();
1745
1746 if workspaces.is_empty() {
1747 return Task::ready(Ok(false));
1748 }
1749
1750 let removing_active = workspaces.iter().any(|ws| ws == self.workspace());
1751 let original_active = self.workspace().clone();
1752
1753 let fallback_task = removing_active.then(|| fallback_workspace(self, window, cx));
1754
1755 cx.spawn_in(window, async move |this, cx| {
1756 // Prompt each workspace for unsaved changes. If any workspace
1757 // has dirty buffers, save_all_internal will emit Activate to
1758 // bring it into view before showing the save dialog.
1759 for workspace in &workspaces {
1760 let should_continue = workspace
1761 .update_in(cx, |workspace, window, cx| {
1762 workspace.save_all_internal(crate::SaveIntent::Close, window, cx)
1763 })?
1764 .await?;
1765
1766 if !should_continue {
1767 return Ok(false);
1768 }
1769 }
1770
1771 // If we're removing the active workspace, await the
1772 // fallback and switch to it before tearing anything down.
1773 // Otherwise restore the original active workspace in case
1774 // prompting switched away from it.
1775 if let Some(fallback_task) = fallback_task {
1776 let new_active = fallback_task.await?;
1777
1778 this.update_in(cx, |this, window, cx| {
1779 assert!(
1780 !workspaces.contains(&new_active),
1781 "fallback workspace must not be one of the workspaces being removed"
1782 );
1783 this.activate(new_active, window, cx);
1784 })?;
1785 } else {
1786 this.update_in(cx, |this, window, cx| {
1787 if *this.workspace() != original_active {
1788 this.activate(original_active, window, cx);
1789 }
1790 })?;
1791 }
1792
1793 // Actually remove the workspaces.
1794 this.update_in(cx, |this, _, cx| {
1795 let mut removed_any = false;
1796
1797 for workspace in &workspaces {
1798 let was_retained = this.is_workspace_retained(workspace);
1799 if was_retained {
1800 this.detach_workspace(workspace, cx);
1801 removed_any = true;
1802 }
1803 }
1804
1805 if removed_any {
1806 this.serialize(cx);
1807 cx.notify();
1808 }
1809
1810 Ok(removed_any)
1811 })?
1812 })
1813 }
1814
1815 pub fn open_project(
1816 &mut self,
1817 paths: Vec<PathBuf>,
1818 open_mode: OpenMode,
1819 window: &mut Window,
1820 cx: &mut Context<Self>,
1821 ) -> Task<Result<Entity<Workspace>>> {
1822 if self.multi_workspace_enabled(cx) {
1823 self.find_or_create_local_workspace(PathList::new(&paths), None, &[], window, cx)
1824 } else {
1825 let workspace = self.workspace().clone();
1826 cx.spawn_in(window, async move |_this, cx| {
1827 let should_continue = workspace
1828 .update_in(cx, |workspace, window, cx| {
1829 workspace.prepare_to_close(crate::CloseIntent::ReplaceWindow, window, cx)
1830 })?
1831 .await?;
1832 if should_continue {
1833 workspace
1834 .update_in(cx, |workspace, window, cx| {
1835 workspace.open_workspace_for_paths(open_mode, paths, window, cx)
1836 })?
1837 .await
1838 } else {
1839 Ok(workspace)
1840 }
1841 })
1842 }
1843 }
1844}
1845
1846impl Render for MultiWorkspace {
1847 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1848 let multi_workspace_enabled = self.multi_workspace_enabled(cx);
1849 let sidebar_side = self.sidebar_side(cx);
1850 let sidebar_on_right = sidebar_side == SidebarSide::Right;
1851
1852 let sidebar: Option<AnyElement> = if multi_workspace_enabled && self.sidebar_open() {
1853 self.sidebar.as_ref().map(|sidebar_handle| {
1854 let weak = cx.weak_entity();
1855
1856 let sidebar_width = sidebar_handle.width(cx);
1857 let resize_handle = deferred(
1858 div()
1859 .id("sidebar-resize-handle")
1860 .absolute()
1861 .when(!sidebar_on_right, |el| {
1862 el.right(-SIDEBAR_RESIZE_HANDLE_SIZE / 2.)
1863 })
1864 .when(sidebar_on_right, |el| {
1865 el.left(-SIDEBAR_RESIZE_HANDLE_SIZE / 2.)
1866 })
1867 .top(px(0.))
1868 .h_full()
1869 .w(SIDEBAR_RESIZE_HANDLE_SIZE)
1870 .cursor_col_resize()
1871 .on_drag(DraggedSidebar, |dragged, _, _, cx| {
1872 cx.stop_propagation();
1873 cx.new(|_| dragged.clone())
1874 })
1875 .on_mouse_down(MouseButton::Left, |_, _, cx| {
1876 cx.stop_propagation();
1877 })
1878 .on_mouse_up(MouseButton::Left, move |event, _, cx| {
1879 if event.click_count == 2 {
1880 weak.update(cx, |this, cx| {
1881 if let Some(sidebar) = this.sidebar.as_mut() {
1882 sidebar.set_width(None, cx);
1883 }
1884 this.serialize(cx);
1885 })
1886 .ok();
1887 cx.stop_propagation();
1888 } else {
1889 weak.update(cx, |this, cx| {
1890 this.serialize(cx);
1891 })
1892 .ok();
1893 }
1894 })
1895 .occlude(),
1896 );
1897
1898 div()
1899 .id("sidebar-container")
1900 .relative()
1901 .h_full()
1902 .w(sidebar_width)
1903 .flex_shrink_0()
1904 .child(sidebar_handle.to_any())
1905 .child(resize_handle)
1906 .into_any_element()
1907 })
1908 } else {
1909 None
1910 };
1911
1912 let (left_sidebar, right_sidebar) = if sidebar_on_right {
1913 (None, sidebar)
1914 } else {
1915 (sidebar, None)
1916 };
1917
1918 let ui_font = theme_settings::setup_ui_font(window, cx);
1919 let text_color = cx.theme().colors().text;
1920
1921 let workspace = self.workspace().clone();
1922 let workspace_key_context = workspace.update(cx, |workspace, cx| workspace.key_context(cx));
1923 let root = workspace.update(cx, |workspace, cx| workspace.actions(h_flex(), window, cx));
1924
1925 client_side_decorations(
1926 root.key_context(workspace_key_context)
1927 .relative()
1928 .size_full()
1929 .font(ui_font)
1930 .text_color(text_color)
1931 .on_action(cx.listener(Self::close_window))
1932 .when(self.multi_workspace_enabled(cx), |this| {
1933 this.on_action(cx.listener(
1934 |this: &mut Self, _: &ToggleWorkspaceSidebar, window, cx| {
1935 this.toggle_sidebar(window, cx);
1936 },
1937 ))
1938 .on_action(cx.listener(
1939 |this: &mut Self, _: &CloseWorkspaceSidebar, window, cx| {
1940 this.close_sidebar_action(window, cx);
1941 },
1942 ))
1943 .on_action(cx.listener(
1944 |this: &mut Self, _: &FocusWorkspaceSidebar, window, cx| {
1945 this.focus_sidebar(window, cx);
1946 },
1947 ))
1948 .on_action(cx.listener(
1949 |this: &mut Self, action: &ToggleThreadSwitcher, window, cx| {
1950 if let Some(sidebar) = &this.sidebar {
1951 sidebar.toggle_thread_switcher(action.select_last, window, cx);
1952 }
1953 },
1954 ))
1955 .on_action(cx.listener(|this: &mut Self, _: &NextProject, window, cx| {
1956 if let Some(sidebar) = &this.sidebar {
1957 sidebar.cycle_project(true, window, cx);
1958 }
1959 }))
1960 .on_action(
1961 cx.listener(|this: &mut Self, _: &PreviousProject, window, cx| {
1962 if let Some(sidebar) = &this.sidebar {
1963 sidebar.cycle_project(false, window, cx);
1964 }
1965 }),
1966 )
1967 .on_action(cx.listener(|this: &mut Self, _: &NextThread, window, cx| {
1968 if let Some(sidebar) = &this.sidebar {
1969 sidebar.cycle_thread(true, window, cx);
1970 }
1971 }))
1972 .on_action(
1973 cx.listener(|this: &mut Self, _: &PreviousThread, window, cx| {
1974 if let Some(sidebar) = &this.sidebar {
1975 sidebar.cycle_thread(false, window, cx);
1976 }
1977 }),
1978 )
1979 .when(self.project_group_keys().len() >= 2, |el| {
1980 el.on_action(cx.listener(
1981 |this: &mut Self, _: &MoveProjectToNewWindow, window, cx| {
1982 let key =
1983 this.project_group_key_for_workspace(this.workspace(), cx);
1984 this.open_project_group_in_new_window(&key, window, cx)
1985 .detach_and_log_err(cx);
1986 },
1987 ))
1988 })
1989 })
1990 .when(
1991 self.sidebar_open() && self.multi_workspace_enabled(cx),
1992 |this| {
1993 this.on_drag_move(cx.listener(
1994 move |this: &mut Self,
1995 e: &DragMoveEvent<DraggedSidebar>,
1996 window,
1997 cx| {
1998 if let Some(sidebar) = &this.sidebar {
1999 let new_width = if sidebar_on_right {
2000 window.bounds().size.width - e.event.position.x
2001 } else {
2002 e.event.position.x
2003 };
2004 sidebar.set_width(Some(new_width), cx);
2005 }
2006 },
2007 ))
2008 },
2009 )
2010 .children(left_sidebar)
2011 .child(
2012 div()
2013 .flex()
2014 .flex_1()
2015 .size_full()
2016 .overflow_hidden()
2017 .child(self.workspace().clone()),
2018 )
2019 .children(right_sidebar)
2020 .child(self.workspace().read(cx).modal_layer.clone())
2021 .children(self.sidebar_overlay.as_ref().map(|view| {
2022 deferred(div().absolute().size_full().inset_0().occlude().child(
2023 v_flex().h(px(0.0)).top_20().items_center().child(
2024 h_flex().occlude().child(view.clone()).on_mouse_down(
2025 MouseButton::Left,
2026 |_, _, cx| {
2027 cx.stop_propagation();
2028 },
2029 ),
2030 ),
2031 ))
2032 .with_priority(2)
2033 })),
2034 window,
2035 cx,
2036 Tiling {
2037 left: !sidebar_on_right && multi_workspace_enabled && self.sidebar_open(),
2038 right: sidebar_on_right && multi_workspace_enabled && self.sidebar_open(),
2039 ..Tiling::default()
2040 },
2041 )
2042 }
2043}