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