diff --git a/Cargo.lock b/Cargo.lock index 8b0e92ebb1..a9ae6cbbb6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6031,6 +6031,20 @@ dependencies = [ [[package]] name = "workspace" version = "0.1.0" +dependencies = [ + "anyhow", + "buffer", + "client", + "editor", + "gpui", + "log", + "postage", + "project", + "serde_json 1.0.64", + "theme", + "tree-sitter", + "tree-sitter-rust", +] [[package]] name = "wyz" @@ -6122,6 +6136,7 @@ dependencies = [ "unindent", "url", "util", + "workspace", ] [[package]] diff --git a/crates/client/Cargo.toml b/crates/client/Cargo.toml index e71d7d4510..455e6a00fe 100644 --- a/crates/client/Cargo.toml +++ b/crates/client/Cargo.toml @@ -4,14 +4,13 @@ version = "0.1.0" edition = "2018" [features] -test-support = [] +test-support = ["rpc/test-support"] [dependencies] gpui = { path = "../gpui" } util = { path = "../util" } rpc = { path = "../rpc" } sum_tree = { path = "../sum_tree" } - anyhow = "1.0.38" async-recursion = "0.3" async-tungstenite = { version = "0.14", features = ["async-tls"] } diff --git a/crates/client/src/lib.rs b/crates/client/src/lib.rs index c3eb6807d4..1f3e2c008b 100644 --- a/crates/client/src/lib.rs +++ b/crates/client/src/lib.rs @@ -11,7 +11,7 @@ use async_tungstenite::tungstenite::{ error::Error as WebsocketError, http::{Request, StatusCode}, }; -use gpui::{AsyncAppContext, Entity, ModelContext, Task}; +use gpui::{action, AsyncAppContext, Entity, ModelContext, MutableAppContext, Task}; use lazy_static::lazy_static; use parking_lot::RwLock; use postage::{prelude::Stream, watch}; @@ -28,7 +28,7 @@ use std::{ }; use surf::Url; use thiserror::Error; -use util::ResultExt; +use util::{ResultExt, TryFutureExt}; pub use channel::*; pub use rpc::*; @@ -42,6 +42,16 @@ lazy_static! { .and_then(|s| if s.is_empty() { None } else { Some(s) }); } +action!(Authenticate); + +pub fn init(rpc: Arc, cx: &mut MutableAppContext) { + cx.add_global_action(move |_: &Authenticate, cx| { + let rpc = rpc.clone(); + cx.spawn(|cx| async move { rpc.authenticate_and_connect(&cx).log_err().await }) + .detach(); + }); +} + pub struct Client { peer: Arc, state: RwLock, diff --git a/crates/editor/src/lib.rs b/crates/editor/src/lib.rs index d0553bebec..d8380143c1 100644 --- a/crates/editor/src/lib.rs +++ b/crates/editor/src/lib.rs @@ -283,7 +283,7 @@ pub struct EditorSettings { pub style: EditorStyle, } -#[derive(Clone, Deserialize)] +#[derive(Clone, Deserialize, Default)] pub struct EditorStyle { pub text: TextStyle, #[serde(default)] diff --git a/crates/gpui/src/elements/label.rs b/crates/gpui/src/elements/label.rs index 4ae1504c1f..33274ffaeb 100644 --- a/crates/gpui/src/elements/label.rs +++ b/crates/gpui/src/elements/label.rs @@ -18,7 +18,7 @@ pub struct Label { highlight_indices: Vec, } -#[derive(Clone, Debug, Deserialize)] +#[derive(Clone, Debug, Deserialize, Default)] pub struct LabelStyle { pub text: TextStyle, pub highlight_text: Option, diff --git a/crates/gpui/src/fonts.rs b/crates/gpui/src/fonts.rs index f2bbd04477..4ac7a92bc4 100644 --- a/crates/gpui/src/fonts.rs +++ b/crates/gpui/src/fonts.rs @@ -167,6 +167,32 @@ impl From for HighlightStyle { } } +impl Default for TextStyle { + fn default() -> Self { + FONT_CACHE.with(|font_cache| { + let font_cache = font_cache.borrow(); + let font_cache = font_cache + .as_ref() + .expect("TextStyle::default can only be called within a call to with_font_cache"); + + let font_family_name = Arc::from("Courier"); + let font_family_id = font_cache.load_family(&[&font_family_name]).unwrap(); + let font_id = font_cache + .select_font(font_family_id, &Default::default()) + .unwrap(); + Self { + color: Default::default(), + font_family_name, + font_family_id, + font_id, + font_size: 14., + font_properties: Default::default(), + underline: Default::default(), + } + }) + } +} + impl HighlightStyle { fn from_json(json: HighlightStyleJson) -> Self { let font_properties = properties_from_json(json.weight, json.italic); diff --git a/crates/server/src/rpc.rs b/crates/server/src/rpc.rs index 119da770f7..1de7d68185 100644 --- a/crates/server/src/rpc.rs +++ b/crates/server/src/rpc.rs @@ -985,7 +985,7 @@ mod tests { fs::{FakeFs, Fs as _}, people_panel::JoinWorktree, project::{ProjectPath, Worktree}, - workspace::Workspace, + workspace::{Workspace, WorkspaceParams}, }; #[gpui::test] @@ -1102,13 +1102,9 @@ mod tests { let mut server = TestServer::start().await; let (client_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 app_state_b = zed::AppState { - client: client_b, - user_store: user_store_b, - ..Arc::try_unwrap(cx_b.update(zed::test::test_app_state)) - .ok() - .unwrap() - }; + let mut workspace_b_params = cx_b.update(WorkspaceParams::test); + workspace_b_params.client = client_b; + workspace_b_params.user_store = user_store_b; cx_a.foreground().forbid_parking(); @@ -1141,7 +1137,7 @@ mod tests { .await .unwrap(); - let (window_b, workspace_b) = cx_b.add_window(|cx| Workspace::new(&app_state_b, cx)); + let (window_b, workspace_b) = cx_b.add_window(|cx| Workspace::new(&workspace_b_params, cx)); cx_b.update(|cx| { cx.dispatch_action( window_b, diff --git a/crates/theme/src/lib.rs b/crates/theme/src/lib.rs index 9f93ba4608..b3d4befd00 100644 --- a/crates/theme/src/lib.rs +++ b/crates/theme/src/lib.rs @@ -14,7 +14,7 @@ pub use theme_registry::*; pub const DEFAULT_THEME_NAME: &'static str = "black"; -#[derive(Deserialize)] +#[derive(Deserialize, Default)] pub struct Theme { #[serde(default)] pub name: String, @@ -26,7 +26,7 @@ pub struct Theme { pub editor: EditorStyle, } -#[derive(Deserialize)] +#[derive(Deserialize, Default)] pub struct Workspace { pub background: Color, pub titlebar: Titlebar, @@ -37,7 +37,7 @@ pub struct Workspace { pub right_sidebar: Sidebar, } -#[derive(Clone, Deserialize)] +#[derive(Clone, Deserialize, Default)] pub struct Titlebar { #[serde(flatten)] pub container: ContainerStyle, @@ -49,14 +49,14 @@ pub struct Titlebar { pub outdated_warning: ContainedText, } -#[derive(Clone, Deserialize)] +#[derive(Clone, Deserialize, Default)] pub struct OfflineIcon { #[serde(flatten)] pub container: ContainerStyle, pub width: f32, } -#[derive(Clone, Deserialize)] +#[derive(Clone, Deserialize, Default)] pub struct Tab { pub height: f32, #[serde(flatten)] @@ -71,7 +71,7 @@ pub struct Tab { pub icon_conflict: Color, } -#[derive(Deserialize)] +#[derive(Deserialize, Default)] pub struct Sidebar { #[serde(flatten)] pub container: ContainerStyle, @@ -81,14 +81,14 @@ pub struct Sidebar { pub resize_handle: ContainerStyle, } -#[derive(Deserialize)] +#[derive(Deserialize, Default)] pub struct SidebarItem { pub icon_color: Color, pub icon_size: f32, pub height: f32, } -#[derive(Deserialize)] +#[derive(Deserialize, Default)] pub struct ChatPanel { #[serde(flatten)] pub container: ContainerStyle, @@ -100,7 +100,7 @@ pub struct ChatPanel { pub hovered_sign_in_prompt: TextStyle, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, Default)] pub struct ProjectPanel { #[serde(flatten)] pub container: ContainerStyle, @@ -110,7 +110,7 @@ pub struct ProjectPanel { pub hovered_selected_entry: ProjectPanelEntry, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, Default)] pub struct ProjectPanelEntry { pub height: f32, #[serde(flatten)] @@ -121,7 +121,7 @@ pub struct ProjectPanelEntry { pub icon_spacing: f32, } -#[derive(Deserialize)] +#[derive(Deserialize, Default)] pub struct PeoplePanel { #[serde(flatten)] pub container: ContainerStyle, @@ -136,7 +136,7 @@ pub struct PeoplePanel { pub hovered_unshared_worktree: WorktreeRow, } -#[derive(Deserialize)] +#[derive(Deserialize, Default)] pub struct WorktreeRow { #[serde(flatten)] pub container: ContainerStyle, @@ -146,7 +146,7 @@ pub struct WorktreeRow { pub guest_avatar_spacing: f32, } -#[derive(Deserialize)] +#[derive(Deserialize, Default)] pub struct ChatMessage { #[serde(flatten)] pub container: ContainerStyle, @@ -155,7 +155,7 @@ pub struct ChatMessage { pub timestamp: ContainedText, } -#[derive(Deserialize)] +#[derive(Deserialize, Default)] pub struct ChannelSelect { #[serde(flatten)] pub container: ContainerStyle, @@ -167,7 +167,7 @@ pub struct ChannelSelect { pub menu: ContainerStyle, } -#[derive(Deserialize)] +#[derive(Deserialize, Default)] pub struct ChannelName { #[serde(flatten)] pub container: ContainerStyle, @@ -175,7 +175,7 @@ pub struct ChannelName { pub name: TextStyle, } -#[derive(Deserialize)] +#[derive(Deserialize, Default)] pub struct Selector { #[serde(flatten)] pub container: ContainerStyle, @@ -185,7 +185,7 @@ pub struct Selector { pub active_item: ContainedLabel, } -#[derive(Clone, Debug, Deserialize)] +#[derive(Clone, Debug, Deserialize, Default)] pub struct ContainedText { #[serde(flatten)] pub container: ContainerStyle, @@ -193,7 +193,7 @@ pub struct ContainedText { pub text: TextStyle, } -#[derive(Deserialize)] +#[derive(Deserialize, Default)] pub struct ContainedLabel { #[serde(flatten)] pub container: ContainerStyle, @@ -201,7 +201,7 @@ pub struct ContainedLabel { pub label: LabelStyle, } -#[derive(Clone, Deserialize)] +#[derive(Clone, Deserialize, Default)] pub struct InputEditorStyle { #[serde(flatten)] pub container: ContainerStyle, diff --git a/crates/workspace/Cargo.toml b/crates/workspace/Cargo.toml index c8d25c0dba..3dcd3e557e 100644 --- a/crates/workspace/Cargo.toml +++ b/crates/workspace/Cargo.toml @@ -2,3 +2,31 @@ name = "workspace" version = "0.1.0" edition = "2018" + +[features] +test-support = [ + "client/test-support", + "project/test-support", + "tree-sitter", + "tree-sitter-rust" +] + +[dependencies] +buffer = { path = "../buffer" } +client = { path = "../client" } +editor = { path = "../editor" } +gpui = { path = "../gpui" } +project = { path = "../project" } +theme = { path = "../theme" } +anyhow = "1.0.38" +log = "0.4" +postage = { version = "0.4.1", features = ["futures-traits"] } +tree-sitter = { version = "0.19.5", optional = true } +tree-sitter-rust = { version = "0.19.0", optional = true } + +[dev-dependencies] +client = { path = "../client", features = ["test-support"] } +project = { path = "../project", features = ["test-support"] } +serde_json = { version = "1.0.64", features = ["preserve_order"] } +tree-sitter = "0.19.5" +tree-sitter-rust = "0.19.0" diff --git a/crates/zed/src/workspace/items.rs b/crates/workspace/src/items.rs similarity index 98% rename from crates/zed/src/workspace/items.rs rename to crates/workspace/src/items.rs index 9d927fcf64..b62858ed51 100644 --- a/crates/zed/src/workspace/items.rs +++ b/crates/workspace/src/items.rs @@ -1,11 +1,11 @@ use super::{Item, ItemView}; -use crate::{project::ProjectPath, Settings}; +use crate::Settings; use anyhow::Result; use buffer::{Buffer, File as _}; use editor::{Editor, EditorSettings, Event}; use gpui::{fonts::TextStyle, AppContext, ModelHandle, Task, ViewContext}; use postage::watch; -use project::Worktree; +use project::{ProjectPath, Worktree}; use std::path::Path; impl Item for Buffer { diff --git a/crates/workspace/src/lib.rs b/crates/workspace/src/lib.rs index e69de29bb2..6a5fc7f76b 100644 --- a/crates/workspace/src/lib.rs +++ b/crates/workspace/src/lib.rs @@ -0,0 +1,1517 @@ +mod items; +pub mod pane; +pub mod pane_group; +pub mod settings; +pub mod sidebar; + +use anyhow::Result; +use buffer::{Buffer, LanguageRegistry}; +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, +}; +use log::error; +pub use pane::*; +pub use pane_group::*; +use postage::{prelude::Stream, watch}; +use project::{Fs, Project, ProjectPath, Worktree}; +pub use settings::Settings; +use sidebar::{Side, Sidebar, SidebarItemId, ToggleSidebarItem, ToggleSidebarItemFocus}; +use std::{ + collections::{hash_map::Entry, HashMap}, + future::Future, + path::{Path, PathBuf}, + sync::Arc, +}; + +action!(OpenNew, WorkspaceParams); +action!(Save); +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![ + Binding::new("cmd-s", Save, None), + Binding::new("cmd-alt-i", DebugElements, None), + Binding::new( + "cmd-shift-!", + ToggleSidebarItem(SidebarItemId { + side: Side::Left, + item_index: 0, + }), + None, + ), + Binding::new( + "cmd-1", + ToggleSidebarItemFocus(SidebarItemId { + side: Side::Left, + item_index: 0, + }), + None, + ), + ]); + pane::init(cx); +} + +pub trait Item: Entity + Sized { + type View: ItemView; + + fn build_view( + handle: ModelHandle, + settings: watch::Receiver, + cx: &mut ViewContext, + ) -> Self::View; + + fn project_path(&self) -> Option; +} + +pub trait ItemView: View { + fn title(&self, cx: &AppContext) -> String; + fn project_path(&self, cx: &AppContext) -> Option; + fn clone_on_split(&self, _: &mut ViewContext) -> Option + where + Self: Sized, + { + None + } + fn is_dirty(&self, _: &AppContext) -> bool { + false + } + fn has_conflict(&self, _: &AppContext) -> bool { + false + } + fn save(&mut self, cx: &mut ViewContext) -> Result>>; + fn save_as( + &mut self, + worktree: ModelHandle, + path: &Path, + cx: &mut ViewContext, + ) -> Task>; + fn should_activate_item_on_event(_: &Self::Event) -> bool { + false + } + fn should_close_item_on_event(_: &Self::Event) -> bool { + false + } + fn should_update_tab_on_event(_: &Self::Event) -> bool { + false + } +} + +pub trait ItemHandle: Send + Sync { + fn boxed_clone(&self) -> Box; + fn downgrade(&self) -> Box; +} + +pub trait WeakItemHandle { + fn add_view( + &self, + window_id: usize, + settings: watch::Receiver, + cx: &mut MutableAppContext, + ) -> Option>; + fn alive(&self, cx: &AppContext) -> bool; + fn project_path(&self, cx: &AppContext) -> Option; +} + +pub trait ItemViewHandle { + fn title(&self, cx: &AppContext) -> String; + fn project_path(&self, cx: &AppContext) -> Option; + fn boxed_clone(&self) -> Box; + fn clone_on_split(&self, cx: &mut MutableAppContext) -> Option>; + fn set_parent_pane(&self, pane: &ViewHandle, cx: &mut MutableAppContext); + fn id(&self) -> usize; + fn to_any(&self) -> AnyViewHandle; + fn is_dirty(&self, cx: &AppContext) -> bool; + fn has_conflict(&self, cx: &AppContext) -> bool; + fn save(&self, cx: &mut MutableAppContext) -> Result>>; + fn save_as( + &self, + worktree: ModelHandle, + path: &Path, + cx: &mut MutableAppContext, + ) -> Task>; +} + +impl ItemHandle for ModelHandle { + fn boxed_clone(&self) -> Box { + Box::new(self.clone()) + } + + fn downgrade(&self) -> Box { + Box::new(self.downgrade()) + } +} + +impl WeakItemHandle for WeakModelHandle { + fn add_view( + &self, + window_id: usize, + settings: watch::Receiver, + cx: &mut MutableAppContext, + ) -> Option> { + 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 + } + } + + fn alive(&self, cx: &AppContext) -> bool { + self.upgrade(cx).is_some() + } + + fn project_path(&self, cx: &AppContext) -> Option { + self.upgrade(cx).and_then(|h| h.read(cx).project_path()) + } +} + +impl ItemViewHandle for ViewHandle { + fn title(&self, cx: &AppContext) -> String { + self.read(cx).title(cx) + } + + fn project_path(&self, cx: &AppContext) -> Option { + self.read(cx).project_path(cx) + } + + fn boxed_clone(&self) -> Box { + Box::new(self.clone()) + } + + fn clone_on_split(&self, cx: &mut MutableAppContext) -> Option> { + self.update(cx, |item, cx| { + cx.add_option_view(|cx| item.clone_on_split(cx)) + }) + .map(|handle| Box::new(handle) as Box) + } + + fn set_parent_pane(&self, pane: &ViewHandle, cx: &mut MutableAppContext) { + pane.update(cx, |_, cx| { + cx.subscribe(self, |pane, item, event, cx| { + if T::should_close_item_on_event(event) { + pane.close_item(item.id(), cx); + return; + } + if T::should_activate_item_on_event(event) { + if let Some(ix) = pane.item_index(&item) { + pane.activate_item(ix, cx); + pane.activate(cx); + } + } + if T::should_update_tab_on_event(event) { + cx.notify() + } + }) + .detach(); + }); + } + + fn save(&self, cx: &mut MutableAppContext) -> Result>> { + self.update(cx, |item, cx| item.save(cx)) + } + + fn save_as( + &self, + worktree: ModelHandle, + path: &Path, + cx: &mut MutableAppContext, + ) -> Task> { + self.update(cx, |item, cx| item.save_as(worktree, path, cx)) + } + + fn is_dirty(&self, cx: &AppContext) -> bool { + self.read(cx).is_dirty(cx) + } + + fn has_conflict(&self, cx: &AppContext) -> bool { + self.read(cx).has_conflict(cx) + } + + fn id(&self) -> usize { + self.id() + } + + fn to_any(&self) -> AnyViewHandle { + self.into() + } +} + +impl Clone for Box { + fn clone(&self) -> Box { + self.boxed_clone() + } +} + +impl Clone for Box { + fn clone(&self) -> Box { + self.boxed_clone() + } +} + +#[derive(Clone)] +pub struct WorkspaceParams { + pub client: Arc, + pub fs: Arc, + pub languages: Arc, + pub settings: watch::Receiver, + pub user_store: ModelHandle, + pub channel_list: ModelHandle, +} + +impl WorkspaceParams { + #[cfg(any(test, feature = "test-support"))] + pub fn test(cx: &mut MutableAppContext) -> Self { + let grammar = tree_sitter_rust::language(); + let language = Arc::new(buffer::Language { + config: buffer::LanguageConfig { + name: "Rust".to_string(), + path_suffixes: vec!["rs".to_string()], + }, + brackets_query: tree_sitter::Query::new(grammar, "").unwrap(), + highlight_query: tree_sitter::Query::new(grammar, "").unwrap(), + highlight_map: Default::default(), + grammar, + }); + let mut languages = LanguageRegistry::new(); + languages.add(language); + + let client = Client::new(); + let http_client = client::test::FakeHttpClient::new(|_| async move { + Ok(client::http::ServerResponse::new(404)) + }); + let theme = + gpui::fonts::with_font_cache(cx.font_cache().clone(), || theme::Theme::default()); + let settings = Settings::new("Courier", cx.font_cache(), Arc::new(theme)).unwrap(); + let user_store = cx.add_model(|cx| UserStore::new(client.clone(), http_client, cx)); + Self { + channel_list: cx + .add_model(|cx| ChannelList::new(user_store.clone(), client.clone(), cx)), + client, + fs: Arc::new(project::FakeFs::new()), + languages: Arc::new(languages), + settings: watch::channel_with(settings).1, + user_store, + } + } +} + +pub struct Workspace { + pub settings: watch::Receiver, + client: Arc, + user_store: ModelHandle, + fs: Arc, + modal: Option, + center: PaneGroup, + left_sidebar: Sidebar, + right_sidebar: Sidebar, + panes: Vec>, + active_pane: ViewHandle, + project: ModelHandle, + items: Vec>, + loading_items: HashMap< + ProjectPath, + postage::watch::Receiver, Arc>>>, + >, + _observe_current_user: Task<()>, +} + +impl Workspace { + pub fn new(params: &WorkspaceParams, cx: &mut ViewContext) -> Self { + let project = cx.add_model(|_| { + Project::new( + params.languages.clone(), + params.client.clone(), + params.fs.clone(), + ) + }); + cx.observe(&project, |_, _, cx| cx.notify()).detach(); + + let pane = cx.add_view(|_| Pane::new(params.settings.clone())); + let pane_id = pane.id(); + cx.observe(&pane, move |me, _, cx| { + let active_entry = me.active_project_path(cx); + me.project + .update(cx, |project, cx| project.set_active_path(active_entry, cx)); + }) + .detach(); + cx.subscribe(&pane, move |me, _, event, cx| { + me.handle_pane_event(pane_id, event, cx) + }) + .detach(); + cx.focus(&pane); + + 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 { + current_user.recv().await; + connection_status.recv().await; + let mut stream = + Stream::map(current_user, drop).merge(Stream::map(connection_status, drop)); + + while stream.recv().await.is_some() { + cx.update(|cx| { + if let Some(this) = this.upgrade(&cx) { + this.update(cx, |_, cx| cx.notify()); + } + }) + } + }); + + Workspace { + modal: None, + center: PaneGroup::new(pane.id()), + panes: vec![pane.clone()], + active_pane: pane.clone(), + settings: params.settings.clone(), + client: params.client.clone(), + user_store: params.user_store.clone(), + fs: params.fs.clone(), + left_sidebar: Sidebar::new(Side::Left), + right_sidebar: Sidebar::new(Side::Right), + project, + items: Default::default(), + loading_items: Default::default(), + _observe_current_user, + } + } + + pub fn left_sidebar_mut(&mut self) -> &mut Sidebar { + &mut self.left_sidebar + } + + pub fn right_sidebar_mut(&mut self) -> &mut Sidebar { + &mut self.right_sidebar + } + + pub fn project(&self) -> &ModelHandle { + &self.project + } + + pub fn worktrees<'a>(&self, cx: &'a AppContext) -> &'a [ModelHandle] { + &self.project.read(cx).worktrees() + } + + pub fn contains_paths(&self, paths: &[PathBuf], cx: &AppContext) -> bool { + paths.iter().all(|path| self.contains_path(&path, cx)) + } + + pub fn contains_path(&self, path: &Path, cx: &AppContext) -> bool { + for worktree in self.worktrees(cx) { + let worktree = worktree.read(cx).as_local(); + if worktree.map_or(false, |w| w.contains_abs_path(path)) { + return true; + } + } + false + } + + pub fn worktree_scans_complete(&self, cx: &AppContext) -> impl Future + 'static { + let futures = self + .worktrees(cx) + .iter() + .filter_map(|worktree| worktree.read(cx).as_local()) + .map(|worktree| worktree.scan_complete()) + .collect::>(); + async move { + for future in futures { + future.await; + } + } + } + + pub fn open_paths(&mut self, abs_paths: &[PathBuf], cx: &mut ViewContext) -> Task<()> { + let entries = abs_paths + .iter() + .cloned() + .map(|path| self.project_path_for_path(&path, cx)) + .collect::>(); + + let fs = self.fs.clone(); + let tasks = abs_paths + .iter() + .cloned() + .zip(entries.into_iter()) + .map(|(abs_path, project_path)| { + cx.spawn(|this, mut cx| { + let fs = fs.clone(); + async move { + let project_path = project_path.await?; + if fs.is_file(&abs_path).await { + if let Some(entry) = + this.update(&mut cx, |this, cx| this.open_entry(project_path, cx)) + { + entry.await; + } + } + Ok(()) + } + }) + }) + .collect::>>>(); + + cx.foreground().spawn(async move { + for task in tasks { + if let Err(error) = task.await { + log::error!("error opening paths {}", error); + } + } + }) + } + + fn worktree_for_abs_path( + &self, + abs_path: &Path, + cx: &mut ViewContext, + ) -> Task, PathBuf)>> { + let abs_path: Arc = Arc::from(abs_path); + cx.spawn(|this, mut cx| async move { + let mut entry_id = None; + this.read_with(&cx, |this, cx| { + for tree in this.worktrees(cx) { + if let Some(relative_path) = tree + .read(cx) + .as_local() + .and_then(|t| abs_path.strip_prefix(t.abs_path()).ok()) + { + entry_id = Some((tree.clone(), relative_path.into())); + break; + } + } + }); + + if let Some(entry_id) = entry_id { + Ok(entry_id) + } else { + let worktree = this + .update(&mut cx, |this, cx| this.add_worktree(&abs_path, cx)) + .await?; + Ok((worktree, PathBuf::new())) + } + }) + } + + fn project_path_for_path( + &self, + abs_path: &Path, + cx: &mut ViewContext, + ) -> Task> { + let entry = self.worktree_for_abs_path(abs_path, cx); + cx.spawn(|_, _| async move { + let (worktree, path) = entry.await?; + Ok(ProjectPath { + worktree_id: worktree.id(), + path: path.into(), + }) + }) + } + + pub fn add_worktree( + &self, + path: &Path, + cx: &mut ViewContext, + ) -> Task>> { + self.project + .update(cx, |project, cx| project.add_local_worktree(path, cx)) + } + + pub fn toggle_modal(&mut self, cx: &mut ViewContext, add_view: F) + where + V: 'static + View, + F: FnOnce(&mut ViewContext, &mut Self) -> ViewHandle, + { + if self.modal.as_ref().map_or(false, |modal| modal.is::()) { + self.modal.take(); + cx.focus_self(); + } else { + let modal = add_view(cx, self); + cx.focus(&modal); + self.modal = Some(modal.into()); + } + cx.notify(); + } + + pub fn modal(&self) -> Option<&AnyViewHandle> { + self.modal.as_ref() + } + + pub fn dismiss_modal(&mut self, cx: &mut ViewContext) { + if self.modal.take().is_some() { + cx.focus(&self.active_pane); + cx.notify(); + } + } + + pub fn open_new_file(&mut self, _: &OpenNew, cx: &mut ViewContext) { + 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, + project_path: ProjectPath, + cx: &mut ViewContext, + ) -> Option> { + let pane = self.active_pane().clone(); + if self.activate_or_open_existing_entry(project_path.clone(), &pane, cx) { + return None; + } + + // let (worktree_id, path) = project_path.clone(); + + let worktree = match self + .project + .read(cx) + .worktree_for_id(project_path.worktree_id) + { + Some(worktree) => worktree, + None => { + log::error!("worktree {} does not exist", project_path.worktree_id); + return None; + } + }; + + if let Entry::Vacant(entry) = self.loading_items.entry(project_path.clone()) { + let (mut tx, rx) = postage::watch::channel(); + entry.insert(rx); + + let project_path = project_path.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) + }) + .await; + *tx.borrow_mut() = Some( + buffer + .map(|buffer| Box::new(buffer) as Box) + .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 { + let load_result = loop { + if let Some(load_result) = watch.borrow().as_ref() { + break load_result.clone(); + } + watch.recv().await; + }; + + this.update(&mut cx, |this, cx| { + this.loading_items.remove(&project_path); + if let Some(pane) = pane.upgrade(&cx) { + match load_result { + Ok(item) => { + // By the time loading finishes, the entry could have been already added + // 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()); + } + } + Err(error) => { + log::error!("error opening item: {}", error); + } + } + } + }) + })) + } + + fn activate_or_open_existing_entry( + &mut self, + project_path: ProjectPath, + pane: &ViewHandle, + cx: &mut ViewContext, + ) -> bool { + // If the pane contains a view for this file, then activate + // that item view. + if pane.update(cx, |pane, cx| pane.activate_entry(project_path.clone(), cx)) { + return true; + } + + // Otherwise, if this file is already open somewhere in the workspace, + // then add another view for it. + let settings = self.settings.clone(); + let mut view_for_existing_item = None; + self.items.retain(|item| { + if item.alive(cx.as_ref()) { + 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(), + ); + } + true + } else { + false + } + }); + if let Some(view) = view_for_existing_item { + pane.add_item_view(view, cx.as_mut()); + true + } else { + false + } + } + + pub fn active_item(&self, cx: &ViewContext) -> Option> { + self.active_pane().read(cx).active_item() + } + + fn active_project_path(&self, cx: &ViewContext) -> Option { + self.active_item(cx).and_then(|item| item.project_path(cx)) + } + + pub fn save_active_item(&mut self, _: &Save, cx: &mut ViewContext) { + if let Some(item) = self.active_item(cx) { + let handle = cx.handle(); + if item.project_path(cx.as_ref()).is_none() { + let worktree = self.worktrees(cx).first(); + let start_abs_path = worktree + .and_then(|w| w.read(cx).as_local()) + .map_or(Path::new(""), |w| w.abs_path()) + .to_path_buf(); + cx.prompt_for_new_path(&start_abs_path, move |abs_path, cx| { + if let Some(abs_path) = abs_path { + cx.spawn(|mut cx| async move { + let result = match handle + .update(&mut cx, |this, cx| { + this.worktree_for_abs_path(&abs_path, cx) + }) + .await + { + Ok((worktree, path)) => { + handle + .update(&mut cx, |_, cx| { + item.save_as(worktree, &path, cx.as_mut()) + }) + .await + } + Err(error) => Err(error), + }; + + if let Err(error) = result { + error!("failed to save item: {:?}, ", error); + } + }) + .detach() + } + }); + return; + } else if item.has_conflict(cx.as_ref()) { + const CONFLICT_MESSAGE: &'static str = "This file has changed on disk since you started editing it. Do you want to overwrite it?"; + + cx.prompt( + PromptLevel::Warning, + CONFLICT_MESSAGE, + &["Overwrite", "Cancel"], + move |answer, cx| { + if answer == 0 { + cx.spawn(|mut cx| async move { + if let Err(error) = cx.update(|cx| item.save(cx)).unwrap().await { + error!("failed to save item: {:?}, ", error); + } + }) + .detach(); + } + }, + ); + } else { + cx.spawn(|_, mut cx| async move { + if let Err(error) = cx.update(|cx| item.save(cx)).unwrap().await { + error!("failed to save item: {:?}, ", error); + } + }) + .detach(); + } + } + } + + pub fn toggle_sidebar_item(&mut self, action: &ToggleSidebarItem, cx: &mut ViewContext) { + let sidebar = match action.0.side { + Side::Left => &mut self.left_sidebar, + Side::Right => &mut self.right_sidebar, + }; + sidebar.toggle_item(action.0.item_index); + if let Some(active_item) = sidebar.active_item() { + cx.focus(active_item); + } else { + cx.focus_self(); + } + cx.notify(); + } + + pub fn toggle_sidebar_item_focus( + &mut self, + action: &ToggleSidebarItemFocus, + cx: &mut ViewContext, + ) { + let sidebar = match action.0.side { + Side::Left => &mut self.left_sidebar, + Side::Right => &mut self.right_sidebar, + }; + sidebar.activate_item(action.0.item_index); + if let Some(active_item) = sidebar.active_item() { + if active_item.is_focused(cx) { + cx.focus_self(); + } else { + cx.focus(active_item); + } + } + cx.notify(); + } + + pub fn debug_elements(&mut self, _: &DebugElements, cx: &mut ViewContext) { + match to_string_pretty(&cx.debug_elements()) { + Ok(json) => { + let kib = json.len() as f32 / 1024.; + cx.as_mut().write_to_clipboard(ClipboardItem::new(json)); + log::info!( + "copied {:.1} KiB of element debug JSON to the clipboard", + kib + ); + } + Err(error) => { + log::error!("error debugging elements: {}", error); + } + }; + } + + fn add_pane(&mut self, cx: &mut ViewContext) -> ViewHandle { + let pane = cx.add_view(|_| Pane::new(self.settings.clone())); + let pane_id = pane.id(); + cx.observe(&pane, move |me, _, cx| { + let active_entry = me.active_project_path(cx); + me.project + .update(cx, |project, cx| project.set_active_path(active_entry, cx)); + }) + .detach(); + cx.subscribe(&pane, move |me, _, event, cx| { + me.handle_pane_event(pane_id, event, cx) + }) + .detach(); + self.panes.push(pane.clone()); + self.activate_pane(pane.clone(), cx); + pane + } + + fn activate_pane(&mut self, pane: ViewHandle, cx: &mut ViewContext) { + self.active_pane = pane; + cx.focus(&self.active_pane); + cx.notify(); + } + + fn handle_pane_event( + &mut self, + pane_id: usize, + event: &pane::Event, + cx: &mut ViewContext, + ) { + if let Some(pane) = self.pane(pane_id) { + match event { + pane::Event::Split(direction) => { + self.split_pane(pane, *direction, cx); + } + pane::Event::Remove => { + self.remove_pane(pane, cx); + } + pane::Event::Activate => { + self.activate_pane(pane, cx); + } + } + } else { + error!("pane {} not found", pane_id); + } + } + + fn split_pane( + &mut self, + pane: ViewHandle, + direction: SplitDirection, + cx: &mut ViewContext, + ) -> ViewHandle { + let new_pane = self.add_pane(cx); + self.activate_pane(new_pane.clone(), cx); + if let Some(item) = pane.read(cx).active_item() { + if let Some(clone) = item.clone_on_split(cx.as_mut()) { + new_pane.add_item_view(clone, cx.as_mut()); + } + } + self.center + .split(pane.id(), new_pane.id(), direction) + .unwrap(); + cx.notify(); + new_pane + } + + fn remove_pane(&mut self, pane: ViewHandle, cx: &mut ViewContext) { + if self.center.remove(pane.id()).unwrap() { + self.panes.retain(|p| p != &pane); + self.activate_pane(self.panes.last().unwrap().clone(), cx); + } + } + + fn pane(&self, pane_id: usize) -> Option> { + self.panes.iter().find(|pane| pane.id() == pane_id).cloned() + } + + pub fn active_pane(&self) -> &ViewHandle { + &self.active_pane + } + + fn render_connection_status(&self) -> Option { + let theme = &self.settings.borrow().theme; + match &*self.client.status().borrow() { + client::Status::ConnectionError + | client::Status::ConnectionLost + | client::Status::Reauthenticating + | client::Status::Reconnecting { .. } + | client::Status::ReconnectionError { .. } => Some( + Container::new( + Align::new( + ConstrainedBox::new( + Svg::new("icons/offline-14.svg") + .with_color(theme.workspace.titlebar.icon_color) + .boxed(), + ) + .with_width(theme.workspace.titlebar.offline_icon.width) + .boxed(), + ) + .boxed(), + ) + .with_style(theme.workspace.titlebar.offline_icon.container) + .boxed(), + ), + client::Status::UpgradeRequired => Some( + Label::new( + "Please update Zed to collaborate".to_string(), + theme.workspace.titlebar.outdated_warning.text.clone(), + ) + .contained() + .with_style(theme.workspace.titlebar.outdated_warning.container) + .aligned() + .boxed(), + ), + _ => None, + } + } + + fn render_avatar(&self, cx: &mut RenderContext) -> ElementBox { + let theme = &self.settings.borrow().theme; + let avatar = if let Some(avatar) = self + .user_store + .read(cx) + .current_user() + .and_then(|user| user.avatar.clone()) + { + Image::new(avatar) + .with_style(theme.workspace.titlebar.avatar) + .boxed() + } else { + MouseEventHandler::new::(0, cx, |_, _| { + Svg::new("icons/signed-out-12.svg") + .with_color(theme.workspace.titlebar.icon_color) + .boxed() + }) + .on_click(|cx| cx.dispatch_action(Authenticate)) + .with_cursor_style(CursorStyle::PointingHand) + .boxed() + }; + + ConstrainedBox::new( + Align::new( + ConstrainedBox::new(avatar) + .with_width(theme.workspace.titlebar.avatar_width) + .boxed(), + ) + .boxed(), + ) + .with_width(theme.workspace.right_sidebar.width) + .boxed() + } +} + +impl Entity for Workspace { + type Event = (); +} + +impl View for Workspace { + fn ui_name() -> &'static str { + "Workspace" + } + + fn render(&mut self, cx: &mut RenderContext) -> ElementBox { + let settings = self.settings.borrow(); + let theme = &settings.theme; + Container::new( + Flex::column() + .with_child( + ConstrainedBox::new( + Container::new( + Stack::new() + .with_child( + Align::new( + Label::new( + "zed".into(), + theme.workspace.titlebar.title.clone(), + ) + .boxed(), + ) + .boxed(), + ) + .with_child( + Align::new( + Flex::row() + .with_children(self.render_connection_status()) + .with_child(self.render_avatar(cx)) + .boxed(), + ) + .right() + .boxed(), + ) + .boxed(), + ) + .with_style(theme.workspace.titlebar.container) + .boxed(), + ) + .with_height(32.) + .named("titlebar"), + ) + .with_child( + Expanded::new( + 1.0, + Stack::new() + .with_child({ + let mut content = Flex::row(); + content.add_child(self.left_sidebar.render(&settings, cx)); + if let Some(element) = + self.left_sidebar.render_active_item(&settings, cx) + { + content.add_child(Flexible::new(0.8, element).boxed()); + } + content.add_child( + Expanded::new(1.0, self.center.render(&settings.theme)).boxed(), + ); + if let Some(element) = + self.right_sidebar.render_active_item(&settings, cx) + { + content.add_child(Flexible::new(0.8, element).boxed()); + } + content.add_child(self.right_sidebar.render(&settings, cx)); + content.boxed() + }) + .with_children( + self.modal.as_ref().map(|m| ChildView::new(m.id()).boxed()), + ) + .boxed(), + ) + .boxed(), + ) + .boxed(), + ) + .with_background_color(settings.theme.workspace.background) + .named("workspace") + } + + fn on_focus(&mut self, cx: &mut ViewContext) { + cx.focus(&self.active_pane); + } +} + +#[cfg(test)] +pub trait WorkspaceHandle { + fn file_project_paths(&self, cx: &AppContext) -> Vec; +} + +#[cfg(test)] +impl WorkspaceHandle for ViewHandle { + fn file_project_paths(&self, cx: &AppContext) -> Vec { + self.read(cx) + .worktrees(cx) + .iter() + .flat_map(|worktree| { + let worktree_id = worktree.id(); + worktree.read(cx).files(true, 0).map(move |f| ProjectPath { + worktree_id, + path: f.path.clone(), + }) + }) + .collect::>() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use editor::{Editor, Insert}; + 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(¶ms, 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::>(); + 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(¶ms, 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::>(); + 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(¶ms, 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::().unwrap() + }); + + cx.update(|cx| editor.update(cx, |editor, cx| editor.insert(&Insert("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(¶ms, 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::() + .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.insert(&Insert("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.insert(&Insert(" 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::() + .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(¶ms, 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::() + .unwrap() + }); + + editor.update(&mut cx, |editor, cx| { + assert!(editor.language(cx).is_none()); + editor.insert(&Insert("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(¶ms, 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); + }); + } +} diff --git a/crates/zed/src/workspace/pane.rs b/crates/workspace/src/pane.rs similarity index 99% rename from crates/zed/src/workspace/pane.rs rename to crates/workspace/src/pane.rs index 7a530d348c..a18674a1e4 100644 --- a/crates/zed/src/workspace/pane.rs +++ b/crates/workspace/src/pane.rs @@ -1,5 +1,5 @@ use super::{ItemViewHandle, SplitDirection}; -use crate::{project::ProjectPath, settings::Settings}; +use crate::Settings; use gpui::{ action, elements::*, @@ -9,6 +9,7 @@ use gpui::{ Entity, MutableAppContext, Quad, RenderContext, View, ViewContext, ViewHandle, }; use postage::watch; +use project::ProjectPath; use std::cmp; action!(Split, SplitDirection); diff --git a/crates/zed/src/workspace/pane_group.rs b/crates/workspace/src/pane_group.rs similarity index 100% rename from crates/zed/src/workspace/pane_group.rs rename to crates/workspace/src/pane_group.rs diff --git a/crates/zed/src/settings.rs b/crates/workspace/src/settings.rs similarity index 100% rename from crates/zed/src/settings.rs rename to crates/workspace/src/settings.rs diff --git a/crates/zed/src/workspace/sidebar.rs b/crates/workspace/src/sidebar.rs similarity index 100% rename from crates/zed/src/workspace/sidebar.rs rename to crates/workspace/src/sidebar.rs diff --git a/crates/zed/Cargo.toml b/crates/zed/Cargo.toml index 1315176340..e20dacddf5 100644 --- a/crates/zed/Cargo.toml +++ b/crates/zed/Cargo.toml @@ -36,6 +36,7 @@ rpc = { path = "../rpc" } sum_tree = { path = "../sum_tree" } theme = { path = "../theme" } util = { path = "../util" } +workspace = { path = "../workspace" } anyhow = "1.0.38" async-recursion = "0.3" async-trait = "0.1" @@ -83,6 +84,7 @@ project = { path = "../project", features = ["test-support"] } rpc = { path = "../rpc", features = ["test-support"] } client = { path = "../client", features = ["test-support"] } util = { path = "../util", features = ["test-support"] } +workspace = { path = "../workspace", features = ["test-support"] } cargo-bundle = "0.5.0" env_logger = "0.8" serde_json = { version = "1.0.64", features = ["preserve_order"] } diff --git a/crates/zed/src/file_finder.rs b/crates/zed/src/file_finder.rs index 2e9e562f6a..95c4ae9249 100644 --- a/crates/zed/src/file_finder.rs +++ b/crates/zed/src/file_finder.rs @@ -1,4 +1,3 @@ -use crate::{settings::Settings, workspace::Workspace}; use editor::{Editor, EditorSettings}; use fuzzy::PathMatch; use gpui::{ @@ -23,6 +22,7 @@ use std::{ }, }; use util::post_inc; +use workspace::{Settings, Workspace}; pub struct FileFinder { handle: WeakViewHandle, @@ -422,16 +422,15 @@ impl FileFinder { #[cfg(test)] mod tests { use super::*; - use crate::{test::test_app_state, workspace::Workspace}; use editor::Insert; - use project::fs::FakeFs; use serde_json::json; use std::path::PathBuf; + use workspace::{Workspace, WorkspaceParams}; #[gpui::test] async fn test_matching_paths(mut cx: gpui::TestAppContext) { - let app_state = cx.update(test_app_state); - app_state + let params = cx.update(WorkspaceParams::test); + params .fs .as_fake() .insert_tree( @@ -449,7 +448,7 @@ mod tests { editor::init(cx); }); - let (window_id, workspace) = cx.add_window(|cx| Workspace::new(&app_state, cx)); + let (window_id, workspace) = cx.add_window(|cx| Workspace::new(¶ms, cx)); workspace .update(&mut cx, |workspace, cx| { workspace.add_worktree(Path::new("/root"), cx) @@ -493,7 +492,8 @@ mod tests { #[gpui::test] async fn test_matching_cancellation(mut cx: gpui::TestAppContext) { - let fs = Arc::new(FakeFs::new()); + let params = cx.update(WorkspaceParams::test); + let fs = params.fs.as_fake(); fs.insert_tree( "/dir", json!({ @@ -508,10 +508,7 @@ mod tests { ) .await; - let mut app_state = cx.update(test_app_state); - Arc::get_mut(&mut app_state).unwrap().fs = fs; - - let (_, workspace) = cx.add_window(|cx| Workspace::new(&app_state, cx)); + let (_, workspace) = cx.add_window(|cx| Workspace::new(¶ms, cx)); workspace .update(&mut cx, |workspace, cx| { workspace.add_worktree("/dir".as_ref(), cx) @@ -522,7 +519,7 @@ mod tests { .await; let (_, finder) = cx.add_window(|cx| { FileFinder::new( - app_state.settings.clone(), + params.settings.clone(), workspace.read(cx).project().clone(), cx, ) @@ -569,14 +566,14 @@ mod tests { #[gpui::test] async fn test_single_file_worktrees(mut cx: gpui::TestAppContext) { - let app_state = cx.update(test_app_state); - app_state + let params = cx.update(WorkspaceParams::test); + params .fs .as_fake() .insert_tree("/root", json!({ "the-parent-dir": { "the-file": "" } })) .await; - let (_, workspace) = cx.add_window(|cx| Workspace::new(&app_state, cx)); + let (_, workspace) = cx.add_window(|cx| Workspace::new(¶ms, cx)); workspace .update(&mut cx, |workspace, cx| { workspace.add_worktree(Path::new("/root/the-parent-dir/the-file"), cx) @@ -587,7 +584,7 @@ mod tests { .await; let (_, finder) = cx.add_window(|cx| { FileFinder::new( - app_state.settings.clone(), + params.settings.clone(), workspace.read(cx).project().clone(), cx, ) @@ -622,8 +619,8 @@ mod tests { #[gpui::test(retries = 5)] async fn test_multiple_matches_with_same_relative_path(mut cx: gpui::TestAppContext) { - let app_state = cx.update(test_app_state); - app_state + let params = cx.update(WorkspaceParams::test); + params .fs .as_fake() .insert_tree( @@ -635,7 +632,7 @@ mod tests { ) .await; - let (_, workspace) = cx.add_window(|cx| Workspace::new(&app_state, cx)); + let (_, workspace) = cx.add_window(|cx| Workspace::new(¶ms, cx)); workspace .update(&mut cx, |workspace, cx| { @@ -650,7 +647,7 @@ mod tests { let (_, finder) = cx.add_window(|cx| { FileFinder::new( - app_state.settings.clone(), + params.settings.clone(), workspace.read(cx).project().clone(), cx, ) diff --git a/crates/zed/src/lib.rs b/crates/zed/src/lib.rs index c41ec01c12..471e45146e 100644 --- a/crates/zed/src/lib.rs +++ b/crates/zed/src/lib.rs @@ -5,11 +5,9 @@ pub mod language; pub mod menus; pub mod people_panel; pub mod project_panel; -pub mod settings; #[cfg(any(test, feature = "test-support"))] pub mod test; pub mod theme_selector; -pub mod workspace; pub use buffer; use buffer::LanguageRegistry; @@ -28,18 +26,15 @@ use people_panel::PeoplePanel; use postage::watch; pub use project::{self, fs}; use project_panel::ProjectPanel; -pub use settings::Settings; use std::{path::PathBuf, sync::Arc}; use theme::ThemeRegistry; -use util::TryFutureExt; - -use crate::workspace::Workspace; +pub use workspace; +use workspace::{Settings, Workspace, WorkspaceParams}; action!(About); action!(Open, Arc); action!(OpenPaths, OpenParams); action!(Quit); -action!(Authenticate); action!(AdjustBufferFontSize, f32); const MIN_FONT_SIZE: f32 = 6.0; @@ -69,15 +64,6 @@ pub fn init(app_state: &Arc, cx: &mut gpui::MutableAppContext) { cx.add_global_action(open_new); cx.add_global_action(quit); - cx.add_global_action({ - let rpc = app_state.client.clone(); - move |_: &Authenticate, cx| { - let rpc = rpc.clone(); - cx.spawn(|cx| async move { rpc.authenticate_and_connect(&cx).log_err().await }) - .detach(); - } - }); - cx.add_global_action({ let settings_tx = app_state.settings_tx.clone(); @@ -135,8 +121,9 @@ fn open_paths(action: &OpenPaths, cx: &mut MutableAppContext) -> Task<()> { log::info!("open new workspace"); // Add a new workspace if necessary + let app_state = &action.0.app_state; let (_, workspace) = cx.add_window(window_options(), |cx| { - build_workspace(&action.0.app_state, cx) + build_workspace(&WorkspaceParams::from(app_state.as_ref()), cx) }); workspace.update(cx, |workspace, cx| { workspace.open_paths(&action.0.paths, cx) @@ -145,33 +132,31 @@ 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.as_ref(), cx); + let mut workspace = build_workspace(&action.0, cx); workspace.open_new_file(&action, cx); workspace }); } -fn build_workspace(app_state: &AppState, cx: &mut ViewContext) -> Workspace { - let mut workspace = Workspace::new(app_state, cx); +fn build_workspace(params: &WorkspaceParams, cx: &mut ViewContext) -> Workspace { + let mut workspace = Workspace::new(params, cx); let project = workspace.project().clone(); workspace.left_sidebar_mut().add_item( "icons/folder-tree-16.svg", - ProjectPanel::new(project, app_state.settings.clone(), cx).into(), + ProjectPanel::new(project, params.settings.clone(), cx).into(), ); workspace.right_sidebar_mut().add_item( "icons/user-16.svg", - cx.add_view(|cx| { - PeoplePanel::new(app_state.user_store.clone(), app_state.settings.clone(), cx) - }) - .into(), + cx.add_view(|cx| PeoplePanel::new(params.user_store.clone(), params.settings.clone(), cx)) + .into(), ); workspace.right_sidebar_mut().add_item( "icons/comment-16.svg", cx.add_view(|cx| { ChatPanel::new( - app_state.client.clone(), - app_state.channel_list.clone(), - app_state.settings.clone(), + params.client.clone(), + params.channel_list.clone(), + params.settings.clone(), cx, ) }) @@ -193,13 +178,27 @@ fn quit(_: &Quit, cx: &mut gpui::MutableAppContext) { cx.platform().quit(); } +impl<'a> From<&'a AppState> for WorkspaceParams { + fn from(state: &'a AppState) -> Self { + Self { + client: state.client.clone(), + fs: state.fs.clone(), + languages: state.languages.clone(), + settings: state.settings.clone(), + user_store: state.user_store.clone(), + channel_list: state.channel_list.clone(), + } + } +} + #[cfg(test)] mod tests { use super::*; - use crate::{test::test_app_state, workspace::ItemView}; use serde_json::json; + use test::test_app_state; use theme::DEFAULT_THEME_NAME; use util::test::temp_tree; + use workspace::ItemView; #[gpui::test] async fn test_open_paths_action(mut cx: gpui::TestAppContext) { @@ -270,7 +269,7 @@ mod tests { async fn test_new_empty_workspace(mut cx: gpui::TestAppContext) { let app_state = cx.update(test_app_state); cx.update(|cx| init(&app_state, cx)); - cx.dispatch_global_action(workspace::OpenNew(app_state.clone())); + cx.dispatch_global_action(workspace::OpenNew(app_state.as_ref().into())); let window_id = *cx.window_ids().first().unwrap(); let workspace = cx.root_view::(window_id).unwrap(); let editor = workspace.update(&mut cx, |workspace, cx| { diff --git a/crates/zed/src/main.rs b/crates/zed/src/main.rs index 8356c516f6..c328f3be97 100644 --- a/crates/zed/src/main.rs +++ b/crates/zed/src/main.rs @@ -8,6 +8,7 @@ use parking_lot::Mutex; use simplelog::SimpleLogger; use std::{fs, path::PathBuf, sync::Arc}; use theme::ThemeRegistry; +use workspace::{self, settings, OpenNew}; use zed::{ self, assets::Assets, @@ -15,9 +16,7 @@ use zed::{ client::{http, ChannelList, UserStore}, editor, file_finder, fs::RealFs, - language, menus, people_panel, project_panel, settings, theme_selector, - workspace::{self, OpenNew}, - AppState, OpenParams, OpenPaths, + language, menus, people_panel, project_panel, theme_selector, AppState, OpenParams, OpenPaths, }; fn main() { @@ -54,6 +53,7 @@ fn main() { }); zed::init(&app_state, cx); + client::init(app_state.client.clone(), cx); workspace::init(cx); editor::init(cx); file_finder::init(cx); @@ -70,7 +70,7 @@ fn main() { let paths = collect_path_args(); if paths.is_empty() { - cx.dispatch_global_action(OpenNew(app_state)); + cx.dispatch_global_action(OpenNew(app_state.as_ref().into())); } else { cx.dispatch_global_action(OpenPaths(OpenParams { paths, app_state })); } diff --git a/crates/zed/src/menus.rs b/crates/zed/src/menus.rs index fcb315692e..52557e3904 100644 --- a/crates/zed/src/menus.rs +++ b/crates/zed/src/menus.rs @@ -1,9 +1,18 @@ -use crate::{workspace, AppState}; +use crate::{AppState, WorkspaceParams}; use gpui::{Menu, MenuItem}; use std::sync::Arc; #[cfg(target_os = "macos")] pub fn menus(state: &Arc) -> Vec> { + let workspace_params = WorkspaceParams { + client: state.client.clone(), + fs: state.fs.clone(), + languages: state.languages.clone(), + settings: state.settings.clone(), + user_store: state.user_store.clone(), + channel_list: state.channel_list.clone(), + }; + vec![ Menu { name: "Zed", @@ -27,7 +36,7 @@ pub fn menus(state: &Arc) -> Vec> { MenuItem::Action { name: "New", keystroke: Some("cmd-n"), - action: Box::new(workspace::OpenNew(state.clone())), + action: Box::new(workspace::OpenNew(workspace_params)), }, MenuItem::Separator, MenuItem::Action { diff --git a/crates/zed/src/people_panel.rs b/crates/zed/src/people_panel.rs index 3360e58c5f..4a9d292737 100644 --- a/crates/zed/src/people_panel.rs +++ b/crates/zed/src/people_panel.rs @@ -1,4 +1,3 @@ -use crate::{workspace::Workspace, Settings}; use client::{Collaborator, UserStore}; use gpui::{ action, @@ -10,6 +9,7 @@ use gpui::{ }; use postage::watch; use theme::Theme; +use workspace::{Settings, Workspace}; action!(JoinWorktree, u64); action!(LeaveWorktree, u64); diff --git a/crates/zed/src/project_panel.rs b/crates/zed/src/project_panel.rs index 09b75e2dec..3ea20a2c58 100644 --- a/crates/zed/src/project_panel.rs +++ b/crates/zed/src/project_panel.rs @@ -648,7 +648,7 @@ mod tests { .read_with(&cx, |t, _| t.as_local().unwrap().scan_complete()) .await; - let (_, workspace) = cx.add_window(|cx| Workspace::new(&app_state, cx)); + let (_, workspace) = cx.add_window(|cx| Workspace::new(&app_state.as_ref().into(), cx)); let panel = workspace.update(&mut cx, |_, cx| ProjectPanel::new(project, settings, cx)); assert_eq!( visible_entry_details(&panel, 0..50, &mut cx), diff --git a/crates/zed/src/test.rs b/crates/zed/src/test.rs index c2a276e337..793f73470e 100644 --- a/crates/zed/src/test.rs +++ b/crates/zed/src/test.rs @@ -1,4 +1,4 @@ -use crate::{assets::Assets, language, settings::Settings, AppState}; +use crate::{assets::Assets, language, AppState}; use buffer::LanguageRegistry; use client::{http::ServerResponse, test::FakeHttpClient, ChannelList, Client, UserStore}; use gpui::{AssetSource, MutableAppContext}; @@ -7,6 +7,7 @@ use postage::watch; use project::fs::FakeFs; use std::sync::Arc; use theme::{Theme, ThemeRegistry, DEFAULT_THEME_NAME}; +use workspace::Settings; #[cfg(test)] #[ctor::ctor] diff --git a/crates/zed/src/workspace.rs b/crates/zed/src/workspace.rs deleted file mode 100644 index 8c5fb36a71..0000000000 --- a/crates/zed/src/workspace.rs +++ /dev/null @@ -1,1488 +0,0 @@ -mod items; -pub mod pane; -pub mod pane_group; -pub mod sidebar; - -use crate::{ - fs::Fs, - project::{Project, ProjectPath}, - settings::Settings, - workspace::sidebar::{Side, Sidebar, SidebarItemId, ToggleSidebarItem, ToggleSidebarItemFocus}, - AppState, Authenticate, -}; -use anyhow::Result; -use buffer::Buffer; -use client::Client; -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, -}; -use log::error; -pub use pane::*; -pub use pane_group::*; -use postage::{prelude::Stream, watch}; -use project::Worktree; -use std::{ - collections::{hash_map::Entry, HashMap}, - future::Future, - path::{Path, PathBuf}, - sync::Arc, -}; - -action!(OpenNew, Arc); -action!(Save); -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![ - Binding::new("cmd-s", Save, None), - Binding::new("cmd-alt-i", DebugElements, None), - Binding::new( - "cmd-shift-!", - ToggleSidebarItem(SidebarItemId { - side: Side::Left, - item_index: 0, - }), - None, - ), - Binding::new( - "cmd-1", - ToggleSidebarItemFocus(SidebarItemId { - side: Side::Left, - item_index: 0, - }), - None, - ), - ]); - pane::init(cx); -} - -pub trait Item: Entity + Sized { - type View: ItemView; - - fn build_view( - handle: ModelHandle, - settings: watch::Receiver, - cx: &mut ViewContext, - ) -> Self::View; - - fn project_path(&self) -> Option; -} - -pub trait ItemView: View { - fn title(&self, cx: &AppContext) -> String; - fn project_path(&self, cx: &AppContext) -> Option; - fn clone_on_split(&self, _: &mut ViewContext) -> Option - where - Self: Sized, - { - None - } - fn is_dirty(&self, _: &AppContext) -> bool { - false - } - fn has_conflict(&self, _: &AppContext) -> bool { - false - } - fn save(&mut self, cx: &mut ViewContext) -> Result>>; - fn save_as( - &mut self, - worktree: ModelHandle, - path: &Path, - cx: &mut ViewContext, - ) -> Task>; - fn should_activate_item_on_event(_: &Self::Event) -> bool { - false - } - fn should_close_item_on_event(_: &Self::Event) -> bool { - false - } - fn should_update_tab_on_event(_: &Self::Event) -> bool { - false - } -} - -pub trait ItemHandle: Send + Sync { - fn boxed_clone(&self) -> Box; - fn downgrade(&self) -> Box; -} - -pub trait WeakItemHandle { - fn add_view( - &self, - window_id: usize, - settings: watch::Receiver, - cx: &mut MutableAppContext, - ) -> Option>; - fn alive(&self, cx: &AppContext) -> bool; - fn project_path(&self, cx: &AppContext) -> Option; -} - -pub trait ItemViewHandle { - fn title(&self, cx: &AppContext) -> String; - fn project_path(&self, cx: &AppContext) -> Option; - fn boxed_clone(&self) -> Box; - fn clone_on_split(&self, cx: &mut MutableAppContext) -> Option>; - fn set_parent_pane(&self, pane: &ViewHandle, cx: &mut MutableAppContext); - fn id(&self) -> usize; - fn to_any(&self) -> AnyViewHandle; - fn is_dirty(&self, cx: &AppContext) -> bool; - fn has_conflict(&self, cx: &AppContext) -> bool; - fn save(&self, cx: &mut MutableAppContext) -> Result>>; - fn save_as( - &self, - worktree: ModelHandle, - path: &Path, - cx: &mut MutableAppContext, - ) -> Task>; -} - -impl ItemHandle for ModelHandle { - fn boxed_clone(&self) -> Box { - Box::new(self.clone()) - } - - fn downgrade(&self) -> Box { - Box::new(self.downgrade()) - } -} - -impl WeakItemHandle for WeakModelHandle { - fn add_view( - &self, - window_id: usize, - settings: watch::Receiver, - cx: &mut MutableAppContext, - ) -> Option> { - 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 - } - } - - fn alive(&self, cx: &AppContext) -> bool { - self.upgrade(cx).is_some() - } - - fn project_path(&self, cx: &AppContext) -> Option { - self.upgrade(cx).and_then(|h| h.read(cx).project_path()) - } -} - -impl ItemViewHandle for ViewHandle { - fn title(&self, cx: &AppContext) -> String { - self.read(cx).title(cx) - } - - fn project_path(&self, cx: &AppContext) -> Option { - self.read(cx).project_path(cx) - } - - fn boxed_clone(&self) -> Box { - Box::new(self.clone()) - } - - fn clone_on_split(&self, cx: &mut MutableAppContext) -> Option> { - self.update(cx, |item, cx| { - cx.add_option_view(|cx| item.clone_on_split(cx)) - }) - .map(|handle| Box::new(handle) as Box) - } - - fn set_parent_pane(&self, pane: &ViewHandle, cx: &mut MutableAppContext) { - pane.update(cx, |_, cx| { - cx.subscribe(self, |pane, item, event, cx| { - if T::should_close_item_on_event(event) { - pane.close_item(item.id(), cx); - return; - } - if T::should_activate_item_on_event(event) { - if let Some(ix) = pane.item_index(&item) { - pane.activate_item(ix, cx); - pane.activate(cx); - } - } - if T::should_update_tab_on_event(event) { - cx.notify() - } - }) - .detach(); - }); - } - - fn save(&self, cx: &mut MutableAppContext) -> Result>> { - self.update(cx, |item, cx| item.save(cx)) - } - - fn save_as( - &self, - worktree: ModelHandle, - path: &Path, - cx: &mut MutableAppContext, - ) -> Task> { - self.update(cx, |item, cx| item.save_as(worktree, path, cx)) - } - - fn is_dirty(&self, cx: &AppContext) -> bool { - self.read(cx).is_dirty(cx) - } - - fn has_conflict(&self, cx: &AppContext) -> bool { - self.read(cx).has_conflict(cx) - } - - fn id(&self) -> usize { - self.id() - } - - fn to_any(&self) -> AnyViewHandle { - self.into() - } -} - -impl Clone for Box { - fn clone(&self) -> Box { - self.boxed_clone() - } -} - -impl Clone for Box { - fn clone(&self) -> Box { - self.boxed_clone() - } -} - -pub struct Workspace { - pub settings: watch::Receiver, - client: Arc, - user_store: ModelHandle, - fs: Arc, - modal: Option, - center: PaneGroup, - left_sidebar: Sidebar, - right_sidebar: Sidebar, - panes: Vec>, - active_pane: ViewHandle, - project: ModelHandle, - items: Vec>, - loading_items: HashMap< - ProjectPath, - postage::watch::Receiver, Arc>>>, - >, - _observe_current_user: Task<()>, -} - -impl Workspace { - pub fn new(app_state: &AppState, cx: &mut ViewContext) -> Self { - let project = cx.add_model(|_| { - Project::new( - app_state.languages.clone(), - app_state.client.clone(), - app_state.fs.clone(), - ) - }); - cx.observe(&project, |_, _, cx| cx.notify()).detach(); - - let pane = cx.add_view(|_| Pane::new(app_state.settings.clone())); - let pane_id = pane.id(); - cx.observe(&pane, move |me, _, cx| { - let active_entry = me.active_project_path(cx); - me.project - .update(cx, |project, cx| project.set_active_path(active_entry, cx)); - }) - .detach(); - cx.subscribe(&pane, move |me, _, event, cx| { - me.handle_pane_event(pane_id, event, cx) - }) - .detach(); - cx.focus(&pane); - - let mut current_user = app_state.user_store.read(cx).watch_current_user().clone(); - let mut connection_status = app_state.client.status().clone(); - let _observe_current_user = cx.spawn_weak(|this, mut cx| async move { - current_user.recv().await; - connection_status.recv().await; - let mut stream = - Stream::map(current_user, drop).merge(Stream::map(connection_status, drop)); - - while stream.recv().await.is_some() { - cx.update(|cx| { - if let Some(this) = this.upgrade(&cx) { - this.update(cx, |_, cx| cx.notify()); - } - }) - } - }); - - Workspace { - modal: None, - center: PaneGroup::new(pane.id()), - panes: vec![pane.clone()], - active_pane: pane.clone(), - settings: app_state.settings.clone(), - client: app_state.client.clone(), - user_store: app_state.user_store.clone(), - fs: app_state.fs.clone(), - left_sidebar: Sidebar::new(Side::Left), - right_sidebar: Sidebar::new(Side::Right), - project, - items: Default::default(), - loading_items: Default::default(), - _observe_current_user, - } - } - - pub fn left_sidebar_mut(&mut self) -> &mut Sidebar { - &mut self.left_sidebar - } - - pub fn right_sidebar_mut(&mut self) -> &mut Sidebar { - &mut self.right_sidebar - } - - pub fn project(&self) -> &ModelHandle { - &self.project - } - - pub fn worktrees<'a>(&self, cx: &'a AppContext) -> &'a [ModelHandle] { - &self.project.read(cx).worktrees() - } - - pub fn contains_paths(&self, paths: &[PathBuf], cx: &AppContext) -> bool { - paths.iter().all(|path| self.contains_path(&path, cx)) - } - - pub fn contains_path(&self, path: &Path, cx: &AppContext) -> bool { - for worktree in self.worktrees(cx) { - let worktree = worktree.read(cx).as_local(); - if worktree.map_or(false, |w| w.contains_abs_path(path)) { - return true; - } - } - false - } - - pub fn worktree_scans_complete(&self, cx: &AppContext) -> impl Future + 'static { - let futures = self - .worktrees(cx) - .iter() - .filter_map(|worktree| worktree.read(cx).as_local()) - .map(|worktree| worktree.scan_complete()) - .collect::>(); - async move { - for future in futures { - future.await; - } - } - } - - pub fn open_paths(&mut self, abs_paths: &[PathBuf], cx: &mut ViewContext) -> Task<()> { - let entries = abs_paths - .iter() - .cloned() - .map(|path| self.project_path_for_path(&path, cx)) - .collect::>(); - - let fs = self.fs.clone(); - let tasks = abs_paths - .iter() - .cloned() - .zip(entries.into_iter()) - .map(|(abs_path, project_path)| { - cx.spawn(|this, mut cx| { - let fs = fs.clone(); - async move { - let project_path = project_path.await?; - if fs.is_file(&abs_path).await { - if let Some(entry) = - this.update(&mut cx, |this, cx| this.open_entry(project_path, cx)) - { - entry.await; - } - } - Ok(()) - } - }) - }) - .collect::>>>(); - - cx.foreground().spawn(async move { - for task in tasks { - if let Err(error) = task.await { - log::error!("error opening paths {}", error); - } - } - }) - } - - fn worktree_for_abs_path( - &self, - abs_path: &Path, - cx: &mut ViewContext, - ) -> Task, PathBuf)>> { - let abs_path: Arc = Arc::from(abs_path); - cx.spawn(|this, mut cx| async move { - let mut entry_id = None; - this.read_with(&cx, |this, cx| { - for tree in this.worktrees(cx) { - if let Some(relative_path) = tree - .read(cx) - .as_local() - .and_then(|t| abs_path.strip_prefix(t.abs_path()).ok()) - { - entry_id = Some((tree.clone(), relative_path.into())); - break; - } - } - }); - - if let Some(entry_id) = entry_id { - Ok(entry_id) - } else { - let worktree = this - .update(&mut cx, |this, cx| this.add_worktree(&abs_path, cx)) - .await?; - Ok((worktree, PathBuf::new())) - } - }) - } - - fn project_path_for_path( - &self, - abs_path: &Path, - cx: &mut ViewContext, - ) -> Task> { - let entry = self.worktree_for_abs_path(abs_path, cx); - cx.spawn(|_, _| async move { - let (worktree, path) = entry.await?; - Ok(ProjectPath { - worktree_id: worktree.id(), - path: path.into(), - }) - }) - } - - pub fn add_worktree( - &self, - path: &Path, - cx: &mut ViewContext, - ) -> Task>> { - self.project - .update(cx, |project, cx| project.add_local_worktree(path, cx)) - } - - pub fn toggle_modal(&mut self, cx: &mut ViewContext, add_view: F) - where - V: 'static + View, - F: FnOnce(&mut ViewContext, &mut Self) -> ViewHandle, - { - if self.modal.as_ref().map_or(false, |modal| modal.is::()) { - self.modal.take(); - cx.focus_self(); - } else { - let modal = add_view(cx, self); - cx.focus(&modal); - self.modal = Some(modal.into()); - } - cx.notify(); - } - - pub fn modal(&self) -> Option<&AnyViewHandle> { - self.modal.as_ref() - } - - pub fn dismiss_modal(&mut self, cx: &mut ViewContext) { - if self.modal.take().is_some() { - cx.focus(&self.active_pane); - cx.notify(); - } - } - - pub fn open_new_file(&mut self, _: &OpenNew, cx: &mut ViewContext) { - 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, - project_path: ProjectPath, - cx: &mut ViewContext, - ) -> Option> { - let pane = self.active_pane().clone(); - if self.activate_or_open_existing_entry(project_path.clone(), &pane, cx) { - return None; - } - - // let (worktree_id, path) = project_path.clone(); - - let worktree = match self - .project - .read(cx) - .worktree_for_id(project_path.worktree_id) - { - Some(worktree) => worktree, - None => { - log::error!("worktree {} does not exist", project_path.worktree_id); - return None; - } - }; - - if let Entry::Vacant(entry) = self.loading_items.entry(project_path.clone()) { - let (mut tx, rx) = postage::watch::channel(); - entry.insert(rx); - - let project_path = project_path.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) - }) - .await; - *tx.borrow_mut() = Some( - buffer - .map(|buffer| Box::new(buffer) as Box) - .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 { - let load_result = loop { - if let Some(load_result) = watch.borrow().as_ref() { - break load_result.clone(); - } - watch.recv().await; - }; - - this.update(&mut cx, |this, cx| { - this.loading_items.remove(&project_path); - if let Some(pane) = pane.upgrade(&cx) { - match load_result { - Ok(item) => { - // By the time loading finishes, the entry could have been already added - // 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()); - } - } - Err(error) => { - log::error!("error opening item: {}", error); - } - } - } - }) - })) - } - - fn activate_or_open_existing_entry( - &mut self, - project_path: ProjectPath, - pane: &ViewHandle, - cx: &mut ViewContext, - ) -> bool { - // If the pane contains a view for this file, then activate - // that item view. - if pane.update(cx, |pane, cx| pane.activate_entry(project_path.clone(), cx)) { - return true; - } - - // Otherwise, if this file is already open somewhere in the workspace, - // then add another view for it. - let settings = self.settings.clone(); - let mut view_for_existing_item = None; - self.items.retain(|item| { - if item.alive(cx.as_ref()) { - 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(), - ); - } - true - } else { - false - } - }); - if let Some(view) = view_for_existing_item { - pane.add_item_view(view, cx.as_mut()); - true - } else { - false - } - } - - pub fn active_item(&self, cx: &ViewContext) -> Option> { - self.active_pane().read(cx).active_item() - } - - fn active_project_path(&self, cx: &ViewContext) -> Option { - self.active_item(cx).and_then(|item| item.project_path(cx)) - } - - pub fn save_active_item(&mut self, _: &Save, cx: &mut ViewContext) { - if let Some(item) = self.active_item(cx) { - let handle = cx.handle(); - if item.project_path(cx.as_ref()).is_none() { - let worktree = self.worktrees(cx).first(); - let start_abs_path = worktree - .and_then(|w| w.read(cx).as_local()) - .map_or(Path::new(""), |w| w.abs_path()) - .to_path_buf(); - cx.prompt_for_new_path(&start_abs_path, move |abs_path, cx| { - if let Some(abs_path) = abs_path { - cx.spawn(|mut cx| async move { - let result = match handle - .update(&mut cx, |this, cx| { - this.worktree_for_abs_path(&abs_path, cx) - }) - .await - { - Ok((worktree, path)) => { - handle - .update(&mut cx, |_, cx| { - item.save_as(worktree, &path, cx.as_mut()) - }) - .await - } - Err(error) => Err(error), - }; - - if let Err(error) = result { - error!("failed to save item: {:?}, ", error); - } - }) - .detach() - } - }); - return; - } else if item.has_conflict(cx.as_ref()) { - const CONFLICT_MESSAGE: &'static str = "This file has changed on disk since you started editing it. Do you want to overwrite it?"; - - cx.prompt( - PromptLevel::Warning, - CONFLICT_MESSAGE, - &["Overwrite", "Cancel"], - move |answer, cx| { - if answer == 0 { - cx.spawn(|mut cx| async move { - if let Err(error) = cx.update(|cx| item.save(cx)).unwrap().await { - error!("failed to save item: {:?}, ", error); - } - }) - .detach(); - } - }, - ); - } else { - cx.spawn(|_, mut cx| async move { - if let Err(error) = cx.update(|cx| item.save(cx)).unwrap().await { - error!("failed to save item: {:?}, ", error); - } - }) - .detach(); - } - } - } - - pub fn toggle_sidebar_item(&mut self, action: &ToggleSidebarItem, cx: &mut ViewContext) { - let sidebar = match action.0.side { - Side::Left => &mut self.left_sidebar, - Side::Right => &mut self.right_sidebar, - }; - sidebar.toggle_item(action.0.item_index); - if let Some(active_item) = sidebar.active_item() { - cx.focus(active_item); - } else { - cx.focus_self(); - } - cx.notify(); - } - - pub fn toggle_sidebar_item_focus( - &mut self, - action: &ToggleSidebarItemFocus, - cx: &mut ViewContext, - ) { - let sidebar = match action.0.side { - Side::Left => &mut self.left_sidebar, - Side::Right => &mut self.right_sidebar, - }; - sidebar.activate_item(action.0.item_index); - if let Some(active_item) = sidebar.active_item() { - if active_item.is_focused(cx) { - cx.focus_self(); - } else { - cx.focus(active_item); - } - } - cx.notify(); - } - - pub fn debug_elements(&mut self, _: &DebugElements, cx: &mut ViewContext) { - match to_string_pretty(&cx.debug_elements()) { - Ok(json) => { - let kib = json.len() as f32 / 1024.; - cx.as_mut().write_to_clipboard(ClipboardItem::new(json)); - log::info!( - "copied {:.1} KiB of element debug JSON to the clipboard", - kib - ); - } - Err(error) => { - log::error!("error debugging elements: {}", error); - } - }; - } - - fn add_pane(&mut self, cx: &mut ViewContext) -> ViewHandle { - let pane = cx.add_view(|_| Pane::new(self.settings.clone())); - let pane_id = pane.id(); - cx.observe(&pane, move |me, _, cx| { - let active_entry = me.active_project_path(cx); - me.project - .update(cx, |project, cx| project.set_active_path(active_entry, cx)); - }) - .detach(); - cx.subscribe(&pane, move |me, _, event, cx| { - me.handle_pane_event(pane_id, event, cx) - }) - .detach(); - self.panes.push(pane.clone()); - self.activate_pane(pane.clone(), cx); - pane - } - - fn activate_pane(&mut self, pane: ViewHandle, cx: &mut ViewContext) { - self.active_pane = pane; - cx.focus(&self.active_pane); - cx.notify(); - } - - fn handle_pane_event( - &mut self, - pane_id: usize, - event: &pane::Event, - cx: &mut ViewContext, - ) { - if let Some(pane) = self.pane(pane_id) { - match event { - pane::Event::Split(direction) => { - self.split_pane(pane, *direction, cx); - } - pane::Event::Remove => { - self.remove_pane(pane, cx); - } - pane::Event::Activate => { - self.activate_pane(pane, cx); - } - } - } else { - error!("pane {} not found", pane_id); - } - } - - fn split_pane( - &mut self, - pane: ViewHandle, - direction: SplitDirection, - cx: &mut ViewContext, - ) -> ViewHandle { - let new_pane = self.add_pane(cx); - self.activate_pane(new_pane.clone(), cx); - if let Some(item) = pane.read(cx).active_item() { - if let Some(clone) = item.clone_on_split(cx.as_mut()) { - new_pane.add_item_view(clone, cx.as_mut()); - } - } - self.center - .split(pane.id(), new_pane.id(), direction) - .unwrap(); - cx.notify(); - new_pane - } - - fn remove_pane(&mut self, pane: ViewHandle, cx: &mut ViewContext) { - if self.center.remove(pane.id()).unwrap() { - self.panes.retain(|p| p != &pane); - self.activate_pane(self.panes.last().unwrap().clone(), cx); - } - } - - fn pane(&self, pane_id: usize) -> Option> { - self.panes.iter().find(|pane| pane.id() == pane_id).cloned() - } - - pub fn active_pane(&self) -> &ViewHandle { - &self.active_pane - } - - fn render_connection_status(&self) -> Option { - let theme = &self.settings.borrow().theme; - match &*self.client.status().borrow() { - client::Status::ConnectionError - | client::Status::ConnectionLost - | client::Status::Reauthenticating - | client::Status::Reconnecting { .. } - | client::Status::ReconnectionError { .. } => Some( - Container::new( - Align::new( - ConstrainedBox::new( - Svg::new("icons/offline-14.svg") - .with_color(theme.workspace.titlebar.icon_color) - .boxed(), - ) - .with_width(theme.workspace.titlebar.offline_icon.width) - .boxed(), - ) - .boxed(), - ) - .with_style(theme.workspace.titlebar.offline_icon.container) - .boxed(), - ), - client::Status::UpgradeRequired => Some( - Label::new( - "Please update Zed to collaborate".to_string(), - theme.workspace.titlebar.outdated_warning.text.clone(), - ) - .contained() - .with_style(theme.workspace.titlebar.outdated_warning.container) - .aligned() - .boxed(), - ), - _ => None, - } - } - - fn render_avatar(&self, cx: &mut RenderContext) -> ElementBox { - let theme = &self.settings.borrow().theme; - let avatar = if let Some(avatar) = self - .user_store - .read(cx) - .current_user() - .and_then(|user| user.avatar.clone()) - { - Image::new(avatar) - .with_style(theme.workspace.titlebar.avatar) - .boxed() - } else { - MouseEventHandler::new::(0, cx, |_, _| { - Svg::new("icons/signed-out-12.svg") - .with_color(theme.workspace.titlebar.icon_color) - .boxed() - }) - .on_click(|cx| cx.dispatch_action(Authenticate)) - .with_cursor_style(CursorStyle::PointingHand) - .boxed() - }; - - ConstrainedBox::new( - Align::new( - ConstrainedBox::new(avatar) - .with_width(theme.workspace.titlebar.avatar_width) - .boxed(), - ) - .boxed(), - ) - .with_width(theme.workspace.right_sidebar.width) - .boxed() - } -} - -impl Entity for Workspace { - type Event = (); -} - -impl View for Workspace { - fn ui_name() -> &'static str { - "Workspace" - } - - fn render(&mut self, cx: &mut RenderContext) -> ElementBox { - let settings = self.settings.borrow(); - let theme = &settings.theme; - Container::new( - Flex::column() - .with_child( - ConstrainedBox::new( - Container::new( - Stack::new() - .with_child( - Align::new( - Label::new( - "zed".into(), - theme.workspace.titlebar.title.clone(), - ) - .boxed(), - ) - .boxed(), - ) - .with_child( - Align::new( - Flex::row() - .with_children(self.render_connection_status()) - .with_child(self.render_avatar(cx)) - .boxed(), - ) - .right() - .boxed(), - ) - .boxed(), - ) - .with_style(theme.workspace.titlebar.container) - .boxed(), - ) - .with_height(32.) - .named("titlebar"), - ) - .with_child( - Expanded::new( - 1.0, - Stack::new() - .with_child({ - let mut content = Flex::row(); - content.add_child(self.left_sidebar.render(&settings, cx)); - if let Some(element) = - self.left_sidebar.render_active_item(&settings, cx) - { - content.add_child(Flexible::new(0.8, element).boxed()); - } - content.add_child( - Expanded::new(1.0, self.center.render(&settings.theme)).boxed(), - ); - if let Some(element) = - self.right_sidebar.render_active_item(&settings, cx) - { - content.add_child(Flexible::new(0.8, element).boxed()); - } - content.add_child(self.right_sidebar.render(&settings, cx)); - content.boxed() - }) - .with_children( - self.modal.as_ref().map(|m| ChildView::new(m.id()).boxed()), - ) - .boxed(), - ) - .boxed(), - ) - .boxed(), - ) - .with_background_color(settings.theme.workspace.background) - .named("workspace") - } - - fn on_focus(&mut self, cx: &mut ViewContext) { - cx.focus(&self.active_pane); - } -} - -#[cfg(test)] -pub trait WorkspaceHandle { - fn file_project_paths(&self, cx: &AppContext) -> Vec; -} - -#[cfg(test)] -impl WorkspaceHandle for ViewHandle { - fn file_project_paths(&self, cx: &AppContext) -> Vec { - self.read(cx) - .worktrees(cx) - .iter() - .flat_map(|worktree| { - let worktree_id = worktree.id(); - worktree.read(cx).files(true, 0).map(move |f| ProjectPath { - worktree_id, - path: f.path.clone(), - }) - }) - .collect::>() - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::{fs::FakeFs, test::test_app_state}; - use editor::{Editor, Insert}; - use serde_json::json; - use std::collections::HashSet; - - #[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, 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::>(); - assert_eq!(pane_entries, &[file1, file2, file3]); - }); - } - - #[gpui::test] - async fn test_open_paths(mut cx: gpui::TestAppContext) { - let fs = FakeFs::new(); - 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 mut app_state = cx.update(test_app_state); - Arc::get_mut(&mut app_state).unwrap().fs = Arc::new(fs); - - let (_, workspace) = cx.add_window(|cx| Workspace::new(&app_state, 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::>(); - 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); - app_state - .fs - .as_fake() - .insert_tree( - "/root", - json!({ - "a.txt": "", - }), - ) - .await; - - let (window_id, workspace) = cx.add_window(|cx| Workspace::new(&app_state, 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::().unwrap() - }); - - cx.update(|cx| editor.update(cx, |editor, cx| editor.insert(&Insert("x".into()), cx))); - app_state - .fs - .as_fake() - .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 app_state = cx.update(test_app_state); - app_state.fs.as_fake().insert_dir("/root").await.unwrap(); - let (_, workspace) = cx.add_window(|cx| Workspace::new(&app_state, 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(app_state.clone()), cx); - workspace - .active_item(cx) - .unwrap() - .to_any() - .downcast::() - .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.insert(&Insert("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.insert(&Insert(" 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(app_state.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::() - .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 (_, workspace) = cx.add_window(|cx| Workspace::new(&app_state, cx)); - - // Create a new untitled buffer - let editor = workspace.update(&mut cx, |workspace, cx| { - workspace.open_new_file(&OpenNew(app_state.clone()), cx); - workspace - .active_item(cx) - .unwrap() - .to_any() - .downcast::() - .unwrap() - }); - - editor.update(&mut cx, |editor, cx| { - assert!(editor.language(cx).is_none()); - editor.insert(&Insert("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 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, 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); - }); - } -}