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