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