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