sidebar.rs

   1use acp_thread::ThreadStatus;
   2use agent::ThreadStore;
   3use agent_client_protocol as acp;
   4use agent_ui::{AgentPanel, AgentPanelEvent};
   5use chrono::{DateTime, Utc};
   6use gpui::{
   7    AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, ListState, Pixels,
   8    Render, SharedString, Subscription, Window, actions, list, prelude::*, px,
   9};
  10use menu::{Confirm, SelectFirst, SelectLast, SelectNext, SelectPrevious};
  11use project::Event as ProjectEvent;
  12use std::collections::{HashMap, HashSet};
  13use theme::ActiveTheme;
  14use ui::utils::TRAFFIC_LIGHT_PADDING;
  15use ui::{AgentThreadStatus, KeyBinding, Tooltip, prelude::*};
  16use util::path_list::PathList;
  17use workspace::{
  18    FocusWorkspaceSidebar, MultiWorkspace, NewWorkspaceInWindow, Sidebar as WorkspaceSidebar,
  19    SidebarEvent, ToggleWorkspaceSidebar, Workspace,
  20};
  21
  22actions!(
  23    workspace_sidebar,
  24    [
  25        /// Collapses the selected entry in the workspace sidebar.
  26        CollapseSelectedEntry,
  27        /// Expands the selected entry in the workspace sidebar.
  28        ExpandSelectedEntry,
  29    ]
  30);
  31
  32const DEFAULT_WIDTH: Pixels = px(320.0);
  33const MIN_WIDTH: Pixels = px(200.0);
  34const MAX_WIDTH: Pixels = px(800.0);
  35const DEFAULT_THREADS_SHOWN: usize = 5;
  36
  37#[derive(Clone, Debug)]
  38struct ActiveThreadInfo {
  39    session_id: acp::SessionId,
  40    title: SharedString,
  41    status: AgentThreadStatus,
  42    icon: IconName,
  43}
  44
  45#[derive(Clone, Debug)]
  46#[allow(dead_code)]
  47enum ListEntry {
  48    ProjectHeader {
  49        path_list: PathList,
  50        label: SharedString,
  51    },
  52    Thread {
  53        session_id: acp::SessionId,
  54        title: SharedString,
  55        icon: IconName,
  56        status: AgentThreadStatus,
  57        updated_at: DateTime<Utc>,
  58        diff_stats: Option<(usize, usize)>,
  59        workspace_index: Option<usize>,
  60    },
  61    ViewMore {
  62        path_list: PathList,
  63        remaining_count: usize,
  64    },
  65}
  66
  67pub struct Sidebar {
  68    // Reference cycle with the Workspace?
  69    multi_workspace: Entity<MultiWorkspace>,
  70    width: Pixels,
  71    focus_handle: FocusHandle,
  72    list_state: ListState,
  73    entries: Vec<ListEntry>,
  74    selection: Option<usize>,
  75    collapsed_groups: HashSet<PathList>,
  76    expanded_groups: HashSet<PathList>,
  77    notified_workspaces: HashSet<usize>,
  78    _subscription: Subscription,
  79    _project_subscriptions: Vec<Subscription>,
  80    _agent_panel_subscriptions: Vec<Subscription>,
  81    _thread_store_subscription: Option<Subscription>,
  82}
  83
  84impl EventEmitter<SidebarEvent> for Sidebar {}
  85
  86impl Sidebar {
  87    pub fn new(
  88        multi_workspace: Entity<MultiWorkspace>,
  89        window: &mut Window,
  90        cx: &mut Context<Self>,
  91    ) -> Self {
  92        let focus_handle = cx.focus_handle();
  93        cx.on_focus(&focus_handle, window, Self::focus_in).detach();
  94
  95        let subscription = cx.observe_in(
  96            &multi_workspace,
  97            window,
  98            |this, _multi_workspace, window, cx| {
  99                this.update_entries(window, cx);
 100            },
 101        );
 102
 103        let mut this = Self {
 104            multi_workspace,
 105            width: DEFAULT_WIDTH,
 106            focus_handle,
 107            list_state: ListState::new(0, gpui::ListAlignment::Top, px(1000.)),
 108            entries: Vec::new(),
 109            selection: None,
 110            collapsed_groups: HashSet::new(),
 111            expanded_groups: HashSet::new(),
 112            notified_workspaces: HashSet::new(),
 113            _subscription: subscription,
 114            _project_subscriptions: Vec::new(),
 115            _agent_panel_subscriptions: Vec::new(),
 116            _thread_store_subscription: None,
 117        };
 118        this.update_entries(window, cx);
 119        this
 120    }
 121
 122    fn subscribe_to_projects(
 123        &mut self,
 124        window: &mut Window,
 125        cx: &mut Context<Self>,
 126    ) -> Vec<Subscription> {
 127        let projects: Vec<_> = self
 128            .multi_workspace
 129            .read(cx)
 130            .workspaces()
 131            .iter()
 132            .map(|w| w.read(cx).project().clone())
 133            .collect();
 134
 135        projects
 136            .iter()
 137            .map(|project| {
 138                cx.subscribe_in(
 139                    project,
 140                    window,
 141                    |this, _project, event, window, cx| match event {
 142                        ProjectEvent::WorktreeAdded(_)
 143                        | ProjectEvent::WorktreeRemoved(_)
 144                        | ProjectEvent::WorktreeOrderChanged => {
 145                            this.update_entries(window, cx);
 146                        }
 147                        _ => {}
 148                    },
 149                )
 150            })
 151            .collect()
 152    }
 153
 154    fn subscribe_to_agent_panels(
 155        &mut self,
 156        window: &mut Window,
 157        cx: &mut Context<Self>,
 158    ) -> Vec<Subscription> {
 159        let workspaces: Vec<_> = self.multi_workspace.read(cx).workspaces().to_vec();
 160
 161        workspaces
 162            .iter()
 163            .map(|workspace| {
 164                if let Some(agent_panel) = workspace.read(cx).panel::<AgentPanel>(cx) {
 165                    cx.subscribe_in(
 166                        &agent_panel,
 167                        window,
 168                        |this, _, _event: &AgentPanelEvent, window, cx| {
 169                            this.update_entries(window, cx);
 170                        },
 171                    )
 172                } else {
 173                    cx.observe_in(workspace, window, |this, _, window, cx| {
 174                        this.update_entries(window, cx);
 175                    })
 176                }
 177            })
 178            .collect()
 179    }
 180
 181    fn subscribe_to_thread_store(&mut self, window: &mut Window, cx: &mut Context<Self>) {
 182        if self._thread_store_subscription.is_some() {
 183            return;
 184        }
 185        if let Some(thread_store) = ThreadStore::try_global(cx) {
 186            self._thread_store_subscription =
 187                Some(cx.observe_in(&thread_store, window, |this, _, window, cx| {
 188                    this.update_entries(window, cx);
 189                }));
 190        }
 191    }
 192
 193    fn workspace_path_list_and_label(
 194        workspace: &Entity<Workspace>,
 195        cx: &App,
 196    ) -> (PathList, SharedString) {
 197        let workspace_ref = workspace.read(cx);
 198        let mut paths = Vec::new();
 199        let mut names = Vec::new();
 200
 201        for worktree in workspace_ref.worktrees(cx) {
 202            let worktree_ref = worktree.read(cx);
 203            if !worktree_ref.is_visible() {
 204                continue;
 205            }
 206            let abs_path = worktree_ref.abs_path();
 207            paths.push(abs_path.to_path_buf());
 208            if let Some(name) = abs_path.file_name() {
 209                names.push(name.to_string_lossy().to_string());
 210            }
 211        }
 212
 213        let label: SharedString = if names.is_empty() {
 214            // TODO: Can we do something better in this case?
 215            "Empty Workspace".into()
 216        } else {
 217            names.join(", ").into()
 218        };
 219
 220        (PathList::new(&paths), label)
 221    }
 222
 223    fn all_thread_infos_for_workspace(
 224        workspace: &Entity<Workspace>,
 225        cx: &App,
 226    ) -> Vec<ActiveThreadInfo> {
 227        let Some(agent_panel) = workspace.read(cx).panel::<AgentPanel>(cx) else {
 228            return Vec::new();
 229        };
 230        let agent_panel_ref = agent_panel.read(cx);
 231
 232        agent_panel_ref
 233            .parent_threads(cx)
 234            .into_iter()
 235            .map(|thread_view| {
 236                let thread_view_ref = thread_view.read(cx);
 237                let thread = thread_view_ref.thread.read(cx);
 238
 239                let icon = thread_view_ref.agent_icon;
 240                let title = thread.title();
 241                let session_id = thread.session_id().clone();
 242
 243                let status = if thread.is_waiting_for_confirmation() {
 244                    AgentThreadStatus::WaitingForConfirmation
 245                } else if thread.had_error() {
 246                    AgentThreadStatus::Error
 247                } else {
 248                    match thread.status() {
 249                        ThreadStatus::Generating => AgentThreadStatus::Running,
 250                        ThreadStatus::Idle => AgentThreadStatus::Completed,
 251                    }
 252                };
 253
 254                ActiveThreadInfo {
 255                    session_id,
 256                    title,
 257                    status,
 258                    icon,
 259                }
 260            })
 261            .collect()
 262    }
 263
 264    fn update_entries(&mut self, window: &mut Window, cx: &mut Context<Self>) {
 265        let multi_workspace = self.multi_workspace.clone();
 266        cx.defer_in(window, move |this, window, cx| {
 267            if !this.multi_workspace.read(cx).multi_workspace_enabled(cx) {
 268                return;
 269            }
 270
 271            this._project_subscriptions = this.subscribe_to_projects(window, cx);
 272            this._agent_panel_subscriptions = this.subscribe_to_agent_panels(window, cx);
 273            this.subscribe_to_thread_store(window, cx);
 274
 275            let (workspaces, active_workspace_index) = {
 276                let mw = multi_workspace.read(cx);
 277                (mw.workspaces().to_vec(), mw.active_workspace_index())
 278            };
 279
 280            let thread_store = ThreadStore::try_global(cx);
 281
 282            let had_notifications = !this.notified_workspaces.is_empty();
 283
 284            let old_statuses: HashMap<(usize, acp::SessionId), AgentThreadStatus> = this
 285                .entries
 286                .iter()
 287                .filter_map(|entry| match entry {
 288                    ListEntry::Thread {
 289                        workspace_index: Some(index),
 290                        session_id,
 291                        status,
 292                        ..
 293                    } => Some(((*index, session_id.clone()), *status)),
 294                    _ => None,
 295                })
 296                .collect();
 297
 298            this.entries.clear();
 299
 300            for (index, workspace) in workspaces.iter().enumerate() {
 301                let (path_list, label) =
 302                    Self::workspace_path_list_and_label(workspace, cx);
 303
 304                this.entries.push(ListEntry::ProjectHeader {
 305                    path_list: path_list.clone(),
 306                    label,
 307                });
 308
 309                if this.collapsed_groups.contains(&path_list) {
 310                    continue;
 311                }
 312
 313                let mut threads: Vec<ListEntry> = Vec::new();
 314
 315                if let Some(ref thread_store) = thread_store {
 316                    for meta in thread_store.read(cx).threads_for_paths(&path_list) {
 317                        threads.push(ListEntry::Thread {
 318                            session_id: meta.id.clone(),
 319                            title: meta.title.clone(),
 320                            icon: IconName::ZedAgent,
 321                            status: AgentThreadStatus::default(),
 322                            updated_at: meta.updated_at,
 323                            diff_stats: None,
 324                            workspace_index: None,
 325                        });
 326                    }
 327                }
 328
 329                let live_infos = Self::all_thread_infos_for_workspace(workspace, cx);
 330
 331                for info in &live_infos {
 332                    let existing = threads.iter_mut().find(|t| {
 333                        matches!(t, ListEntry::Thread { session_id, .. } if session_id == &info.session_id)
 334                    });
 335
 336                    if let Some(existing) = existing {
 337                        if let ListEntry::Thread {
 338                            status,
 339                            icon,
 340                            workspace_index,
 341                            title,
 342                            ..
 343                        } = existing
 344                        {
 345                            *status = info.status;
 346                            *icon = info.icon;
 347                            *workspace_index = Some(index);
 348                            *title = info.title.clone();
 349                        }
 350                    } else {
 351                        threads.push(ListEntry::Thread {
 352                            session_id: info.session_id.clone(),
 353                            title: info.title.clone(),
 354                            icon: info.icon,
 355                            status: info.status,
 356                            updated_at: Utc::now(),
 357                            diff_stats: None,
 358                            workspace_index: Some(index),
 359                        });
 360                    }
 361                }
 362
 363                // Detect Running → Completed transitions on background workspaces.
 364                for thread in &threads {
 365                    if let ListEntry::Thread {
 366                        workspace_index: Some(workspace_idx),
 367                        session_id,
 368                        status,
 369                        ..
 370                    } = thread
 371                    {
 372                        let key = (*workspace_idx, session_id.clone());
 373                        if *status == AgentThreadStatus::Completed
 374                            && *workspace_idx != active_workspace_index
 375                            && old_statuses.get(&key) == Some(&AgentThreadStatus::Running)
 376                        {
 377                            this.notified_workspaces.insert(*workspace_idx);
 378                        }
 379                    }
 380                }
 381
 382                threads.sort_by(|a, b| {
 383                    let a_time = match a {
 384                        ListEntry::Thread { updated_at, .. } => updated_at,
 385                        _ => unreachable!(),
 386                    };
 387                    let b_time = match b {
 388                        ListEntry::Thread { updated_at, .. } => updated_at,
 389                        _ => unreachable!(),
 390                    };
 391                    b_time.cmp(a_time)
 392                });
 393
 394                let total = threads.len();
 395                let show_view_more =
 396                    total > DEFAULT_THREADS_SHOWN && !this.expanded_groups.contains(&path_list);
 397
 398                let count = if show_view_more {
 399                    DEFAULT_THREADS_SHOWN
 400                } else {
 401                    total
 402                };
 403
 404                this.entries.extend(threads.into_iter().take(count));
 405
 406                if show_view_more {
 407                    this.entries.push(ListEntry::ViewMore {
 408                        path_list: path_list.clone(),
 409                        remaining_count: total - DEFAULT_THREADS_SHOWN,
 410                    });
 411                }
 412            }
 413
 414            this.notified_workspaces.remove(&active_workspace_index);
 415
 416            this.list_state.reset(this.entries.len());
 417
 418            if let Some(selection) = this.selection {
 419                if selection >= this.entries.len() {
 420                    this.selection = this.entries.len().checked_sub(1);
 421                }
 422            }
 423
 424            let has_notifications = !this.notified_workspaces.is_empty();
 425            if had_notifications != has_notifications {
 426                multi_workspace.update(cx, |_, cx| cx.notify());
 427            }
 428
 429            cx.notify();
 430        });
 431    }
 432
 433    fn render_list_entry(
 434        &mut self,
 435        ix: usize,
 436        _window: &mut Window,
 437        cx: &mut Context<Self>,
 438    ) -> AnyElement {
 439        let Some(entry) = self.entries.get(ix) else {
 440            return div().into_any_element();
 441        };
 442        let is_selected = self.selection == Some(ix);
 443
 444        match entry {
 445            ListEntry::ProjectHeader { path_list, label } => {
 446                self.render_project_header(path_list, label, is_selected, cx)
 447            }
 448            ListEntry::Thread {
 449                session_id,
 450                title,
 451                icon,
 452                status,
 453                workspace_index,
 454                ..
 455            } => self.render_thread(
 456                ix,
 457                session_id,
 458                title,
 459                *icon,
 460                *status,
 461                *workspace_index,
 462                is_selected,
 463                cx,
 464            ),
 465            ListEntry::ViewMore {
 466                path_list,
 467                remaining_count,
 468            } => self.render_view_more(ix, path_list, *remaining_count, is_selected, cx),
 469        }
 470    }
 471
 472    fn render_project_header(
 473        &self,
 474        path_list: &PathList,
 475        label: &SharedString,
 476        is_selected: bool,
 477        cx: &mut Context<Self>,
 478    ) -> AnyElement {
 479        let is_collapsed = self.collapsed_groups.contains(path_list);
 480        let disclosure_icon = if is_collapsed {
 481            IconName::ChevronRight
 482        } else {
 483            IconName::ChevronDown
 484        };
 485        let path_list = path_list.clone();
 486
 487        h_flex()
 488            .id(SharedString::from(format!("project-header-{}", label)))
 489            .w_full()
 490            .px_2()
 491            .py_1()
 492            .gap_1()
 493            .hover(|style| style.bg(cx.theme().colors().ghost_element_hover))
 494            .active(|style| style.bg(cx.theme().colors().ghost_element_active))
 495            .when(is_selected, |this| {
 496                this.bg(cx.theme().colors().ghost_element_selected)
 497            })
 498            .rounded_md()
 499            .child(
 500                Icon::new(disclosure_icon)
 501                    .size(IconSize::Small)
 502                    .color(Color::Muted),
 503            )
 504            .child(
 505                Label::new(label.clone())
 506                    .size(LabelSize::Small)
 507                    .color(Color::Muted),
 508            )
 509            .cursor_pointer()
 510            .on_click(cx.listener(move |this, _, window, cx| {
 511                this.toggle_collapse(&path_list, window, cx);
 512            }))
 513            .into_any_element()
 514    }
 515
 516    fn toggle_collapse(
 517        &mut self,
 518        path_list: &PathList,
 519        window: &mut Window,
 520        cx: &mut Context<Self>,
 521    ) {
 522        if self.collapsed_groups.contains(path_list) {
 523            self.collapsed_groups.remove(path_list);
 524        } else {
 525            self.collapsed_groups.insert(path_list.clone());
 526        }
 527        self.update_entries(window, cx);
 528    }
 529
 530    fn focus_in(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
 531        if self.selection.is_none() && !self.entries.is_empty() {
 532            self.selection = Some(0);
 533            cx.notify();
 534        }
 535    }
 536
 537    fn select_next(&mut self, _: &SelectNext, _window: &mut Window, cx: &mut Context<Self>) {
 538        let next = match self.selection {
 539            Some(ix) if ix + 1 < self.entries.len() => ix + 1,
 540            None if !self.entries.is_empty() => 0,
 541            _ => return,
 542        };
 543        self.selection = Some(next);
 544        self.list_state.scroll_to_reveal_item(next);
 545        cx.notify();
 546    }
 547
 548    fn select_previous(
 549        &mut self,
 550        _: &SelectPrevious,
 551        _window: &mut Window,
 552        cx: &mut Context<Self>,
 553    ) {
 554        let prev = match self.selection {
 555            Some(ix) if ix > 0 => ix - 1,
 556            None if !self.entries.is_empty() => self.entries.len() - 1,
 557            _ => return,
 558        };
 559        self.selection = Some(prev);
 560        self.list_state.scroll_to_reveal_item(prev);
 561        cx.notify();
 562    }
 563
 564    fn select_first(&mut self, _: &SelectFirst, _window: &mut Window, cx: &mut Context<Self>) {
 565        if !self.entries.is_empty() {
 566            self.selection = Some(0);
 567            self.list_state.scroll_to_reveal_item(0);
 568            cx.notify();
 569        }
 570    }
 571
 572    fn select_last(&mut self, _: &SelectLast, _window: &mut Window, cx: &mut Context<Self>) {
 573        if let Some(last) = self.entries.len().checked_sub(1) {
 574            self.selection = Some(last);
 575            self.list_state.scroll_to_reveal_item(last);
 576            cx.notify();
 577        }
 578    }
 579
 580    fn confirm(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context<Self>) {
 581        let Some(ix) = self.selection else { return };
 582        let Some(entry) = self.entries.get(ix) else {
 583            return;
 584        };
 585
 586        match entry {
 587            ListEntry::ProjectHeader { path_list, .. } => {
 588                let path_list = path_list.clone();
 589                self.toggle_collapse(&path_list, window, cx);
 590            }
 591            ListEntry::Thread {
 592                session_id,
 593                workspace_index,
 594                ..
 595            } => {
 596                let session_id = session_id.clone();
 597                let workspace_index = *workspace_index;
 598                self.activate_thread(&session_id, workspace_index, window, cx);
 599            }
 600            ListEntry::ViewMore { path_list, .. } => {
 601                let path_list = path_list.clone();
 602                self.expanded_groups.insert(path_list);
 603                self.update_entries(window, cx);
 604            }
 605        }
 606    }
 607
 608    fn activate_thread(
 609        &mut self,
 610        session_id: &acp::SessionId,
 611        workspace_index: Option<usize>,
 612        window: &mut Window,
 613        cx: &mut Context<Self>,
 614    ) {
 615        let Some(target_index) = workspace_index else {
 616            return;
 617        };
 618        let multi_workspace = self.multi_workspace.clone();
 619        let session_id = session_id.clone();
 620
 621        multi_workspace.update(cx, |multi_workspace, cx| {
 622            multi_workspace.activate_index(target_index, window, cx);
 623        });
 624        let workspaces = multi_workspace.read(cx).workspaces().to_vec();
 625        if let Some(workspace) = workspaces.get(target_index) {
 626            if let Some(agent_panel) = workspace.read(cx).panel::<AgentPanel>(cx) {
 627                agent_panel.update(cx, |panel, cx| {
 628                    panel.load_agent_thread(
 629                        acp_thread::AgentSessionInfo {
 630                            session_id,
 631                            cwd: None,
 632                            title: None,
 633                            updated_at: None,
 634                            meta: None,
 635                        },
 636                        window,
 637                        cx,
 638                    );
 639                });
 640            }
 641        }
 642    }
 643
 644    fn expand_selected_entry(
 645        &mut self,
 646        _: &ExpandSelectedEntry,
 647        window: &mut Window,
 648        cx: &mut Context<Self>,
 649    ) {
 650        let Some(ix) = self.selection else { return };
 651
 652        match self.entries.get(ix) {
 653            Some(ListEntry::ProjectHeader { path_list, .. }) => {
 654                if self.collapsed_groups.contains(path_list) {
 655                    let path_list = path_list.clone();
 656                    self.collapsed_groups.remove(&path_list);
 657                    self.update_entries(window, cx);
 658                } else if ix + 1 < self.entries.len() {
 659                    self.selection = Some(ix + 1);
 660                    self.list_state.scroll_to_reveal_item(ix + 1);
 661                    cx.notify();
 662                }
 663            }
 664            _ => {}
 665        }
 666    }
 667
 668    fn collapse_selected_entry(
 669        &mut self,
 670        _: &CollapseSelectedEntry,
 671        window: &mut Window,
 672        cx: &mut Context<Self>,
 673    ) {
 674        let Some(ix) = self.selection else { return };
 675
 676        match self.entries.get(ix) {
 677            Some(ListEntry::ProjectHeader { path_list, .. }) => {
 678                if !self.collapsed_groups.contains(path_list) {
 679                    let path_list = path_list.clone();
 680                    self.collapsed_groups.insert(path_list);
 681                    self.update_entries(window, cx);
 682                }
 683            }
 684            Some(ListEntry::Thread { .. } | ListEntry::ViewMore { .. }) => {
 685                for i in (0..ix).rev() {
 686                    if let Some(ListEntry::ProjectHeader { path_list, .. }) =
 687                        self.entries.get(i)
 688                    {
 689                        let path_list = path_list.clone();
 690                        self.selection = Some(i);
 691                        self.collapsed_groups.insert(path_list);
 692                        self.update_entries(window, cx);
 693                        break;
 694                    }
 695                }
 696            }
 697            None => {}
 698        }
 699    }
 700
 701    fn render_thread(
 702        &self,
 703        ix: usize,
 704        session_id: &acp::SessionId,
 705        title: &SharedString,
 706        icon: IconName,
 707        status: AgentThreadStatus,
 708        workspace_index: Option<usize>,
 709        is_selected: bool,
 710        cx: &mut Context<Self>,
 711    ) -> AnyElement {
 712        let running = matches!(
 713            status,
 714            AgentThreadStatus::Running | AgentThreadStatus::WaitingForConfirmation
 715        );
 716
 717        let has_notification = workspace_index
 718            .map(|idx| self.notified_workspaces.contains(&idx))
 719            .unwrap_or(false);
 720
 721        let is_active = workspace_index.is_some();
 722
 723        let session_id = session_id.clone();
 724
 725        h_flex()
 726            .id(SharedString::from(format!("thread-entry-{}", ix)))
 727            .w_full()
 728            .px_2()
 729            .py_1()
 730            .gap_2()
 731            .hover(|style| style.bg(cx.theme().colors().ghost_element_hover))
 732            .active(|style| style.bg(cx.theme().colors().ghost_element_active))
 733            .when(is_selected, |this| {
 734                this.bg(cx.theme().colors().ghost_element_selected)
 735            })
 736            .rounded_md()
 737            .cursor_pointer()
 738            .child(Icon::new(icon).size(IconSize::Small).color(if running {
 739                Color::Accent
 740            } else {
 741                Color::Muted
 742            }))
 743            .child(
 744                div().flex_1().overflow_hidden().child(
 745                    Label::new(title.clone())
 746                        .size(LabelSize::Small)
 747                        .single_line()
 748                        .color(if is_active {
 749                            Color::Default
 750                        } else {
 751                            Color::Muted
 752                        }),
 753                ),
 754            )
 755            .when(running, |this| {
 756                this.child(
 757                    Label::new("Running")
 758                        .size(LabelSize::XSmall)
 759                        .color(Color::Accent),
 760                )
 761            })
 762            .when(has_notification, |this| {
 763                this.child(div().size_2().rounded_full().bg(cx.theme().status().info))
 764            })
 765            .on_click(cx.listener(move |this, _, window, cx| {
 766                this.activate_thread(&session_id, workspace_index, window, cx);
 767            }))
 768            .into_any_element()
 769    }
 770
 771    fn render_view_more(
 772        &self,
 773        ix: usize,
 774        path_list: &PathList,
 775        remaining_count: usize,
 776        is_selected: bool,
 777        cx: &mut Context<Self>,
 778    ) -> AnyElement {
 779        let path_list = path_list.clone();
 780
 781        h_flex()
 782            .id(SharedString::from(format!("view-more-{}", ix)))
 783            .w_full()
 784            .px_2()
 785            .py_1()
 786            .hover(|style| style.bg(cx.theme().colors().ghost_element_hover))
 787            .active(|style| style.bg(cx.theme().colors().ghost_element_active))
 788            .when(is_selected, |this| {
 789                this.bg(cx.theme().colors().ghost_element_selected)
 790            })
 791            .rounded_md()
 792            .cursor_pointer()
 793            .child(
 794                Label::new(format!("+ View More ({})", remaining_count))
 795                    .size(LabelSize::Small)
 796                    .color(Color::Accent),
 797            )
 798            .on_click(cx.listener(move |this, _, window, cx| {
 799                this.expanded_groups.insert(path_list.clone());
 800                this.update_entries(window, cx);
 801            }))
 802            .into_any_element()
 803    }
 804}
 805
 806impl WorkspaceSidebar for Sidebar {
 807    fn width(&self, _cx: &App) -> Pixels {
 808        self.width
 809    }
 810
 811    fn set_width(&mut self, width: Option<Pixels>, cx: &mut Context<Self>) {
 812        self.width = width.unwrap_or(DEFAULT_WIDTH).clamp(MIN_WIDTH, MAX_WIDTH);
 813        cx.notify();
 814    }
 815
 816    fn has_notifications(&self, _cx: &App) -> bool {
 817        !self.notified_workspaces.is_empty()
 818    }
 819}
 820
 821impl Focusable for Sidebar {
 822    fn focus_handle(&self, _cx: &App) -> FocusHandle {
 823        self.focus_handle.clone()
 824    }
 825}
 826
 827impl Render for Sidebar {
 828    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
 829        let titlebar_height = ui::utils::platform_title_bar_height(window);
 830        let ui_font = theme::setup_ui_font(window, cx);
 831        let is_focused = self.focus_handle.is_focused(window);
 832
 833        let focus_tooltip_label = if is_focused {
 834            "Focus Workspace"
 835        } else {
 836            "Focus Sidebar"
 837        };
 838
 839        v_flex()
 840            .id("workspace-sidebar")
 841            .key_context("WorkspaceSidebar")
 842            .track_focus(&self.focus_handle)
 843            .on_action(cx.listener(Self::select_next))
 844            .on_action(cx.listener(Self::select_previous))
 845            .on_action(cx.listener(Self::select_first))
 846            .on_action(cx.listener(Self::select_last))
 847            .on_action(cx.listener(Self::confirm))
 848            .on_action(cx.listener(Self::expand_selected_entry))
 849            .on_action(cx.listener(Self::collapse_selected_entry))
 850            .font(ui_font)
 851            .h_full()
 852            .w(self.width)
 853            .bg(cx.theme().colors().surface_background)
 854            .border_r_1()
 855            .border_color(cx.theme().colors().border)
 856            .child(
 857                h_flex()
 858                    .flex_none()
 859                    .h(titlebar_height)
 860                    .w_full()
 861                    .mt_px()
 862                    .pb_px()
 863                    .pr_1()
 864                    .when_else(
 865                        cfg!(target_os = "macos") && !window.is_fullscreen(),
 866                        |this| this.pl(px(TRAFFIC_LIGHT_PADDING)),
 867                        |this| this.pl_2(),
 868                    )
 869                    .justify_between()
 870                    .border_b_1()
 871                    .border_color(cx.theme().colors().border)
 872                    .child({
 873                        let focus_handle_toggle = self.focus_handle.clone();
 874                        let focus_handle_focus = self.focus_handle.clone();
 875                        IconButton::new("close-sidebar", IconName::WorkspaceNavOpen)
 876                            .icon_size(IconSize::Small)
 877                            .tooltip(Tooltip::element(move |_, cx| {
 878                                v_flex()
 879                                    .gap_1()
 880                                    .child(
 881                                        h_flex()
 882                                            .gap_2()
 883                                            .justify_between()
 884                                            .child(Label::new("Close Sidebar"))
 885                                            .child(KeyBinding::for_action_in(
 886                                                &ToggleWorkspaceSidebar,
 887                                                &focus_handle_toggle,
 888                                                cx,
 889                                            )),
 890                                    )
 891                                    .child(
 892                                        h_flex()
 893                                            .pt_1()
 894                                            .gap_2()
 895                                            .border_t_1()
 896                                            .border_color(cx.theme().colors().border_variant)
 897                                            .justify_between()
 898                                            .child(Label::new(focus_tooltip_label))
 899                                            .child(KeyBinding::for_action_in(
 900                                                &FocusWorkspaceSidebar,
 901                                                &focus_handle_focus,
 902                                                cx,
 903                                            )),
 904                                    )
 905                                    .into_any_element()
 906                            }))
 907                            .on_click(cx.listener(|_this, _, _window, cx| {
 908                                cx.emit(SidebarEvent::Close);
 909                            }))
 910                    })
 911                    .child(
 912                        IconButton::new("new-workspace", IconName::Plus)
 913                            .icon_size(IconSize::Small)
 914                            .tooltip(|_window, cx| {
 915                                Tooltip::for_action("New Workspace", &NewWorkspaceInWindow, cx)
 916                            })
 917                            .on_click(cx.listener(|this, _, window, cx| {
 918                                this.multi_workspace.update(cx, |multi_workspace, cx| {
 919                                    multi_workspace.create_workspace(window, cx);
 920                                });
 921                            })),
 922                    ),
 923            )
 924            .child(
 925                div().flex_1().overflow_hidden().child(
 926                    list(
 927                        self.list_state.clone(),
 928                        cx.processor(Self::render_list_entry),
 929                    )
 930                    .size_full(),
 931                ),
 932            )
 933    }
 934}
 935
 936#[cfg(test)]
 937mod tests {
 938    use super::*;
 939    use agent::ThreadStore;
 940    use feature_flags::FeatureFlagAppExt as _;
 941    use fs::FakeFs;
 942    use gpui::TestAppContext;
 943    use settings::SettingsStore;
 944    use std::sync::Arc;
 945    use util::path_list::PathList;
 946
 947    fn init_test(cx: &mut TestAppContext) {
 948        cx.update(|cx| {
 949            let settings_store = SettingsStore::test(cx);
 950            cx.set_global(settings_store);
 951            theme::init(theme::LoadThemes::JustBase, cx);
 952            editor::init(cx);
 953            cx.update_flags(false, vec!["agent-v2".into()]);
 954            ThreadStore::init_global(cx);
 955        });
 956    }
 957
 958    fn make_test_thread(title: &str, updated_at: DateTime<Utc>) -> agent::DbThread {
 959        agent::DbThread {
 960            title: title.to_string().into(),
 961            messages: Vec::new(),
 962            updated_at,
 963            detailed_summary: None,
 964            initial_project_snapshot: None,
 965            cumulative_token_usage: Default::default(),
 966            request_token_usage: Default::default(),
 967            model: None,
 968            profile: None,
 969            imported: false,
 970            subagent_context: None,
 971            speed: None,
 972            thinking_enabled: false,
 973            thinking_effort: None,
 974        }
 975    }
 976
 977    async fn init_test_project(
 978        worktree_path: &str,
 979        cx: &mut TestAppContext,
 980    ) -> Entity<project::Project> {
 981        init_test(cx);
 982        let fs = FakeFs::new(cx.executor());
 983        fs.insert_tree(worktree_path, serde_json::json!({ "src": {} }))
 984            .await;
 985        cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
 986        project::Project::test(fs, [worktree_path.as_ref()], cx).await
 987    }
 988
 989    fn setup_sidebar(
 990        multi_workspace: &Entity<MultiWorkspace>,
 991        cx: &mut gpui::VisualTestContext,
 992    ) -> Entity<Sidebar> {
 993        let sidebar = multi_workspace.update_in(cx, |_mw, window, cx| {
 994            let mw_handle = cx.entity();
 995            cx.new(|cx| Sidebar::new(mw_handle, window, cx))
 996        });
 997        multi_workspace.update_in(cx, |mw, window, cx| {
 998            mw.register_sidebar(sidebar.clone(), window, cx);
 999        });
1000        cx.run_until_parked();
1001        sidebar
1002    }
1003
1004    fn visible_entries_as_strings(
1005        sidebar: &Entity<Sidebar>,
1006        cx: &mut gpui::VisualTestContext,
1007    ) -> Vec<String> {
1008        sidebar.read_with(cx, |sidebar, _cx| {
1009            sidebar
1010                .entries
1011                .iter()
1012                .enumerate()
1013                .map(|(ix, entry)| {
1014                    let selected = if sidebar.selection == Some(ix) {
1015                        "  <== selected"
1016                    } else {
1017                        ""
1018                    };
1019                    match entry {
1020                        ListEntry::ProjectHeader {
1021                            label, path_list, ..
1022                        } => {
1023                            let icon = if sidebar.collapsed_groups.contains(path_list) {
1024                                ">"
1025                            } else {
1026                                "v"
1027                            };
1028                            format!("{} [{}]{}", icon, label, selected)
1029                        }
1030                        ListEntry::Thread {
1031                            title,
1032                            status,
1033                            workspace_index,
1034                            ..
1035                        } => {
1036                            let active = if workspace_index.is_some() { " *" } else { "" };
1037                            let status_str = match status {
1038                                AgentThreadStatus::Running => " (running)",
1039                                AgentThreadStatus::Error => " (error)",
1040                                AgentThreadStatus::WaitingForConfirmation => " (waiting)",
1041                                _ => "",
1042                            };
1043                            format!("  {}{}{}{}", title, active, status_str, selected)
1044                        }
1045                        ListEntry::ViewMore {
1046                            remaining_count, ..
1047                        } => {
1048                            format!("  + View More ({}){}", remaining_count, selected)
1049                        }
1050                    }
1051                })
1052                .collect()
1053        })
1054    }
1055
1056    #[gpui::test]
1057    async fn test_single_workspace_no_threads(cx: &mut TestAppContext) {
1058        let project = init_test_project("/my-project", cx).await;
1059        let (multi_workspace, cx) =
1060            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
1061        let sidebar = setup_sidebar(&multi_workspace, cx);
1062
1063        assert_eq!(
1064            visible_entries_as_strings(&sidebar, cx),
1065            vec!["v [my-project]"]
1066        );
1067    }
1068
1069    #[gpui::test]
1070    async fn test_single_workspace_with_saved_threads(cx: &mut TestAppContext) {
1071        let project = init_test_project("/my-project", cx).await;
1072        let (multi_workspace, cx) =
1073            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
1074        let sidebar = setup_sidebar(&multi_workspace, cx);
1075
1076        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
1077        let thread_store = cx.update(|_window, cx| ThreadStore::global(cx));
1078
1079        let save_task = thread_store.update(cx, |store, cx| {
1080            store.save_thread(
1081                acp::SessionId::new(Arc::from("thread-1")),
1082                make_test_thread(
1083                    "Fix crash in project panel",
1084                    chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 3, 0, 0, 0).unwrap(),
1085                ),
1086                path_list.clone(),
1087                cx,
1088            )
1089        });
1090        save_task.await.unwrap();
1091
1092        let save_task = thread_store.update(cx, |store, cx| {
1093            store.save_thread(
1094                acp::SessionId::new(Arc::from("thread-2")),
1095                make_test_thread(
1096                    "Add inline diff view",
1097                    chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
1098                ),
1099                path_list.clone(),
1100                cx,
1101            )
1102        });
1103        save_task.await.unwrap();
1104        cx.run_until_parked();
1105
1106        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
1107        cx.run_until_parked();
1108
1109        assert_eq!(
1110            visible_entries_as_strings(&sidebar, cx),
1111            vec![
1112                "v [my-project]",
1113                "  Fix crash in project panel",
1114                "  Add inline diff view",
1115            ]
1116        );
1117    }
1118
1119    #[gpui::test]
1120    async fn test_workspace_lifecycle(cx: &mut TestAppContext) {
1121        let project = init_test_project("/project-a", cx).await;
1122        let (multi_workspace, cx) =
1123            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
1124        let sidebar = setup_sidebar(&multi_workspace, cx);
1125
1126        // Single workspace with a thread
1127        let path_list = PathList::new(&[std::path::PathBuf::from("/project-a")]);
1128        let thread_store = cx.update(|_window, cx| ThreadStore::global(cx));
1129
1130        let save_task = thread_store.update(cx, |store, cx| {
1131            store.save_thread(
1132                acp::SessionId::new(Arc::from("thread-a1")),
1133                make_test_thread(
1134                    "Thread A1",
1135                    chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
1136                ),
1137                path_list.clone(),
1138                cx,
1139            )
1140        });
1141        save_task.await.unwrap();
1142        cx.run_until_parked();
1143
1144        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
1145        cx.run_until_parked();
1146
1147        assert_eq!(
1148            visible_entries_as_strings(&sidebar, cx),
1149            vec!["v [project-a]", "  Thread A1"]
1150        );
1151
1152        // Add a second workspace
1153        multi_workspace.update_in(cx, |mw, window, cx| {
1154            mw.create_workspace(window, cx);
1155        });
1156        cx.run_until_parked();
1157
1158        assert_eq!(
1159            visible_entries_as_strings(&sidebar, cx),
1160            vec!["v [project-a]", "  Thread A1", "v [Empty Workspace]"]
1161        );
1162
1163        // Remove the second workspace
1164        multi_workspace.update_in(cx, |mw, window, cx| {
1165            mw.remove_workspace(1, window, cx);
1166        });
1167        cx.run_until_parked();
1168
1169        assert_eq!(
1170            visible_entries_as_strings(&sidebar, cx),
1171            vec!["v [project-a]", "  Thread A1"]
1172        );
1173    }
1174
1175    #[gpui::test]
1176    async fn test_view_more_pagination(cx: &mut TestAppContext) {
1177        let project = init_test_project("/my-project", cx).await;
1178        let (multi_workspace, cx) =
1179            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
1180        let sidebar = setup_sidebar(&multi_workspace, cx);
1181
1182        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
1183        let thread_store = cx.update(|_window, cx| ThreadStore::global(cx));
1184
1185        for i in 0..12 {
1186            let save_task = thread_store.update(cx, |store, cx| {
1187                store.save_thread(
1188                    acp::SessionId::new(Arc::from(format!("thread-{}", i))),
1189                    make_test_thread(
1190                        &format!("Thread {}", i + 1),
1191                        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, i).unwrap(),
1192                    ),
1193                    path_list.clone(),
1194                    cx,
1195                )
1196            });
1197            save_task.await.unwrap();
1198        }
1199        cx.run_until_parked();
1200
1201        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
1202        cx.run_until_parked();
1203
1204        assert_eq!(
1205            visible_entries_as_strings(&sidebar, cx),
1206            vec![
1207                "v [my-project]",
1208                "  Thread 12",
1209                "  Thread 11",
1210                "  Thread 10",
1211                "  Thread 9",
1212                "  Thread 8",
1213                "  + View More (7)",
1214            ]
1215        );
1216    }
1217
1218    #[gpui::test]
1219    async fn test_collapse_and_expand_group(cx: &mut TestAppContext) {
1220        let project = init_test_project("/my-project", cx).await;
1221        let (multi_workspace, cx) =
1222            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
1223        let sidebar = setup_sidebar(&multi_workspace, cx);
1224
1225        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
1226        let thread_store = cx.update(|_window, cx| ThreadStore::global(cx));
1227
1228        let save_task = thread_store.update(cx, |store, cx| {
1229            store.save_thread(
1230                acp::SessionId::new(Arc::from("test-thread")),
1231                make_test_thread(
1232                    "Test Thread",
1233                    chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
1234                ),
1235                path_list.clone(),
1236                cx,
1237            )
1238        });
1239        save_task.await.unwrap();
1240        cx.run_until_parked();
1241
1242        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
1243        cx.run_until_parked();
1244
1245        assert_eq!(
1246            visible_entries_as_strings(&sidebar, cx),
1247            vec!["v [my-project]", "  Test Thread"]
1248        );
1249
1250        // Collapse
1251        sidebar.update_in(cx, |s, window, cx| {
1252            s.toggle_collapse(&path_list, window, cx);
1253        });
1254        cx.run_until_parked();
1255
1256        assert_eq!(
1257            visible_entries_as_strings(&sidebar, cx),
1258            vec!["> [my-project]"]
1259        );
1260
1261        // Expand
1262        sidebar.update_in(cx, |s, window, cx| {
1263            s.toggle_collapse(&path_list, window, cx);
1264        });
1265        cx.run_until_parked();
1266
1267        assert_eq!(
1268            visible_entries_as_strings(&sidebar, cx),
1269            vec!["v [my-project]", "  Test Thread"]
1270        );
1271    }
1272
1273    #[gpui::test]
1274    async fn test_visible_entries_as_strings(cx: &mut TestAppContext) {
1275        let project = init_test_project("/my-project", cx).await;
1276        let (multi_workspace, cx) =
1277            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
1278        let sidebar = setup_sidebar(&multi_workspace, cx);
1279
1280        let expanded_path = PathList::new(&[std::path::PathBuf::from("/expanded")]);
1281        let collapsed_path = PathList::new(&[std::path::PathBuf::from("/collapsed")]);
1282
1283        sidebar.update_in(cx, |s, _window, _cx| {
1284            s.collapsed_groups.insert(collapsed_path.clone());
1285            s.entries = vec![
1286                // Expanded project header
1287                ListEntry::ProjectHeader {
1288                    path_list: expanded_path.clone(),
1289                    label: "expanded-project".into(),
1290                },
1291                // Thread with default (Completed) status, not active
1292                ListEntry::Thread {
1293                    session_id: acp::SessionId::new(Arc::from("t-1")),
1294                    title: "Completed thread".into(),
1295                    icon: IconName::ZedAgent,
1296                    status: AgentThreadStatus::Completed,
1297                    updated_at: Utc::now(),
1298                    diff_stats: None,
1299                    workspace_index: None,
1300                },
1301                // Active thread with Running status
1302                ListEntry::Thread {
1303                    session_id: acp::SessionId::new(Arc::from("t-2")),
1304                    title: "Running thread".into(),
1305                    icon: IconName::ZedAgent,
1306                    status: AgentThreadStatus::Running,
1307                    updated_at: Utc::now(),
1308                    diff_stats: None,
1309                    workspace_index: Some(0),
1310                },
1311                // Active thread with Error status
1312                ListEntry::Thread {
1313                    session_id: acp::SessionId::new(Arc::from("t-3")),
1314                    title: "Error thread".into(),
1315                    icon: IconName::ZedAgent,
1316                    status: AgentThreadStatus::Error,
1317                    updated_at: Utc::now(),
1318                    diff_stats: None,
1319                    workspace_index: Some(1),
1320                },
1321                // Thread with WaitingForConfirmation status, not active
1322                ListEntry::Thread {
1323                    session_id: acp::SessionId::new(Arc::from("t-4")),
1324                    title: "Waiting thread".into(),
1325                    icon: IconName::ZedAgent,
1326                    status: AgentThreadStatus::WaitingForConfirmation,
1327                    updated_at: Utc::now(),
1328                    diff_stats: None,
1329                    workspace_index: None,
1330                },
1331                // View More entry
1332                ListEntry::ViewMore {
1333                    path_list: expanded_path.clone(),
1334                    remaining_count: 42,
1335                },
1336                // Collapsed project header
1337                ListEntry::ProjectHeader {
1338                    path_list: collapsed_path.clone(),
1339                    label: "collapsed-project".into(),
1340                },
1341            ];
1342            // Select the Running thread (index 2)
1343            s.selection = Some(2);
1344        });
1345
1346        assert_eq!(
1347            visible_entries_as_strings(&sidebar, cx),
1348            vec![
1349                "v [expanded-project]",
1350                "  Completed thread",
1351                "  Running thread * (running)  <== selected",
1352                "  Error thread * (error)",
1353                "  Waiting thread (waiting)",
1354                "  + View More (42)",
1355                "> [collapsed-project]",
1356            ]
1357        );
1358
1359        // Move selection to the collapsed header
1360        sidebar.update_in(cx, |s, _window, _cx| {
1361            s.selection = Some(6);
1362        });
1363
1364        assert_eq!(
1365            visible_entries_as_strings(&sidebar, cx).last().cloned(),
1366            Some("> [collapsed-project]  <== selected".to_string()),
1367        );
1368
1369        // Clear selection
1370        sidebar.update_in(cx, |s, _window, _cx| {
1371            s.selection = None;
1372        });
1373
1374        // No entry should have the selected marker
1375        let entries = visible_entries_as_strings(&sidebar, cx);
1376        for entry in &entries {
1377            assert!(
1378                !entry.contains("<== selected"),
1379                "unexpected selection marker in: {}",
1380                entry
1381            );
1382        }
1383    }
1384
1385    #[gpui::test]
1386    async fn test_keyboard_select_next_and_previous(cx: &mut TestAppContext) {
1387        let project = init_test_project("/my-project", cx).await;
1388        let (multi_workspace, cx) =
1389            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
1390        let sidebar = setup_sidebar(&multi_workspace, cx);
1391
1392        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
1393        let thread_store = cx.update(|_window, cx| ThreadStore::global(cx));
1394
1395        for i in 0..3 {
1396            let save_task = thread_store.update(cx, |store, cx| {
1397                store.save_thread(
1398                    acp::SessionId::new(Arc::from(format!("thread-{}", i))),
1399                    make_test_thread(
1400                        &format!("Thread {}", i + 1),
1401                        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, i).unwrap(),
1402                    ),
1403                    path_list.clone(),
1404                    cx,
1405                )
1406            });
1407            save_task.await.unwrap();
1408        }
1409        cx.run_until_parked();
1410
1411        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
1412        cx.run_until_parked();
1413
1414        // Entries: [header, thread3, thread2, thread1]
1415        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None);
1416
1417        // SelectNext from None selects the first entry
1418        sidebar.update_in(cx, |s, window, cx| {
1419            s.select_next(&SelectNext, window, cx);
1420        });
1421        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
1422
1423        // Move down through all entries
1424        sidebar.update_in(cx, |s, window, cx| {
1425            s.select_next(&SelectNext, window, cx);
1426        });
1427        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
1428
1429        sidebar.update_in(cx, |s, window, cx| {
1430            s.select_next(&SelectNext, window, cx);
1431        });
1432        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(2));
1433
1434        sidebar.update_in(cx, |s, window, cx| {
1435            s.select_next(&SelectNext, window, cx);
1436        });
1437        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(3));
1438
1439        // At the end, selection stays on the last entry
1440        sidebar.update_in(cx, |s, window, cx| {
1441            s.select_next(&SelectNext, window, cx);
1442        });
1443        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(3));
1444
1445        // Move back up
1446        sidebar.update_in(cx, |s, window, cx| {
1447            s.select_previous(&SelectPrevious, window, cx);
1448        });
1449        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(2));
1450
1451        sidebar.update_in(cx, |s, window, cx| {
1452            s.select_previous(&SelectPrevious, window, cx);
1453        });
1454        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
1455
1456        sidebar.update_in(cx, |s, window, cx| {
1457            s.select_previous(&SelectPrevious, window, cx);
1458        });
1459        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
1460
1461        // At the top, selection stays on the first entry
1462        sidebar.update_in(cx, |s, window, cx| {
1463            s.select_previous(&SelectPrevious, window, cx);
1464        });
1465        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
1466    }
1467
1468    #[gpui::test]
1469    async fn test_keyboard_select_first_and_last(cx: &mut TestAppContext) {
1470        let project = init_test_project("/my-project", cx).await;
1471        let (multi_workspace, cx) =
1472            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
1473        let sidebar = setup_sidebar(&multi_workspace, cx);
1474
1475        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
1476        let thread_store = cx.update(|_window, cx| ThreadStore::global(cx));
1477
1478        for i in 0..3 {
1479            let save_task = thread_store.update(cx, |store, cx| {
1480                store.save_thread(
1481                    acp::SessionId::new(Arc::from(format!("thread-{}", i))),
1482                    make_test_thread(
1483                        &format!("Thread {}", i + 1),
1484                        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, i).unwrap(),
1485                    ),
1486                    path_list.clone(),
1487                    cx,
1488                )
1489            });
1490            save_task.await.unwrap();
1491        }
1492        cx.run_until_parked();
1493        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
1494        cx.run_until_parked();
1495
1496        // SelectLast jumps to the end
1497        sidebar.update_in(cx, |s, window, cx| {
1498            s.select_last(&SelectLast, window, cx);
1499        });
1500        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(3));
1501
1502        // SelectFirst jumps to the beginning
1503        sidebar.update_in(cx, |s, window, cx| {
1504            s.select_first(&SelectFirst, window, cx);
1505        });
1506        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
1507    }
1508
1509    #[gpui::test]
1510    async fn test_keyboard_focus_in_selects_first(cx: &mut TestAppContext) {
1511        let project = init_test_project("/my-project", cx).await;
1512        let (multi_workspace, cx) =
1513            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
1514        let sidebar = setup_sidebar(&multi_workspace, cx);
1515
1516        // Initially no selection
1517        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None);
1518
1519        // Simulate focus_in
1520        sidebar.update_in(cx, |s, window, cx| {
1521            s.focus_in(window, cx);
1522        });
1523        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
1524
1525        // Calling focus_in again preserves existing selection
1526        sidebar.update_in(cx, |s, window, cx| {
1527            s.selection = Some(0);
1528            s.select_next(&SelectNext, window, cx);
1529        });
1530        cx.run_until_parked();
1531
1532        let selection_before = sidebar.read_with(cx, |s, _| s.selection);
1533        sidebar.update_in(cx, |s, window, cx| {
1534            s.focus_in(window, cx);
1535        });
1536        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), selection_before);
1537    }
1538
1539    #[gpui::test]
1540    async fn test_keyboard_confirm_on_project_header_toggles_collapse(cx: &mut TestAppContext) {
1541        let project = init_test_project("/my-project", cx).await;
1542        let (multi_workspace, cx) =
1543            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
1544        let sidebar = setup_sidebar(&multi_workspace, cx);
1545
1546        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
1547        let thread_store = cx.update(|_window, cx| ThreadStore::global(cx));
1548
1549        let save_task = thread_store.update(cx, |store, cx| {
1550            store.save_thread(
1551                acp::SessionId::new(Arc::from("thread-1")),
1552                make_test_thread(
1553                    "My Thread",
1554                    chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
1555                ),
1556                path_list.clone(),
1557                cx,
1558            )
1559        });
1560        save_task.await.unwrap();
1561        cx.run_until_parked();
1562        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
1563        cx.run_until_parked();
1564
1565        assert_eq!(
1566            visible_entries_as_strings(&sidebar, cx),
1567            vec!["v [my-project]", "  My Thread"]
1568        );
1569
1570        // Select the header and press confirm to collapse
1571        sidebar.update_in(cx, |s, window, cx| {
1572            s.selection = Some(0);
1573            s.confirm(&Confirm, window, cx);
1574        });
1575        cx.run_until_parked();
1576
1577        assert_eq!(
1578            visible_entries_as_strings(&sidebar, cx),
1579            vec!["> [my-project]  <== selected"]
1580        );
1581
1582        // Confirm again to expand
1583        sidebar.update_in(cx, |s, window, cx| {
1584            s.confirm(&Confirm, window, cx);
1585        });
1586        cx.run_until_parked();
1587
1588        assert_eq!(
1589            visible_entries_as_strings(&sidebar, cx),
1590            vec!["v [my-project]  <== selected", "  My Thread"]
1591        );
1592    }
1593
1594    #[gpui::test]
1595    async fn test_keyboard_confirm_on_view_more_expands(cx: &mut TestAppContext) {
1596        let project = init_test_project("/my-project", cx).await;
1597        let (multi_workspace, cx) =
1598            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
1599        let sidebar = setup_sidebar(&multi_workspace, cx);
1600
1601        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
1602        let thread_store = cx.update(|_window, cx| ThreadStore::global(cx));
1603
1604        for i in 0..8 {
1605            let save_task = thread_store.update(cx, |store, cx| {
1606                store.save_thread(
1607                    acp::SessionId::new(Arc::from(format!("thread-{}", i))),
1608                    make_test_thread(
1609                        &format!("Thread {}", i + 1),
1610                        chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, i).unwrap(),
1611                    ),
1612                    path_list.clone(),
1613                    cx,
1614                )
1615            });
1616            save_task.await.unwrap();
1617        }
1618        cx.run_until_parked();
1619        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
1620        cx.run_until_parked();
1621
1622        // Should show header + 5 threads + "View More (3)"
1623        let entries = visible_entries_as_strings(&sidebar, cx);
1624        assert_eq!(entries.len(), 7);
1625        assert!(entries.last().unwrap().contains("View More (3)"));
1626
1627        // Select the "View More" entry and confirm
1628        sidebar.update_in(cx, |s, _window, _cx| {
1629            s.selection = Some(6);
1630        });
1631        sidebar.update_in(cx, |s, window, cx| {
1632            s.confirm(&Confirm, window, cx);
1633        });
1634        cx.run_until_parked();
1635
1636        // All 8 threads should now be visible, no "View More"
1637        let entries = visible_entries_as_strings(&sidebar, cx);
1638        assert_eq!(entries.len(), 9); // header + 8 threads
1639        assert!(!entries.iter().any(|e| e.contains("View More")));
1640    }
1641
1642    #[gpui::test]
1643    async fn test_keyboard_expand_and_collapse_selected_entry(cx: &mut TestAppContext) {
1644        let project = init_test_project("/my-project", cx).await;
1645        let (multi_workspace, cx) =
1646            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
1647        let sidebar = setup_sidebar(&multi_workspace, cx);
1648
1649        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
1650        let thread_store = cx.update(|_window, cx| ThreadStore::global(cx));
1651
1652        let save_task = thread_store.update(cx, |store, cx| {
1653            store.save_thread(
1654                acp::SessionId::new(Arc::from("thread-1")),
1655                make_test_thread(
1656                    "My Thread",
1657                    chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
1658                ),
1659                path_list.clone(),
1660                cx,
1661            )
1662        });
1663        save_task.await.unwrap();
1664        cx.run_until_parked();
1665        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
1666        cx.run_until_parked();
1667
1668        assert_eq!(
1669            visible_entries_as_strings(&sidebar, cx),
1670            vec!["v [my-project]", "  My Thread"]
1671        );
1672
1673        // Select the header and press left to collapse
1674        sidebar.update_in(cx, |s, window, cx| {
1675            s.selection = Some(0);
1676            s.collapse_selected_entry(&CollapseSelectedEntry, window, cx);
1677        });
1678        cx.run_until_parked();
1679
1680        assert_eq!(
1681            visible_entries_as_strings(&sidebar, cx),
1682            vec!["> [my-project]  <== selected"]
1683        );
1684
1685        // Press right to expand
1686        sidebar.update_in(cx, |s, window, cx| {
1687            s.expand_selected_entry(&ExpandSelectedEntry, window, cx);
1688        });
1689        cx.run_until_parked();
1690
1691        assert_eq!(
1692            visible_entries_as_strings(&sidebar, cx),
1693            vec!["v [my-project]  <== selected", "  My Thread"]
1694        );
1695
1696        // Press right again on already-expanded header moves selection down
1697        sidebar.update_in(cx, |s, window, cx| {
1698            s.expand_selected_entry(&ExpandSelectedEntry, window, cx);
1699        });
1700        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
1701    }
1702
1703    #[gpui::test]
1704    async fn test_keyboard_collapse_from_child_selects_parent(cx: &mut TestAppContext) {
1705        let project = init_test_project("/my-project", cx).await;
1706        let (multi_workspace, cx) =
1707            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
1708        let sidebar = setup_sidebar(&multi_workspace, cx);
1709
1710        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
1711        let thread_store = cx.update(|_window, cx| ThreadStore::global(cx));
1712
1713        let save_task = thread_store.update(cx, |store, cx| {
1714            store.save_thread(
1715                acp::SessionId::new(Arc::from("thread-1")),
1716                make_test_thread(
1717                    "My Thread",
1718                    chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
1719                ),
1720                path_list.clone(),
1721                cx,
1722            )
1723        });
1724        save_task.await.unwrap();
1725        cx.run_until_parked();
1726        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
1727        cx.run_until_parked();
1728
1729        // Select the thread entry (child)
1730        sidebar.update_in(cx, |s, _window, _cx| {
1731            s.selection = Some(1);
1732        });
1733
1734        assert_eq!(
1735            visible_entries_as_strings(&sidebar, cx),
1736            vec!["v [my-project]", "  My Thread  <== selected"]
1737        );
1738
1739        // Pressing left on a child collapses the parent group and selects it
1740        sidebar.update_in(cx, |s, window, cx| {
1741            s.collapse_selected_entry(&CollapseSelectedEntry, window, cx);
1742        });
1743        cx.run_until_parked();
1744
1745        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
1746        assert_eq!(
1747            visible_entries_as_strings(&sidebar, cx),
1748            vec!["> [my-project]  <== selected"]
1749        );
1750    }
1751
1752    #[gpui::test]
1753    async fn test_keyboard_navigation_on_empty_list(cx: &mut TestAppContext) {
1754        let project = init_test_project("/empty-project", cx).await;
1755        let (multi_workspace, cx) =
1756            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
1757        let sidebar = setup_sidebar(&multi_workspace, cx);
1758
1759        // Even an empty project has the header
1760        assert_eq!(
1761            visible_entries_as_strings(&sidebar, cx),
1762            vec!["v [empty-project]"]
1763        );
1764
1765        // SelectNext on single-entry list stays at 0
1766        sidebar.update_in(cx, |s, window, cx| {
1767            s.select_next(&SelectNext, window, cx);
1768        });
1769        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
1770
1771        sidebar.update_in(cx, |s, window, cx| {
1772            s.select_next(&SelectNext, window, cx);
1773        });
1774        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
1775
1776        // SelectPrevious stays at 0
1777        sidebar.update_in(cx, |s, window, cx| {
1778            s.select_previous(&SelectPrevious, window, cx);
1779        });
1780        assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
1781    }
1782
1783    #[gpui::test]
1784    async fn test_selection_clamps_after_entry_removal(cx: &mut TestAppContext) {
1785        let project = init_test_project("/my-project", cx).await;
1786        let (multi_workspace, cx) =
1787            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
1788        let sidebar = setup_sidebar(&multi_workspace, cx);
1789
1790        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
1791        let thread_store = cx.update(|_window, cx| ThreadStore::global(cx));
1792
1793        let save_task = thread_store.update(cx, |store, cx| {
1794            store.save_thread(
1795                acp::SessionId::new(Arc::from("thread-1")),
1796                make_test_thread(
1797                    "Thread 1",
1798                    chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
1799                ),
1800                path_list.clone(),
1801                cx,
1802            )
1803        });
1804        save_task.await.unwrap();
1805        cx.run_until_parked();
1806        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
1807        cx.run_until_parked();
1808
1809        // Select the thread (index 1)
1810        sidebar.update_in(cx, |s, _window, _cx| {
1811            s.selection = Some(1);
1812        });
1813
1814        // Collapse the group, which removes the thread from the list
1815        sidebar.update_in(cx, |s, window, cx| {
1816            s.collapse_selected_entry(&CollapseSelectedEntry, window, cx);
1817        });
1818        cx.run_until_parked();
1819
1820        // Selection should be clamped to the last valid index (0 = header)
1821        let selection = sidebar.read_with(cx, |s, _| s.selection);
1822        let entry_count = sidebar.read_with(cx, |s, _| s.entries.len());
1823        assert!(
1824            selection.unwrap_or(0) < entry_count,
1825            "selection {} should be within bounds (entries: {})",
1826            selection.unwrap_or(0),
1827            entry_count,
1828        );
1829    }
1830}