Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T01:50:34.972Z Public web read
NIP-34 coordinate30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omega
MaintainersHidden in public view
References2 branches · 1 tag
Read-only clonegit clone https://openagents.com/git/tenant.openagents/omega.git
Browse files

multi_workspace_tests.rs

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