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:
parent
f9b9123606
commit
72d36d0213
3 changed files with 369 additions and 39 deletions
|
@ -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 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 project_path(&self, cx: &AppContext) -> Option<ProjectPath>;
|
||||
}
|
||||
|
@ -9616,6 +9623,14 @@ fn resolve_path(base: &Path, path: &Path) -> PathBuf {
|
|||
}
|
||||
|
||||
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> {
|
||||
File::from_dyn(self.file()).and_then(|file| file.project_entry_id(cx))
|
||||
}
|
||||
|
|
|
@ -32,6 +32,7 @@ use std::{
|
|||
time::Duration,
|
||||
};
|
||||
use theme::Theme;
|
||||
use ui::Element as _;
|
||||
|
||||
pub const LEADER_UPDATE_THROTTLE: Duration = Duration::from_millis(200);
|
||||
|
||||
|
@ -100,6 +101,15 @@ pub struct BreadcrumbText {
|
|||
|
||||
pub trait Item: FocusableView + EventEmitter<Self::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 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> {
|
||||
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)
|
||||
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")
|
||||
}
|
||||
|
||||
fn to_item_events(event: &Self::Event, f: impl FnMut(ItemEvent));
|
||||
|
||||
fn act_as_type<'a>(
|
||||
&'a self,
|
||||
type_id: TypeId,
|
||||
|
@ -847,6 +856,14 @@ pub mod test {
|
|||
}
|
||||
|
||||
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> {
|
||||
self.entry_id
|
||||
}
|
||||
|
|
|
@ -26,13 +26,13 @@ use futures::{
|
|||
Future, FutureExt, StreamExt,
|
||||
};
|
||||
use gpui::{
|
||||
actions, canvas, div, impl_actions, point, size, Action, AnyElement, AnyModel, AnyView,
|
||||
AnyWeakView, AppContext, AsyncAppContext, AsyncWindowContext, Bounds, Context, Div,
|
||||
DragMoveEvent, Element, ElementContext, Entity, EntityId, EventEmitter, FocusHandle,
|
||||
FocusableView, Global, GlobalPixels, InteractiveElement, IntoElement, KeyContext, Keystroke,
|
||||
LayoutId, ManagedView, Model, ModelContext, ParentElement, PathPromptOptions, Pixels, Point,
|
||||
PromptLevel, Render, SharedString, Size, Styled, Subscription, Task, View, ViewContext,
|
||||
VisualContext, WeakView, WindowContext, WindowHandle, WindowOptions,
|
||||
actions, canvas, div, impl_actions, point, size, Action, AnyElement, AnyView, AnyWeakView,
|
||||
AppContext, AsyncAppContext, AsyncWindowContext, Bounds, Context, Div, DragMoveEvent, Element,
|
||||
ElementContext, Entity, EntityId, EventEmitter, FocusHandle, FocusableView, Global,
|
||||
GlobalPixels, InteractiveElement, IntoElement, KeyContext, Keystroke, LayoutId, ManagedView,
|
||||
Model, ModelContext, ParentElement, PathPromptOptions, Pixels, Point, PromptLevel, Render,
|
||||
SharedString, Size, Styled, Subscription, Task, View, ViewContext, VisualContext, WeakView,
|
||||
WindowContext, WindowHandle, WindowOptions,
|
||||
};
|
||||
use item::{FollowableItem, FollowableItemHandle, Item, ItemHandle, ItemSettings, ProjectItem};
|
||||
use itertools::Itertools;
|
||||
|
@ -275,17 +275,37 @@ pub fn init(app_state: Arc<AppState>, cx: &mut AppContext) {
|
|||
}
|
||||
|
||||
#[derive(Clone, Default, Deref, DerefMut)]
|
||||
struct ProjectItemBuilders(
|
||||
HashMap<TypeId, fn(Model<Project>, AnyModel, &mut ViewContext<Pane>) -> Box<dyn ItemHandle>>,
|
||||
);
|
||||
struct ProjectItemOpeners(Vec<ProjectItemOpener>);
|
||||
|
||||
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) {
|
||||
let builders = cx.default_global::<ProjectItemBuilders>();
|
||||
builders.insert(TypeId::of::<I::Item>(), |project, model, cx| {
|
||||
let item = model.downcast::<I::Item>().unwrap();
|
||||
Box::new(cx.new_view(|cx| I::for_project_item(project, item, cx)))
|
||||
let builders = cx.default_global::<ProjectItemOpeners>();
|
||||
builders.push(|project, project_path, cx| {
|
||||
let project_item = <I::Item as project::Item>::try_open(&project, project_path, 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,
|
||||
path: ProjectPath,
|
||||
cx: &mut WindowContext,
|
||||
) -> Task<
|
||||
Result<(
|
||||
Option<ProjectEntryId>,
|
||||
impl 'static + Send + FnOnce(&mut ViewContext<Pane>) -> Box<dyn ItemHandle>,
|
||||
)>,
|
||||
> {
|
||||
) -> Task<Result<(Option<ProjectEntryId>, WorkspaceItemBuilder)>> {
|
||||
let project = self.project().clone();
|
||||
let project_item = project.update(cx, |project, cx| project.open_path(path, cx));
|
||||
cx.spawn(|mut cx| async move {
|
||||
let (project_entry_id, project_item) = project_item.await?;
|
||||
let build_item = cx.update(|cx| {
|
||||
cx.default_global::<ProjectItemBuilders>()
|
||||
.get(&project_item.entity_type())
|
||||
.ok_or_else(|| anyhow!("no item builder for project item"))
|
||||
.cloned()
|
||||
})??;
|
||||
let build_item =
|
||||
move |cx: &mut ViewContext<Pane>| build_item(project, project_item, cx);
|
||||
Ok((project_entry_id, build_item))
|
||||
})
|
||||
let project_item_builders = cx.default_global::<ProjectItemOpeners>().clone();
|
||||
let Some(open_project_item) = project_item_builders
|
||||
.iter()
|
||||
.rev()
|
||||
.find_map(|open_project_item| open_project_item(&project, &path, cx))
|
||||
else {
|
||||
return Task::ready(Err(anyhow!("cannot open file {:?}", path.path)));
|
||||
};
|
||||
open_project_item
|
||||
}
|
||||
|
||||
pub fn open_project_item<T>(
|
||||
|
@ -4811,7 +4822,7 @@ mod tests {
|
|||
},
|
||||
};
|
||||
use fs::FakeFs;
|
||||
use gpui::{px, DismissEvent, TestAppContext, VisualTestContext};
|
||||
use gpui::{px, DismissEvent, Empty, TestAppContext, VisualTestContext};
|
||||
use project::{Project, ProjectEntryId};
|
||||
use serde_json::json;
|
||||
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) {
|
||||
cx.update(|cx| {
|
||||
let settings_store = SettingsStore::test(cx);
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue