Skip to repository content1132 lines · 38.9 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T03:54:19.342Z Public web read
NIP-34 coordinate
30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omegaMaintainersHidden in public view
References2 branches · 1 tag
Read-only clone
git clone https://openagents.com/git/tenant.openagents/omega.gitBrowse files
multi_workspace_tests.rs
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