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",
Will Bradley and Bennet created
Closes #11529
Release Notes:
- Fixed an issue where the image preview would not update when the
underlying file changed
---------
Co-authored-by: Bennet <bennet@zed.dev>
Cargo.lock | 2
crates/copilot/src/copilot.rs | 4
crates/gpui/src/elements/img.rs | 6
crates/image_viewer/Cargo.toml | 1
crates/image_viewer/src/image_viewer.rs | 172 +++----
crates/language/src/buffer.rs | 5
crates/project/Cargo.toml | 1
crates/project/src/image_store.rs | 584 +++++++++++++++++++++++++++
crates/project/src/project.rs | 89 +++
crates/worktree/src/worktree.rs | 73 +++
10 files changed, 834 insertions(+), 103 deletions(-)
@@ -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",
@@ -1272,5 +1272,9 @@ mod tests {
fn load(&self, _: &AppContext) -> Task<Result<String>> {
unimplemented!()
}
+
+ fn load_bytes(&self, _cx: &AppContext) -> Task<Result<Vec<u8>>> {
+ unimplemented!()
+ }
}
}
@@ -92,6 +92,12 @@ impl From<Arc<RenderImage>> for ImageSource {
}
}
+impl From<Arc<Image>> for ImageSource {
+ fn from(value: Arc<Image>) -> Self {
+ Self::Image(value)
+ }
+}
+
/// An image element.
pub struct Img {
interactivity: Interactivity,
@@ -21,4 +21,5 @@ project.workspace = true
settings.workspace = true
theme.workspace = true
ui.workspace = true
+util.workspace = true
workspace.workspace = true
@@ -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,
+pub struct ImageView {
+ image_item: Model<ImageItem>,
project: Model<Project>,
+ focus_handle: FocusHandle,
}
-impl project::Item for ImageItem {
- fn try_open(
- project: &Model<Project>,
- path: &ProjectPath,
- cx: &mut AppContext,
- ) -> Option<Task<gpui::Result<Model<Self>>>> {
- 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
+impl ImageView {
+ pub fn new(
+ image_item: Model<ImageItem>,
+ project: Model<Project>,
+ cx: &mut ViewContext<Self>,
+ ) -> Self {
+ cx.subscribe(&image_item, Self::on_image_event).detach();
+ Self {
+ image_item,
+ project,
+ focus_handle: cx.focus_handle(),
}
}
- fn entry_id(&self, _: &AppContext) -> Option<ProjectEntryId> {
- Some(self.id)
- }
-
- fn project_path(&self, _: &AppContext) -> Option<ProjectPath> {
- Some(self.project_path.clone())
+ fn on_image_event(
+ &mut self,
+ _: Model<ImageItem>,
+ event: &ImageItemEvent,
+ cx: &mut ViewContext<Self>,
+ ) {
+ match event {
+ ImageItemEvent::FileHandleChanged | ImageItemEvent::Reloaded => {
+ cx.emit(ImageViewEvent::TitleChanged);
+ cx.notify();
+ }
+ ImageItemEvent::ReloadNeeded => {}
+ }
}
}
-pub struct ImageView {
- image: Model<ImageItem>,
- focus_handle: FocusHandle,
+pub enum ImageViewEvent {
+ TitleChanged,
}
+impl EventEmitter<ImageViewEvent> 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<SharedString> {
+ 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<Icon> {
- 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<Vec<BreadcrumbText>> {
- 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;
-
- 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(),
- }))
- })?
+ let image_item = project
+ .update(&mut cx, |project, cx| project.open_image(project_path, cx))?
+ .await?;
+
+ 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<Self>,
) -> Option<Task<gpui::Result<()>>> {
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<Self>) -> 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<Pixels>, _, 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>,
+ project: Model<Project>,
item: Model<Self::Item>,
cx: &mut ViewContext<Self>,
) -> Self
where
Self: Sized,
{
- Self {
- image: item,
- focus_handle: cx.focus_handle(),
- }
+ Self::new(item, project, cx)
}
}
@@ -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<Result<String>>;
+ /// Loads the file's contents from disk.
+ fn load_bytes(&self, cx: &AppContext) -> Task<Result<Vec<u8>>>;
+
/// Returns true if the file should not be shared with collaborators.
fn is_private(&self, _: &AppContext) -> bool {
false
@@ -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
@@ -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<NonZeroU64> for ImageId {
+ fn from(id: NonZeroU64) -> Self {
+ ImageId(id)
+ }
+}
+
+pub enum ImageItemEvent {
+ ReloadNeeded,
+ Reloaded,
+ FileHandleChanged,
+}
+
+impl EventEmitter<ImageItemEvent> for ImageItem {}
+
+pub enum ImageStoreEvent {
+ ImageAdded(Model<ImageItem>),
+}
+
+impl EventEmitter<ImageStoreEvent> for ImageStore {}
+
+pub struct ImageItem {
+ pub id: ImageId,
+ pub file: Arc<dyn File>,
+ pub image: Arc<gpui::Image>,
+ reload_task: Option<Task<()>>,
+}
+
+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<Path> {
+ self.file.path()
+ }
+
+ fn file_updated(&mut self, new_file: Arc<dyn File>, cx: &mut ModelContext<Self>) {
+ 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<Self>) -> Option<oneshot::Receiver<()>> {
+ 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<Project>,
+ path: &ProjectPath,
+ cx: &mut AppContext,
+ ) -> Option<Task<gpui::Result<Model<Self>>>> {
+ 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<ProjectEntryId> {
+ worktree::File::from_dyn(Some(&self.file))?.entry_id
+ }
+
+ fn project_path(&self, cx: &AppContext) -> Option<ProjectPath> {
+ Some(self.project_path(cx).clone())
+ }
+}
+
+trait ImageStoreImpl {
+ fn open_image(
+ &self,
+ path: Arc<Path>,
+ worktree: Model<Worktree>,
+ cx: &mut ModelContext<ImageStore>,
+ ) -> Task<Result<Model<ImageItem>>>;
+
+ fn reload_images(
+ &self,
+ images: HashSet<Model<ImageItem>>,
+ cx: &mut ModelContext<ImageStore>,
+ ) -> Task<Result<()>>;
+
+ fn as_local(&self) -> Option<Model<LocalImageStore>>;
+}
+
+struct RemoteImageStore {}
+
+struct LocalImageStore {
+ local_image_ids_by_path: HashMap<ProjectPath, ImageId>,
+ local_image_ids_by_entry_id: HashMap<ProjectEntryId, ImageId>,
+ image_store: WeakModel<ImageStore>,
+ _subscription: Subscription,
+}
+
+pub struct ImageStore {
+ state: Box<dyn ImageStoreImpl>,
+ opened_images: HashMap<ImageId, WeakModel<ImageItem>>,
+ worktree_store: Model<WorktreeStore>,
+}
+
+impl ImageStore {
+ pub fn local(worktree_store: Model<WorktreeStore>, cx: &mut ModelContext<Self>) -> 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<WorktreeStore>,
+ _upstream_client: AnyProtoClient,
+ _remote_id: u64,
+ cx: &mut ModelContext<Self>,
+ ) -> Self {
+ Self {
+ state: Box::new(cx.new_model(|_| RemoteImageStore {})),
+ opened_images: Default::default(),
+ worktree_store,
+ }
+ }
+
+ pub fn images(&self) -> impl '_ + Iterator<Item = Model<ImageItem>> {
+ self.opened_images
+ .values()
+ .filter_map(|image| image.upgrade())
+ }
+
+ pub fn get(&self, image_id: ImageId) -> Option<Model<ImageItem>> {
+ self.opened_images
+ .get(&image_id)
+ .and_then(|image| image.upgrade())
+ }
+
+ pub fn get_by_path(&self, path: &ProjectPath, cx: &AppContext) -> Option<Model<ImageItem>> {
+ self.images()
+ .find(|image| &image.read(cx).project_path(cx) == path)
+ }
+
+ pub fn open_image(
+ &mut self,
+ project_path: ProjectPath,
+ cx: &mut ModelContext<Self>,
+ ) -> Task<Result<Model<ImageItem>>> {
+ 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<Model<ImageItem>>,
+ cx: &mut ModelContext<ImageStore>,
+ ) -> Task<Result<()>> {
+ if images.is_empty() {
+ return Task::ready(Ok(()));
+ }
+
+ self.state.reload_images(images, cx)
+ }
+
+ fn add_image(
+ &mut self,
+ image: Model<ImageItem>,
+ cx: &mut ModelContext<ImageStore>,
+ ) -> 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<ImageItem>,
+ event: &ImageItemEvent,
+ cx: &mut ModelContext<Self>,
+ ) {
+ 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<LocalImageStore> {
+ fn open_image(
+ &self,
+ path: Arc<Path>,
+ worktree: Model<Worktree>,
+ cx: &mut ModelContext<ImageStore>,
+ ) -> Task<Result<Model<ImageItem>>> {
+ 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<Model<ImageItem>>,
+ cx: &mut ModelContext<ImageStore>,
+ ) -> Task<Result<()>> {
+ 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<Model<LocalImageStore>> {
+ Some(self.clone())
+ }
+}
+
+impl LocalImageStore {
+ fn subscribe_to_worktree(&mut self, worktree: &Model<Worktree>, cx: &mut ModelContext<Self>) {
+ 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<Worktree>,
+ changes: &[(Arc<Path>, ProjectEntryId, PathChange)],
+ cx: &mut ModelContext<Self>,
+ ) {
+ 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<Path>,
+ worktree: &Model<worktree::Worktree>,
+ snapshot: &worktree::Snapshot,
+ cx: &mut ModelContext<Self>,
+ ) -> 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<ImageItem>, 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<u8>) -> anyhow::Result<Arc<gpui::Image>> {
+ 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<RemoteImageStore> {
+ fn open_image(
+ &self,
+ _path: Arc<Path>,
+ _worktree: Model<Worktree>,
+ _cx: &mut ModelContext<ImageStore>,
+ ) -> Task<Result<Model<ImageItem>>> {
+ Task::ready(Err(anyhow::anyhow!(
+ "Opening images from remote is not supported"
+ )))
+ }
+
+ fn reload_images(
+ &self,
+ _images: HashSet<Model<ImageItem>>,
+ _cx: &mut ModelContext<ImageStore>,
+ ) -> Task<Result<()>> {
+ Task::ready(Err(anyhow::anyhow!(
+ "Reloading images from remote is not supported"
+ )))
+ }
+
+ fn as_local(&self) -> Option<Model<LocalImageStore>> {
+ None
+ }
+}
@@ -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<client::Subscription>,
worktree_store: Model<WorktreeStore>,
buffer_store: Model<BufferStore>,
+ image_store: Model<ImageStore>,
lsp_store: Model<LspStore>,
_subscriptions: Vec<gpui::Subscription>,
buffers_needing_diff: HashSet<WeakModel<Buffer>>,
@@ -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<ProjectPath>,
cx: &mut ModelContext<Self>,
) -> Task<Result<Model<Buffer>>> {
- 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<ProjectPath>,
+ cx: &mut ModelContext<Self>,
+ ) -> Task<Result<Model<ImageItem>>> {
+ 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<Self>,
rx: UnboundedReceiver<BufferOrderedMessage>,
@@ -2013,6 +2050,22 @@ impl Project {
}
}
+ fn on_image_store_event(
+ &mut self,
+ _: Model<ImageStore>,
+ event: &ImageStoreEvent,
+ cx: &mut ModelContext<Self>,
+ ) {
+ 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<LspStore>,
@@ -2253,6 +2306,25 @@ impl Project {
None
}
+ fn on_image_event(
+ &mut self,
+ image: Model<ImageItem>,
+ event: &ImageItemEvent,
+ cx: &mut ModelContext<Self>,
+ ) -> 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<Buffer>,
@@ -2466,6 +2538,15 @@ impl Project {
})
}
+ pub fn reload_images(
+ &self,
+ images: HashSet<Model<ImageItem>>,
+ cx: &mut ModelContext<Self>,
+ ) -> Task<Result<()>> {
+ self.image_store
+ .update(cx, |image_store, cx| image_store.reload_images(images, cx))
+ }
+
pub fn format(
&mut self,
buffers: HashSet<Model<Buffer>>,
@@ -103,6 +103,11 @@ pub struct LoadedFile {
pub diff_base: Option<String>,
}
+pub struct LoadedBinaryFile {
+ pub file: Arc<File>,
+ pub content: Vec<u8>,
+}
+
pub struct LocalWorktree {
snapshot: LocalSnapshot,
scan_requests_tx: channel::Sender<ScanRequest>,
@@ -685,6 +690,19 @@ impl Worktree {
}
}
+ pub fn load_binary_file(
+ &self,
+ path: &Path,
+ cx: &ModelContext<Worktree>,
+ ) -> Task<Result<LoadedBinaryFile>> {
+ 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<Worktree>,
+ ) -> Task<Result<LoadedBinaryFile>> {
+ 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<Worktree>) -> Task<Result<LoadedFile>> {
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<Result<Vec<u8>>> {
+ 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 {