multi_workspace.rs

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