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        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            eprintln!("");
311            all_items
312                .sort_by_key(|tab| (Reverse(history.get(&tab.item.item_id())), tab.item_index));
313            all_items
314        } else {
315            let candidates = all_items
316                .iter()
317                .enumerate()
318                .flat_map(|(ix, tab_match)| {
319                    Some(StringMatchCandidate::new(
320                        ix,
321                        &tab_match.item.tab_content_text(0, cx),
322                    ))
323                })
324                .collect::<Vec<_>>();
325            smol::block_on(fuzzy::match_strings(
326                &candidates,
327                &query,
328                true,
329                true,
330                10000,
331                &Default::default(),
332                cx.background_executor().clone(),
333            ))
334            .into_iter()
335            .map(|m| all_items[m.candidate_id].clone())
336            .collect()
337        };
338
339        let selected_item_id = self.selected_item_id();
340        self.matches = matches;
341        self.selected_index = self.compute_selected_index(selected_item_id);
342    }
343
344    fn update_matches(
345        &mut self,
346        query: String,
347        window: &mut Window,
348        cx: &mut Context<Picker<Self>>,
349    ) {
350        if self.is_all_panes {
351            // needed because we need to borrow the workspace, but that may be borrowed when the picker
352            // calls update_matches.
353            let this = cx.entity();
354            window.defer(cx, move |window, cx| {
355                this.update(cx, |this, cx| {
356                    this.delegate.update_all_pane_matches(query, window, cx);
357                })
358            });
359            return;
360        }
361        let selected_item_id = self.selected_item_id();
362        self.matches.clear();
363        let Some(pane) = self.pane.upgrade() else {
364            return;
365        };
366
367        let pane = pane.read(cx);
368        let mut history_indices = HashMap::default();
369        pane.activation_history().iter().rev().enumerate().for_each(
370            |(history_index, history_entry)| {
371                history_indices.insert(history_entry.entity_id, history_index);
372            },
373        );
374
375        let items: Vec<Box<dyn ItemHandle>> = pane.items().map(|item| item.boxed_clone()).collect();
376        items
377            .iter()
378            .enumerate()
379            .zip(tab_details(&items, window, cx))
380            .map(|((item_index, item), detail)| TabMatch {
381                pane: self.pane.clone(),
382                item_index,
383                item: item.boxed_clone(),
384                detail,
385                preview: pane.is_active_preview_item(item.item_id()),
386            })
387            .for_each(|tab_match| self.matches.push(tab_match));
388
389        let non_history_base = history_indices.len();
390        self.matches.sort_by(move |a, b| {
391            let a_score = *history_indices
392                .get(&a.item.item_id())
393                .unwrap_or(&(a.item_index + non_history_base));
394            let b_score = *history_indices
395                .get(&b.item.item_id())
396                .unwrap_or(&(b.item_index + non_history_base));
397            a_score.cmp(&b_score)
398        });
399
400        self.selected_index = self.compute_selected_index(selected_item_id);
401    }
402
403    fn selected_item_id(&self) -> Option<EntityId> {
404        self.matches
405            .get(self.selected_index())
406            .map(|tab_match| tab_match.item.item_id())
407    }
408
409    fn compute_selected_index(&mut self, prev_selected_item_id: Option<EntityId>) -> usize {
410        if self.matches.is_empty() {
411            return 0;
412        }
413
414        if let Some(selected_item_id) = prev_selected_item_id {
415            // If the previously selected item is still in the list, select its new position.
416            if let Some(item_index) = self
417                .matches
418                .iter()
419                .position(|tab_match| tab_match.item.item_id() == selected_item_id)
420            {
421                return item_index;
422            }
423            // Otherwise, try to preserve the previously selected index.
424            return self.selected_index.min(self.matches.len() - 1);
425        }
426
427        if self.select_last {
428            return self.matches.len() - 1;
429        }
430
431        if self.matches.len() > 1 {
432            // Index 0 is active, so don't preselect it for switching.
433            return 1;
434        }
435
436        0
437    }
438
439    fn close_item_at(
440        &mut self,
441        ix: usize,
442        window: &mut Window,
443        cx: &mut Context<Picker<TabSwitcherDelegate>>,
444    ) {
445        let Some(tab_match) = self.matches.get(ix) else {
446            return;
447        };
448        let Some(pane) = self.pane.upgrade() else {
449            return;
450        };
451        pane.update(cx, |pane, cx| {
452            pane.close_item_by_id(tab_match.item.item_id(), SaveIntent::Close, window, cx)
453                .detach_and_log_err(cx);
454        });
455    }
456}
457
458impl PickerDelegate for TabSwitcherDelegate {
459    type ListItem = ListItem;
460
461    fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
462        "Search all tabs…".into()
463    }
464
465    fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option<SharedString> {
466        Some("No tabs".into())
467    }
468
469    fn match_count(&self) -> usize {
470        self.matches.len()
471    }
472
473    fn selected_index(&self) -> usize {
474        self.selected_index
475    }
476
477    fn set_selected_index(&mut self, ix: usize, _: &mut Window, cx: &mut Context<Picker<Self>>) {
478        self.selected_index = ix;
479        cx.notify();
480    }
481
482    fn separators_after_indices(&self) -> Vec<usize> {
483        Vec::new()
484    }
485
486    fn update_matches(
487        &mut self,
488        raw_query: String,
489        window: &mut Window,
490        cx: &mut Context<Picker<Self>>,
491    ) -> Task<()> {
492        self.update_matches(raw_query, window, cx);
493        Task::ready(())
494    }
495
496    fn confirm(
497        &mut self,
498        _secondary: bool,
499        window: &mut Window,
500        cx: &mut Context<Picker<TabSwitcherDelegate>>,
501    ) {
502        let Some(selected_match) = self.matches.get(self.selected_index()) else {
503            return;
504        };
505        selected_match
506            .pane
507            .update(cx, |pane, cx| {
508                if let Some(index) = pane.index_for_item(selected_match.item.as_ref()) {
509                    pane.activate_item(index, true, true, window, cx);
510                }
511            })
512            .ok();
513    }
514
515    fn dismissed(&mut self, _: &mut Window, cx: &mut Context<Picker<TabSwitcherDelegate>>) {
516        self.tab_switcher
517            .update(cx, |_, cx| cx.emit(DismissEvent))
518            .log_err();
519    }
520
521    fn render_match(
522        &self,
523        ix: usize,
524        selected: bool,
525        window: &mut Window,
526        cx: &mut Context<Picker<Self>>,
527    ) -> Option<Self::ListItem> {
528        let tab_match = self
529            .matches
530            .get(ix)
531            .expect("Invalid matches state: no element for index {ix}");
532
533        let params = TabContentParams {
534            detail: Some(tab_match.detail),
535            selected: true,
536            preview: tab_match.preview,
537            deemphasized: false,
538        };
539        let label = tab_match.item.tab_content(params, window, cx);
540
541        let icon = tab_match.item.tab_icon(window, cx).map(|icon| {
542            let git_status_color = ItemSettings::get_global(cx)
543                .git_status
544                .then(|| {
545                    tab_match
546                        .item
547                        .project_path(cx)
548                        .as_ref()
549                        .and_then(|path| {
550                            let project = self.project.read(cx);
551                            let entry = project.entry_for_path(path, cx)?;
552                            let git_status = project
553                                .project_path_git_status(path, cx)
554                                .map(|status| status.summary())
555                                .unwrap_or_default();
556                            Some((entry, git_status))
557                        })
558                        .map(|(entry, git_status)| {
559                            entry_git_aware_label_color(git_status, entry.is_ignored, selected)
560                        })
561                })
562                .flatten();
563
564            icon.color(git_status_color.unwrap_or_default())
565        });
566
567        let indicator = render_item_indicator(tab_match.item.boxed_clone(), cx);
568        let indicator_color = if let Some(ref indicator) = indicator {
569            indicator.color
570        } else {
571            Color::default()
572        };
573        let indicator = h_flex()
574            .flex_shrink_0()
575            .children(indicator)
576            .child(div().w_2())
577            .into_any_element();
578        let close_button = div()
579            .id("close-button")
580            .on_mouse_up(
581                // We need this on_mouse_up here because on macOS you may have ctrl held
582                // down to open the menu, and a ctrl-click comes through as a right click.
583                MouseButton::Right,
584                cx.listener(move |picker, _: &MouseUpEvent, window, cx| {
585                    cx.stop_propagation();
586                    picker.delegate.close_item_at(ix, window, cx);
587                }),
588            )
589            .child(
590                IconButton::new("close_tab", IconName::Close)
591                    .icon_size(IconSize::Small)
592                    .icon_color(indicator_color)
593                    .tooltip(Tooltip::for_action_title("Close", &CloseSelectedItem))
594                    .on_click(cx.listener(move |picker, _, window, cx| {
595                        cx.stop_propagation();
596                        picker.delegate.close_item_at(ix, window, cx);
597                    })),
598            )
599            .into_any_element();
600
601        Some(
602            ListItem::new(ix)
603                .spacing(ListItemSpacing::Sparse)
604                .inset(true)
605                .toggle_state(selected)
606                .child(h_flex().w_full().child(label))
607                .start_slot::<Icon>(icon)
608                .map(|el| {
609                    if self.selected_index == ix {
610                        el.end_slot::<AnyElement>(close_button)
611                    } else {
612                        el.end_slot::<AnyElement>(indicator)
613                            .end_hover_slot::<AnyElement>(close_button)
614                    }
615                }),
616        )
617    }
618}