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