1#[cfg(test)]
2mod tab_switcher_tests;
3
4use collections::HashMap;
5use editor::items::{
6 entry_diagnostic_aware_icon_decoration_and_color, entry_git_aware_label_color,
7};
8use fuzzy::StringMatchCandidate;
9use gpui::{
10 Action, AnyElement, App, Context, DismissEvent, Entity, EntityId, EventEmitter, FocusHandle,
11 Focusable, Modifiers, ModifiersChangedEvent, MouseButton, MouseUpEvent, ParentElement, Point,
12 Render, Styled, Task, WeakEntity, Window, actions, rems,
13};
14use picker::{Picker, PickerDelegate};
15use project::Project;
16use schemars::JsonSchema;
17use serde::Deserialize;
18use settings::Settings;
19use std::{cmp::Reverse, sync::Arc};
20use ui::{
21 DecoratedIcon, IconDecoration, IconDecorationKind, ListItem, ListItemSpacing, Tooltip,
22 prelude::*,
23};
24use util::ResultExt;
25use workspace::{
26 Event as WorkspaceEvent, ModalView, Pane, SaveIntent, Workspace,
27 item::{ItemHandle, ItemSettings, ShowDiagnostics, TabContentParams},
28 pane::{render_item_indicator, tab_details},
29};
30
31const PANEL_WIDTH_REMS: f32 = 28.;
32
33/// Toggles the tab switcher interface.
34#[derive(PartialEq, Clone, Deserialize, JsonSchema, Default, Action)]
35#[action(namespace = tab_switcher)]
36#[serde(deny_unknown_fields)]
37pub struct Toggle {
38 #[serde(default)]
39 pub select_last: bool,
40}
41actions!(
42 tab_switcher,
43 [
44 /// Closes the selected item in the tab switcher.
45 CloseSelectedItem,
46 /// Toggles between showing all tabs or just the current pane's tabs.
47 ToggleAll
48 ]
49);
50
51pub struct TabSwitcher {
52 picker: Entity<Picker<TabSwitcherDelegate>>,
53 init_modifiers: Option<Modifiers>,
54}
55
56impl ModalView for TabSwitcher {}
57
58pub fn init(cx: &mut App) {
59 cx.observe_new(TabSwitcher::register).detach();
60}
61
62impl TabSwitcher {
63 fn register(
64 workspace: &mut Workspace,
65 _window: Option<&mut Window>,
66 _: &mut Context<Workspace>,
67 ) {
68 workspace.register_action(|workspace, action: &Toggle, window, cx| {
69 let Some(tab_switcher) = workspace.active_modal::<Self>(cx) else {
70 Self::open(workspace, action.select_last, false, window, cx);
71 return;
72 };
73
74 tab_switcher.update(cx, |tab_switcher, cx| {
75 tab_switcher
76 .picker
77 .update(cx, |picker, cx| picker.cycle_selection(window, cx))
78 });
79 });
80 workspace.register_action(|workspace, _action: &ToggleAll, window, cx| {
81 let Some(tab_switcher) = workspace.active_modal::<Self>(cx) else {
82 Self::open(workspace, false, true, window, cx);
83 return;
84 };
85
86 tab_switcher.update(cx, |tab_switcher, cx| {
87 tab_switcher
88 .picker
89 .update(cx, |picker, cx| picker.cycle_selection(window, cx))
90 });
91 });
92 }
93
94 fn open(
95 workspace: &mut Workspace,
96 select_last: bool,
97 is_global: bool,
98 window: &mut Window,
99 cx: &mut Context<Workspace>,
100 ) {
101 let mut weak_pane = workspace.active_pane().downgrade();
102 for dock in [
103 workspace.left_dock(),
104 workspace.bottom_dock(),
105 workspace.right_dock(),
106 ] {
107 dock.update(cx, |this, cx| {
108 let Some(panel) = this
109 .active_panel()
110 .filter(|panel| panel.panel_focus_handle(cx).contains_focused(window, cx))
111 else {
112 return;
113 };
114 if let Some(pane) = panel.pane(cx) {
115 weak_pane = pane.downgrade();
116 }
117 })
118 }
119
120 let weak_workspace = workspace.weak_handle();
121
122 let project = workspace.project().clone();
123 let original_items: Vec<_> = workspace
124 .panes()
125 .iter()
126 .map(|p| (p.clone(), p.read(cx).active_item_index()))
127 .collect();
128 workspace.toggle_modal(window, cx, |window, cx| {
129 let delegate = TabSwitcherDelegate::new(
130 project,
131 select_last,
132 cx.entity().downgrade(),
133 weak_pane,
134 weak_workspace,
135 is_global,
136 window,
137 cx,
138 original_items,
139 );
140 TabSwitcher::new(delegate, window, is_global, cx)
141 });
142 }
143
144 fn new(
145 delegate: TabSwitcherDelegate,
146 window: &mut Window,
147 is_global: bool,
148 cx: &mut Context<Self>,
149 ) -> Self {
150 let init_modifiers = if is_global {
151 None
152 } else {
153 window.modifiers().modified().then_some(window.modifiers())
154 };
155 Self {
156 picker: cx.new(|cx| {
157 if is_global {
158 Picker::list(delegate, window, cx)
159 } else {
160 Picker::nonsearchable_list(delegate, window, cx)
161 }
162 }),
163 init_modifiers,
164 }
165 }
166
167 fn handle_modifiers_changed(
168 &mut self,
169 event: &ModifiersChangedEvent,
170 window: &mut Window,
171 cx: &mut Context<Self>,
172 ) {
173 let Some(init_modifiers) = self.init_modifiers else {
174 return;
175 };
176 if !event.modified() || !init_modifiers.is_subset_of(event) {
177 self.init_modifiers = None;
178 if self.picker.read(cx).delegate.matches.is_empty() {
179 cx.emit(DismissEvent)
180 } else {
181 window.dispatch_action(menu::Confirm.boxed_clone(), cx);
182 }
183 }
184 }
185
186 fn handle_close_selected_item(
187 &mut self,
188 _: &CloseSelectedItem,
189 window: &mut Window,
190 cx: &mut Context<Self>,
191 ) {
192 self.picker.update(cx, |picker, cx| {
193 picker
194 .delegate
195 .close_item_at(picker.delegate.selected_index(), window, cx)
196 });
197 }
198}
199
200impl EventEmitter<DismissEvent> for TabSwitcher {}
201
202impl Focusable for TabSwitcher {
203 fn focus_handle(&self, cx: &App) -> FocusHandle {
204 self.picker.focus_handle(cx)
205 }
206}
207
208impl Render for TabSwitcher {
209 fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
210 v_flex()
211 .key_context("TabSwitcher")
212 .w(rems(PANEL_WIDTH_REMS))
213 .on_modifiers_changed(cx.listener(Self::handle_modifiers_changed))
214 .on_action(cx.listener(Self::handle_close_selected_item))
215 .child(self.picker.clone())
216 }
217}
218
219#[derive(Clone)]
220struct TabMatch {
221 pane: WeakEntity<Pane>,
222 item_index: usize,
223 item: Box<dyn ItemHandle>,
224 detail: usize,
225 preview: bool,
226}
227
228pub struct TabSwitcherDelegate {
229 select_last: bool,
230 tab_switcher: WeakEntity<TabSwitcher>,
231 selected_index: usize,
232 pane: WeakEntity<Pane>,
233 workspace: WeakEntity<Workspace>,
234 project: Entity<Project>,
235 matches: Vec<TabMatch>,
236 original_items: Vec<(Entity<Pane>, usize)>,
237 is_all_panes: bool,
238 restored_items: bool,
239}
240
241impl TabMatch {
242 fn icon(
243 &self,
244 project: &Entity<Project>,
245 selected: bool,
246 window: &Window,
247 cx: &App,
248 ) -> Option<DecoratedIcon> {
249 let icon = self.item.tab_icon(window, cx)?;
250 let item_settings = ItemSettings::get_global(cx);
251 let show_diagnostics = item_settings.show_diagnostics;
252 let git_status_color = item_settings
253 .git_status
254 .then(|| {
255 let path = self.item.project_path(cx)?;
256 let project = project.read(cx);
257 let entry = project.entry_for_path(&path, cx)?;
258 let git_status = project
259 .project_path_git_status(&path, cx)
260 .map(|status| status.summary())
261 .unwrap_or_default();
262 Some(entry_git_aware_label_color(
263 git_status,
264 entry.is_ignored,
265 selected,
266 ))
267 })
268 .flatten();
269 let colored_icon = icon.color(git_status_color.unwrap_or_default());
270
271 let most_severe_diagnostic_level = if show_diagnostics == ShowDiagnostics::Off {
272 None
273 } else {
274 let buffer_store = project.read(cx).buffer_store().read(cx);
275 let buffer = self
276 .item
277 .project_path(cx)
278 .and_then(|path| buffer_store.get_by_path(&path))
279 .map(|buffer| buffer.read(cx));
280 buffer.and_then(|buffer| {
281 buffer
282 .buffer_diagnostics(None)
283 .iter()
284 .map(|diagnostic_entry| diagnostic_entry.diagnostic.severity)
285 .min()
286 })
287 };
288
289 let decorations =
290 entry_diagnostic_aware_icon_decoration_and_color(most_severe_diagnostic_level)
291 .filter(|(d, _)| {
292 *d != IconDecorationKind::Triangle
293 || show_diagnostics != ShowDiagnostics::Errors
294 })
295 .map(|(icon, color)| {
296 let knockout_item_color = if selected {
297 cx.theme().colors().element_selected
298 } else {
299 cx.theme().colors().element_background
300 };
301 IconDecoration::new(icon, knockout_item_color, cx)
302 .color(color.color(cx))
303 .position(Point {
304 x: px(-2.),
305 y: px(-2.),
306 })
307 });
308 Some(DecoratedIcon::new(colored_icon, decorations))
309 }
310}
311
312impl TabSwitcherDelegate {
313 #[allow(clippy::complexity)]
314 fn new(
315 project: Entity<Project>,
316 select_last: bool,
317 tab_switcher: WeakEntity<TabSwitcher>,
318 pane: WeakEntity<Pane>,
319 workspace: WeakEntity<Workspace>,
320 is_all_panes: bool,
321 window: &mut Window,
322 cx: &mut Context<TabSwitcher>,
323 original_items: Vec<(Entity<Pane>, usize)>,
324 ) -> Self {
325 Self::subscribe_to_updates(&workspace, window, cx);
326 Self {
327 select_last,
328 tab_switcher,
329 selected_index: 0,
330 pane,
331 workspace,
332 project,
333 matches: Vec::new(),
334 is_all_panes,
335 original_items,
336 restored_items: false,
337 }
338 }
339
340 fn subscribe_to_updates(
341 workspace: &WeakEntity<Workspace>,
342 window: &mut Window,
343 cx: &mut Context<TabSwitcher>,
344 ) {
345 let Some(workspace) = workspace.upgrade() else {
346 return;
347 };
348 cx.subscribe_in(&workspace, window, |tab_switcher, _, event, window, cx| {
349 match event {
350 WorkspaceEvent::ItemAdded { .. } | WorkspaceEvent::PaneRemoved => {
351 tab_switcher.picker.update(cx, |picker, cx| {
352 let query = picker.query(cx);
353 picker.delegate.update_matches(query, window, cx);
354 cx.notify();
355 })
356 }
357 WorkspaceEvent::ItemRemoved { .. } => {
358 tab_switcher.picker.update(cx, |picker, cx| {
359 let query = picker.query(cx);
360 picker.delegate.update_matches(query, window, cx);
361
362 // When the Tab Switcher is being used and an item is
363 // removed, there's a chance that the new selected index
364 // will not match the actual tab that is now being displayed
365 // by the pane, as such, the selected index needs to be
366 // updated to match the pane's state.
367 picker.delegate.sync_selected_index(cx);
368 cx.notify();
369 })
370 }
371 _ => {}
372 };
373 })
374 .detach();
375 }
376
377 fn update_all_pane_matches(
378 &mut self,
379 query: String,
380 window: &mut Window,
381 cx: &mut Context<Picker<Self>>,
382 ) {
383 let Some(workspace) = self.workspace.upgrade() else {
384 return;
385 };
386 let mut all_items = Vec::new();
387 let mut item_index = 0;
388 for pane_handle in workspace.read(cx).panes() {
389 let pane = pane_handle.read(cx);
390 let items: Vec<Box<dyn ItemHandle>> =
391 pane.items().map(|item| item.boxed_clone()).collect();
392 for ((_detail, item), detail) in items
393 .iter()
394 .enumerate()
395 .zip(tab_details(&items, window, cx))
396 {
397 all_items.push(TabMatch {
398 pane: pane_handle.downgrade(),
399 item_index,
400 item: item.clone(),
401 detail,
402 preview: pane.is_active_preview_item(item.item_id()),
403 });
404 item_index += 1;
405 }
406 }
407
408 let matches = if query.is_empty() {
409 let history = workspace.read(cx).recently_activated_items(cx);
410 all_items
411 .sort_by_key(|tab| (Reverse(history.get(&tab.item.item_id())), tab.item_index));
412 all_items
413 } else {
414 let candidates = all_items
415 .iter()
416 .enumerate()
417 .flat_map(|(ix, tab_match)| {
418 Some(StringMatchCandidate::new(
419 ix,
420 &tab_match.item.tab_content_text(0, cx),
421 ))
422 })
423 .collect::<Vec<_>>();
424 smol::block_on(fuzzy::match_strings(
425 &candidates,
426 &query,
427 true,
428 true,
429 10000,
430 &Default::default(),
431 cx.background_executor().clone(),
432 ))
433 .into_iter()
434 .map(|m| all_items[m.candidate_id].clone())
435 .collect()
436 };
437
438 let selected_item_id = self.selected_item_id();
439 self.matches = matches;
440 self.selected_index = self.compute_selected_index(selected_item_id, window, cx);
441 }
442
443 fn update_matches(
444 &mut self,
445 query: String,
446 window: &mut Window,
447 cx: &mut Context<Picker<Self>>,
448 ) {
449 if self.is_all_panes {
450 // needed because we need to borrow the workspace, but that may be borrowed when the picker
451 // calls update_matches.
452 let this = cx.entity();
453 window.defer(cx, move |window, cx| {
454 this.update(cx, |this, cx| {
455 this.delegate.update_all_pane_matches(query, window, cx);
456 })
457 });
458 return;
459 }
460 let selected_item_id = self.selected_item_id();
461 self.matches.clear();
462 let Some(pane) = self.pane.upgrade() else {
463 return;
464 };
465
466 let pane = pane.read(cx);
467 let mut history_indices = HashMap::default();
468 pane.activation_history().iter().rev().enumerate().for_each(
469 |(history_index, history_entry)| {
470 history_indices.insert(history_entry.entity_id, history_index);
471 },
472 );
473
474 let items: Vec<Box<dyn ItemHandle>> = pane.items().map(|item| item.boxed_clone()).collect();
475 items
476 .iter()
477 .enumerate()
478 .zip(tab_details(&items, window, cx))
479 .map(|((item_index, item), detail)| TabMatch {
480 pane: self.pane.clone(),
481 item_index,
482 item: item.boxed_clone(),
483 detail,
484 preview: pane.is_active_preview_item(item.item_id()),
485 })
486 .for_each(|tab_match| self.matches.push(tab_match));
487
488 let non_history_base = history_indices.len();
489 self.matches.sort_by(move |a, b| {
490 let a_score = *history_indices
491 .get(&a.item.item_id())
492 .unwrap_or(&(a.item_index + non_history_base));
493 let b_score = *history_indices
494 .get(&b.item.item_id())
495 .unwrap_or(&(b.item_index + non_history_base));
496 a_score.cmp(&b_score)
497 });
498
499 self.selected_index = self.compute_selected_index(selected_item_id, window, cx);
500 }
501
502 fn selected_item_id(&self) -> Option<EntityId> {
503 self.matches
504 .get(self.selected_index())
505 .map(|tab_match| tab_match.item.item_id())
506 }
507
508 fn compute_selected_index(
509 &mut self,
510 prev_selected_item_id: Option<EntityId>,
511 window: &mut Window,
512 cx: &mut Context<Picker<Self>>,
513 ) -> usize {
514 if self.matches.is_empty() {
515 return 0;
516 }
517
518 if let Some(selected_item_id) = prev_selected_item_id {
519 // If the previously selected item is still in the list, select its new position.
520 if let Some(item_index) = self
521 .matches
522 .iter()
523 .position(|tab_match| tab_match.item.item_id() == selected_item_id)
524 {
525 return item_index;
526 }
527 // Otherwise, try to preserve the previously selected index.
528 return self.selected_index.min(self.matches.len() - 1);
529 }
530
531 if self.select_last {
532 let item_index = self.matches.len() - 1;
533 self.set_selected_index(item_index, window, cx);
534 return item_index;
535 }
536
537 // This only runs when initially opening the picker
538 // Index 0 is already active, so don't preselect it for switching.
539 if self.matches.len() > 1 {
540 self.set_selected_index(1, window, cx);
541 return 1;
542 }
543
544 0
545 }
546
547 fn close_item_at(
548 &mut self,
549 ix: usize,
550 window: &mut Window,
551 cx: &mut Context<Picker<TabSwitcherDelegate>>,
552 ) {
553 let Some(tab_match) = self.matches.get(ix) else {
554 return;
555 };
556 let Some(pane) = tab_match.pane.upgrade() else {
557 return;
558 };
559
560 pane.update(cx, |pane, cx| {
561 pane.close_item_by_id(tab_match.item.item_id(), SaveIntent::Close, window, cx)
562 .detach_and_log_err(cx);
563 });
564 }
565
566 /// Updates the selected index to ensure it matches the pane's active item,
567 /// as the pane's active item can be indirectly updated and this method
568 /// ensures that the picker can react to those changes.
569 fn sync_selected_index(&mut self, cx: &mut Context<Picker<TabSwitcherDelegate>>) {
570 let item = if self.is_all_panes {
571 self.workspace
572 .read_with(cx, |workspace, cx| workspace.active_item(cx))
573 } else {
574 self.pane.read_with(cx, |pane, _cx| pane.active_item())
575 };
576
577 let Ok(Some(item)) = item else {
578 return;
579 };
580
581 let item_id = item.item_id();
582 let Some((index, _tab_match)) = self
583 .matches
584 .iter()
585 .enumerate()
586 .find(|(_index, tab_match)| tab_match.item.item_id() == item_id)
587 else {
588 return;
589 };
590
591 self.selected_index = index;
592 }
593}
594
595impl PickerDelegate for TabSwitcherDelegate {
596 type ListItem = ListItem;
597
598 fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
599 "Search all tabs…".into()
600 }
601
602 fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option<SharedString> {
603 Some("No tabs".into())
604 }
605
606 fn match_count(&self) -> usize {
607 self.matches.len()
608 }
609
610 fn selected_index(&self) -> usize {
611 self.selected_index
612 }
613
614 fn set_selected_index(
615 &mut self,
616 ix: usize,
617 window: &mut Window,
618 cx: &mut Context<Picker<Self>>,
619 ) {
620 self.selected_index = ix;
621
622 let Some(selected_match) = self.matches.get(self.selected_index()) else {
623 return;
624 };
625 selected_match
626 .pane
627 .update(cx, |pane, cx| {
628 if let Some(index) = pane.index_for_item(selected_match.item.as_ref()) {
629 pane.activate_item(index, false, false, window, cx);
630 }
631 })
632 .ok();
633 cx.notify();
634 }
635
636 fn separators_after_indices(&self) -> Vec<usize> {
637 Vec::new()
638 }
639
640 fn update_matches(
641 &mut self,
642 raw_query: String,
643 window: &mut Window,
644 cx: &mut Context<Picker<Self>>,
645 ) -> Task<()> {
646 self.update_matches(raw_query, window, cx);
647 Task::ready(())
648 }
649
650 fn confirm(
651 &mut self,
652 _secondary: bool,
653 window: &mut Window,
654 cx: &mut Context<Picker<TabSwitcherDelegate>>,
655 ) {
656 let Some(selected_match) = self.matches.get(self.selected_index()) else {
657 return;
658 };
659
660 self.restored_items = true;
661 for (pane, index) in self.original_items.iter() {
662 pane.update(cx, |this, cx| {
663 this.activate_item(*index, false, false, window, cx);
664 })
665 }
666 selected_match
667 .pane
668 .update(cx, |pane, cx| {
669 if let Some(index) = pane.index_for_item(selected_match.item.as_ref()) {
670 pane.activate_item(index, true, true, window, cx);
671 }
672 })
673 .ok();
674 }
675
676 fn dismissed(&mut self, window: &mut Window, cx: &mut Context<Picker<TabSwitcherDelegate>>) {
677 if !self.restored_items {
678 for (pane, index) in self.original_items.iter() {
679 pane.update(cx, |this, cx| {
680 this.activate_item(*index, false, false, window, cx);
681 })
682 }
683 }
684
685 self.tab_switcher
686 .update(cx, |_, cx| cx.emit(DismissEvent))
687 .log_err();
688 }
689
690 fn render_match(
691 &self,
692 ix: usize,
693 selected: bool,
694 window: &mut Window,
695 cx: &mut Context<Picker<Self>>,
696 ) -> Option<Self::ListItem> {
697 let tab_match = self.matches.get(ix)?;
698
699 let params = TabContentParams {
700 detail: Some(tab_match.detail),
701 selected: true,
702 preview: tab_match.preview,
703 deemphasized: false,
704 };
705 let label = tab_match.item.tab_content(params, window, cx);
706
707 let icon = tab_match.icon(&self.project, selected, window, cx);
708
709 let indicator = render_item_indicator(tab_match.item.boxed_clone(), cx);
710 let indicator_color = if let Some(ref indicator) = indicator {
711 indicator.color
712 } else {
713 Color::default()
714 };
715 let indicator = h_flex()
716 .flex_shrink_0()
717 .children(indicator)
718 .child(div().w_2())
719 .into_any_element();
720 let close_button = div()
721 .id("close-button")
722 .on_mouse_up(
723 // We need this on_mouse_up here because on macOS you may have ctrl held
724 // down to open the menu, and a ctrl-click comes through as a right click.
725 MouseButton::Right,
726 cx.listener(move |picker, _: &MouseUpEvent, window, cx| {
727 cx.stop_propagation();
728 picker.delegate.close_item_at(ix, window, cx);
729 }),
730 )
731 .child(
732 IconButton::new("close_tab", IconName::Close)
733 .icon_size(IconSize::Small)
734 .icon_color(indicator_color)
735 .tooltip(Tooltip::for_action_title("Close", &CloseSelectedItem))
736 .on_click(cx.listener(move |picker, _, window, cx| {
737 cx.stop_propagation();
738 picker.delegate.close_item_at(ix, window, cx);
739 })),
740 )
741 .into_any_element();
742
743 Some(
744 ListItem::new(ix)
745 .spacing(ListItemSpacing::Sparse)
746 .inset(true)
747 .toggle_state(selected)
748 .child(h_flex().w_full().child(label))
749 .start_slot::<DecoratedIcon>(icon)
750 .map(|el| {
751 if self.selected_index == ix {
752 el.end_slot::<AnyElement>(close_button)
753 } else {
754 el.end_slot::<AnyElement>(indicator)
755 .end_hover_slot::<AnyElement>(close_button)
756 }
757 }),
758 )
759 }
760}