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