Add basic ico support (#40822)

Kirill Bulatov created

Closes https://github.com/zed-industries/zed/discussions/40763

<img width="867" height="1088" alt="Screenshot 2025-10-21 at 23 14 47"
src="https://github.com/user-attachments/assets/d691fb2a-afc6-4445-a335-054ef164e0d3"
/>

Also improves error handling on image open failure:

<img width="864" height="1083" alt="Screenshot 2025-10-21 at 23 14 30"
src="https://github.com/user-attachments/assets/d5388b61-995f-441b-b375-ad5136d1533b"
/>


Release Notes:

- Added basic ico support, improved unsupported image handling

Change summary

crates/editor/src/editor_tests.rs               |   4 
crates/editor/src/items.rs                      |   6 
crates/gpui/src/platform.rs                     |   5 
crates/gpui/src/platform/linux/x11/clipboard.rs |   2 
crates/gpui/src/platform/mac/platform.rs        |   6 
crates/image_viewer/src/image_viewer.rs         |  16 ++
crates/project/src/image_store.rs               |   1 
crates/project/src/invalid_item_view.rs         |  13 +
crates/repl/src/outputs/image.rs                |   1 
crates/workspace/src/invalid_item_view.rs       | 117 +++++++++++++++++++
crates/workspace/src/item.rs                    |   4 
crates/workspace/src/pane.rs                    |   6 
crates/workspace/src/workspace.rs               |   2 
13 files changed, 166 insertions(+), 17 deletions(-)

Detailed changes

crates/editor/src/editor_tests.rs 🔗

@@ -62,7 +62,7 @@ use util::{
 use workspace::{
     CloseActiveItem, CloseAllItems, CloseOtherItems, MoveItemToPaneInDirection, NavigationEntry,
     OpenOptions, ViewId,
-    invalid_buffer_view::InvalidBufferView,
+    invalid_item_view::InvalidItemView,
     item::{FollowEvent, FollowableItem, Item, ItemHandle, SaveOptions},
     register_project_item,
 };
@@ -26251,7 +26251,7 @@ async fn test_non_utf_8_opens(cx: &mut TestAppContext) {
 
     assert_eq!(
         handle.to_any().entity_type(),
-        TypeId::of::<InvalidBufferView>()
+        TypeId::of::<InvalidItemView>()
     );
 }
 

crates/editor/src/items.rs 🔗

@@ -42,7 +42,7 @@ use ui::{IconDecorationKind, prelude::*};
 use util::{ResultExt, TryFutureExt, paths::PathExt};
 use workspace::{
     CollaboratorId, ItemId, ItemNavHistory, ToolbarItemLocation, ViewId, Workspace, WorkspaceId,
-    invalid_buffer_view::InvalidBufferView,
+    invalid_item_view::InvalidItemView,
     item::{FollowableItem, Item, ItemBufferKind, ItemEvent, ProjectItem, SaveOptions},
     searchable::{
         Direction, FilteredSearchRange, SearchEvent, SearchableItem, SearchableItemHandle,
@@ -1392,8 +1392,8 @@ impl ProjectItem for Editor {
         e: &anyhow::Error,
         window: &mut Window,
         cx: &mut App,
-    ) -> Option<InvalidBufferView> {
-        Some(InvalidBufferView::new(abs_path, is_local, e, window, cx))
+    ) -> Option<InvalidItemView> {
+        Some(InvalidItemView::new(abs_path, is_local, e, window, cx))
     }
 }
 

crates/gpui/src/platform.rs 🔗

@@ -1644,6 +1644,8 @@ pub enum ImageFormat {
     Bmp,
     /// .tif or .tiff
     Tiff,
+    /// .ico
+    Ico,
 }
 
 impl ImageFormat {
@@ -1657,6 +1659,7 @@ impl ImageFormat {
             ImageFormat::Svg => "image/svg+xml",
             ImageFormat::Bmp => "image/bmp",
             ImageFormat::Tiff => "image/tiff",
+            ImageFormat::Ico => "image/ico",
         }
     }
 
@@ -1670,6 +1673,7 @@ impl ImageFormat {
             "image/svg+xml" => Some(Self::Svg),
             "image/bmp" => Some(Self::Bmp),
             "image/tiff" | "image/tif" => Some(Self::Tiff),
+            "image/ico" => Some(Self::Ico),
             _ => None,
         }
     }
@@ -1776,6 +1780,7 @@ impl Image {
             ImageFormat::Webp => frames_for_image(&self.bytes, image::ImageFormat::WebP)?,
             ImageFormat::Bmp => frames_for_image(&self.bytes, image::ImageFormat::Bmp)?,
             ImageFormat::Tiff => frames_for_image(&self.bytes, image::ImageFormat::Tiff)?,
+            ImageFormat::Ico => frames_for_image(&self.bytes, image::ImageFormat::Ico)?,
             ImageFormat::Svg => {
                 let pixmap = svg_renderer.render_pixmap(&self.bytes, SvgSize::ScaleFactor(1.0))?;
 

crates/gpui/src/platform/linux/x11/clipboard.rs 🔗

@@ -86,6 +86,7 @@ x11rb::atom_manager! {
         SVG__MIME: ImageFormat::mime_type(ImageFormat::Svg ).as_bytes(),
         BMP__MIME: ImageFormat::mime_type(ImageFormat::Bmp ).as_bytes(),
         TIFF_MIME: ImageFormat::mime_type(ImageFormat::Tiff).as_bytes(),
+        ICO__MIME: ImageFormat::mime_type(ImageFormat::Ico ).as_bytes(),
 
         // This is just some random name for the property on our window, into which
         // the clipboard owner writes the data we requested.
@@ -1003,6 +1004,7 @@ impl Clipboard {
             ImageFormat::Svg => self.inner.atoms.SVG__MIME,
             ImageFormat::Bmp => self.inner.atoms.BMP__MIME,
             ImageFormat::Tiff => self.inner.atoms.TIFF_MIME,
+            ImageFormat::Ico => self.inner.atoms.ICO__MIME,
         };
         let data = vec![ClipboardData {
             bytes: image.bytes,

crates/gpui/src/platform/mac/platform.rs 🔗

@@ -1607,6 +1607,7 @@ impl From<ImageFormat> for UTType {
             ImageFormat::Gif => Self::gif(),
             ImageFormat::Bmp => Self::bmp(),
             ImageFormat::Svg => Self::svg(),
+            ImageFormat::Ico => Self::ico(),
         }
     }
 }
@@ -1645,6 +1646,11 @@ impl UTType {
         Self(unsafe { ns_string("public.svg-image") })
     }
 
+    pub fn ico() -> Self {
+        // https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/ico
+        Self(unsafe { ns_string("com.microsoft.ico") })
+    }
+
     pub fn tiff() -> Self {
         // https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/tiff
         Self(unsafe { NSPasteboardTypeTIFF }) // This is a rare case where there's a built-in NSPasteboardType

crates/image_viewer/src/image_viewer.rs 🔗

@@ -1,6 +1,8 @@
 mod image_info;
 mod image_viewer_settings;
 
+use std::path::Path;
+
 use anyhow::Context as _;
 use editor::{EditorSettings, items::entry_git_aware_label_color};
 use file_icons::FileIcons;
@@ -18,6 +20,7 @@ use ui::prelude::*;
 use util::paths::PathExt;
 use workspace::{
     ItemId, ItemSettings, Pane, ToolbarItemLocation, Workspace, WorkspaceId, delete_unloaded_items,
+    invalid_item_view::InvalidItemView,
     item::{BreadcrumbText, Item, ProjectItem, SerializableItem, TabContentParams},
 };
 
@@ -389,6 +392,19 @@ impl ProjectItem for ImageView {
     {
         Self::new(item, project, window, cx)
     }
+
+    fn for_broken_project_item(
+        abs_path: &Path,
+        is_local: bool,
+        e: &anyhow::Error,
+        window: &mut Window,
+        cx: &mut App,
+    ) -> Option<InvalidItemView>
+    where
+        Self: Sized,
+    {
+        Some(InvalidItemView::new(abs_path, is_local, e, window, cx))
+    }
 }
 
 pub fn init(cx: &mut App) {

crates/project/src/image_store.rs 🔗

@@ -687,6 +687,7 @@ fn create_gpui_image(content: Vec<u8>) -> anyhow::Result<Arc<gpui::Image>> {
             image::ImageFormat::Gif => gpui::ImageFormat::Gif,
             image::ImageFormat::Bmp => gpui::ImageFormat::Bmp,
             image::ImageFormat::Tiff => gpui::ImageFormat::Tiff,
+            image::ImageFormat::Ico => gpui::ImageFormat::Ico,
             format => anyhow::bail!("Image format {format:?} not supported"),
         },
         content,

crates/workspace/src/invalid_buffer_view.rs → crates/project/src/invalid_item_view.rs 🔗

@@ -11,7 +11,8 @@ use zed_actions::workspace::OpenWithSystem;
 use crate::Item;
 
 /// A view to display when a certain buffer fails to open.
-pub struct InvalidBufferView {
+#[derive(Debug)]
+pub struct InvalidItemView {
     /// Which path was attempted to open.
     pub abs_path: Arc<Path>,
     /// An error message, happened when opening the buffer.
@@ -20,7 +21,7 @@ pub struct InvalidBufferView {
     focus_handle: FocusHandle,
 }
 
-impl InvalidBufferView {
+impl InvalidItemView {
     pub fn new(
         abs_path: &Path,
         is_local: bool,
@@ -37,7 +38,7 @@ impl InvalidBufferView {
     }
 }
 
-impl Item for InvalidBufferView {
+impl Item for InvalidItemView {
     type Event = ();
 
     fn tab_content_text(&self, mut detail: usize, _: &App) -> SharedString {
@@ -66,15 +67,15 @@ impl Item for InvalidBufferView {
     }
 }
 
-impl EventEmitter<()> for InvalidBufferView {}
+impl EventEmitter<()> for InvalidItemView {}
 
-impl Focusable for InvalidBufferView {
+impl Focusable for InvalidItemView {
     fn focus_handle(&self, _: &App) -> FocusHandle {
         self.focus_handle.clone()
     }
 }
 
-impl Render for InvalidBufferView {
+impl Render for InvalidItemView {
     fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl gpui::IntoElement {
         let abs_path = self.abs_path.clone();
         v_flex()

crates/repl/src/outputs/image.rs 🔗

@@ -51,6 +51,7 @@ impl ImageView {
             image::ImageFormat::WebP => ImageFormat::Webp,
             image::ImageFormat::Tiff => ImageFormat::Tiff,
             image::ImageFormat::Bmp => ImageFormat::Bmp,
+            image::ImageFormat::Ico => ImageFormat::Ico,
             format => {
                 anyhow::bail!("unsupported image format {format:?}");
             }

crates/workspace/src/invalid_item_view.rs 🔗

@@ -0,0 +1,117 @@
+use std::{path::Path, sync::Arc};
+
+use gpui::{EventEmitter, FocusHandle, Focusable};
+use ui::{
+    App, Button, ButtonCommon, ButtonStyle, Clickable, Context, FluentBuilder, InteractiveElement,
+    KeyBinding, Label, LabelCommon, LabelSize, ParentElement, Render, SharedString, Styled as _,
+    Window, h_flex, v_flex,
+};
+use zed_actions::workspace::OpenWithSystem;
+
+use crate::Item;
+
+/// A view to display when a certain buffer/image/other item fails to open.
+pub struct InvalidItemView {
+    /// Which path was attempted to open.
+    pub abs_path: Arc<Path>,
+    /// An error message, happened when opening the item.
+    pub error: SharedString,
+    is_local: bool,
+    focus_handle: FocusHandle,
+}
+
+impl InvalidItemView {
+    pub fn new(
+        abs_path: &Path,
+        is_local: bool,
+        e: &anyhow::Error,
+        _: &mut Window,
+        cx: &mut App,
+    ) -> Self {
+        Self {
+            is_local,
+            abs_path: Arc::from(abs_path),
+            error: format!("{}", e.root_cause()).into(),
+            focus_handle: cx.focus_handle(),
+        }
+    }
+}
+
+impl Item for InvalidItemView {
+    type Event = ();
+
+    fn tab_content_text(&self, mut detail: usize, _: &App) -> SharedString {
+        // Ensure we always render at least the filename.
+        detail += 1;
+
+        let path = self.abs_path.as_ref();
+
+        let mut prefix = path;
+        while detail > 0 {
+            if let Some(parent) = prefix.parent() {
+                prefix = parent;
+                detail -= 1;
+            } else {
+                break;
+            }
+        }
+
+        let path = if detail > 0 {
+            path
+        } else {
+            path.strip_prefix(prefix).unwrap_or(path)
+        };
+
+        SharedString::new(path.to_string_lossy())
+    }
+}
+
+impl EventEmitter<()> for InvalidItemView {}
+
+impl Focusable for InvalidItemView {
+    fn focus_handle(&self, _: &App) -> FocusHandle {
+        self.focus_handle.clone()
+    }
+}
+
+impl Render for InvalidItemView {
+    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl gpui::IntoElement {
+        let abs_path = self.abs_path.clone();
+        v_flex()
+            .size_full()
+            .track_focus(&self.focus_handle(cx))
+            .flex_none()
+            .justify_center()
+            .overflow_hidden()
+            .key_context("InvalidItem")
+            .child(
+                h_flex().size_full().justify_center().child(
+                    v_flex()
+                        .justify_center()
+                        .gap_2()
+                        .child(h_flex().justify_center().child("Could not open file"))
+                        .child(
+                            h_flex()
+                                .justify_center()
+                                .child(Label::new(self.error.clone()).size(LabelSize::Small)),
+                        )
+                        .when(self.is_local, |contents| {
+                            contents.child(
+                                h_flex().justify_center().child(
+                                    Button::new("open-with-system", "Open in Default App")
+                                        .on_click(move |_, _, cx| {
+                                            cx.open_with_system(&abs_path);
+                                        })
+                                        .style(ButtonStyle::Outlined)
+                                        .key_binding(KeyBinding::for_action(
+                                            &OpenWithSystem,
+                                            window,
+                                            cx,
+                                        )),
+                                ),
+                            )
+                        }),
+                ),
+            )
+    }
+}

crates/workspace/src/item.rs 🔗

@@ -1,7 +1,7 @@
 use crate::{
     CollaboratorId, DelayedDebouncedEditAction, FollowableViewRegistry, ItemNavHistory,
     SerializableItemRegistry, ToolbarItemLocation, ViewId, Workspace, WorkspaceId,
-    invalid_buffer_view::InvalidBufferView,
+    invalid_item_view::InvalidItemView,
     pane::{self, Pane},
     persistence::model::ItemId,
     searchable::SearchableItemHandle,
@@ -1062,7 +1062,7 @@ pub trait ProjectItem: Item {
         _e: &anyhow::Error,
         _window: &mut Window,
         _cx: &mut App,
-    ) -> Option<InvalidBufferView>
+    ) -> Option<InvalidItemView>
     where
         Self: Sized,
     {

crates/workspace/src/pane.rs 🔗

@@ -2,7 +2,7 @@ use crate::{
     CloseWindow, NewFile, NewTerminal, OpenInTerminal, OpenOptions, OpenTerminal, OpenVisible,
     SplitDirection, ToggleFileFinder, ToggleProjectSymbols, ToggleZoom, Workspace,
     WorkspaceItemBuilder,
-    invalid_buffer_view::InvalidBufferView,
+    invalid_item_view::InvalidItemView,
     item::{
         ActivateOnClose, ClosePosition, Item, ItemBufferKind, ItemHandle, ItemSettings,
         PreviewTabsSettings, ProjectItemKind, SaveOptions, ShowCloseButton, ShowDiagnostics,
@@ -992,11 +992,11 @@ impl Pane {
 
             let new_item = build_item(self, window, cx);
             // A special case that won't ever get a `project_entry_id` but has to be deduplicated nonetheless.
-            if let Some(invalid_buffer_view) = new_item.downcast::<InvalidBufferView>() {
+            if let Some(invalid_buffer_view) = new_item.downcast::<InvalidItemView>() {
                 let mut already_open_view = None;
                 let mut views_to_close = HashSet::default();
                 for existing_error_view in self
-                    .items_of_type::<InvalidBufferView>()
+                    .items_of_type::<InvalidItemView>()
                     .filter(|item| item.read(cx).abs_path == invalid_buffer_view.read(cx).abs_path)
                 {
                     if already_open_view.is_none()

crates/workspace/src/workspace.rs 🔗

@@ -1,6 +1,6 @@
 pub mod dock;
 pub mod history_manager;
-pub mod invalid_buffer_view;
+pub mod invalid_item_view;
 pub mod item;
 mod modal_layer;
 pub mod notifications;