sidebar.rs

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