multi_workspace_tests.rs

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