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