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}