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