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(&self, ix: usize, _window: &mut Window, _cx: &mut Context<Picker<Self>>) -> bool {
 390        match self.matches.get(ix) {
 391            Some(SidebarMatch {
 392                entry: SidebarEntry::Separator(_),
 393                ..
 394            }) => false,
 395            _ => true,
 396        }
 397    }
 398
 399    fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
 400        "Search…".into()
 401    }
 402
 403    fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option<SharedString> {
 404        if self.query.is_empty() {
 405            None
 406        } else {
 407            Some("No threads match your search.".into())
 408        }
 409    }
 410
 411    fn update_matches(
 412        &mut self,
 413        query: String,
 414        window: &mut Window,
 415        cx: &mut Context<Picker<Self>>,
 416    ) -> Task<()> {
 417        let query_changed = self.query != query;
 418        self.query = query.clone();
 419        if query_changed {
 420            self.hovered_thread_item = None;
 421        }
 422        let entries = self.entries.clone();
 423
 424        if query.is_empty() {
 425            self.matches = entries
 426                .into_iter()
 427                .map(|entry| SidebarMatch {
 428                    entry,
 429                    positions: Vec::new(),
 430                })
 431                .collect();
 432
 433            let separator_offset = if self.workspace_thread_count > 0 {
 434                1
 435            } else {
 436                0
 437            };
 438            self.selected_index = (self.active_workspace_index + separator_offset)
 439                .min(self.matches.len().saturating_sub(1));
 440            return Task::ready(());
 441        }
 442
 443        let executor = cx.background_executor().clone();
 444        cx.spawn_in(window, async move |picker, cx| {
 445            let matches = cx
 446                .background_spawn(async move {
 447                    let data_entries: Vec<(usize, &SidebarEntry)> = entries
 448                        .iter()
 449                        .enumerate()
 450                        .filter(|(_, entry)| !matches!(entry, SidebarEntry::Separator(_)))
 451                        .collect();
 452
 453                    let candidates: Vec<StringMatchCandidate> = data_entries
 454                        .iter()
 455                        .enumerate()
 456                        .map(|(candidate_index, (_, entry))| {
 457                            StringMatchCandidate::new(candidate_index, entry.searchable_text())
 458                        })
 459                        .collect();
 460
 461                    let search_matches = fuzzy::match_strings(
 462                        &candidates,
 463                        &query,
 464                        false,
 465                        true,
 466                        MAX_MATCHES,
 467                        &Default::default(),
 468                        executor,
 469                    )
 470                    .await;
 471
 472                    let mut workspace_matches = Vec::new();
 473                    let mut project_matches = Vec::new();
 474
 475                    for search_match in search_matches {
 476                        let (original_index, _) = data_entries[search_match.candidate_id];
 477                        let entry = entries[original_index].clone();
 478                        let sidebar_match = SidebarMatch {
 479                            positions: search_match.positions,
 480                            entry: entry.clone(),
 481                        };
 482                        match entry {
 483                            SidebarEntry::WorkspaceThread(_) => {
 484                                workspace_matches.push(sidebar_match)
 485                            }
 486                            SidebarEntry::RecentProject(_) => project_matches.push(sidebar_match),
 487                            SidebarEntry::Separator(_) => {}
 488                        }
 489                    }
 490
 491                    let mut result = Vec::new();
 492                    if !workspace_matches.is_empty() {
 493                        result.push(SidebarMatch {
 494                            entry: SidebarEntry::Separator("Active Workspaces".into()),
 495                            positions: Vec::new(),
 496                        });
 497                        result.extend(workspace_matches);
 498                    }
 499                    if !project_matches.is_empty() {
 500                        result.push(SidebarMatch {
 501                            entry: SidebarEntry::Separator("Recent Projects".into()),
 502                            positions: Vec::new(),
 503                        });
 504                        result.extend(project_matches);
 505                    }
 506                    result
 507                })
 508                .await;
 509
 510            picker
 511                .update_in(cx, |picker, _window, _cx| {
 512                    picker.delegate.matches = matches;
 513                    if picker.delegate.matches.is_empty() {
 514                        picker.delegate.selected_index = 0;
 515                    } else {
 516                        let first_selectable = picker
 517                            .delegate
 518                            .matches
 519                            .iter()
 520                            .position(|m| !matches!(m.entry, SidebarEntry::Separator(_)))
 521                            .unwrap_or(0);
 522                        picker.delegate.selected_index = first_selectable;
 523                    }
 524                })
 525                .log_err();
 526        })
 527    }
 528
 529    fn confirm(&mut self, _secondary: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
 530        let Some(selected_match) = self.matches.get(self.selected_index) else {
 531            return;
 532        };
 533
 534        match &selected_match.entry {
 535            SidebarEntry::Separator(_) => {}
 536            SidebarEntry::WorkspaceThread(thread_entry) => {
 537                let target_index = thread_entry.index;
 538                self.multi_workspace.update(cx, |multi_workspace, cx| {
 539                    multi_workspace.activate_index(target_index, window, cx);
 540                });
 541            }
 542            SidebarEntry::RecentProject(project_entry) => {
 543                let paths = project_entry.paths.clone();
 544                open_recent_project(paths, window, cx);
 545            }
 546        }
 547    }
 548
 549    fn dismissed(&mut self, _window: &mut Window, _cx: &mut Context<Picker<Self>>) {}
 550
 551    fn render_match(
 552        &self,
 553        index: usize,
 554        selected: bool,
 555        _window: &mut Window,
 556        cx: &mut Context<Picker<Self>>,
 557    ) -> Option<Self::ListItem> {
 558        let match_entry = self.matches.get(index)?;
 559        let SidebarMatch { entry, positions } = match_entry;
 560
 561        match entry {
 562            SidebarEntry::Separator(title) => Some(
 563                v_flex()
 564                    .when(index > 0, |this| {
 565                        this.mt_1()
 566                            .gap_2()
 567                            .child(Divider::horizontal().color(DividerColor::BorderFaded))
 568                    })
 569                    .child(ListSubHeader::new(title.clone()).inset(true))
 570                    .into_any_element(),
 571            ),
 572            SidebarEntry::WorkspaceThread(thread_entry) => {
 573                let worktree_label = thread_entry.worktree_label.clone();
 574                let full_path = thread_entry.full_path.clone();
 575                let thread_info = thread_entry.thread_info.clone();
 576                let workspace_index = thread_entry.index;
 577                let multi_workspace = self.multi_workspace.clone();
 578                let workspace_count = self.multi_workspace.read(cx).workspaces().len();
 579                let is_hovered = self.hovered_thread_item == Some(workspace_index);
 580
 581                let remove_btn = IconButton::new(
 582                    format!("remove-workspace-{}", workspace_index),
 583                    IconName::Close,
 584                )
 585                .icon_size(IconSize::Small)
 586                .icon_color(Color::Muted)
 587                .tooltip(Tooltip::text("Remove Workspace"))
 588                .on_click({
 589                    let multi_workspace = multi_workspace;
 590                    move |_, window, cx| {
 591                        multi_workspace.update(cx, |mw, cx| {
 592                            mw.remove_workspace(workspace_index, window, cx);
 593                        });
 594                    }
 595                });
 596
 597                let has_notification = self.notified_workspaces.contains(&workspace_index);
 598                let thread_subtitle = thread_info.as_ref().map(|info| info.title.clone());
 599                let status = thread_info
 600                    .as_ref()
 601                    .map_or(AgentThreadStatus::default(), |info| info.status);
 602                let running = matches!(
 603                    status,
 604                    AgentThreadStatus::Running | AgentThreadStatus::WaitingForConfirmation
 605                );
 606
 607                Some(
 608                    ThreadItem::new(
 609                        ("workspace-item", thread_entry.index),
 610                        thread_subtitle.unwrap_or("New Thread".into()),
 611                    )
 612                    .icon(
 613                        thread_info
 614                            .as_ref()
 615                            .map_or(IconName::ZedAgent, |info| info.icon),
 616                    )
 617                    .running(running)
 618                    .generation_done(has_notification)
 619                    .status(status)
 620                    .selected(selected)
 621                    .worktree(worktree_label.clone())
 622                    .worktree_highlight_positions(positions.clone())
 623                    .when(workspace_count > 1, |item| item.action_slot(remove_btn))
 624                    .hovered(is_hovered)
 625                    .on_hover(cx.listener(move |picker, is_hovered, _window, cx| {
 626                        let mut changed = false;
 627                        if *is_hovered {
 628                            if picker.delegate.hovered_thread_item != Some(workspace_index) {
 629                                picker.delegate.hovered_thread_item = Some(workspace_index);
 630                                changed = true;
 631                            }
 632                        } else if picker.delegate.hovered_thread_item == Some(workspace_index) {
 633                            picker.delegate.hovered_thread_item = None;
 634                            changed = true;
 635                        }
 636                        if changed {
 637                            cx.notify();
 638                        }
 639                    }))
 640                    .when(!full_path.is_empty(), |this| {
 641                        this.tooltip(move |_, cx| {
 642                            Tooltip::with_meta(worktree_label.clone(), None, full_path.clone(), cx)
 643                        })
 644                    })
 645                    .into_any_element(),
 646                )
 647            }
 648            SidebarEntry::RecentProject(project_entry) => {
 649                let name = project_entry.name.clone();
 650                let full_path = project_entry.full_path.clone();
 651                let item_id: SharedString =
 652                    format!("recent-project-{:?}", project_entry.workspace_id).into();
 653
 654                Some(
 655                    ThreadItem::new(item_id, name.clone())
 656                        .icon(IconName::Folder)
 657                        .selected(selected)
 658                        .highlight_positions(positions.clone())
 659                        .tooltip(move |_, cx| {
 660                            Tooltip::with_meta(name.clone(), None, full_path.clone(), cx)
 661                        })
 662                        .into_any_element(),
 663                )
 664            }
 665        }
 666    }
 667
 668    fn render_editor(
 669        &self,
 670        editor: &Arc<dyn ErasedEditor>,
 671        window: &mut Window,
 672        cx: &mut Context<Picker<Self>>,
 673    ) -> Div {
 674        h_flex()
 675            .h(Tab::container_height(cx))
 676            .w_full()
 677            .px_2()
 678            .gap_2()
 679            .justify_between()
 680            .border_b_1()
 681            .border_color(cx.theme().colors().border)
 682            .child(
 683                Icon::new(IconName::MagnifyingGlass)
 684                    .color(Color::Muted)
 685                    .size(IconSize::Small),
 686            )
 687            .child(editor.render(window, cx))
 688    }
 689}
 690
 691pub struct Sidebar {
 692    multi_workspace: Entity<MultiWorkspace>,
 693    width: Pixels,
 694    picker: Entity<Picker<WorkspacePickerDelegate>>,
 695    _subscription: Subscription,
 696    _project_subscriptions: Vec<Subscription>,
 697    _agent_panel_subscriptions: Vec<Subscription>,
 698    _thread_subscriptions: Vec<Subscription>,
 699    #[cfg(any(test, feature = "test-support"))]
 700    test_thread_infos: HashMap<usize, AgentThreadInfo>,
 701    #[cfg(any(test, feature = "test-support"))]
 702    test_recent_project_thread_titles: HashMap<SharedString, SharedString>,
 703    _fetch_recent_projects: Task<()>,
 704}
 705
 706impl EventEmitter<SidebarEvent> for Sidebar {}
 707
 708impl Sidebar {
 709    pub fn new(
 710        multi_workspace: Entity<MultiWorkspace>,
 711        window: &mut Window,
 712        cx: &mut Context<Self>,
 713    ) -> Self {
 714        let delegate = WorkspacePickerDelegate::new(multi_workspace.clone());
 715        let picker = cx.new(|cx| {
 716            Picker::list(delegate, window, cx)
 717                .max_height(None)
 718                .show_scrollbar(true)
 719                .modal(false)
 720        });
 721
 722        let subscription = cx.observe_in(
 723            &multi_workspace,
 724            window,
 725            |this, _multi_workspace, window, cx| {
 726                this.update_entries(window, cx);
 727            },
 728        );
 729
 730        let fetch_recent_projects = {
 731            let picker = picker.downgrade();
 732            let fs = <dyn Fs>::global(cx);
 733            cx.spawn_in(window, async move |_this, cx| {
 734                let projects = get_recent_projects(None, None, fs).await;
 735
 736                cx.update(|window, cx| {
 737                    if let Some(picker) = picker.upgrade() {
 738                        picker.update(cx, |picker, cx| {
 739                            picker.delegate.set_recent_projects(projects, cx);
 740                            let query = picker.query(cx);
 741                            picker.update_matches(query, window, cx);
 742                        });
 743                    }
 744                })
 745                .log_err();
 746            })
 747        };
 748
 749        let mut this = Self {
 750            multi_workspace,
 751            width: DEFAULT_WIDTH,
 752            picker,
 753            _subscription: subscription,
 754            _project_subscriptions: Vec::new(),
 755            _agent_panel_subscriptions: Vec::new(),
 756            _thread_subscriptions: Vec::new(),
 757            #[cfg(any(test, feature = "test-support"))]
 758            test_thread_infos: HashMap::new(),
 759            #[cfg(any(test, feature = "test-support"))]
 760            test_recent_project_thread_titles: HashMap::new(),
 761            _fetch_recent_projects: fetch_recent_projects,
 762        };
 763        this.update_entries(window, cx);
 764        this
 765    }
 766
 767    fn subscribe_to_projects(
 768        &mut self,
 769        window: &mut Window,
 770        cx: &mut Context<Self>,
 771    ) -> Vec<Subscription> {
 772        let projects: Vec<_> = self
 773            .multi_workspace
 774            .read(cx)
 775            .workspaces()
 776            .iter()
 777            .map(|w| w.read(cx).project().clone())
 778            .collect();
 779
 780        projects
 781            .iter()
 782            .map(|project| {
 783                cx.subscribe_in(
 784                    project,
 785                    window,
 786                    |this, _project, event, window, cx| match event {
 787                        ProjectEvent::WorktreeAdded(_)
 788                        | ProjectEvent::WorktreeRemoved(_)
 789                        | ProjectEvent::WorktreeOrderChanged => {
 790                            this.update_entries(window, cx);
 791                        }
 792                        _ => {}
 793                    },
 794                )
 795            })
 796            .collect()
 797    }
 798
 799    fn build_workspace_thread_entries(
 800        &self,
 801        multi_workspace: &MultiWorkspace,
 802        cx: &App,
 803    ) -> (Vec<WorkspaceThreadEntry>, usize) {
 804        #[allow(unused_mut)]
 805        let mut entries: Vec<WorkspaceThreadEntry> = multi_workspace
 806            .workspaces()
 807            .iter()
 808            .enumerate()
 809            .map(|(index, workspace)| WorkspaceThreadEntry::new(index, workspace, cx))
 810            .collect();
 811
 812        #[cfg(any(test, feature = "test-support"))]
 813        for (index, info) in &self.test_thread_infos {
 814            if let Some(entry) = entries.get_mut(*index) {
 815                entry.thread_info = Some(info.clone());
 816            }
 817        }
 818
 819        (entries, multi_workspace.active_workspace_index())
 820    }
 821
 822    #[cfg(any(test, feature = "test-support"))]
 823    pub fn set_test_recent_projects(
 824        &self,
 825        projects: Vec<RecentProjectEntry>,
 826        cx: &mut Context<Self>,
 827    ) {
 828        self.picker.update(cx, |picker, _cx| {
 829            picker.delegate.recent_projects = projects;
 830        });
 831    }
 832
 833    #[cfg(any(test, feature = "test-support"))]
 834    pub fn set_test_thread_info(
 835        &mut self,
 836        index: usize,
 837        title: SharedString,
 838        status: AgentThreadStatus,
 839    ) {
 840        self.test_thread_infos.insert(
 841            index,
 842            AgentThreadInfo {
 843                title,
 844                status,
 845                icon: IconName::ZedAgent,
 846            },
 847        );
 848    }
 849
 850    #[cfg(any(test, feature = "test-support"))]
 851    pub fn set_test_recent_project_thread_title(
 852        &mut self,
 853        full_path: SharedString,
 854        title: SharedString,
 855        cx: &mut Context<Self>,
 856    ) {
 857        self.test_recent_project_thread_titles
 858            .insert(full_path.clone(), title.clone());
 859        self.picker.update(cx, |picker, _cx| {
 860            picker
 861                .delegate
 862                .recent_project_thread_titles
 863                .insert(full_path, title);
 864        });
 865    }
 866
 867    fn subscribe_to_agent_panels(
 868        &mut self,
 869        window: &mut Window,
 870        cx: &mut Context<Self>,
 871    ) -> Vec<Subscription> {
 872        let workspaces: Vec<_> = self.multi_workspace.read(cx).workspaces().to_vec();
 873
 874        workspaces
 875            .iter()
 876            .map(|workspace| {
 877                if let Some(agent_panel) = workspace.read(cx).panel::<AgentPanel>(cx) {
 878                    cx.subscribe_in(
 879                        &agent_panel,
 880                        window,
 881                        |this, _, _event: &AgentPanelEvent, window, cx| {
 882                            this.update_entries(window, cx);
 883                        },
 884                    )
 885                } else {
 886                    // Panel hasn't loaded yet — observe the workspace so we
 887                    // re-subscribe once the panel appears on its dock.
 888                    cx.observe_in(workspace, window, |this, _, window, cx| {
 889                        this.update_entries(window, cx);
 890                    })
 891                }
 892            })
 893            .collect()
 894    }
 895
 896    fn subscribe_to_threads(
 897        &mut self,
 898        window: &mut Window,
 899        cx: &mut Context<Self>,
 900    ) -> Vec<Subscription> {
 901        let workspaces: Vec<_> = self.multi_workspace.read(cx).workspaces().to_vec();
 902
 903        workspaces
 904            .iter()
 905            .filter_map(|workspace| {
 906                let agent_panel = workspace.read(cx).panel::<AgentPanel>(cx)?;
 907                let thread = agent_panel.read(cx).active_agent_thread(cx)?;
 908                Some(cx.observe_in(&thread, window, |this, _, window, cx| {
 909                    this.update_entries(window, cx);
 910                }))
 911            })
 912            .collect()
 913    }
 914
 915    /// Reconciles the sidebar's displayed entries with the current state of all
 916    /// workspaces and their agent threads.
 917    fn update_entries(&mut self, window: &mut Window, cx: &mut Context<Self>) {
 918        let multi_workspace = self.multi_workspace.clone();
 919        cx.defer_in(window, move |this, window, cx| {
 920            if !this.multi_workspace.read(cx).multi_workspace_enabled(cx) {
 921                return;
 922            }
 923
 924            this._project_subscriptions = this.subscribe_to_projects(window, cx);
 925            this._agent_panel_subscriptions = this.subscribe_to_agent_panels(window, cx);
 926            this._thread_subscriptions = this.subscribe_to_threads(window, cx);
 927            let (entries, active_index) = multi_workspace.read_with(cx, |multi_workspace, cx| {
 928                this.build_workspace_thread_entries(multi_workspace, cx)
 929            });
 930
 931            let had_notifications = !this.picker.read(cx).delegate.notified_workspaces.is_empty();
 932            this.picker.update(cx, |picker, cx| {
 933                picker.delegate.set_entries(entries, active_index, cx);
 934                let query = picker.query(cx);
 935                picker.update_matches(query, window, cx);
 936            });
 937            let has_notifications = !this.picker.read(cx).delegate.notified_workspaces.is_empty();
 938            if had_notifications != has_notifications {
 939                multi_workspace.update(cx, |_, cx| cx.notify());
 940            }
 941        });
 942    }
 943}
 944
 945impl WorkspaceSidebar for Sidebar {
 946    fn width(&self, _cx: &App) -> Pixels {
 947        self.width
 948    }
 949
 950    fn set_width(&mut self, width: Option<Pixels>, cx: &mut Context<Self>) {
 951        self.width = width.unwrap_or(DEFAULT_WIDTH).clamp(MIN_WIDTH, MAX_WIDTH);
 952        cx.notify();
 953    }
 954
 955    fn has_notifications(&self, cx: &App) -> bool {
 956        !self.picker.read(cx).delegate.notified_workspaces.is_empty()
 957    }
 958}
 959
 960impl Focusable for Sidebar {
 961    fn focus_handle(&self, cx: &App) -> FocusHandle {
 962        self.picker.read(cx).focus_handle(cx)
 963    }
 964}
 965
 966impl Render for Sidebar {
 967    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
 968        let titlebar_height = ui::utils::platform_title_bar_height(window);
 969        let ui_font = theme::setup_ui_font(window, cx);
 970        let is_focused = self.focus_handle(cx).is_focused(window);
 971
 972        let focus_tooltip_label = if is_focused {
 973            "Focus Workspace"
 974        } else {
 975            "Focus Sidebar"
 976        };
 977
 978        v_flex()
 979            .id("workspace-sidebar")
 980            .key_context("WorkspaceSidebar")
 981            .font(ui_font)
 982            .h_full()
 983            .w(self.width)
 984            .bg(cx.theme().colors().surface_background)
 985            .border_r_1()
 986            .border_color(cx.theme().colors().border)
 987            .child(
 988                h_flex()
 989                    .flex_none()
 990                    .h(titlebar_height)
 991                    .w_full()
 992                    .mt_px()
 993                    .pb_px()
 994                    .pr_1()
 995                    .when_else(
 996                        cfg!(target_os = "macos") && !window.is_fullscreen(),
 997                        |this| this.pl(px(TRAFFIC_LIGHT_PADDING)),
 998                        |this| this.pl_2(),
 999                    )
1000                    .justify_between()
1001                    .border_b_1()
1002                    .border_color(cx.theme().colors().border)
1003                    .child({
1004                        let focus_handle = cx.focus_handle();
1005                        IconButton::new("close-sidebar", IconName::WorkspaceNavOpen)
1006                            .icon_size(IconSize::Small)
1007                            .tooltip(Tooltip::element(move |_, cx| {
1008                                v_flex()
1009                                    .gap_1()
1010                                    .child(
1011                                        h_flex()
1012                                            .gap_2()
1013                                            .justify_between()
1014                                            .child(Label::new("Close Sidebar"))
1015                                            .child(KeyBinding::for_action_in(
1016                                                &ToggleWorkspaceSidebar,
1017                                                &focus_handle,
1018                                                cx,
1019                                            )),
1020                                    )
1021                                    .child(
1022                                        h_flex()
1023                                            .pt_1()
1024                                            .gap_2()
1025                                            .border_t_1()
1026                                            .border_color(cx.theme().colors().border_variant)
1027                                            .justify_between()
1028                                            .child(Label::new(focus_tooltip_label))
1029                                            .child(KeyBinding::for_action_in(
1030                                                &FocusWorkspaceSidebar,
1031                                                &focus_handle,
1032                                                cx,
1033                                            )),
1034                                    )
1035                                    .into_any_element()
1036                            }))
1037                            .on_click(cx.listener(|_this, _, _window, cx| {
1038                                cx.emit(SidebarEvent::Close);
1039                            }))
1040                    })
1041                    .child(
1042                        IconButton::new("new-workspace", IconName::Plus)
1043                            .icon_size(IconSize::Small)
1044                            .tooltip(|_window, cx| {
1045                                Tooltip::for_action("New Workspace", &NewWorkspaceInWindow, cx)
1046                            })
1047                            .on_click(cx.listener(|this, _, window, cx| {
1048                                this.multi_workspace.update(cx, |multi_workspace, cx| {
1049                                    multi_workspace.create_workspace(window, cx);
1050                                });
1051                            })),
1052                    ),
1053            )
1054            .child(self.picker.clone())
1055    }
1056}
1057
1058#[cfg(test)]
1059mod tests {
1060    use super::*;
1061    use feature_flags::FeatureFlagAppExt as _;
1062    use fs::FakeFs;
1063    use gpui::TestAppContext;
1064    use settings::SettingsStore;
1065
1066    fn init_test(cx: &mut TestAppContext) {
1067        cx.update(|cx| {
1068            let settings_store = SettingsStore::test(cx);
1069            cx.set_global(settings_store);
1070            theme::init(theme::LoadThemes::JustBase, cx);
1071            editor::init(cx);
1072            cx.update_flags(false, vec!["agent-v2".into()]);
1073        });
1074    }
1075
1076    fn set_thread_info_and_refresh(
1077        sidebar: &Entity<Sidebar>,
1078        multi_workspace: &Entity<MultiWorkspace>,
1079        index: usize,
1080        title: &str,
1081        status: AgentThreadStatus,
1082        cx: &mut gpui::VisualTestContext,
1083    ) {
1084        sidebar.update_in(cx, |s, _window, _cx| {
1085            s.set_test_thread_info(index, SharedString::from(title.to_string()), status);
1086        });
1087        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
1088        cx.run_until_parked();
1089    }
1090
1091    fn has_notifications(sidebar: &Entity<Sidebar>, cx: &mut gpui::VisualTestContext) -> bool {
1092        sidebar.read_with(cx, |s, cx| s.has_notifications(cx))
1093    }
1094
1095    #[gpui::test]
1096    async fn test_notification_on_running_to_completed_transition(cx: &mut TestAppContext) {
1097        init_test(cx);
1098        let fs = FakeFs::new(cx.executor());
1099        cx.update(|cx| <dyn Fs>::set_global(fs.clone(), cx));
1100        let project = project::Project::test(fs, [], cx).await;
1101
1102        let (multi_workspace, cx) =
1103            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
1104
1105        let sidebar = multi_workspace.update_in(cx, |_mw, window, cx| {
1106            let mw_handle = cx.entity();
1107            cx.new(|cx| Sidebar::new(mw_handle, window, cx))
1108        });
1109        multi_workspace.update_in(cx, |mw, window, cx| {
1110            mw.register_sidebar(sidebar.clone(), window, cx);
1111        });
1112        cx.run_until_parked();
1113
1114        // Create a second workspace and switch to it so workspace 0 is background.
1115        multi_workspace.update_in(cx, |mw, window, cx| {
1116            mw.create_workspace(window, cx);
1117        });
1118        cx.run_until_parked();
1119        multi_workspace.update_in(cx, |mw, window, cx| {
1120            mw.activate_index(1, window, cx);
1121        });
1122        cx.run_until_parked();
1123
1124        assert!(
1125            !has_notifications(&sidebar, cx),
1126            "should have no notifications initially"
1127        );
1128
1129        set_thread_info_and_refresh(
1130            &sidebar,
1131            &multi_workspace,
1132            0,
1133            "Test Thread",
1134            AgentThreadStatus::Running,
1135            cx,
1136        );
1137
1138        assert!(
1139            !has_notifications(&sidebar, cx),
1140            "Running status alone should not create a notification"
1141        );
1142
1143        set_thread_info_and_refresh(
1144            &sidebar,
1145            &multi_workspace,
1146            0,
1147            "Test Thread",
1148            AgentThreadStatus::Completed,
1149            cx,
1150        );
1151
1152        assert!(
1153            has_notifications(&sidebar, cx),
1154            "Running → Completed transition should create a notification"
1155        );
1156    }
1157
1158    #[gpui::test]
1159    async fn test_no_notification_for_active_workspace(cx: &mut TestAppContext) {
1160        init_test(cx);
1161        let fs = FakeFs::new(cx.executor());
1162        cx.update(|cx| <dyn Fs>::set_global(fs.clone(), cx));
1163        let project = project::Project::test(fs, [], cx).await;
1164
1165        let (multi_workspace, cx) =
1166            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
1167
1168        let sidebar = multi_workspace.update_in(cx, |_mw, window, cx| {
1169            let mw_handle = cx.entity();
1170            cx.new(|cx| Sidebar::new(mw_handle, window, cx))
1171        });
1172        multi_workspace.update_in(cx, |mw, window, cx| {
1173            mw.register_sidebar(sidebar.clone(), window, cx);
1174        });
1175        cx.run_until_parked();
1176
1177        // Workspace 0 is the active workspace — thread completes while
1178        // the user is already looking at it.
1179        set_thread_info_and_refresh(
1180            &sidebar,
1181            &multi_workspace,
1182            0,
1183            "Test Thread",
1184            AgentThreadStatus::Running,
1185            cx,
1186        );
1187        set_thread_info_and_refresh(
1188            &sidebar,
1189            &multi_workspace,
1190            0,
1191            "Test Thread",
1192            AgentThreadStatus::Completed,
1193            cx,
1194        );
1195
1196        assert!(
1197            !has_notifications(&sidebar, cx),
1198            "should not notify for the workspace the user is already looking at"
1199        );
1200    }
1201
1202    #[gpui::test]
1203    async fn test_notification_cleared_on_workspace_activation(cx: &mut TestAppContext) {
1204        init_test(cx);
1205        let fs = FakeFs::new(cx.executor());
1206        cx.update(|cx| <dyn Fs>::set_global(fs.clone(), cx));
1207        let project = project::Project::test(fs, [], cx).await;
1208
1209        let (multi_workspace, cx) =
1210            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
1211
1212        let sidebar = multi_workspace.update_in(cx, |_mw, window, cx| {
1213            let mw_handle = cx.entity();
1214            cx.new(|cx| Sidebar::new(mw_handle, window, cx))
1215        });
1216        multi_workspace.update_in(cx, |mw, window, cx| {
1217            mw.register_sidebar(sidebar.clone(), window, cx);
1218        });
1219        cx.run_until_parked();
1220
1221        // Create a second workspace so we can switch away and back.
1222        multi_workspace.update_in(cx, |mw, window, cx| {
1223            mw.create_workspace(window, cx);
1224        });
1225        cx.run_until_parked();
1226
1227        // Switch to workspace 1 so workspace 0 becomes a background workspace.
1228        multi_workspace.update_in(cx, |mw, window, cx| {
1229            mw.activate_index(1, window, cx);
1230        });
1231        cx.run_until_parked();
1232
1233        // Thread on workspace 0 transitions Running → Completed while
1234        // the user is looking at workspace 1.
1235        set_thread_info_and_refresh(
1236            &sidebar,
1237            &multi_workspace,
1238            0,
1239            "Test Thread",
1240            AgentThreadStatus::Running,
1241            cx,
1242        );
1243        set_thread_info_and_refresh(
1244            &sidebar,
1245            &multi_workspace,
1246            0,
1247            "Test Thread",
1248            AgentThreadStatus::Completed,
1249            cx,
1250        );
1251
1252        assert!(
1253            has_notifications(&sidebar, cx),
1254            "background workspace completion should create a notification"
1255        );
1256
1257        // Switching back to workspace 0 should clear the notification.
1258        multi_workspace.update_in(cx, |mw, window, cx| {
1259            mw.activate_index(0, window, cx);
1260        });
1261        cx.run_until_parked();
1262
1263        assert!(
1264            !has_notifications(&sidebar, cx),
1265            "notification should be cleared when workspace becomes active"
1266        );
1267    }
1268}