Show file open error view instead of the modal (#36764)

Kirill Bulatov and Conrad Irwin created

Closes https://github.com/zed-industries/zed/issues/36672

Before:
either 
<img width="966" height="642" alt="image"
src="https://github.com/user-attachments/assets/7263ea3c-3d48-4f4d-be9e-16b24ca6f60b"
/>
(when opening from the project panel)

or

<img width="959" height="1019" alt="image"
src="https://github.com/user-attachments/assets/834041d4-f4d6-46db-b333-803169ec4803"
/>

(for the rest of the cases)

After:

<img width="2032" height="1167" alt="Screenshot 2025-08-22 at 19 34 10"
src="https://github.com/user-attachments/assets/1aa4530b-69f6-4c3a-8ea1-d4035dbb28da"
/>

(the unified error view)

Release Notes:

- Improved unsupported file opening in Zed

---------

Co-authored-by: Conrad Irwin <conrad.irwin@gmail.com>

Change summary

assets/keymaps/default-linux.json           |   9 +
assets/keymaps/default-macos.json           |   9 +
assets/keymaps/vim.json                     |   2 
crates/editor/src/editor_tests.rs           |  37 +++++++
crates/editor/src/items.rs                  |  11 ++
crates/project_panel/src/project_panel.rs   |   3 
crates/workspace/src/invalid_buffer_view.rs | 111 +++++++++++++++++++++++
crates/workspace/src/item.rs                |  18 +++
crates/workspace/src/pane.rs                | 100 +++++++++++++++-----
crates/workspace/src/workspace.rs           |  56 ++++++++--
crates/zed_actions/src/lib.rs               |   5 
11 files changed, 316 insertions(+), 45 deletions(-)

Detailed changes

assets/keymaps/default-linux.json 🔗

@@ -855,7 +855,7 @@
       "ctrl-backspace": ["project_panel::Delete", { "skip_prompt": false }],
       "ctrl-delete": ["project_panel::Delete", { "skip_prompt": false }],
       "alt-ctrl-r": "project_panel::RevealInFileManager",
-      "ctrl-shift-enter": "project_panel::OpenWithSystem",
+      "ctrl-shift-enter": "workspace::OpenWithSystem",
       "alt-d": "project_panel::CompareMarkedFiles",
       "shift-find": "project_panel::NewSearchInDirectory",
       "ctrl-alt-shift-f": "project_panel::NewSearchInDirectory",
@@ -1198,5 +1198,12 @@
       "alt-shift-l": "onboarding::SignIn",
       "alt-shift-a": "onboarding::OpenAccount"
     }
+  },
+  {
+    "context": "InvalidBuffer",
+    "use_key_equivalents": true,
+    "bindings": {
+      "ctrl-shift-enter": "workspace::OpenWithSystem"
+    }
   }
 ]

assets/keymaps/default-macos.json 🔗

@@ -915,7 +915,7 @@
       "cmd-backspace": ["project_panel::Trash", { "skip_prompt": true }],
       "cmd-delete": ["project_panel::Delete", { "skip_prompt": false }],
       "alt-cmd-r": "project_panel::RevealInFileManager",
-      "ctrl-shift-enter": "project_panel::OpenWithSystem",
+      "ctrl-shift-enter": "workspace::OpenWithSystem",
       "alt-d": "project_panel::CompareMarkedFiles",
       "cmd-alt-backspace": ["project_panel::Delete", { "skip_prompt": false }],
       "cmd-alt-shift-f": "project_panel::NewSearchInDirectory",
@@ -1301,5 +1301,12 @@
       "alt-tab": "onboarding::SignIn",
       "alt-shift-a": "onboarding::OpenAccount"
     }
+  },
+  {
+    "context": "InvalidBuffer",
+    "use_key_equivalents": true,
+    "bindings": {
+      "ctrl-shift-enter": "workspace::OpenWithSystem"
+    }
   }
 ]

assets/keymaps/vim.json 🔗

@@ -819,7 +819,7 @@
       "v": "project_panel::OpenPermanent",
       "p": "project_panel::Open",
       "x": "project_panel::RevealInFileManager",
-      "s": "project_panel::OpenWithSystem",
+      "s": "workspace::OpenWithSystem",
       "z d": "project_panel::CompareMarkedFiles",
       "] c": "project_panel::SelectNextGitEntry",
       "[ c": "project_panel::SelectPrevGitEntry",

crates/editor/src/editor_tests.rs 🔗

@@ -57,7 +57,9 @@ use util::{
 use workspace::{
     CloseActiveItem, CloseAllItems, CloseOtherItems, MoveItemToPaneInDirection, NavigationEntry,
     OpenOptions, ViewId,
+    invalid_buffer_view::InvalidBufferView,
     item::{FollowEvent, FollowableItem, Item, ItemHandle, SaveOptions},
+    register_project_item,
 };
 
 #[gpui::test]
@@ -24348,6 +24350,41 @@ async fn test_newline_replacement_in_single_line(cx: &mut TestAppContext) {
     });
 }
 
+#[gpui::test]
+async fn test_non_utf_8_opens(cx: &mut TestAppContext) {
+    init_test(cx, |_| {});
+
+    cx.update(|cx| {
+        register_project_item::<Editor>(cx);
+    });
+
+    let fs = FakeFs::new(cx.executor());
+    fs.insert_tree("/root1", json!({})).await;
+    fs.insert_file("/root1/one.pdf", vec![0xff, 0xfe, 0xfd])
+        .await;
+
+    let project = Project::test(fs, ["/root1".as_ref()], cx).await;
+    let (workspace, cx) =
+        cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
+
+    let worktree_id = project.update(cx, |project, cx| {
+        project.worktrees(cx).next().unwrap().read(cx).id()
+    });
+
+    let handle = workspace
+        .update_in(cx, |workspace, window, cx| {
+            let project_path = (worktree_id, "one.pdf");
+            workspace.open_path(project_path, None, true, window, cx)
+        })
+        .await
+        .unwrap();
+
+    assert_eq!(
+        handle.to_any().entity_type(),
+        TypeId::of::<InvalidBufferView>()
+    );
+}
+
 #[track_caller]
 fn extract_color_inlays(editor: &Editor, cx: &App) -> Vec<Rgba> {
     editor

crates/editor/src/items.rs 🔗

@@ -42,6 +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,
     item::{FollowableItem, Item, ItemEvent, ProjectItem, SaveOptions},
     searchable::{Direction, SearchEvent, SearchableItem, SearchableItemHandle},
 };
@@ -1401,6 +1402,16 @@ impl ProjectItem for Editor {
 
         editor
     }
+
+    fn for_broken_project_item(
+        abs_path: PathBuf,
+        is_local: bool,
+        e: &anyhow::Error,
+        window: &mut Window,
+        cx: &mut App,
+    ) -> Option<InvalidBufferView> {
+        Some(InvalidBufferView::new(abs_path, is_local, e, window, cx))
+    }
 }
 
 fn clip_ranges<'a>(

crates/project_panel/src/project_panel.rs 🔗

@@ -69,6 +69,7 @@ use workspace::{
     notifications::{DetachAndPromptErr, NotifyTaskExt},
 };
 use worktree::CreatedEntry;
+use zed_actions::workspace::OpenWithSystem;
 
 const PROJECT_PANEL_KEY: &str = "ProjectPanel";
 const NEW_ENTRY_ID: ProjectEntryId = ProjectEntryId::MAX;
@@ -255,8 +256,6 @@ actions!(
         RevealInFileManager,
         /// Removes the selected folder from the project.
         RemoveFromProject,
-        /// Opens the selected file with the system's default application.
-        OpenWithSystem,
         /// Cuts the selected file or directory.
         Cut,
         /// Pastes the previously cut or copied item.

crates/workspace/src/invalid_buffer_view.rs 🔗

@@ -0,0 +1,111 @@
+use std::{path::PathBuf, sync::Arc};
+
+use gpui::{EventEmitter, FocusHandle, Focusable};
+use ui::{
+    App, Button, ButtonCommon, ButtonStyle, Clickable, Context, FluentBuilder, InteractiveElement,
+    KeyBinding, 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 fails to open.
+pub struct InvalidBufferView {
+    /// Which path was attempted to open.
+    pub abs_path: Arc<PathBuf>,
+    /// An error message, happened when opening the buffer.
+    pub error: SharedString,
+    is_local: bool,
+    focus_handle: FocusHandle,
+}
+
+impl InvalidBufferView {
+    pub fn new(
+        abs_path: PathBuf,
+        is_local: bool,
+        e: &anyhow::Error,
+        _: &mut Window,
+        cx: &mut App,
+    ) -> Self {
+        Self {
+            is_local,
+            abs_path: Arc::new(abs_path),
+            error: format!("{e}").into(),
+            focus_handle: cx.focus_handle(),
+        }
+    }
+}
+
+impl Item for InvalidBufferView {
+    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_path();
+
+        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 InvalidBufferView {}
+
+impl Focusable for InvalidBufferView {
+    fn focus_handle(&self, _: &App) -> FocusHandle {
+        self.focus_handle.clone()
+    }
+}
+
+impl Render for InvalidBufferView {
+    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("InvalidBuffer")
+            .child(
+                h_flex().size_full().justify_center().child(
+                    v_flex()
+                        .justify_center()
+                        .gap_2()
+                        .child("Cannot display the file contents in Zed")
+                        .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,6 +1,7 @@
 use crate::{
     CollaboratorId, DelayedDebouncedEditAction, FollowableViewRegistry, ItemNavHistory,
     SerializableItemRegistry, ToolbarItemLocation, ViewId, Workspace, WorkspaceId,
+    invalid_buffer_view::InvalidBufferView,
     pane::{self, Pane},
     persistence::model::ItemId,
     searchable::SearchableItemHandle,
@@ -22,6 +23,7 @@ use std::{
     any::{Any, TypeId},
     cell::RefCell,
     ops::Range,
+    path::PathBuf,
     rc::Rc,
     sync::Arc,
     time::Duration,
@@ -1161,6 +1163,22 @@ pub trait ProjectItem: Item {
     ) -> Self
     where
         Self: Sized;
+
+    /// A fallback handler, which will be called after [`project::ProjectItem::try_open`] fails,
+    /// with the error from that failure as an argument.
+    /// Allows to open an item that can gracefully display and handle errors.
+    fn for_broken_project_item(
+        _abs_path: PathBuf,
+        _is_local: bool,
+        _e: &anyhow::Error,
+        _window: &mut Window,
+        _cx: &mut App,
+    ) -> Option<InvalidBufferView>
+    where
+        Self: Sized,
+    {
+        None
+    }
 }
 
 #[derive(Debug)]

crates/workspace/src/pane.rs 🔗

@@ -2,6 +2,7 @@ use crate::{
     CloseWindow, NewFile, NewTerminal, OpenInTerminal, OpenOptions, OpenTerminal, OpenVisible,
     SplitDirection, ToggleFileFinder, ToggleProjectSymbols, ToggleZoom, Workspace,
     WorkspaceItemBuilder,
+    invalid_buffer_view::InvalidBufferView,
     item::{
         ActivateOnClose, ClosePosition, Item, ItemHandle, ItemSettings, PreviewTabsSettings,
         ProjectItemKind, SaveOptions, ShowCloseButton, ShowDiagnostics, TabContentParams,
@@ -897,19 +898,43 @@ impl Pane {
                 }
             }
         }
-        if let Some((index, existing_item)) = existing_item {
-            // If the item is already open, and the item is a preview item
-            // and we are not allowing items to open as preview, mark the item as persistent.
-            if let Some(preview_item_id) = self.preview_item_id
-                && let Some(tab) = self.items.get(index)
-                && tab.item_id() == preview_item_id
-                && !allow_preview
-            {
-                self.set_preview_item_id(None, cx);
-            }
-            if activate {
-                self.activate_item(index, focus_item, focus_item, window, cx);
+
+        let set_up_existing_item =
+            |index: usize, pane: &mut Self, window: &mut Window, cx: &mut Context<Self>| {
+                // If the item is already open, and the item is a preview item
+                // and we are not allowing items to open as preview, mark the item as persistent.
+                if let Some(preview_item_id) = pane.preview_item_id
+                    && let Some(tab) = pane.items.get(index)
+                    && tab.item_id() == preview_item_id
+                    && !allow_preview
+                {
+                    pane.set_preview_item_id(None, cx);
+                }
+                if activate {
+                    pane.activate_item(index, focus_item, focus_item, window, cx);
+                }
+            };
+        let set_up_new_item = |new_item: Box<dyn ItemHandle>,
+                               destination_index: Option<usize>,
+                               pane: &mut Self,
+                               window: &mut Window,
+                               cx: &mut Context<Self>| {
+            if allow_preview {
+                pane.set_preview_item_id(Some(new_item.item_id()), cx);
             }
+            pane.add_item_inner(
+                new_item,
+                true,
+                focus_item,
+                activate,
+                destination_index,
+                window,
+                cx,
+            );
+        };
+
+        if let Some((index, existing_item)) = existing_item {
+            set_up_existing_item(index, self, window, cx);
             existing_item
         } else {
             // If the item is being opened as preview and we have an existing preview tab,
@@ -921,21 +946,46 @@ 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>() {
+                let mut already_open_view = None;
+                let mut views_to_close = HashSet::default();
+                for existing_error_view in self
+                    .items_of_type::<InvalidBufferView>()
+                    .filter(|item| item.read(cx).abs_path == invalid_buffer_view.read(cx).abs_path)
+                {
+                    if already_open_view.is_none()
+                        && existing_error_view.read(cx).error == invalid_buffer_view.read(cx).error
+                    {
+                        already_open_view = Some(existing_error_view);
+                    } else {
+                        views_to_close.insert(existing_error_view.item_id());
+                    }
+                }
 
-            if allow_preview {
-                self.set_preview_item_id(Some(new_item.item_id()), cx);
-            }
-            self.add_item_inner(
-                new_item.clone(),
-                true,
-                focus_item,
-                activate,
-                destination_index,
-                window,
-                cx,
-            );
+                let resulting_item = match already_open_view {
+                    Some(already_open_view) => {
+                        if let Some(index) = self.index_for_item_id(already_open_view.item_id()) {
+                            set_up_existing_item(index, self, window, cx);
+                        }
+                        Box::new(already_open_view) as Box<_>
+                    }
+                    None => {
+                        set_up_new_item(new_item.clone(), destination_index, self, window, cx);
+                        new_item
+                    }
+                };
 
-            new_item
+                self.close_items(window, cx, SaveIntent::Skip, |existing_item| {
+                    views_to_close.contains(&existing_item)
+                })
+                .detach();
+
+                resulting_item
+            } else {
+                set_up_new_item(new_item.clone(), destination_index, self, window, cx);
+                new_item
+            }
         }
     }
 

crates/workspace/src/workspace.rs 🔗

@@ -1,5 +1,6 @@
 pub mod dock;
 pub mod history_manager;
+pub mod invalid_buffer_view;
 pub mod item;
 mod modal_layer;
 pub mod notifications;
@@ -612,21 +613,49 @@ impl ProjectItemRegistry {
         );
         self.build_project_item_for_path_fns
             .push(|project, project_path, window, cx| {
+                let project_path = project_path.clone();
+                let abs_path = project.read(cx).absolute_path(&project_path, cx);
+                let is_local = project.read(cx).is_local();
                 let project_item =
-                    <T::Item as project::ProjectItem>::try_open(project, project_path, cx)?;
+                    <T::Item as project::ProjectItem>::try_open(project, &project_path, cx)?;
                 let project = project.clone();
-                Some(window.spawn(cx, async move |cx| {
-                    let project_item = project_item.await?;
-                    let project_entry_id: Option<ProjectEntryId> =
-                        project_item.read_with(cx, project::ProjectItem::entry_id)?;
-                    let build_workspace_item = Box::new(
-                        |pane: &mut Pane, window: &mut Window, cx: &mut Context<Pane>| {
-                            Box::new(cx.new(|cx| {
-                                T::for_project_item(project, Some(pane), project_item, window, cx)
-                            })) as Box<dyn ItemHandle>
+                Some(window.spawn(cx, async move |cx| match project_item.await {
+                    Ok(project_item) => {
+                        let project_item = project_item;
+                        let project_entry_id: Option<ProjectEntryId> =
+                            project_item.read_with(cx, project::ProjectItem::entry_id)?;
+                        let build_workspace_item = Box::new(
+                            |pane: &mut Pane, window: &mut Window, cx: &mut Context<Pane>| {
+                                Box::new(cx.new(|cx| {
+                                    T::for_project_item(
+                                        project,
+                                        Some(pane),
+                                        project_item,
+                                        window,
+                                        cx,
+                                    )
+                                })) as Box<dyn ItemHandle>
+                            },
+                        ) as Box<_>;
+                        Ok((project_entry_id, build_workspace_item))
+                    }
+                    Err(e) => match abs_path {
+                        Some(abs_path) => match cx.update(|window, cx| {
+                            T::for_broken_project_item(abs_path, is_local, &e, window, cx)
+                        })? {
+                            Some(broken_project_item_view) => {
+                                let build_workspace_item = Box::new(
+                                    move |_: &mut Pane, _: &mut Window, cx: &mut Context<Pane>| {
+                                        cx.new(|_| broken_project_item_view).boxed_clone()
+                                    },
+                                )
+                                    as Box<_>;
+                                Ok((None, build_workspace_item))
+                            }
+                            None => Err(e)?,
                         },
-                    ) as Box<_>;
-                    Ok((project_entry_id, build_workspace_item))
+                        None => Err(e)?,
+                    },
                 }))
             });
     }
@@ -3379,9 +3408,8 @@ impl Workspace {
         window: &mut Window,
         cx: &mut App,
     ) -> Task<Result<(Option<ProjectEntryId>, WorkspaceItemBuilder)>> {
-        let project = self.project().clone();
         let registry = cx.default_global::<ProjectItemRegistry>().clone();
-        registry.open_path(&project, &path, window, cx)
+        registry.open_path(self.project(), &path, window, cx)
     }
 
     pub fn find_project_item<T>(

crates/zed_actions/src/lib.rs 🔗

@@ -156,7 +156,10 @@ pub mod workspace {
             #[action(deprecated_aliases = ["editor::CopyPath", "outline_panel::CopyPath", "project_panel::CopyPath"])]
             CopyPath,
             #[action(deprecated_aliases = ["editor::CopyRelativePath", "outline_panel::CopyRelativePath", "project_panel::CopyRelativePath"])]
-            CopyRelativePath
+            CopyRelativePath,
+            /// Opens the selected file with the system's default application.
+            #[action(deprecated_aliases = ["project_panel::OpenWithSystem"])]
+            OpenWithSystem,
         ]
     );
 }