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 let project = workspace.project().clone();
117 workspace.toggle_modal(window, cx, |window, cx| {
118 let delegate = TabSwitcherDelegate::new(
119 project,
120 select_last,
121 cx.entity().downgrade(),
122 weak_pane,
123 weak_workspace,
124 is_global,
125 window,
126 cx,
127 );
128 TabSwitcher::new(delegate, window, is_global, cx)
129 });
130 }
131
132 fn new(
133 delegate: TabSwitcherDelegate,
134 window: &mut Window,
135 is_global: bool,
136 cx: &mut Context<Self>,
137 ) -> Self {
138 let init_modifiers = if is_global {
139 None
140 } else {
141 window.modifiers().modified().then_some(window.modifiers())
142 };
143 Self {
144 picker: cx.new(|cx| {
145 if is_global {
146 Picker::uniform_list(delegate, window, cx)
147 } else {
148 Picker::nonsearchable_uniform_list(delegate, window, cx)
149 }
150 }),
151 init_modifiers,
152 }
153 }
154
155 fn handle_modifiers_changed(
156 &mut self,
157 event: &ModifiersChangedEvent,
158 window: &mut Window,
159 cx: &mut Context<Self>,
160 ) {
161 let Some(init_modifiers) = self.init_modifiers else {
162 return;
163 };
164 if !event.modified() || !init_modifiers.is_subset_of(event) {
165 self.init_modifiers = None;
166 if self.picker.read(cx).delegate.matches.is_empty() {
167 cx.emit(DismissEvent)
168 } else {
169 window.dispatch_action(menu::Confirm.boxed_clone(), cx);
170 }
171 }
172 }
173
174 fn handle_close_selected_item(
175 &mut self,
176 _: &CloseSelectedItem,
177 window: &mut Window,
178 cx: &mut Context<Self>,
179 ) {
180 self.picker.update(cx, |picker, cx| {
181 picker
182 .delegate
183 .close_item_at(picker.delegate.selected_index(), window, cx)
184 });
185 }
186}
187
188impl EventEmitter<DismissEvent> for TabSwitcher {}
189
190impl Focusable for TabSwitcher {
191 fn focus_handle(&self, cx: &App) -> FocusHandle {
192 self.picker.focus_handle(cx)
193 }
194}
195
196impl Render for TabSwitcher {
197 fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
198 v_flex()
199 .key_context("TabSwitcher")
200 .w(rems(PANEL_WIDTH_REMS))
201 .on_modifiers_changed(cx.listener(Self::handle_modifiers_changed))
202 .on_action(cx.listener(Self::handle_close_selected_item))
203 .child(self.picker.clone())
204 }
205}
206
207#[derive(Clone)]
208struct TabMatch {
209 pane: WeakEntity<Pane>,
210 item_index: usize,
211 item: Box<dyn ItemHandle>,
212 detail: usize,
213 preview: bool,
214}
215
216pub struct TabSwitcherDelegate {
217 select_last: bool,
218 tab_switcher: WeakEntity<TabSwitcher>,
219 selected_index: usize,
220 pane: WeakEntity<Pane>,
221 workspace: WeakEntity<Workspace>,
222 project: Entity<Project>,
223 matches: Vec<TabMatch>,
224 is_all_panes: bool,
225}
226
227impl TabSwitcherDelegate {
228 #[allow(clippy::complexity)]
229 fn new(
230 project: Entity<Project>,
231 select_last: bool,
232 tab_switcher: WeakEntity<TabSwitcher>,
233 pane: WeakEntity<Pane>,
234 workspace: WeakEntity<Workspace>,
235 is_all_panes: bool,
236 window: &mut Window,
237 cx: &mut Context<TabSwitcher>,
238 ) -> Self {
239 Self::subscribe_to_updates(&pane, window, cx);
240 Self {
241 select_last,
242 tab_switcher,
243 selected_index: 0,
244 pane,
245 workspace,
246 project,
247 matches: Vec::new(),
248 is_all_panes,
249 }
250 }
251
252 fn subscribe_to_updates(
253 pane: &WeakEntity<Pane>,
254 window: &mut Window,
255 cx: &mut Context<TabSwitcher>,
256 ) {
257 let Some(pane) = pane.upgrade() else {
258 return;
259 };
260 cx.subscribe_in(&pane, window, |tab_switcher, _, event, window, cx| {
261 match event {
262 PaneEvent::AddItem { .. }
263 | PaneEvent::RemovedItem { .. }
264 | PaneEvent::Remove { .. } => tab_switcher.picker.update(cx, |picker, cx| {
265 let query = picker.query(cx);
266 picker.delegate.update_matches(query, window, cx);
267 cx.notify();
268 }),
269 _ => {}
270 };
271 })
272 .detach();
273 }
274
275 fn update_all_pane_matches(&mut self, query: String, window: &mut Window, cx: &mut App) {
276 let Some(workspace) = self.workspace.upgrade() else {
277 return;
278 };
279 let mut all_items = Vec::new();
280 let mut item_index = 0;
281 for pane_handle in workspace.read(cx).panes() {
282 let pane = pane_handle.read(cx);
283 let items: Vec<Box<dyn ItemHandle>> =
284 pane.items().map(|item| item.boxed_clone()).collect();
285 for ((_detail, item), detail) in items
286 .iter()
287 .enumerate()
288 .zip(tab_details(&items, window, cx))
289 {
290 all_items.push(TabMatch {
291 pane: pane_handle.downgrade(),
292 item_index,
293 item: item.clone(),
294 detail,
295 preview: pane.is_active_preview_item(item.item_id()),
296 });
297 item_index += 1;
298 }
299 }
300
301 let matches = if query.is_empty() {
302 let history = workspace.read(cx).recently_activated_items(cx);
303 for item in &all_items {
304 eprintln!(
305 "{:?} {:?}",
306 item.item.tab_content_text(0, cx),
307 (Reverse(history.get(&item.item.item_id())), item.item_index)
308 )
309 }
310 all_items
311 .sort_by_key(|tab| (Reverse(history.get(&tab.item.item_id())), tab.item_index));
312 all_items
313 } else {
314 let candidates = all_items
315 .iter()
316 .enumerate()
317 .flat_map(|(ix, tab_match)| {
318 Some(StringMatchCandidate::new(
319 ix,
320 &tab_match.item.tab_content_text(0, cx),
321 ))
322 })
323 .collect::<Vec<_>>();
324 smol::block_on(fuzzy::match_strings(
325 &candidates,
326 &query,
327 true,
328 true,
329 10000,
330 &Default::default(),
331 cx.background_executor().clone(),
332 ))
333 .into_iter()
334 .map(|m| all_items[m.candidate_id].clone())
335 .collect()
336 };
337
338 let selected_item_id = self.selected_item_id();
339 self.matches = matches;
340 self.selected_index = self.compute_selected_index(selected_item_id);
341 }
342
343 fn update_matches(
344 &mut self,
345 query: String,
346 window: &mut Window,
347 cx: &mut Context<Picker<Self>>,
348 ) {
349 if self.is_all_panes {
350 // needed because we need to borrow the workspace, but that may be borrowed when the picker
351 // calls update_matches.
352 let this = cx.entity();
353 window.defer(cx, move |window, cx| {
354 this.update(cx, |this, cx| {
355 this.delegate.update_all_pane_matches(query, window, cx);
356 })
357 });
358 return;
359 }
360 let selected_item_id = self.selected_item_id();
361 self.matches.clear();
362 let Some(pane) = self.pane.upgrade() else {
363 return;
364 };
365
366 let pane = pane.read(cx);
367 let mut history_indices = HashMap::default();
368 pane.activation_history().iter().rev().enumerate().for_each(
369 |(history_index, history_entry)| {
370 history_indices.insert(history_entry.entity_id, history_index);
371 },
372 );
373
374 let items: Vec<Box<dyn ItemHandle>> = pane.items().map(|item| item.boxed_clone()).collect();
375 items
376 .iter()
377 .enumerate()
378 .zip(tab_details(&items, window, cx))
379 .map(|((item_index, item), detail)| TabMatch {
380 pane: self.pane.clone(),
381 item_index,
382 item: item.boxed_clone(),
383 detail,
384 preview: pane.is_active_preview_item(item.item_id()),
385 })
386 .for_each(|tab_match| self.matches.push(tab_match));
387
388 let non_history_base = history_indices.len();
389 self.matches.sort_by(move |a, b| {
390 let a_score = *history_indices
391 .get(&a.item.item_id())
392 .unwrap_or(&(a.item_index + non_history_base));
393 let b_score = *history_indices
394 .get(&b.item.item_id())
395 .unwrap_or(&(b.item_index + non_history_base));
396 a_score.cmp(&b_score)
397 });
398
399 self.selected_index = self.compute_selected_index(selected_item_id);
400 }
401
402 fn selected_item_id(&self) -> Option<EntityId> {
403 self.matches
404 .get(self.selected_index())
405 .map(|tab_match| tab_match.item.item_id())
406 }
407
408 fn compute_selected_index(&mut self, prev_selected_item_id: Option<EntityId>) -> usize {
409 if self.matches.is_empty() {
410 return 0;
411 }
412
413 if let Some(selected_item_id) = prev_selected_item_id {
414 // If the previously selected item is still in the list, select its new position.
415 if let Some(item_index) = self
416 .matches
417 .iter()
418 .position(|tab_match| tab_match.item.item_id() == selected_item_id)
419 {
420 return item_index;
421 }
422 // Otherwise, try to preserve the previously selected index.
423 return self.selected_index.min(self.matches.len() - 1);
424 }
425
426 if self.select_last {
427 return self.matches.len() - 1;
428 }
429
430 if self.matches.len() > 1 {
431 // Index 0 is active, so don't preselect it for switching.
432 return 1;
433 }
434
435 0
436 }
437
438 fn close_item_at(
439 &mut self,
440 ix: usize,
441 window: &mut Window,
442 cx: &mut Context<Picker<TabSwitcherDelegate>>,
443 ) {
444 let Some(tab_match) = self.matches.get(ix) else {
445 return;
446 };
447 let Some(pane) = self.pane.upgrade() else {
448 return;
449 };
450 pane.update(cx, |pane, cx| {
451 pane.close_item_by_id(tab_match.item.item_id(), SaveIntent::Close, window, cx)
452 .detach_and_log_err(cx);
453 });
454 }
455}
456
457impl PickerDelegate for TabSwitcherDelegate {
458 type ListItem = ListItem;
459
460 fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
461 "Search all tabs…".into()
462 }
463
464 fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option<SharedString> {
465 Some("No tabs".into())
466 }
467
468 fn match_count(&self) -> usize {
469 self.matches.len()
470 }
471
472 fn selected_index(&self) -> usize {
473 self.selected_index
474 }
475
476 fn set_selected_index(&mut self, ix: usize, _: &mut Window, cx: &mut Context<Picker<Self>>) {
477 self.selected_index = ix;
478 cx.notify();
479 }
480
481 fn separators_after_indices(&self) -> Vec<usize> {
482 Vec::new()
483 }
484
485 fn update_matches(
486 &mut self,
487 raw_query: String,
488 window: &mut Window,
489 cx: &mut Context<Picker<Self>>,
490 ) -> Task<()> {
491 self.update_matches(raw_query, window, cx);
492 Task::ready(())
493 }
494
495 fn confirm(
496 &mut self,
497 _secondary: bool,
498 window: &mut Window,
499 cx: &mut Context<Picker<TabSwitcherDelegate>>,
500 ) {
501 let Some(selected_match) = self.matches.get(self.selected_index()) else {
502 return;
503 };
504 selected_match
505 .pane
506 .update(cx, |pane, cx| {
507 if let Some(index) = pane.index_for_item(selected_match.item.as_ref()) {
508 pane.activate_item(index, true, true, window, cx);
509 }
510 })
511 .ok();
512 }
513
514 fn dismissed(&mut self, _: &mut Window, cx: &mut Context<Picker<TabSwitcherDelegate>>) {
515 self.tab_switcher
516 .update(cx, |_, cx| cx.emit(DismissEvent))
517 .log_err();
518 }
519
520 fn render_match(
521 &self,
522 ix: usize,
523 selected: bool,
524 window: &mut Window,
525 cx: &mut Context<Picker<Self>>,
526 ) -> Option<Self::ListItem> {
527 let tab_match = self
528 .matches
529 .get(ix)
530 .expect("Invalid matches state: no element for index {ix}");
531
532 let params = TabContentParams {
533 detail: Some(tab_match.detail),
534 selected: true,
535 preview: tab_match.preview,
536 deemphasized: false,
537 };
538 let label = tab_match.item.tab_content(params, window, cx);
539
540 let icon = tab_match.item.tab_icon(window, cx).map(|icon| {
541 let git_status_color = ItemSettings::get_global(cx)
542 .git_status
543 .then(|| {
544 tab_match
545 .item
546 .project_path(cx)
547 .as_ref()
548 .and_then(|path| {
549 let project = self.project.read(cx);
550 let entry = project.entry_for_path(path, cx)?;
551 let git_status = project
552 .project_path_git_status(path, cx)
553 .map(|status| status.summary())
554 .unwrap_or_default();
555 Some((entry, git_status))
556 })
557 .map(|(entry, git_status)| {
558 entry_git_aware_label_color(git_status, entry.is_ignored, selected)
559 })
560 })
561 .flatten();
562
563 icon.color(git_status_color.unwrap_or_default())
564 });
565
566 let indicator = render_item_indicator(tab_match.item.boxed_clone(), cx);
567 let indicator_color = if let Some(ref indicator) = indicator {
568 indicator.color
569 } else {
570 Color::default()
571 };
572 let indicator = h_flex()
573 .flex_shrink_0()
574 .children(indicator)
575 .child(div().w_2())
576 .into_any_element();
577 let close_button = div()
578 .id("close-button")
579 .on_mouse_up(
580 // We need this on_mouse_up here because on macOS you may have ctrl held
581 // down to open the menu, and a ctrl-click comes through as a right click.
582 MouseButton::Right,
583 cx.listener(move |picker, _: &MouseUpEvent, window, cx| {
584 cx.stop_propagation();
585 picker.delegate.close_item_at(ix, window, cx);
586 }),
587 )
588 .child(
589 IconButton::new("close_tab", IconName::Close)
590 .icon_size(IconSize::Small)
591 .icon_color(indicator_color)
592 .tooltip(Tooltip::for_action_title("Close", &CloseSelectedItem))
593 .on_click(cx.listener(move |picker, _, window, cx| {
594 cx.stop_propagation();
595 picker.delegate.close_item_at(ix, window, cx);
596 })),
597 )
598 .into_any_element();
599
600 Some(
601 ListItem::new(ix)
602 .spacing(ListItemSpacing::Sparse)
603 .inset(true)
604 .toggle_state(selected)
605 .child(h_flex().w_full().child(label))
606 .start_slot::<Icon>(icon)
607 .map(|el| {
608 if self.selected_index == ix {
609 el.end_slot::<AnyElement>(close_button)
610 } else {
611 el.end_slot::<AnyElement>(indicator)
612 .end_hover_slot::<AnyElement>(close_button)
613 }
614 }),
615 )
616 }
617}