Add a dedicated action to open files (#22625)

Cole Miller created

Closes #22531
Closes #22250
Closes #15679

Release Notes:

- Add `workspace::OpenFiles` action to enable opening individual files
on Linux and Windows

Change summary

crates/gpui/src/app.rs                       |   5 +
crates/gpui/src/platform.rs                  |   1 
crates/gpui/src/platform/linux/platform.rs   |   5 +
crates/gpui/src/platform/mac/platform.rs     |   4 
crates/gpui/src/platform/test/platform.rs    |   4 
crates/gpui/src/platform/windows/platform.rs |   5 +
crates/util/src/util.rs                      |   4 
crates/workspace/src/workspace.rs            | 105 ++++++++++++++-------
8 files changed, 95 insertions(+), 38 deletions(-)

Detailed changes

crates/gpui/src/app.rs 🔗

@@ -1418,6 +1418,11 @@ impl AppContext {
     pub fn get_name(&self) -> &'static str {
         self.name.as_ref().unwrap()
     }
+
+    /// Returns `true` if the platform file picker supports selecting a mix of files and directories.
+    pub fn can_select_mixed_files_and_dirs(&self) -> bool {
+        self.platform.can_select_mixed_files_and_dirs()
+    }
 }
 
 impl Context for AppContext {

crates/gpui/src/platform.rs 🔗

@@ -175,6 +175,7 @@ pub(crate) trait Platform: 'static {
         options: PathPromptOptions,
     ) -> oneshot::Receiver<Result<Option<Vec<PathBuf>>>>;
     fn prompt_for_new_path(&self, directory: &Path) -> oneshot::Receiver<Result<Option<PathBuf>>>;
+    fn can_select_mixed_files_and_dirs(&self) -> bool;
     fn reveal_path(&self, path: &Path);
     fn open_with_system(&self, path: &Path);
 

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

@@ -372,6 +372,11 @@ impl<P: LinuxClient + 'static> Platform for P {
         done_rx
     }
 
+    fn can_select_mixed_files_and_dirs(&self) -> bool {
+        // org.freedesktop.portal.FileChooser only supports "pick files" and "pick directories".
+        false
+    }
+
     fn reveal_path(&self, path: &Path) {
         self.reveal_path(path.to_owned());
     }

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

@@ -759,6 +759,10 @@ impl Platform for MacPlatform {
         done_rx
     }
 
+    fn can_select_mixed_files_and_dirs(&self) -> bool {
+        true
+    }
+
     fn reveal_path(&self, path: &Path) {
         unsafe {
             let path = path.to_path_buf();

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

@@ -299,6 +299,10 @@ impl Platform for TestPlatform {
         rx
     }
 
+    fn can_select_mixed_files_and_dirs(&self) -> bool {
+        true
+    }
+
     fn reveal_path(&self, _path: &std::path::Path) {
         unimplemented!()
     }

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

@@ -407,6 +407,11 @@ impl Platform for WindowsPlatform {
         rx
     }
 
+    fn can_select_mixed_files_and_dirs(&self) -> bool {
+        // The FOS_PICKFOLDERS flag toggles between "only files" and "only folders".
+        false
+    }
+
     fn reveal_path(&self, path: &Path) {
         let Ok(file_full_path) = path.canonicalize() else {
             log::error!("unable to parse file path");

crates/util/src/util.rs 🔗

@@ -449,6 +449,10 @@ where
     );
 }
 
+pub fn log_err<E: std::fmt::Debug>(error: &E) {
+    log_error_with_caller(*Location::caller(), error, log::Level::Warn);
+}
+
 pub trait TryFutureExt {
     fn log_err(self) -> LogErrorFuture<Self>
     where

crates/workspace/src/workspace.rs 🔗

@@ -34,10 +34,10 @@ use gpui::{
     action_as, actions, canvas, impl_action_as, impl_actions, point, relative, size,
     transparent_black, Action, AnyView, AnyWeakView, AppContext, AsyncAppContext,
     AsyncWindowContext, Bounds, CursorStyle, Decorations, DragMoveEvent, Entity as _, EntityId,
-    EventEmitter, Flatten, FocusHandle, FocusableView, Global, Hsla, KeyContext, Keystroke,
-    ManagedView, Model, ModelContext, MouseButton, PathPromptOptions, Point, PromptLevel, Render,
-    ResizeEdge, Size, Stateful, Subscription, Task, Tiling, View, WeakView, WindowBounds,
-    WindowHandle, WindowId, WindowOptions,
+    EventEmitter, FocusHandle, FocusableView, Global, Hsla, KeyContext, Keystroke, ManagedView,
+    Model, ModelContext, MouseButton, PathPromptOptions, Point, PromptLevel, Render, ResizeEdge,
+    Size, Stateful, Subscription, Task, Tiling, View, WeakView, WindowBounds, WindowHandle,
+    WindowId, WindowOptions,
 };
 pub use item::{
     FollowableItem, FollowableItemHandle, Item, ItemHandle, ItemSettings, PreviewTabsSettings,
@@ -145,6 +145,7 @@ actions!(
         NewTerminal,
         NewWindow,
         Open,
+        OpenFiles,
         OpenInTerminal,
         ReloadActiveItem,
         SaveAs,
@@ -332,6 +333,42 @@ pub fn init_settings(cx: &mut AppContext) {
     TabBarSettings::register(cx);
 }
 
+fn prompt_and_open_paths(
+    app_state: Arc<AppState>,
+    options: PathPromptOptions,
+    cx: &mut AppContext,
+) {
+    let paths = cx.prompt_for_paths(options);
+    cx.spawn(|cx| async move {
+        match paths.await.anyhow().and_then(|res| res) {
+            Ok(Some(paths)) => {
+                cx.update(|cx| {
+                    open_paths(&paths, app_state, OpenOptions::default(), cx).detach_and_log_err(cx)
+                })
+                .ok();
+            }
+            Ok(None) => {}
+            Err(err) => {
+                util::log_err(&err);
+                cx.update(|cx| {
+                    if let Some(workspace_window) = cx
+                        .active_window()
+                        .and_then(|window| window.downcast::<Workspace>())
+                    {
+                        workspace_window
+                            .update(cx, |workspace, cx| {
+                                workspace.show_portal_error(err.to_string(), cx);
+                            })
+                            .ok();
+                    }
+                })
+                .ok();
+            }
+        }
+    })
+    .detach();
+}
+
 pub fn init(app_state: Arc<AppState>, cx: &mut AppContext) {
     init_settings(cx);
     notifications::init(cx);
@@ -343,41 +380,33 @@ pub fn init(app_state: Arc<AppState>, cx: &mut AppContext) {
     cx.on_action({
         let app_state = Arc::downgrade(&app_state);
         move |_: &Open, cx: &mut AppContext| {
-            let paths = cx.prompt_for_paths(PathPromptOptions {
-                files: true,
-                directories: true,
-                multiple: true,
-            });
-
             if let Some(app_state) = app_state.upgrade() {
-                cx.spawn(move |cx| async move {
-                    match Flatten::flatten(paths.await.map_err(|e| e.into())) {
-                        Ok(Some(paths)) => {
-                            cx.update(|cx| {
-                                open_paths(&paths, app_state, OpenOptions::default(), cx)
-                                    .detach_and_log_err(cx)
-                            })
-                            .ok();
-                        }
-                        Ok(None) => {}
-                        Err(err) => {
-                            cx.update(|cx| {
-                                if let Some(workspace_window) = cx
-                                    .active_window()
-                                    .and_then(|window| window.downcast::<Workspace>())
-                                {
-                                    workspace_window
-                                        .update(cx, |workspace, cx| {
-                                            workspace.show_portal_error(err.to_string(), cx);
-                                        })
-                                        .ok();
-                                }
-                            })
-                            .ok();
-                        }
-                    };
-                })
-                .detach();
+                prompt_and_open_paths(
+                    app_state,
+                    PathPromptOptions {
+                        files: true,
+                        directories: true,
+                        multiple: true,
+                    },
+                    cx,
+                );
+            }
+        }
+    });
+    cx.on_action({
+        let app_state = Arc::downgrade(&app_state);
+        move |_: &OpenFiles, cx: &mut AppContext| {
+            let directories = cx.can_select_mixed_files_and_dirs();
+            if let Some(app_state) = app_state.upgrade() {
+                prompt_and_open_paths(
+                    app_state,
+                    PathPromptOptions {
+                        files: true,
+                        directories,
+                        multiple: true,
+                    },
+                    cx,
+                );
             }
         }
     });