sidebar.rs

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