diff --git a/Cargo.lock b/Cargo.lock index abc3eff861..93dcbd050e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5978,6 +5978,7 @@ dependencies = [ "settings", "theme", "ui", + "util", "workspace", ] @@ -9080,6 +9081,7 @@ dependencies = [ "globset", "gpui", "http_client", + "image", "itertools 0.13.0", "language", "log", diff --git a/crates/copilot/src/copilot.rs b/crates/copilot/src/copilot.rs index 3773f5cdb2..15b6bf6083 100644 --- a/crates/copilot/src/copilot.rs +++ b/crates/copilot/src/copilot.rs @@ -1272,5 +1272,9 @@ mod tests { fn load(&self, _: &AppContext) -> Task> { unimplemented!() } + + fn load_bytes(&self, _cx: &AppContext) -> Task>> { + unimplemented!() + } } } diff --git a/crates/gpui/src/elements/img.rs b/crates/gpui/src/elements/img.rs index 58ee639265..729e944b34 100644 --- a/crates/gpui/src/elements/img.rs +++ b/crates/gpui/src/elements/img.rs @@ -92,6 +92,12 @@ impl From> for ImageSource { } } +impl From> for ImageSource { + fn from(value: Arc) -> Self { + Self::Image(value) + } +} + /// An image element. pub struct Img { interactivity: Interactivity, diff --git a/crates/image_viewer/Cargo.toml b/crates/image_viewer/Cargo.toml index 717b3009d4..9c431e5edc 100644 --- a/crates/image_viewer/Cargo.toml +++ b/crates/image_viewer/Cargo.toml @@ -21,4 +21,5 @@ project.workspace = true settings.workspace = true theme.workspace = true ui.workspace = true +util.workspace = true workspace.workspace = true diff --git a/crates/image_viewer/src/image_viewer.rs b/crates/image_viewer/src/image_viewer.rs index 09e187950d..5e58cc49fb 100644 --- a/crates/image_viewer/src/image_viewer.rs +++ b/crates/image_viewer/src/image_viewer.rs @@ -1,18 +1,19 @@ +use std::path::PathBuf; + use anyhow::Context as _; use gpui::{ - canvas, div, fill, img, opaque_grey, point, size, AnyElement, AppContext, Bounds, Context, - EventEmitter, FocusHandle, FocusableView, Img, InteractiveElement, IntoElement, Model, - ObjectFit, ParentElement, Render, Styled, Task, View, ViewContext, VisualContext, WeakView, - WindowContext, + canvas, div, fill, img, opaque_grey, point, size, AnyElement, AppContext, Bounds, EventEmitter, + FocusHandle, FocusableView, InteractiveElement, IntoElement, Model, ObjectFit, ParentElement, + Render, Styled, Task, View, ViewContext, VisualContext, WeakView, WindowContext, }; use persistence::IMAGE_VIEWER; use theme::Theme; use ui::prelude::*; use file_icons::FileIcons; -use project::{Project, ProjectEntryId, ProjectPath}; +use project::{image_store::ImageItemEvent, ImageItem, Project, ProjectPath}; use settings::Settings; -use std::{ffi::OsStr, path::PathBuf}; +use util::paths::PathExt; use workspace::{ item::{BreadcrumbText, Item, ProjectItem, SerializableItem, TabContentParams}, ItemId, ItemSettings, Pane, ToolbarItemLocation, Workspace, WorkspaceId, @@ -20,86 +21,80 @@ use workspace::{ const IMAGE_VIEWER_KIND: &str = "ImageView"; -pub struct ImageItem { - id: ProjectEntryId, - path: PathBuf, - project_path: ProjectPath, - project: Model, -} - -impl project::Item for ImageItem { - fn try_open( - project: &Model, - path: &ProjectPath, - cx: &mut AppContext, - ) -> Option>>> { - let path = path.clone(); - let project = project.clone(); - - let ext = path - .path - .extension() - .and_then(OsStr::to_str) - .map(str::to_lowercase) - .unwrap_or_default(); - let ext = ext.as_str(); - - // Only open the item if it's a binary image (no SVGs, etc.) - // Since we do not have a way to toggle to an editor - if Img::extensions().contains(&ext) && !ext.contains("svg") { - Some(cx.spawn(|mut cx| async move { - let abs_path = project - .read_with(&cx, |project, cx| project.absolute_path(&path, cx))? - .ok_or_else(|| anyhow::anyhow!("Failed to find the absolute path"))?; - - let id = project - .update(&mut cx, |project, cx| project.entry_for_path(&path, cx))? - .context("Entry not found")? - .id; - - cx.new_model(|_| ImageItem { - project, - path: abs_path, - project_path: path, - id, - }) - })) - } else { - None - } - } - - fn entry_id(&self, _: &AppContext) -> Option { - Some(self.id) - } - - fn project_path(&self, _: &AppContext) -> Option { - Some(self.project_path.clone()) - } -} - pub struct ImageView { - image: Model, + image_item: Model, + project: Model, focus_handle: FocusHandle, } +impl ImageView { + pub fn new( + image_item: Model, + project: Model, + cx: &mut ViewContext, + ) -> Self { + cx.subscribe(&image_item, Self::on_image_event).detach(); + Self { + image_item, + project, + focus_handle: cx.focus_handle(), + } + } + + fn on_image_event( + &mut self, + _: Model, + event: &ImageItemEvent, + cx: &mut ViewContext, + ) { + match event { + ImageItemEvent::FileHandleChanged | ImageItemEvent::Reloaded => { + cx.emit(ImageViewEvent::TitleChanged); + cx.notify(); + } + ImageItemEvent::ReloadNeeded => {} + } + } +} + +pub enum ImageViewEvent { + TitleChanged, +} + +impl EventEmitter for ImageView {} + impl Item for ImageView { - type Event = (); + type Event = ImageViewEvent; + + fn to_item_events(event: &Self::Event, mut f: impl FnMut(workspace::item::ItemEvent)) { + match event { + ImageViewEvent::TitleChanged => { + f(workspace::item::ItemEvent::UpdateTab); + f(workspace::item::ItemEvent::UpdateBreadcrumbs); + } + } + } fn for_each_project_item( &self, cx: &AppContext, f: &mut dyn FnMut(gpui::EntityId, &dyn project::Item), ) { - f(self.image.entity_id(), self.image.read(cx)) + f(self.image_item.entity_id(), self.image_item.read(cx)) } fn is_singleton(&self, _cx: &AppContext) -> bool { true } + fn tab_tooltip_text(&self, cx: &AppContext) -> Option { + let abs_path = self.image_item.read(cx).file.as_local()?.abs_path(cx); + let file_path = abs_path.compact().to_string_lossy().to_string(); + Some(file_path.into()) + } + fn tab_content(&self, params: TabContentParams, cx: &WindowContext) -> AnyElement { - let path = &self.image.read(cx).path; + let path = self.image_item.read(cx).file.path(); let title = path .file_name() .unwrap_or_else(|| path.as_os_str()) @@ -113,10 +108,10 @@ impl Item for ImageView { } fn tab_icon(&self, cx: &WindowContext) -> Option { - let path = &self.image.read(cx).path; + let path = self.image_item.read(cx).path(); ItemSettings::get_global(cx) .file_icons - .then(|| FileIcons::get_icon(path.as_path(), cx)) + .then(|| FileIcons::get_icon(path, cx)) .flatten() .map(Icon::from_path) } @@ -126,7 +121,7 @@ impl Item for ImageView { } fn breadcrumbs(&self, _theme: &Theme, cx: &AppContext) -> Option> { - let text = breadcrumbs_text_for_image(self.image.read(cx), cx); + let text = breadcrumbs_text_for_image(self.project.read(cx), self.image_item.read(cx), cx); Some(vec![BreadcrumbText { text, highlights: None, @@ -143,22 +138,21 @@ impl Item for ImageView { Self: Sized, { Some(cx.new_view(|cx| Self { - image: self.image.clone(), + image_item: self.image_item.clone(), + project: self.project.clone(), focus_handle: cx.focus_handle(), })) } } -fn breadcrumbs_text_for_image(image: &ImageItem, cx: &AppContext) -> String { - let path = &image.project_path.path; - let project = image.project.read(cx); - +fn breadcrumbs_text_for_image(project: &Project, image: &ImageItem, cx: &AppContext) -> String { + let path = image.path(); if project.visible_worktrees(cx).count() <= 1 { return path.to_string_lossy().to_string(); } project - .worktree_for_entry(image.id, cx) + .worktree_for_id(image.project_path(cx).worktree_id, cx) .map(|worktree| { PathBuf::from(worktree.read(cx).root_name()) .join(path) @@ -198,26 +192,11 @@ impl SerializableItem for ImageView { path: relative_path.into(), }; - let id = project - .update(&mut cx, |project, cx| { - project.entry_for_path(&project_path, cx) - })? - .context("No entry found")? - .id; + let image_item = project + .update(&mut cx, |project, cx| project.open_image(project_path, cx))? + .await?; - cx.update(|cx| { - let image = cx.new_model(|_| ImageItem { - id, - path: image_path, - project_path, - project, - }); - - Ok(cx.new_view(|cx| ImageView { - image, - focus_handle: cx.focus_handle(), - })) - })? + cx.update(|cx| Ok(cx.new_view(|cx| ImageView::new(image_item, project, cx))))? }) } @@ -237,9 +216,9 @@ impl SerializableItem for ImageView { cx: &mut ViewContext, ) -> Option>> { let workspace_id = workspace.database_id()?; + let image_path = self.image_item.read(cx).file.as_local()?.abs_path(cx); Some(cx.background_executor().spawn({ - let image_path = self.image.read(cx).path.clone(); async move { IMAGE_VIEWER .save_image_path(item_id, workspace_id, image_path) @@ -262,7 +241,7 @@ impl FocusableView for ImageView { impl Render for ImageView { fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { - let image_path = self.image.read(cx).path.clone(); + let image = self.image_item.read(cx).image.clone(); let checkered_background = |bounds: Bounds, _, cx: &mut WindowContext| { let square_size = 32.0; @@ -319,7 +298,7 @@ impl Render for ImageView { // TODO: In browser based Tailwind & Flex this would be h-screen and we'd use w-full .h_full() .child( - img(image_path) + img(image) .object_fit(ObjectFit::ScaleDown) .max_w_full() .max_h_full(), @@ -332,17 +311,14 @@ impl ProjectItem for ImageView { type Item = ImageItem; fn for_project_item( - _project: Model, + project: Model, item: Model, cx: &mut ViewContext, ) -> Self where Self: Sized, { - Self { - image: item, - focus_handle: cx.focus_handle(), - } + Self::new(item, project, cx) } } diff --git a/crates/language/src/buffer.rs b/crates/language/src/buffer.rs index 2f010a9c3f..690b2d15ba 100644 --- a/crates/language/src/buffer.rs +++ b/crates/language/src/buffer.rs @@ -413,9 +413,12 @@ pub trait LocalFile: File { /// Returns the absolute path of this file fn abs_path(&self, cx: &AppContext) -> PathBuf; - /// Loads the file's contents from disk. + /// Loads the file contents from disk and returns them as a UTF-8 encoded string. fn load(&self, cx: &AppContext) -> Task>; + /// Loads the file's contents from disk. + fn load_bytes(&self, cx: &AppContext) -> Task>>; + /// Returns true if the file should not be shared with collaborators. fn is_private(&self, _: &AppContext) -> bool { false diff --git a/crates/project/Cargo.toml b/crates/project/Cargo.toml index c7ab31927f..b9fdd04be6 100644 --- a/crates/project/Cargo.toml +++ b/crates/project/Cargo.toml @@ -42,6 +42,7 @@ language.workspace = true log.workspace = true lsp.workspace = true node_runtime.workspace = true +image.workspace = true parking_lot.workspace = true pathdiff.workspace = true paths.workspace = true diff --git a/crates/project/src/image_store.rs b/crates/project/src/image_store.rs new file mode 100644 index 0000000000..fc31d75e52 --- /dev/null +++ b/crates/project/src/image_store.rs @@ -0,0 +1,584 @@ +use crate::worktree_store::{WorktreeStore, WorktreeStoreEvent}; +use crate::{Project, ProjectEntryId, ProjectPath}; +use anyhow::{Context as _, Result}; +use collections::{HashMap, HashSet}; +use futures::channel::oneshot; +use gpui::{ + hash, prelude::*, AppContext, EventEmitter, Img, Model, ModelContext, Subscription, Task, + WeakModel, +}; +use language::File; +use rpc::AnyProtoClient; +use std::ffi::OsStr; +use std::num::NonZeroU64; +use std::path::Path; +use std::sync::Arc; +use util::ResultExt; +use worktree::{LoadedBinaryFile, PathChange, Worktree}; + +#[derive(Clone, Copy, Debug, Hash, PartialEq, PartialOrd, Ord, Eq)] +pub struct ImageId(NonZeroU64); + +impl std::fmt::Display for ImageId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +impl From for ImageId { + fn from(id: NonZeroU64) -> Self { + ImageId(id) + } +} + +pub enum ImageItemEvent { + ReloadNeeded, + Reloaded, + FileHandleChanged, +} + +impl EventEmitter for ImageItem {} + +pub enum ImageStoreEvent { + ImageAdded(Model), +} + +impl EventEmitter for ImageStore {} + +pub struct ImageItem { + pub id: ImageId, + pub file: Arc, + pub image: Arc, + reload_task: Option>, +} + +impl ImageItem { + pub fn project_path(&self, cx: &AppContext) -> ProjectPath { + ProjectPath { + worktree_id: self.file.worktree_id(cx), + path: self.file.path().clone(), + } + } + + pub fn path(&self) -> &Arc { + self.file.path() + } + + fn file_updated(&mut self, new_file: Arc, cx: &mut ModelContext) { + let mut file_changed = false; + + let old_file = self.file.as_ref(); + if new_file.path() != old_file.path() { + file_changed = true; + } + + if !new_file.is_deleted() { + let new_mtime = new_file.mtime(); + if new_mtime != old_file.mtime() { + file_changed = true; + cx.emit(ImageItemEvent::ReloadNeeded); + } + } + + self.file = new_file; + if file_changed { + cx.emit(ImageItemEvent::FileHandleChanged); + cx.notify(); + } + } + + fn reload(&mut self, cx: &mut ModelContext) -> Option> { + let local_file = self.file.as_local()?; + let (tx, rx) = futures::channel::oneshot::channel(); + + let content = local_file.load_bytes(cx); + self.reload_task = Some(cx.spawn(|this, mut cx| async move { + if let Some(image) = content + .await + .context("Failed to load image content") + .and_then(create_gpui_image) + .log_err() + { + this.update(&mut cx, |this, cx| { + this.image = image; + cx.emit(ImageItemEvent::Reloaded); + }) + .log_err(); + } + _ = tx.send(()); + })); + Some(rx) + } +} + +impl crate::Item for ImageItem { + fn try_open( + project: &Model, + path: &ProjectPath, + cx: &mut AppContext, + ) -> Option>>> { + let path = path.clone(); + let project = project.clone(); + + let ext = path + .path + .extension() + .and_then(OsStr::to_str) + .map(str::to_lowercase) + .unwrap_or_default(); + let ext = ext.as_str(); + + // Only open the item if it's a binary image (no SVGs, etc.) + // Since we do not have a way to toggle to an editor + if Img::extensions().contains(&ext) && !ext.contains("svg") { + Some(cx.spawn(|mut cx| async move { + project + .update(&mut cx, |project, cx| project.open_image(path, cx))? + .await + })) + } else { + None + } + } + + fn entry_id(&self, _: &AppContext) -> Option { + worktree::File::from_dyn(Some(&self.file))?.entry_id + } + + fn project_path(&self, cx: &AppContext) -> Option { + Some(self.project_path(cx).clone()) + } +} + +trait ImageStoreImpl { + fn open_image( + &self, + path: Arc, + worktree: Model, + cx: &mut ModelContext, + ) -> Task>>; + + fn reload_images( + &self, + images: HashSet>, + cx: &mut ModelContext, + ) -> Task>; + + fn as_local(&self) -> Option>; +} + +struct RemoteImageStore {} + +struct LocalImageStore { + local_image_ids_by_path: HashMap, + local_image_ids_by_entry_id: HashMap, + image_store: WeakModel, + _subscription: Subscription, +} + +pub struct ImageStore { + state: Box, + opened_images: HashMap>, + worktree_store: Model, +} + +impl ImageStore { + pub fn local(worktree_store: Model, cx: &mut ModelContext) -> Self { + let this = cx.weak_model(); + Self { + state: Box::new(cx.new_model(|cx| { + let subscription = cx.subscribe( + &worktree_store, + |this: &mut LocalImageStore, _, event, cx| { + if let WorktreeStoreEvent::WorktreeAdded(worktree) = event { + this.subscribe_to_worktree(worktree, cx); + } + }, + ); + + LocalImageStore { + local_image_ids_by_path: Default::default(), + local_image_ids_by_entry_id: Default::default(), + image_store: this, + _subscription: subscription, + } + })), + opened_images: Default::default(), + worktree_store, + } + } + + pub fn remote( + worktree_store: Model, + _upstream_client: AnyProtoClient, + _remote_id: u64, + cx: &mut ModelContext, + ) -> Self { + Self { + state: Box::new(cx.new_model(|_| RemoteImageStore {})), + opened_images: Default::default(), + worktree_store, + } + } + + pub fn images(&self) -> impl '_ + Iterator> { + self.opened_images + .values() + .filter_map(|image| image.upgrade()) + } + + pub fn get(&self, image_id: ImageId) -> Option> { + self.opened_images + .get(&image_id) + .and_then(|image| image.upgrade()) + } + + pub fn get_by_path(&self, path: &ProjectPath, cx: &AppContext) -> Option> { + self.images() + .find(|image| &image.read(cx).project_path(cx) == path) + } + + pub fn open_image( + &mut self, + project_path: ProjectPath, + cx: &mut ModelContext, + ) -> Task>> { + let existing_image = self.get_by_path(&project_path, cx); + if let Some(existing_image) = existing_image { + return Task::ready(Ok(existing_image)); + } + + let Some(worktree) = self + .worktree_store + .read(cx) + .worktree_for_id(project_path.worktree_id, cx) + else { + return Task::ready(Err(anyhow::anyhow!("no such worktree"))); + }; + + self.state + .open_image(project_path.path.clone(), worktree, cx) + } + + pub fn reload_images( + &self, + images: HashSet>, + cx: &mut ModelContext, + ) -> Task> { + if images.is_empty() { + return Task::ready(Ok(())); + } + + self.state.reload_images(images, cx) + } + + fn add_image( + &mut self, + image: Model, + cx: &mut ModelContext, + ) -> Result<()> { + let image_id = image.read(cx).id; + + self.opened_images.insert(image_id, image.downgrade()); + + cx.subscribe(&image, Self::on_image_event).detach(); + cx.emit(ImageStoreEvent::ImageAdded(image)); + Ok(()) + } + + fn on_image_event( + &mut self, + image: Model, + event: &ImageItemEvent, + cx: &mut ModelContext, + ) { + match event { + ImageItemEvent::FileHandleChanged => { + if let Some(local) = self.state.as_local() { + local.update(cx, |local, cx| { + local.image_changed_file(image, cx); + }) + } + } + _ => {} + } + } +} + +impl ImageStoreImpl for Model { + fn open_image( + &self, + path: Arc, + worktree: Model, + cx: &mut ModelContext, + ) -> Task>> { + let this = self.clone(); + + let load_file = worktree.update(cx, |worktree, cx| { + worktree.load_binary_file(path.as_ref(), cx) + }); + cx.spawn(move |image_store, mut cx| async move { + let LoadedBinaryFile { file, content } = load_file.await?; + let image = create_gpui_image(content)?; + + let model = cx.new_model(|cx| ImageItem { + id: cx.entity_id().as_non_zero_u64().into(), + file: file.clone(), + image, + reload_task: None, + })?; + + let image_id = cx.read_model(&model, |model, _| model.id)?; + + this.update(&mut cx, |this, cx| { + image_store.update(cx, |image_store, cx| { + image_store.add_image(model.clone(), cx) + })??; + this.local_image_ids_by_path.insert( + ProjectPath { + worktree_id: file.worktree_id(cx), + path: file.path.clone(), + }, + image_id, + ); + + if let Some(entry_id) = file.entry_id { + this.local_image_ids_by_entry_id.insert(entry_id, image_id); + } + + anyhow::Ok(()) + })??; + + Ok(model) + }) + } + + fn reload_images( + &self, + images: HashSet>, + cx: &mut ModelContext, + ) -> Task> { + cx.spawn(move |_, mut cx| async move { + for image in images { + if let Some(rec) = image.update(&mut cx, |image, cx| image.reload(cx))? { + rec.await? + } + } + Ok(()) + }) + } + + fn as_local(&self) -> Option> { + Some(self.clone()) + } +} + +impl LocalImageStore { + fn subscribe_to_worktree(&mut self, worktree: &Model, cx: &mut ModelContext) { + cx.subscribe(worktree, |this, worktree, event, cx| { + if worktree.read(cx).is_local() { + match event { + worktree::Event::UpdatedEntries(changes) => { + this.local_worktree_entries_changed(&worktree, changes, cx); + } + _ => {} + } + } + }) + .detach(); + } + + fn local_worktree_entries_changed( + &mut self, + worktree_handle: &Model, + changes: &[(Arc, ProjectEntryId, PathChange)], + cx: &mut ModelContext, + ) { + let snapshot = worktree_handle.read(cx).snapshot(); + for (path, entry_id, _) in changes { + self.local_worktree_entry_changed(*entry_id, path, worktree_handle, &snapshot, cx); + } + } + + fn local_worktree_entry_changed( + &mut self, + entry_id: ProjectEntryId, + path: &Arc, + worktree: &Model, + snapshot: &worktree::Snapshot, + cx: &mut ModelContext, + ) -> Option<()> { + let project_path = ProjectPath { + worktree_id: snapshot.id(), + path: path.clone(), + }; + let image_id = match self.local_image_ids_by_entry_id.get(&entry_id) { + Some(&image_id) => image_id, + None => self.local_image_ids_by_path.get(&project_path).copied()?, + }; + + let image = self + .image_store + .update(cx, |image_store, _| { + if let Some(image) = image_store.get(image_id) { + Some(image) + } else { + image_store.opened_images.remove(&image_id); + None + } + }) + .ok() + .flatten(); + let image = if let Some(image) = image { + image + } else { + self.local_image_ids_by_path.remove(&project_path); + self.local_image_ids_by_entry_id.remove(&entry_id); + return None; + }; + + image.update(cx, |image, cx| { + let Some(old_file) = worktree::File::from_dyn(Some(&image.file)) else { + return; + }; + if old_file.worktree != *worktree { + return; + } + + let new_file = if let Some(entry) = old_file + .entry_id + .and_then(|entry_id| snapshot.entry_for_id(entry_id)) + { + worktree::File { + is_local: true, + entry_id: Some(entry.id), + mtime: entry.mtime, + path: entry.path.clone(), + worktree: worktree.clone(), + is_deleted: false, + is_private: entry.is_private, + } + } else if let Some(entry) = snapshot.entry_for_path(old_file.path.as_ref()) { + worktree::File { + is_local: true, + entry_id: Some(entry.id), + mtime: entry.mtime, + path: entry.path.clone(), + worktree: worktree.clone(), + is_deleted: false, + is_private: entry.is_private, + } + } else { + worktree::File { + is_local: true, + entry_id: old_file.entry_id, + path: old_file.path.clone(), + mtime: old_file.mtime, + worktree: worktree.clone(), + is_deleted: true, + is_private: old_file.is_private, + } + }; + + if new_file == *old_file { + return; + } + + if new_file.path != old_file.path { + self.local_image_ids_by_path.remove(&ProjectPath { + path: old_file.path.clone(), + worktree_id: old_file.worktree_id(cx), + }); + self.local_image_ids_by_path.insert( + ProjectPath { + worktree_id: new_file.worktree_id(cx), + path: new_file.path.clone(), + }, + image_id, + ); + } + + if new_file.entry_id != old_file.entry_id { + if let Some(entry_id) = old_file.entry_id { + self.local_image_ids_by_entry_id.remove(&entry_id); + } + if let Some(entry_id) = new_file.entry_id { + self.local_image_ids_by_entry_id.insert(entry_id, image_id); + } + } + + image.file_updated(Arc::new(new_file), cx); + }); + None + } + + fn image_changed_file(&mut self, image: Model, cx: &mut AppContext) -> Option<()> { + let file = worktree::File::from_dyn(Some(&image.read(cx).file))?; + + let image_id = image.read(cx).id; + if let Some(entry_id) = file.entry_id { + match self.local_image_ids_by_entry_id.get(&entry_id) { + Some(_) => { + return None; + } + None => { + self.local_image_ids_by_entry_id.insert(entry_id, image_id); + } + } + }; + self.local_image_ids_by_path.insert( + ProjectPath { + worktree_id: file.worktree_id(cx), + path: file.path.clone(), + }, + image_id, + ); + + Some(()) + } +} + +fn create_gpui_image(content: Vec) -> anyhow::Result> { + let format = image::guess_format(&content)?; + + Ok(Arc::new(gpui::Image { + id: hash(&content), + format: match format { + image::ImageFormat::Png => gpui::ImageFormat::Png, + image::ImageFormat::Jpeg => gpui::ImageFormat::Jpeg, + image::ImageFormat::WebP => gpui::ImageFormat::Webp, + image::ImageFormat::Gif => gpui::ImageFormat::Gif, + image::ImageFormat::Bmp => gpui::ImageFormat::Bmp, + image::ImageFormat::Tiff => gpui::ImageFormat::Tiff, + _ => Err(anyhow::anyhow!("Image format not supported"))?, + }, + bytes: content, + })) +} + +impl ImageStoreImpl for Model { + fn open_image( + &self, + _path: Arc, + _worktree: Model, + _cx: &mut ModelContext, + ) -> Task>> { + Task::ready(Err(anyhow::anyhow!( + "Opening images from remote is not supported" + ))) + } + + fn reload_images( + &self, + _images: HashSet>, + _cx: &mut ModelContext, + ) -> Task> { + Task::ready(Err(anyhow::anyhow!( + "Reloading images from remote is not supported" + ))) + } + + fn as_local(&self) -> Option> { + None + } +} diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index 4ef78ff5b8..48c88d46ac 100644 --- a/crates/project/src/project.rs +++ b/crates/project/src/project.rs @@ -2,6 +2,7 @@ pub mod buffer_store; mod color_extractor; pub mod connection_manager; pub mod debounced_delay; +pub mod image_store; pub mod lsp_command; pub mod lsp_ext_command; pub mod lsp_store; @@ -35,6 +36,8 @@ use futures::{ future::try_join_all, StreamExt, }; +pub use image_store::{ImageItem, ImageStore}; +use image_store::{ImageItemEvent, ImageStoreEvent}; use git::{blame::Blame, repository::GitRepository}; use gpui::{ @@ -146,6 +149,7 @@ pub struct Project { client_subscriptions: Vec, worktree_store: Model, buffer_store: Model, + image_store: Model, lsp_store: Model, _subscriptions: Vec, buffers_needing_diff: HashSet>, @@ -205,10 +209,11 @@ enum BufferOrderedMessage { #[derive(Debug)] enum ProjectClientState { + /// Single-player mode. Local, - Shared { - remote_id: u64, - }, + /// Multi-player mode but still a local project. + Shared { remote_id: u64 }, + /// Multi-player mode but working on a remote project. Remote { sharing_has_stopped: bool, capability: Capability, @@ -606,6 +611,10 @@ impl Project { cx.subscribe(&buffer_store, Self::on_buffer_store_event) .detach(); + let image_store = cx.new_model(|cx| ImageStore::local(worktree_store.clone(), cx)); + cx.subscribe(&image_store, Self::on_image_store_event) + .detach(); + let prettier_store = cx.new_model(|cx| { PrettierStore::new( node.clone(), @@ -666,6 +675,7 @@ impl Project { collaborators: Default::default(), worktree_store, buffer_store, + image_store, lsp_store, join_project_response_message_id: 0, client_state: ProjectClientState::Local, @@ -729,6 +739,14 @@ impl Project { cx, ) }); + let image_store = cx.new_model(|cx| { + ImageStore::remote( + worktree_store.clone(), + ssh.read(cx).proto_client(), + SSH_PROJECT_ID, + cx, + ) + }); cx.subscribe(&buffer_store, Self::on_buffer_store_event) .detach(); @@ -774,6 +792,7 @@ impl Project { collaborators: Default::default(), worktree_store, buffer_store, + image_store, lsp_store, join_project_response_message_id: 0, client_state: ProjectClientState::Local, @@ -920,6 +939,9 @@ impl Project { let buffer_store = cx.new_model(|cx| { BufferStore::remote(worktree_store.clone(), client.clone().into(), remote_id, cx) })?; + let image_store = cx.new_model(|cx| { + ImageStore::remote(worktree_store.clone(), client.clone().into(), remote_id, cx) + })?; let lsp_store = cx.new_model(|cx| { let mut lsp_store = LspStore::new_remote( @@ -982,6 +1004,7 @@ impl Project { let mut this = Self { buffer_ordered_messages_tx: tx, buffer_store: buffer_store.clone(), + image_store, worktree_store: worktree_store.clone(), lsp_store: lsp_store.clone(), active_entry: None, @@ -1783,7 +1806,7 @@ impl Project { path: impl Into, cx: &mut ModelContext, ) -> Task>> { - if (self.is_via_collab() || self.is_via_ssh()) && self.is_disconnected(cx) { + if self.is_disconnected(cx) { return Task::ready(Err(anyhow!(ErrorCode::Disconnected))); } @@ -1879,6 +1902,20 @@ impl Project { Ok(()) } + pub fn open_image( + &mut self, + path: impl Into, + cx: &mut ModelContext, + ) -> Task>> { + if self.is_disconnected(cx) { + return Task::ready(Err(anyhow!(ErrorCode::Disconnected))); + } + + self.image_store.update(cx, |image_store, cx| { + image_store.open_image(path.into(), cx) + }) + } + async fn send_buffer_ordered_messages( this: WeakModel, rx: UnboundedReceiver, @@ -2013,6 +2050,22 @@ impl Project { } } + fn on_image_store_event( + &mut self, + _: Model, + event: &ImageStoreEvent, + cx: &mut ModelContext, + ) { + match event { + ImageStoreEvent::ImageAdded(image) => { + cx.subscribe(image, |this, image, event, cx| { + this.on_image_event(image, event, cx); + }) + .detach(); + } + } + } + fn on_lsp_store_event( &mut self, _: Model, @@ -2253,6 +2306,25 @@ impl Project { None } + fn on_image_event( + &mut self, + image: Model, + event: &ImageItemEvent, + cx: &mut ModelContext, + ) -> Option<()> { + match event { + ImageItemEvent::ReloadNeeded => { + if !self.is_via_collab() { + self.reload_images([image.clone()].into_iter().collect(), cx) + .detach_and_log_err(cx); + } + } + _ => {} + } + + None + } + fn request_buffer_diff_recalculation( &mut self, buffer: &Model, @@ -2466,6 +2538,15 @@ impl Project { }) } + pub fn reload_images( + &self, + images: HashSet>, + cx: &mut ModelContext, + ) -> Task> { + self.image_store + .update(cx, |image_store, cx| image_store.reload_images(images, cx)) + } + pub fn format( &mut self, buffers: HashSet>, diff --git a/crates/worktree/src/worktree.rs b/crates/worktree/src/worktree.rs index 1d1ad7da83..28b23d2fa7 100644 --- a/crates/worktree/src/worktree.rs +++ b/crates/worktree/src/worktree.rs @@ -103,6 +103,11 @@ pub struct LoadedFile { pub diff_base: Option, } +pub struct LoadedBinaryFile { + pub file: Arc, + pub content: Vec, +} + pub struct LocalWorktree { snapshot: LocalSnapshot, scan_requests_tx: channel::Sender, @@ -685,6 +690,19 @@ impl Worktree { } } + pub fn load_binary_file( + &self, + path: &Path, + cx: &ModelContext, + ) -> Task> { + match self { + Worktree::Local(this) => this.load_binary_file(path, cx), + Worktree::Remote(_) => { + Task::ready(Err(anyhow!("remote worktrees can't yet load binary files"))) + } + } + } + pub fn write_file( &self, path: &Path, @@ -1260,6 +1278,53 @@ impl LocalWorktree { self.git_repositories.get(&repo.work_directory.0) } + fn load_binary_file( + &self, + path: &Path, + cx: &ModelContext, + ) -> Task> { + let path = Arc::from(path); + let abs_path = self.absolutize(&path); + let fs = self.fs.clone(); + let entry = self.refresh_entry(path.clone(), None, cx); + let is_private = self.is_path_private(path.as_ref()); + + let worktree = cx.weak_model(); + cx.background_executor().spawn(async move { + let abs_path = abs_path?; + let content = fs.load_bytes(&abs_path).await?; + + let worktree = worktree + .upgrade() + .ok_or_else(|| anyhow!("worktree was dropped"))?; + let file = match entry.await? { + Some(entry) => File::for_entry(entry, worktree), + None => { + let metadata = fs + .metadata(&abs_path) + .await + .with_context(|| { + format!("Loading metadata for excluded file {abs_path:?}") + })? + .with_context(|| { + format!("Excluded file {abs_path:?} got removed during loading") + })?; + Arc::new(File { + entry_id: None, + worktree, + path, + mtime: Some(metadata.mtime), + is_local: true, + is_deleted: false, + is_private, + }) + } + }; + + Ok(LoadedBinaryFile { file, content }) + }) + } + fn load_file(&self, path: &Path, cx: &ModelContext) -> Task> { let path = Arc::from(path); let abs_path = self.absolutize(&path); @@ -3213,6 +3278,14 @@ impl language::LocalFile for File { cx.background_executor() .spawn(async move { fs.load(&abs_path?).await }) } + + fn load_bytes(&self, cx: &AppContext) -> Task>> { + let worktree = self.worktree.read(cx).as_local().unwrap(); + let abs_path = worktree.absolutize(&self.path); + let fs = worktree.fs.clone(); + cx.background_executor() + .spawn(async move { fs.load_bytes(&abs_path?).await }) + } } impl File {