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}