image viewer: Reuse existing tabs (#19717)

Bennet Bo Fenner , Kirill , and Mikayla created

Co-authored-by: Kirill <kirill@zed.dev>
Co-authored-by: Mikayla <mikayla@zed.dev>

Fixes #9896

Release Notes:

- Fixed an issue where clicking on an image inside the project panel
would not re-use an existing image tab

Co-authored-by: Kirill <kirill@zed.dev>
Co-authored-by: Mikayla <mikayla@zed.dev>

Change summary

crates/image_viewer/src/image_viewer.rs | 82 +++++++++++++++++++++-----
1 file changed, 66 insertions(+), 16 deletions(-)

Detailed changes

crates/image_viewer/src/image_viewer.rs 🔗

@@ -1,3 +1,4 @@
+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,
@@ -19,6 +20,7 @@ use workspace::{
 const IMAGE_VIEWER_KIND: &str = "ImageView";
 
 pub struct ImageItem {
+    id: ProjectEntryId,
     path: PathBuf,
     project_path: ProjectPath,
 }
@@ -48,9 +50,15 @@ impl project::Item for ImageItem {
                     .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 {
                     path: abs_path,
                     project_path: path,
+                    id,
                 })
             }))
         } else {
@@ -59,7 +67,7 @@ impl project::Item for ImageItem {
     }
 
     fn entry_id(&self, _: &AppContext) -> Option<ProjectEntryId> {
-        None
+        Some(self.id)
     }
 
     fn project_path(&self, _: &AppContext) -> Option<ProjectPath> {
@@ -68,18 +76,30 @@ impl project::Item for ImageItem {
 }
 
 pub struct ImageView {
-    path: PathBuf,
+    image: Model<ImageItem>,
     focus_handle: FocusHandle,
 }
 
 impl Item for ImageView {
     type Event = ();
 
-    fn tab_content(&self, params: TabContentParams, _cx: &WindowContext) -> AnyElement {
-        let title = self
-            .path
+    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))
+    }
+
+    fn is_singleton(&self, _cx: &AppContext) -> bool {
+        true
+    }
+
+    fn tab_content(&self, params: TabContentParams, cx: &WindowContext) -> AnyElement {
+        let path = &self.image.read(cx).path;
+        let title = path
             .file_name()
-            .unwrap_or_else(|| self.path.as_os_str())
+            .unwrap_or_else(|| path.as_os_str())
             .to_string_lossy()
             .to_string();
         Label::new(title)
@@ -90,9 +110,10 @@ impl Item for ImageView {
     }
 
     fn tab_icon(&self, cx: &WindowContext) -> Option<Icon> {
+        let path = &self.image.read(cx).path;
         ItemSettings::get_global(cx)
             .file_icons
-            .then(|| FileIcons::get_icon(self.path.as_path(), cx))
+            .then(|| FileIcons::get_icon(path.as_path(), cx))
             .flatten()
             .map(Icon::from_path)
     }
@@ -106,7 +127,7 @@ impl Item for ImageView {
         Self: Sized,
     {
         Some(cx.new_view(|cx| Self {
-            path: self.path.clone(),
+            image: self.image.clone(),
             focus_handle: cx.focus_handle(),
         }))
     }
@@ -118,7 +139,7 @@ impl SerializableItem for ImageView {
     }
 
     fn deserialize(
-        _project: Model<Project>,
+        project: Model<Project>,
         _workspace: WeakView<Workspace>,
         workspace_id: WorkspaceId,
         item_id: ItemId,
@@ -129,10 +150,38 @@ impl SerializableItem for ImageView {
                 .get_image_path(item_id, workspace_id)?
                 .ok_or_else(|| anyhow::anyhow!("No image path found"))?;
 
-            cx.new_view(|cx| ImageView {
-                path: image_path,
-                focus_handle: cx.focus_handle(),
-            })
+            let (worktree, relative_path) = project
+                .update(&mut cx, |project, cx| {
+                    project.find_or_create_worktree(image_path.clone(), false, cx)
+                })?
+                .await
+                .context("Path not found")?;
+            let worktree_id = worktree.update(&mut cx, |worktree, _cx| worktree.id())?;
+
+            let project_path = ProjectPath {
+                worktree_id,
+                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,
+                });
+
+                Ok(cx.new_view(|cx| ImageView {
+                    image,
+                    focus_handle: cx.focus_handle(),
+                }))
+            })?
         })
     }
 
@@ -154,7 +203,7 @@ impl SerializableItem for ImageView {
         let workspace_id = workspace.database_id()?;
 
         Some(cx.background_executor().spawn({
-            let image_path = self.path.clone();
+            let image_path = self.image.read(cx).path.clone();
             async move {
                 IMAGE_VIEWER
                     .save_image_path(item_id, workspace_id, image_path)
@@ -177,6 +226,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 checkered_background = |bounds: Bounds<Pixels>, _, cx: &mut WindowContext| {
             let square_size = 32.0;
 
@@ -233,7 +283,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(self.path.clone())
+                        img(image_path)
                             .object_fit(ObjectFit::ScaleDown)
                             .max_w_full()
                             .max_h_full(),
@@ -254,7 +304,7 @@ impl ProjectItem for ImageView {
         Self: Sized,
     {
         Self {
-            path: item.read(cx).path.clone(),
+            image: item,
             focus_handle: cx.focus_handle(),
         }
     }