multi_workspace_tests.rs

  1use std::path::PathBuf;
  2
  3use super::*;
  4use client::proto;
  5use fs::FakeFs;
  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_project_group_keys_duplicate_not_added(cx: &mut TestAppContext) {
206    init_test(cx);
207    let fs = FakeFs::new(cx.executor());
208    fs.insert_tree("/root_a", json!({ "file.txt": "" })).await;
209    let project_a = Project::test(fs.clone(), ["/root_a".as_ref()], cx).await;
210    // A second project entity pointing at the same path produces the same key.
211    let project_a2 = Project::test(fs.clone(), ["/root_a".as_ref()], cx).await;
212
213    let key_a = project_a.read_with(cx, |p, cx| p.project_group_key(cx));
214    let key_a2 = project_a2.read_with(cx, |p, cx| p.project_group_key(cx));
215    assert_eq!(key_a, key_a2, "same root path should produce the same key");
216
217    let (multi_workspace, cx) =
218        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
219
220    multi_workspace.update(cx, |mw, cx| {
221        mw.open_sidebar(cx);
222    });
223
224    multi_workspace.update_in(cx, |mw, window, cx| {
225        mw.test_add_workspace(project_a2, window, cx);
226    });
227
228    multi_workspace.read_with(cx, |mw, _cx| {
229        let keys: Vec<ProjectGroupKey> = mw.project_group_keys();
230        assert_eq!(
231            keys.len(),
232            1,
233            "duplicate key should not be added when a workspace with the same root is inserted"
234        );
235    });
236}
237
238#[gpui::test]
239async fn test_groups_with_same_paths_merge(cx: &mut TestAppContext) {
240    init_test(cx);
241    let fs = FakeFs::new(cx.executor());
242    fs.insert_tree("/a", json!({ "file.txt": "" })).await;
243    fs.insert_tree("/b", json!({ "file.txt": "" })).await;
244    let project_a = Project::test(fs.clone(), ["/a".as_ref()], cx).await;
245    let project_b = Project::test(fs.clone(), ["/b".as_ref()], cx).await;
246
247    let (multi_workspace, cx) =
248        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
249
250    // Open the sidebar so workspaces get grouped.
251    multi_workspace.update(cx, |mw, cx| {
252        mw.open_sidebar(cx);
253    });
254    cx.run_until_parked();
255
256    // Add a second workspace, creating group_b with path [/b].
257    let group_a_key = multi_workspace.update_in(cx, |mw, window, cx| {
258        let group_a_key = mw.project_groups(cx)[0].key.clone();
259        mw.test_add_workspace(project_b, window, cx);
260        group_a_key
261    });
262    cx.run_until_parked();
263
264    // Now add /b to group_a so it has [/a, /b].
265    multi_workspace.update(cx, |mw, cx| {
266        mw.add_folders_to_project_group(&group_a_key, vec!["/b".into()], cx);
267    });
268    cx.run_until_parked();
269
270    // Verify we have two groups.
271    multi_workspace.read_with(cx, |mw, cx| {
272        assert_eq!(
273            mw.project_groups(cx).len(),
274            2,
275            "should have two groups before the merge"
276        );
277    });
278
279    // After adding /b, group_a's key changed. Get the updated key.
280    let group_a_key_updated = multi_workspace.read_with(cx, |mw, cx| {
281        mw.project_groups(cx)
282            .iter()
283            .find(|g| g.key.path_list().paths().contains(&PathBuf::from("/a")))
284            .unwrap()
285            .key
286            .clone()
287    });
288
289    // Remove /a from group_a, making its key [/b] — same as group_b.
290    multi_workspace.update(cx, |mw, cx| {
291        mw.remove_folder_from_project_group(&group_a_key_updated, Path::new("/a"), cx);
292    });
293    cx.run_until_parked();
294
295    // The two groups now have identical keys [/b] and should have been merged.
296    multi_workspace.read_with(cx, |mw, cx| {
297        assert_eq!(
298            mw.project_groups(cx).len(),
299            1,
300            "groups with identical paths should be merged into one"
301        );
302    });
303}
304
305#[gpui::test]
306async fn test_adding_worktree_updates_project_group_key(cx: &mut TestAppContext) {
307    init_test(cx);
308    let fs = FakeFs::new(cx.executor());
309    fs.insert_tree("/root_a", json!({ "file.txt": "" })).await;
310    fs.insert_tree("/root_b", json!({ "other.txt": "" })).await;
311    let project = Project::test(fs.clone(), ["/root_a".as_ref()], cx).await;
312
313    let initial_key = project.read_with(cx, |p, cx| p.project_group_key(cx));
314
315    let (multi_workspace, cx) =
316        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
317
318    // Open sidebar to retain the workspace and create the initial group.
319    multi_workspace.update(cx, |mw, cx| {
320        mw.open_sidebar(cx);
321    });
322    cx.run_until_parked();
323
324    multi_workspace.read_with(cx, |mw, _cx| {
325        let keys = mw.project_group_keys();
326        assert_eq!(keys.len(), 1);
327        assert_eq!(keys[0], initial_key);
328    });
329
330    // Add a second worktree to the project. This triggers WorktreeAdded →
331    // handle_workspace_key_change, which should update the group key.
332    project
333        .update(cx, |project, cx| {
334            project.find_or_create_worktree("/root_b", true, cx)
335        })
336        .await
337        .expect("adding worktree should succeed");
338    cx.run_until_parked();
339
340    let updated_key = project.read_with(cx, |p, cx| p.project_group_key(cx));
341    assert_ne!(
342        initial_key, updated_key,
343        "adding a worktree should change the project group key"
344    );
345
346    multi_workspace.read_with(cx, |mw, _cx| {
347        let keys = mw.project_group_keys();
348        assert!(
349            keys.contains(&updated_key),
350            "should contain the updated key; got {keys:?}"
351        );
352    });
353}
354
355#[gpui::test]
356async fn test_find_or_create_local_workspace_reuses_active_workspace_when_sidebar_closed(
357    cx: &mut TestAppContext,
358) {
359    init_test(cx);
360    let fs = FakeFs::new(cx.executor());
361    fs.insert_tree("/root_a", json!({ "file.txt": "" })).await;
362    let project = Project::test(fs, ["/root_a".as_ref()], cx).await;
363
364    let (multi_workspace, cx) =
365        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
366
367    let active_workspace = multi_workspace.read_with(cx, |mw, cx| {
368        assert!(
369            mw.project_groups(cx).is_empty(),
370            "sidebar-closed setup should start with no retained project groups"
371        );
372        mw.workspace().clone()
373    });
374    let active_workspace_id = active_workspace.entity_id();
375
376    let workspace = multi_workspace
377        .update_in(cx, |mw, window, cx| {
378            mw.find_or_create_local_workspace(
379                PathList::new(&[PathBuf::from("/root_a")]),
380                window,
381                cx,
382            )
383        })
384        .await
385        .expect("reopening the same local workspace should succeed");
386
387    assert_eq!(
388        workspace.entity_id(),
389        active_workspace_id,
390        "should reuse the current active workspace when the sidebar is closed"
391    );
392
393    multi_workspace.read_with(cx, |mw, _cx| {
394        assert_eq!(
395            mw.workspace().entity_id(),
396            active_workspace_id,
397            "active workspace should remain unchanged after reopening the same path"
398        );
399        assert_eq!(
400            mw.workspaces().count(),
401            1,
402            "reusing the active workspace should not create a second open workspace"
403        );
404    });
405}
406
407#[gpui::test]
408async fn test_find_or_create_local_workspace_reuses_active_workspace_after_sidebar_open(
409    cx: &mut TestAppContext,
410) {
411    init_test(cx);
412    let fs = FakeFs::new(cx.executor());
413    fs.insert_tree("/root_a", json!({ "file.txt": "" })).await;
414    let project = Project::test(fs, ["/root_a".as_ref()], cx).await;
415
416    let (multi_workspace, cx) =
417        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
418
419    multi_workspace.update(cx, |mw, cx| {
420        mw.open_sidebar(cx);
421    });
422    cx.run_until_parked();
423
424    let active_workspace = multi_workspace.read_with(cx, |mw, cx| {
425        assert_eq!(
426            mw.project_groups(cx).len(),
427            1,
428            "opening the sidebar should retain the active workspace in a project group"
429        );
430        mw.workspace().clone()
431    });
432    let active_workspace_id = active_workspace.entity_id();
433
434    let workspace = multi_workspace
435        .update_in(cx, |mw, window, cx| {
436            mw.find_or_create_local_workspace(
437                PathList::new(&[PathBuf::from("/root_a")]),
438                window,
439                cx,
440            )
441        })
442        .await
443        .expect("reopening the same retained local workspace should succeed");
444
445    assert_eq!(
446        workspace.entity_id(),
447        active_workspace_id,
448        "should reuse the retained active workspace after the sidebar is opened"
449    );
450
451    multi_workspace.read_with(cx, |mw, _cx| {
452        assert_eq!(
453            mw.workspaces().count(),
454            1,
455            "reopening the same retained workspace should not create another workspace"
456        );
457    });
458}
459
460#[gpui::test]
461async fn test_switching_projects_with_sidebar_closed_detaches_old_active_workspace(
462    cx: &mut TestAppContext,
463) {
464    init_test(cx);
465    let fs = FakeFs::new(cx.executor());
466    fs.insert_tree("/root_a", json!({ "file_a.txt": "" })).await;
467    fs.insert_tree("/root_b", json!({ "file_b.txt": "" })).await;
468    let project_a = Project::test(fs.clone(), ["/root_a".as_ref()], cx).await;
469    let project_b = Project::test(fs, ["/root_b".as_ref()], cx).await;
470
471    let (multi_workspace, cx) =
472        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
473
474    let workspace_a = multi_workspace.read_with(cx, |mw, cx| {
475        assert!(
476            mw.project_groups(cx).is_empty(),
477            "sidebar-closed setup should start with no retained project groups"
478        );
479        mw.workspace().clone()
480    });
481    assert!(
482        workspace_a.read_with(cx, |workspace, _cx| workspace.session_id().is_some()),
483        "initial active workspace should start attached to the session"
484    );
485
486    let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
487        mw.test_add_workspace(project_b, window, cx)
488    });
489    cx.run_until_parked();
490
491    multi_workspace.read_with(cx, |mw, _cx| {
492        assert_eq!(
493            mw.workspace().entity_id(),
494            workspace_b.entity_id(),
495            "the new workspace should become active"
496        );
497        assert_eq!(
498            mw.workspaces().count(),
499                        1,
500                        "only the new active workspace should remain open after switching with the sidebar closed"
501        );
502    });
503
504    assert!(
505        workspace_a.read_with(cx, |workspace, _cx| workspace.session_id().is_none()),
506        "the previous active workspace should be detached when switching away with the sidebar closed"
507    );
508}
509
510#[gpui::test]
511async fn test_remote_worktree_without_git_updates_project_group(cx: &mut TestAppContext) {
512    init_test(cx);
513    let fs = FakeFs::new(cx.executor());
514    fs.insert_tree("/local", json!({ "file.txt": "" })).await;
515    let project = Project::test(fs.clone(), ["/local".as_ref()], cx).await;
516
517    let (multi_workspace, cx) =
518        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
519
520    multi_workspace.update(cx, |mw, cx| {
521        mw.open_sidebar(cx);
522    });
523    cx.run_until_parked();
524
525    let initial_key = project.read_with(cx, |p, cx| p.project_group_key(cx));
526    multi_workspace.read_with(cx, |mw, _cx| {
527        let keys = mw.project_group_keys();
528        assert_eq!(keys.len(), 1);
529        assert_eq!(keys[0], initial_key);
530    });
531
532    // Add a remote worktree without git repo info.
533    let remote_worktree = project.update(cx, |project, cx| {
534        project.add_test_remote_worktree("/remote/project", cx)
535    });
536    cx.run_until_parked();
537
538    // The remote worktree has no entries yet, so project_group_key should
539    // still exclude it.
540    let key_after_add = project.read_with(cx, |p, cx| p.project_group_key(cx));
541    assert_eq!(
542        key_after_add, initial_key,
543        "remote worktree without entries should not affect the group key"
544    );
545
546    // Send an UpdateWorktree to the remote worktree with entries but no repo.
547    // This triggers UpdatedRootRepoCommonDir on the first update (the fix),
548    // which propagates through WorktreeStore → Project → MultiWorkspace.
549    let worktree_id = remote_worktree.read_with(cx, |wt, _| wt.id().to_proto());
550    remote_worktree.update(cx, |worktree, _cx| {
551        worktree
552            .as_remote()
553            .unwrap()
554            .update_from_remote(proto::UpdateWorktree {
555                project_id: 0,
556                worktree_id,
557                abs_path: "/remote/project".to_string(),
558                root_name: "project".to_string(),
559                updated_entries: vec![proto::Entry {
560                    id: 1,
561                    is_dir: true,
562                    path: "".to_string(),
563                    inode: 1,
564                    mtime: Some(proto::Timestamp {
565                        seconds: 0,
566                        nanos: 0,
567                    }),
568                    is_ignored: false,
569                    is_hidden: false,
570                    is_external: false,
571                    is_fifo: false,
572                    size: None,
573                    canonical_path: None,
574                }],
575                removed_entries: vec![],
576                scan_id: 1,
577                is_last_update: true,
578                updated_repositories: vec![],
579                removed_repositories: vec![],
580                root_repo_common_dir: None,
581            });
582    });
583    cx.run_until_parked();
584
585    let updated_key = project.read_with(cx, |p, cx| p.project_group_key(cx));
586    assert_ne!(
587        initial_key, updated_key,
588        "adding a remote worktree should change the project group key"
589    );
590
591    multi_workspace.read_with(cx, |mw, _cx| {
592        let keys = mw.project_group_keys();
593        assert!(
594            keys.contains(&updated_key),
595            "should contain the updated key; got {keys:?}"
596        );
597    });
598}