Foundations for Open All the Things (#9353)

This is the beginning of setting up a flexible way to open items beyond
the text buffer -- think notebooks, images, GeoJSON, etc. The primary
requirement to allow opening an arbitrary file is `try_open` on the
`project::Item` trait. Now we can make new `Item`s for other types with
their own ways to render.

Under the hood, `register_project_item` uses this new opening scheme. It
supports a dynamic array of opener functions, that will handle specific
item types. By default, a `Buffer` should be able to be able to open any
file that another opener did not.

A key detail here is that the order of registration matters. The last
item has primacy. Here's an example:

```rust
workspace::register_project_item::<Editor>(cx);
workspace::register_project_item::<Notebook>(cx);
workspace::register_project_item::<ImageViewer>(cx);
```

When a project item (file) is attempted to be opened, it's first tried
with the `ImageViewer`, followed by the `Notebook`, then the `Editor`.

The tests are set up in a way that should make it _hopefully_ easy to
learn how to write a new opener. First to go after should probably be
image files.

Release Notes:

N/A

---------

Co-authored-by: Antonio Scandurra <me@as-cii.com>
Co-authored-by: Mikayla Maki <mikayla@zed.dev>
This commit is contained in:
Kyle Kelley 2024-03-14 18:01:40 -07:00 committed by GitHub
parent f9b9123606
commit 72d36d0213
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 369 additions and 39 deletions

View file

@ -118,6 +118,13 @@ const SERVER_LAUNCHING_BEFORE_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(5
pub const SERVER_PROGRESS_DEBOUNCE_TIMEOUT: Duration = Duration::from_millis(100); pub const SERVER_PROGRESS_DEBOUNCE_TIMEOUT: Duration = Duration::from_millis(100);
pub trait Item { pub trait Item {
fn try_open(
project: &Model<Project>,
path: &ProjectPath,
cx: &mut AppContext,
) -> Option<Task<Result<Model<Self>>>>
where
Self: Sized;
fn entry_id(&self, cx: &AppContext) -> Option<ProjectEntryId>; fn entry_id(&self, cx: &AppContext) -> Option<ProjectEntryId>;
fn project_path(&self, cx: &AppContext) -> Option<ProjectPath>; fn project_path(&self, cx: &AppContext) -> Option<ProjectPath>;
} }
@ -9616,6 +9623,14 @@ fn resolve_path(base: &Path, path: &Path) -> PathBuf {
} }
impl Item for Buffer { impl Item for Buffer {
fn try_open(
project: &Model<Project>,
path: &ProjectPath,
cx: &mut AppContext,
) -> Option<Task<Result<Model<Self>>>> {
Some(project.update(cx, |project, cx| project.open_buffer(path.clone(), cx)))
}
fn entry_id(&self, cx: &AppContext) -> Option<ProjectEntryId> { fn entry_id(&self, cx: &AppContext) -> Option<ProjectEntryId> {
File::from_dyn(self.file()).and_then(|file| file.project_entry_id(cx)) File::from_dyn(self.file()).and_then(|file| file.project_entry_id(cx))
} }

View file

@ -32,6 +32,7 @@ use std::{
time::Duration, time::Duration,
}; };
use theme::Theme; use theme::Theme;
use ui::Element as _;
pub const LEADER_UPDATE_THROTTLE: Duration = Duration::from_millis(200); pub const LEADER_UPDATE_THROTTLE: Duration = Duration::from_millis(200);
@ -100,6 +101,15 @@ pub struct BreadcrumbText {
pub trait Item: FocusableView + EventEmitter<Self::Event> { pub trait Item: FocusableView + EventEmitter<Self::Event> {
type Event; type Event;
fn tab_content(
&self,
_detail: Option<usize>,
_selected: bool,
_cx: &WindowContext,
) -> AnyElement {
gpui::Empty.into_any()
}
fn to_item_events(_event: &Self::Event, _f: impl FnMut(ItemEvent)) {}
fn deactivated(&mut self, _: &mut ViewContext<Self>) {} fn deactivated(&mut self, _: &mut ViewContext<Self>) {}
fn workspace_deactivated(&mut self, _: &mut ViewContext<Self>) {} fn workspace_deactivated(&mut self, _: &mut ViewContext<Self>) {}
@ -112,9 +122,10 @@ pub trait Item: FocusableView + EventEmitter<Self::Event> {
fn tab_description(&self, _: usize, _: &AppContext) -> Option<SharedString> { fn tab_description(&self, _: usize, _: &AppContext) -> Option<SharedString> {
None None
} }
fn tab_content(&self, detail: Option<usize>, selected: bool, cx: &WindowContext) -> AnyElement;
fn telemetry_event_text(&self) -> Option<&'static str>; fn telemetry_event_text(&self) -> Option<&'static str> {
None
}
/// (model id, Item) /// (model id, Item)
fn for_each_project_item( fn for_each_project_item(
@ -170,8 +181,6 @@ pub trait Item: FocusableView + EventEmitter<Self::Event> {
unimplemented!("reload() must be implemented if can_save() returns true") unimplemented!("reload() must be implemented if can_save() returns true")
} }
fn to_item_events(event: &Self::Event, f: impl FnMut(ItemEvent));
fn act_as_type<'a>( fn act_as_type<'a>(
&'a self, &'a self,
type_id: TypeId, type_id: TypeId,
@ -847,6 +856,14 @@ pub mod test {
} }
impl project::Item for TestProjectItem { impl project::Item for TestProjectItem {
fn try_open(
_project: &Model<Project>,
_path: &ProjectPath,
_cx: &mut AppContext,
) -> Option<Task<gpui::Result<Model<Self>>>> {
None
}
fn entry_id(&self, _: &AppContext) -> Option<ProjectEntryId> { fn entry_id(&self, _: &AppContext) -> Option<ProjectEntryId> {
self.entry_id self.entry_id
} }

View file

@ -26,13 +26,13 @@ use futures::{
Future, FutureExt, StreamExt, Future, FutureExt, StreamExt,
}; };
use gpui::{ use gpui::{
actions, canvas, div, impl_actions, point, size, Action, AnyElement, AnyModel, AnyView, actions, canvas, div, impl_actions, point, size, Action, AnyElement, AnyView, AnyWeakView,
AnyWeakView, AppContext, AsyncAppContext, AsyncWindowContext, Bounds, Context, Div, AppContext, AsyncAppContext, AsyncWindowContext, Bounds, Context, Div, DragMoveEvent, Element,
DragMoveEvent, Element, ElementContext, Entity, EntityId, EventEmitter, FocusHandle, ElementContext, Entity, EntityId, EventEmitter, FocusHandle, FocusableView, Global,
FocusableView, Global, GlobalPixels, InteractiveElement, IntoElement, KeyContext, Keystroke, GlobalPixels, InteractiveElement, IntoElement, KeyContext, Keystroke, LayoutId, ManagedView,
LayoutId, ManagedView, Model, ModelContext, ParentElement, PathPromptOptions, Pixels, Point, Model, ModelContext, ParentElement, PathPromptOptions, Pixels, Point, PromptLevel, Render,
PromptLevel, Render, SharedString, Size, Styled, Subscription, Task, View, ViewContext, SharedString, Size, Styled, Subscription, Task, View, ViewContext, VisualContext, WeakView,
VisualContext, WeakView, WindowContext, WindowHandle, WindowOptions, WindowContext, WindowHandle, WindowOptions,
}; };
use item::{FollowableItem, FollowableItemHandle, Item, ItemHandle, ItemSettings, ProjectItem}; use item::{FollowableItem, FollowableItemHandle, Item, ItemHandle, ItemSettings, ProjectItem};
use itertools::Itertools; use itertools::Itertools;
@ -275,17 +275,37 @@ pub fn init(app_state: Arc<AppState>, cx: &mut AppContext) {
} }
#[derive(Clone, Default, Deref, DerefMut)] #[derive(Clone, Default, Deref, DerefMut)]
struct ProjectItemBuilders( struct ProjectItemOpeners(Vec<ProjectItemOpener>);
HashMap<TypeId, fn(Model<Project>, AnyModel, &mut ViewContext<Pane>) -> Box<dyn ItemHandle>>,
);
impl Global for ProjectItemBuilders {} type ProjectItemOpener = fn(
&Model<Project>,
&ProjectPath,
&mut WindowContext,
)
-> Option<Task<Result<(Option<ProjectEntryId>, WorkspaceItemBuilder)>>>;
type WorkspaceItemBuilder = Box<dyn FnOnce(&mut ViewContext<Pane>) -> Box<dyn ItemHandle>>;
impl Global for ProjectItemOpeners {}
/// Registers a [ProjectItem] for the app. When opening a file, all the registered
/// items will get a chance to open the file, starting from the project item that
/// was added last.
pub fn register_project_item<I: ProjectItem>(cx: &mut AppContext) { pub fn register_project_item<I: ProjectItem>(cx: &mut AppContext) {
let builders = cx.default_global::<ProjectItemBuilders>(); let builders = cx.default_global::<ProjectItemOpeners>();
builders.insert(TypeId::of::<I::Item>(), |project, model, cx| { builders.push(|project, project_path, cx| {
let item = model.downcast::<I::Item>().unwrap(); let project_item = <I::Item as project::Item>::try_open(&project, project_path, cx)?;
Box::new(cx.new_view(|cx| I::for_project_item(project, item, cx))) let project = project.clone();
Some(cx.spawn(|cx| async move {
let project_item = project_item.await?;
let project_entry_id: Option<ProjectEntryId> =
project_item.read_with(&cx, |item, cx| project::Item::entry_id(item, cx))?;
let build_workspace_item = Box::new(|cx: &mut ViewContext<Pane>| {
Box::new(cx.new_view(|cx| I::for_project_item(project, project_item, cx)))
as Box<dyn ItemHandle>
}) as Box<_>;
Ok((project_entry_id, build_workspace_item))
}))
}); });
} }
@ -2051,26 +2071,17 @@ impl Workspace {
&mut self, &mut self,
path: ProjectPath, path: ProjectPath,
cx: &mut WindowContext, cx: &mut WindowContext,
) -> Task< ) -> Task<Result<(Option<ProjectEntryId>, WorkspaceItemBuilder)>> {
Result<(
Option<ProjectEntryId>,
impl 'static + Send + FnOnce(&mut ViewContext<Pane>) -> Box<dyn ItemHandle>,
)>,
> {
let project = self.project().clone(); let project = self.project().clone();
let project_item = project.update(cx, |project, cx| project.open_path(path, cx)); let project_item_builders = cx.default_global::<ProjectItemOpeners>().clone();
cx.spawn(|mut cx| async move { let Some(open_project_item) = project_item_builders
let (project_entry_id, project_item) = project_item.await?; .iter()
let build_item = cx.update(|cx| { .rev()
cx.default_global::<ProjectItemBuilders>() .find_map(|open_project_item| open_project_item(&project, &path, cx))
.get(&project_item.entity_type()) else {
.ok_or_else(|| anyhow!("no item builder for project item")) return Task::ready(Err(anyhow!("cannot open file {:?}", path.path)));
.cloned() };
})??; open_project_item
let build_item =
move |cx: &mut ViewContext<Pane>| build_item(project, project_item, cx);
Ok((project_entry_id, build_item))
})
} }
pub fn open_project_item<T>( pub fn open_project_item<T>(
@ -4811,7 +4822,7 @@ mod tests {
}, },
}; };
use fs::FakeFs; use fs::FakeFs;
use gpui::{px, DismissEvent, TestAppContext, VisualTestContext}; use gpui::{px, DismissEvent, Empty, TestAppContext, VisualTestContext};
use project::{Project, ProjectEntryId}; use project::{Project, ProjectEntryId};
use serde_json::json; use serde_json::json;
use settings::SettingsStore; use settings::SettingsStore;
@ -5787,6 +5798,293 @@ mod tests {
}); });
} }
mod register_project_item_tests {
use super::*;
const TEST_PNG_KIND: &str = "TestPngItemView";
// View
struct TestPngItemView {
focus_handle: FocusHandle,
}
// Model
struct TestPngItem {}
impl project::Item for TestPngItem {
fn try_open(
_project: &Model<Project>,
path: &ProjectPath,
cx: &mut AppContext,
) -> Option<Task<gpui::Result<Model<Self>>>> {
if path.path.extension().unwrap() == "png" {
Some(cx.spawn(|mut cx| async move { cx.new_model(|_| TestPngItem {}) }))
} else {
None
}
}
fn entry_id(&self, _: &AppContext) -> Option<ProjectEntryId> {
None
}
fn project_path(&self, _: &AppContext) -> Option<ProjectPath> {
None
}
}
impl Item for TestPngItemView {
type Event = ();
fn serialized_item_kind() -> Option<&'static str> {
Some(TEST_PNG_KIND)
}
}
impl EventEmitter<()> for TestPngItemView {}
impl FocusableView for TestPngItemView {
fn focus_handle(&self, _cx: &AppContext) -> FocusHandle {
self.focus_handle.clone()
}
}
impl Render for TestPngItemView {
fn render(&mut self, _cx: &mut ViewContext<Self>) -> impl IntoElement {
Empty
}
}
impl ProjectItem for TestPngItemView {
type Item = TestPngItem;
fn for_project_item(
_project: Model<Project>,
_item: Model<Self::Item>,
cx: &mut ViewContext<Self>,
) -> Self
where
Self: Sized,
{
Self {
focus_handle: cx.focus_handle(),
}
}
}
const TEST_IPYNB_KIND: &str = "TestIpynbItemView";
// View
struct TestIpynbItemView {
focus_handle: FocusHandle,
}
// Model
struct TestIpynbItem {}
impl project::Item for TestIpynbItem {
fn try_open(
_project: &Model<Project>,
path: &ProjectPath,
cx: &mut AppContext,
) -> Option<Task<gpui::Result<Model<Self>>>> {
if path.path.extension().unwrap() == "ipynb" {
Some(cx.spawn(|mut cx| async move { cx.new_model(|_| TestIpynbItem {}) }))
} else {
None
}
}
fn entry_id(&self, _: &AppContext) -> Option<ProjectEntryId> {
None
}
fn project_path(&self, _: &AppContext) -> Option<ProjectPath> {
None
}
}
impl Item for TestIpynbItemView {
type Event = ();
fn serialized_item_kind() -> Option<&'static str> {
Some(TEST_IPYNB_KIND)
}
}
impl EventEmitter<()> for TestIpynbItemView {}
impl FocusableView for TestIpynbItemView {
fn focus_handle(&self, _cx: &AppContext) -> FocusHandle {
self.focus_handle.clone()
}
}
impl Render for TestIpynbItemView {
fn render(&mut self, _cx: &mut ViewContext<Self>) -> impl IntoElement {
Empty
}
}
impl ProjectItem for TestIpynbItemView {
type Item = TestIpynbItem;
fn for_project_item(
_project: Model<Project>,
_item: Model<Self::Item>,
cx: &mut ViewContext<Self>,
) -> Self
where
Self: Sized,
{
Self {
focus_handle: cx.focus_handle(),
}
}
}
struct TestAlternatePngItemView {
focus_handle: FocusHandle,
}
const TEST_ALTERNATE_PNG_KIND: &str = "TestAlternatePngItemView";
impl Item for TestAlternatePngItemView {
type Event = ();
fn serialized_item_kind() -> Option<&'static str> {
Some(TEST_ALTERNATE_PNG_KIND)
}
}
impl EventEmitter<()> for TestAlternatePngItemView {}
impl FocusableView for TestAlternatePngItemView {
fn focus_handle(&self, _cx: &AppContext) -> FocusHandle {
self.focus_handle.clone()
}
}
impl Render for TestAlternatePngItemView {
fn render(&mut self, _cx: &mut ViewContext<Self>) -> impl IntoElement {
Empty
}
}
impl ProjectItem for TestAlternatePngItemView {
type Item = TestPngItem;
fn for_project_item(
_project: Model<Project>,
_item: Model<Self::Item>,
cx: &mut ViewContext<Self>,
) -> Self
where
Self: Sized,
{
Self {
focus_handle: cx.focus_handle(),
}
}
}
#[gpui::test]
async fn test_register_project_item(cx: &mut TestAppContext) {
init_test(cx);
cx.update(|cx| {
register_project_item::<TestPngItemView>(cx);
register_project_item::<TestIpynbItemView>(cx);
});
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
"/root1",
json!({
"one.png": "BINARYDATAHERE",
"two.ipynb": "{ totally a notebook }",
"three.txt": "editing text, sure why not?"
}),
)
.await;
let project = Project::test(fs, ["root1".as_ref()], cx).await;
let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project.clone(), cx));
let worktree_id = project.update(cx, |project, cx| {
project.worktrees().next().unwrap().read(cx).id()
});
let handle = workspace
.update(cx, |workspace, cx| {
let project_path = (worktree_id, "one.png");
workspace.open_path(project_path, None, true, cx)
})
.await
.unwrap();
// Now we can check if the handle we got back errored or not
assert_eq!(handle.serialized_item_kind().unwrap(), TEST_PNG_KIND);
let handle = workspace
.update(cx, |workspace, cx| {
let project_path = (worktree_id, "two.ipynb");
workspace.open_path(project_path, None, true, cx)
})
.await
.unwrap();
assert_eq!(handle.serialized_item_kind().unwrap(), TEST_IPYNB_KIND);
let handle = workspace
.update(cx, |workspace, cx| {
let project_path = (worktree_id, "three.txt");
workspace.open_path(project_path, None, true, cx)
})
.await;
assert!(handle.is_err());
}
#[gpui::test]
async fn test_register_project_item_two_enter_one_leaves(cx: &mut TestAppContext) {
init_test(cx);
cx.update(|cx| {
register_project_item::<TestPngItemView>(cx);
register_project_item::<TestAlternatePngItemView>(cx);
});
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
"/root1",
json!({
"one.png": "BINARYDATAHERE",
"two.ipynb": "{ totally a notebook }",
"three.txt": "editing text, sure why not?"
}),
)
.await;
let project = Project::test(fs, ["root1".as_ref()], cx).await;
let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project.clone(), cx));
let worktree_id = project.update(cx, |project, cx| {
project.worktrees().next().unwrap().read(cx).id()
});
let handle = workspace
.update(cx, |workspace, cx| {
let project_path = (worktree_id, "one.png");
workspace.open_path(project_path, None, true, cx)
})
.await
.unwrap();
// This _must_ be the second item registered
assert_eq!(
handle.serialized_item_kind().unwrap(),
TEST_ALTERNATE_PNG_KIND
);
let handle = workspace
.update(cx, |workspace, cx| {
let project_path = (worktree_id, "three.txt");
workspace.open_path(project_path, None, true, cx)
})
.await;
assert!(handle.is_err());
}
}
pub fn init_test(cx: &mut TestAppContext) { pub fn init_test(cx: &mut TestAppContext) {
cx.update(|cx| { cx.update(|cx| {
let settings_store = SettingsStore::test(cx); let settings_store = SettingsStore::test(cx);