tab_switcher_tests.rs

  1use super::*;
  2use editor::Editor;
  3use gpui::{TestAppContext, VisualTestContext};
  4use menu::SelectPrevious;
  5use project::{Project, ProjectPath};
  6use serde_json::json;
  7use util::{path, rel_path::rel_path};
  8use workspace::{ActivatePreviousItem, AppState, MultiWorkspace, Workspace, item::test::TestItem};
  9
 10#[ctor::ctor]
 11fn init_logger() {
 12    zlog::init_test();
 13}
 14
 15#[gpui::test]
 16async fn test_open_with_prev_tab_selected_and_cycle_on_toggle_action(
 17    cx: &mut gpui::TestAppContext,
 18) {
 19    let app_state = init_test(cx);
 20
 21    app_state
 22        .fs
 23        .as_fake()
 24        .insert_tree(
 25            path!("/root"),
 26            json!({
 27                "1.txt": "First file",
 28                "2.txt": "Second file",
 29                "3.txt": "Third file",
 30                "4.txt": "Fourth file",
 31            }),
 32        )
 33        .await;
 34
 35    let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await;
 36    let (multi_workspace, cx) =
 37        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 38    let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
 39
 40    let tab_1 = open_buffer("1.txt", &workspace, cx).await;
 41    let tab_2 = open_buffer("2.txt", &workspace, cx).await;
 42    let tab_3 = open_buffer("3.txt", &workspace, cx).await;
 43    let tab_4 = open_buffer("4.txt", &workspace, cx).await;
 44
 45    // Starts with the previously opened item selected
 46    let tab_switcher = open_tab_switcher(false, &workspace, cx);
 47    tab_switcher.update(cx, |tab_switcher, _| {
 48        assert_eq!(tab_switcher.delegate.matches.len(), 4);
 49        assert_match_at_position(tab_switcher, 0, tab_4.boxed_clone());
 50        assert_match_selection(tab_switcher, 1, tab_3.boxed_clone());
 51        assert_match_at_position(tab_switcher, 2, tab_2.boxed_clone());
 52        assert_match_at_position(tab_switcher, 3, tab_1.boxed_clone());
 53    });
 54
 55    cx.dispatch_action(Toggle { select_last: false });
 56    cx.dispatch_action(Toggle { select_last: false });
 57    tab_switcher.update(cx, |tab_switcher, _| {
 58        assert_eq!(tab_switcher.delegate.matches.len(), 4);
 59        assert_match_at_position(tab_switcher, 0, tab_4.boxed_clone());
 60        assert_match_at_position(tab_switcher, 1, tab_3.boxed_clone());
 61        assert_match_at_position(tab_switcher, 2, tab_2.boxed_clone());
 62        assert_match_selection(tab_switcher, 3, tab_1.boxed_clone());
 63    });
 64
 65    cx.dispatch_action(SelectPrevious);
 66    tab_switcher.update(cx, |tab_switcher, _| {
 67        assert_eq!(tab_switcher.delegate.matches.len(), 4);
 68        assert_match_at_position(tab_switcher, 0, tab_4.boxed_clone());
 69        assert_match_at_position(tab_switcher, 1, tab_3.boxed_clone());
 70        assert_match_selection(tab_switcher, 2, tab_2.boxed_clone());
 71        assert_match_at_position(tab_switcher, 3, tab_1.boxed_clone());
 72    });
 73}
 74
 75#[gpui::test]
 76async fn test_open_with_last_tab_selected(cx: &mut gpui::TestAppContext) {
 77    let app_state = init_test(cx);
 78
 79    app_state
 80        .fs
 81        .as_fake()
 82        .insert_tree(
 83            path!("/root"),
 84            json!({
 85                "1.txt": "First file",
 86                "2.txt": "Second file",
 87                "3.txt": "Third file",
 88            }),
 89        )
 90        .await;
 91
 92    let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await;
 93    let (multi_workspace, cx) =
 94        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 95    let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
 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            path!("/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(), [path!("/root").as_ref()], cx).await;
128    let (multi_workspace, cx) =
129        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
130    let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
131
132    let tab_1 = open_buffer("1.txt", &workspace, cx).await;
133    let tab_2 = open_buffer("2.txt", &workspace, cx).await;
134
135    cx.simulate_modifiers_change(Modifiers::control());
136    let tab_switcher = open_tab_switcher(false, &workspace, cx);
137    tab_switcher.update(cx, |tab_switcher, _| {
138        assert_eq!(tab_switcher.delegate.matches.len(), 2);
139        assert_match_at_position(tab_switcher, 0, tab_2.boxed_clone());
140        assert_match_selection(tab_switcher, 1, tab_1.boxed_clone());
141    });
142
143    cx.simulate_modifiers_change(Modifiers::none());
144    cx.read(|cx| {
145        let active_editor = workspace.read(cx).active_item_as::<Editor>(cx).unwrap();
146        assert_eq!(active_editor.read(cx).title(cx), "1.txt");
147    });
148    assert_tab_switcher_is_closed(workspace, cx);
149}
150
151#[gpui::test]
152async fn test_open_on_empty_pane(cx: &mut gpui::TestAppContext) {
153    let app_state = init_test(cx);
154    app_state.fs.as_fake().insert_tree("/root", json!({})).await;
155
156    let project = Project::test(app_state.fs.clone(), ["/root".as_ref()], cx).await;
157    let (multi_workspace, cx) =
158        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
159    let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
160
161    cx.simulate_modifiers_change(Modifiers::control());
162    let tab_switcher = open_tab_switcher(false, &workspace, cx);
163    tab_switcher.update(cx, |tab_switcher, _| {
164        assert!(tab_switcher.delegate.matches.is_empty());
165    });
166
167    cx.simulate_modifiers_change(Modifiers::none());
168    assert_tab_switcher_is_closed(workspace, cx);
169}
170
171#[gpui::test]
172async fn test_open_with_single_item(cx: &mut gpui::TestAppContext) {
173    let app_state = init_test(cx);
174    app_state
175        .fs
176        .as_fake()
177        .insert_tree(path!("/root"), json!({"1.txt": "Single file"}))
178        .await;
179
180    let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await;
181    let (multi_workspace, cx) =
182        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
183    let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
184
185    let tab = open_buffer("1.txt", &workspace, cx).await;
186
187    let tab_switcher = open_tab_switcher(false, &workspace, cx);
188    tab_switcher.update(cx, |tab_switcher, _| {
189        assert_eq!(tab_switcher.delegate.matches.len(), 1);
190        assert_match_selection(tab_switcher, 0, tab);
191    });
192}
193
194#[gpui::test]
195async fn test_close_selected_item(cx: &mut gpui::TestAppContext) {
196    let app_state = init_test(cx);
197    app_state
198        .fs
199        .as_fake()
200        .insert_tree(
201            path!("/root"),
202            json!({
203                "1.txt": "First file",
204                "2.txt": "Second file",
205                "3.txt": "Third file",
206                "4.txt": "Fourth file",
207            }),
208        )
209        .await;
210
211    let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await;
212    let (multi_workspace, cx) =
213        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
214    let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
215
216    let tab_1 = open_buffer("1.txt", &workspace, cx).await;
217    let tab_3 = open_buffer("3.txt", &workspace, cx).await;
218    let tab_2 = open_buffer("2.txt", &workspace, cx).await;
219    let tab_4 = open_buffer("4.txt", &workspace, cx).await;
220
221    // After opening all buffers, let's navigate to the previous item two times, finishing with:
222    //
223    // 1.txt | [3.txt] | 2.txt | 4.txt
224    //
225    // With 3.txt being the active item in the pane.
226    cx.dispatch_action(ActivatePreviousItem);
227    cx.dispatch_action(ActivatePreviousItem);
228    cx.run_until_parked();
229
230    cx.simulate_modifiers_change(Modifiers::control());
231    let tab_switcher = open_tab_switcher(false, &workspace, cx);
232    tab_switcher.update(cx, |tab_switcher, _| {
233        assert_eq!(tab_switcher.delegate.matches.len(), 4);
234        assert_match_at_position(tab_switcher, 0, tab_3.boxed_clone());
235        assert_match_selection(tab_switcher, 1, tab_2.boxed_clone());
236        assert_match_at_position(tab_switcher, 2, tab_4.boxed_clone());
237        assert_match_at_position(tab_switcher, 3, tab_1.boxed_clone());
238    });
239
240    cx.simulate_modifiers_change(Modifiers::control());
241    cx.dispatch_action(CloseSelectedItem);
242    tab_switcher.update(cx, |tab_switcher, _| {
243        assert_eq!(tab_switcher.delegate.matches.len(), 3);
244        assert_match_selection(tab_switcher, 0, tab_3);
245        assert_match_at_position(tab_switcher, 1, tab_4);
246        assert_match_at_position(tab_switcher, 2, tab_1);
247    });
248
249    // Still switches tab on modifiers release
250    cx.simulate_modifiers_change(Modifiers::none());
251    cx.read(|cx| {
252        let active_editor = workspace.read(cx).active_item_as::<Editor>(cx).unwrap();
253        assert_eq!(active_editor.read(cx).title(cx), "3.txt");
254    });
255    assert_tab_switcher_is_closed(workspace, cx);
256}
257
258fn init_test(cx: &mut TestAppContext) -> Arc<AppState> {
259    cx.update(|cx| {
260        let state = AppState::test(cx);
261        theme::init(theme::LoadThemes::JustBase, cx);
262        super::init(cx);
263        editor::init(cx);
264        state
265    })
266}
267
268#[track_caller]
269fn open_tab_switcher(
270    select_last: bool,
271    workspace: &Entity<Workspace>,
272    cx: &mut VisualTestContext,
273) -> Entity<Picker<TabSwitcherDelegate>> {
274    cx.dispatch_action(Toggle { select_last });
275    get_active_tab_switcher(workspace, cx)
276}
277
278#[track_caller]
279fn get_active_tab_switcher(
280    workspace: &Entity<Workspace>,
281    cx: &mut VisualTestContext,
282) -> Entity<Picker<TabSwitcherDelegate>> {
283    workspace.update(cx, |workspace, cx| {
284        workspace
285            .active_modal::<TabSwitcher>(cx)
286            .expect("tab switcher is not open")
287            .read(cx)
288            .picker
289            .clone()
290    })
291}
292
293async fn open_buffer(
294    file_path: &str,
295    workspace: &Entity<Workspace>,
296    cx: &mut gpui::VisualTestContext,
297) -> Box<dyn ItemHandle> {
298    let project = workspace.read_with(cx, |workspace, _| workspace.project().clone());
299    let worktree_id = project.update(cx, |project, cx| {
300        let worktree = project.worktrees(cx).last().expect("worktree not found");
301        worktree.read(cx).id()
302    });
303    let project_path = ProjectPath {
304        worktree_id,
305        path: rel_path(file_path).into(),
306    };
307    workspace
308        .update_in(cx, move |workspace, window, cx| {
309            workspace.open_path(project_path, None, true, window, cx)
310        })
311        .await
312        .unwrap()
313}
314
315#[track_caller]
316fn assert_match_selection(
317    tab_switcher: &Picker<TabSwitcherDelegate>,
318    expected_selection_index: usize,
319    expected_item: Box<dyn ItemHandle>,
320) {
321    assert_eq!(
322        tab_switcher.delegate.selected_index(),
323        expected_selection_index,
324        "item is not selected"
325    );
326    assert_match_at_position(tab_switcher, expected_selection_index, expected_item);
327}
328
329#[track_caller]
330fn assert_match_at_position(
331    tab_switcher: &Picker<TabSwitcherDelegate>,
332    match_index: usize,
333    expected_item: Box<dyn ItemHandle>,
334) {
335    let match_item = tab_switcher
336        .delegate
337        .matches
338        .get(match_index)
339        .unwrap_or_else(|| panic!("Tab Switcher has no match for index {match_index}"));
340    assert_eq!(match_item.item.item_id(), expected_item.item_id());
341}
342
343#[track_caller]
344fn assert_tab_switcher_is_closed(workspace: Entity<Workspace>, cx: &mut VisualTestContext) {
345    workspace.update(cx, |workspace, cx| {
346        assert!(
347            workspace.active_modal::<TabSwitcher>(cx).is_none(),
348            "tab switcher is still open"
349        );
350    });
351}
352
353#[track_caller]
354fn open_tab_switcher_for_active_pane(
355    workspace: &Entity<Workspace>,
356    cx: &mut VisualTestContext,
357) -> Entity<Picker<TabSwitcherDelegate>> {
358    cx.dispatch_action(OpenInActivePane);
359    get_active_tab_switcher(workspace, cx)
360}
361
362#[gpui::test]
363async fn test_open_in_active_pane_deduplicates_files_by_path(cx: &mut gpui::TestAppContext) {
364    let app_state = init_test(cx);
365    app_state
366        .fs
367        .as_fake()
368        .insert_tree(
369            path!("/root"),
370            json!({
371                "1.txt": "",
372                "2.txt": "",
373            }),
374        )
375        .await;
376
377    let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await;
378    let (multi_workspace, cx) =
379        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
380    let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
381
382    open_buffer("1.txt", &workspace, cx).await;
383    open_buffer("2.txt", &workspace, cx).await;
384
385    workspace.update_in(cx, |workspace, window, cx| {
386        workspace.split_pane(
387            workspace.active_pane().clone(),
388            workspace::SplitDirection::Right,
389            window,
390            cx,
391        );
392    });
393    open_buffer("1.txt", &workspace, cx).await;
394
395    let tab_switcher = open_tab_switcher_for_active_pane(&workspace, cx);
396
397    tab_switcher.read_with(cx, |picker, _cx| {
398        assert_eq!(
399            picker.delegate.matches.len(),
400            2,
401            "should show 2 unique files despite 3 tabs"
402        );
403    });
404}
405
406#[gpui::test]
407async fn test_open_in_active_pane_clones_files_to_current_pane(cx: &mut gpui::TestAppContext) {
408    let app_state = init_test(cx);
409    app_state
410        .fs
411        .as_fake()
412        .insert_tree(path!("/root"), json!({"1.txt": ""}))
413        .await;
414
415    let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await;
416    let (multi_workspace, cx) =
417        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
418    let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
419
420    open_buffer("1.txt", &workspace, cx).await;
421
422    workspace.update_in(cx, |workspace, window, cx| {
423        workspace.split_pane(
424            workspace.active_pane().clone(),
425            workspace::SplitDirection::Right,
426            window,
427            cx,
428        );
429    });
430
431    let panes = workspace.read_with(cx, |workspace, _| workspace.panes().to_vec());
432
433    let tab_switcher = open_tab_switcher_for_active_pane(&workspace, cx);
434    tab_switcher.update(cx, |picker, _| {
435        picker.delegate.selected_index = 0;
436    });
437
438    cx.dispatch_action(menu::Confirm);
439    cx.run_until_parked();
440
441    let editor_1 = panes[0].read_with(cx, |pane, cx| {
442        pane.active_item()
443            .and_then(|item| item.act_as::<Editor>(cx))
444            .expect("pane 1 should have editor")
445    });
446
447    let editor_2 = panes[1].read_with(cx, |pane, cx| {
448        pane.active_item()
449            .and_then(|item| item.act_as::<Editor>(cx))
450            .expect("pane 2 should have editor")
451    });
452
453    assert_ne!(
454        editor_1.entity_id(),
455        editor_2.entity_id(),
456        "should clone to new instance"
457    );
458}
459
460#[gpui::test]
461async fn test_open_in_active_pane_moves_terminals_to_current_pane(cx: &mut gpui::TestAppContext) {
462    let app_state = init_test(cx);
463    let project = Project::test(app_state.fs.clone(), [], cx).await;
464    let (multi_workspace, cx) =
465        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
466    let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
467
468    let test_item = cx.new(|cx| TestItem::new(cx).with_label("terminal"));
469    workspace.update_in(cx, |workspace, window, cx| {
470        workspace.add_item_to_active_pane(Box::new(test_item.clone()), None, true, window, cx);
471    });
472
473    workspace.update_in(cx, |workspace, window, cx| {
474        workspace.split_pane(
475            workspace.active_pane().clone(),
476            workspace::SplitDirection::Right,
477            window,
478            cx,
479        );
480    });
481
482    let panes = workspace.read_with(cx, |workspace, _| workspace.panes().to_vec());
483
484    let tab_switcher = open_tab_switcher_for_active_pane(&workspace, cx);
485    tab_switcher.update(cx, |picker, _| {
486        picker.delegate.selected_index = 0;
487    });
488
489    cx.dispatch_action(menu::Confirm);
490    cx.run_until_parked();
491
492    assert!(
493        !panes[0].read_with(cx, |pane, _| {
494            pane.items()
495                .any(|item| item.item_id() == test_item.item_id())
496        }),
497        "should be removed from pane 1"
498    );
499    assert!(
500        panes[1].read_with(cx, |pane, _| {
501            pane.items()
502                .any(|item| item.item_id() == test_item.item_id())
503        }),
504        "should be moved to pane 2"
505    );
506}
507
508#[gpui::test]
509async fn test_open_in_active_pane_closes_file_in_all_panes(cx: &mut gpui::TestAppContext) {
510    let app_state = init_test(cx);
511    app_state
512        .fs
513        .as_fake()
514        .insert_tree(path!("/root"), json!({"1.txt": ""}))
515        .await;
516
517    let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await;
518    let (multi_workspace, cx) =
519        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
520    let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
521
522    open_buffer("1.txt", &workspace, cx).await;
523
524    workspace.update_in(cx, |workspace, window, cx| {
525        workspace.split_pane(
526            workspace.active_pane().clone(),
527            workspace::SplitDirection::Right,
528            window,
529            cx,
530        );
531    });
532    open_buffer("1.txt", &workspace, cx).await;
533
534    let panes = workspace.read_with(cx, |workspace, _| workspace.panes().to_vec());
535
536    let tab_switcher = open_tab_switcher_for_active_pane(&workspace, cx);
537    tab_switcher.update(cx, |picker, _| {
538        picker.delegate.selected_index = 0;
539    });
540
541    cx.dispatch_action(CloseSelectedItem);
542    cx.run_until_parked();
543
544    for pane in &panes {
545        assert_eq!(
546            pane.read_with(cx, |pane, _| pane.items_len()),
547            0,
548            "all panes should be empty"
549        );
550    }
551}