1use anyhow::Result;
2use feature_flags::{AgentV2FeatureFlag, FeatureFlagAppExt};
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};
8use project::DisableAiSettings;
9#[cfg(any(test, feature = "test-support"))]
10use project::Project;
11use settings::Settings;
12pub use settings::SidebarSide;
13use std::future::Future;
14use std::path::PathBuf;
15use std::sync::Arc;
16use ui::prelude::*;
17use util::ResultExt;
18use zed_actions::agents_sidebar::{MoveWorkspaceToNewWindow, ToggleThreadSwitcher};
19
20use agent_settings::AgentSettings;
21use settings::SidebarDockPosition;
22use ui::{ContextMenu, right_click_menu};
23
24const SIDEBAR_RESIZE_HANDLE_SIZE: Pixels = px(6.0);
25
26use crate::{
27 CloseIntent, CloseWindow, DockPosition, Event as WorkspaceEvent, Item, ModalView, OpenMode,
28 Panel, Workspace, WorkspaceId, client_side_decorations,
29};
30
31actions!(
32 multi_workspace,
33 [
34 /// Toggles the workspace switcher sidebar.
35 ToggleWorkspaceSidebar,
36 /// Closes the workspace sidebar.
37 CloseWorkspaceSidebar,
38 /// Moves focus to or from the workspace sidebar without closing it.
39 FocusWorkspaceSidebar,
40 /// Switches to the next workspace.
41 NextWorkspace,
42 /// Switches to the previous workspace.
43 PreviousWorkspace,
44 ]
45);
46
47#[derive(Default)]
48pub struct SidebarRenderState {
49 pub open: bool,
50 pub side: SidebarSide,
51}
52
53pub fn sidebar_side_context_menu(
54 id: impl Into<ElementId>,
55 cx: &App,
56) -> ui::RightClickMenu<ContextMenu> {
57 let current_position = AgentSettings::get_global(cx).sidebar_side;
58 right_click_menu(id).menu(move |window, cx| {
59 let fs = <dyn fs::Fs>::global(cx);
60 ContextMenu::build(window, cx, move |mut menu, _, _cx| {
61 let positions: [(SidebarDockPosition, &str); 2] = [
62 (SidebarDockPosition::Left, "Left"),
63 (SidebarDockPosition::Right, "Right"),
64 ];
65 for (position, label) in positions {
66 let fs = fs.clone();
67 menu = menu.toggleable_entry(
68 label,
69 position == current_position,
70 IconPosition::Start,
71 None,
72 move |_window, cx| {
73 settings::update_settings_file(fs.clone(), cx, move |settings, _cx| {
74 settings
75 .agent
76 .get_or_insert_default()
77 .set_sidebar_side(position);
78 });
79 },
80 );
81 }
82 menu
83 })
84 })
85}
86
87pub enum MultiWorkspaceEvent {
88 ActiveWorkspaceChanged,
89 WorkspaceAdded(Entity<Workspace>),
90 WorkspaceRemoved(EntityId),
91}
92
93pub trait Sidebar: Focusable + Render + Sized {
94 fn width(&self, cx: &App) -> Pixels;
95 fn set_width(&mut self, width: Option<Pixels>, cx: &mut Context<Self>);
96 fn has_notifications(&self, cx: &App) -> bool;
97 fn side(&self, _cx: &App) -> SidebarSide;
98
99 fn is_threads_list_view_active(&self) -> bool {
100 true
101 }
102 /// Makes focus reset back to the search editor upon toggling the sidebar from outside
103 fn prepare_for_focus(&mut self, _window: &mut Window, _cx: &mut Context<Self>) {}
104 /// Opens or cycles the thread switcher popup.
105 fn toggle_thread_switcher(
106 &mut self,
107 _select_last: bool,
108 _window: &mut Window,
109 _cx: &mut Context<Self>,
110 ) {
111 }
112}
113
114pub trait SidebarHandle: 'static + Send + Sync {
115 fn width(&self, cx: &App) -> Pixels;
116 fn set_width(&self, width: Option<Pixels>, cx: &mut App);
117 fn focus_handle(&self, cx: &App) -> FocusHandle;
118 fn focus(&self, window: &mut Window, cx: &mut App);
119 fn prepare_for_focus(&self, window: &mut Window, cx: &mut App);
120 fn has_notifications(&self, cx: &App) -> bool;
121 fn to_any(&self) -> AnyView;
122 fn entity_id(&self) -> EntityId;
123 fn toggle_thread_switcher(&self, select_last: bool, window: &mut Window, cx: &mut App);
124
125 fn is_threads_list_view_active(&self, cx: &App) -> bool;
126
127 fn side(&self, cx: &App) -> SidebarSide;
128}
129
130#[derive(Clone)]
131pub struct DraggedSidebar;
132
133impl Render for DraggedSidebar {
134 fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
135 gpui::Empty
136 }
137}
138
139impl<T: Sidebar> SidebarHandle for Entity<T> {
140 fn width(&self, cx: &App) -> Pixels {
141 self.read(cx).width(cx)
142 }
143
144 fn set_width(&self, width: Option<Pixels>, cx: &mut App) {
145 self.update(cx, |this, cx| this.set_width(width, cx))
146 }
147
148 fn focus_handle(&self, cx: &App) -> FocusHandle {
149 self.read(cx).focus_handle(cx)
150 }
151
152 fn focus(&self, window: &mut Window, cx: &mut App) {
153 let handle = self.read(cx).focus_handle(cx);
154 window.focus(&handle, cx);
155 }
156
157 fn prepare_for_focus(&self, window: &mut Window, cx: &mut App) {
158 self.update(cx, |this, cx| this.prepare_for_focus(window, cx));
159 }
160
161 fn has_notifications(&self, cx: &App) -> bool {
162 self.read(cx).has_notifications(cx)
163 }
164
165 fn to_any(&self) -> AnyView {
166 self.clone().into()
167 }
168
169 fn entity_id(&self) -> EntityId {
170 Entity::entity_id(self)
171 }
172
173 fn toggle_thread_switcher(&self, select_last: bool, window: &mut Window, cx: &mut App) {
174 let entity = self.clone();
175 window.defer(cx, move |window, cx| {
176 entity.update(cx, |this, cx| {
177 this.toggle_thread_switcher(select_last, window, cx);
178 });
179 });
180 }
181
182 fn is_threads_list_view_active(&self, cx: &App) -> bool {
183 self.read(cx).is_threads_list_view_active()
184 }
185
186 fn side(&self, cx: &App) -> SidebarSide {
187 self.read(cx).side(cx)
188 }
189}
190
191pub struct MultiWorkspace {
192 window_id: WindowId,
193 workspaces: Vec<Entity<Workspace>>,
194 active_workspace_index: usize,
195 sidebar: Option<Box<dyn SidebarHandle>>,
196 sidebar_open: bool,
197 sidebar_overlay: Option<AnyView>,
198 pending_removal_tasks: Vec<Task<()>>,
199 _serialize_task: Option<Task<()>>,
200 _subscriptions: Vec<Subscription>,
201}
202
203impl EventEmitter<MultiWorkspaceEvent> for MultiWorkspace {}
204
205impl MultiWorkspace {
206 pub fn sidebar_side(&self, cx: &App) -> SidebarSide {
207 self.sidebar
208 .as_ref()
209 .map_or(SidebarSide::Left, |s| s.side(cx))
210 }
211
212 pub fn sidebar_render_state(&self, cx: &App) -> SidebarRenderState {
213 SidebarRenderState {
214 open: self.sidebar_open() && self.multi_workspace_enabled(cx),
215 side: self.sidebar_side(cx),
216 }
217 }
218
219 pub fn new(workspace: Entity<Workspace>, window: &mut Window, cx: &mut Context<Self>) -> Self {
220 let release_subscription = cx.on_release(|this: &mut MultiWorkspace, _cx| {
221 if let Some(task) = this._serialize_task.take() {
222 task.detach();
223 }
224 for task in std::mem::take(&mut this.pending_removal_tasks) {
225 task.detach();
226 }
227 });
228 let quit_subscription = cx.on_app_quit(Self::app_will_quit);
229 let settings_subscription =
230 cx.observe_global_in::<settings::SettingsStore>(window, |this, window, cx| {
231 if DisableAiSettings::get_global(cx).disable_ai && this.sidebar_open {
232 this.close_sidebar(window, cx);
233 }
234 });
235 Self::subscribe_to_workspace(&workspace, window, cx);
236 let weak_self = cx.weak_entity();
237 workspace.update(cx, |workspace, cx| {
238 workspace.set_multi_workspace(weak_self, cx);
239 });
240 Self {
241 window_id: window.window_handle().window_id(),
242 workspaces: vec![workspace],
243 active_workspace_index: 0,
244 sidebar: None,
245 sidebar_open: false,
246 sidebar_overlay: None,
247 pending_removal_tasks: Vec::new(),
248 _serialize_task: None,
249 _subscriptions: vec![
250 release_subscription,
251 quit_subscription,
252 settings_subscription,
253 ],
254 }
255 }
256
257 pub fn register_sidebar<T: Sidebar>(&mut self, sidebar: Entity<T>, cx: &mut Context<Self>) {
258 self._subscriptions
259 .push(cx.observe(&sidebar, |_this, _, cx| {
260 cx.notify();
261 }));
262 self.sidebar = Some(Box::new(sidebar));
263 }
264
265 pub fn sidebar(&self) -> Option<&dyn SidebarHandle> {
266 self.sidebar.as_deref()
267 }
268
269 pub fn set_sidebar_overlay(&mut self, overlay: Option<AnyView>, cx: &mut Context<Self>) {
270 self.sidebar_overlay = overlay;
271 cx.notify();
272 }
273
274 pub fn sidebar_open(&self) -> bool {
275 self.sidebar_open
276 }
277
278 pub fn sidebar_has_notifications(&self, cx: &App) -> bool {
279 self.sidebar
280 .as_ref()
281 .map_or(false, |s| s.has_notifications(cx))
282 }
283
284 pub fn is_threads_list_view_active(&self, cx: &App) -> bool {
285 self.sidebar
286 .as_ref()
287 .map_or(false, |s| s.is_threads_list_view_active(cx))
288 }
289
290 pub fn multi_workspace_enabled(&self, cx: &App) -> bool {
291 cx.has_flag::<AgentV2FeatureFlag>() && !DisableAiSettings::get_global(cx).disable_ai
292 }
293
294 pub fn toggle_sidebar(&mut self, window: &mut Window, cx: &mut Context<Self>) {
295 if !self.multi_workspace_enabled(cx) {
296 return;
297 }
298
299 if self.sidebar_open {
300 self.close_sidebar(window, cx);
301 } else {
302 self.open_sidebar(cx);
303 if let Some(sidebar) = &self.sidebar {
304 sidebar.prepare_for_focus(window, cx);
305 sidebar.focus(window, cx);
306 }
307 }
308 }
309
310 pub fn close_sidebar_action(&mut self, window: &mut Window, cx: &mut Context<Self>) {
311 if !self.multi_workspace_enabled(cx) {
312 return;
313 }
314
315 if self.sidebar_open {
316 self.close_sidebar(window, cx);
317 }
318 }
319
320 pub fn focus_sidebar(&mut self, window: &mut Window, cx: &mut Context<Self>) {
321 if !self.multi_workspace_enabled(cx) {
322 return;
323 }
324
325 if self.sidebar_open {
326 let sidebar_is_focused = self
327 .sidebar
328 .as_ref()
329 .is_some_and(|s| s.focus_handle(cx).contains_focused(window, cx));
330
331 if sidebar_is_focused {
332 let pane = self.workspace().read(cx).active_pane().clone();
333 let pane_focus = pane.read(cx).focus_handle(cx);
334 window.focus(&pane_focus, cx);
335 } else if let Some(sidebar) = &self.sidebar {
336 sidebar.prepare_for_focus(window, cx);
337 sidebar.focus(window, cx);
338 }
339 } else {
340 self.open_sidebar(cx);
341 if let Some(sidebar) = &self.sidebar {
342 sidebar.prepare_for_focus(window, cx);
343 sidebar.focus(window, cx);
344 }
345 }
346 }
347
348 pub fn open_sidebar(&mut self, cx: &mut Context<Self>) {
349 self.sidebar_open = true;
350 let sidebar_focus_handle = self.sidebar.as_ref().map(|s| s.focus_handle(cx));
351 for workspace in &self.workspaces {
352 workspace.update(cx, |workspace, _cx| {
353 workspace.set_sidebar_focus_handle(sidebar_focus_handle.clone());
354 });
355 }
356 self.serialize(cx);
357 cx.notify();
358 }
359
360 pub fn close_sidebar(&mut self, window: &mut Window, cx: &mut Context<Self>) {
361 self.sidebar_open = false;
362 for workspace in &self.workspaces {
363 workspace.update(cx, |workspace, _cx| {
364 workspace.set_sidebar_focus_handle(None);
365 });
366 }
367 let pane = self.workspace().read(cx).active_pane().clone();
368 let pane_focus = pane.read(cx).focus_handle(cx);
369 window.focus(&pane_focus, cx);
370 self.serialize(cx);
371 cx.notify();
372 }
373
374 pub fn close_window(&mut self, _: &CloseWindow, window: &mut Window, cx: &mut Context<Self>) {
375 cx.spawn_in(window, async move |this, cx| {
376 let workspaces = this.update(cx, |multi_workspace, _cx| {
377 multi_workspace.workspaces().to_vec()
378 })?;
379
380 for workspace in workspaces {
381 let should_continue = workspace
382 .update_in(cx, |workspace, window, cx| {
383 workspace.prepare_to_close(CloseIntent::CloseWindow, window, cx)
384 })?
385 .await?;
386 if !should_continue {
387 return anyhow::Ok(());
388 }
389 }
390
391 cx.update(|window, _cx| {
392 window.remove_window();
393 })?;
394
395 anyhow::Ok(())
396 })
397 .detach_and_log_err(cx);
398 }
399
400 fn subscribe_to_workspace(
401 workspace: &Entity<Workspace>,
402 window: &Window,
403 cx: &mut Context<Self>,
404 ) {
405 cx.subscribe_in(workspace, window, |this, workspace, event, window, cx| {
406 if let WorkspaceEvent::Activate = event {
407 this.activate(workspace.clone(), window, cx);
408 }
409 })
410 .detach();
411 }
412
413 pub fn workspace(&self) -> &Entity<Workspace> {
414 &self.workspaces[self.active_workspace_index]
415 }
416
417 pub fn workspaces(&self) -> &[Entity<Workspace>] {
418 &self.workspaces
419 }
420
421 pub fn active_workspace_index(&self) -> usize {
422 self.active_workspace_index
423 }
424
425 /// Adds a workspace to this window without changing which workspace is
426 /// active.
427 pub fn add(&mut self, workspace: Entity<Workspace>, window: &Window, cx: &mut Context<Self>) {
428 if !self.multi_workspace_enabled(cx) {
429 self.set_single_workspace(workspace, cx);
430 return;
431 }
432
433 self.insert_workspace(workspace, window, cx);
434 }
435
436 /// Ensures the workspace is in the multiworkspace and makes it the active one.
437 pub fn activate(
438 &mut self,
439 workspace: Entity<Workspace>,
440 window: &mut Window,
441 cx: &mut Context<Self>,
442 ) {
443 if !self.multi_workspace_enabled(cx) {
444 self.set_single_workspace(workspace, cx);
445 return;
446 }
447
448 let index = self.insert_workspace(workspace, &*window, cx);
449 let changed = self.active_workspace_index != index;
450 self.active_workspace_index = index;
451 if changed {
452 cx.emit(MultiWorkspaceEvent::ActiveWorkspaceChanged);
453 self.serialize(cx);
454 }
455 self.focus_active_workspace(window, cx);
456 cx.notify();
457 }
458
459 /// Replaces the currently active workspace with a new one. If the
460 /// workspace is already in the list, this just switches to it.
461 pub fn replace(
462 &mut self,
463 workspace: Entity<Workspace>,
464 window: &Window,
465 cx: &mut Context<Self>,
466 ) {
467 if !self.multi_workspace_enabled(cx) {
468 self.set_single_workspace(workspace, cx);
469 return;
470 }
471
472 if let Some(index) = self.workspaces.iter().position(|w| *w == workspace) {
473 let changed = self.active_workspace_index != index;
474 self.active_workspace_index = index;
475 if changed {
476 cx.emit(MultiWorkspaceEvent::ActiveWorkspaceChanged);
477 self.serialize(cx);
478 }
479 cx.notify();
480 return;
481 }
482
483 let old_workspace = std::mem::replace(
484 &mut self.workspaces[self.active_workspace_index],
485 workspace.clone(),
486 );
487
488 let old_entity_id = old_workspace.entity_id();
489 self.detach_workspace(&old_workspace, cx);
490
491 Self::subscribe_to_workspace(&workspace, window, cx);
492 self.sync_sidebar_to_workspace(&workspace, cx);
493
494 cx.emit(MultiWorkspaceEvent::WorkspaceRemoved(old_entity_id));
495 cx.emit(MultiWorkspaceEvent::WorkspaceAdded(workspace));
496 cx.emit(MultiWorkspaceEvent::ActiveWorkspaceChanged);
497 self.serialize(cx);
498 cx.notify();
499 }
500
501 fn set_single_workspace(&mut self, workspace: Entity<Workspace>, cx: &mut Context<Self>) {
502 self.workspaces[0] = workspace;
503 self.active_workspace_index = 0;
504 cx.emit(MultiWorkspaceEvent::ActiveWorkspaceChanged);
505 cx.notify();
506 }
507
508 /// Inserts a workspace into the list if not already present. Returns the
509 /// index of the workspace (existing or newly inserted). Does not change
510 /// the active workspace index.
511 fn insert_workspace(
512 &mut self,
513 workspace: Entity<Workspace>,
514 window: &Window,
515 cx: &mut Context<Self>,
516 ) -> usize {
517 if let Some(index) = self.workspaces.iter().position(|w| *w == workspace) {
518 index
519 } else {
520 Self::subscribe_to_workspace(&workspace, window, cx);
521 self.sync_sidebar_to_workspace(&workspace, cx);
522 let weak_self = cx.weak_entity();
523 workspace.update(cx, |workspace, cx| {
524 workspace.set_multi_workspace(weak_self, cx);
525 });
526 self.workspaces.push(workspace.clone());
527 cx.emit(MultiWorkspaceEvent::WorkspaceAdded(workspace));
528 cx.notify();
529 self.workspaces.len() - 1
530 }
531 }
532
533 /// Clears session state and DB binding for a workspace that is being
534 /// removed or replaced. The DB row is preserved so the workspace still
535 /// appears in the recent-projects list.
536 fn detach_workspace(&mut self, workspace: &Entity<Workspace>, cx: &mut Context<Self>) {
537 workspace.update(cx, |workspace, _cx| {
538 workspace.session_id.take();
539 workspace._schedule_serialize_workspace.take();
540 workspace._serialize_workspace_task.take();
541 });
542
543 if let Some(workspace_id) = workspace.read(cx).database_id() {
544 let db = crate::persistence::WorkspaceDb::global(cx);
545 self.pending_removal_tasks.retain(|task| !task.is_ready());
546 self.pending_removal_tasks
547 .push(cx.background_spawn(async move {
548 db.set_session_binding(workspace_id, None, None)
549 .await
550 .log_err();
551 }));
552 }
553 }
554
555 fn sync_sidebar_to_workspace(&self, workspace: &Entity<Workspace>, cx: &mut Context<Self>) {
556 if self.sidebar_open {
557 let sidebar_focus_handle = self.sidebar.as_ref().map(|s| s.focus_handle(cx));
558 workspace.update(cx, |workspace, _| {
559 workspace.set_sidebar_focus_handle(sidebar_focus_handle);
560 });
561 }
562 }
563
564 fn cycle_workspace(&mut self, delta: isize, window: &mut Window, cx: &mut Context<Self>) {
565 let count = self.workspaces.len() as isize;
566 if count <= 1 {
567 return;
568 }
569 let current = self.active_workspace_index as isize;
570 let next = ((current + delta).rem_euclid(count)) as usize;
571 let workspace = self.workspaces[next].clone();
572 self.activate(workspace, window, cx);
573 }
574
575 fn next_workspace(&mut self, _: &NextWorkspace, window: &mut Window, cx: &mut Context<Self>) {
576 self.cycle_workspace(1, window, cx);
577 }
578
579 fn previous_workspace(
580 &mut self,
581 _: &PreviousWorkspace,
582 window: &mut Window,
583 cx: &mut Context<Self>,
584 ) {
585 self.cycle_workspace(-1, window, cx);
586 }
587
588 fn serialize(&mut self, cx: &mut App) {
589 let window_id = self.window_id;
590 let state = crate::persistence::model::MultiWorkspaceState {
591 active_workspace_id: self.workspace().read(cx).database_id(),
592 sidebar_open: self.sidebar_open,
593 };
594 let kvp = db::kvp::KeyValueStore::global(cx);
595 self._serialize_task = Some(cx.background_spawn(async move {
596 crate::persistence::write_multi_workspace_state(&kvp, window_id, state).await;
597 }));
598 }
599
600 /// Returns the in-flight serialization task (if any) so the caller can
601 /// await it. Used by the quit handler to ensure pending DB writes
602 /// complete before the process exits.
603 pub fn flush_serialization(&mut self) -> Task<()> {
604 self._serialize_task.take().unwrap_or(Task::ready(()))
605 }
606
607 fn app_will_quit(&mut self, _cx: &mut Context<Self>) -> impl Future<Output = ()> + use<> {
608 let mut tasks: Vec<Task<()>> = Vec::new();
609 if let Some(task) = self._serialize_task.take() {
610 tasks.push(task);
611 }
612 tasks.extend(std::mem::take(&mut self.pending_removal_tasks));
613
614 async move {
615 futures::future::join_all(tasks).await;
616 }
617 }
618
619 pub fn focus_active_workspace(&self, window: &mut Window, cx: &mut App) {
620 // If a dock panel is zoomed, focus it instead of the center pane.
621 // Otherwise, focusing the center pane triggers dismiss_zoomed_items_to_reveal
622 // which closes the zoomed dock.
623 let focus_handle = {
624 let workspace = self.workspace().read(cx);
625 let mut target = None;
626 for dock in workspace.all_docks() {
627 let dock = dock.read(cx);
628 if dock.is_open() {
629 if let Some(panel) = dock.active_panel() {
630 if panel.is_zoomed(window, cx) {
631 target = Some(panel.panel_focus_handle(cx));
632 break;
633 }
634 }
635 }
636 }
637 target.unwrap_or_else(|| {
638 let pane = workspace.active_pane().clone();
639 pane.read(cx).focus_handle(cx)
640 })
641 };
642 window.focus(&focus_handle, cx);
643 }
644
645 pub fn panel<T: Panel>(&self, cx: &App) -> Option<Entity<T>> {
646 self.workspace().read(cx).panel::<T>(cx)
647 }
648
649 pub fn active_modal<V: ManagedView + 'static>(&self, cx: &App) -> Option<Entity<V>> {
650 self.workspace().read(cx).active_modal::<V>(cx)
651 }
652
653 pub fn add_panel<T: Panel>(
654 &mut self,
655 panel: Entity<T>,
656 window: &mut Window,
657 cx: &mut Context<Self>,
658 ) {
659 self.workspace().update(cx, |workspace, cx| {
660 workspace.add_panel(panel, window, cx);
661 });
662 }
663
664 pub fn focus_panel<T: Panel>(
665 &mut self,
666 window: &mut Window,
667 cx: &mut Context<Self>,
668 ) -> Option<Entity<T>> {
669 self.workspace()
670 .update(cx, |workspace, cx| workspace.focus_panel::<T>(window, cx))
671 }
672
673 // used in a test
674 pub fn toggle_modal<V: ModalView, B>(
675 &mut self,
676 window: &mut Window,
677 cx: &mut Context<Self>,
678 build: B,
679 ) where
680 B: FnOnce(&mut Window, &mut gpui::Context<V>) -> V,
681 {
682 self.workspace().update(cx, |workspace, cx| {
683 workspace.toggle_modal(window, cx, build);
684 });
685 }
686
687 pub fn toggle_dock(
688 &mut self,
689 dock_side: DockPosition,
690 window: &mut Window,
691 cx: &mut Context<Self>,
692 ) {
693 self.workspace().update(cx, |workspace, cx| {
694 workspace.toggle_dock(dock_side, window, cx);
695 });
696 }
697
698 pub fn active_item_as<I: 'static>(&self, cx: &App) -> Option<Entity<I>> {
699 self.workspace().read(cx).active_item_as::<I>(cx)
700 }
701
702 pub fn items_of_type<'a, T: Item>(
703 &'a self,
704 cx: &'a App,
705 ) -> impl 'a + Iterator<Item = Entity<T>> {
706 self.workspace().read(cx).items_of_type::<T>(cx)
707 }
708
709 pub fn database_id(&self, cx: &App) -> Option<WorkspaceId> {
710 self.workspace().read(cx).database_id()
711 }
712
713 pub fn take_pending_removal_tasks(&mut self) -> Vec<Task<()>> {
714 let tasks: Vec<Task<()>> = std::mem::take(&mut self.pending_removal_tasks)
715 .into_iter()
716 .filter(|task| !task.is_ready())
717 .collect();
718 tasks
719 }
720
721 #[cfg(any(test, feature = "test-support"))]
722 pub fn set_random_database_id(&mut self, cx: &mut Context<Self>) {
723 self.workspace().update(cx, |workspace, _cx| {
724 workspace.set_random_database_id();
725 });
726 }
727
728 #[cfg(any(test, feature = "test-support"))]
729 pub fn test_new(project: Entity<Project>, window: &mut Window, cx: &mut Context<Self>) -> Self {
730 let workspace = cx.new(|cx| Workspace::test_new(project, window, cx));
731 Self::new(workspace, window, cx)
732 }
733
734 #[cfg(any(test, feature = "test-support"))]
735 pub fn test_add_workspace(
736 &mut self,
737 project: Entity<Project>,
738 window: &mut Window,
739 cx: &mut Context<Self>,
740 ) -> Entity<Workspace> {
741 let workspace = cx.new(|cx| Workspace::test_new(project, window, cx));
742 self.activate(workspace.clone(), window, cx);
743 workspace
744 }
745
746 #[cfg(any(test, feature = "test-support"))]
747 pub fn create_test_workspace(
748 &mut self,
749 window: &mut Window,
750 cx: &mut Context<Self>,
751 ) -> Task<()> {
752 let app_state = self.workspace().read(cx).app_state().clone();
753 let project = Project::local(
754 app_state.client.clone(),
755 app_state.node_runtime.clone(),
756 app_state.user_store.clone(),
757 app_state.languages.clone(),
758 app_state.fs.clone(),
759 None,
760 project::LocalProjectFlags::default(),
761 cx,
762 );
763 let new_workspace = cx.new(|cx| Workspace::new(None, project, app_state, window, cx));
764 self.activate(new_workspace.clone(), window, cx);
765
766 let weak_workspace = new_workspace.downgrade();
767 let db = crate::persistence::WorkspaceDb::global(cx);
768 cx.spawn_in(window, async move |this, cx| {
769 let workspace_id = db.next_id().await.unwrap();
770 let workspace = weak_workspace.upgrade().unwrap();
771 let task: Task<()> = this
772 .update_in(cx, |this, window, cx| {
773 let session_id = workspace.read(cx).session_id();
774 let window_id = window.window_handle().window_id().as_u64();
775 workspace.update(cx, |workspace, _cx| {
776 workspace.set_database_id(workspace_id);
777 });
778 this.serialize(cx);
779 let db = db.clone();
780 cx.background_spawn(async move {
781 db.set_session_binding(workspace_id, session_id, Some(window_id))
782 .await
783 .log_err();
784 })
785 })
786 .unwrap();
787 task.await
788 })
789 }
790
791 pub fn remove(
792 &mut self,
793 workspace: &Entity<Workspace>,
794 window: &mut Window,
795 cx: &mut Context<Self>,
796 ) -> bool {
797 let Some(index) = self.workspaces.iter().position(|w| w == workspace) else {
798 return false;
799 };
800 if self.workspaces.len() <= 1 {
801 return false;
802 }
803
804 let removed_workspace = self.workspaces.remove(index);
805
806 if self.active_workspace_index >= self.workspaces.len() {
807 self.active_workspace_index = self.workspaces.len() - 1;
808 } else if self.active_workspace_index > index {
809 self.active_workspace_index -= 1;
810 }
811
812 self.detach_workspace(&removed_workspace, cx);
813
814 self.serialize(cx);
815 self.focus_active_workspace(window, cx);
816 cx.emit(MultiWorkspaceEvent::WorkspaceRemoved(
817 removed_workspace.entity_id(),
818 ));
819 cx.emit(MultiWorkspaceEvent::ActiveWorkspaceChanged);
820 cx.notify();
821
822 true
823 }
824
825 pub fn move_workspace_to_new_window(
826 &mut self,
827 workspace: &Entity<Workspace>,
828 window: &mut Window,
829 cx: &mut Context<Self>,
830 ) {
831 let workspace = workspace.clone();
832 if !self.remove(&workspace, window, cx) {
833 return;
834 }
835
836 let app_state: Arc<crate::AppState> = workspace.read(cx).app_state().clone();
837
838 cx.defer(move |cx| {
839 let options = (app_state.build_window_options)(None, cx);
840
841 let Ok(window) = cx.open_window(options, |window, cx| {
842 cx.new(|cx| MultiWorkspace::new(workspace, window, cx))
843 }) else {
844 return;
845 };
846
847 let _ = window.update(cx, |_, window, _| {
848 window.activate_window();
849 });
850 });
851 }
852
853 fn move_active_workspace_to_new_window(
854 &mut self,
855 _: &MoveWorkspaceToNewWindow,
856 window: &mut Window,
857 cx: &mut Context<Self>,
858 ) {
859 let workspace = self.workspace().clone();
860 self.move_workspace_to_new_window(&workspace, window, cx);
861 }
862
863 pub fn open_project(
864 &mut self,
865 paths: Vec<PathBuf>,
866 open_mode: OpenMode,
867 window: &mut Window,
868 cx: &mut Context<Self>,
869 ) -> Task<Result<Entity<Workspace>>> {
870 let workspace = self.workspace().clone();
871
872 let needs_close_prompt =
873 open_mode == OpenMode::Replace || !self.multi_workspace_enabled(cx);
874 let open_mode = if self.multi_workspace_enabled(cx) {
875 open_mode
876 } else {
877 OpenMode::Replace
878 };
879
880 if needs_close_prompt {
881 cx.spawn_in(window, async move |_this, cx| {
882 let should_continue = workspace
883 .update_in(cx, |workspace, window, cx| {
884 workspace.prepare_to_close(crate::CloseIntent::ReplaceWindow, window, cx)
885 })?
886 .await?;
887 if should_continue {
888 workspace
889 .update_in(cx, |workspace, window, cx| {
890 workspace.open_workspace_for_paths(open_mode, paths, window, cx)
891 })?
892 .await
893 } else {
894 Ok(workspace)
895 }
896 })
897 } else {
898 workspace.update(cx, |workspace, cx| {
899 workspace.open_workspace_for_paths(open_mode, paths, window, cx)
900 })
901 }
902 }
903}
904
905impl Render for MultiWorkspace {
906 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
907 let multi_workspace_enabled = self.multi_workspace_enabled(cx);
908 let sidebar_side = self.sidebar_side(cx);
909 let sidebar_on_right = sidebar_side == SidebarSide::Right;
910
911 let sidebar: Option<AnyElement> = if multi_workspace_enabled && self.sidebar_open() {
912 self.sidebar.as_ref().map(|sidebar_handle| {
913 let weak = cx.weak_entity();
914
915 let sidebar_width = sidebar_handle.width(cx);
916 let resize_handle = deferred(
917 div()
918 .id("sidebar-resize-handle")
919 .absolute()
920 .when(!sidebar_on_right, |el| {
921 el.right(-SIDEBAR_RESIZE_HANDLE_SIZE / 2.)
922 })
923 .when(sidebar_on_right, |el| {
924 el.left(-SIDEBAR_RESIZE_HANDLE_SIZE / 2.)
925 })
926 .top(px(0.))
927 .h_full()
928 .w(SIDEBAR_RESIZE_HANDLE_SIZE)
929 .cursor_col_resize()
930 .on_drag(DraggedSidebar, |dragged, _, _, cx| {
931 cx.stop_propagation();
932 cx.new(|_| dragged.clone())
933 })
934 .on_mouse_down(MouseButton::Left, |_, _, cx| {
935 cx.stop_propagation();
936 })
937 .on_mouse_up(MouseButton::Left, move |event, _, cx| {
938 if event.click_count == 2 {
939 weak.update(cx, |this, cx| {
940 if let Some(sidebar) = this.sidebar.as_mut() {
941 sidebar.set_width(None, cx);
942 }
943 })
944 .ok();
945 cx.stop_propagation();
946 }
947 })
948 .occlude(),
949 );
950
951 div()
952 .id("sidebar-container")
953 .relative()
954 .h_full()
955 .w(sidebar_width)
956 .flex_shrink_0()
957 .child(sidebar_handle.to_any())
958 .child(resize_handle)
959 .into_any_element()
960 })
961 } else {
962 None
963 };
964
965 let (left_sidebar, right_sidebar) = if sidebar_on_right {
966 (None, sidebar)
967 } else {
968 (sidebar, None)
969 };
970
971 let ui_font = theme_settings::setup_ui_font(window, cx);
972 let text_color = cx.theme().colors().text;
973
974 let workspace = self.workspace().clone();
975 let workspace_key_context = workspace.update(cx, |workspace, cx| workspace.key_context(cx));
976 let root = workspace.update(cx, |workspace, cx| workspace.actions(h_flex(), window, cx));
977
978 client_side_decorations(
979 root.key_context(workspace_key_context)
980 .relative()
981 .size_full()
982 .font(ui_font)
983 .text_color(text_color)
984 .on_action(cx.listener(Self::close_window))
985 .when(self.multi_workspace_enabled(cx), |this| {
986 this.on_action(cx.listener(
987 |this: &mut Self, _: &ToggleWorkspaceSidebar, window, cx| {
988 this.toggle_sidebar(window, cx);
989 },
990 ))
991 .on_action(cx.listener(
992 |this: &mut Self, _: &CloseWorkspaceSidebar, window, cx| {
993 this.close_sidebar_action(window, cx);
994 },
995 ))
996 .on_action(cx.listener(
997 |this: &mut Self, _: &FocusWorkspaceSidebar, window, cx| {
998 this.focus_sidebar(window, cx);
999 },
1000 ))
1001 .on_action(cx.listener(Self::next_workspace))
1002 .on_action(cx.listener(Self::previous_workspace))
1003 .on_action(cx.listener(Self::move_active_workspace_to_new_window))
1004 .on_action(cx.listener(
1005 |this: &mut Self, action: &ToggleThreadSwitcher, window, cx| {
1006 if let Some(sidebar) = &this.sidebar {
1007 sidebar.toggle_thread_switcher(action.select_last, window, cx);
1008 }
1009 },
1010 ))
1011 })
1012 .when(
1013 self.sidebar_open() && self.multi_workspace_enabled(cx),
1014 |this| {
1015 this.on_drag_move(cx.listener(
1016 move |this: &mut Self,
1017 e: &DragMoveEvent<DraggedSidebar>,
1018 window,
1019 cx| {
1020 if let Some(sidebar) = &this.sidebar {
1021 let new_width = if sidebar_on_right {
1022 window.bounds().size.width - e.event.position.x
1023 } else {
1024 e.event.position.x
1025 };
1026 sidebar.set_width(Some(new_width), cx);
1027 }
1028 },
1029 ))
1030 },
1031 )
1032 .children(left_sidebar)
1033 .child(
1034 div()
1035 .flex()
1036 .flex_1()
1037 .size_full()
1038 .overflow_hidden()
1039 .child(self.workspace().clone()),
1040 )
1041 .children(right_sidebar)
1042 .child(self.workspace().read(cx).modal_layer.clone())
1043 .children(self.sidebar_overlay.as_ref().map(|view| {
1044 deferred(div().absolute().size_full().inset_0().occlude().child(
1045 v_flex().h(px(0.0)).top_20().items_center().child(
1046 h_flex().occlude().child(view.clone()).on_mouse_down(
1047 MouseButton::Left,
1048 |_, _, cx| {
1049 cx.stop_propagation();
1050 },
1051 ),
1052 ),
1053 ))
1054 .with_priority(2)
1055 })),
1056 window,
1057 cx,
1058 Tiling {
1059 left: !sidebar_on_right && multi_workspace_enabled && self.sidebar_open(),
1060 right: sidebar_on_right && multi_workspace_enabled && self.sidebar_open(),
1061 ..Tiling::default()
1062 },
1063 )
1064 }
1065}
1066
1067#[cfg(test)]
1068mod tests {
1069 use super::*;
1070 use fs::FakeFs;
1071 use gpui::TestAppContext;
1072 use settings::SettingsStore;
1073
1074 fn init_test(cx: &mut TestAppContext) {
1075 cx.update(|cx| {
1076 let settings_store = SettingsStore::test(cx);
1077 cx.set_global(settings_store);
1078 theme_settings::init(theme::LoadThemes::JustBase, cx);
1079 DisableAiSettings::register(cx);
1080 cx.update_flags(false, vec!["agent-v2".into()]);
1081 });
1082 }
1083
1084 #[gpui::test]
1085 async fn test_sidebar_disabled_when_disable_ai_is_enabled(cx: &mut TestAppContext) {
1086 init_test(cx);
1087 let fs = FakeFs::new(cx.executor());
1088 let project = Project::test(fs, [], cx).await;
1089
1090 let (multi_workspace, cx) =
1091 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
1092
1093 multi_workspace.read_with(cx, |mw, cx| {
1094 assert!(mw.multi_workspace_enabled(cx));
1095 });
1096
1097 multi_workspace.update_in(cx, |mw, _window, cx| {
1098 mw.open_sidebar(cx);
1099 assert!(mw.sidebar_open());
1100 });
1101
1102 cx.update(|_window, cx| {
1103 DisableAiSettings::override_global(DisableAiSettings { disable_ai: true }, cx);
1104 });
1105 cx.run_until_parked();
1106
1107 multi_workspace.read_with(cx, |mw, cx| {
1108 assert!(
1109 !mw.sidebar_open(),
1110 "Sidebar should be closed when disable_ai is true"
1111 );
1112 assert!(
1113 !mw.multi_workspace_enabled(cx),
1114 "Multi-workspace should be disabled when disable_ai is true"
1115 );
1116 });
1117
1118 multi_workspace.update_in(cx, |mw, window, cx| {
1119 mw.toggle_sidebar(window, cx);
1120 });
1121 multi_workspace.read_with(cx, |mw, _cx| {
1122 assert!(
1123 !mw.sidebar_open(),
1124 "Sidebar should remain closed when toggled with disable_ai true"
1125 );
1126 });
1127
1128 cx.update(|_window, cx| {
1129 DisableAiSettings::override_global(DisableAiSettings { disable_ai: false }, cx);
1130 });
1131 cx.run_until_parked();
1132
1133 multi_workspace.read_with(cx, |mw, cx| {
1134 assert!(
1135 mw.multi_workspace_enabled(cx),
1136 "Multi-workspace should be enabled after re-enabling AI"
1137 );
1138 assert!(
1139 !mw.sidebar_open(),
1140 "Sidebar should still be closed after re-enabling AI (not auto-opened)"
1141 );
1142 });
1143
1144 multi_workspace.update_in(cx, |mw, window, cx| {
1145 mw.toggle_sidebar(window, cx);
1146 });
1147 multi_workspace.read_with(cx, |mw, _cx| {
1148 assert!(
1149 mw.sidebar_open(),
1150 "Sidebar should open when toggled after re-enabling AI"
1151 );
1152 });
1153 }
1154
1155 #[gpui::test]
1156 async fn test_replace(cx: &mut TestAppContext) {
1157 init_test(cx);
1158 let fs = FakeFs::new(cx.executor());
1159 let project_a = Project::test(fs.clone(), [], cx).await;
1160 let project_b = Project::test(fs.clone(), [], cx).await;
1161 let project_c = Project::test(fs.clone(), [], cx).await;
1162 let project_d = Project::test(fs.clone(), [], cx).await;
1163
1164 let (multi_workspace, cx) = cx
1165 .add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
1166
1167 let workspace_a_id =
1168 multi_workspace.read_with(cx, |mw, _cx| mw.workspaces()[0].entity_id());
1169
1170 // Replace the only workspace (single-workspace case).
1171 let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
1172 let workspace = cx.new(|cx| Workspace::test_new(project_b.clone(), window, cx));
1173 mw.replace(workspace.clone(), &*window, cx);
1174 workspace
1175 });
1176
1177 multi_workspace.read_with(cx, |mw, _cx| {
1178 assert_eq!(mw.workspaces().len(), 1);
1179 assert_eq!(
1180 mw.workspaces()[0].entity_id(),
1181 workspace_b.entity_id(),
1182 "slot should now be project_b"
1183 );
1184 assert_ne!(
1185 mw.workspaces()[0].entity_id(),
1186 workspace_a_id,
1187 "project_a should be gone"
1188 );
1189 });
1190
1191 // Add project_c as a second workspace, then replace it with project_d.
1192 let workspace_c = multi_workspace.update_in(cx, |mw, window, cx| {
1193 mw.test_add_workspace(project_c.clone(), window, cx)
1194 });
1195
1196 multi_workspace.read_with(cx, |mw, _cx| {
1197 assert_eq!(mw.workspaces().len(), 2);
1198 assert_eq!(mw.active_workspace_index(), 1);
1199 });
1200
1201 let workspace_d = multi_workspace.update_in(cx, |mw, window, cx| {
1202 let workspace = cx.new(|cx| Workspace::test_new(project_d.clone(), window, cx));
1203 mw.replace(workspace.clone(), &*window, cx);
1204 workspace
1205 });
1206
1207 multi_workspace.read_with(cx, |mw, _cx| {
1208 assert_eq!(mw.workspaces().len(), 2, "should still have 2 workspaces");
1209 assert_eq!(mw.active_workspace_index(), 1);
1210 assert_eq!(
1211 mw.workspaces()[1].entity_id(),
1212 workspace_d.entity_id(),
1213 "active slot should now be project_d"
1214 );
1215 assert_ne!(
1216 mw.workspaces()[1].entity_id(),
1217 workspace_c.entity_id(),
1218 "project_c should be gone"
1219 );
1220 });
1221
1222 // Replace with workspace_b which is already in the list — should just switch.
1223 multi_workspace.update_in(cx, |mw, window, cx| {
1224 mw.replace(workspace_b.clone(), &*window, cx);
1225 });
1226
1227 multi_workspace.read_with(cx, |mw, _cx| {
1228 assert_eq!(
1229 mw.workspaces().len(),
1230 2,
1231 "no workspace should be added or removed"
1232 );
1233 assert_eq!(
1234 mw.active_workspace_index(),
1235 0,
1236 "should have switched to workspace_b"
1237 );
1238 });
1239 }
1240}