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