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}