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, Project};
9use settings::Settings;
10use std::future::Future;
11use std::path::PathBuf;
12use ui::prelude::*;
13use util::ResultExt;
14
15const SIDEBAR_RESIZE_HANDLE_SIZE: Pixels = px(6.0);
16
17use crate::{
18 CloseIntent, CloseWindow, DockPosition, Event as WorkspaceEvent, Item, ModalView, Panel, Toast,
19 Workspace, WorkspaceId, client_side_decorations, notifications::NotificationId,
20};
21
22actions!(
23 multi_workspace,
24 [
25 /// Creates a new workspace within the current window.
26 NewWorkspaceInWindow,
27 /// Switches to the next workspace within the current window.
28 NextWorkspaceInWindow,
29 /// Switches to the previous workspace within the current window.
30 PreviousWorkspaceInWindow,
31 /// Toggles the workspace switcher sidebar.
32 ToggleWorkspaceSidebar,
33 /// Moves focus to or from the workspace sidebar without closing it.
34 FocusWorkspaceSidebar,
35 ]
36);
37
38pub enum SidebarEvent {
39 Open,
40 Close,
41}
42
43pub trait Sidebar: EventEmitter<SidebarEvent> + Focusable + Render + Sized {
44 fn width(&self, cx: &App) -> Pixels;
45 fn set_width(&mut self, width: Option<Pixels>, cx: &mut Context<Self>);
46 fn has_notifications(&self, cx: &App) -> bool;
47}
48
49pub trait SidebarHandle: 'static + Send + Sync {
50 fn width(&self, cx: &App) -> Pixels;
51 fn set_width(&self, width: Option<Pixels>, cx: &mut App);
52 fn focus_handle(&self, cx: &App) -> FocusHandle;
53 fn focus(&self, window: &mut Window, cx: &mut App);
54 fn has_notifications(&self, cx: &App) -> bool;
55 fn to_any(&self) -> AnyView;
56 fn entity_id(&self) -> EntityId;
57}
58
59#[derive(Clone)]
60pub struct DraggedSidebar;
61
62impl Render for DraggedSidebar {
63 fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
64 gpui::Empty
65 }
66}
67
68impl<T: Sidebar> SidebarHandle for Entity<T> {
69 fn width(&self, cx: &App) -> Pixels {
70 self.read(cx).width(cx)
71 }
72
73 fn set_width(&self, width: Option<Pixels>, cx: &mut App) {
74 self.update(cx, |this, cx| this.set_width(width, cx))
75 }
76
77 fn focus_handle(&self, cx: &App) -> FocusHandle {
78 self.read(cx).focus_handle(cx)
79 }
80
81 fn focus(&self, window: &mut Window, cx: &mut App) {
82 let handle = self.read(cx).focus_handle(cx);
83 window.focus(&handle, cx);
84 }
85
86 fn has_notifications(&self, cx: &App) -> bool {
87 self.read(cx).has_notifications(cx)
88 }
89
90 fn to_any(&self) -> AnyView {
91 self.clone().into()
92 }
93
94 fn entity_id(&self) -> EntityId {
95 Entity::entity_id(self)
96 }
97}
98
99pub struct MultiWorkspace {
100 window_id: WindowId,
101 workspaces: Vec<Entity<Workspace>>,
102 active_workspace_index: usize,
103 sidebar: Option<Box<dyn SidebarHandle>>,
104 sidebar_open: bool,
105 _sidebar_subscription: Option<Subscription>,
106 pending_removal_tasks: Vec<Task<()>>,
107 _serialize_task: Option<Task<()>>,
108 _create_task: Option<Task<()>>,
109 _subscriptions: Vec<Subscription>,
110}
111
112impl MultiWorkspace {
113 pub fn new(workspace: Entity<Workspace>, window: &mut Window, cx: &mut Context<Self>) -> Self {
114 let release_subscription = cx.on_release(|this: &mut MultiWorkspace, _cx| {
115 if let Some(task) = this._serialize_task.take() {
116 task.detach();
117 }
118 if let Some(task) = this._create_task.take() {
119 task.detach();
120 }
121 for task in std::mem::take(&mut this.pending_removal_tasks) {
122 task.detach();
123 }
124 });
125 let quit_subscription = cx.on_app_quit(Self::app_will_quit);
126 let settings_subscription =
127 cx.observe_global_in::<settings::SettingsStore>(window, |this, window, cx| {
128 if DisableAiSettings::get_global(cx).disable_ai && this.sidebar_open {
129 this.close_sidebar(window, cx);
130 }
131 });
132 Self::subscribe_to_workspace(&workspace, cx);
133 Self {
134 window_id: window.window_handle().window_id(),
135 workspaces: vec![workspace],
136 active_workspace_index: 0,
137 sidebar: None,
138 sidebar_open: false,
139 _sidebar_subscription: None,
140 pending_removal_tasks: Vec::new(),
141 _serialize_task: None,
142 _create_task: None,
143 _subscriptions: vec![
144 release_subscription,
145 quit_subscription,
146 settings_subscription,
147 ],
148 }
149 }
150
151 pub fn register_sidebar<T: Sidebar>(
152 &mut self,
153 sidebar: Entity<T>,
154 window: &mut Window,
155 cx: &mut Context<Self>,
156 ) {
157 let subscription =
158 cx.subscribe_in(&sidebar, window, |this, _, event, window, cx| match event {
159 SidebarEvent::Open => this.toggle_sidebar(window, cx),
160 SidebarEvent::Close => {
161 this.close_sidebar(window, cx);
162 }
163 });
164 self.sidebar = Some(Box::new(sidebar));
165 self._sidebar_subscription = Some(subscription);
166 }
167
168 pub fn sidebar(&self) -> Option<&dyn SidebarHandle> {
169 self.sidebar.as_deref()
170 }
171
172 pub fn sidebar_open(&self) -> bool {
173 self.sidebar_open && self.sidebar.is_some()
174 }
175
176 pub fn sidebar_has_notifications(&self, cx: &App) -> bool {
177 self.sidebar
178 .as_ref()
179 .map_or(false, |s| s.has_notifications(cx))
180 }
181
182 pub fn multi_workspace_enabled(&self, cx: &App) -> bool {
183 cx.has_flag::<AgentV2FeatureFlag>() && !DisableAiSettings::get_global(cx).disable_ai
184 }
185
186 pub fn toggle_sidebar(&mut self, window: &mut Window, cx: &mut Context<Self>) {
187 if !self.multi_workspace_enabled(cx) {
188 return;
189 }
190
191 if self.sidebar_open {
192 self.close_sidebar(window, cx);
193 } else {
194 self.open_sidebar(cx);
195 if let Some(sidebar) = &self.sidebar {
196 sidebar.focus(window, cx);
197 }
198 }
199 }
200
201 pub fn focus_sidebar(&mut self, window: &mut Window, cx: &mut Context<Self>) {
202 if !self.multi_workspace_enabled(cx) {
203 return;
204 }
205
206 if self.sidebar_open {
207 let sidebar_is_focused = self
208 .sidebar
209 .as_ref()
210 .is_some_and(|s| s.focus_handle(cx).contains_focused(window, cx));
211
212 if sidebar_is_focused {
213 let pane = self.workspace().read(cx).active_pane().clone();
214 let pane_focus = pane.read(cx).focus_handle(cx);
215 window.focus(&pane_focus, cx);
216 } else if let Some(sidebar) = &self.sidebar {
217 sidebar.focus(window, cx);
218 }
219 } else {
220 self.open_sidebar(cx);
221 if let Some(sidebar) = &self.sidebar {
222 sidebar.focus(window, cx);
223 }
224 }
225 }
226
227 pub fn open_sidebar(&mut self, cx: &mut Context<Self>) {
228 self.sidebar_open = true;
229 for workspace in &self.workspaces {
230 workspace.update(cx, |workspace, cx| {
231 workspace.set_workspace_sidebar_open(true, cx);
232 });
233 }
234 self.serialize(cx);
235 cx.notify();
236 }
237
238 fn close_sidebar(&mut self, window: &mut Window, cx: &mut Context<Self>) {
239 self.sidebar_open = false;
240 for workspace in &self.workspaces {
241 workspace.update(cx, |workspace, cx| {
242 workspace.set_workspace_sidebar_open(false, cx);
243 });
244 }
245 let pane = self.workspace().read(cx).active_pane().clone();
246 let pane_focus = pane.read(cx).focus_handle(cx);
247 window.focus(&pane_focus, cx);
248 self.serialize(cx);
249 cx.notify();
250 }
251
252 pub fn close_window(&mut self, _: &CloseWindow, window: &mut Window, cx: &mut Context<Self>) {
253 cx.spawn_in(window, async move |this, cx| {
254 let workspaces = this.update(cx, |multi_workspace, _cx| {
255 multi_workspace.workspaces().to_vec()
256 })?;
257
258 for workspace in workspaces {
259 let should_continue = workspace
260 .update_in(cx, |workspace, window, cx| {
261 workspace.prepare_to_close(CloseIntent::CloseWindow, window, cx)
262 })?
263 .await?;
264 if !should_continue {
265 return anyhow::Ok(());
266 }
267 }
268
269 cx.update(|window, _cx| {
270 window.remove_window();
271 })?;
272
273 anyhow::Ok(())
274 })
275 .detach_and_log_err(cx);
276 }
277
278 fn subscribe_to_workspace(workspace: &Entity<Workspace>, cx: &mut Context<Self>) {
279 cx.subscribe(workspace, |this, workspace, event, cx| {
280 if let WorkspaceEvent::Activate = event {
281 this.activate(workspace, cx);
282 }
283 })
284 .detach();
285 }
286
287 pub fn is_sidebar_open(&self) -> bool {
288 self.sidebar_open
289 }
290
291 pub fn workspace(&self) -> &Entity<Workspace> {
292 &self.workspaces[self.active_workspace_index]
293 }
294
295 pub fn workspaces(&self) -> &[Entity<Workspace>] {
296 &self.workspaces
297 }
298
299 pub fn active_workspace_index(&self) -> usize {
300 self.active_workspace_index
301 }
302
303 pub fn activate(&mut self, workspace: Entity<Workspace>, cx: &mut Context<Self>) {
304 if !self.multi_workspace_enabled(cx) {
305 self.workspaces[0] = workspace;
306 self.active_workspace_index = 0;
307 cx.notify();
308 return;
309 }
310
311 let old_index = self.active_workspace_index;
312 let new_index = self.set_active_workspace(workspace, cx);
313 if old_index != new_index {
314 self.serialize(cx);
315 }
316 }
317
318 fn set_active_workspace(
319 &mut self,
320 workspace: Entity<Workspace>,
321 cx: &mut Context<Self>,
322 ) -> usize {
323 let index = self.add_workspace(workspace, cx);
324 self.active_workspace_index = index;
325 cx.notify();
326 index
327 }
328
329 /// Adds a workspace to this window without changing which workspace is active.
330 /// Returns the index of the workspace (existing or newly inserted).
331 pub fn add_workspace(&mut self, workspace: Entity<Workspace>, cx: &mut Context<Self>) -> usize {
332 if let Some(index) = self.workspaces.iter().position(|w| *w == workspace) {
333 index
334 } else {
335 if self.sidebar_open {
336 workspace.update(cx, |workspace, cx| {
337 workspace.set_workspace_sidebar_open(true, cx);
338 });
339 }
340 Self::subscribe_to_workspace(&workspace, cx);
341 self.workspaces.push(workspace);
342 cx.notify();
343 self.workspaces.len() - 1
344 }
345 }
346
347 pub fn activate_index(&mut self, index: usize, window: &mut Window, cx: &mut Context<Self>) {
348 debug_assert!(
349 index < self.workspaces.len(),
350 "workspace index out of bounds"
351 );
352 self.active_workspace_index = index;
353 self.serialize(cx);
354 self.focus_active_workspace(window, cx);
355 cx.notify();
356 }
357
358 pub fn activate_next_workspace(&mut self, window: &mut Window, cx: &mut Context<Self>) {
359 if self.workspaces.len() > 1 {
360 let next_index = (self.active_workspace_index + 1) % self.workspaces.len();
361 self.activate_index(next_index, window, cx);
362 }
363 }
364
365 pub fn activate_previous_workspace(&mut self, window: &mut Window, cx: &mut Context<Self>) {
366 if self.workspaces.len() > 1 {
367 let prev_index = if self.active_workspace_index == 0 {
368 self.workspaces.len() - 1
369 } else {
370 self.active_workspace_index - 1
371 };
372 self.activate_index(prev_index, window, cx);
373 }
374 }
375
376 fn serialize(&mut self, cx: &mut App) {
377 let window_id = self.window_id;
378 let state = crate::persistence::model::MultiWorkspaceState {
379 active_workspace_id: self.workspace().read(cx).database_id(),
380 sidebar_open: self.sidebar_open,
381 };
382 self._serialize_task = Some(cx.background_spawn(async move {
383 crate::persistence::write_multi_workspace_state(window_id, state).await;
384 }));
385 }
386
387 /// Returns the in-flight serialization task (if any) so the caller can
388 /// await it. Used by the quit handler to ensure pending DB writes
389 /// complete before the process exits.
390 pub fn flush_serialization(&mut self) -> Task<()> {
391 self._serialize_task.take().unwrap_or(Task::ready(()))
392 }
393
394 fn app_will_quit(&mut self, _cx: &mut Context<Self>) -> impl Future<Output = ()> + use<> {
395 let mut tasks: Vec<Task<()>> = Vec::new();
396 if let Some(task) = self._serialize_task.take() {
397 tasks.push(task);
398 }
399 if let Some(task) = self._create_task.take() {
400 tasks.push(task);
401 }
402 tasks.extend(std::mem::take(&mut self.pending_removal_tasks));
403
404 async move {
405 futures::future::join_all(tasks).await;
406 }
407 }
408
409 fn focus_active_workspace(&self, window: &mut Window, cx: &mut App) {
410 // If a dock panel is zoomed, focus it instead of the center pane.
411 // Otherwise, focusing the center pane triggers dismiss_zoomed_items_to_reveal
412 // which closes the zoomed dock.
413 let focus_handle = {
414 let workspace = self.workspace().read(cx);
415 let mut target = None;
416 for dock in workspace.all_docks() {
417 let dock = dock.read(cx);
418 if dock.is_open() {
419 if let Some(panel) = dock.active_panel() {
420 if panel.is_zoomed(window, cx) {
421 target = Some(panel.panel_focus_handle(cx));
422 break;
423 }
424 }
425 }
426 }
427 target.unwrap_or_else(|| {
428 let pane = workspace.active_pane().clone();
429 pane.read(cx).focus_handle(cx)
430 })
431 };
432 window.focus(&focus_handle, cx);
433 }
434
435 pub fn panel<T: Panel>(&self, cx: &App) -> Option<Entity<T>> {
436 self.workspace().read(cx).panel::<T>(cx)
437 }
438
439 pub fn active_modal<V: ManagedView + 'static>(&self, cx: &App) -> Option<Entity<V>> {
440 self.workspace().read(cx).active_modal::<V>(cx)
441 }
442
443 pub fn add_panel<T: Panel>(
444 &mut self,
445 panel: Entity<T>,
446 window: &mut Window,
447 cx: &mut Context<Self>,
448 ) {
449 self.workspace().update(cx, |workspace, cx| {
450 workspace.add_panel(panel, window, cx);
451 });
452 }
453
454 pub fn focus_panel<T: Panel>(
455 &mut self,
456 window: &mut Window,
457 cx: &mut Context<Self>,
458 ) -> Option<Entity<T>> {
459 self.workspace()
460 .update(cx, |workspace, cx| workspace.focus_panel::<T>(window, cx))
461 }
462
463 // used in a test
464 pub fn toggle_modal<V: ModalView, B>(
465 &mut self,
466 window: &mut Window,
467 cx: &mut Context<Self>,
468 build: B,
469 ) where
470 B: FnOnce(&mut Window, &mut gpui::Context<V>) -> V,
471 {
472 self.workspace().update(cx, |workspace, cx| {
473 workspace.toggle_modal(window, cx, build);
474 });
475 }
476
477 pub fn toggle_dock(
478 &mut self,
479 dock_side: DockPosition,
480 window: &mut Window,
481 cx: &mut Context<Self>,
482 ) {
483 self.workspace().update(cx, |workspace, cx| {
484 workspace.toggle_dock(dock_side, window, cx);
485 });
486 }
487
488 pub fn active_item_as<I: 'static>(&self, cx: &App) -> Option<Entity<I>> {
489 self.workspace().read(cx).active_item_as::<I>(cx)
490 }
491
492 pub fn items_of_type<'a, T: Item>(
493 &'a self,
494 cx: &'a App,
495 ) -> impl 'a + Iterator<Item = Entity<T>> {
496 self.workspace().read(cx).items_of_type::<T>(cx)
497 }
498
499 pub fn database_id(&self, cx: &App) -> Option<WorkspaceId> {
500 self.workspace().read(cx).database_id()
501 }
502
503 pub fn take_pending_removal_tasks(&mut self) -> Vec<Task<()>> {
504 let mut tasks: Vec<Task<()>> = std::mem::take(&mut self.pending_removal_tasks)
505 .into_iter()
506 .filter(|task| !task.is_ready())
507 .collect();
508 if let Some(task) = self._create_task.take() {
509 if !task.is_ready() {
510 tasks.push(task);
511 }
512 }
513 tasks
514 }
515
516 #[cfg(any(test, feature = "test-support"))]
517 pub fn set_random_database_id(&mut self, cx: &mut Context<Self>) {
518 self.workspace().update(cx, |workspace, _cx| {
519 workspace.set_random_database_id();
520 });
521 }
522
523 #[cfg(any(test, feature = "test-support"))]
524 pub fn test_new(project: Entity<Project>, window: &mut Window, cx: &mut Context<Self>) -> Self {
525 let workspace = cx.new(|cx| Workspace::test_new(project, window, cx));
526 Self::new(workspace, window, cx)
527 }
528
529 #[cfg(any(test, feature = "test-support"))]
530 pub fn test_add_workspace(
531 &mut self,
532 project: Entity<Project>,
533 window: &mut Window,
534 cx: &mut Context<Self>,
535 ) -> Entity<Workspace> {
536 let workspace = cx.new(|cx| Workspace::test_new(project, window, cx));
537 self.activate(workspace.clone(), cx);
538 workspace
539 }
540
541 pub fn create_workspace(&mut self, window: &mut Window, cx: &mut Context<Self>) {
542 if !self.multi_workspace_enabled(cx) {
543 return;
544 }
545 let app_state = self.workspace().read(cx).app_state().clone();
546 let project = Project::local(
547 app_state.client.clone(),
548 app_state.node_runtime.clone(),
549 app_state.user_store.clone(),
550 app_state.languages.clone(),
551 app_state.fs.clone(),
552 None,
553 project::LocalProjectFlags::default(),
554 cx,
555 );
556 let new_workspace = cx.new(|cx| Workspace::new(None, project, app_state, window, cx));
557 self.set_active_workspace(new_workspace.clone(), cx);
558 self.focus_active_workspace(window, cx);
559
560 let weak_workspace = new_workspace.downgrade();
561 self._create_task = Some(cx.spawn_in(window, async move |this, cx| {
562 let result = crate::persistence::DB.next_id().await;
563 this.update_in(cx, |this, window, cx| match result {
564 Ok(workspace_id) => {
565 if let Some(workspace) = weak_workspace.upgrade() {
566 let session_id = workspace.read(cx).session_id();
567 let window_id = window.window_handle().window_id().as_u64();
568 workspace.update(cx, |workspace, _cx| {
569 workspace.set_database_id(workspace_id);
570 });
571 cx.background_spawn(async move {
572 crate::persistence::DB
573 .set_session_binding(workspace_id, session_id, Some(window_id))
574 .await
575 .log_err();
576 })
577 .detach();
578 } else {
579 cx.background_spawn(async move {
580 crate::persistence::DB
581 .delete_workspace_by_id(workspace_id)
582 .await
583 .log_err();
584 })
585 .detach();
586 }
587 this.serialize(cx);
588 }
589 Err(error) => {
590 log::error!("Failed to create workspace: {error:#}");
591 if let Some(index) = weak_workspace
592 .upgrade()
593 .and_then(|w| this.workspaces.iter().position(|ws| *ws == w))
594 {
595 this.remove_workspace(index, window, cx);
596 }
597 this.workspace().update(cx, |workspace, cx| {
598 let id = NotificationId::unique::<MultiWorkspace>();
599 workspace.show_toast(
600 Toast::new(id, format!("Failed to create workspace: {error}")),
601 cx,
602 );
603 });
604 }
605 })
606 .log_err();
607 }));
608 }
609
610 pub fn remove_workspace(&mut self, index: usize, window: &mut Window, cx: &mut Context<Self>) {
611 if self.workspaces.len() <= 1 || index >= self.workspaces.len() {
612 return;
613 }
614
615 let removed_workspace = self.workspaces.remove(index);
616
617 if self.active_workspace_index >= self.workspaces.len() {
618 self.active_workspace_index = self.workspaces.len() - 1;
619 } else if self.active_workspace_index > index {
620 self.active_workspace_index -= 1;
621 }
622
623 if let Some(workspace_id) = removed_workspace.read(cx).database_id() {
624 self.pending_removal_tasks.retain(|task| !task.is_ready());
625 self.pending_removal_tasks
626 .push(cx.background_spawn(async move {
627 crate::persistence::DB
628 .delete_workspace_by_id(workspace_id)
629 .await
630 .log_err();
631 }));
632 }
633
634 self.serialize(cx);
635 self.focus_active_workspace(window, cx);
636 cx.notify();
637 }
638
639 pub fn open_project(
640 &mut self,
641 paths: Vec<PathBuf>,
642 window: &mut Window,
643 cx: &mut Context<Self>,
644 ) -> Task<Result<()>> {
645 let workspace = self.workspace().clone();
646
647 if self.multi_workspace_enabled(cx) {
648 workspace.update(cx, |workspace, cx| {
649 workspace.open_workspace_for_paths(true, paths, window, cx)
650 })
651 } else {
652 cx.spawn_in(window, async move |_this, cx| {
653 let should_continue = workspace
654 .update_in(cx, |workspace, window, cx| {
655 workspace.prepare_to_close(crate::CloseIntent::ReplaceWindow, window, cx)
656 })?
657 .await?;
658 if should_continue {
659 workspace
660 .update_in(cx, |workspace, window, cx| {
661 workspace.open_workspace_for_paths(true, paths, window, cx)
662 })?
663 .await
664 } else {
665 Ok(())
666 }
667 })
668 }
669 }
670}
671
672impl Render for MultiWorkspace {
673 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
674 let multi_workspace_enabled = self.multi_workspace_enabled(cx);
675
676 let sidebar: Option<AnyElement> = if multi_workspace_enabled && self.sidebar_open {
677 self.sidebar.as_ref().map(|sidebar_handle| {
678 let weak = cx.weak_entity();
679
680 let sidebar_width = sidebar_handle.width(cx);
681 let resize_handle = deferred(
682 div()
683 .id("sidebar-resize-handle")
684 .absolute()
685 .right(-SIDEBAR_RESIZE_HANDLE_SIZE / 2.)
686 .top(px(0.))
687 .h_full()
688 .w(SIDEBAR_RESIZE_HANDLE_SIZE)
689 .cursor_col_resize()
690 .on_drag(DraggedSidebar, |dragged, _, _, cx| {
691 cx.stop_propagation();
692 cx.new(|_| dragged.clone())
693 })
694 .on_mouse_down(MouseButton::Left, |_, _, cx| {
695 cx.stop_propagation();
696 })
697 .on_mouse_up(MouseButton::Left, move |event, _, cx| {
698 if event.click_count == 2 {
699 weak.update(cx, |this, cx| {
700 if let Some(sidebar) = this.sidebar.as_mut() {
701 sidebar.set_width(None, cx);
702 }
703 })
704 .ok();
705 cx.stop_propagation();
706 }
707 })
708 .occlude(),
709 );
710
711 div()
712 .id("sidebar-container")
713 .relative()
714 .h_full()
715 .w(sidebar_width)
716 .flex_shrink_0()
717 .child(sidebar_handle.to_any())
718 .child(resize_handle)
719 .into_any_element()
720 })
721 } else {
722 None
723 };
724
725 let ui_font = theme::setup_ui_font(window, cx);
726 let text_color = cx.theme().colors().text;
727
728 let workspace = self.workspace().clone();
729 let workspace_key_context = workspace.update(cx, |workspace, cx| workspace.key_context(cx));
730 let root = workspace.update(cx, |workspace, cx| workspace.actions(h_flex(), window, cx));
731
732 client_side_decorations(
733 root.key_context(workspace_key_context)
734 .relative()
735 .size_full()
736 .font(ui_font)
737 .text_color(text_color)
738 .on_action(cx.listener(Self::close_window))
739 .on_action(
740 cx.listener(|this: &mut Self, _: &NewWorkspaceInWindow, window, cx| {
741 this.create_workspace(window, cx);
742 }),
743 )
744 .on_action(
745 cx.listener(|this: &mut Self, _: &NextWorkspaceInWindow, window, cx| {
746 this.activate_next_workspace(window, cx);
747 }),
748 )
749 .on_action(cx.listener(
750 |this: &mut Self, _: &PreviousWorkspaceInWindow, window, cx| {
751 this.activate_previous_workspace(window, cx);
752 },
753 ))
754 .when(self.multi_workspace_enabled(cx), |this| {
755 this.on_action(cx.listener(
756 |this: &mut Self, _: &ToggleWorkspaceSidebar, window, cx| {
757 this.toggle_sidebar(window, cx);
758 },
759 ))
760 .on_action(cx.listener(
761 |this: &mut Self, _: &FocusWorkspaceSidebar, window, cx| {
762 this.focus_sidebar(window, cx);
763 },
764 ))
765 })
766 .when(
767 self.sidebar_open() && self.multi_workspace_enabled(cx),
768 |this| {
769 this.on_drag_move(cx.listener(
770 |this: &mut Self, e: &DragMoveEvent<DraggedSidebar>, _window, cx| {
771 if let Some(sidebar) = &this.sidebar {
772 let new_width = e.event.position.x;
773 sidebar.set_width(Some(new_width), cx);
774 }
775 },
776 ))
777 .children(sidebar)
778 },
779 )
780 .child(
781 div()
782 .flex()
783 .flex_1()
784 .size_full()
785 .overflow_hidden()
786 .child(self.workspace().clone()),
787 )
788 .child(self.workspace().read(cx).modal_layer.clone()),
789 window,
790 cx,
791 Tiling {
792 left: multi_workspace_enabled && self.sidebar_open,
793 ..Tiling::default()
794 },
795 )
796 }
797}
798
799#[cfg(test)]
800mod tests {
801 use super::*;
802 use fs::FakeFs;
803 use gpui::TestAppContext;
804 use settings::SettingsStore;
805
806 fn init_test(cx: &mut TestAppContext) {
807 cx.update(|cx| {
808 let settings_store = SettingsStore::test(cx);
809 cx.set_global(settings_store);
810 theme::init(theme::LoadThemes::JustBase, cx);
811 DisableAiSettings::register(cx);
812 cx.update_flags(false, vec!["agent-v2".into()]);
813 });
814 }
815
816 #[gpui::test]
817 async fn test_sidebar_disabled_when_disable_ai_is_enabled(cx: &mut TestAppContext) {
818 init_test(cx);
819 let fs = FakeFs::new(cx.executor());
820 let project = Project::test(fs, [], cx).await;
821
822 let (multi_workspace, cx) =
823 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
824
825 multi_workspace.read_with(cx, |mw, cx| {
826 assert!(mw.multi_workspace_enabled(cx));
827 });
828
829 multi_workspace.update_in(cx, |mw, _window, cx| {
830 mw.open_sidebar(cx);
831 assert!(mw.is_sidebar_open());
832 });
833
834 cx.update(|_window, cx| {
835 DisableAiSettings::override_global(DisableAiSettings { disable_ai: true }, cx);
836 });
837 cx.run_until_parked();
838
839 multi_workspace.read_with(cx, |mw, cx| {
840 assert!(
841 !mw.is_sidebar_open(),
842 "Sidebar should be closed when disable_ai is true"
843 );
844 assert!(
845 !mw.multi_workspace_enabled(cx),
846 "Multi-workspace should be disabled when disable_ai is true"
847 );
848 });
849
850 multi_workspace.update_in(cx, |mw, window, cx| {
851 mw.toggle_sidebar(window, cx);
852 });
853 multi_workspace.read_with(cx, |mw, _cx| {
854 assert!(
855 !mw.is_sidebar_open(),
856 "Sidebar should remain closed when toggled with disable_ai true"
857 );
858 });
859
860 cx.update(|_window, cx| {
861 DisableAiSettings::override_global(DisableAiSettings { disable_ai: false }, cx);
862 });
863 cx.run_until_parked();
864
865 multi_workspace.read_with(cx, |mw, cx| {
866 assert!(
867 mw.multi_workspace_enabled(cx),
868 "Multi-workspace should be enabled after re-enabling AI"
869 );
870 assert!(
871 !mw.is_sidebar_open(),
872 "Sidebar should still be closed after re-enabling AI (not auto-opened)"
873 );
874 });
875
876 multi_workspace.update_in(cx, |mw, window, cx| {
877 mw.toggle_sidebar(window, cx);
878 });
879 multi_workspace.read_with(cx, |mw, _cx| {
880 assert!(
881 mw.is_sidebar_open(),
882 "Sidebar should open when toggled after re-enabling AI"
883 );
884 });
885 }
886}