1use std::path::PathBuf;
2
3use super::*;
4use crate::item::test::TestItem;
5use client::proto;
6use fs::{FakeFs, Fs};
7use gpui::{TestAppContext, VisualTestContext};
8use project::DisableAiSettings;
9use serde_json::json;
10use settings::SettingsStore;
11use util::path;
12
13fn init_test(cx: &mut TestAppContext) {
14 cx.update(|cx| {
15 let settings_store = SettingsStore::test(cx);
16 cx.set_global(settings_store);
17 theme_settings::init(theme::LoadThemes::JustBase, cx);
18 DisableAiSettings::register(cx);
19 });
20}
21
22#[gpui::test]
23async fn test_sidebar_disabled_when_disable_ai_is_enabled(cx: &mut TestAppContext) {
24 init_test(cx);
25 let fs = FakeFs::new(cx.executor());
26 let project = Project::test(fs, [], cx).await;
27
28 let (multi_workspace, cx) =
29 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
30
31 multi_workspace.read_with(cx, |mw, cx| {
32 assert!(mw.multi_workspace_enabled(cx));
33 });
34
35 multi_workspace.update_in(cx, |mw, _window, cx| {
36 mw.open_sidebar(cx);
37 assert!(mw.sidebar_open());
38 });
39
40 cx.update(|_window, cx| {
41 DisableAiSettings::override_global(DisableAiSettings { disable_ai: true }, cx);
42 });
43 cx.run_until_parked();
44
45 multi_workspace.read_with(cx, |mw, cx| {
46 assert!(
47 !mw.sidebar_open(),
48 "Sidebar should be closed when disable_ai is true"
49 );
50 assert!(
51 !mw.multi_workspace_enabled(cx),
52 "Multi-workspace should be disabled when disable_ai is true"
53 );
54 });
55
56 multi_workspace.update_in(cx, |mw, window, cx| {
57 mw.toggle_sidebar(window, cx);
58 });
59 multi_workspace.read_with(cx, |mw, _cx| {
60 assert!(
61 !mw.sidebar_open(),
62 "Sidebar should remain closed when toggled with disable_ai true"
63 );
64 });
65
66 cx.update(|_window, cx| {
67 DisableAiSettings::override_global(DisableAiSettings { disable_ai: false }, cx);
68 });
69 cx.run_until_parked();
70
71 multi_workspace.read_with(cx, |mw, cx| {
72 assert!(
73 mw.multi_workspace_enabled(cx),
74 "Multi-workspace should be enabled after re-enabling AI"
75 );
76 assert!(
77 !mw.sidebar_open(),
78 "Sidebar should still be closed after re-enabling AI (not auto-opened)"
79 );
80 });
81
82 multi_workspace.update_in(cx, |mw, window, cx| {
83 mw.toggle_sidebar(window, cx);
84 });
85 multi_workspace.read_with(cx, |mw, _cx| {
86 assert!(
87 mw.sidebar_open(),
88 "Sidebar should open when toggled after re-enabling AI"
89 );
90 });
91}
92
93#[gpui::test]
94async fn test_project_group_keys_initial(cx: &mut TestAppContext) {
95 init_test(cx);
96 let fs = FakeFs::new(cx.executor());
97 fs.insert_tree("/root_a", json!({ "file.txt": "" })).await;
98 let project = Project::test(fs, ["/root_a".as_ref()], cx).await;
99
100 let expected_key = project.read_with(cx, |project, cx| project.project_group_key(cx));
101
102 let (multi_workspace, cx) =
103 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
104
105 multi_workspace.update(cx, |mw, cx| {
106 mw.open_sidebar(cx);
107 });
108
109 multi_workspace.read_with(cx, |mw, _cx| {
110 let keys: Vec<ProjectGroupKey> = mw.project_group_keys();
111 assert_eq!(keys.len(), 1, "should have exactly one key on creation");
112 assert_eq!(keys[0], expected_key);
113 });
114}
115
116#[gpui::test]
117async fn test_project_group_keys_add_workspace(cx: &mut TestAppContext) {
118 init_test(cx);
119 let fs = FakeFs::new(cx.executor());
120 fs.insert_tree("/root_a", json!({ "file.txt": "" })).await;
121 fs.insert_tree("/root_b", json!({ "file.txt": "" })).await;
122 let project_a = Project::test(fs.clone(), ["/root_a".as_ref()], cx).await;
123 let project_b = Project::test(fs.clone(), ["/root_b".as_ref()], cx).await;
124
125 let key_a = project_a.read_with(cx, |p, cx| p.project_group_key(cx));
126 let key_b = project_b.read_with(cx, |p, cx| p.project_group_key(cx));
127 assert_ne!(
128 key_a, key_b,
129 "different roots should produce different keys"
130 );
131
132 let (multi_workspace, cx) =
133 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
134
135 multi_workspace.update(cx, |mw, cx| {
136 mw.open_sidebar(cx);
137 });
138
139 multi_workspace.read_with(cx, |mw, _cx| {
140 assert_eq!(mw.project_group_keys().len(), 1);
141 });
142
143 // Adding a workspace with a different project root adds a new key.
144 multi_workspace.update_in(cx, |mw, window, cx| {
145 mw.test_add_workspace(project_b, window, cx);
146 });
147
148 multi_workspace.read_with(cx, |mw, _cx| {
149 let keys: Vec<ProjectGroupKey> = mw.project_group_keys();
150 assert_eq!(
151 keys.len(),
152 2,
153 "should have two keys after adding a second workspace"
154 );
155 assert_eq!(keys[0], key_b);
156 assert_eq!(keys[1], key_a);
157 });
158}
159
160#[gpui::test]
161async fn test_open_new_window_does_not_open_sidebar_on_existing_window(cx: &mut TestAppContext) {
162 init_test(cx);
163
164 let app_state = cx.update(AppState::test);
165 let fs = app_state.fs.as_fake();
166 fs.insert_tree(path!("/project_a"), json!({ "file.txt": "" }))
167 .await;
168 fs.insert_tree(path!("/project_b"), json!({ "file.txt": "" }))
169 .await;
170
171 let project = Project::test(app_state.fs.clone(), [path!("/project_a").as_ref()], cx).await;
172
173 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project, window, cx));
174
175 window
176 .read_with(cx, |mw, _cx| {
177 assert!(!mw.sidebar_open(), "sidebar should start closed",);
178 })
179 .unwrap();
180
181 cx.update(|cx| {
182 open_paths(
183 &[PathBuf::from(path!("/project_b"))],
184 app_state,
185 OpenOptions {
186 open_mode: OpenMode::NewWindow,
187 ..OpenOptions::default()
188 },
189 cx,
190 )
191 })
192 .await
193 .unwrap();
194
195 window
196 .read_with(cx, |mw, _cx| {
197 assert!(
198 !mw.sidebar_open(),
199 "opening a project in a new window must not open the sidebar on the original window",
200 );
201 })
202 .unwrap();
203}
204
205#[gpui::test]
206async fn test_open_directory_in_empty_workspace_does_not_open_sidebar(cx: &mut TestAppContext) {
207 init_test(cx);
208
209 let app_state = cx.update(AppState::test);
210 let fs = app_state.fs.as_fake();
211 fs.insert_tree(path!("/project"), json!({ "file.txt": "" }))
212 .await;
213
214 let project = Project::test(app_state.fs.clone(), [], cx).await;
215 let window = cx.add_window(|window, cx| {
216 let mw = MultiWorkspace::test_new(project, window, cx);
217 // Simulate a blank project that has an untitled editor tab,
218 // so that workspace_windows_for_location finds this window.
219 mw.workspace().update(cx, |workspace, cx| {
220 workspace.active_pane().update(cx, |pane, cx| {
221 let item = cx.new(|cx| item::test::TestItem::new(cx));
222 pane.add_item(Box::new(item), false, false, None, window, cx);
223 });
224 });
225 mw
226 });
227
228 window
229 .read_with(cx, |mw, _cx| {
230 assert!(!mw.sidebar_open(), "sidebar should start closed");
231 })
232 .unwrap();
233
234 // Simulate what open_workspace_for_paths does for an empty workspace:
235 // it downgrades OpenMode::NewWindow to Activate and sets requesting_window.
236 cx.update(|cx| {
237 open_paths(
238 &[PathBuf::from(path!("/project"))],
239 app_state,
240 OpenOptions {
241 requesting_window: Some(window),
242 open_mode: OpenMode::Activate,
243 ..OpenOptions::default()
244 },
245 cx,
246 )
247 })
248 .await
249 .unwrap();
250
251 window
252 .read_with(cx, |mw, _cx| {
253 assert!(
254 !mw.sidebar_open(),
255 "opening a directory in a blank project via the file picker must not open the sidebar",
256 );
257 })
258 .unwrap();
259}
260
261#[gpui::test]
262async fn test_project_group_keys_duplicate_not_added(cx: &mut TestAppContext) {
263 init_test(cx);
264 let fs = FakeFs::new(cx.executor());
265 fs.insert_tree("/root_a", json!({ "file.txt": "" })).await;
266 let project_a = Project::test(fs.clone(), ["/root_a".as_ref()], cx).await;
267 // A second project entity pointing at the same path produces the same key.
268 let project_a2 = Project::test(fs.clone(), ["/root_a".as_ref()], cx).await;
269
270 let key_a = project_a.read_with(cx, |p, cx| p.project_group_key(cx));
271 let key_a2 = project_a2.read_with(cx, |p, cx| p.project_group_key(cx));
272 assert_eq!(key_a, key_a2, "same root path should produce the same key");
273
274 let (multi_workspace, cx) =
275 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
276
277 multi_workspace.update(cx, |mw, cx| {
278 mw.open_sidebar(cx);
279 });
280
281 multi_workspace.update_in(cx, |mw, window, cx| {
282 mw.test_add_workspace(project_a2, window, cx);
283 });
284
285 multi_workspace.read_with(cx, |mw, _cx| {
286 let keys: Vec<ProjectGroupKey> = mw.project_group_keys();
287 assert_eq!(
288 keys.len(),
289 1,
290 "duplicate key should not be added when a workspace with the same root is inserted"
291 );
292 });
293}
294
295#[gpui::test]
296async fn test_adding_worktree_updates_project_group_key(cx: &mut TestAppContext) {
297 init_test(cx);
298 let fs = FakeFs::new(cx.executor());
299 fs.insert_tree("/root_a", json!({ "file.txt": "" })).await;
300 fs.insert_tree("/root_b", json!({ "other.txt": "" })).await;
301 let project = Project::test(fs.clone(), ["/root_a".as_ref()], cx).await;
302
303 let initial_key = project.read_with(cx, |p, cx| p.project_group_key(cx));
304
305 let (multi_workspace, cx) =
306 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
307
308 // Open sidebar to retain the workspace and create the initial group.
309 multi_workspace.update(cx, |mw, cx| {
310 mw.open_sidebar(cx);
311 });
312 cx.run_until_parked();
313
314 multi_workspace.read_with(cx, |mw, _cx| {
315 let keys = mw.project_group_keys();
316 assert_eq!(keys.len(), 1);
317 assert_eq!(keys[0], initial_key);
318 });
319
320 // Add a second worktree to the project. This triggers WorktreeAdded →
321 // handle_workspace_key_change, which should update the group key.
322 project
323 .update(cx, |project, cx| {
324 project.find_or_create_worktree("/root_b", true, cx)
325 })
326 .await
327 .expect("adding worktree should succeed");
328 cx.run_until_parked();
329
330 let updated_key = project.read_with(cx, |p, cx| p.project_group_key(cx));
331 assert_ne!(
332 initial_key, updated_key,
333 "adding a worktree should change the project group key"
334 );
335
336 multi_workspace.read_with(cx, |mw, _cx| {
337 let keys = mw.project_group_keys();
338 assert!(
339 keys.contains(&updated_key),
340 "should contain the updated key; got {keys:?}"
341 );
342 });
343}
344
345#[gpui::test]
346async fn test_find_or_create_local_workspace_reuses_active_workspace_when_sidebar_closed(
347 cx: &mut TestAppContext,
348) {
349 init_test(cx);
350 let fs = FakeFs::new(cx.executor());
351 fs.insert_tree("/root_a", json!({ "file.txt": "" })).await;
352 let project = Project::test(fs, ["/root_a".as_ref()], cx).await;
353
354 let (multi_workspace, cx) =
355 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
356
357 let active_workspace = multi_workspace.read_with(cx, |mw, cx| {
358 assert!(
359 mw.project_groups(cx).is_empty(),
360 "sidebar-closed setup should start with no retained project groups"
361 );
362 mw.workspace().clone()
363 });
364 let active_workspace_id = active_workspace.entity_id();
365
366 let workspace = multi_workspace
367 .update_in(cx, |mw, window, cx| {
368 mw.find_or_create_local_workspace(
369 PathList::new(&[PathBuf::from("/root_a")]),
370 None,
371 &[],
372 None,
373 OpenMode::Activate,
374 window,
375 cx,
376 )
377 })
378 .await
379 .expect("reopening the same local workspace should succeed");
380
381 assert_eq!(
382 workspace.entity_id(),
383 active_workspace_id,
384 "should reuse the current active workspace when the sidebar is closed"
385 );
386
387 multi_workspace.read_with(cx, |mw, _cx| {
388 assert_eq!(
389 mw.workspace().entity_id(),
390 active_workspace_id,
391 "active workspace should remain unchanged after reopening the same path"
392 );
393 assert_eq!(
394 mw.workspaces().count(),
395 1,
396 "reusing the active workspace should not create a second open workspace"
397 );
398 });
399}
400
401#[gpui::test]
402async fn test_find_or_create_workspace_uses_project_group_key_when_paths_are_missing(
403 cx: &mut TestAppContext,
404) {
405 init_test(cx);
406 let fs = FakeFs::new(cx.executor());
407 fs.insert_tree(
408 "/project",
409 json!({
410 ".git": {},
411 "src": {},
412 }),
413 )
414 .await;
415 cx.update(|cx| <dyn Fs>::set_global(fs.clone(), cx));
416 let project = Project::test(fs.clone(), ["/project".as_ref()], cx).await;
417 project
418 .update(cx, |project, cx| project.git_scans_complete(cx))
419 .await;
420
421 let project_group_key = project.read_with(cx, |project, cx| project.project_group_key(cx));
422
423 let (multi_workspace, cx) =
424 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
425
426 let main_workspace = multi_workspace.read_with(cx, |mw, _cx| mw.workspace().clone());
427 let main_workspace_id = main_workspace.entity_id();
428
429 let workspace = multi_workspace
430 .update_in(cx, |mw, window, cx| {
431 mw.find_or_create_workspace(
432 PathList::new(&[PathBuf::from("/wt-feature-a")]),
433 None,
434 Some(project_group_key.clone()),
435 |_options, _window, _cx| Task::ready(Ok(None)),
436 &[],
437 None,
438 OpenMode::Activate,
439 window,
440 cx,
441 )
442 })
443 .await
444 .expect("opening a missing linked-worktree path should fall back to the project group key workspace");
445
446 assert_eq!(
447 workspace.entity_id(),
448 main_workspace_id,
449 "missing linked-worktree paths should reuse the main worktree workspace from the project group key"
450 );
451
452 multi_workspace.read_with(cx, |mw, cx| {
453 assert_eq!(
454 mw.workspace().entity_id(),
455 main_workspace_id,
456 "the active workspace should remain the main worktree workspace"
457 );
458 assert_eq!(
459 PathList::new(&mw.workspace().read(cx).root_paths(cx)),
460 project_group_key.path_list().clone(),
461 "the activated workspace should use the project group key path list rather than the missing linked-worktree path"
462 );
463 assert_eq!(
464 mw.workspaces().count(),
465 1,
466 "falling back to the project group key should not create a second workspace"
467 );
468 });
469}
470
471#[gpui::test]
472async fn test_find_or_create_local_workspace_reuses_active_workspace_after_sidebar_open(
473 cx: &mut TestAppContext,
474) {
475 init_test(cx);
476 let fs = FakeFs::new(cx.executor());
477 fs.insert_tree("/root_a", json!({ "file.txt": "" })).await;
478 let project = Project::test(fs, ["/root_a".as_ref()], cx).await;
479
480 let (multi_workspace, cx) =
481 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
482
483 multi_workspace.update(cx, |mw, cx| {
484 mw.open_sidebar(cx);
485 });
486 cx.run_until_parked();
487
488 let active_workspace = multi_workspace.read_with(cx, |mw, cx| {
489 assert_eq!(
490 mw.project_groups(cx).len(),
491 1,
492 "opening the sidebar should retain the active workspace in a project group"
493 );
494 mw.workspace().clone()
495 });
496 let active_workspace_id = active_workspace.entity_id();
497
498 let workspace = multi_workspace
499 .update_in(cx, |mw, window, cx| {
500 mw.find_or_create_local_workspace(
501 PathList::new(&[PathBuf::from("/root_a")]),
502 None,
503 &[],
504 None,
505 OpenMode::Activate,
506 window,
507 cx,
508 )
509 })
510 .await
511 .expect("reopening the same retained local workspace should succeed");
512
513 assert_eq!(
514 workspace.entity_id(),
515 active_workspace_id,
516 "should reuse the retained active workspace after the sidebar is opened"
517 );
518
519 multi_workspace.read_with(cx, |mw, _cx| {
520 assert_eq!(
521 mw.workspaces().count(),
522 1,
523 "reopening the same retained workspace should not create another workspace"
524 );
525 });
526}
527
528#[gpui::test]
529async fn test_close_workspace_prefers_already_loaded_neighboring_workspace(
530 cx: &mut TestAppContext,
531) {
532 init_test(cx);
533 let fs = FakeFs::new(cx.executor());
534 fs.insert_tree("/root_a", json!({ "file_a.txt": "" })).await;
535 fs.insert_tree("/root_b", json!({ "file_b.txt": "" })).await;
536 fs.insert_tree("/root_c", json!({ "file_c.txt": "" })).await;
537 let project_a = Project::test(fs.clone(), ["/root_a".as_ref()], cx).await;
538 let project_b = Project::test(fs.clone(), ["/root_b".as_ref()], cx).await;
539 let project_b_key = project_b.read_with(cx, |project, cx| project.project_group_key(cx));
540 let project_c = Project::test(fs, ["/root_c".as_ref()], cx).await;
541 let project_c_key = project_c.read_with(cx, |project, cx| project.project_group_key(cx));
542
543 let (multi_workspace, cx) =
544 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
545
546 multi_workspace.update(cx, |multi_workspace, cx| {
547 multi_workspace.open_sidebar(cx);
548 });
549 cx.run_until_parked();
550
551 let workspace_a = multi_workspace.read_with(cx, |multi_workspace, _cx| {
552 multi_workspace.workspace().clone()
553 });
554 let workspace_b = multi_workspace.update_in(cx, |multi_workspace, window, cx| {
555 multi_workspace.test_add_workspace(project_b, window, cx)
556 });
557
558 multi_workspace.update_in(cx, |multi_workspace, window, cx| {
559 multi_workspace.activate(workspace_a.clone(), None, window, cx);
560 multi_workspace.test_add_project_group(ProjectGroup {
561 key: project_c_key.clone(),
562 workspaces: Vec::new(),
563 expanded: true,
564 });
565 });
566
567 multi_workspace.read_with(cx, |multi_workspace, _cx| {
568 let keys = multi_workspace.project_group_keys();
569 assert_eq!(
570 keys.len(),
571 3,
572 "expected three project groups in the test setup"
573 );
574 assert_eq!(keys[0], project_b_key);
575 assert_eq!(
576 keys[1],
577 workspace_a.read_with(cx, |workspace, cx| { workspace.project_group_key(cx) })
578 );
579 assert_eq!(keys[2], project_c_key);
580 assert_eq!(
581 multi_workspace.workspace().entity_id(),
582 workspace_a.entity_id(),
583 "workspace A should be active before closing"
584 );
585 });
586
587 let closed = multi_workspace
588 .update_in(cx, |multi_workspace, window, cx| {
589 multi_workspace.close_workspace(&workspace_a, window, cx)
590 })
591 .await
592 .expect("closing the active workspace should succeed");
593
594 assert!(
595 closed,
596 "close_workspace should report that it removed a workspace"
597 );
598
599 multi_workspace.read_with(cx, |multi_workspace, cx| {
600 assert_eq!(
601 multi_workspace.workspace().entity_id(),
602 workspace_b.entity_id(),
603 "closing workspace A should activate the already-loaded workspace B instead of opening group C"
604 );
605 assert_eq!(
606 multi_workspace.workspaces().count(),
607 1,
608 "only workspace B should remain loaded after closing workspace A"
609 );
610 assert!(
611 multi_workspace
612 .workspaces_for_project_group(&project_c_key, cx)
613 .unwrap_or_default()
614 .is_empty(),
615 "the unloaded neighboring group C should remain unopened"
616 );
617 });
618}
619
620#[gpui::test]
621async fn test_switching_projects_with_sidebar_closed_detaches_old_active_workspace(
622 cx: &mut TestAppContext,
623) {
624 init_test(cx);
625 let fs = FakeFs::new(cx.executor());
626 fs.insert_tree("/root_a", json!({ "file_a.txt": "" })).await;
627 fs.insert_tree("/root_b", json!({ "file_b.txt": "" })).await;
628 let project_a = Project::test(fs.clone(), ["/root_a".as_ref()], cx).await;
629 let project_b = Project::test(fs, ["/root_b".as_ref()], cx).await;
630
631 let (multi_workspace, cx) =
632 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
633
634 let workspace_a = multi_workspace.read_with(cx, |mw, cx| {
635 assert!(
636 mw.project_groups(cx).is_empty(),
637 "sidebar-closed setup should start with no retained project groups"
638 );
639 mw.workspace().clone()
640 });
641 assert!(
642 workspace_a.read_with(cx, |workspace, _cx| workspace.session_id().is_some()),
643 "initial active workspace should start attached to the session"
644 );
645
646 let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
647 mw.test_add_workspace(project_b, window, cx)
648 });
649 cx.run_until_parked();
650
651 multi_workspace.read_with(cx, |mw, _cx| {
652 assert_eq!(
653 mw.workspace().entity_id(),
654 workspace_b.entity_id(),
655 "the new workspace should become active"
656 );
657 assert_eq!(
658 mw.workspaces().count(),
659 1,
660 "only the new active workspace should remain open after switching with the sidebar closed"
661 );
662 });
663
664 assert!(
665 workspace_a.read_with(cx, |workspace, _cx| workspace.session_id().is_none()),
666 "the previous active workspace should be detached when switching away with the sidebar closed"
667 );
668}
669
670#[gpui::test]
671async fn test_remote_project_root_dir_changes_update_groups(cx: &mut TestAppContext) {
672 init_test(cx);
673 let fs = FakeFs::new(cx.executor());
674 fs.insert_tree("/root_a", json!({ "file.txt": "" })).await;
675 fs.insert_tree("/local_b", json!({ "file.txt": "" })).await;
676 let project_a = Project::test(fs.clone(), ["/root_a".as_ref()], cx).await;
677 let project_b = Project::test(fs.clone(), ["/local_b".as_ref()], cx).await;
678
679 let (multi_workspace, cx) =
680 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
681
682 multi_workspace.update(cx, |mw, cx| {
683 mw.open_sidebar(cx);
684 });
685 cx.run_until_parked();
686
687 let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
688 let workspace = cx.new(|cx| Workspace::test_new(project_b.clone(), window, cx));
689 let key = workspace.read(cx).project_group_key(cx);
690 mw.activate_provisional_workspace(workspace.clone(), key, window, cx);
691 workspace
692 });
693 cx.run_until_parked();
694
695 multi_workspace.read_with(cx, |mw, _cx| {
696 assert_eq!(
697 mw.workspace().entity_id(),
698 workspace_b.entity_id(),
699 "registered workspace should become active"
700 );
701 });
702
703 let initial_key = project_b.read_with(cx, |p, cx| p.project_group_key(cx));
704 multi_workspace.read_with(cx, |mw, _cx| {
705 let keys = mw.project_group_keys();
706 assert!(
707 keys.contains(&initial_key),
708 "project groups should contain the initial key for the registered workspace"
709 );
710 });
711
712 let remote_worktree = project_b.update(cx, |project, cx| {
713 project.add_test_remote_worktree("/remote/project", cx)
714 });
715 cx.run_until_parked();
716
717 let worktree_id = remote_worktree.read_with(cx, |wt, _| wt.id().to_proto());
718 remote_worktree.update(cx, |worktree, _cx| {
719 worktree
720 .as_remote()
721 .unwrap()
722 .update_from_remote(proto::UpdateWorktree {
723 project_id: 0,
724 worktree_id,
725 abs_path: "/remote/project".to_string(),
726 root_name: "project".to_string(),
727 updated_entries: vec![proto::Entry {
728 id: 1,
729 is_dir: true,
730 path: "".to_string(),
731 inode: 1,
732 mtime: Some(proto::Timestamp {
733 seconds: 0,
734 nanos: 0,
735 }),
736 is_ignored: false,
737 is_hidden: false,
738 is_external: false,
739 is_fifo: false,
740 size: None,
741 canonical_path: None,
742 }],
743 removed_entries: vec![],
744 scan_id: 1,
745 is_last_update: true,
746 updated_repositories: vec![],
747 removed_repositories: vec![],
748 root_repo_common_dir: None,
749 });
750 });
751 cx.run_until_parked();
752
753 let updated_key = project_b.read_with(cx, |p, cx| p.project_group_key(cx));
754 assert_ne!(
755 initial_key, updated_key,
756 "remote worktree update should change the project group key"
757 );
758
759 multi_workspace.read_with(cx, |mw, _cx| {
760 let keys = mw.project_group_keys();
761 assert!(
762 keys.contains(&updated_key),
763 "project groups should contain the updated key after remote change; got {keys:?}"
764 );
765 assert!(
766 !keys.contains(&initial_key),
767 "project groups should no longer contain the stale initial key; got {keys:?}"
768 );
769 });
770}
771
772#[gpui::test]
773async fn test_open_project_closes_empty_workspace_but_not_non_empty_ones(cx: &mut TestAppContext) {
774 init_test(cx);
775 let app_state = cx.update(AppState::test);
776 let fs = app_state.fs.as_fake();
777 fs.insert_tree(path!("/project_a"), json!({ "file_a.txt": "" }))
778 .await;
779 fs.insert_tree(path!("/project_b"), json!({ "file_b.txt": "" }))
780 .await;
781
782 // Start with an empty (no-worktrees) workspace.
783 let project = Project::test(app_state.fs.clone(), [], cx).await;
784 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project, window, cx));
785 cx.run_until_parked();
786
787 window
788 .update(cx, |mw, _window, cx| mw.open_sidebar(cx))
789 .unwrap();
790 cx.run_until_parked();
791
792 let empty_workspace = window
793 .read_with(cx, |mw, _| mw.workspace().clone())
794 .unwrap();
795 let cx = &mut VisualTestContext::from_window(window.into(), cx);
796
797 // Add a dirty untitled item to the empty workspace.
798 let dirty_item = cx.new(|cx| TestItem::new(cx).with_dirty(true));
799 empty_workspace.update_in(cx, |workspace, window, cx| {
800 workspace.add_item_to_active_pane(Box::new(dirty_item.clone()), None, true, window, cx);
801 });
802
803 // Opening a project while the lone empty workspace has unsaved
804 // changes prompts the user.
805 let open_task = window
806 .update(cx, |mw, window, cx| {
807 mw.open_project(
808 vec![PathBuf::from(path!("/project_a"))],
809 OpenMode::Activate,
810 window,
811 cx,
812 )
813 })
814 .unwrap();
815 cx.run_until_parked();
816
817 // Cancelling keeps the empty workspace.
818 assert!(cx.has_pending_prompt(),);
819 cx.simulate_prompt_answer("Cancel");
820 cx.run_until_parked();
821 assert_eq!(open_task.await.unwrap(), empty_workspace);
822 window
823 .read_with(cx, |mw, _cx| {
824 assert_eq!(mw.workspaces().count(), 1);
825 assert_eq!(mw.workspace(), &empty_workspace);
826 assert_eq!(mw.project_group_keys(), vec![]);
827 })
828 .unwrap();
829
830 // Discarding the unsaved changes closes the empty workspace
831 // and opens the new project in its place.
832 let open_task = window
833 .update(cx, |mw, window, cx| {
834 mw.open_project(
835 vec![PathBuf::from(path!("/project_a"))],
836 OpenMode::Activate,
837 window,
838 cx,
839 )
840 })
841 .unwrap();
842 cx.run_until_parked();
843
844 assert!(cx.has_pending_prompt(),);
845 cx.simulate_prompt_answer("Don't Save");
846 cx.run_until_parked();
847
848 let workspace_a = open_task.await.unwrap();
849 assert_ne!(workspace_a, empty_workspace);
850
851 window
852 .read_with(cx, |mw, _cx| {
853 assert_eq!(mw.workspaces().count(), 1);
854 assert_eq!(mw.workspace(), &workspace_a);
855 assert_eq!(
856 mw.project_group_keys(),
857 vec![ProjectGroupKey::new(
858 None,
859 PathList::new(&[path!("/project_a")])
860 )]
861 );
862 })
863 .unwrap();
864 assert!(
865 empty_workspace.read_with(cx, |workspace, _cx| workspace.session_id().is_none()),
866 "the detached empty workspace should no longer be attached to the session",
867 );
868
869 let dirty_item = cx.new(|cx| TestItem::new(cx).with_dirty(true));
870 workspace_a.update_in(cx, |workspace, window, cx| {
871 workspace.add_item_to_active_pane(Box::new(dirty_item.clone()), None, true, window, cx);
872 });
873
874 // Opening another project does not close the existing project or prompt.
875 let workspace_b = window
876 .update(cx, |mw, window, cx| {
877 mw.open_project(
878 vec![PathBuf::from(path!("/project_b"))],
879 OpenMode::Activate,
880 window,
881 cx,
882 )
883 })
884 .unwrap()
885 .await
886 .unwrap();
887 cx.run_until_parked();
888
889 assert!(!cx.has_pending_prompt());
890 assert_ne!(workspace_b, workspace_a);
891 window
892 .read_with(cx, |mw, _cx| {
893 assert_eq!(mw.workspaces().count(), 2);
894 assert_eq!(mw.workspace(), &workspace_b);
895 assert_eq!(
896 mw.project_group_keys(),
897 vec![
898 ProjectGroupKey::new(None, PathList::new(&[path!("/project_b")])),
899 ProjectGroupKey::new(None, PathList::new(&[path!("/project_a")]))
900 ]
901 );
902 })
903 .unwrap();
904 assert!(workspace_a.read_with(cx, |workspace, _cx| workspace.session_id().is_some()),);
905}