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