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