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}