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