Merge pull request #249 from zed-industries/invert-workspace-editor-dependency

Antonio Scandurra created

Invert dependency between workspace and editor

Change summary

Cargo.lock                      |   4 
crates/editor/Cargo.toml        |   2 
crates/editor/src/items.rs      | 134 +++++--
crates/editor/src/lib.rs        |  16 
crates/file_finder/Cargo.toml   |   1 
crates/file_finder/src/lib.rs   |  13 
crates/project_panel/Cargo.toml |   2 
crates/server/src/rpc.rs        |  22 
crates/workspace/Cargo.toml     |   3 
crates/workspace/src/lib.rs     | 620 +++++-----------------------------
crates/workspace/src/pane.rs    |   1 
crates/zed/src/lib.rs           | 474 ++++++++++++++++++++++++++
crates/zed/src/main.rs          |  19 
crates/zed/src/menus.rs         |   1 
crates/zed/src/test.rs          |  14 
15 files changed, 716 insertions(+), 610 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -1532,6 +1532,7 @@ dependencies = [
  "log",
  "parking_lot",
  "postage",
+ "project",
  "rand 0.8.3",
  "serde",
  "smallvec",
@@ -1542,6 +1543,7 @@ dependencies = [
  "tree-sitter-rust",
  "unindent",
  "util",
+ "workspace",
 ]
 
 [[package]]
@@ -5611,9 +5613,7 @@ name = "workspace"
 version = "0.1.0"
 dependencies = [
  "anyhow",
- "buffer",
  "client",
- "editor",
  "gpui",
  "language",
  "log",

crates/editor/Cargo.toml 🔗

@@ -15,9 +15,11 @@ buffer = { path = "../buffer" }
 clock = { path = "../clock" }
 gpui = { path = "../gpui" }
 language = { path = "../language" }
+project = { path = "../project" }
 sum_tree = { path = "../sum_tree" }
 theme = { path = "../theme" }
 util = { path = "../util" }
+workspace = { path = "../workspace" }
 anyhow = "1.0"
 lazy_static = "1.4"
 log = "0.4"

crates/workspace/src/items.rs → crates/editor/src/items.rs 🔗

@@ -1,66 +1,110 @@
-use super::{Item, ItemView};
-use crate::{status_bar::StatusItemView, Settings};
+use crate::{Editor, EditorSettings, Event};
 use anyhow::Result;
 use buffer::{Point, Selection, ToPoint};
-use editor::{Editor, EditorSettings, Event};
 use gpui::{
-    elements::*, fonts::TextStyle, AppContext, Entity, ModelHandle, RenderContext, Subscription,
-    Task, View, ViewContext, ViewHandle,
+    elements::*, fonts::TextStyle, AppContext, Entity, ModelContext, ModelHandle,
+    MutableAppContext, RenderContext, Subscription, Task, View, ViewContext, ViewHandle,
+    WeakModelHandle,
 };
 use language::{Buffer, Diagnostic, File as _};
 use postage::watch;
 use project::{ProjectPath, Worktree};
 use std::fmt::Write;
 use std::path::Path;
+use workspace::{
+    EntryOpener, ItemHandle, ItemView, ItemViewHandle, Settings, StatusItemView, WeakItemHandle,
+};
 
-impl Item for Buffer {
-    type View = Editor;
+pub struct BufferOpener;
+
+#[derive(Clone)]
+pub struct BufferItemHandle(pub ModelHandle<Buffer>);
+
+#[derive(Clone)]
+struct WeakBufferItemHandle(WeakModelHandle<Buffer>);
+
+impl EntryOpener for BufferOpener {
+    fn open(
+        &self,
+        worktree: &mut Worktree,
+        project_path: ProjectPath,
+        cx: &mut ModelContext<Worktree>,
+    ) -> Option<Task<Result<Box<dyn ItemHandle>>>> {
+        let buffer = worktree.open_buffer(project_path.path, cx);
+        let task = cx.spawn(|_, _| async move {
+            buffer
+                .await
+                .map(|buffer| Box::new(BufferItemHandle(buffer)) as Box<dyn ItemHandle>)
+        });
+        Some(task)
+    }
+}
 
-    fn build_view(
-        handle: ModelHandle<Self>,
+impl ItemHandle for BufferItemHandle {
+    fn add_view(
+        &self,
+        window_id: usize,
         settings: watch::Receiver<Settings>,
-        cx: &mut ViewContext<Self::View>,
-    ) -> Self::View {
-        Editor::for_buffer(
-            handle,
-            move |cx| {
-                let settings = settings.borrow();
-                let font_cache = cx.font_cache();
-                let font_family_id = settings.buffer_font_family;
-                let font_family_name = cx.font_cache().family_name(font_family_id).unwrap();
-                let font_properties = Default::default();
-                let font_id = font_cache
-                    .select_font(font_family_id, &font_properties)
-                    .unwrap();
-                let font_size = settings.buffer_font_size;
-
-                let mut theme = settings.theme.editor.clone();
-                theme.text = TextStyle {
-                    color: theme.text.color,
-                    font_family_name,
-                    font_family_id,
-                    font_id,
-                    font_size,
-                    font_properties,
-                    underline: None,
-                };
-                EditorSettings {
-                    tab_size: settings.tab_size,
-                    style: theme,
-                }
-            },
-            cx,
-        )
+        cx: &mut MutableAppContext,
+    ) -> Box<dyn ItemViewHandle> {
+        Box::new(cx.add_view(window_id, |cx| {
+            Editor::for_buffer(
+                self.0.clone(),
+                move |cx| {
+                    let settings = settings.borrow();
+                    let font_cache = cx.font_cache();
+                    let font_family_id = settings.buffer_font_family;
+                    let font_family_name = cx.font_cache().family_name(font_family_id).unwrap();
+                    let font_properties = Default::default();
+                    let font_id = font_cache
+                        .select_font(font_family_id, &font_properties)
+                        .unwrap();
+                    let font_size = settings.buffer_font_size;
+
+                    let mut theme = settings.theme.editor.clone();
+                    theme.text = TextStyle {
+                        color: theme.text.color,
+                        font_family_name,
+                        font_family_id,
+                        font_id,
+                        font_size,
+                        font_properties,
+                        underline: None,
+                    };
+                    EditorSettings {
+                        tab_size: settings.tab_size,
+                        style: theme,
+                    }
+                },
+                cx,
+            )
+        }))
+    }
+
+    fn boxed_clone(&self) -> Box<dyn ItemHandle> {
+        Box::new(self.clone())
     }
 
-    fn project_path(&self) -> Option<ProjectPath> {
-        self.file().map(|f| ProjectPath {
+    fn downgrade(&self) -> Box<dyn workspace::WeakItemHandle> {
+        Box::new(WeakBufferItemHandle(self.0.downgrade()))
+    }
+
+    fn project_path(&self, cx: &AppContext) -> Option<ProjectPath> {
+        self.0.read(cx).file().map(|f| ProjectPath {
             worktree_id: f.worktree_id(),
             path: f.path().clone(),
         })
     }
 }
 
+impl WeakItemHandle for WeakBufferItemHandle {
+    fn upgrade(&self, cx: &AppContext) -> Option<Box<dyn ItemHandle>> {
+        self.0
+            .upgrade(cx)
+            .map(|buffer| Box::new(BufferItemHandle(buffer)) as Box<dyn ItemHandle>)
+    }
+}
+
 impl ItemView for Editor {
     fn should_activate_item_on_event(event: &Event) -> bool {
         matches!(event, Event::Activate)
@@ -226,7 +270,7 @@ impl View for CursorPosition {
 impl StatusItemView for CursorPosition {
     fn set_active_pane_item(
         &mut self,
-        active_pane_item: Option<&dyn crate::ItemViewHandle>,
+        active_pane_item: Option<&dyn ItemViewHandle>,
         cx: &mut ViewContext<Self>,
     ) {
         if let Some(editor) = active_pane_item.and_then(|item| item.to_any().downcast::<Editor>()) {
@@ -312,7 +356,7 @@ impl View for DiagnosticMessage {
 impl StatusItemView for DiagnosticMessage {
     fn set_active_pane_item(
         &mut self,
-        active_pane_item: Option<&dyn crate::ItemViewHandle>,
+        active_pane_item: Option<&dyn ItemViewHandle>,
         cx: &mut ViewContext<Self>,
     ) {
         if let Some(editor) = active_pane_item.and_then(|item| item.to_any().downcast::<Editor>()) {

crates/editor/src/lib.rs 🔗

@@ -1,5 +1,6 @@
 pub mod display_map;
 mod element;
+pub mod items;
 pub mod movement;
 
 #[cfg(test)]
@@ -17,6 +18,7 @@ use gpui::{
     text_layout, AppContext, ClipboardItem, Element, ElementBox, Entity, ModelHandle,
     MutableAppContext, RenderContext, View, ViewContext, WeakViewHandle,
 };
+use items::BufferItemHandle;
 use language::*;
 use serde::{Deserialize, Serialize};
 use smallvec::SmallVec;
@@ -34,6 +36,7 @@ use std::{
 use sum_tree::Bias;
 use theme::{DiagnosticStyle, EditorStyle, SyntaxTheme};
 use util::post_inc;
+use workspace::{EntryOpener, Workspace};
 
 const CURSOR_BLINK_INTERVAL: Duration = Duration::from_millis(500);
 const MAX_LINE_LEN: usize = 1024;
@@ -97,7 +100,8 @@ action!(FoldSelectedRanges);
 action!(Scroll, Vector2F);
 action!(Select, SelectPhase);
 
-pub fn init(cx: &mut MutableAppContext) {
+pub fn init(cx: &mut MutableAppContext, entry_openers: &mut Vec<Box<dyn EntryOpener>>) {
+    entry_openers.push(Box::new(items::BufferOpener));
     cx.add_bindings(vec![
         Binding::new("escape", Cancel, Some("Editor")),
         Binding::new("backspace", Backspace, Some("Editor")),
@@ -201,6 +205,7 @@ pub fn init(cx: &mut MutableAppContext) {
         Binding::new("alt-cmd-f", FoldSelectedRanges, Some("Editor")),
     ]);
 
+    cx.add_action(Editor::open_new);
     cx.add_action(|this: &mut Editor, action: &Scroll, cx| this.set_scroll_position(action.0, cx));
     cx.add_action(Editor::select);
     cx.add_action(Editor::cancel);
@@ -478,6 +483,15 @@ impl Editor {
         }
     }
 
+    pub fn open_new(
+        workspace: &mut Workspace,
+        _: &workspace::OpenNew,
+        cx: &mut ViewContext<Workspace>,
+    ) {
+        let buffer = cx.add_model(|cx| Buffer::new(0, "", cx));
+        workspace.add_item(BufferItemHandle(buffer), cx);
+    }
+
     pub fn replica_id(&self, cx: &AppContext) -> ReplicaId {
         self.buffer.read(cx).replica_id()
     }

crates/file_finder/Cargo.toml 🔗

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

crates/file_finder/src/lib.rs 🔗

@@ -429,7 +429,14 @@ mod tests {
 
     #[gpui::test]
     async fn test_matching_paths(mut cx: gpui::TestAppContext) {
-        let params = cx.update(WorkspaceParams::test);
+        let mut entry_openers = Vec::new();
+        cx.update(|cx| {
+            super::init(cx);
+            editor::init(cx, &mut entry_openers);
+        });
+
+        let mut params = cx.update(WorkspaceParams::test);
+        params.entry_openers = Arc::from(entry_openers);
         params
             .fs
             .as_fake()
@@ -443,10 +450,6 @@ mod tests {
                 }),
             )
             .await;
-        cx.update(|cx| {
-            super::init(cx);
-            editor::init(cx);
-        });
 
         let (window_id, workspace) = cx.add_window(|cx| Workspace::new(&params, cx));
         workspace

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/Cargo.toml 🔗

@@ -12,9 +12,7 @@ test-support = [
 ]
 
 [dependencies]
-buffer = { path = "../buffer" }
 client = { path = "../client" }
-editor = { path = "../editor" }
 gpui = { path = "../gpui" }
 language = { path = "../language" }
 project = { path = "../project" }
@@ -27,6 +25,7 @@ tree-sitter-rust = { version = "0.19.0", optional = true }
 
 [dev-dependencies]
 client = { path = "../client", features = ["test-support"] }
+gpui = { path = "../gpui", features = ["test-support"] }
 project = { path = "../project", features = ["test-support"] }
 serde_json = { version = "1.0.64", features = ["preserve_order"] }
 tree-sitter = "0.19.5"

crates/workspace/src/lib.rs 🔗

@@ -1,18 +1,17 @@
-mod items;
 pub mod pane;
 pub mod pane_group;
 pub mod settings;
 pub mod sidebar;
 mod status_bar;
 
-use anyhow::Result;
+use anyhow::{anyhow, Result};
 use client::{Authenticate, ChannelList, Client, UserStore};
 use gpui::{
     action, elements::*, json::to_string_pretty, keymap::Binding, platform::CursorStyle,
-    AnyViewHandle, AppContext, ClipboardItem, Entity, ModelHandle, MutableAppContext, PromptLevel,
-    RenderContext, Task, View, ViewContext, ViewHandle, WeakModelHandle,
+    AnyViewHandle, AppContext, ClipboardItem, Entity, ModelContext, ModelHandle, MutableAppContext,
+    PromptLevel, RenderContext, Task, View, ViewContext, ViewHandle, WeakModelHandle,
 };
-use language::{Buffer, LanguageRegistry};
+use language::LanguageRegistry;
 use log::error;
 pub use pane::*;
 pub use pane_group::*;
@@ -20,6 +19,8 @@ use postage::{prelude::Stream, watch};
 use project::{Fs, Project, ProjectPath, Worktree};
 pub use settings::Settings;
 use sidebar::{Side, Sidebar, SidebarItemId, ToggleSidebarItem, ToggleSidebarItemFocus};
+use status_bar::StatusBar;
+pub use status_bar::StatusItemView;
 use std::{
     collections::{hash_map::Entry, HashMap},
     future::Future,
@@ -27,8 +28,6 @@ use std::{
     sync::Arc,
 };
 
-use crate::status_bar::StatusBar;
-
 action!(OpenNew, WorkspaceParams);
 action!(Save);
 action!(DebugElements);
@@ -36,7 +35,6 @@ action!(DebugElements);
 pub fn init(cx: &mut MutableAppContext) {
     cx.add_action(Workspace::save_active_item);
     cx.add_action(Workspace::debug_elements);
-    cx.add_action(Workspace::open_new_file);
     cx.add_action(Workspace::toggle_sidebar_item);
     cx.add_action(Workspace::toggle_sidebar_item_focus);
     cx.add_bindings(vec![
@@ -62,6 +60,15 @@ pub fn init(cx: &mut MutableAppContext) {
     pane::init(cx);
 }
 
+pub trait EntryOpener {
+    fn open(
+        &self,
+        worktree: &mut Worktree,
+        path: ProjectPath,
+        cx: &mut ModelContext<Worktree>,
+    ) -> Option<Task<Result<Box<dyn ItemHandle>>>>;
+}
+
 pub trait Item: Entity + Sized {
     type View: ItemView;
 
@@ -108,21 +115,21 @@ pub trait ItemView: View {
 }
 
 pub trait ItemHandle: Send + Sync {
-    fn boxed_clone(&self) -> Box<dyn ItemHandle>;
-    fn downgrade(&self) -> Box<dyn WeakItemHandle>;
-}
-
-pub trait WeakItemHandle {
     fn add_view(
         &self,
         window_id: usize,
         settings: watch::Receiver<Settings>,
         cx: &mut MutableAppContext,
-    ) -> Option<Box<dyn ItemViewHandle>>;
-    fn alive(&self, cx: &AppContext) -> bool;
+    ) -> Box<dyn ItemViewHandle>;
+    fn boxed_clone(&self) -> Box<dyn ItemHandle>;
+    fn downgrade(&self) -> Box<dyn WeakItemHandle>;
     fn project_path(&self, cx: &AppContext) -> Option<ProjectPath>;
 }
 
+pub trait WeakItemHandle {
+    fn upgrade(&self, cx: &AppContext) -> Option<Box<dyn ItemHandle>>;
+}
+
 pub trait ItemViewHandle {
     fn title(&self, cx: &AppContext) -> String;
     fn project_path(&self, cx: &AppContext) -> Option<ProjectPath>;
@@ -143,6 +150,15 @@ pub trait ItemViewHandle {
 }
 
 impl<T: Item> ItemHandle for ModelHandle<T> {
+    fn add_view(
+        &self,
+        window_id: usize,
+        settings: watch::Receiver<Settings>,
+        cx: &mut MutableAppContext,
+    ) -> Box<dyn ItemViewHandle> {
+        Box::new(cx.add_view(window_id, |cx| T::build_view(self.clone(), settings, cx)))
+    }
+
     fn boxed_clone(&self) -> Box<dyn ItemHandle> {
         Box::new(self.clone())
     }
@@ -150,30 +166,38 @@ impl<T: Item> ItemHandle for ModelHandle<T> {
     fn downgrade(&self) -> Box<dyn WeakItemHandle> {
         Box::new(self.downgrade())
     }
+
+    fn project_path(&self, cx: &AppContext) -> Option<ProjectPath> {
+        self.read(cx).project_path()
+    }
 }
 
-impl<T: Item> WeakItemHandle for WeakModelHandle<T> {
+impl ItemHandle for Box<dyn ItemHandle> {
     fn add_view(
         &self,
         window_id: usize,
         settings: watch::Receiver<Settings>,
         cx: &mut MutableAppContext,
-    ) -> Option<Box<dyn ItemViewHandle>> {
-        if let Some(handle) = self.upgrade(cx.as_ref()) {
-            Some(Box::new(cx.add_view(window_id, |cx| {
-                T::build_view(handle, settings, cx)
-            })))
-        } else {
-            None
-        }
+    ) -> Box<dyn ItemViewHandle> {
+        ItemHandle::add_view(self.as_ref(), window_id, settings, cx)
     }
 
-    fn alive(&self, cx: &AppContext) -> bool {
-        self.upgrade(cx).is_some()
+    fn boxed_clone(&self) -> Box<dyn ItemHandle> {
+        self.as_ref().boxed_clone()
+    }
+
+    fn downgrade(&self) -> Box<dyn WeakItemHandle> {
+        self.as_ref().downgrade()
     }
 
     fn project_path(&self, cx: &AppContext) -> Option<ProjectPath> {
-        self.upgrade(cx).and_then(|h| h.read(cx).project_path())
+        self.as_ref().project_path(cx)
+    }
+}
+
+impl<T: Item> WeakItemHandle for WeakModelHandle<T> {
+    fn upgrade(&self, cx: &AppContext) -> Option<Box<dyn ItemHandle>> {
+        WeakModelHandle::<T>::upgrade(*self, cx).map(|i| Box::new(i) as Box<dyn ItemHandle>)
     }
 }
 
@@ -268,21 +292,13 @@ pub struct WorkspaceParams {
     pub settings: watch::Receiver<Settings>,
     pub user_store: ModelHandle<UserStore>,
     pub channel_list: ModelHandle<ChannelList>,
+    pub entry_openers: Arc<[Box<dyn EntryOpener>]>,
 }
 
 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))
@@ -299,6 +315,7 @@ impl WorkspaceParams {
             languages: Arc::new(languages),
             settings: watch::channel_with(settings).1,
             user_store,
+            entry_openers: Arc::from([]),
         }
     }
 }
@@ -316,6 +333,7 @@ pub struct Workspace {
     active_pane: ViewHandle<Pane>,
     status_bar: ViewHandle<StatusBar>,
     project: ModelHandle<Project>,
+    entry_openers: Arc<[Box<dyn EntryOpener>]>,
     items: Vec<Box<dyn WeakItemHandle>>,
     loading_items: HashMap<
         ProjectPath,
@@ -349,15 +367,7 @@ impl Workspace {
         .detach();
         cx.focus(&pane);
 
-        let cursor_position = cx.add_view(|_| items::CursorPosition::new(params.settings.clone()));
-        let diagnostic = cx.add_view(|_| items::DiagnosticMessage::new(params.settings.clone()));
-        let status_bar = cx.add_view(|cx| {
-            let mut status_bar = StatusBar::new(&pane, params.settings.clone(), cx);
-            status_bar.add_left_item(diagnostic, cx);
-            status_bar.add_right_item(cursor_position, cx);
-            status_bar
-        });
-
+        let status_bar = cx.add_view(|cx| StatusBar::new(&pane, params.settings.clone(), cx));
         let mut current_user = params.user_store.read(cx).watch_current_user().clone();
         let mut connection_status = params.client.status().clone();
         let _observe_current_user = cx.spawn_weak(|this, mut cx| async move {
@@ -388,6 +398,7 @@ impl Workspace {
             left_sidebar: Sidebar::new(Side::Left),
             right_sidebar: Sidebar::new(Side::Right),
             project,
+            entry_openers: params.entry_openers.clone(),
             items: Default::default(),
             loading_items: Default::default(),
             _observe_current_user,
@@ -402,6 +413,10 @@ impl Workspace {
         &mut self.right_sidebar
     }
 
+    pub fn status_bar(&self) -> &ViewHandle<StatusBar> {
+        &self.status_bar
+    }
+
     pub fn project(&self) -> &ModelHandle<Project> {
         &self.project
     }
@@ -560,16 +575,6 @@ impl Workspace {
         }
     }
 
-    pub fn open_new_file(&mut self, _: &OpenNew, cx: &mut ViewContext<Self>) {
-        let buffer = cx.add_model(|cx| Buffer::new(0, "", cx));
-        let item_handle = ItemHandle::downgrade(&buffer);
-        let view = item_handle
-            .add_view(cx.window_id(), self.settings.clone(), cx)
-            .unwrap();
-        self.items.push(item_handle);
-        self.active_pane().add_item_view(view, cx.as_mut());
-    }
-
     #[must_use]
     pub fn open_entry(
         &mut self,
@@ -581,8 +586,6 @@ impl Workspace {
             return None;
         }
 
-        // let (worktree_id, path) = project_path.clone();
-
         let worktree = match self
             .project
             .read(cx)
@@ -600,24 +603,26 @@ impl Workspace {
             entry.insert(rx);
 
             let project_path = project_path.clone();
+            let entry_openers = self.entry_openers.clone();
             cx.as_mut()
                 .spawn(|mut cx| async move {
-                    let buffer = worktree
-                        .update(&mut cx, |worktree, cx| {
-                            worktree.open_buffer(project_path.path.as_ref(), cx)
+                    let item = worktree.update(&mut cx, move |worktree, cx| {
+                        for opener in entry_openers.iter() {
+                            if let Some(task) = opener.open(worktree, project_path.clone(), cx) {
+                                return task;
+                            }
+                        }
+
+                        cx.spawn(|_, _| async move {
+                            Err(anyhow!("no opener for path {:?} found", project_path))
                         })
-                        .await;
-                    *tx.borrow_mut() = Some(
-                        buffer
-                            .map(|buffer| Box::new(buffer) as Box<dyn ItemHandle>)
-                            .map_err(Arc::new),
-                    );
+                    });
+                    *tx.borrow_mut() = Some(item.await.map_err(Arc::new));
                 })
                 .detach();
         }
 
         let pane = pane.downgrade();
-        let settings = self.settings.clone();
         let mut watch = self.loading_items.get(&project_path).unwrap().clone();
 
         Some(cx.spawn(|this, mut cx| async move {
@@ -637,12 +642,7 @@ impl Workspace {
                             // to the pane. If it was, we activate it, otherwise we'll store the
                             // item and add a new view for it.
                             if !this.activate_or_open_existing_entry(project_path, &pane, cx) {
-                                let weak_item = item.downgrade();
-                                let view = weak_item
-                                    .add_view(cx.window_id(), settings, cx.as_mut())
-                                    .unwrap();
-                                this.items.push(weak_item);
-                                pane.add_item_view(view, cx.as_mut());
+                                this.add_item(item, cx);
                             }
                         }
                         Err(error) => {
@@ -671,16 +671,14 @@ impl Workspace {
         let settings = self.settings.clone();
         let mut view_for_existing_item = None;
         self.items.retain(|item| {
-            if item.alive(cx.as_ref()) {
+            if let Some(item) = item.upgrade(cx) {
                 if view_for_existing_item.is_none()
                     && item
                         .project_path(cx)
                         .map_or(false, |item_project_path| item_project_path == project_path)
                 {
-                    view_for_existing_item = Some(
-                        item.add_view(cx.window_id(), settings.clone(), cx.as_mut())
-                            .unwrap(),
-                    );
+                    view_for_existing_item =
+                        Some(item.add_view(cx.window_id(), settings.clone(), cx.as_mut()));
                 }
                 true
             } else {
@@ -695,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()
     }
 
@@ -836,6 +834,15 @@ impl Workspace {
         pane
     }
 
+    pub fn add_item<T>(&mut self, item_handle: T, cx: &mut ViewContext<Self>)
+    where
+        T: ItemHandle,
+    {
+        let view = item_handle.add_view(cx.window_id(), self.settings.clone(), cx);
+        self.items.push(item_handle.downgrade());
+        self.active_pane().add_item_view(view, cx.as_mut());
+    }
+
     fn activate_pane(&mut self, pane: ViewHandle<Pane>, cx: &mut ViewContext<Self>) {
         self.active_pane = pane;
         self.status_bar.update(cx, |status_bar, cx| {
@@ -868,7 +875,7 @@ impl Workspace {
         }
     }
 
-    fn split_pane(
+    pub fn split_pane(
         &mut self,
         pane: ViewHandle<Pane>,
         direction: SplitDirection,
@@ -895,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()
     }
@@ -1069,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)
@@ -1090,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>);
@@ -45,6 +45,7 @@ pub struct AppState {
     pub user_store: ModelHandle<client::UserStore>,
     pub fs: Arc<dyn fs::Fs>,
     pub channel_list: ModelHandle<client::ChannelList>,
+    pub entry_openers: Arc<[Box<dyn workspace::EntryOpener>]>,
 }
 
 #[derive(Clone)]
@@ -128,12 +129,10 @@ fn open_paths(action: &OpenPaths, cx: &mut MutableAppContext) -> Task<()> {
     })
 }
 
-fn open_new(action: &workspace::OpenNew, cx: &mut MutableAppContext) {
-    cx.add_window(window_options(), |cx| {
-        let mut workspace = build_workspace(&action.0, cx);
-        workspace.open_new_file(&action, cx);
-        workspace
-    });
+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);
 }
 
 fn build_workspace(params: &WorkspaceParams, cx: &mut ViewContext<Workspace>) -> Workspace {
@@ -160,6 +159,16 @@ fn build_workspace(params: &WorkspaceParams, cx: &mut ViewContext<Workspace>) ->
         })
         .into(),
     );
+
+    let diagnostic =
+        cx.add_view(|_| editor::items::DiagnosticMessage::new(params.settings.clone()));
+    let cursor_position =
+        cx.add_view(|_| editor::items::CursorPosition::new(params.settings.clone()));
+    workspace.status_bar().update(cx, |status_bar, cx| {
+        status_bar.add_left_item(diagnostic, cx);
+        status_bar.add_right_item(cursor_position, cx);
+    });
+
     workspace
 }
 
@@ -185,6 +194,7 @@ impl<'a> From<&'a AppState> for WorkspaceParams {
             settings: state.settings.clone(),
             user_store: state.user_store.clone(),
             channel_list: state.channel_list.clone(),
+            entry_openers: state.entry_openers.clone(),
         }
     }
 }
@@ -202,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) {
@@ -308,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/main.rs 🔗

@@ -33,6 +33,16 @@ fn main() {
         let client = client::Client::new();
         let http = http::client();
         let user_store = cx.add_model(|cx| UserStore::new(client.clone(), http.clone(), cx));
+        let mut entry_openers = Vec::new();
+
+        client::init(client.clone(), cx);
+        workspace::init(cx);
+        editor::init(cx, &mut entry_openers);
+        file_finder::init(cx);
+        people_panel::init(cx);
+        chat_panel::init(cx);
+        project_panel::init(cx);
+
         let app_state = Arc::new(AppState {
             languages: languages.clone(),
             settings_tx: Arc::new(Mutex::new(settings_tx)),
@@ -43,16 +53,9 @@ fn main() {
             client,
             user_store,
             fs: Arc::new(RealFs),
+            entry_openers: Arc::from(entry_openers),
         });
-
         zed::init(&app_state, cx);
-        client::init(app_state.client.clone(), cx);
-        workspace::init(cx);
-        editor::init(cx);
-        file_finder::init(cx);
-        people_panel::init(cx);
-        chat_panel::init(cx);
-        project_panel::init(cx);
         theme_selector::init(app_state.as_ref().into(), cx);
 
         cx.set_menus(menus::menus(&app_state.clone()));

crates/zed/src/menus.rs 🔗

@@ -11,6 +11,7 @@ pub fn menus(state: &Arc<AppState>) -> Vec<Menu<'static>> {
         settings: state.settings.clone(),
         user_store: state.user_store.clone(),
         channel_list: state.channel_list.clone(),
+        entry_openers: state.entry_openers.clone(),
     };
 
     vec![

crates/zed/src/test.rs 🔗

@@ -16,20 +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),
     })
 }