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