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