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