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