Move integration test up into the `zed` crate

Antonio Scandurra created

Change summary

crates/project_panel/Cargo.toml |   2 
crates/server/src/rpc.rs        |  22 
crates/workspace/src/lib.rs     | 466 ----------------------------------
crates/workspace/src/pane.rs    |   1 
crates/zed/src/lib.rs           | 454 +++++++++++++++++++++++++++++++++
crates/zed/src/test.rs          |  15 
6 files changed, 486 insertions(+), 474 deletions(-)

Detailed changes

crates/project_panel/Cargo.toml 🔗

@@ -11,4 +11,6 @@ workspace = { path = "../workspace" }
 postage = { version = "0.4.1", features = ["futures-traits"] }
 
 [dev-dependencies]
+gpui = { path = "../gpui", features = ["test-support"] }
+workspace = { path = "../workspace", features = ["test-support"] }
 serde_json = { version = "1.0.64", features = ["preserve_order"] }

crates/server/src/rpc.rs 🔗

@@ -948,7 +948,8 @@ mod tests {
         lsp,
         people_panel::JoinWorktree,
         project::{ProjectPath, Worktree},
-        workspace::{Workspace, WorkspaceParams},
+        test::test_app_state,
+        workspace::Workspace,
     };
 
     #[gpui::test]
@@ -1059,15 +1060,17 @@ mod tests {
     #[gpui::test]
     async fn test_unshare_worktree(mut cx_a: TestAppContext, mut cx_b: TestAppContext) {
         cx_b.update(zed::people_panel::init);
-        let lang_registry = Arc::new(LanguageRegistry::new());
+        let mut app_state_a = cx_a.update(test_app_state);
+        let mut app_state_b = cx_b.update(test_app_state);
 
         // Connect to a server as 2 clients.
         let mut server = TestServer::start().await;
-        let (client_a, _) = server.create_client(&mut cx_a, "user_a").await;
+        let (client_a, user_store_a) = server.create_client(&mut cx_a, "user_a").await;
         let (client_b, user_store_b) = server.create_client(&mut cx_b, "user_b").await;
-        let mut workspace_b_params = cx_b.update(WorkspaceParams::test);
-        workspace_b_params.client = client_b;
-        workspace_b_params.user_store = user_store_b;
+        Arc::get_mut(&mut app_state_a).unwrap().client = client_a;
+        Arc::get_mut(&mut app_state_a).unwrap().user_store = user_store_a;
+        Arc::get_mut(&mut app_state_b).unwrap().client = client_b;
+        Arc::get_mut(&mut app_state_b).unwrap().user_store = user_store_b;
 
         cx_a.foreground().forbid_parking();
 
@@ -1083,10 +1086,10 @@ mod tests {
         )
         .await;
         let worktree_a = Worktree::open_local(
-            client_a.clone(),
+            app_state_a.client.clone(),
             "/a".as_ref(),
             fs,
-            lang_registry.clone(),
+            app_state_a.languages.clone(),
             &mut cx_a.to_async(),
         )
         .await
@@ -1100,7 +1103,8 @@ mod tests {
             .await
             .unwrap();
 
-        let (window_b, workspace_b) = cx_b.add_window(|cx| Workspace::new(&workspace_b_params, cx));
+        let (window_b, workspace_b) =
+            cx_b.add_window(|cx| Workspace::new(&app_state_b.as_ref().into(), cx));
         cx_b.update(|cx| {
             cx.dispatch_action(
                 window_b,

crates/workspace/src/lib.rs 🔗

@@ -298,16 +298,7 @@ pub struct WorkspaceParams {
 impl WorkspaceParams {
     #[cfg(any(test, feature = "test-support"))]
     pub fn test(cx: &mut MutableAppContext) -> Self {
-        let mut languages = LanguageRegistry::new();
-        languages.add(Arc::new(language::Language::new(
-            language::LanguageConfig {
-                name: "Rust".to_string(),
-                path_suffixes: vec!["rs".to_string()],
-                ..Default::default()
-            },
-            tree_sitter_rust::language(),
-        )));
-
+        let languages = LanguageRegistry::new();
         let client = Client::new();
         let http_client = client::test::FakeHttpClient::new(|_| async move {
             Ok(client::http::ServerResponse::new(404))
@@ -702,7 +693,7 @@ impl Workspace {
         }
     }
 
-    pub fn active_item(&self, cx: &ViewContext<Self>) -> Option<Box<dyn ItemViewHandle>> {
+    pub fn active_item(&self, cx: &AppContext) -> Option<Box<dyn ItemViewHandle>> {
         self.active_pane().read(cx).active_item()
     }
 
@@ -884,7 +875,7 @@ impl Workspace {
         }
     }
 
-    fn split_pane(
+    pub fn split_pane(
         &mut self,
         pane: ViewHandle<Pane>,
         direction: SplitDirection,
@@ -911,6 +902,10 @@ impl Workspace {
         }
     }
 
+    pub fn panes(&self) -> &[ViewHandle<Pane>] {
+        &self.panes
+    }
+
     fn pane(&self, pane_id: usize) -> Option<ViewHandle<Pane>> {
         self.panes.iter().find(|pane| pane.id() == pane_id).cloned()
     }
@@ -1085,12 +1080,10 @@ impl View for Workspace {
     }
 }
 
-#[cfg(test)]
 pub trait WorkspaceHandle {
     fn file_project_paths(&self, cx: &AppContext) -> Vec<ProjectPath>;
 }
 
-#[cfg(test)]
 impl WorkspaceHandle for ViewHandle<Workspace> {
     fn file_project_paths(&self, cx: &AppContext) -> Vec<ProjectPath> {
         self.read(cx)
@@ -1106,448 +1099,3 @@ impl WorkspaceHandle for ViewHandle<Workspace> {
             .collect::<Vec<_>>()
     }
 }
-
-// #[cfg(test)]
-// mod tests {
-//     use super::*;
-//     use editor::{Editor, Input};
-//     use serde_json::json;
-//     use std::collections::HashSet;
-
-//     #[gpui::test]
-//     async fn test_open_entry(mut cx: gpui::TestAppContext) {
-//         let params = cx.update(WorkspaceParams::test);
-//         params
-//             .fs
-//             .as_fake()
-//             .insert_tree(
-//                 "/root",
-//                 json!({
-//                     "a": {
-//                         "file1": "contents 1",
-//                         "file2": "contents 2",
-//                         "file3": "contents 3",
-//                     },
-//                 }),
-//             )
-//             .await;
-
-//         let (_, workspace) = cx.add_window(|cx| Workspace::new(&params, cx));
-//         workspace
-//             .update(&mut cx, |workspace, cx| {
-//                 workspace.add_worktree(Path::new("/root"), cx)
-//             })
-//             .await
-//             .unwrap();
-
-//         cx.read(|cx| workspace.read(cx).worktree_scans_complete(cx))
-//             .await;
-//         let entries = cx.read(|cx| workspace.file_project_paths(cx));
-//         let file1 = entries[0].clone();
-//         let file2 = entries[1].clone();
-//         let file3 = entries[2].clone();
-
-//         // Open the first entry
-//         workspace
-//             .update(&mut cx, |w, cx| w.open_entry(file1.clone(), cx))
-//             .unwrap()
-//             .await;
-//         cx.read(|cx| {
-//             let pane = workspace.read(cx).active_pane().read(cx);
-//             assert_eq!(
-//                 pane.active_item().unwrap().project_path(cx),
-//                 Some(file1.clone())
-//             );
-//             assert_eq!(pane.items().len(), 1);
-//         });
-
-//         // Open the second entry
-//         workspace
-//             .update(&mut cx, |w, cx| w.open_entry(file2.clone(), cx))
-//             .unwrap()
-//             .await;
-//         cx.read(|cx| {
-//             let pane = workspace.read(cx).active_pane().read(cx);
-//             assert_eq!(
-//                 pane.active_item().unwrap().project_path(cx),
-//                 Some(file2.clone())
-//             );
-//             assert_eq!(pane.items().len(), 2);
-//         });
-
-//         // Open the first entry again. The existing pane item is activated.
-//         workspace.update(&mut cx, |w, cx| {
-//             assert!(w.open_entry(file1.clone(), cx).is_none())
-//         });
-//         cx.read(|cx| {
-//             let pane = workspace.read(cx).active_pane().read(cx);
-//             assert_eq!(
-//                 pane.active_item().unwrap().project_path(cx),
-//                 Some(file1.clone())
-//             );
-//             assert_eq!(pane.items().len(), 2);
-//         });
-
-//         // Split the pane with the first entry, then open the second entry again.
-//         workspace.update(&mut cx, |w, cx| {
-//             w.split_pane(w.active_pane().clone(), SplitDirection::Right, cx);
-//             assert!(w.open_entry(file2.clone(), cx).is_none());
-//             assert_eq!(
-//                 w.active_pane()
-//                     .read(cx)
-//                     .active_item()
-//                     .unwrap()
-//                     .project_path(cx.as_ref()),
-//                 Some(file2.clone())
-//             );
-//         });
-
-//         // Open the third entry twice concurrently. Only one pane item is added.
-//         let (t1, t2) = workspace.update(&mut cx, |w, cx| {
-//             (
-//                 w.open_entry(file3.clone(), cx).unwrap(),
-//                 w.open_entry(file3.clone(), cx).unwrap(),
-//             )
-//         });
-//         t1.await;
-//         t2.await;
-//         cx.read(|cx| {
-//             let pane = workspace.read(cx).active_pane().read(cx);
-//             assert_eq!(
-//                 pane.active_item().unwrap().project_path(cx),
-//                 Some(file3.clone())
-//             );
-//             let pane_entries = pane
-//                 .items()
-//                 .iter()
-//                 .map(|i| i.project_path(cx).unwrap())
-//                 .collect::<Vec<_>>();
-//             assert_eq!(pane_entries, &[file1, file2, file3]);
-//         });
-//     }
-
-//     #[gpui::test]
-//     async fn test_open_paths(mut cx: gpui::TestAppContext) {
-//         let params = cx.update(WorkspaceParams::test);
-//         let fs = params.fs.as_fake();
-//         fs.insert_dir("/dir1").await.unwrap();
-//         fs.insert_dir("/dir2").await.unwrap();
-//         fs.insert_file("/dir1/a.txt", "".into()).await.unwrap();
-//         fs.insert_file("/dir2/b.txt", "".into()).await.unwrap();
-
-//         let (_, workspace) = cx.add_window(|cx| Workspace::new(&params, cx));
-//         workspace
-//             .update(&mut cx, |workspace, cx| {
-//                 workspace.add_worktree("/dir1".as_ref(), cx)
-//             })
-//             .await
-//             .unwrap();
-//         cx.read(|cx| workspace.read(cx).worktree_scans_complete(cx))
-//             .await;
-
-//         // Open a file within an existing worktree.
-//         cx.update(|cx| {
-//             workspace.update(cx, |view, cx| view.open_paths(&["/dir1/a.txt".into()], cx))
-//         })
-//         .await;
-//         cx.read(|cx| {
-//             assert_eq!(
-//                 workspace
-//                     .read(cx)
-//                     .active_pane()
-//                     .read(cx)
-//                     .active_item()
-//                     .unwrap()
-//                     .title(cx),
-//                 "a.txt"
-//             );
-//         });
-
-//         // Open a file outside of any existing worktree.
-//         cx.update(|cx| {
-//             workspace.update(cx, |view, cx| view.open_paths(&["/dir2/b.txt".into()], cx))
-//         })
-//         .await;
-//         cx.read(|cx| {
-//             let worktree_roots = workspace
-//                 .read(cx)
-//                 .worktrees(cx)
-//                 .iter()
-//                 .map(|w| w.read(cx).as_local().unwrap().abs_path())
-//                 .collect::<HashSet<_>>();
-//             assert_eq!(
-//                 worktree_roots,
-//                 vec!["/dir1", "/dir2/b.txt"]
-//                     .into_iter()
-//                     .map(Path::new)
-//                     .collect(),
-//             );
-//             assert_eq!(
-//                 workspace
-//                     .read(cx)
-//                     .active_pane()
-//                     .read(cx)
-//                     .active_item()
-//                     .unwrap()
-//                     .title(cx),
-//                 "b.txt"
-//             );
-//         });
-//     }
-
-//     #[gpui::test]
-//     async fn test_save_conflicting_item(mut cx: gpui::TestAppContext) {
-//         let params = cx.update(WorkspaceParams::test);
-//         let fs = params.fs.as_fake();
-//         fs.insert_tree("/root", json!({ "a.txt": "" })).await;
-
-//         let (window_id, workspace) = cx.add_window(|cx| Workspace::new(&params, cx));
-//         workspace
-//             .update(&mut cx, |workspace, cx| {
-//                 workspace.add_worktree(Path::new("/root"), cx)
-//             })
-//             .await
-//             .unwrap();
-
-//         // Open a file within an existing worktree.
-//         cx.update(|cx| {
-//             workspace.update(cx, |view, cx| {
-//                 view.open_paths(&[PathBuf::from("/root/a.txt")], cx)
-//             })
-//         })
-//         .await;
-//         let editor = cx.read(|cx| {
-//             let pane = workspace.read(cx).active_pane().read(cx);
-//             let item = pane.active_item().unwrap();
-//             item.to_any().downcast::<Editor>().unwrap()
-//         });
-
-//         cx.update(|cx| editor.update(cx, |editor, cx| editor.handle_input(&Input("x".into()), cx)));
-//         fs.insert_file("/root/a.txt", "changed".to_string())
-//             .await
-//             .unwrap();
-//         editor
-//             .condition(&cx, |editor, cx| editor.has_conflict(cx))
-//             .await;
-//         cx.read(|cx| assert!(editor.is_dirty(cx)));
-
-//         cx.update(|cx| workspace.update(cx, |w, cx| w.save_active_item(&Save, cx)));
-//         cx.simulate_prompt_answer(window_id, 0);
-//         editor
-//             .condition(&cx, |editor, cx| !editor.is_dirty(cx))
-//             .await;
-//         cx.read(|cx| assert!(!editor.has_conflict(cx)));
-//     }
-
-//     #[gpui::test]
-//     async fn test_open_and_save_new_file(mut cx: gpui::TestAppContext) {
-//         let params = cx.update(WorkspaceParams::test);
-//         params.fs.as_fake().insert_dir("/root").await.unwrap();
-//         let (_, workspace) = cx.add_window(|cx| Workspace::new(&params, cx));
-//         workspace
-//             .update(&mut cx, |workspace, cx| {
-//                 workspace.add_worktree(Path::new("/root"), cx)
-//             })
-//             .await
-//             .unwrap();
-//         let worktree = cx.read(|cx| {
-//             workspace
-//                 .read(cx)
-//                 .worktrees(cx)
-//                 .iter()
-//                 .next()
-//                 .unwrap()
-//                 .clone()
-//         });
-
-//         // Create a new untitled buffer
-//         let editor = workspace.update(&mut cx, |workspace, cx| {
-//             workspace.open_new_file(&OpenNew(params.clone()), cx);
-//             workspace
-//                 .active_item(cx)
-//                 .unwrap()
-//                 .to_any()
-//                 .downcast::<Editor>()
-//                 .unwrap()
-//         });
-
-//         editor.update(&mut cx, |editor, cx| {
-//             assert!(!editor.is_dirty(cx.as_ref()));
-//             assert_eq!(editor.title(cx.as_ref()), "untitled");
-//             assert!(editor.language(cx).is_none());
-//             editor.handle_input(&Input("hi".into()), cx);
-//             assert!(editor.is_dirty(cx.as_ref()));
-//         });
-
-//         // Save the buffer. This prompts for a filename.
-//         workspace.update(&mut cx, |workspace, cx| {
-//             workspace.save_active_item(&Save, cx)
-//         });
-//         cx.simulate_new_path_selection(|parent_dir| {
-//             assert_eq!(parent_dir, Path::new("/root"));
-//             Some(parent_dir.join("the-new-name.rs"))
-//         });
-//         cx.read(|cx| {
-//             assert!(editor.is_dirty(cx));
-//             assert_eq!(editor.title(cx), "untitled");
-//         });
-
-//         // When the save completes, the buffer's title is updated.
-//         editor
-//             .condition(&cx, |editor, cx| !editor.is_dirty(cx))
-//             .await;
-//         cx.read(|cx| {
-//             assert!(!editor.is_dirty(cx));
-//             assert_eq!(editor.title(cx), "the-new-name.rs");
-//         });
-//         // The language is assigned based on the path
-//         editor.read_with(&cx, |editor, cx| {
-//             assert_eq!(editor.language(cx).unwrap().name(), "Rust")
-//         });
-
-//         // Edit the file and save it again. This time, there is no filename prompt.
-//         editor.update(&mut cx, |editor, cx| {
-//             editor.handle_input(&Input(" there".into()), cx);
-//             assert_eq!(editor.is_dirty(cx.as_ref()), true);
-//         });
-//         workspace.update(&mut cx, |workspace, cx| {
-//             workspace.save_active_item(&Save, cx)
-//         });
-//         assert!(!cx.did_prompt_for_new_path());
-//         editor
-//             .condition(&cx, |editor, cx| !editor.is_dirty(cx))
-//             .await;
-//         cx.read(|cx| assert_eq!(editor.title(cx), "the-new-name.rs"));
-
-//         // Open the same newly-created file in another pane item. The new editor should reuse
-//         // the same buffer.
-//         workspace.update(&mut cx, |workspace, cx| {
-//             workspace.open_new_file(&OpenNew(params.clone()), cx);
-//             workspace.split_pane(workspace.active_pane().clone(), SplitDirection::Right, cx);
-//             assert!(workspace
-//                 .open_entry(
-//                     ProjectPath {
-//                         worktree_id: worktree.id(),
-//                         path: Path::new("the-new-name.rs").into()
-//                     },
-//                     cx
-//                 )
-//                 .is_none());
-//         });
-//         let editor2 = workspace.update(&mut cx, |workspace, cx| {
-//             workspace
-//                 .active_item(cx)
-//                 .unwrap()
-//                 .to_any()
-//                 .downcast::<Editor>()
-//                 .unwrap()
-//         });
-//         cx.read(|cx| {
-//             assert_eq!(editor2.read(cx).buffer(), editor.read(cx).buffer());
-//         })
-//     }
-
-//     #[gpui::test]
-//     async fn test_setting_language_when_saving_as_single_file_worktree(
-//         mut cx: gpui::TestAppContext,
-//     ) {
-//         let params = cx.update(WorkspaceParams::test);
-//         params.fs.as_fake().insert_dir("/root").await.unwrap();
-//         let (_, workspace) = cx.add_window(|cx| Workspace::new(&params, cx));
-
-//         // Create a new untitled buffer
-//         let editor = workspace.update(&mut cx, |workspace, cx| {
-//             workspace.open_new_file(&OpenNew(params.clone()), cx);
-//             workspace
-//                 .active_item(cx)
-//                 .unwrap()
-//                 .to_any()
-//                 .downcast::<Editor>()
-//                 .unwrap()
-//         });
-
-//         editor.update(&mut cx, |editor, cx| {
-//             assert!(editor.language(cx).is_none());
-//             editor.handle_input(&Input("hi".into()), cx);
-//             assert!(editor.is_dirty(cx.as_ref()));
-//         });
-
-//         // Save the buffer. This prompts for a filename.
-//         workspace.update(&mut cx, |workspace, cx| {
-//             workspace.save_active_item(&Save, cx)
-//         });
-//         cx.simulate_new_path_selection(|_| Some(PathBuf::from("/root/the-new-name.rs")));
-
-//         editor
-//             .condition(&cx, |editor, cx| !editor.is_dirty(cx))
-//             .await;
-
-//         // The language is assigned based on the path
-//         editor.read_with(&cx, |editor, cx| {
-//             assert_eq!(editor.language(cx).unwrap().name(), "Rust")
-//         });
-//     }
-
-//     #[gpui::test]
-//     async fn test_pane_actions(mut cx: gpui::TestAppContext) {
-//         cx.update(|cx| pane::init(cx));
-//         let params = cx.update(WorkspaceParams::test);
-//         params
-//             .fs
-//             .as_fake()
-//             .insert_tree(
-//                 "/root",
-//                 json!({
-//                     "a": {
-//                         "file1": "contents 1",
-//                         "file2": "contents 2",
-//                         "file3": "contents 3",
-//                     },
-//                 }),
-//             )
-//             .await;
-
-//         let (window_id, workspace) = cx.add_window(|cx| Workspace::new(&params, cx));
-//         workspace
-//             .update(&mut cx, |workspace, cx| {
-//                 workspace.add_worktree(Path::new("/root"), cx)
-//             })
-//             .await
-//             .unwrap();
-//         cx.read(|cx| workspace.read(cx).worktree_scans_complete(cx))
-//             .await;
-//         let entries = cx.read(|cx| workspace.file_project_paths(cx));
-//         let file1 = entries[0].clone();
-
-//         let pane_1 = cx.read(|cx| workspace.read(cx).active_pane().clone());
-
-//         workspace
-//             .update(&mut cx, |w, cx| w.open_entry(file1.clone(), cx))
-//             .unwrap()
-//             .await;
-//         cx.read(|cx| {
-//             assert_eq!(
-//                 pane_1.read(cx).active_item().unwrap().project_path(cx),
-//                 Some(file1.clone())
-//             );
-//         });
-
-//         cx.dispatch_action(
-//             window_id,
-//             vec![pane_1.id()],
-//             pane::Split(SplitDirection::Right),
-//         );
-//         cx.update(|cx| {
-//             let pane_2 = workspace.read(cx).active_pane().clone();
-//             assert_ne!(pane_1, pane_2);
-
-//             let pane2_item = pane_2.read(cx).active_item().unwrap();
-//             assert_eq!(pane2_item.project_path(cx.as_ref()), Some(file1.clone()));
-
-//             cx.dispatch_action(window_id, vec![pane_2.id()], &CloseActiveItem);
-//             let workspace = workspace.read(cx);
-//             assert_eq!(workspace.panes.len(), 1);
-//             assert_eq!(workspace.active_pane(), &pane_1);
-//         });
-//     }
-// }

crates/zed/src/lib.rs 🔗

@@ -26,7 +26,7 @@ use std::{path::PathBuf, sync::Arc};
 use theme::ThemeRegistry;
 use theme_selector::ThemeSelectorParams;
 pub use workspace;
-use workspace::{Settings, Workspace, WorkspaceParams};
+use workspace::{OpenNew, Settings, Workspace, WorkspaceParams};
 
 action!(About);
 action!(Open, Arc<AppState>);
@@ -129,7 +129,7 @@ fn open_paths(action: &OpenPaths, cx: &mut MutableAppContext) -> Task<()> {
     })
 }
 
-fn open_new(action: &workspace::OpenNew, cx: &mut MutableAppContext) {
+fn open_new(action: &OpenNew, cx: &mut MutableAppContext) {
     let (window_id, workspace) =
         cx.add_window(window_options(), |cx| build_workspace(&action.0, cx));
     cx.dispatch_action(window_id, vec![workspace.id()], action);
@@ -212,11 +212,14 @@ impl<'a> From<&'a AppState> for ThemeSelectorParams {
 #[cfg(test)]
 mod tests {
     use super::*;
+    use editor::Editor;
+    use project::ProjectPath;
     use serde_json::json;
+    use std::{collections::HashSet, path::Path};
     use test::test_app_state;
     use theme::DEFAULT_THEME_NAME;
     use util::test::temp_tree;
-    use workspace::ItemView;
+    use workspace::{pane, ItemView, ItemViewHandle, SplitDirection, WorkspaceHandle};
 
     #[gpui::test]
     async fn test_open_paths_action(mut cx: gpui::TestAppContext) {
@@ -318,6 +321,451 @@ mod tests {
         });
     }
 
+    #[gpui::test]
+    async fn test_open_entry(mut cx: gpui::TestAppContext) {
+        let app_state = cx.update(test_app_state);
+        app_state
+            .fs
+            .as_fake()
+            .insert_tree(
+                "/root",
+                json!({
+                    "a": {
+                        "file1": "contents 1",
+                        "file2": "contents 2",
+                        "file3": "contents 3",
+                    },
+                }),
+            )
+            .await;
+
+        let (_, workspace) = cx.add_window(|cx| Workspace::new(&app_state.as_ref().into(), cx));
+        workspace
+            .update(&mut cx, |workspace, cx| {
+                workspace.add_worktree(Path::new("/root"), cx)
+            })
+            .await
+            .unwrap();
+
+        cx.read(|cx| workspace.read(cx).worktree_scans_complete(cx))
+            .await;
+        let entries = cx.read(|cx| workspace.file_project_paths(cx));
+        let file1 = entries[0].clone();
+        let file2 = entries[1].clone();
+        let file3 = entries[2].clone();
+
+        // Open the first entry
+        workspace
+            .update(&mut cx, |w, cx| w.open_entry(file1.clone(), cx))
+            .unwrap()
+            .await;
+        cx.read(|cx| {
+            let pane = workspace.read(cx).active_pane().read(cx);
+            assert_eq!(
+                pane.active_item().unwrap().project_path(cx),
+                Some(file1.clone())
+            );
+            assert_eq!(pane.items().len(), 1);
+        });
+
+        // Open the second entry
+        workspace
+            .update(&mut cx, |w, cx| w.open_entry(file2.clone(), cx))
+            .unwrap()
+            .await;
+        cx.read(|cx| {
+            let pane = workspace.read(cx).active_pane().read(cx);
+            assert_eq!(
+                pane.active_item().unwrap().project_path(cx),
+                Some(file2.clone())
+            );
+            assert_eq!(pane.items().len(), 2);
+        });
+
+        // Open the first entry again. The existing pane item is activated.
+        workspace.update(&mut cx, |w, cx| {
+            assert!(w.open_entry(file1.clone(), cx).is_none())
+        });
+        cx.read(|cx| {
+            let pane = workspace.read(cx).active_pane().read(cx);
+            assert_eq!(
+                pane.active_item().unwrap().project_path(cx),
+                Some(file1.clone())
+            );
+            assert_eq!(pane.items().len(), 2);
+        });
+
+        // Split the pane with the first entry, then open the second entry again.
+        workspace.update(&mut cx, |w, cx| {
+            w.split_pane(w.active_pane().clone(), SplitDirection::Right, cx);
+            assert!(w.open_entry(file2.clone(), cx).is_none());
+            assert_eq!(
+                w.active_pane()
+                    .read(cx)
+                    .active_item()
+                    .unwrap()
+                    .project_path(cx.as_ref()),
+                Some(file2.clone())
+            );
+        });
+
+        // Open the third entry twice concurrently. Only one pane item is added.
+        let (t1, t2) = workspace.update(&mut cx, |w, cx| {
+            (
+                w.open_entry(file3.clone(), cx).unwrap(),
+                w.open_entry(file3.clone(), cx).unwrap(),
+            )
+        });
+        t1.await;
+        t2.await;
+        cx.read(|cx| {
+            let pane = workspace.read(cx).active_pane().read(cx);
+            assert_eq!(
+                pane.active_item().unwrap().project_path(cx),
+                Some(file3.clone())
+            );
+            let pane_entries = pane
+                .items()
+                .iter()
+                .map(|i| i.project_path(cx).unwrap())
+                .collect::<Vec<_>>();
+            assert_eq!(pane_entries, &[file1, file2, file3]);
+        });
+    }
+
+    #[gpui::test]
+    async fn test_open_paths(mut cx: gpui::TestAppContext) {
+        let app_state = cx.update(test_app_state);
+        let fs = app_state.fs.as_fake();
+        fs.insert_dir("/dir1").await.unwrap();
+        fs.insert_dir("/dir2").await.unwrap();
+        fs.insert_file("/dir1/a.txt", "".into()).await.unwrap();
+        fs.insert_file("/dir2/b.txt", "".into()).await.unwrap();
+
+        let (_, workspace) = cx.add_window(|cx| Workspace::new(&app_state.as_ref().into(), cx));
+        workspace
+            .update(&mut cx, |workspace, cx| {
+                workspace.add_worktree("/dir1".as_ref(), cx)
+            })
+            .await
+            .unwrap();
+        cx.read(|cx| workspace.read(cx).worktree_scans_complete(cx))
+            .await;
+
+        // Open a file within an existing worktree.
+        cx.update(|cx| {
+            workspace.update(cx, |view, cx| view.open_paths(&["/dir1/a.txt".into()], cx))
+        })
+        .await;
+        cx.read(|cx| {
+            assert_eq!(
+                workspace
+                    .read(cx)
+                    .active_pane()
+                    .read(cx)
+                    .active_item()
+                    .unwrap()
+                    .title(cx),
+                "a.txt"
+            );
+        });
+
+        // Open a file outside of any existing worktree.
+        cx.update(|cx| {
+            workspace.update(cx, |view, cx| view.open_paths(&["/dir2/b.txt".into()], cx))
+        })
+        .await;
+        cx.read(|cx| {
+            let worktree_roots = workspace
+                .read(cx)
+                .worktrees(cx)
+                .iter()
+                .map(|w| w.read(cx).as_local().unwrap().abs_path())
+                .collect::<HashSet<_>>();
+            assert_eq!(
+                worktree_roots,
+                vec!["/dir1", "/dir2/b.txt"]
+                    .into_iter()
+                    .map(Path::new)
+                    .collect(),
+            );
+            assert_eq!(
+                workspace
+                    .read(cx)
+                    .active_pane()
+                    .read(cx)
+                    .active_item()
+                    .unwrap()
+                    .title(cx),
+                "b.txt"
+            );
+        });
+    }
+
+    #[gpui::test]
+    async fn test_save_conflicting_item(mut cx: gpui::TestAppContext) {
+        let app_state = cx.update(test_app_state);
+        let fs = app_state.fs.as_fake();
+        fs.insert_tree("/root", json!({ "a.txt": "" })).await;
+
+        let (window_id, workspace) =
+            cx.add_window(|cx| Workspace::new(&app_state.as_ref().into(), cx));
+        workspace
+            .update(&mut cx, |workspace, cx| {
+                workspace.add_worktree(Path::new("/root"), cx)
+            })
+            .await
+            .unwrap();
+
+        // Open a file within an existing worktree.
+        cx.update(|cx| {
+            workspace.update(cx, |view, cx| {
+                view.open_paths(&[PathBuf::from("/root/a.txt")], cx)
+            })
+        })
+        .await;
+        let editor = cx.read(|cx| {
+            let pane = workspace.read(cx).active_pane().read(cx);
+            let item = pane.active_item().unwrap();
+            item.to_any().downcast::<Editor>().unwrap()
+        });
+
+        cx.update(|cx| {
+            editor.update(cx, |editor, cx| {
+                editor.handle_input(&editor::Input("x".into()), cx)
+            })
+        });
+        fs.insert_file("/root/a.txt", "changed".to_string())
+            .await
+            .unwrap();
+        editor
+            .condition(&cx, |editor, cx| editor.has_conflict(cx))
+            .await;
+        cx.read(|cx| assert!(editor.is_dirty(cx)));
+
+        cx.update(|cx| workspace.update(cx, |w, cx| w.save_active_item(&workspace::Save, cx)));
+        cx.simulate_prompt_answer(window_id, 0);
+        editor
+            .condition(&cx, |editor, cx| !editor.is_dirty(cx))
+            .await;
+        cx.read(|cx| assert!(!editor.has_conflict(cx)));
+    }
+
+    #[gpui::test]
+    async fn test_open_and_save_new_file(mut cx: gpui::TestAppContext) {
+        let app_state = cx.update(test_app_state);
+        app_state.fs.as_fake().insert_dir("/root").await.unwrap();
+        let params = app_state.as_ref().into();
+        let (window_id, workspace) = cx.add_window(|cx| Workspace::new(&params, cx));
+        workspace
+            .update(&mut cx, |workspace, cx| {
+                workspace.add_worktree(Path::new("/root"), cx)
+            })
+            .await
+            .unwrap();
+        let worktree = cx.read(|cx| {
+            workspace
+                .read(cx)
+                .worktrees(cx)
+                .iter()
+                .next()
+                .unwrap()
+                .clone()
+        });
+
+        // Create a new untitled buffer
+        cx.dispatch_action(window_id, vec![workspace.id()], OpenNew(params.clone()));
+        let editor = workspace.read_with(&cx, |workspace, cx| {
+            workspace
+                .active_item(cx)
+                .unwrap()
+                .to_any()
+                .downcast::<Editor>()
+                .unwrap()
+        });
+
+        editor.update(&mut cx, |editor, cx| {
+            assert!(!editor.is_dirty(cx.as_ref()));
+            assert_eq!(editor.title(cx.as_ref()), "untitled");
+            assert!(editor.language(cx).is_none());
+            editor.handle_input(&editor::Input("hi".into()), cx);
+            assert!(editor.is_dirty(cx.as_ref()));
+        });
+
+        // Save the buffer. This prompts for a filename.
+        workspace.update(&mut cx, |workspace, cx| {
+            workspace.save_active_item(&workspace::Save, cx)
+        });
+        cx.simulate_new_path_selection(|parent_dir| {
+            assert_eq!(parent_dir, Path::new("/root"));
+            Some(parent_dir.join("the-new-name.rs"))
+        });
+        cx.read(|cx| {
+            assert!(editor.is_dirty(cx));
+            assert_eq!(editor.title(cx), "untitled");
+        });
+
+        // When the save completes, the buffer's title is updated.
+        editor
+            .condition(&cx, |editor, cx| !editor.is_dirty(cx))
+            .await;
+        cx.read(|cx| {
+            assert!(!editor.is_dirty(cx));
+            assert_eq!(editor.title(cx), "the-new-name.rs");
+        });
+        // The language is assigned based on the path
+        editor.read_with(&cx, |editor, cx| {
+            assert_eq!(editor.language(cx).unwrap().name(), "Rust")
+        });
+
+        // Edit the file and save it again. This time, there is no filename prompt.
+        editor.update(&mut cx, |editor, cx| {
+            editor.handle_input(&editor::Input(" there".into()), cx);
+            assert_eq!(editor.is_dirty(cx.as_ref()), true);
+        });
+        workspace.update(&mut cx, |workspace, cx| {
+            workspace.save_active_item(&workspace::Save, cx)
+        });
+        assert!(!cx.did_prompt_for_new_path());
+        editor
+            .condition(&cx, |editor, cx| !editor.is_dirty(cx))
+            .await;
+        cx.read(|cx| assert_eq!(editor.title(cx), "the-new-name.rs"));
+
+        // Open the same newly-created file in another pane item. The new editor should reuse
+        // the same buffer.
+        cx.dispatch_action(window_id, vec![workspace.id()], OpenNew(params.clone()));
+        workspace.update(&mut cx, |workspace, cx| {
+            workspace.split_pane(workspace.active_pane().clone(), SplitDirection::Right, cx);
+            assert!(workspace
+                .open_entry(
+                    ProjectPath {
+                        worktree_id: worktree.id(),
+                        path: Path::new("the-new-name.rs").into()
+                    },
+                    cx
+                )
+                .is_none());
+        });
+        let editor2 = workspace.update(&mut cx, |workspace, cx| {
+            workspace
+                .active_item(cx)
+                .unwrap()
+                .to_any()
+                .downcast::<Editor>()
+                .unwrap()
+        });
+        cx.read(|cx| {
+            assert_eq!(editor2.read(cx).buffer(), editor.read(cx).buffer());
+        })
+    }
+
+    #[gpui::test]
+    async fn test_setting_language_when_saving_as_single_file_worktree(
+        mut cx: gpui::TestAppContext,
+    ) {
+        let app_state = cx.update(test_app_state);
+        app_state.fs.as_fake().insert_dir("/root").await.unwrap();
+        let params = app_state.as_ref().into();
+        let (window_id, workspace) = cx.add_window(|cx| Workspace::new(&params, cx));
+
+        // Create a new untitled buffer
+        cx.dispatch_action(window_id, vec![workspace.id()], OpenNew(params.clone()));
+        let editor = workspace.read_with(&cx, |workspace, cx| {
+            workspace
+                .active_item(cx)
+                .unwrap()
+                .to_any()
+                .downcast::<Editor>()
+                .unwrap()
+        });
+
+        editor.update(&mut cx, |editor, cx| {
+            assert!(editor.language(cx).is_none());
+            editor.handle_input(&editor::Input("hi".into()), cx);
+            assert!(editor.is_dirty(cx.as_ref()));
+        });
+
+        // Save the buffer. This prompts for a filename.
+        workspace.update(&mut cx, |workspace, cx| {
+            workspace.save_active_item(&workspace::Save, cx)
+        });
+        cx.simulate_new_path_selection(|_| Some(PathBuf::from("/root/the-new-name.rs")));
+
+        editor
+            .condition(&cx, |editor, cx| !editor.is_dirty(cx))
+            .await;
+
+        // The language is assigned based on the path
+        editor.read_with(&cx, |editor, cx| {
+            assert_eq!(editor.language(cx).unwrap().name(), "Rust")
+        });
+    }
+
+    #[gpui::test]
+    async fn test_pane_actions(mut cx: gpui::TestAppContext) {
+        cx.update(|cx| pane::init(cx));
+        let app_state = cx.update(test_app_state);
+        app_state
+            .fs
+            .as_fake()
+            .insert_tree(
+                "/root",
+                json!({
+                    "a": {
+                        "file1": "contents 1",
+                        "file2": "contents 2",
+                        "file3": "contents 3",
+                    },
+                }),
+            )
+            .await;
+
+        let (window_id, workspace) =
+            cx.add_window(|cx| Workspace::new(&app_state.as_ref().into(), cx));
+        workspace
+            .update(&mut cx, |workspace, cx| {
+                workspace.add_worktree(Path::new("/root"), cx)
+            })
+            .await
+            .unwrap();
+        cx.read(|cx| workspace.read(cx).worktree_scans_complete(cx))
+            .await;
+        let entries = cx.read(|cx| workspace.file_project_paths(cx));
+        let file1 = entries[0].clone();
+
+        let pane_1 = cx.read(|cx| workspace.read(cx).active_pane().clone());
+
+        workspace
+            .update(&mut cx, |w, cx| w.open_entry(file1.clone(), cx))
+            .unwrap()
+            .await;
+        cx.read(|cx| {
+            assert_eq!(
+                pane_1.read(cx).active_item().unwrap().project_path(cx),
+                Some(file1.clone())
+            );
+        });
+
+        cx.dispatch_action(
+            window_id,
+            vec![pane_1.id()],
+            pane::Split(SplitDirection::Right),
+        );
+        cx.update(|cx| {
+            let pane_2 = workspace.read(cx).active_pane().clone();
+            assert_ne!(pane_1, pane_2);
+
+            let pane2_item = pane_2.read(cx).active_item().unwrap();
+            assert_eq!(pane2_item.project_path(cx.as_ref()), Some(file1.clone()));
+
+            cx.dispatch_action(window_id, vec![pane_2.id()], &workspace::CloseActiveItem);
+            let workspace = workspace.read(cx);
+            assert_eq!(workspace.panes().len(), 1);
+            assert_eq!(workspace.active_pane(), &pane_1);
+        });
+    }
+
     #[gpui::test]
     fn test_bundled_themes(cx: &mut MutableAppContext) {
         let app_state = test_app_state(cx);

crates/zed/src/test.rs 🔗

@@ -16,21 +16,32 @@ fn init_logger() {
 }
 
 pub fn test_app_state(cx: &mut MutableAppContext) -> Arc<AppState> {
+    let mut entry_openers = Vec::new();
+    editor::init(cx, &mut entry_openers);
     let (settings_tx, settings) = watch::channel_with(build_settings(cx));
     let themes = ThemeRegistry::new(Assets, cx.font_cache().clone());
     let client = Client::new();
     let http = FakeHttpClient::new(|_| async move { Ok(ServerResponse::new(404)) });
     let user_store = cx.add_model(|cx| UserStore::new(client.clone(), http, cx));
+    let mut languages = LanguageRegistry::new();
+    languages.add(Arc::new(language::Language::new(
+        language::LanguageConfig {
+            name: "Rust".to_string(),
+            path_suffixes: vec!["rs".to_string()],
+            ..Default::default()
+        },
+        tree_sitter_rust::language(),
+    )));
     Arc::new(AppState {
         settings_tx: Arc::new(Mutex::new(settings_tx)),
         settings,
         themes,
-        languages: Arc::new(LanguageRegistry::new()),
+        languages: Arc::new(languages),
         channel_list: cx.add_model(|cx| ChannelList::new(user_store.clone(), client.clone(), cx)),
         client,
         user_store,
         fs: Arc::new(FakeFs::new()),
-        entry_openers: Arc::from([]),
+        entry_openers: Arc::from(entry_openers),
     })
 }