tab_switcher.rs

  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}