tab_switcher.rs

  1#[cfg(test)]
  2mod tab_switcher_tests;
  3
  4use collections::HashMap;
  5use gpui::{
  6    actions, impl_actions, rems, Action, AnyElement, AppContext, DismissEvent, EntityId,
  7    EventEmitter, FocusHandle, FocusableView, Modifiers, ModifiersChangedEvent, MouseButton,
  8    MouseUpEvent, ParentElement, Render, Styled, Task, View, ViewContext, VisualContext, WeakView,
  9};
 10use picker::{Picker, PickerDelegate};
 11use serde::Deserialize;
 12use std::sync::Arc;
 13use ui::{prelude::*, ListItem, ListItemSpacing, Tooltip};
 14use util::ResultExt;
 15use workspace::{
 16    item::{ItemHandle, TabContentParams},
 17    pane::{render_item_indicator, tab_details, Event as PaneEvent},
 18    ModalView, Pane, SaveIntent, Workspace,
 19};
 20
 21const PANEL_WIDTH_REMS: f32 = 28.;
 22
 23#[derive(PartialEq, Clone, Deserialize, Default)]
 24pub struct Toggle {
 25    #[serde(default)]
 26    pub select_last: bool,
 27}
 28
 29impl_actions!(tab_switcher, [Toggle]);
 30actions!(tab_switcher, [CloseSelectedItem]);
 31
 32pub struct TabSwitcher {
 33    picker: View<Picker<TabSwitcherDelegate>>,
 34    init_modifiers: Option<Modifiers>,
 35}
 36
 37impl ModalView for TabSwitcher {}
 38
 39pub fn init(cx: &mut AppContext) {
 40    cx.observe_new_views(TabSwitcher::register).detach();
 41}
 42
 43impl TabSwitcher {
 44    fn register(workspace: &mut Workspace, _: &mut ViewContext<Workspace>) {
 45        workspace.register_action(|workspace, action: &Toggle, cx| {
 46            let Some(tab_switcher) = workspace.active_modal::<Self>(cx) else {
 47                Self::open(action, workspace, cx);
 48                return;
 49            };
 50
 51            tab_switcher.update(cx, |tab_switcher, cx| {
 52                tab_switcher
 53                    .picker
 54                    .update(cx, |picker, cx| picker.cycle_selection(cx))
 55            });
 56        });
 57    }
 58
 59    fn open(action: &Toggle, workspace: &mut Workspace, cx: &mut ViewContext<Workspace>) {
 60        let terminal = workspace.panel::<terminal_view::terminal_panel::TerminalPanel>(cx);
 61        let terminal_pane = terminal.and_then(|terminal| {
 62            terminal
 63                .focus_handle(cx)
 64                .contains_focused(cx)
 65                .then(|| terminal.read(cx).pane())
 66        });
 67        let weak_pane = terminal_pane
 68            .unwrap_or_else(|| workspace.active_pane())
 69            .downgrade();
 70        workspace.toggle_modal(cx, |cx| {
 71            let delegate = TabSwitcherDelegate::new(action, cx.view().downgrade(), weak_pane, cx);
 72            TabSwitcher::new(delegate, cx)
 73        });
 74    }
 75
 76    fn new(delegate: TabSwitcherDelegate, cx: &mut ViewContext<Self>) -> Self {
 77        Self {
 78            picker: cx.new_view(|cx| Picker::nonsearchable_uniform_list(delegate, cx)),
 79            init_modifiers: cx.modifiers().modified().then_some(cx.modifiers()),
 80        }
 81    }
 82
 83    fn handle_modifiers_changed(
 84        &mut self,
 85        event: &ModifiersChangedEvent,
 86        cx: &mut ViewContext<Self>,
 87    ) {
 88        let Some(init_modifiers) = self.init_modifiers else {
 89            return;
 90        };
 91        if !event.modified() || !init_modifiers.is_subset_of(event) {
 92            self.init_modifiers = None;
 93            if self.picker.read(cx).delegate.matches.is_empty() {
 94                cx.emit(DismissEvent)
 95            } else {
 96                cx.dispatch_action(menu::Confirm.boxed_clone());
 97            }
 98        }
 99    }
100
101    fn handle_close_selected_item(&mut self, _: &CloseSelectedItem, cx: &mut ViewContext<Self>) {
102        self.picker.update(cx, |picker, cx| {
103            picker
104                .delegate
105                .close_item_at(picker.delegate.selected_index(), cx)
106        });
107    }
108}
109
110impl EventEmitter<DismissEvent> for TabSwitcher {}
111
112impl FocusableView for TabSwitcher {
113    fn focus_handle(&self, cx: &AppContext) -> FocusHandle {
114        self.picker.focus_handle(cx)
115    }
116}
117
118impl Render for TabSwitcher {
119    fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
120        v_flex()
121            .key_context("TabSwitcher")
122            .w(rems(PANEL_WIDTH_REMS))
123            .on_modifiers_changed(cx.listener(Self::handle_modifiers_changed))
124            .on_action(cx.listener(Self::handle_close_selected_item))
125            .child(self.picker.clone())
126    }
127}
128
129struct TabMatch {
130    item_index: usize,
131    item: Box<dyn ItemHandle>,
132    detail: usize,
133    preview: bool,
134}
135
136pub struct TabSwitcherDelegate {
137    select_last: bool,
138    tab_switcher: WeakView<TabSwitcher>,
139    selected_index: usize,
140    pane: WeakView<Pane>,
141    matches: Vec<TabMatch>,
142}
143
144impl TabSwitcherDelegate {
145    fn new(
146        action: &Toggle,
147        tab_switcher: WeakView<TabSwitcher>,
148        pane: WeakView<Pane>,
149        cx: &mut ViewContext<TabSwitcher>,
150    ) -> Self {
151        Self::subscribe_to_updates(&pane, cx);
152        Self {
153            select_last: action.select_last,
154            tab_switcher,
155            selected_index: 0,
156            pane,
157            matches: Vec::new(),
158        }
159    }
160
161    fn subscribe_to_updates(pane: &WeakView<Pane>, cx: &mut ViewContext<TabSwitcher>) {
162        let Some(pane) = pane.upgrade() else {
163            return;
164        };
165        cx.subscribe(&pane, |tab_switcher, _, event, cx| {
166            match event {
167                PaneEvent::AddItem { .. } | PaneEvent::RemoveItem { .. } | PaneEvent::Remove => {
168                    tab_switcher.picker.update(cx, |picker, cx| {
169                        let selected_item_id = picker.delegate.selected_item_id();
170                        picker.delegate.update_matches(cx);
171                        if let Some(item_id) = selected_item_id {
172                            picker.delegate.select_item(item_id, cx);
173                        }
174                        cx.notify();
175                    })
176                }
177                _ => {}
178            };
179        })
180        .detach();
181    }
182
183    fn update_matches(&mut self, cx: &mut WindowContext) {
184        self.matches.clear();
185        let Some(pane) = self.pane.upgrade() else {
186            return;
187        };
188
189        let pane = pane.read(cx);
190        let mut history_indices = HashMap::default();
191        pane.activation_history().iter().rev().enumerate().for_each(
192            |(history_index, history_entry)| {
193                history_indices.insert(history_entry.entity_id, history_index);
194            },
195        );
196
197        let items: Vec<Box<dyn ItemHandle>> = pane.items().map(|item| item.boxed_clone()).collect();
198        items
199            .iter()
200            .enumerate()
201            .zip(tab_details(&items, cx))
202            .map(|((item_index, item), detail)| TabMatch {
203                item_index,
204                item: item.boxed_clone(),
205                detail,
206                preview: pane.is_active_preview_item(item.item_id()),
207            })
208            .for_each(|tab_match| self.matches.push(tab_match));
209
210        let non_history_base = history_indices.len();
211        self.matches.sort_by(move |a, b| {
212            let a_score = *history_indices
213                .get(&a.item.item_id())
214                .unwrap_or(&(a.item_index + non_history_base));
215            let b_score = *history_indices
216                .get(&b.item.item_id())
217                .unwrap_or(&(b.item_index + non_history_base));
218            a_score.cmp(&b_score)
219        });
220
221        if self.matches.len() > 1 {
222            if self.select_last {
223                self.selected_index = self.matches.len() - 1;
224            } else {
225                self.selected_index = 1;
226            }
227        }
228    }
229
230    fn selected_item_id(&self) -> Option<EntityId> {
231        self.matches
232            .get(self.selected_index())
233            .map(|tab_match| tab_match.item.item_id())
234    }
235
236    fn select_item(
237        &mut self,
238        item_id: EntityId,
239        cx: &mut ViewContext<'_, Picker<TabSwitcherDelegate>>,
240    ) {
241        let selected_idx = self
242            .matches
243            .iter()
244            .position(|tab_match| tab_match.item.item_id() == item_id)
245            .unwrap_or(0);
246        self.set_selected_index(selected_idx, cx);
247    }
248
249    fn close_item_at(&mut self, ix: usize, cx: &mut ViewContext<'_, Picker<TabSwitcherDelegate>>) {
250        let Some(tab_match) = self.matches.get(ix) else {
251            return;
252        };
253        let Some(pane) = self.pane.upgrade() else {
254            return;
255        };
256        pane.update(cx, |pane, cx| {
257            pane.close_item_by_id(tab_match.item.item_id(), SaveIntent::Close, cx)
258                .detach_and_log_err(cx);
259        });
260    }
261}
262
263impl PickerDelegate for TabSwitcherDelegate {
264    type ListItem = ListItem;
265
266    fn placeholder_text(&self, _cx: &mut WindowContext) -> Arc<str> {
267        "".into()
268    }
269
270    fn no_matches_text(&self, _cx: &mut WindowContext) -> SharedString {
271        "No tabs".into()
272    }
273
274    fn match_count(&self) -> usize {
275        self.matches.len()
276    }
277
278    fn selected_index(&self) -> usize {
279        self.selected_index
280    }
281
282    fn set_selected_index(&mut self, ix: usize, cx: &mut ViewContext<Picker<Self>>) {
283        self.selected_index = ix;
284        cx.notify();
285    }
286
287    fn separators_after_indices(&self) -> Vec<usize> {
288        Vec::new()
289    }
290
291    fn update_matches(
292        &mut self,
293        _raw_query: String,
294        cx: &mut ViewContext<Picker<Self>>,
295    ) -> Task<()> {
296        self.update_matches(cx);
297        Task::ready(())
298    }
299
300    fn confirm(&mut self, _secondary: bool, cx: &mut ViewContext<Picker<TabSwitcherDelegate>>) {
301        let Some(pane) = self.pane.upgrade() else {
302            return;
303        };
304        let Some(selected_match) = self.matches.get(self.selected_index()) else {
305            return;
306        };
307        pane.update(cx, |pane, cx| {
308            pane.activate_item(selected_match.item_index, true, true, cx);
309        });
310    }
311
312    fn dismissed(&mut self, cx: &mut ViewContext<Picker<TabSwitcherDelegate>>) {
313        self.tab_switcher
314            .update(cx, |_, cx| cx.emit(DismissEvent))
315            .log_err();
316    }
317
318    fn render_match(
319        &self,
320        ix: usize,
321        selected: bool,
322        cx: &mut ViewContext<Picker<Self>>,
323    ) -> Option<Self::ListItem> {
324        let tab_match = self
325            .matches
326            .get(ix)
327            .expect("Invalid matches state: no element for index {ix}");
328
329        let params = TabContentParams {
330            detail: Some(tab_match.detail),
331            selected: true,
332            preview: tab_match.preview,
333        };
334        let label = tab_match.item.tab_content(params, cx);
335        let indicator = render_item_indicator(tab_match.item.boxed_clone(), cx);
336        let indicator_color = if let Some(ref indicator) = indicator {
337            indicator.color
338        } else {
339            Color::default()
340        };
341        let indicator = h_flex()
342            .flex_shrink_0()
343            .children(indicator)
344            .child(div().w_2())
345            .into_any_element();
346        let close_button = div()
347            // We need this on_mouse_up here instead of on_click on the close
348            // button because Picker intercepts the same events and handles them
349            // as click's on list items.
350            // See the same handler in Picker for more details.
351            .on_mouse_up(
352                MouseButton::Right,
353                cx.listener(move |picker, _: &MouseUpEvent, cx| {
354                    cx.stop_propagation();
355                    picker.delegate.close_item_at(ix, cx);
356                }),
357            )
358            .child(
359                IconButton::new("close_tab", IconName::Close)
360                    .icon_size(IconSize::Small)
361                    .icon_color(indicator_color)
362                    .tooltip(|cx| Tooltip::text("Close", cx)),
363            )
364            .into_any_element();
365
366        Some(
367            ListItem::new(ix)
368                .spacing(ListItemSpacing::Sparse)
369                .inset(true)
370                .selected(selected)
371                .child(h_flex().w_full().child(label))
372                .map(|el| {
373                    if self.selected_index == ix {
374                        el.end_slot::<AnyElement>(close_button)
375                    } else {
376                        el.end_slot::<AnyElement>(indicator)
377                            .end_hover_slot::<AnyElement>(close_button)
378                    }
379                }),
380        )
381    }
382}