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