tab_switcher_tests.rs

  1use super::*;
  2use editor::Editor;
  3use gpui::{TestAppContext, VisualTestContext};
  4use menu::SelectPrev;
  5use project::{Project, ProjectPath};
  6use serde_json::json;
  7use std::path::Path;
  8use workspace::{AppState, Workspace};
  9
 10#[ctor::ctor]
 11fn init_logger() {
 12    if std::env::var("RUST_LOG").is_ok() {
 13        env_logger::init();
 14    }
 15}
 16
 17#[gpui::test]
 18async fn test_open_with_prev_tab_selected_and_cycle_on_toggle_action(
 19    cx: &mut gpui::TestAppContext,
 20) {
 21    let app_state = init_test(cx);
 22
 23    app_state
 24        .fs
 25        .as_fake()
 26        .insert_tree(
 27            "/root",
 28            json!({
 29                "1.txt": "First file",
 30                "2.txt": "Second file",
 31                "3.txt": "Third file",
 32                "4.txt": "Fourth file",
 33            }),
 34        )
 35        .await;
 36
 37    let project = Project::test(app_state.fs.clone(), ["/root".as_ref()], cx).await;
 38    let (workspace, cx) =
 39        cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
 40
 41    let tab_1 = open_buffer("1.txt", &workspace, cx).await;
 42    let tab_2 = open_buffer("2.txt", &workspace, cx).await;
 43    let tab_3 = open_buffer("3.txt", &workspace, cx).await;
 44    let tab_4 = open_buffer("4.txt", &workspace, cx).await;
 45
 46    // Starts with the previously opened item selected
 47    let tab_switcher = open_tab_switcher(false, &workspace, cx);
 48    tab_switcher.update(cx, |tab_switcher, _| {
 49        assert_eq!(tab_switcher.delegate.matches.len(), 4);
 50        assert_match_at_position(tab_switcher, 0, tab_4.boxed_clone());
 51        assert_match_selection(tab_switcher, 1, tab_3.boxed_clone());
 52        assert_match_at_position(tab_switcher, 2, tab_2.boxed_clone());
 53        assert_match_at_position(tab_switcher, 3, tab_1.boxed_clone());
 54    });
 55
 56    cx.dispatch_action(Toggle { select_last: false });
 57    cx.dispatch_action(Toggle { select_last: false });
 58    tab_switcher.update(cx, |tab_switcher, _| {
 59        assert_eq!(tab_switcher.delegate.matches.len(), 4);
 60        assert_match_at_position(tab_switcher, 0, tab_4.boxed_clone());
 61        assert_match_at_position(tab_switcher, 1, tab_3.boxed_clone());
 62        assert_match_at_position(tab_switcher, 2, tab_2.boxed_clone());
 63        assert_match_selection(tab_switcher, 3, tab_1.boxed_clone());
 64    });
 65
 66    cx.dispatch_action(SelectPrev);
 67    tab_switcher.update(cx, |tab_switcher, _| {
 68        assert_eq!(tab_switcher.delegate.matches.len(), 4);
 69        assert_match_at_position(tab_switcher, 0, tab_4.boxed_clone());
 70        assert_match_at_position(tab_switcher, 1, tab_3.boxed_clone());
 71        assert_match_selection(tab_switcher, 2, tab_2.boxed_clone());
 72        assert_match_at_position(tab_switcher, 3, tab_1.boxed_clone());
 73    });
 74}
 75
 76#[gpui::test]
 77async fn test_open_with_last_tab_selected(cx: &mut gpui::TestAppContext) {
 78    let app_state = init_test(cx);
 79
 80    app_state
 81        .fs
 82        .as_fake()
 83        .insert_tree(
 84            "/root",
 85            json!({
 86                "1.txt": "First file",
 87                "2.txt": "Second file",
 88                "3.txt": "Third file",
 89            }),
 90        )
 91        .await;
 92
 93    let project = Project::test(app_state.fs.clone(), ["/root".as_ref()], cx).await;
 94    let (workspace, cx) =
 95        cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
 96
 97    let tab_1 = open_buffer("1.txt", &workspace, cx).await;
 98    let tab_2 = open_buffer("2.txt", &workspace, cx).await;
 99    let tab_3 = open_buffer("3.txt", &workspace, cx).await;
100
101    // Starts with the last item selected
102    let tab_switcher = open_tab_switcher(true, &workspace, cx);
103    tab_switcher.update(cx, |tab_switcher, _| {
104        assert_eq!(tab_switcher.delegate.matches.len(), 3);
105        assert_match_at_position(tab_switcher, 0, tab_3);
106        assert_match_at_position(tab_switcher, 1, tab_2);
107        assert_match_selection(tab_switcher, 2, tab_1);
108    });
109}
110
111#[gpui::test]
112async fn test_open_item_on_modifiers_release(cx: &mut gpui::TestAppContext) {
113    let app_state = init_test(cx);
114
115    app_state
116        .fs
117        .as_fake()
118        .insert_tree(
119            "/root",
120            json!({
121                "1.txt": "First file",
122                "2.txt": "Second file",
123            }),
124        )
125        .await;
126
127    let project = Project::test(app_state.fs.clone(), ["/root".as_ref()], cx).await;
128    let (workspace, cx) =
129        cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
130
131    let tab_1 = open_buffer("1.txt", &workspace, cx).await;
132    let tab_2 = open_buffer("2.txt", &workspace, cx).await;
133
134    cx.simulate_modifiers_change(Modifiers::control());
135    let tab_switcher = open_tab_switcher(false, &workspace, cx);
136    tab_switcher.update(cx, |tab_switcher, _| {
137        assert_eq!(tab_switcher.delegate.matches.len(), 2);
138        assert_match_at_position(tab_switcher, 0, tab_2.boxed_clone());
139        assert_match_selection(tab_switcher, 1, tab_1.boxed_clone());
140    });
141
142    cx.simulate_modifiers_change(Modifiers::none());
143    cx.read(|cx| {
144        let active_editor = workspace.read(cx).active_item_as::<Editor>(cx).unwrap();
145        assert_eq!(active_editor.read(cx).title(cx), "1.txt");
146    });
147    assert_tab_switcher_is_closed(workspace, cx);
148}
149
150#[gpui::test]
151async fn test_open_on_empty_pane(cx: &mut gpui::TestAppContext) {
152    let app_state = init_test(cx);
153    app_state.fs.as_fake().insert_tree("/root", json!({})).await;
154
155    let project = Project::test(app_state.fs.clone(), ["/root".as_ref()], cx).await;
156    let (workspace, cx) =
157        cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
158
159    cx.simulate_modifiers_change(Modifiers::control());
160    let tab_switcher = open_tab_switcher(false, &workspace, cx);
161    tab_switcher.update(cx, |tab_switcher, _| {
162        assert!(tab_switcher.delegate.matches.is_empty());
163    });
164
165    cx.simulate_modifiers_change(Modifiers::none());
166    assert_tab_switcher_is_closed(workspace, cx);
167}
168
169#[gpui::test]
170async fn test_open_with_single_item(cx: &mut gpui::TestAppContext) {
171    let app_state = init_test(cx);
172    app_state
173        .fs
174        .as_fake()
175        .insert_tree("/root", json!({"1.txt": "Single file"}))
176        .await;
177
178    let project = Project::test(app_state.fs.clone(), ["/root".as_ref()], cx).await;
179    let (workspace, cx) =
180        cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
181
182    let tab = open_buffer("1.txt", &workspace, cx).await;
183
184    let tab_switcher = open_tab_switcher(false, &workspace, cx);
185    tab_switcher.update(cx, |tab_switcher, _| {
186        assert_eq!(tab_switcher.delegate.matches.len(), 1);
187        assert_match_selection(tab_switcher, 0, tab);
188    });
189}
190
191#[gpui::test]
192async fn test_close_selected_item(cx: &mut gpui::TestAppContext) {
193    let app_state = init_test(cx);
194    app_state
195        .fs
196        .as_fake()
197        .insert_tree(
198            "/root",
199            json!({
200                "1.txt": "First file",
201                "2.txt": "Second file",
202            }),
203        )
204        .await;
205
206    let project = Project::test(app_state.fs.clone(), ["/root".as_ref()], cx).await;
207    let (workspace, cx) =
208        cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
209
210    let tab_1 = open_buffer("1.txt", &workspace, cx).await;
211    let tab_2 = open_buffer("2.txt", &workspace, cx).await;
212
213    cx.simulate_modifiers_change(Modifiers::control());
214    let tab_switcher = open_tab_switcher(false, &workspace, cx);
215    tab_switcher.update(cx, |tab_switcher, _| {
216        assert_eq!(tab_switcher.delegate.matches.len(), 2);
217        assert_match_at_position(tab_switcher, 0, tab_2.boxed_clone());
218        assert_match_selection(tab_switcher, 1, tab_1.boxed_clone());
219    });
220
221    cx.simulate_modifiers_change(Modifiers::control());
222    cx.dispatch_action(CloseSelectedItem);
223    tab_switcher.update(cx, |tab_switcher, _| {
224        assert_eq!(tab_switcher.delegate.matches.len(), 1);
225        assert_match_selection(tab_switcher, 0, tab_2);
226    });
227
228    // Still switches tab on modifiers release
229    cx.simulate_modifiers_change(Modifiers::none());
230    cx.read(|cx| {
231        let active_editor = workspace.read(cx).active_item_as::<Editor>(cx).unwrap();
232        assert_eq!(active_editor.read(cx).title(cx), "2.txt");
233    });
234    assert_tab_switcher_is_closed(workspace, cx);
235}
236
237#[gpui::test]
238async fn test_close_preserves_selected_position(cx: &mut gpui::TestAppContext) {
239    let app_state = init_test(cx);
240    app_state
241        .fs
242        .as_fake()
243        .insert_tree(
244            "/root",
245            json!({
246                "1.txt": "First file",
247                "2.txt": "Second file",
248                "3.txt": "Third file",
249            }),
250        )
251        .await;
252
253    let project = Project::test(app_state.fs.clone(), ["/root".as_ref()], cx).await;
254    let (workspace, cx) =
255        cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
256
257    let tab_1 = open_buffer("1.txt", &workspace, cx).await;
258    let tab_2 = open_buffer("2.txt", &workspace, cx).await;
259    let tab_3 = open_buffer("3.txt", &workspace, cx).await;
260
261    let tab_switcher = open_tab_switcher(false, &workspace, cx);
262    tab_switcher.update(cx, |tab_switcher, _| {
263        assert_eq!(tab_switcher.delegate.matches.len(), 3);
264        assert_match_at_position(tab_switcher, 0, tab_3.boxed_clone());
265        assert_match_selection(tab_switcher, 1, tab_2.boxed_clone());
266        assert_match_at_position(tab_switcher, 2, tab_1.boxed_clone());
267    });
268
269    // Verify that if the selected tab was closed, tab at the same position is selected.
270    cx.dispatch_action(CloseSelectedItem);
271    tab_switcher.update(cx, |tab_switcher, _| {
272        assert_eq!(tab_switcher.delegate.matches.len(), 2);
273        assert_match_at_position(tab_switcher, 0, tab_3.boxed_clone());
274        assert_match_selection(tab_switcher, 1, tab_1.boxed_clone());
275    });
276
277    // But if the position is no longer valid, fall back to the position above.
278    cx.dispatch_action(CloseSelectedItem);
279    tab_switcher.update(cx, |tab_switcher, _| {
280        assert_eq!(tab_switcher.delegate.matches.len(), 1);
281        assert_match_selection(tab_switcher, 0, tab_3.boxed_clone());
282    });
283}
284
285fn init_test(cx: &mut TestAppContext) -> Arc<AppState> {
286    cx.update(|cx| {
287        let state = AppState::test(cx);
288        theme::init(theme::LoadThemes::JustBase, cx);
289        language::init(cx);
290        super::init(cx);
291        editor::init(cx);
292        workspace::init_settings(cx);
293        Project::init_settings(cx);
294        state
295    })
296}
297
298#[track_caller]
299fn open_tab_switcher(
300    select_last: bool,
301    workspace: &Entity<Workspace>,
302    cx: &mut VisualTestContext,
303) -> Entity<Picker<TabSwitcherDelegate>> {
304    cx.dispatch_action(Toggle { select_last });
305    get_active_tab_switcher(workspace, cx)
306}
307
308#[track_caller]
309fn get_active_tab_switcher(
310    workspace: &Entity<Workspace>,
311    cx: &mut VisualTestContext,
312) -> Entity<Picker<TabSwitcherDelegate>> {
313    workspace.update(cx, |workspace, cx| {
314        workspace
315            .active_modal::<TabSwitcher>(cx)
316            .expect("tab switcher is not open")
317            .read(cx)
318            .picker
319            .clone()
320    })
321}
322
323async fn open_buffer(
324    file_path: &str,
325    workspace: &Entity<Workspace>,
326    cx: &mut gpui::VisualTestContext,
327) -> Box<dyn ItemHandle> {
328    let project = workspace.update(cx, |workspace, _| workspace.project().clone());
329    let worktree_id = project.update(cx, |project, cx| {
330        let worktree = project.worktrees(cx).last().expect("worktree not found");
331        worktree.read(cx).id()
332    });
333    let project_path = ProjectPath {
334        worktree_id,
335        path: Arc::from(Path::new(file_path)),
336    };
337    workspace
338        .update_in(cx, move |workspace, window, cx| {
339            workspace.open_path(project_path, None, true, window, cx)
340        })
341        .await
342        .unwrap()
343}
344
345#[track_caller]
346fn assert_match_selection(
347    tab_switcher: &Picker<TabSwitcherDelegate>,
348    expected_selection_index: usize,
349    expected_item: Box<dyn ItemHandle>,
350) {
351    assert_eq!(
352        tab_switcher.delegate.selected_index(),
353        expected_selection_index,
354        "item is not selected"
355    );
356    assert_match_at_position(tab_switcher, expected_selection_index, expected_item);
357}
358
359#[track_caller]
360fn assert_match_at_position(
361    tab_switcher: &Picker<TabSwitcherDelegate>,
362    match_index: usize,
363    expected_item: Box<dyn ItemHandle>,
364) {
365    let match_item = tab_switcher
366        .delegate
367        .matches
368        .get(match_index)
369        .unwrap_or_else(|| panic!("Tab Switcher has no match for index {match_index}"));
370    assert_eq!(match_item.item.item_id(), expected_item.item_id());
371}
372
373#[track_caller]
374fn assert_tab_switcher_is_closed(workspace: Entity<Workspace>, cx: &mut VisualTestContext) {
375    workspace.update(cx, |workspace, cx| {
376        assert!(
377            workspace.active_modal::<TabSwitcher>(cx).is_none(),
378            "tab switcher is still open"
379        );
380    });
381}