multi_workspace_tests.rs

  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}