sidebar.rs

   1use acp_thread::ThreadStatus;
   2use agent_ui::{AgentPanel, AgentPanelEvent};
   3use chrono::{Datelike, Local, NaiveDate, TimeDelta};
   4
   5use fs::Fs;
   6use fuzzy::StringMatchCandidate;
   7use gpui::{
   8    App, Context, Entity, EventEmitter, FocusHandle, Focusable, Pixels, Render, SharedString,
   9    Subscription, Task, Window, px,
  10};
  11use picker::{Picker, PickerDelegate};
  12use project::Event as ProjectEvent;
  13use recent_projects::{RecentProjectEntry, get_recent_projects};
  14use std::fmt::Display;
  15
  16use std::collections::{HashMap, HashSet};
  17
  18use std::path::{Path, PathBuf};
  19use std::sync::Arc;
  20use theme::ActiveTheme;
  21use ui::utils::TRAFFIC_LIGHT_PADDING;
  22use ui::{
  23    AgentThreadStatus, Divider, DividerColor, KeyBinding, ListSubHeader, Tab, ThreadItem, Tooltip,
  24    prelude::*,
  25};
  26use ui_input::ErasedEditor;
  27use util::ResultExt as _;
  28use workspace::{
  29    FocusWorkspaceSidebar, MultiWorkspace, NewWorkspaceInWindow, Sidebar as WorkspaceSidebar,
  30    SidebarEvent, ToggleWorkspaceSidebar, Workspace,
  31};
  32
  33#[derive(Clone, Debug)]
  34struct AgentThreadInfo {
  35    title: SharedString,
  36    status: AgentThreadStatus,
  37    icon: IconName,
  38}
  39
  40const DEFAULT_WIDTH: Pixels = px(320.0);
  41const MIN_WIDTH: Pixels = px(200.0);
  42const MAX_WIDTH: Pixels = px(800.0);
  43const MAX_MATCHES: usize = 100;
  44
  45#[derive(Clone)]
  46struct WorkspaceThreadEntry {
  47    index: usize,
  48    worktree_label: SharedString,
  49    full_path: SharedString,
  50    thread_info: Option<AgentThreadInfo>,
  51}
  52
  53impl WorkspaceThreadEntry {
  54    fn new(index: usize, workspace: &Entity<Workspace>, cx: &App) -> Self {
  55        let workspace_ref = workspace.read(cx);
  56
  57        let worktrees: Vec<_> = workspace_ref
  58            .worktrees(cx)
  59            .filter(|worktree| worktree.read(cx).is_visible())
  60            .map(|worktree| worktree.read(cx).abs_path())
  61            .collect();
  62
  63        let worktree_names: Vec<String> = worktrees
  64            .iter()
  65            .filter_map(|path| {
  66                path.file_name()
  67                    .map(|name| name.to_string_lossy().to_string())
  68            })
  69            .collect();
  70
  71        let worktree_label: SharedString = if worktree_names.is_empty() {
  72            format!("Workspace {}", index + 1).into()
  73        } else {
  74            worktree_names.join(", ").into()
  75        };
  76
  77        let full_path: SharedString = worktrees
  78            .iter()
  79            .map(|path| path.to_string_lossy().to_string())
  80            .collect::<Vec<_>>()
  81            .join("\n")
  82            .into();
  83
  84        let thread_info = Self::thread_info(workspace, cx);
  85
  86        Self {
  87            index,
  88            worktree_label,
  89            full_path,
  90            thread_info,
  91        }
  92    }
  93
  94    fn thread_info(workspace: &Entity<Workspace>, cx: &App) -> Option<AgentThreadInfo> {
  95        let agent_panel = workspace.read(cx).panel::<AgentPanel>(cx)?;
  96        let agent_panel_ref = agent_panel.read(cx);
  97
  98        let thread_view = agent_panel_ref.as_active_thread_view(cx)?.read(cx);
  99        let thread = thread_view.thread.read(cx);
 100
 101        let icon = thread_view.agent_icon;
 102        let title = thread.title();
 103
 104        let status = if thread.is_waiting_for_confirmation() {
 105            AgentThreadStatus::WaitingForConfirmation
 106        } else if thread.had_error() {
 107            AgentThreadStatus::Error
 108        } else {
 109            match thread.status() {
 110                ThreadStatus::Generating => AgentThreadStatus::Running,
 111                ThreadStatus::Idle => AgentThreadStatus::Completed,
 112            }
 113        };
 114        Some(AgentThreadInfo {
 115            title,
 116            status,
 117            icon,
 118        })
 119    }
 120}
 121
 122#[derive(Clone)]
 123enum SidebarEntry {
 124    Separator(SharedString),
 125    WorkspaceThread(WorkspaceThreadEntry),
 126    RecentProject(RecentProjectEntry),
 127}
 128
 129impl SidebarEntry {
 130    fn searchable_text(&self) -> &str {
 131        match self {
 132            SidebarEntry::Separator(_) => "",
 133            SidebarEntry::WorkspaceThread(entry) => entry.worktree_label.as_ref(),
 134            SidebarEntry::RecentProject(entry) => entry.name.as_ref(),
 135        }
 136    }
 137}
 138
 139#[derive(Clone)]
 140struct SidebarMatch {
 141    entry: SidebarEntry,
 142    positions: Vec<usize>,
 143}
 144
 145struct WorkspacePickerDelegate {
 146    multi_workspace: Entity<MultiWorkspace>,
 147    entries: Vec<SidebarEntry>,
 148    active_workspace_index: usize,
 149    workspace_thread_count: usize,
 150    /// All recent projects including what's filtered out of entries
 151    /// used to add unopened projects to entries on rebuild
 152    recent_projects: Vec<RecentProjectEntry>,
 153    recent_project_thread_titles: HashMap<SharedString, SharedString>,
 154    matches: Vec<SidebarMatch>,
 155    selected_index: usize,
 156    query: String,
 157    hovered_thread_item: Option<usize>,
 158    notified_workspaces: HashSet<usize>,
 159}
 160
 161impl WorkspacePickerDelegate {
 162    fn new(multi_workspace: Entity<MultiWorkspace>) -> Self {
 163        Self {
 164            multi_workspace,
 165            entries: Vec::new(),
 166            active_workspace_index: 0,
 167            workspace_thread_count: 0,
 168            recent_projects: Vec::new(),
 169            recent_project_thread_titles: HashMap::new(),
 170            matches: Vec::new(),
 171            selected_index: 0,
 172            query: String::new(),
 173            hovered_thread_item: None,
 174            notified_workspaces: HashSet::new(),
 175        }
 176    }
 177
 178    fn set_entries(
 179        &mut self,
 180        workspace_threads: Vec<WorkspaceThreadEntry>,
 181        active_workspace_index: usize,
 182        cx: &App,
 183    ) {
 184        if let Some(hovered_index) = self.hovered_thread_item {
 185            let still_exists = workspace_threads
 186                .iter()
 187                .any(|thread| thread.index == hovered_index);
 188            if !still_exists {
 189                self.hovered_thread_item = None;
 190            }
 191        }
 192
 193        let old_statuses: HashMap<usize, AgentThreadStatus> = self
 194            .entries
 195            .iter()
 196            .filter_map(|entry| match entry {
 197                SidebarEntry::WorkspaceThread(thread) => thread
 198                    .thread_info
 199                    .as_ref()
 200                    .map(|info| (thread.index, info.status)),
 201                _ => None,
 202            })
 203            .collect();
 204
 205        for thread in &workspace_threads {
 206            if let Some(info) = &thread.thread_info {
 207                if info.status == AgentThreadStatus::Completed
 208                    && thread.index != active_workspace_index
 209                {
 210                    if old_statuses.get(&thread.index) == Some(&AgentThreadStatus::Running) {
 211                        self.notified_workspaces.insert(thread.index);
 212                    }
 213                }
 214            }
 215        }
 216
 217        if self.active_workspace_index != active_workspace_index {
 218            self.notified_workspaces.remove(&active_workspace_index);
 219        }
 220        self.active_workspace_index = active_workspace_index;
 221        self.workspace_thread_count = workspace_threads.len();
 222        self.rebuild_entries(workspace_threads, cx);
 223    }
 224
 225    fn set_recent_projects(&mut self, recent_projects: Vec<RecentProjectEntry>, cx: &App) {
 226        self.recent_project_thread_titles.clear();
 227
 228        self.recent_projects = recent_projects;
 229
 230        let workspace_threads: Vec<WorkspaceThreadEntry> = self
 231            .entries
 232            .iter()
 233            .filter_map(|entry| match entry {
 234                SidebarEntry::WorkspaceThread(thread) => Some(thread.clone()),
 235                _ => None,
 236            })
 237            .collect();
 238        self.rebuild_entries(workspace_threads, cx);
 239    }
 240
 241    fn open_workspace_path_sets(&self, cx: &App) -> Vec<Vec<Arc<Path>>> {
 242        self.multi_workspace
 243            .read(cx)
 244            .workspaces()
 245            .iter()
 246            .map(|workspace| {
 247                let mut paths = workspace.read(cx).root_paths(cx);
 248                paths.sort();
 249                paths
 250            })
 251            .collect()
 252    }
 253
 254    fn rebuild_entries(&mut self, workspace_threads: Vec<WorkspaceThreadEntry>, cx: &App) {
 255        let open_path_sets = self.open_workspace_path_sets(cx);
 256
 257        self.entries.clear();
 258
 259        if !workspace_threads.is_empty() {
 260            self.entries
 261                .push(SidebarEntry::Separator("Active Workspaces".into()));
 262            for thread in workspace_threads {
 263                self.entries.push(SidebarEntry::WorkspaceThread(thread));
 264            }
 265        }
 266
 267        let recent: Vec<_> = self
 268            .recent_projects
 269            .iter()
 270            .filter(|project| {
 271                let mut project_paths: Vec<&Path> =
 272                    project.paths.iter().map(|p| p.as_path()).collect();
 273                project_paths.sort();
 274                !open_path_sets.iter().any(|open_paths| {
 275                    open_paths.len() == project_paths.len()
 276                        && open_paths
 277                            .iter()
 278                            .zip(&project_paths)
 279                            .all(|(a, b)| a.as_ref() == *b)
 280                })
 281            })
 282            .cloned()
 283            .collect();
 284
 285        if !recent.is_empty() {
 286            let today = Local::now().naive_local().date();
 287            let mut current_bucket: Option<TimeBucket> = None;
 288
 289            for project in recent {
 290                let entry_date = project.timestamp.with_timezone(&Local).naive_local().date();
 291                let bucket = TimeBucket::from_dates(today, entry_date);
 292
 293                if current_bucket != Some(bucket) {
 294                    current_bucket = Some(bucket);
 295                    self.entries
 296                        .push(SidebarEntry::Separator(bucket.to_string().into()));
 297                }
 298
 299                self.entries.push(SidebarEntry::RecentProject(project));
 300            }
 301        }
 302    }
 303}
 304
 305#[derive(Clone, Copy, Debug, PartialEq, Eq)]
 306enum TimeBucket {
 307    Today,
 308    Yesterday,
 309    ThisWeek,
 310    PastWeek,
 311    All,
 312}
 313
 314impl TimeBucket {
 315    fn from_dates(reference: NaiveDate, date: NaiveDate) -> Self {
 316        if date == reference {
 317            return TimeBucket::Today;
 318        }
 319
 320        if date == reference - TimeDelta::days(1) {
 321            return TimeBucket::Yesterday;
 322        }
 323
 324        let week = date.iso_week();
 325
 326        if reference.iso_week() == week {
 327            return TimeBucket::ThisWeek;
 328        }
 329
 330        let last_week = (reference - TimeDelta::days(7)).iso_week();
 331
 332        if week == last_week {
 333            return TimeBucket::PastWeek;
 334        }
 335
 336        TimeBucket::All
 337    }
 338}
 339
 340impl Display for TimeBucket {
 341    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
 342        match self {
 343            TimeBucket::Today => write!(f, "Today"),
 344            TimeBucket::Yesterday => write!(f, "Yesterday"),
 345            TimeBucket::ThisWeek => write!(f, "This Week"),
 346            TimeBucket::PastWeek => write!(f, "Past Week"),
 347            TimeBucket::All => write!(f, "All"),
 348        }
 349    }
 350}
 351
 352fn open_recent_project(paths: Vec<PathBuf>, window: &mut Window, cx: &mut App) {
 353    let Some(handle) = window.window_handle().downcast::<MultiWorkspace>() else {
 354        return;
 355    };
 356
 357    cx.defer(move |cx| {
 358        if let Some(task) = handle
 359            .update(cx, |multi_workspace, window, cx| {
 360                multi_workspace.open_project(paths, window, cx)
 361            })
 362            .log_err()
 363        {
 364            task.detach_and_log_err(cx);
 365        }
 366    });
 367}
 368
 369impl PickerDelegate for WorkspacePickerDelegate {
 370    type ListItem = AnyElement;
 371
 372    fn match_count(&self) -> usize {
 373        self.matches.len()
 374    }
 375
 376    fn selected_index(&self) -> usize {
 377        self.selected_index
 378    }
 379
 380    fn set_selected_index(
 381        &mut self,
 382        ix: usize,
 383        _window: &mut Window,
 384        _cx: &mut Context<Picker<Self>>,
 385    ) {
 386        self.selected_index = ix;
 387    }
 388
 389    fn can_select(
 390        &mut self,
 391        ix: usize,
 392        _window: &mut Window,
 393        _cx: &mut Context<Picker<Self>>,
 394    ) -> bool {
 395        match self.matches.get(ix) {
 396            Some(SidebarMatch {
 397                entry: SidebarEntry::Separator(_),
 398                ..
 399            }) => false,
 400            _ => true,
 401        }
 402    }
 403
 404    fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
 405        "Search…".into()
 406    }
 407
 408    fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option<SharedString> {
 409        if self.query.is_empty() {
 410            None
 411        } else {
 412            Some("No threads match your search.".into())
 413        }
 414    }
 415
 416    fn update_matches(
 417        &mut self,
 418        query: String,
 419        window: &mut Window,
 420        cx: &mut Context<Picker<Self>>,
 421    ) -> Task<()> {
 422        let query_changed = self.query != query;
 423        self.query = query.clone();
 424        if query_changed {
 425            self.hovered_thread_item = None;
 426        }
 427        let entries = self.entries.clone();
 428
 429        if query.is_empty() {
 430            self.matches = entries
 431                .into_iter()
 432                .map(|entry| SidebarMatch {
 433                    entry,
 434                    positions: Vec::new(),
 435                })
 436                .collect();
 437
 438            let separator_offset = if self.workspace_thread_count > 0 {
 439                1
 440            } else {
 441                0
 442            };
 443            self.selected_index = (self.active_workspace_index + separator_offset)
 444                .min(self.matches.len().saturating_sub(1));
 445            return Task::ready(());
 446        }
 447
 448        let executor = cx.background_executor().clone();
 449        cx.spawn_in(window, async move |picker, cx| {
 450            let matches = cx
 451                .background_spawn(async move {
 452                    let data_entries: Vec<(usize, &SidebarEntry)> = entries
 453                        .iter()
 454                        .enumerate()
 455                        .filter(|(_, entry)| !matches!(entry, SidebarEntry::Separator(_)))
 456                        .collect();
 457
 458                    let candidates: Vec<StringMatchCandidate> = data_entries
 459                        .iter()
 460                        .enumerate()
 461                        .map(|(candidate_index, (_, entry))| {
 462                            StringMatchCandidate::new(candidate_index, entry.searchable_text())
 463                        })
 464                        .collect();
 465
 466                    let search_matches = fuzzy::match_strings(
 467                        &candidates,
 468                        &query,
 469                        false,
 470                        true,
 471                        MAX_MATCHES,
 472                        &Default::default(),
 473                        executor,
 474                    )
 475                    .await;
 476
 477                    let mut workspace_matches = Vec::new();
 478                    let mut project_matches = Vec::new();
 479
 480                    for search_match in search_matches {
 481                        let (original_index, _) = data_entries[search_match.candidate_id];
 482                        let entry = entries[original_index].clone();
 483                        let sidebar_match = SidebarMatch {
 484                            positions: search_match.positions,
 485                            entry: entry.clone(),
 486                        };
 487                        match entry {
 488                            SidebarEntry::WorkspaceThread(_) => {
 489                                workspace_matches.push(sidebar_match)
 490                            }
 491                            SidebarEntry::RecentProject(_) => project_matches.push(sidebar_match),
 492                            SidebarEntry::Separator(_) => {}
 493                        }
 494                    }
 495
 496                    let mut result = Vec::new();
 497                    if !workspace_matches.is_empty() {
 498                        result.push(SidebarMatch {
 499                            entry: SidebarEntry::Separator("Active Workspaces".into()),
 500                            positions: Vec::new(),
 501                        });
 502                        result.extend(workspace_matches);
 503                    }
 504                    if !project_matches.is_empty() {
 505                        result.push(SidebarMatch {
 506                            entry: SidebarEntry::Separator("Recent Projects".into()),
 507                            positions: Vec::new(),
 508                        });
 509                        result.extend(project_matches);
 510                    }
 511                    result
 512                })
 513                .await;
 514
 515            picker
 516                .update_in(cx, |picker, _window, _cx| {
 517                    picker.delegate.matches = matches;
 518                    if picker.delegate.matches.is_empty() {
 519                        picker.delegate.selected_index = 0;
 520                    } else {
 521                        let first_selectable = picker
 522                            .delegate
 523                            .matches
 524                            .iter()
 525                            .position(|m| !matches!(m.entry, SidebarEntry::Separator(_)))
 526                            .unwrap_or(0);
 527                        picker.delegate.selected_index = first_selectable;
 528                    }
 529                })
 530                .log_err();
 531        })
 532    }
 533
 534    fn confirm(&mut self, _secondary: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
 535        let Some(selected_match) = self.matches.get(self.selected_index) else {
 536            return;
 537        };
 538
 539        match &selected_match.entry {
 540            SidebarEntry::Separator(_) => {}
 541            SidebarEntry::WorkspaceThread(thread_entry) => {
 542                let target_index = thread_entry.index;
 543                self.multi_workspace.update(cx, |multi_workspace, cx| {
 544                    multi_workspace.activate_index(target_index, window, cx);
 545                });
 546            }
 547            SidebarEntry::RecentProject(project_entry) => {
 548                let paths = project_entry.paths.clone();
 549                open_recent_project(paths, window, cx);
 550            }
 551        }
 552    }
 553
 554    fn dismissed(&mut self, _window: &mut Window, _cx: &mut Context<Picker<Self>>) {}
 555
 556    fn render_match(
 557        &self,
 558        index: usize,
 559        selected: bool,
 560        _window: &mut Window,
 561        cx: &mut Context<Picker<Self>>,
 562    ) -> Option<Self::ListItem> {
 563        let match_entry = self.matches.get(index)?;
 564        let SidebarMatch { entry, positions } = match_entry;
 565
 566        match entry {
 567            SidebarEntry::Separator(title) => Some(
 568                v_flex()
 569                    .when(index > 0, |this| {
 570                        this.mt_1()
 571                            .gap_2()
 572                            .child(Divider::horizontal().color(DividerColor::BorderFaded))
 573                    })
 574                    .child(ListSubHeader::new(title.clone()).inset(true))
 575                    .into_any_element(),
 576            ),
 577            SidebarEntry::WorkspaceThread(thread_entry) => {
 578                let worktree_label = thread_entry.worktree_label.clone();
 579                let full_path = thread_entry.full_path.clone();
 580                let thread_info = thread_entry.thread_info.clone();
 581                let workspace_index = thread_entry.index;
 582                let multi_workspace = self.multi_workspace.clone();
 583                let workspace_count = self.multi_workspace.read(cx).workspaces().len();
 584                let is_hovered = self.hovered_thread_item == Some(workspace_index);
 585
 586                let remove_btn = IconButton::new(
 587                    format!("remove-workspace-{}", workspace_index),
 588                    IconName::Close,
 589                )
 590                .icon_size(IconSize::Small)
 591                .icon_color(Color::Muted)
 592                .tooltip(Tooltip::text("Remove Workspace"))
 593                .on_click({
 594                    let multi_workspace = multi_workspace;
 595                    move |_, window, cx| {
 596                        multi_workspace.update(cx, |mw, cx| {
 597                            mw.remove_workspace(workspace_index, window, cx);
 598                        });
 599                    }
 600                });
 601
 602                let has_notification = self.notified_workspaces.contains(&workspace_index);
 603                let thread_subtitle = thread_info.as_ref().map(|info| info.title.clone());
 604                let status = thread_info
 605                    .as_ref()
 606                    .map_or(AgentThreadStatus::default(), |info| info.status);
 607                let running = matches!(
 608                    status,
 609                    AgentThreadStatus::Running | AgentThreadStatus::WaitingForConfirmation
 610                );
 611
 612                Some(
 613                    ThreadItem::new(
 614                        ("workspace-item", thread_entry.index),
 615                        thread_subtitle.unwrap_or("New Thread".into()),
 616                    )
 617                    .icon(
 618                        thread_info
 619                            .as_ref()
 620                            .map_or(IconName::ZedAgent, |info| info.icon),
 621                    )
 622                    .running(running)
 623                    .generation_done(has_notification)
 624                    .status(status)
 625                    .selected(selected)
 626                    .worktree(worktree_label.clone())
 627                    .worktree_highlight_positions(positions.clone())
 628                    .when(workspace_count > 1, |item| item.action_slot(remove_btn))
 629                    .hovered(is_hovered)
 630                    .on_hover(cx.listener(move |picker, is_hovered, _window, cx| {
 631                        let mut changed = false;
 632                        if *is_hovered {
 633                            if picker.delegate.hovered_thread_item != Some(workspace_index) {
 634                                picker.delegate.hovered_thread_item = Some(workspace_index);
 635                                changed = true;
 636                            }
 637                        } else if picker.delegate.hovered_thread_item == Some(workspace_index) {
 638                            picker.delegate.hovered_thread_item = None;
 639                            changed = true;
 640                        }
 641                        if changed {
 642                            cx.notify();
 643                        }
 644                    }))
 645                    .when(!full_path.is_empty(), |this| {
 646                        this.tooltip(move |_, cx| {
 647                            Tooltip::with_meta(worktree_label.clone(), None, full_path.clone(), cx)
 648                        })
 649                    })
 650                    .into_any_element(),
 651                )
 652            }
 653            SidebarEntry::RecentProject(project_entry) => {
 654                let name = project_entry.name.clone();
 655                let full_path = project_entry.full_path.clone();
 656                let item_id: SharedString =
 657                    format!("recent-project-{:?}", project_entry.workspace_id).into();
 658
 659                Some(
 660                    ThreadItem::new(item_id, name.clone())
 661                        .icon(IconName::Folder)
 662                        .selected(selected)
 663                        .highlight_positions(positions.clone())
 664                        .tooltip(move |_, cx| {
 665                            Tooltip::with_meta(name.clone(), None, full_path.clone(), cx)
 666                        })
 667                        .into_any_element(),
 668                )
 669            }
 670        }
 671    }
 672
 673    fn render_editor(
 674        &self,
 675        editor: &Arc<dyn ErasedEditor>,
 676        window: &mut Window,
 677        cx: &mut Context<Picker<Self>>,
 678    ) -> Div {
 679        h_flex()
 680            .h(Tab::container_height(cx))
 681            .w_full()
 682            .px_2()
 683            .gap_2()
 684            .justify_between()
 685            .border_b_1()
 686            .border_color(cx.theme().colors().border)
 687            .child(
 688                Icon::new(IconName::MagnifyingGlass)
 689                    .color(Color::Muted)
 690                    .size(IconSize::Small),
 691            )
 692            .child(editor.render(window, cx))
 693    }
 694}
 695
 696pub struct Sidebar {
 697    multi_workspace: Entity<MultiWorkspace>,
 698    width: Pixels,
 699    picker: Entity<Picker<WorkspacePickerDelegate>>,
 700    _subscription: Subscription,
 701    _project_subscriptions: Vec<Subscription>,
 702    _agent_panel_subscriptions: Vec<Subscription>,
 703    _thread_subscriptions: Vec<Subscription>,
 704    #[cfg(any(test, feature = "test-support"))]
 705    test_thread_infos: HashMap<usize, AgentThreadInfo>,
 706    #[cfg(any(test, feature = "test-support"))]
 707    test_recent_project_thread_titles: HashMap<SharedString, SharedString>,
 708    _fetch_recent_projects: Task<()>,
 709}
 710
 711impl EventEmitter<SidebarEvent> for Sidebar {}
 712
 713impl Sidebar {
 714    pub fn new(
 715        multi_workspace: Entity<MultiWorkspace>,
 716        window: &mut Window,
 717        cx: &mut Context<Self>,
 718    ) -> Self {
 719        let delegate = WorkspacePickerDelegate::new(multi_workspace.clone());
 720        let picker = cx.new(|cx| {
 721            Picker::list(delegate, window, cx)
 722                .max_height(None)
 723                .show_scrollbar(true)
 724                .modal(false)
 725        });
 726
 727        let subscription = cx.observe_in(
 728            &multi_workspace,
 729            window,
 730            |this, _multi_workspace, window, cx| {
 731                this.update_entries(window, cx);
 732            },
 733        );
 734
 735        let fetch_recent_projects = {
 736            let picker = picker.downgrade();
 737            let fs = <dyn Fs>::global(cx);
 738            cx.spawn_in(window, async move |_this, cx| {
 739                let projects = get_recent_projects(None, None, fs).await;
 740
 741                cx.update(|window, cx| {
 742                    if let Some(picker) = picker.upgrade() {
 743                        picker.update(cx, |picker, cx| {
 744                            picker.delegate.set_recent_projects(projects, cx);
 745                            let query = picker.query(cx);
 746                            picker.update_matches(query, window, cx);
 747                        });
 748                    }
 749                })
 750                .log_err();
 751            })
 752        };
 753
 754        let mut this = Self {
 755            multi_workspace,
 756            width: DEFAULT_WIDTH,
 757            picker,
 758            _subscription: subscription,
 759            _project_subscriptions: Vec::new(),
 760            _agent_panel_subscriptions: Vec::new(),
 761            _thread_subscriptions: Vec::new(),
 762            #[cfg(any(test, feature = "test-support"))]
 763            test_thread_infos: HashMap::new(),
 764            #[cfg(any(test, feature = "test-support"))]
 765            test_recent_project_thread_titles: HashMap::new(),
 766            _fetch_recent_projects: fetch_recent_projects,
 767        };
 768        this.update_entries(window, cx);
 769        this
 770    }
 771
 772    fn subscribe_to_projects(
 773        &mut self,
 774        window: &mut Window,
 775        cx: &mut Context<Self>,
 776    ) -> Vec<Subscription> {
 777        let projects: Vec<_> = self
 778            .multi_workspace
 779            .read(cx)
 780            .workspaces()
 781            .iter()
 782            .map(|w| w.read(cx).project().clone())
 783            .collect();
 784
 785        projects
 786            .iter()
 787            .map(|project| {
 788                cx.subscribe_in(
 789                    project,
 790                    window,
 791                    |this, _project, event, window, cx| match event {
 792                        ProjectEvent::WorktreeAdded(_)
 793                        | ProjectEvent::WorktreeRemoved(_)
 794                        | ProjectEvent::WorktreeOrderChanged => {
 795                            this.update_entries(window, cx);
 796                        }
 797                        _ => {}
 798                    },
 799                )
 800            })
 801            .collect()
 802    }
 803
 804    fn build_workspace_thread_entries(
 805        &self,
 806        multi_workspace: &MultiWorkspace,
 807        cx: &App,
 808    ) -> (Vec<WorkspaceThreadEntry>, usize) {
 809        #[allow(unused_mut)]
 810        let mut entries: Vec<WorkspaceThreadEntry> = multi_workspace
 811            .workspaces()
 812            .iter()
 813            .enumerate()
 814            .map(|(index, workspace)| WorkspaceThreadEntry::new(index, workspace, cx))
 815            .collect();
 816
 817        #[cfg(any(test, feature = "test-support"))]
 818        for (index, info) in &self.test_thread_infos {
 819            if let Some(entry) = entries.get_mut(*index) {
 820                entry.thread_info = Some(info.clone());
 821            }
 822        }
 823
 824        (entries, multi_workspace.active_workspace_index())
 825    }
 826
 827    #[cfg(any(test, feature = "test-support"))]
 828    pub fn set_test_recent_projects(
 829        &self,
 830        projects: Vec<RecentProjectEntry>,
 831        cx: &mut Context<Self>,
 832    ) {
 833        self.picker.update(cx, |picker, _cx| {
 834            picker.delegate.recent_projects = projects;
 835        });
 836    }
 837
 838    #[cfg(any(test, feature = "test-support"))]
 839    pub fn set_test_thread_info(
 840        &mut self,
 841        index: usize,
 842        title: SharedString,
 843        status: AgentThreadStatus,
 844    ) {
 845        self.test_thread_infos.insert(
 846            index,
 847            AgentThreadInfo {
 848                title,
 849                status,
 850                icon: IconName::ZedAgent,
 851            },
 852        );
 853    }
 854
 855    #[cfg(any(test, feature = "test-support"))]
 856    pub fn set_test_recent_project_thread_title(
 857        &mut self,
 858        full_path: SharedString,
 859        title: SharedString,
 860        cx: &mut Context<Self>,
 861    ) {
 862        self.test_recent_project_thread_titles
 863            .insert(full_path.clone(), title.clone());
 864        self.picker.update(cx, |picker, _cx| {
 865            picker
 866                .delegate
 867                .recent_project_thread_titles
 868                .insert(full_path, title);
 869        });
 870    }
 871
 872    fn subscribe_to_agent_panels(
 873        &mut self,
 874        window: &mut Window,
 875        cx: &mut Context<Self>,
 876    ) -> Vec<Subscription> {
 877        let workspaces: Vec<_> = self.multi_workspace.read(cx).workspaces().to_vec();
 878
 879        workspaces
 880            .iter()
 881            .map(|workspace| {
 882                if let Some(agent_panel) = workspace.read(cx).panel::<AgentPanel>(cx) {
 883                    cx.subscribe_in(
 884                        &agent_panel,
 885                        window,
 886                        |this, _, _event: &AgentPanelEvent, window, cx| {
 887                            this.update_entries(window, cx);
 888                        },
 889                    )
 890                } else {
 891                    // Panel hasn't loaded yet — observe the workspace so we
 892                    // re-subscribe once the panel appears on its dock.
 893                    cx.observe_in(workspace, window, |this, _, window, cx| {
 894                        this.update_entries(window, cx);
 895                    })
 896                }
 897            })
 898            .collect()
 899    }
 900
 901    fn subscribe_to_threads(
 902        &mut self,
 903        window: &mut Window,
 904        cx: &mut Context<Self>,
 905    ) -> Vec<Subscription> {
 906        let workspaces: Vec<_> = self.multi_workspace.read(cx).workspaces().to_vec();
 907
 908        workspaces
 909            .iter()
 910            .filter_map(|workspace| {
 911                let agent_panel = workspace.read(cx).panel::<AgentPanel>(cx)?;
 912                let thread = agent_panel.read(cx).active_agent_thread(cx)?;
 913                Some(cx.observe_in(&thread, window, |this, _, window, cx| {
 914                    this.update_entries(window, cx);
 915                }))
 916            })
 917            .collect()
 918    }
 919
 920    /// Reconciles the sidebar's displayed entries with the current state of all
 921    /// workspaces and their agent threads.
 922    fn update_entries(&mut self, window: &mut Window, cx: &mut Context<Self>) {
 923        let multi_workspace = self.multi_workspace.clone();
 924        cx.defer_in(window, move |this, window, cx| {
 925            if !this.multi_workspace.read(cx).multi_workspace_enabled(cx) {
 926                return;
 927            }
 928
 929            this._project_subscriptions = this.subscribe_to_projects(window, cx);
 930            this._agent_panel_subscriptions = this.subscribe_to_agent_panels(window, cx);
 931            this._thread_subscriptions = this.subscribe_to_threads(window, cx);
 932            let (entries, active_index) = multi_workspace.read_with(cx, |multi_workspace, cx| {
 933                this.build_workspace_thread_entries(multi_workspace, cx)
 934            });
 935
 936            let had_notifications = !this.picker.read(cx).delegate.notified_workspaces.is_empty();
 937            this.picker.update(cx, |picker, cx| {
 938                picker.delegate.set_entries(entries, active_index, cx);
 939                let query = picker.query(cx);
 940                picker.update_matches(query, window, cx);
 941            });
 942            let has_notifications = !this.picker.read(cx).delegate.notified_workspaces.is_empty();
 943            if had_notifications != has_notifications {
 944                multi_workspace.update(cx, |_, cx| cx.notify());
 945            }
 946        });
 947    }
 948}
 949
 950impl WorkspaceSidebar for Sidebar {
 951    fn width(&self, _cx: &App) -> Pixels {
 952        self.width
 953    }
 954
 955    fn set_width(&mut self, width: Option<Pixels>, cx: &mut Context<Self>) {
 956        self.width = width.unwrap_or(DEFAULT_WIDTH).clamp(MIN_WIDTH, MAX_WIDTH);
 957        cx.notify();
 958    }
 959
 960    fn has_notifications(&self, cx: &App) -> bool {
 961        !self.picker.read(cx).delegate.notified_workspaces.is_empty()
 962    }
 963}
 964
 965impl Focusable for Sidebar {
 966    fn focus_handle(&self, cx: &App) -> FocusHandle {
 967        self.picker.read(cx).focus_handle(cx)
 968    }
 969}
 970
 971impl Render for Sidebar {
 972    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
 973        let titlebar_height = ui::utils::platform_title_bar_height(window);
 974        let ui_font = theme::setup_ui_font(window, cx);
 975        let is_focused = self.focus_handle(cx).is_focused(window);
 976
 977        let focus_tooltip_label = if is_focused {
 978            "Focus Workspace"
 979        } else {
 980            "Focus Sidebar"
 981        };
 982
 983        v_flex()
 984            .id("workspace-sidebar")
 985            .key_context("WorkspaceSidebar")
 986            .font(ui_font)
 987            .h_full()
 988            .w(self.width)
 989            .bg(cx.theme().colors().surface_background)
 990            .border_r_1()
 991            .border_color(cx.theme().colors().border)
 992            .child(
 993                h_flex()
 994                    .flex_none()
 995                    .h(titlebar_height)
 996                    .w_full()
 997                    .mt_px()
 998                    .pb_px()
 999                    .pr_1()
1000                    .when_else(
1001                        cfg!(target_os = "macos") && !window.is_fullscreen(),
1002                        |this| this.pl(px(TRAFFIC_LIGHT_PADDING)),
1003                        |this| this.pl_2(),
1004                    )
1005                    .justify_between()
1006                    .border_b_1()
1007                    .border_color(cx.theme().colors().border)
1008                    .child({
1009                        let focus_handle = cx.focus_handle();
1010                        IconButton::new("close-sidebar", IconName::WorkspaceNavOpen)
1011                            .icon_size(IconSize::Small)
1012                            .tooltip(Tooltip::element(move |_, cx| {
1013                                v_flex()
1014                                    .gap_1()
1015                                    .child(
1016                                        h_flex()
1017                                            .gap_2()
1018                                            .justify_between()
1019                                            .child(Label::new("Close Sidebar"))
1020                                            .child(KeyBinding::for_action_in(
1021                                                &ToggleWorkspaceSidebar,
1022                                                &focus_handle,
1023                                                cx,
1024                                            )),
1025                                    )
1026                                    .child(
1027                                        h_flex()
1028                                            .pt_1()
1029                                            .gap_2()
1030                                            .border_t_1()
1031                                            .border_color(cx.theme().colors().border_variant)
1032                                            .justify_between()
1033                                            .child(Label::new(focus_tooltip_label))
1034                                            .child(KeyBinding::for_action_in(
1035                                                &FocusWorkspaceSidebar,
1036                                                &focus_handle,
1037                                                cx,
1038                                            )),
1039                                    )
1040                                    .into_any_element()
1041                            }))
1042                            .on_click(cx.listener(|_this, _, _window, cx| {
1043                                cx.emit(SidebarEvent::Close);
1044                            }))
1045                    })
1046                    .child(
1047                        IconButton::new("new-workspace", IconName::Plus)
1048                            .icon_size(IconSize::Small)
1049                            .tooltip(|_window, cx| {
1050                                Tooltip::for_action("New Workspace", &NewWorkspaceInWindow, cx)
1051                            })
1052                            .on_click(cx.listener(|this, _, window, cx| {
1053                                this.multi_workspace.update(cx, |multi_workspace, cx| {
1054                                    multi_workspace.create_workspace(window, cx);
1055                                });
1056                            })),
1057                    ),
1058            )
1059            .child(self.picker.clone())
1060    }
1061}
1062
1063#[cfg(test)]
1064mod tests {
1065    use super::*;
1066    use feature_flags::FeatureFlagAppExt as _;
1067    use fs::FakeFs;
1068    use gpui::TestAppContext;
1069    use settings::SettingsStore;
1070
1071    fn init_test(cx: &mut TestAppContext) {
1072        cx.update(|cx| {
1073            let settings_store = SettingsStore::test(cx);
1074            cx.set_global(settings_store);
1075            theme::init(theme::LoadThemes::JustBase, cx);
1076            editor::init(cx);
1077            cx.update_flags(false, vec!["agent-v2".into()]);
1078        });
1079    }
1080
1081    fn set_thread_info_and_refresh(
1082        sidebar: &Entity<Sidebar>,
1083        multi_workspace: &Entity<MultiWorkspace>,
1084        index: usize,
1085        title: &str,
1086        status: AgentThreadStatus,
1087        cx: &mut gpui::VisualTestContext,
1088    ) {
1089        sidebar.update_in(cx, |s, _window, _cx| {
1090            s.set_test_thread_info(index, SharedString::from(title.to_string()), status);
1091        });
1092        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
1093        cx.run_until_parked();
1094    }
1095
1096    fn has_notifications(sidebar: &Entity<Sidebar>, cx: &mut gpui::VisualTestContext) -> bool {
1097        sidebar.read_with(cx, |s, cx| s.has_notifications(cx))
1098    }
1099
1100    #[gpui::test]
1101    async fn test_notification_on_running_to_completed_transition(cx: &mut TestAppContext) {
1102        init_test(cx);
1103        let fs = FakeFs::new(cx.executor());
1104        cx.update(|cx| <dyn Fs>::set_global(fs.clone(), cx));
1105        let project = project::Project::test(fs, [], cx).await;
1106
1107        let (multi_workspace, cx) =
1108            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
1109
1110        let sidebar = multi_workspace.update_in(cx, |_mw, window, cx| {
1111            let mw_handle = cx.entity();
1112            cx.new(|cx| Sidebar::new(mw_handle, window, cx))
1113        });
1114        multi_workspace.update_in(cx, |mw, window, cx| {
1115            mw.register_sidebar(sidebar.clone(), window, cx);
1116        });
1117        cx.run_until_parked();
1118
1119        // Create a second workspace and switch to it so workspace 0 is background.
1120        multi_workspace.update_in(cx, |mw, window, cx| {
1121            mw.create_workspace(window, cx);
1122        });
1123        cx.run_until_parked();
1124        multi_workspace.update_in(cx, |mw, window, cx| {
1125            mw.activate_index(1, window, cx);
1126        });
1127        cx.run_until_parked();
1128
1129        assert!(
1130            !has_notifications(&sidebar, cx),
1131            "should have no notifications initially"
1132        );
1133
1134        set_thread_info_and_refresh(
1135            &sidebar,
1136            &multi_workspace,
1137            0,
1138            "Test Thread",
1139            AgentThreadStatus::Running,
1140            cx,
1141        );
1142
1143        assert!(
1144            !has_notifications(&sidebar, cx),
1145            "Running status alone should not create a notification"
1146        );
1147
1148        set_thread_info_and_refresh(
1149            &sidebar,
1150            &multi_workspace,
1151            0,
1152            "Test Thread",
1153            AgentThreadStatus::Completed,
1154            cx,
1155        );
1156
1157        assert!(
1158            has_notifications(&sidebar, cx),
1159            "Running → Completed transition should create a notification"
1160        );
1161    }
1162
1163    #[gpui::test]
1164    async fn test_no_notification_for_active_workspace(cx: &mut TestAppContext) {
1165        init_test(cx);
1166        let fs = FakeFs::new(cx.executor());
1167        cx.update(|cx| <dyn Fs>::set_global(fs.clone(), cx));
1168        let project = project::Project::test(fs, [], cx).await;
1169
1170        let (multi_workspace, cx) =
1171            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
1172
1173        let sidebar = multi_workspace.update_in(cx, |_mw, window, cx| {
1174            let mw_handle = cx.entity();
1175            cx.new(|cx| Sidebar::new(mw_handle, window, cx))
1176        });
1177        multi_workspace.update_in(cx, |mw, window, cx| {
1178            mw.register_sidebar(sidebar.clone(), window, cx);
1179        });
1180        cx.run_until_parked();
1181
1182        // Workspace 0 is the active workspace — thread completes while
1183        // the user is already looking at it.
1184        set_thread_info_and_refresh(
1185            &sidebar,
1186            &multi_workspace,
1187            0,
1188            "Test Thread",
1189            AgentThreadStatus::Running,
1190            cx,
1191        );
1192        set_thread_info_and_refresh(
1193            &sidebar,
1194            &multi_workspace,
1195            0,
1196            "Test Thread",
1197            AgentThreadStatus::Completed,
1198            cx,
1199        );
1200
1201        assert!(
1202            !has_notifications(&sidebar, cx),
1203            "should not notify for the workspace the user is already looking at"
1204        );
1205    }
1206
1207    #[gpui::test]
1208    async fn test_notification_cleared_on_workspace_activation(cx: &mut TestAppContext) {
1209        init_test(cx);
1210        let fs = FakeFs::new(cx.executor());
1211        cx.update(|cx| <dyn Fs>::set_global(fs.clone(), cx));
1212        let project = project::Project::test(fs, [], cx).await;
1213
1214        let (multi_workspace, cx) =
1215            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
1216
1217        let sidebar = multi_workspace.update_in(cx, |_mw, window, cx| {
1218            let mw_handle = cx.entity();
1219            cx.new(|cx| Sidebar::new(mw_handle, window, cx))
1220        });
1221        multi_workspace.update_in(cx, |mw, window, cx| {
1222            mw.register_sidebar(sidebar.clone(), window, cx);
1223        });
1224        cx.run_until_parked();
1225
1226        // Create a second workspace so we can switch away and back.
1227        multi_workspace.update_in(cx, |mw, window, cx| {
1228            mw.create_workspace(window, cx);
1229        });
1230        cx.run_until_parked();
1231
1232        // Switch to workspace 1 so workspace 0 becomes a background workspace.
1233        multi_workspace.update_in(cx, |mw, window, cx| {
1234            mw.activate_index(1, window, cx);
1235        });
1236        cx.run_until_parked();
1237
1238        // Thread on workspace 0 transitions Running → Completed while
1239        // the user is looking at workspace 1.
1240        set_thread_info_and_refresh(
1241            &sidebar,
1242            &multi_workspace,
1243            0,
1244            "Test Thread",
1245            AgentThreadStatus::Running,
1246            cx,
1247        );
1248        set_thread_info_and_refresh(
1249            &sidebar,
1250            &multi_workspace,
1251            0,
1252            "Test Thread",
1253            AgentThreadStatus::Completed,
1254            cx,
1255        );
1256
1257        assert!(
1258            has_notifications(&sidebar, cx),
1259            "background workspace completion should create a notification"
1260        );
1261
1262        // Switching back to workspace 0 should clear the notification.
1263        multi_workspace.update_in(cx, |mw, window, cx| {
1264            mw.activate_index(0, window, cx);
1265        });
1266        cx.run_until_parked();
1267
1268        assert!(
1269            !has_notifications(&sidebar, cx),
1270            "notification should be cleared when workspace becomes active"
1271        );
1272    }
1273}