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