Fix workspace-absolute paths in markdown images (#52708)

danielaalves01 created

## Context

Previously, markdown images failed to load workspace-absolute paths.
This updates the image resolver to identify the active workspace root
directory. Paths which are workspace absolute are correctly resolved and
rendered. The added test covers a successful resolution.

This PR re-implements the fix that was originally proposed in my
previous PR, #52178.

## Fix


https://github.com/user-attachments/assets/d69644ea-06cc-4638-b4ee-ec9f3abbb1ed

## How to Review

Small PR - focus on two changes in the file
`crates/markdown_preview/src/markdown_preview_view.rs`:
- `fn render_markdown_element()` (lines ~583-590): added the logic to
determine the workspace_directory
- `fn resolve_preview_image()` (lines ~714-726): added
workspace_directory variable, and a verification to create the full path
when a path is workspace-absolute

One test was added, covering a successful resolution
(`resolves_workspace_absolute_preview_images`). This test was
implemented in the file
`crates/markdown_preview/src/markdown_preview_view.rs`.


## Self-Review Checklist:

- [x] I've reviewed my own diff for quality, security, and reliability
- [ ] Unsafe blocks (if any) have justifying comments
- [x] The content is consistent with the [UI/UX
checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)
- [x] Tests cover the new/changed behavior
- [x] Performance impact has been considered and is acceptable

Closes #46924 

Release Notes:

- Added workspace-absolute path detection in markdown files

Change summary

crates/markdown_preview/src/markdown_preview_view.rs | 86 +++++++++++++
1 file changed, 84 insertions(+), 2 deletions(-)

Detailed changes

crates/markdown_preview/src/markdown_preview_view.rs 🔗

@@ -580,6 +580,14 @@ impl MarkdownPreviewView {
             .as_ref()
             .map(|state| state.editor.clone());
 
+        let mut workspace_directory = None;
+        if let Some(workspace_entity) = self.workspace.upgrade() {
+            let project = workspace_entity.read(cx).project();
+            if let Some(tree) = project.read(cx).worktrees(cx).next() {
+                workspace_directory = Some(tree.read(cx).abs_path().to_path_buf());
+            }
+        }
+
         let mut markdown_element = MarkdownElement::new(
             self.markdown.clone(),
             MarkdownStyle::themed(MarkdownFont::Editor, window, cx),
@@ -593,7 +601,13 @@ impl MarkdownPreviewView {
         .show_root_block_markers()
         .image_resolver({
             let base_directory = self.base_directory.clone();
-            move |dest_url| resolve_preview_image(dest_url, base_directory.as_deref())
+            move |dest_url| {
+                resolve_preview_image(
+                    dest_url,
+                    base_directory.as_deref(),
+                    workspace_directory.as_deref(),
+                )
+            }
         })
         .on_url_click(move |url, window, cx| {
             open_preview_url(url, base_directory.clone(), &workspace, window, cx);
@@ -687,7 +701,11 @@ fn resolve_preview_path(url: &str, base_directory: Option<&Path>) -> Option<Path
     }
 }
 
-fn resolve_preview_image(dest_url: &str, base_directory: Option<&Path>) -> Option<ImageSource> {
+fn resolve_preview_image(
+    dest_url: &str,
+    base_directory: Option<&Path>,
+    workspace_directory: Option<&Path>,
+) -> Option<ImageSource> {
     if dest_url.starts_with("data:") {
         return None;
     }
@@ -702,6 +720,19 @@ fn resolve_preview_image(dest_url: &str, base_directory: Option<&Path>) -> Optio
         .map(|decoded| decoded.into_owned())
         .unwrap_or_else(|_| dest_url.to_string());
 
+    let decoded_path = Path::new(&decoded);
+
+    if let Ok(relative_path) = decoded_path.strip_prefix("/") {
+        if let Some(root) = workspace_directory {
+            let absolute_path = root.join(relative_path);
+            if absolute_path.exists() {
+                return Some(ImageSource::Resource(Resource::Path(Arc::from(
+                    absolute_path.as_path(),
+                ))));
+            }
+        }
+    }
+
     let path = if Path::new(&decoded).is_absolute() {
         PathBuf::from(decoded)
     } else {
@@ -778,6 +809,9 @@ impl Render for MarkdownPreviewView {
 
 #[cfg(test)]
 mod tests {
+    use crate::markdown_preview_view::ImageSource;
+    use crate::markdown_preview_view::Resource;
+    use crate::markdown_preview_view::resolve_preview_image;
     use anyhow::Result;
     use std::fs;
     use tempfile::TempDir;
@@ -819,6 +853,54 @@ mod tests {
         Ok(())
     }
 
+    #[test]
+    fn resolves_workspace_absolute_preview_images() -> Result<()> {
+        let temp_dir = TempDir::new()?;
+        let workspace_directory = temp_dir.path();
+
+        let base_directory = workspace_directory.join("docs");
+        fs::create_dir_all(&base_directory)?;
+
+        let image_file = workspace_directory.join("test_image.png");
+        fs::write(&image_file, "mock data")?;
+
+        let resolved_success = resolve_preview_image(
+            "/test_image.png",
+            Some(&base_directory),
+            Some(workspace_directory),
+        );
+
+        match resolved_success {
+            Some(ImageSource::Resource(Resource::Path(p))) => {
+                assert_eq!(p.as_ref(), image_file.as_path());
+            }
+            _ => panic!("Expected successful resolution to be a Resource::Path"),
+        }
+
+        let resolved_missing = resolve_preview_image(
+            "/missing_image.png",
+            Some(&base_directory),
+            Some(workspace_directory),
+        );
+
+        let expected_missing_path = if std::path::Path::new("/missing_image.png").is_absolute() {
+            std::path::PathBuf::from("/missing_image.png")
+        } else {
+            // join is to retain windows path prefix C:/
+            #[expect(clippy::join_absolute_paths)]
+            base_directory.join("/missing_image.png")
+        };
+
+        match resolved_missing {
+            Some(ImageSource::Resource(Resource::Path(p))) => {
+                assert_eq!(p.as_ref(), expected_missing_path.as_path());
+            }
+            _ => panic!("Expected missing file to fallback to a Resource::Path"),
+        }
+
+        Ok(())
+    }
+
     #[test]
     fn does_not_treat_web_links_as_preview_paths() {
         assert_eq!(resolve_preview_path("https://zed.dev", None), None);