tab_switcher.rs

  1#[cfg(test)]
  2mod tab_switcher_tests;
  3
  4use collections::HashMap;
  5use editor::items::{
  6    entry_diagnostic_aware_icon_decoration_and_color, entry_git_aware_label_color,
  7};
  8use fuzzy::StringMatchCandidate;
  9use gpui::{
 10    Action, AnyElement, App, Context, DismissEvent, Entity, EntityId, EventEmitter, FocusHandle,
 11    Focusable, Modifiers, ModifiersChangedEvent, MouseButton, MouseUpEvent, ParentElement, Point,
 12    Render, Styled, Task, WeakEntity, Window, actions, rems,
 13};
 14use picker::{Picker, PickerDelegate};
 15use project::Project;
 16use schemars::JsonSchema;
 17use serde::Deserialize;
 18use settings::Settings;
 19use std::{cmp::Reverse, sync::Arc};
 20use ui::{
 21    DecoratedIcon, IconDecoration, IconDecorationKind, ListItem, ListItemSpacing, Tooltip,
 22    prelude::*,
 23};
 24use util::ResultExt;
 25use workspace::{
 26    Event as WorkspaceEvent, ModalView, Pane, SaveIntent, Workspace,
 27    item::{ItemHandle, ItemSettings, ShowDiagnostics, TabContentParams},
 28    pane::{render_item_indicator, tab_details},
 29};
 30
 31const PANEL_WIDTH_REMS: f32 = 28.;
 32
 33/// Toggles the tab switcher interface.
 34#[derive(PartialEq, Clone, Deserialize, JsonSchema, Default, Action)]
 35#[action(namespace = tab_switcher)]
 36#[serde(deny_unknown_fields)]
 37pub struct Toggle {
 38    #[serde(default)]
 39    pub select_last: bool,
 40}
 41actions!(
 42    tab_switcher,
 43    [
 44        /// Closes the selected item in the tab switcher.
 45        CloseSelectedItem,
 46        /// Toggles between showing all tabs or just the current pane's tabs.
 47        ToggleAll
 48    ]
 49);
 50
 51pub struct TabSwitcher {
 52    picker: Entity<Picker<TabSwitcherDelegate>>,
 53    init_modifiers: Option<Modifiers>,
 54}
 55
 56impl ModalView for TabSwitcher {}
 57
 58pub fn init(cx: &mut App) {
 59    cx.observe_new(TabSwitcher::register).detach();
 60}
 61
 62impl TabSwitcher {
 63    fn register(
 64        workspace: &mut Workspace,
 65        _window: Option<&mut Window>,
 66        _: &mut Context<Workspace>,
 67    ) {
 68        workspace.register_action(|workspace, action: &Toggle, window, cx| {
 69            let Some(tab_switcher) = workspace.active_modal::<Self>(cx) else {
 70                Self::open(workspace, action.select_last, false, window, cx);
 71                return;
 72            };
 73
 74            tab_switcher.update(cx, |tab_switcher, cx| {
 75                tab_switcher
 76                    .picker
 77                    .update(cx, |picker, cx| picker.cycle_selection(window, cx))
 78            });
 79        });
 80        workspace.register_action(|workspace, _action: &ToggleAll, window, cx| {
 81            let Some(tab_switcher) = workspace.active_modal::<Self>(cx) else {
 82                Self::open(workspace, false, true, window, cx);
 83                return;
 84            };
 85
 86            tab_switcher.update(cx, |tab_switcher, cx| {
 87                tab_switcher
 88                    .picker
 89                    .update(cx, |picker, cx| picker.cycle_selection(window, cx))
 90            });
 91        });
 92    }
 93
 94    fn open(
 95        workspace: &mut Workspace,
 96        select_last: bool,
 97        is_global: bool,
 98        window: &mut Window,
 99        cx: &mut Context<Workspace>,
100    ) {
101        let mut weak_pane = workspace.active_pane().downgrade();
102        for dock in [
103            workspace.left_dock(),
104            workspace.bottom_dock(),
105            workspace.right_dock(),
106        ] {
107            dock.update(cx, |this, cx| {
108                let Some(panel) = this
109                    .active_panel()
110                    .filter(|panel| panel.panel_focus_handle(cx).contains_focused(window, cx))
111                else {
112                    return;
113                };
114                if let Some(pane) = panel.pane(cx) {
115                    weak_pane = pane.downgrade();
116                }
117            })
118        }
119
120        let weak_workspace = workspace.weak_handle();
121
122        let project = workspace.project().clone();
123        let original_items: Vec<_> = workspace
124            .panes()
125            .iter()
126            .map(|p| (p.clone(), p.read(cx).active_item_index()))
127            .collect();
128        workspace.toggle_modal(window, cx, |window, cx| {
129            let delegate = TabSwitcherDelegate::new(
130                project,
131                select_last,
132                cx.entity().downgrade(),
133                weak_pane,
134                weak_workspace,
135                is_global,
136                window,
137                cx,
138                original_items,
139            );
140            TabSwitcher::new(delegate, window, is_global, cx)
141        });
142    }
143
144    fn new(
145        delegate: TabSwitcherDelegate,
146        window: &mut Window,
147        is_global: bool,
148        cx: &mut Context<Self>,
149    ) -> Self {
150        let init_modifiers = if is_global {
151            None
152        } else {
153            window.modifiers().modified().then_some(window.modifiers())
154        };
155        Self {
156            picker: cx.new(|cx| {
157                if is_global {
158                    Picker::list(delegate, window, cx)
159                } else {
160                    Picker::nonsearchable_list(delegate, window, cx)
161                }
162            }),
163            init_modifiers,
164        }
165    }
166
167    fn handle_modifiers_changed(
168        &mut self,
169        event: &ModifiersChangedEvent,
170        window: &mut Window,
171        cx: &mut Context<Self>,
172    ) {
173        let Some(init_modifiers) = self.init_modifiers else {
174            return;
175        };
176        if !event.modified() || !init_modifiers.is_subset_of(event) {
177            self.init_modifiers = None;
178            if self.picker.read(cx).delegate.matches.is_empty() {
179                cx.emit(DismissEvent)
180            } else {
181                window.dispatch_action(menu::Confirm.boxed_clone(), cx);
182            }
183        }
184    }
185
186    fn handle_close_selected_item(
187        &mut self,
188        _: &CloseSelectedItem,
189        window: &mut Window,
190        cx: &mut Context<Self>,
191    ) {
192        self.picker.update(cx, |picker, cx| {
193            picker
194                .delegate
195                .close_item_at(picker.delegate.selected_index(), window, cx)
196        });
197    }
198}
199
200impl EventEmitter<DismissEvent> for TabSwitcher {}
201
202impl Focusable for TabSwitcher {
203    fn focus_handle(&self, cx: &App) -> FocusHandle {
204        self.picker.focus_handle(cx)
205    }
206}
207
208impl Render for TabSwitcher {
209    fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
210        v_flex()
211            .key_context("TabSwitcher")
212            .w(rems(PANEL_WIDTH_REMS))
213            .on_modifiers_changed(cx.listener(Self::handle_modifiers_changed))
214            .on_action(cx.listener(Self::handle_close_selected_item))
215            .child(self.picker.clone())
216    }
217}
218
219#[derive(Clone)]
220struct TabMatch {
221    pane: WeakEntity<Pane>,
222    item_index: usize,
223    item: Box<dyn ItemHandle>,
224    detail: usize,
225    preview: bool,
226}
227
228pub struct TabSwitcherDelegate {
229    select_last: bool,
230    tab_switcher: WeakEntity<TabSwitcher>,
231    selected_index: usize,
232    pane: WeakEntity<Pane>,
233    workspace: WeakEntity<Workspace>,
234    project: Entity<Project>,
235    matches: Vec<TabMatch>,
236    original_items: Vec<(Entity<Pane>, usize)>,
237    is_all_panes: bool,
238    restored_items: bool,
239}
240
241impl TabMatch {
242    fn icon(
243        &self,
244        project: &Entity<Project>,
245        selected: bool,
246        window: &Window,
247        cx: &App,
248    ) -> Option<DecoratedIcon> {
249        let icon = self.item.tab_icon(window, cx)?;
250        let item_settings = ItemSettings::get_global(cx);
251        let show_diagnostics = item_settings.show_diagnostics;
252        let git_status_color = item_settings
253            .git_status
254            .then(|| {
255                let path = self.item.project_path(cx)?;
256                let project = project.read(cx);
257                let entry = project.entry_for_path(&path, cx)?;
258                let git_status = project
259                    .project_path_git_status(&path, cx)
260                    .map(|status| status.summary())
261                    .unwrap_or_default();
262                Some(entry_git_aware_label_color(
263                    git_status,
264                    entry.is_ignored,
265                    selected,
266                ))
267            })
268            .flatten();
269        let colored_icon = icon.color(git_status_color.unwrap_or_default());
270
271        let most_severe_diagnostic_level = if show_diagnostics == ShowDiagnostics::Off {
272            None
273        } else {
274            let buffer_store = project.read(cx).buffer_store().read(cx);
275            let buffer = self
276                .item
277                .project_path(cx)
278                .and_then(|path| buffer_store.get_by_path(&path))
279                .map(|buffer| buffer.read(cx));
280            buffer.and_then(|buffer| {
281                buffer
282                    .buffer_diagnostics(None)
283                    .iter()
284                    .map(|diagnostic_entry| diagnostic_entry.diagnostic.severity)
285                    .min()
286            })
287        };
288
289        let decorations =
290            entry_diagnostic_aware_icon_decoration_and_color(most_severe_diagnostic_level)
291                .filter(|(d, _)| {
292                    *d != IconDecorationKind::Triangle
293                        || show_diagnostics != ShowDiagnostics::Errors
294                })
295                .map(|(icon, color)| {
296                    let knockout_item_color = if selected {
297                        cx.theme().colors().element_selected
298                    } else {
299                        cx.theme().colors().element_background
300                    };
301                    IconDecoration::new(icon, knockout_item_color, cx)
302                        .color(color.color(cx))
303                        .position(Point {
304                            x: px(-2.),
305                            y: px(-2.),
306                        })
307                });
308        Some(DecoratedIcon::new(colored_icon, decorations))
309    }
310}
311
312impl TabSwitcherDelegate {
313    #[allow(clippy::complexity)]
314    fn new(
315        project: Entity<Project>,
316        select_last: bool,
317        tab_switcher: WeakEntity<TabSwitcher>,
318        pane: WeakEntity<Pane>,
319        workspace: WeakEntity<Workspace>,
320        is_all_panes: bool,
321        window: &mut Window,
322        cx: &mut Context<TabSwitcher>,
323        original_items: Vec<(Entity<Pane>, usize)>,
324    ) -> Self {
325        Self::subscribe_to_updates(&workspace, window, cx);
326        Self {
327            select_last,
328            tab_switcher,
329            selected_index: 0,
330            pane,
331            workspace,
332            project,
333            matches: Vec::new(),
334            is_all_panes,
335            original_items,
336            restored_items: false,
337        }
338    }
339
340    fn subscribe_to_updates(
341        workspace: &WeakEntity<Workspace>,
342        window: &mut Window,
343        cx: &mut Context<TabSwitcher>,
344    ) {
345        let Some(workspace) = workspace.upgrade() else {
346            return;
347        };
348        cx.subscribe_in(&workspace, window, |tab_switcher, _, event, window, cx| {
349            match event {
350                WorkspaceEvent::ItemAdded { .. } | WorkspaceEvent::PaneRemoved => {
351                    tab_switcher.picker.update(cx, |picker, cx| {
352                        let query = picker.query(cx);
353                        picker.delegate.update_matches(query, window, cx);
354                        cx.notify();
355                    })
356                }
357                WorkspaceEvent::ItemRemoved { .. } => {
358                    tab_switcher.picker.update(cx, |picker, cx| {
359                        let query = picker.query(cx);
360                        picker.delegate.update_matches(query, window, cx);
361
362                        // When the Tab Switcher is being used and an item is
363                        // removed, there's a chance that the new selected index
364                        // will not match the actual tab that is now being displayed
365                        // by the pane, as such, the selected index needs to be
366                        // updated to match the pane's state.
367                        picker.delegate.sync_selected_index(cx);
368                        cx.notify();
369                    })
370                }
371                _ => {}
372            };
373        })
374        .detach();
375    }
376
377    fn update_all_pane_matches(
378        &mut self,
379        query: String,
380        window: &mut Window,
381        cx: &mut Context<Picker<Self>>,
382    ) {
383        let Some(workspace) = self.workspace.upgrade() else {
384            return;
385        };
386        let mut all_items = Vec::new();
387        let mut item_index = 0;
388        for pane_handle in workspace.read(cx).panes() {
389            let pane = pane_handle.read(cx);
390            let items: Vec<Box<dyn ItemHandle>> =
391                pane.items().map(|item| item.boxed_clone()).collect();
392            for ((_detail, item), detail) in items
393                .iter()
394                .enumerate()
395                .zip(tab_details(&items, window, cx))
396            {
397                all_items.push(TabMatch {
398                    pane: pane_handle.downgrade(),
399                    item_index,
400                    item: item.clone(),
401                    detail,
402                    preview: pane.is_active_preview_item(item.item_id()),
403                });
404                item_index += 1;
405            }
406        }
407
408        let matches = if query.is_empty() {
409            let history = workspace.read(cx).recently_activated_items(cx);
410            all_items
411                .sort_by_key(|tab| (Reverse(history.get(&tab.item.item_id())), tab.item_index));
412            all_items
413        } else {
414            let candidates = all_items
415                .iter()
416                .enumerate()
417                .flat_map(|(ix, tab_match)| {
418                    Some(StringMatchCandidate::new(
419                        ix,
420                        &tab_match.item.tab_content_text(0, cx),
421                    ))
422                })
423                .collect::<Vec<_>>();
424            smol::block_on(fuzzy::match_strings(
425                &candidates,
426                &query,
427                true,
428                true,
429                10000,
430                &Default::default(),
431                cx.background_executor().clone(),
432            ))
433            .into_iter()
434            .map(|m| all_items[m.candidate_id].clone())
435            .collect()
436        };
437
438        let selected_item_id = self.selected_item_id();
439        self.matches = matches;
440        self.selected_index = self.compute_selected_index(selected_item_id, window, cx);
441    }
442
443    fn update_matches(
444        &mut self,
445        query: String,
446        window: &mut Window,
447        cx: &mut Context<Picker<Self>>,
448    ) {
449        if self.is_all_panes {
450            // needed because we need to borrow the workspace, but that may be borrowed when the picker
451            // calls update_matches.
452            let this = cx.entity();
453            window.defer(cx, move |window, cx| {
454                this.update(cx, |this, cx| {
455                    this.delegate.update_all_pane_matches(query, window, cx);
456                })
457            });
458            return;
459        }
460        let selected_item_id = self.selected_item_id();
461        self.matches.clear();
462        let Some(pane) = self.pane.upgrade() else {
463            return;
464        };
465
466        let pane = pane.read(cx);
467        let mut history_indices = HashMap::default();
468        pane.activation_history().iter().rev().enumerate().for_each(
469            |(history_index, history_entry)| {
470                history_indices.insert(history_entry.entity_id, history_index);
471            },
472        );
473
474        let items: Vec<Box<dyn ItemHandle>> = pane.items().map(|item| item.boxed_clone()).collect();
475        items
476            .iter()
477            .enumerate()
478            .zip(tab_details(&items, window, cx))
479            .map(|((item_index, item), detail)| TabMatch {
480                pane: self.pane.clone(),
481                item_index,
482                item: item.boxed_clone(),
483                detail,
484                preview: pane.is_active_preview_item(item.item_id()),
485            })
486            .for_each(|tab_match| self.matches.push(tab_match));
487
488        let non_history_base = history_indices.len();
489        self.matches.sort_by(move |a, b| {
490            let a_score = *history_indices
491                .get(&a.item.item_id())
492                .unwrap_or(&(a.item_index + non_history_base));
493            let b_score = *history_indices
494                .get(&b.item.item_id())
495                .unwrap_or(&(b.item_index + non_history_base));
496            a_score.cmp(&b_score)
497        });
498
499        self.selected_index = self.compute_selected_index(selected_item_id, window, cx);
500    }
501
502    fn selected_item_id(&self) -> Option<EntityId> {
503        self.matches
504            .get(self.selected_index())
505            .map(|tab_match| tab_match.item.item_id())
506    }
507
508    fn compute_selected_index(
509        &mut self,
510        prev_selected_item_id: Option<EntityId>,
511        window: &mut Window,
512        cx: &mut Context<Picker<Self>>,
513    ) -> usize {
514        if self.matches.is_empty() {
515            return 0;
516        }
517
518        if let Some(selected_item_id) = prev_selected_item_id {
519            // If the previously selected item is still in the list, select its new position.
520            if let Some(item_index) = self
521                .matches
522                .iter()
523                .position(|tab_match| tab_match.item.item_id() == selected_item_id)
524            {
525                return item_index;
526            }
527            // Otherwise, try to preserve the previously selected index.
528            return self.selected_index.min(self.matches.len() - 1);
529        }
530
531        if self.select_last {
532            let item_index = self.matches.len() - 1;
533            self.set_selected_index(item_index, window, cx);
534            return item_index;
535        }
536
537        // This only runs when initially opening the picker
538        // Index 0 is already active, so don't preselect it for switching.
539        if self.matches.len() > 1 {
540            self.set_selected_index(1, window, cx);
541            return 1;
542        }
543
544        0
545    }
546
547    fn close_item_at(
548        &mut self,
549        ix: usize,
550        window: &mut Window,
551        cx: &mut Context<Picker<TabSwitcherDelegate>>,
552    ) {
553        let Some(tab_match) = self.matches.get(ix) else {
554            return;
555        };
556        let Some(pane) = tab_match.pane.upgrade() else {
557            return;
558        };
559
560        pane.update(cx, |pane, cx| {
561            pane.close_item_by_id(tab_match.item.item_id(), SaveIntent::Close, window, cx)
562                .detach_and_log_err(cx);
563        });
564    }
565
566    /// Updates the selected index to ensure it matches the pane's active item,
567    /// as the pane's active item can be indirectly updated and this method
568    /// ensures that the picker can react to those changes.
569    fn sync_selected_index(&mut self, cx: &mut Context<Picker<TabSwitcherDelegate>>) {
570        let item = if self.is_all_panes {
571            self.workspace
572                .read_with(cx, |workspace, cx| workspace.active_item(cx))
573        } else {
574            self.pane.read_with(cx, |pane, _cx| pane.active_item())
575        };
576
577        let Ok(Some(item)) = item else {
578            return;
579        };
580
581        let item_id = item.item_id();
582        let Some((index, _tab_match)) = self
583            .matches
584            .iter()
585            .enumerate()
586            .find(|(_index, tab_match)| tab_match.item.item_id() == item_id)
587        else {
588            return;
589        };
590
591        self.selected_index = index;
592    }
593}
594
595impl PickerDelegate for TabSwitcherDelegate {
596    type ListItem = ListItem;
597
598    fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
599        "Search all tabs…".into()
600    }
601
602    fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option<SharedString> {
603        Some("No tabs".into())
604    }
605
606    fn match_count(&self) -> usize {
607        self.matches.len()
608    }
609
610    fn selected_index(&self) -> usize {
611        self.selected_index
612    }
613
614    fn set_selected_index(
615        &mut self,
616        ix: usize,
617        window: &mut Window,
618        cx: &mut Context<Picker<Self>>,
619    ) {
620        self.selected_index = ix;
621
622        let Some(selected_match) = self.matches.get(self.selected_index()) else {
623            return;
624        };
625        selected_match
626            .pane
627            .update(cx, |pane, cx| {
628                if let Some(index) = pane.index_for_item(selected_match.item.as_ref()) {
629                    pane.activate_item(index, false, false, window, cx);
630                }
631            })
632            .ok();
633        cx.notify();
634    }
635
636    fn separators_after_indices(&self) -> Vec<usize> {
637        Vec::new()
638    }
639
640    fn update_matches(
641        &mut self,
642        raw_query: String,
643        window: &mut Window,
644        cx: &mut Context<Picker<Self>>,
645    ) -> Task<()> {
646        self.update_matches(raw_query, window, cx);
647        Task::ready(())
648    }
649
650    fn confirm(
651        &mut self,
652        _secondary: bool,
653        window: &mut Window,
654        cx: &mut Context<Picker<TabSwitcherDelegate>>,
655    ) {
656        let Some(selected_match) = self.matches.get(self.selected_index()) else {
657            return;
658        };
659
660        self.restored_items = true;
661        for (pane, index) in self.original_items.iter() {
662            pane.update(cx, |this, cx| {
663                this.activate_item(*index, false, false, window, cx);
664            })
665        }
666        selected_match
667            .pane
668            .update(cx, |pane, cx| {
669                if let Some(index) = pane.index_for_item(selected_match.item.as_ref()) {
670                    pane.activate_item(index, true, true, window, cx);
671                }
672            })
673            .ok();
674    }
675
676    fn dismissed(&mut self, window: &mut Window, cx: &mut Context<Picker<TabSwitcherDelegate>>) {
677        if !self.restored_items {
678            for (pane, index) in self.original_items.iter() {
679                pane.update(cx, |this, cx| {
680                    this.activate_item(*index, false, false, window, cx);
681                })
682            }
683        }
684
685        self.tab_switcher
686            .update(cx, |_, cx| cx.emit(DismissEvent))
687            .log_err();
688    }
689
690    fn render_match(
691        &self,
692        ix: usize,
693        selected: bool,
694        window: &mut Window,
695        cx: &mut Context<Picker<Self>>,
696    ) -> Option<Self::ListItem> {
697        let tab_match = self.matches.get(ix)?;
698
699        let params = TabContentParams {
700            detail: Some(tab_match.detail),
701            selected: true,
702            preview: tab_match.preview,
703            deemphasized: false,
704        };
705        let label = tab_match.item.tab_content(params, window, cx);
706
707        let icon = tab_match.icon(&self.project, selected, window, cx);
708
709        let indicator = render_item_indicator(tab_match.item.boxed_clone(), cx);
710        let indicator_color = if let Some(ref indicator) = indicator {
711            indicator.color
712        } else {
713            Color::default()
714        };
715        let indicator = h_flex()
716            .flex_shrink_0()
717            .children(indicator)
718            .child(div().w_2())
719            .into_any_element();
720        let close_button = div()
721            .id("close-button")
722            .on_mouse_up(
723                // We need this on_mouse_up here because on macOS you may have ctrl held
724                // down to open the menu, and a ctrl-click comes through as a right click.
725                MouseButton::Right,
726                cx.listener(move |picker, _: &MouseUpEvent, window, cx| {
727                    cx.stop_propagation();
728                    picker.delegate.close_item_at(ix, window, cx);
729                }),
730            )
731            .child(
732                IconButton::new("close_tab", IconName::Close)
733                    .icon_size(IconSize::Small)
734                    .icon_color(indicator_color)
735                    .tooltip(Tooltip::for_action_title("Close", &CloseSelectedItem))
736                    .on_click(cx.listener(move |picker, _, window, cx| {
737                        cx.stop_propagation();
738                        picker.delegate.close_item_at(ix, window, cx);
739                    })),
740            )
741            .into_any_element();
742
743        Some(
744            ListItem::new(ix)
745                .spacing(ListItemSpacing::Sparse)
746                .inset(true)
747                .toggle_state(selected)
748                .child(h_flex().w_full().child(label))
749                .start_slot::<DecoratedIcon>(icon)
750                .map(|el| {
751                    if self.selected_index == ix {
752                        el.end_slot::<AnyElement>(close_button)
753                    } else {
754                        el.end_slot::<AnyElement>(indicator)
755                            .end_hover_slot::<AnyElement>(close_button)
756                    }
757                }),
758        )
759    }
760}