tab_switcher.rs

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