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                    let selected_item_id = picker.delegate.selected_item_id();
189                    picker.delegate.update_matches(cx);
190                    if let Some(item_id) = selected_item_id {
191                        picker.delegate.select_item(item_id, cx);
192                    }
193                    cx.notify();
194                }),
195                _ => {}
196            };
197        })
198        .detach();
199    }
200
201    fn update_matches(&mut self, cx: &mut WindowContext) {
202        self.matches.clear();
203        let Some(pane) = self.pane.upgrade() else {
204            return;
205        };
206
207        let pane = pane.read(cx);
208        let mut history_indices = HashMap::default();
209        pane.activation_history().iter().rev().enumerate().for_each(
210            |(history_index, history_entry)| {
211                history_indices.insert(history_entry.entity_id, history_index);
212            },
213        );
214
215        let items: Vec<Box<dyn ItemHandle>> = pane.items().map(|item| item.boxed_clone()).collect();
216        items
217            .iter()
218            .enumerate()
219            .zip(tab_details(&items, cx))
220            .map(|((item_index, item), detail)| TabMatch {
221                item_index,
222                item: item.boxed_clone(),
223                detail,
224                preview: pane.is_active_preview_item(item.item_id()),
225            })
226            .for_each(|tab_match| self.matches.push(tab_match));
227
228        let non_history_base = history_indices.len();
229        self.matches.sort_by(move |a, b| {
230            let a_score = *history_indices
231                .get(&a.item.item_id())
232                .unwrap_or(&(a.item_index + non_history_base));
233            let b_score = *history_indices
234                .get(&b.item.item_id())
235                .unwrap_or(&(b.item_index + non_history_base));
236            a_score.cmp(&b_score)
237        });
238
239        if self.matches.len() > 1 {
240            if self.select_last {
241                self.selected_index = self.matches.len() - 1;
242            } else {
243                self.selected_index = 1;
244            }
245        }
246    }
247
248    fn selected_item_id(&self) -> Option<EntityId> {
249        self.matches
250            .get(self.selected_index())
251            .map(|tab_match| tab_match.item.item_id())
252    }
253
254    fn select_item(
255        &mut self,
256        item_id: EntityId,
257        cx: &mut ViewContext<Picker<TabSwitcherDelegate>>,
258    ) {
259        let selected_idx = self
260            .matches
261            .iter()
262            .position(|tab_match| tab_match.item.item_id() == item_id)
263            .unwrap_or(0);
264        self.set_selected_index(selected_idx, cx);
265    }
266
267    fn close_item_at(&mut self, ix: usize, cx: &mut ViewContext<Picker<TabSwitcherDelegate>>) {
268        let Some(tab_match) = self.matches.get(ix) else {
269            return;
270        };
271        let Some(pane) = self.pane.upgrade() else {
272            return;
273        };
274        pane.update(cx, |pane, cx| {
275            pane.close_item_by_id(tab_match.item.item_id(), SaveIntent::Close, cx)
276                .detach_and_log_err(cx);
277        });
278    }
279}
280
281impl PickerDelegate for TabSwitcherDelegate {
282    type ListItem = ListItem;
283
284    fn placeholder_text(&self, _cx: &mut WindowContext) -> Arc<str> {
285        Arc::default()
286    }
287
288    fn no_matches_text(&self, _cx: &mut WindowContext) -> SharedString {
289        "No tabs".into()
290    }
291
292    fn match_count(&self) -> usize {
293        self.matches.len()
294    }
295
296    fn selected_index(&self) -> usize {
297        self.selected_index
298    }
299
300    fn set_selected_index(&mut self, ix: usize, cx: &mut ViewContext<Picker<Self>>) {
301        self.selected_index = ix;
302        cx.notify();
303    }
304
305    fn separators_after_indices(&self) -> Vec<usize> {
306        Vec::new()
307    }
308
309    fn update_matches(
310        &mut self,
311        _raw_query: String,
312        cx: &mut ViewContext<Picker<Self>>,
313    ) -> Task<()> {
314        self.update_matches(cx);
315        Task::ready(())
316    }
317
318    fn confirm(&mut self, _secondary: bool, cx: &mut ViewContext<Picker<TabSwitcherDelegate>>) {
319        let Some(pane) = self.pane.upgrade() else {
320            return;
321        };
322        let Some(selected_match) = self.matches.get(self.selected_index()) else {
323            return;
324        };
325        pane.update(cx, |pane, cx| {
326            pane.activate_item(selected_match.item_index, true, true, cx);
327        });
328    }
329
330    fn dismissed(&mut self, cx: &mut ViewContext<Picker<TabSwitcherDelegate>>) {
331        self.tab_switcher
332            .update(cx, |_, cx| cx.emit(DismissEvent))
333            .log_err();
334    }
335
336    fn render_match(
337        &self,
338        ix: usize,
339        selected: bool,
340        cx: &mut ViewContext<Picker<Self>>,
341    ) -> Option<Self::ListItem> {
342        let tab_match = self
343            .matches
344            .get(ix)
345            .expect("Invalid matches state: no element for index {ix}");
346
347        let params = TabContentParams {
348            detail: Some(tab_match.detail),
349            selected: true,
350            preview: tab_match.preview,
351        };
352        let label = tab_match.item.tab_content(params, cx);
353
354        let icon = tab_match.item.tab_icon(cx).map(|icon| {
355            let git_status_color = ItemSettings::get_global(cx)
356                .git_status
357                .then(|| {
358                    tab_match
359                        .item
360                        .project_path(cx)
361                        .as_ref()
362                        .and_then(|path| {
363                            let project = self.project.read(cx);
364                            let entry = project.entry_for_path(path, cx)?;
365                            let git_status = project.project_path_git_status(path, cx);
366                            Some((entry, git_status))
367                        })
368                        .map(|(entry, git_status)| {
369                            entry_git_aware_label_color(git_status, entry.is_ignored, selected)
370                        })
371                })
372                .flatten();
373
374            icon.color(git_status_color.unwrap_or_default())
375        });
376
377        let indicator = render_item_indicator(tab_match.item.boxed_clone(), cx);
378        let indicator_color = if let Some(ref indicator) = indicator {
379            indicator.color
380        } else {
381            Color::default()
382        };
383        let indicator = h_flex()
384            .flex_shrink_0()
385            .children(indicator)
386            .child(div().w_2())
387            .into_any_element();
388        let close_button = div()
389            // We need this on_mouse_up here instead of on_click on the close
390            // button because Picker intercepts the same events and handles them
391            // as click's on list items.
392            // See the same handler in Picker for more details.
393            .on_mouse_up(
394                MouseButton::Right,
395                cx.listener(move |picker, _: &MouseUpEvent, cx| {
396                    cx.stop_propagation();
397                    picker.delegate.close_item_at(ix, cx);
398                }),
399            )
400            .child(
401                IconButton::new("close_tab", IconName::Close)
402                    .icon_size(IconSize::Small)
403                    .icon_color(indicator_color)
404                    .tooltip(|cx| Tooltip::text("Close", cx)),
405            )
406            .into_any_element();
407
408        Some(
409            ListItem::new(ix)
410                .spacing(ListItemSpacing::Sparse)
411                .inset(true)
412                .toggle_state(selected)
413                .child(h_flex().w_full().child(label))
414                .start_slot::<Icon>(icon)
415                .map(|el| {
416                    if self.selected_index == ix {
417                        el.end_slot::<AnyElement>(close_button)
418                    } else {
419                        el.end_slot::<AnyElement>(indicator)
420                            .end_hover_slot::<AnyElement>(close_button)
421                    }
422                }),
423        )
424    }
425}