Go back

Adding image info/metadata to Zed

Feb 10, 2025 ยท 21 min read

I love the Zed editor because it is a fine piece of software.

I gave it a first try some months ago, and I loved how performant it is. I could open more than one instance of the editor with ease, plus a whole lot of other things I can't mention here.

Feel free to check the article I wrote about my first experience here

Contributing to OSS reduces the barrier to entry

As I would frequently do, I went on GitHub and decided to check for issues with "good first issue" labels, and I found one that seemed like something I could just attempt, yunno? Earlier in November, I started learning how to write computer programs with the Rust programming language.

So, out of boredom and naive "curiosity", I decided to give this a shot, and as you would've guessed correctly, I was jumping from one error to the other trying to make sense of things and asking the right questions.

This comment from Jansol gave me a great headstart.

Retrieving image data from the active ImageView

Just as Jansol mentioned, I went through the code in the image_viewer crate and tried to make sense of things there. It took me a while to realize how the image path is rendered with GPUI โ€” a UI framework for Rust currently being developed internally โ€” in the BreadcrumbText

So, initially, the breadcrumbs function renders just the image path with the breadcrumbs_text_for_image function like so:

zed/crates/image_viewer/src/image_viewer.rs
fn breadcrumbs(&self, _theme: &Theme, cx: &AppContext) -> Option<Vec<BreadcrumbText>> {
  let text = breadcrumbs_text_for_image(self.project.read(cx), self.image_item.read(cx), cx);
 
  Some(vec![BreadcrumbText {
    text: text,
    highlights: None,
    font: None,
  }])
}

Since I needed to get the properties of an image. I included an external image crate from crates.io as a dependency which provided a couple of helper functions for interacting with any image. We'll take a look at that in the image_info function.

With that out of the way, breadcrumbs became this.

zed/crates/image_viewer/src/image_viewer.rs
fn breadcrumbs(&self, _theme: &Theme, cx: &AppContext) -> Option<Vec<BreadcrumbText>> {
  let text = breadcrumbs_text_for_image(self.project.read(cx), self.image_item.read(cx), cx);
 
  let img_info = image_info(self.image_item.read(cx), self.project.read(cx), cx)
    .map(|(width, height, size)| {
      format!(
        "{} | Dimension: {}x{} | Image size: {:.2} KB",
        text,
        width,
        height,
        size as f64 / 1024.0
      )
    })
    .unwrap_or_else(|err| format!("{} | Image info is not available: {}", text, err));
 
    Some(vec![BreadcrumbText {
      text: img_info,
      highlights: None,
      font: None,
    }])
}

Notice how the image_info function is assigned to the image_info variable. Because the function returns three values (width, height, and the file size) for now, I destructured these values and used the format! macro to concatenate the result in a string respectively.

On line 11, you'll notice how the size value is assigned a type f64 which is a primitive type for floating point numbers in Rust. There's another one called f32, both are 32 and 64 bits in size.

Since the default type for floating-point numbers in Rust is f64 I decided to use it because of its double-precision attribute which makes it accurate in terms of precision even on 32-bit CPUs.

The whole point of this type inference is so the Rust compiler doesn't complain about an unknown type since I'm trying to perform an arithmetic operation on that line โ€” size / 1024.0

In the snippet below, you'll see how I used the image crate to extract information about an image, and how I used the standard filesystem module std::fs to get the file size in the image_info function.

zed/crates/image_viewer/src/image_viewer.rs
use image::GenericImageView;
use std::fs::metadata;
 
fn image_info(
    image: &ImageItem,
    project: &Project,
    cx: &AppContext,
) -> Result<(u32, u32, u64), String> {
    let worktree = project
        .worktree_for_id(image.project_path(cx).worktree_id, cx)
        .ok_or_else(|| "Could not find worktree for image".to_string())?;
    let worktree_root = worktree.read(cx).abs_path();
 
    let path = if image.path().is_absolute() {
        image.path().to_path_buf()
    } else {
        worktree_root.join(image.path())
    };
 
    if !path.exists() {
        return Err(format!("File does not exist at path: {:?}", path));
    }
 
    let img = image::open(&path).map_err(|e| format!("Failed to open image: {}", e))?;
    let dimensions = img.dimensions();
 
    let file_metadata = metadata(&path).map_err(|e| format!("Cannot access image data: {}", e))?;
    let file_size = file_metadata.len();
 
    Ok((dimensions.0, dimensions.1, file_size))
}

In a nutshell, here's how the function works:

The image argument represents metadata about the image, including its path. To determine the actual file path, the function first checks if the image's path is absolute. If not, it combines the relative path of the image with a fallback value, worktree_root, which is derived from the project argument. This logic is handled in lines 14 through 18.

If the resolved path does not exist, the function performs an early return using the Err variant of the Result type:

if !path.exists() {
    return Err(format!("File does not exist at path: {:?}", path));
}

Since Rust is statically typed, the function's return type must be explicitly declared or inferred. In this case, the return type is Result<(u32, u32, u64), String>. This indicates that the function returns either a success value of type (u32, u32, u64)โ€”representing the width, height, and file size of the imageโ€”or an error value of type String.

The Result type is an enum with two variants:

  • Ok(T): Indicates success, carrying a value of type T.
  • Err(E): Indicates an error, carrying a value of type E.

For example, when the function successfully processes the image, it returns the dimensions and file size using the Ok variant:

Ok((dimensions.0, dimensions.1, file_size))

If an error occurs at any point (e.g., the file does not exist, cannot be opened, or metadata cannot be accessed), the function returns an appropriate error message wrapped in the Err variant.

The final return type of the function, (u32, u32, u64), consists of two unsigned 32-bit, and 64-bit integers representing the image width, height, and file size respectively.

Async I/O in Rust is the Ghetto!

With this change, I went on to open a pull request feeling so proud, only to be met with very insightful feedback from Zed contributors and maintainers. The first one was to add the color type information of an image which I went on to do by going through the image's crate documentation.

I found out that I can import the ColorType trait from the lib and use it to infer the color_type argument for the function below. By default the image crate returns the value in this form: 8-bit, 32-bit.

In a bid to make the value human-readable, I had to use Rust's pattern-matching approach to return custom text descriptions.

fn image_color_type_description(color_type: ColorType) -> &'static str {
    match color_type {
        ColorType::L8 => "Grayscale (8-bit)",
        ColorType::La8 => "Grayscale with Alpha (8-bit)",
        ColorType::Rgba8 => "PNG (32-bit color)",
        ColorType::Rgb8 => "RGB (24-bit color)",
        ColorType::Rgb16 => "RGB (48-bit color)",
        ColorType::Rgba16 => "PNG (64-bit color)",
        ColorType::L16 => "Grayscale (16-bit)",
        ColorType::La16 => "Grayscale with Alpha (16-bit)",
 
        _ => "unknown color type",
    }
}

See the function in use here:

let img_color_type = image_color_type_description(img.color());

Since my initial approach used the standard fs lib which is synchronous, I got a couple of feedbacks from Mikayla Maki, and one of them was to refactor image_info to use the internal Fs crate which is asynchronous.

At first, my initial approach saw me passing an extra argument, fs: Arc<dyn fs::Fs>, to image_info, which was a little bit okay, but not too good, because to access fs now, I had to create a new pointer like so:

let fs = Arc::new(fs::RealFs::default());

This is pretty much redundant, because fs can be gotten from the project property like this: let fs = project.fs().

Along the line in the review process, Mikayla also recommended using smol for some async operations, so we don't block the main (UI) thread when some actions, opening an image for example with image::open to obtain some data, runs.

let img = smol::unblock(move || image::open(&path_clone))
    .await
    .map_err(|e| format!("Failed to open image: {}", e))?;
 
let (width, height, color_type) =
    smol::unblock(move || -> Result<(u32, u32, &'static str), String> {
        let dimensions = img.dimensions();
        let img_color_type = image_color_type_description(img.color());
        Ok((dimensions.0, dimensions.1, img_color_type))
    })
    .await
    .map_err(|e| format!("Failed to process image: {}", e))?;

Because this feature went through a ton of insightful feedback/review process, a lot has changed in the code I wrote initially, So I'll mostly just introduce excerpts of code snippets going forward. You can always check the PR here

Dealing with lifetime errors in Rust

I had to find my way around the codebase most of the time by myself. One place I wasted a lot of time on was figuring out how image_info would eventually render the image metadata. After a couple of println!() statements here and there I found the function that opens an image in the editor.

It is an implementation of ProjectItem for the ImageItem struct in the project crate of Zed.

zed/crates/project/src/image_store.rs
impl ProjectItem 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 worktree_abs_path = project
            .read(cx)
            .worktree_for_id(path.worktree_id, cx)?
            .read(cx)
            .abs_path();
        let ext = worktree_abs_path
            .extension()
            .or_else(|| path.path.extension())
            .and_then(OsStr::to_str)
            .map(str::to_lowercase)
            .unwrap_or_default();
        let ext = ext.as_str();
 
        if Img::extensions().contains(&ext) && !ext.contains("svg") {
            Some(cx.spawn(|mut cx| async move {
                let image_model = project
                    .update(&mut cx, |project, cx| project.open_image(path, cx))?
                    .await?;
                Ok(image_model)
            }))
        } else {
            None
        }
    }
}

To call image_info, I had this tiny method, load_metadata in ImageItem, this would later get removed as it was part/cause of the problem I faced. Having discovered where I could invoke image_info, I went on to modify the content of try_open

if Img::extensions().contains(&ext) && !ext.contains("svg") {
    Some(cx.spawn(|mut cx| async move {
        let image_model = project
            .update(&mut cx, |project, cx| project.open_image(path, cx))?
                .await?;
            let project_clone = project.clone();
            if let Ok(()) = image_model.update(&mut cx, |image, cx| {
                let project_ref = project_clone.read(cx);
 
                if let Ok(metadata) =
                    futures::executor::block_on(image.load_metadata(&project_ref, cx))
                {
                    image.image_meta = Some(metadata)
                }
        }) {
            // image metadata should be available now
        }
 
        Ok(image_model)
    }))
} else {
    None
}

While this seemed fine at first, this approach is problematic as it'll end up blocking the UI until load_metadata returns the result from the future. One could say that futures in Rust are similar to Promises in JavaScript. The former is lazy. Rust's futures won't start unless they're polled or executed โ€” notice the futures::executor expression โ€” unlike Promises that are eager.

This particular block needed to go, and from Mikayla's suggestion, an ideal solution would be to:

...return it out of the .update(), then .await it, then call update() again to assign the results.

This was what I did, a couple of times, with different approaches, but, I'd always end up with a lifetime error and type mismatches where the function expects AppContext but is receiving AsyncAppContext. Recall that image_info is async in nature.

If I recall correctly, one of my approaches followed this pattern below.

let metadata_future = cx.update(|sync_cx| {
    let project_ref = project.read(sync_cx);
    ImageItem::image_info(&path, &project_ref, sync_cx)
})?;

And the error that followed was this:

rustc: lifetime may not live long enough
returning this value requires that '1 must outlive '2

This is expected because I created the project reference in the update() closure, and that value becomes invalid, once the closure exits. So basically, the compiler was trying to tell me that: The data behind my references ('1) doesnโ€™t live long enough for the future ('2), where:

  • '1 is the lifetime of the data (project_ref in this case).
  • '2 is the lifetime needed by metadata_future.

When I tried all that I could to fix this issue to no avail (skill issue, I guess), I reached out to tims, a serial contributor to Zed, and we scheduled a collab call. At first, we were unable to fix the issue, but later on, tims came up with a solution and sent a PR to my fork. When I saw what was done, I realized that the notable changes revolved around updates to the arguments that image_info expects.

The function went from looking like this:

async fn image_info(
    image: &ImageItem,
    project: &Project,
    cx: &AppContext,
) -> Result<ImageItemMeta, String>

To this

async fn image_info(
    image: Model<ImageItem>,
    project: Model<Project>,
    cx: &mut AsyncAppContext,
) -> Result<ImageItemMeta>

So, instead of using references to ImageItem and ProjectItem, the type annotations for image and project now use Model<T>, cx infers a mutable AysncAppContext now and the result of function now infers Result<ImageItemMeta> with anyhow's error handling instead of Result<ImageItemMeta, String> for errors.

With this refactor, we were able to call image_info safely in ProjectItem below.

crates/project/src/image_store.rs
if Img::extensions().contains(&ext) && !ext.contains("svg") {
    Some(cx.spawn(|mut cx| async move {
        let image_model = project
            .update(&mut cx, |project, cx| project.open_image(path, cx))?
                .await?;
        let image_metadata =
            Self::image_info(image_model.clone(), project, &mut cx).await?;
 
        image_model.update(&mut cx, |image_model, _| {
            image_model.image_meta = Some(image_metadata);
        })?;
 
        Ok(image_model)
    }))
} else {
    None
}

Rendering image metadata and making it customizable

This was the first part of the feature I worked on before anything. Because the image information needs to be displayed in Zed's status bar UI, I think Nate Butler and Mikayla suggested I take a look at the CursorPosition implementation, so there I went... for my dose of inspiration.

My first approach was, well, murky. But, I got the hang of it, eventually. I started by working on the implementation for the ImageInfo struct.

crates/image_viewer/src/image_info.rs
pub struct ImageInfo {
    width: Option<u32>,
    height: Option<u32>,
    file_size: Option<u64>,
    format: Option<String>,
    color_type: Option<String>,
    _observe_active_image: Option<Subscription>,
}
 
impl ImageInfo {
    pub fn new(_workspace: &Workspace, cx: &mut AppContext) -> Self {
        static INIT: std::sync::Once = std::sync::Once::new();
        INIT.call_once(|| {
            ImageFileSizeUnitType::register(cx);
        });
 
        Self {
            width: None,
            height: None,
            file_size: None,
            format: None,
            color_type: None,
            _observe_active_image: None,
        }
    }
 
    // ...find the rest of the code in the PR
}

On lines 12 through 15, my initial approach wasn't close to that and problematic at the same time. To register the unit_type, I called it in settings.rs, initially. This block specifically.

crates/settings/settings.rs
pub fn init(cx: &mut AppContext) {
    let mut settings = SettingsStore::new(cx);
    settings
        .set_default_settings(&default_settings(), cx)
        .unwrap();
    cx.set_global(settings);
 
    ImageFileSizeUnitType::register(cx);
}

But, that had to change based on feedback from Mikayla. So i moved it into ImageItem by trying to include a init function, only to run into the error below

Thread "main" panicked with "unregistered setting type image_viewer::image_info::ImageFileSizeUnitType"
  at /oss/zed/crates/settings/src/settings_store.rs:341:32
   0: zed::reliability::init_panic_hook::{{closure}}

To fix this issue, I ended up using call_once from std::sync like this, so it doesn't call ::register() everytime the statusItem mounts. The panic occurred because we tried to access ImageFileSizeUnitType before it was registered, this is somewhat related to a race condition in initialization order.

pub fn new(_workspace: &Workspace, cx: &mut AppContext) -> Self {
        static INIT: std::sync::Once = std::sync::Once::new();
        INIT.call_once(|| {
            ImageFileSizeUnitType::register(cx);
        });

Along the line, Angelk90 suggested that we give people the ability to choose how they want the unit of their image file size to be calculated. Apparently, the file sizes can be computed in two different ways โ€” Binary and Decimal. Where the file size, returned as a 64-bit floating point integer can be divided with 1024.0 or 1000.0

I think this issue is spanned from the fact that the computation for image file sizes on VScode and WebStorm differs. VScode uses the Binary approach while WebStorm uses Decimal.

To build this into the current feature, I went into the settings crate to kinda understand what is going on there. I got a little bit of what went on in the crate, especially settings_store.rs. Since we can pretty much customize editor settings in Zed via settings.json which accepts key-value pairs of configs, I searched for a particular key I have by default in my Zed config, and it pointed me to assets/settings/default.json

That was the solution I needed. I opened the file, found a couple of global settings in it, and included mine.

// The unit type for image file sizes.
// By default we're setting it to binary.
// The second option is decimal
"image_file_unit_type": "binary",

But, this didn't entirely cut it. To understand how these values are accessed, I head into the settings.rs file in the settings crate. Here's an excerpt of how the settings keymaps are read

crates/settings/src/settings.rs
pub fn default_settings() -> Cow<'static, str> {
    asset_str::<SettingsAssets>("settings/default.json")
}
 
#[cfg(target_os = "macos")]
pub const DEFAULT_KEYMAP_PATH: &str = "keymaps/default-macos.json";
 
#[cfg(not(target_os = "macos"))]
pub const DEFAULT_KEYMAP_PATH: &str = "keymaps/default-linux.json";
 
pub fn default_keymap() -> Cow<'static, str> {
    asset_str::<SettingsAssets>(DEFAULT_KEYMAP_PATH)
}
 
pub fn initial_user_settings_content() -> Cow<'static, str> {
    asset_str::<SettingsAssets>("settings/initial_user_settings.json")
}
// the remaining keymaps

With this established, I went back into settings_store. There, I found the Settings trait, which is what I'll need to read my key, image_file_unit_type, from the settings JSON.

Since image_file_unit_type can be of two types, Binary or Decimal, it is okay that I create an enum type to hold this value, and in the snippet below, you'll find the implementation for the file size too. In our case here, the file size unit is Binary by default.

crates/image_viewer/src/image_info.rs
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, Default)]
#[serde(rename_all = "snake_case")]
pub enum ImageFileSizeUnitType {
    #[default]
    Binary,
    Decimal,
}
 
impl Settings for ImageFileSizeUnitType {
    const KEY: Option<&'static str> = Some("image_file_unit_type");
 
    type FileContent = Self;
 
    fn load(
        sources: SettingsSources<Self::FileContent>,
        _: &mut AppContext,
    ) -> Result<Self, anyhow::Error> {
        sources.json_merge().or_else(|_| Ok(Self::Binary))
    }
}

And here's how I utilized it in the Render method for the StatusItemView

crates/image_viewer/src/image_info.rs
fn format_file_size(&self, size: u64, image_unit_type: &ImageFileSizeUnitType) -> String {
    match image_unit_type {
        ImageFileSizeUnitType::Binary => {
            if size < 1024 {
                format!("{}B", size)
            } else if size < 1024 * 1024 {
                format!("{:.1}KB", size as f64 / 1024.0)
            } else {
                format!("{:.1}MB", size as f64 / (1024.0 * 1024.0))
            }
        }
        ImageFileSizeUnitType::Decimal => {
            if size < 1000 {
                format!("{}B", size)
            } else if size < 1000 * 1000 {
                format!("{:.1}KB", size as f64 / 1000.0)
            } else {
                format!("{:.1}MB", size as f64 / (1000.0 * 1000.0))
            }
        }
    }
}
 
impl Render for ImageInfo {
    fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
        let unit_type = ImageFileSizeUnitType::get_global(cx);
 
        let components = [
            self.width
                .and_then(|w| self.height.map(|h| format!("{}x{}", w, h))),
            self.file_size.map(|s| self.format_file_size(s, unit_type)),
            self.color_type.clone(),
            self.format.clone(),
        ];
 
        let text = components
            .into_iter()
            .flatten()
            .collect::<Vec<_>>()
            .join(" โ€ข ");
 
        div().when(!text.is_empty(), |el| {
            el.child(Button::new("image-metadata", text).label_size(LabelSize::Small))
        })
    }
}

One thing I mustn't fail to mention is how compact and readable lines 28 through 40 are now. Previously, it was a bunch of if statements and a reassignment to the text variable. Something like this โ€” quite repetitive

if let (Some(width), Some(height)) = (self.width, self.height) {
    text.push_str(&format!("{}ร—{}", width, height));
}
 
if let Some(size) = self.file_size {
    if !text.is_empty() {
        text.push_str(" โ€ข ");
    }
    text.push_str(&Self::format_file_size(self, size, unit_type));
}
 
if let Some(size) = &self.color_type {
    if !text.is_empty() {
        text.push_str(" โ€ข ");
    }
    text.push_str(color_type);
}

By putting the values in a Vector of strings (I think this is quite similar to the concept of arrays in JavaScript. not so sure yet), I was able to iterate over the items in it and append the separator.

Below, is how the StatusItemView is implemented to show image information. It also includes an observer task that pretty much does something โ€” similar to how an effect is triggered based on the values in the dependency array in React โ€” whenever you open a new image.

impl StatusItemView for ImageInfo {
    fn set_active_pane_item(
        &mut self,
        active_pane_item: Option<&dyn ItemHandle>,
        cx: &mut ViewContext<Self>,
    ) {
        if let Some(image_view) = active_pane_item.and_then(|item| item.act_as::<ImageView>(cx)) {
            self.update_metadata(&image_view, cx);
            self._observe_active_image = Some(cx.observe(&image_view, |this, view, cx| {
                this.update_metadata(&view, cx);
            }));
        } else {
            self.width = None;
            self.height = None;
            self.file_size = None;
            self.color_type = None;
            self._observe_active_image = None;
        }
        cx.notify();
    }
}

Fixing inaccuracies in image color type info

I missed something from the get-go. Remember the image_color_type_description function where, for images with Rgba8 and Rgba16 color types, I was appending PNG to the description. This is wrong. I was confusing color types with image formats which are different things.

Super thankful that jansol provided an in-depth breakdown of this for me, and went on to elaborate on the concept of image color channels, bits per pixel ratio, etc. If you want to learn more about image encoding you can check this Native pixel formats overview by the Windows Imaging Component (WIC)

Going with the suggestion to use ExtendedColorType over ColorType due to its limitation as it doesn't cover all the available types, I went on to modify the function and it became this:

crates/projects/src/image_store.rs
fn image_color_type_description(color_type: ExtendedColorType) -> String {
    let (channels, bits_per_channel) = match color_type {
        ExtendedColorType::L8 => (1, 8),
        ExtendedColorType::L16 => (1, 16),
        ExtendedColorType::La8 => (2, 8),
        ExtendedColorType::La16 => (2, 16),
        ExtendedColorType::Rgb8 => (3, 8),
        ExtendedColorType::Rgb16 => (3, 16),
        ExtendedColorType::Rgba8 => (4, 8),
        ExtendedColorType::Rgba16 => (4, 16),
        ExtendedColorType::A8 => (1, 8),
        ExtendedColorType::Bgr8 => (3, 8),
        ExtendedColorType::Bgra8 => (4, 8),
        ExtendedColorType::Cmyk8 => (4, 8),
 
        _ => (0, 0),
    };
 
    if channels == 0 {
        "unknown color type".to_string()
    } else {
        let bits_per_pixel = channels * bits_per_channel;
        format!("{} channels, {} bits per pixel", channels, bits_per_pixel)
    }
}

To fix the confusion I had before with the image formats, I went to the docs of the image crate to see how it could be obtained and found out I could use the ImageReader that accepts the image bytes.

Because fs already provides a load_bytes method that I can use, I passed the image path to it and obtained the corresponding file byte which I passed to the reader.

crates/projects/src/image_store.rs
let img_bytes = fs
    .load_bytes(&path)
    .await
    .context("Could not load image bytes")?;
let img_format = image::guess_format(&img_bytes).context("Could not guess image format")?;
 
let img_format_str = match img_format {
    ImageFormat::Png => "PNG",
    ImageFormat::Jpeg => "JPEG",
    ImageFormat::Gif => "GIF",
    ImageFormat::WebP => "WebP",
    ImageFormat::Tiff => "TIFF",
    ImageFormat::Bmp => "BMP",
    ImageFormat::Ico => "ICO",
    ImageFormat::Avif => "Avif",
 
    _ => "Unknown",
};
let path_clone = path.clone();
let image_result = smol::unblock(move || ImageReader::open(&path_clone)?.decode()).await?;

Breaking Changes to GPUI and a couple more code style feedback.

Going through Twitter, I found a tweet from Jason Lee about GPUI's API having a major overhaul and how he's also updating a library of UI components based on GPUI to reflect these changes.

So I went through the Pull Request by Nathan Sobo and noticed from the PR description that a couple of APIs my implementation depends on have changed.

Noting these changes, I went on to update the respective functions; Now, image_info, for brevity, became this:

pub async fn image_info(
    image: Entity<ImageItem>,
    project: Entity<Project>,
    cx: &mut AsyncApp,
) -> Result<ImageMetadata>

If you want to learn more, you can always check the PR to see how all these changes were applied across the codebase.


Remember how I registered the setting that allows people to choose how they want their image sizes to be measured? I eventually, found the init call in ImageViewer after Mikayla recommended, again, that it should go in there, instead of using the INIT.call_once(|| { ImageFileSizeUnitType::register(cx) })

Now, it looks like this:

crates/image_viewer/src/image_viewer.rs
pub fn init(cx: &mut App) {
    workspace::register_project_item::<ImageView>(cx);
    workspace::register_serializable_item::<ImageView>(cx);
    ImageViewerSettings::register(cx);
}

When you take a look at the snippet above closely, you'll notice that the struct changed from ImageFileSizeUnitType to ImageViewerSettings which looks like this:

crates/image_viewer/src/image_info.rs
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, Default)]
pub struct ImageViewerSettings {
    #[serde(default)]
    unit_type: ImageFileSizeUnit,
}
 
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, Default)]
#[serde(rename_all = "snake_case")]
pub enum ImageFileSizeUnit {
    #[default]
    Binary,
    Decimal,
}

This change, suggested by Mikayla allows us to have more settings/options tailored to the image viewer. So now, instead of the former approach, people can open settings.json in Zed and include the snippet below to specify their specific unit of measurement for the image size.

"image_viewer": {
   "unit": "binary"
},

Curtains

When I did all I could to get the CI to pass, the test for the image viewer kept failing. Marshall Bowers came around and made a couple of cleanups โ€” formatting, fixing clippy errors, renaming some structs and getting CI to pass.

Working on this feature has helped me gain a lot of fundamental knowledge about Rust; signed and unsigned integers, mutable variables and references, closures, async IO, binary and decimal computation for file sizes, image encoding etc. I'd be on the lookout for other "good first issues" that I'll hack on next.

Huge thanks to everyone (Mikayla, tims, jansol, Angelk90, Nate) who helped with their awesome feedback. Here's to more!