mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-08 09:24:41 +00:00
395 lines
14 KiB
Rust
Vendored
395 lines
14 KiB
Rust
Vendored
//! The native pickers, Finder/Explorer reveal, and "open with the default app".
|
|
//!
|
|
//! `rfd` owns the dialogs, so the platform differences (AppKit panels, the
|
|
//! Windows common dialogs, the XDG portal on Linux) stay out of this crate. All
|
|
//! of `rfd`'s dialog entry points are blocking, which is acceptable here: the
|
|
//! module has a thread of its own (`THREAD = Own`), so a panel left open blocks
|
|
//! neither JS nor a frame.
|
|
//!
|
|
//! # Open question: dialogs on macOS and the main thread
|
|
//!
|
|
//! The design flags that macOS may require the panel to be created on the main
|
|
//! thread, and that this has to be confirmed under GPUI's run loop before it can
|
|
//! be called done. What `rfd` 0.15's blocking API actually does on macOS is
|
|
//! dispatch the panel onto the main queue when the process already has a running
|
|
//! `NSApplication`, run it inline when the caller is already on the main thread,
|
|
//! and panic when neither holds. This module is on its own thread, so the first
|
|
//! branch is the one that applies, and the call should hop to the main thread and
|
|
//! block this module's thread until the panel closes.
|
|
//!
|
|
//! That is the theory, and it is **not yet verified** under GPUI's run loop. The
|
|
//! spike is: run the app, open a dialog from JS, and check that the panel
|
|
//! appears, that no main-thread warning is logged, that the module thread
|
|
//! resumes when it closes, and that the app does not deadlock while the panel is
|
|
//! up. If any of that fails, the fallback is the one the design names: a second
|
|
//! module, `GpuiFsDialogs`, with `THREAD = Main`, holding only the two pickers;
|
|
//! the rest of this module stays off the main thread. Attaching the panel to the
|
|
//! calling window (a sheet on macOS) is a further step that needs a host
|
|
//! function exposing the active window's raw handle for `rfd`'s `set_parent`,
|
|
//! which does not exist yet either.
|
|
|
|
use std::path::{Path, PathBuf};
|
|
use std::process::Command;
|
|
|
|
use rn_gpui_module::ModuleError;
|
|
|
|
use crate::errors;
|
|
use crate::{DialogFilter, OpenDialogOptions, SaveDialogOptions};
|
|
|
|
/// The open picker. An empty list means the user cancelled, which is what the JS
|
|
/// layer expects; a cancelled dialog is not a failure.
|
|
pub fn show_open(options: OpenDialogOptions) -> Result<Vec<String>, ModuleError> {
|
|
let dialog = configured(
|
|
options.title.as_deref(),
|
|
options.default_path.as_deref(),
|
|
&options.filters,
|
|
);
|
|
|
|
let picked = if options.directory {
|
|
// A folder picker cannot return files, and `pick_folder` selects a
|
|
// single folder — rfd's blocking folder call has no multi-select mode.
|
|
// So `multiple` is honoured only as far as the platform allows: a
|
|
// caller asking for `{ directory: true, multiple: true }` gets at most
|
|
// one path, which is better than refusing the call outright.
|
|
dialog.pick_folder().into_iter().collect()
|
|
} else if options.multiple {
|
|
dialog.pick_files().unwrap_or_default()
|
|
} else {
|
|
dialog.pick_file().into_iter().collect()
|
|
};
|
|
|
|
Ok(utf8_paths(picked))
|
|
}
|
|
|
|
/// The save picker. `None` means the user cancelled; no file is created here
|
|
/// either way, the caller decides what to write.
|
|
pub fn show_save(options: SaveDialogOptions) -> Result<Option<String>, ModuleError> {
|
|
let (_, from_path) = split_default_path(options.default_path.as_deref());
|
|
// `defaultName` wins over the name implied by `defaultPath`, but a path
|
|
// ending in a file name is still the only hint a caller may give.
|
|
let name = options
|
|
.default_name
|
|
.filter(|name| !name.trim().is_empty())
|
|
.or(from_path);
|
|
|
|
let mut dialog = configured(
|
|
options.title.as_deref(),
|
|
options.default_path.as_deref(),
|
|
&options.filters,
|
|
);
|
|
if let Some(name) = name {
|
|
dialog = dialog.set_file_name(name);
|
|
}
|
|
|
|
let picked = dialog.save_file();
|
|
// The picker never writes, so a chosen name the bridge cannot represent is
|
|
// reported as "nothing chosen" rather than as an error the user has no way
|
|
// to act on; the alternative is a file the caller would try to write at a
|
|
// path JS could not have named.
|
|
Ok(picked.as_deref().and_then(utf8_path))
|
|
}
|
|
|
|
/// Shows `path` in the platform's file manager, selected where the platform has
|
|
/// a verb for it.
|
|
pub fn reveal(path: String) -> Result<(), ModuleError> {
|
|
ensure_exists(&path)?;
|
|
run(reveal_command(Path::new(&path)), &path)
|
|
}
|
|
|
|
/// Opens `path` with whatever the platform associates with it.
|
|
pub fn open_external(path: String) -> Result<(), ModuleError> {
|
|
ensure_exists(&path)?;
|
|
run(open_external_command(Path::new(&path)), &path)
|
|
}
|
|
|
|
/// A dialog carrying everything the three calls configure the same way: title,
|
|
/// starting directory, and the extension filters.
|
|
fn configured(
|
|
title: Option<&str>,
|
|
default_path: Option<&str>,
|
|
filters: &[DialogFilter],
|
|
) -> rfd::FileDialog {
|
|
let mut dialog = rfd::FileDialog::new();
|
|
if let Some(title) = title {
|
|
dialog = dialog.set_title(title);
|
|
}
|
|
if let Some(directory) = split_default_path(default_path).0 {
|
|
dialog = dialog.set_directory(directory);
|
|
}
|
|
for filter in filters {
|
|
// An empty extension list carries no information, and on some backends
|
|
// a filter is applied as an allow-list, which would hide every file.
|
|
if !filter.extensions.is_empty() {
|
|
dialog = dialog.add_filter(filter.name.as_str(), &filter.extensions);
|
|
}
|
|
}
|
|
dialog
|
|
}
|
|
|
|
/// Splits `defaultPath` into the directory a picker should open in, and the file
|
|
/// name to prefill when the path names a file rather than a folder.
|
|
///
|
|
/// The two pickers need different halves: the open dialog only starts in the
|
|
/// directory, the save dialog also offers the name.
|
|
fn split_default_path(default_path: Option<&str>) -> (Option<PathBuf>, Option<String>) {
|
|
let Some(raw) = default_path.map(str::trim).filter(|path| !path.is_empty()) else {
|
|
return (None, None);
|
|
};
|
|
let path = Path::new(raw);
|
|
|
|
let is_directory = match std::fs::metadata(path) {
|
|
Ok(metadata) => metadata.is_dir(),
|
|
// A save dialog is usually given a path that does not exist yet, so fall
|
|
// back to the spelling: a trailing separator promises a directory, and
|
|
// anything else is a proposed file name whose parent is the start.
|
|
Err(_) => ends_with_separator(raw),
|
|
};
|
|
|
|
if is_directory {
|
|
(Some(path.to_path_buf()), None)
|
|
} else {
|
|
(
|
|
path.parent()
|
|
.filter(|parent| !parent.as_os_str().is_empty())
|
|
.map(Path::to_path_buf),
|
|
path.file_name()
|
|
.and_then(|name| name.to_str())
|
|
.map(str::to_string),
|
|
)
|
|
}
|
|
}
|
|
|
|
/// Whether a path is spelled as a directory on any of the platforms this module
|
|
/// runs on, so a Windows path still splits correctly when read elsewhere.
|
|
fn ends_with_separator(path: &str) -> bool {
|
|
path.ends_with('/') || path.ends_with('\\')
|
|
}
|
|
|
|
/// The picked paths as strings, dropping any the bridge cannot represent.
|
|
///
|
|
/// A Unix file name need not be UTF-8, but JS can only hold strings. Failing the
|
|
/// whole selection because one of several files has an unusual name would throw
|
|
/// away the files the user can use, so the others are returned instead.
|
|
fn utf8_paths(paths: Vec<PathBuf>) -> Vec<String> {
|
|
paths
|
|
.into_iter()
|
|
.filter_map(|path| utf8_path(&path))
|
|
.collect()
|
|
}
|
|
|
|
fn utf8_path(path: &Path) -> Option<String> {
|
|
path.to_str().map(str::to_string)
|
|
}
|
|
|
|
/// Fails with the same code `stat` would before a platform command can turn the
|
|
/// problem into an opaque non-zero exit.
|
|
fn ensure_exists(path: &str) -> Result<(), ModuleError> {
|
|
// `symlink_metadata` rather than `metadata`: a dangling symlink is still a
|
|
// row in Finder or Explorer, so revealing it is meaningful.
|
|
std::fs::symlink_metadata(path)
|
|
.map(|_| ())
|
|
.map_err(|error| errors::from_io(error, path))
|
|
}
|
|
|
|
/// A command line, kept separate from running it so the platform branches can be
|
|
/// asserted on without opening anything.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
struct CommandSpec {
|
|
program: &'static str,
|
|
args: Vec<String>,
|
|
}
|
|
|
|
/// The command that reveals `path` in the platform's file manager.
|
|
fn reveal_command(path: &Path) -> CommandSpec {
|
|
let shown = path.to_string_lossy().into_owned();
|
|
|
|
#[cfg(target_os = "macos")]
|
|
{
|
|
// `open -R` is the only thing that selects the item rather than opening
|
|
// its containing folder.
|
|
CommandSpec {
|
|
program: "open",
|
|
args: vec!["-R".to_string(), shown],
|
|
}
|
|
}
|
|
|
|
#[cfg(target_os = "windows")]
|
|
{
|
|
// `/select,<path>` is a single argument, comma included.
|
|
//
|
|
// Note: explorer.exe is known to return exit code 1 even when it does
|
|
// open the window, which the non-zero check below would report as a
|
|
// failure. That needs a check on a Windows machine; if it does bite,
|
|
// this branch should treat a successful spawn as success instead of
|
|
// reading the exit code.
|
|
CommandSpec {
|
|
program: "explorer",
|
|
args: vec![format!("/select,{shown}")],
|
|
}
|
|
}
|
|
|
|
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
|
|
{
|
|
// `xdg-open` has no portable "select this item" verb, so the containing
|
|
// folder is the closest equivalent. A path with no parent is its own
|
|
// directory and is opened as it is.
|
|
let folder = path.parent().unwrap_or(path);
|
|
CommandSpec {
|
|
program: "xdg-open",
|
|
args: vec![folder.to_string_lossy().into_owned()],
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The command that opens `path` with the platform's default application.
|
|
fn open_external_command(path: &Path) -> CommandSpec {
|
|
let shown = path.to_string_lossy().into_owned();
|
|
|
|
#[cfg(target_os = "macos")]
|
|
{
|
|
CommandSpec {
|
|
program: "open",
|
|
args: vec![shown],
|
|
}
|
|
}
|
|
|
|
#[cfg(target_os = "windows")]
|
|
{
|
|
// `start` reads its first quoted argument as the window title, so the
|
|
// empty string is required for a path containing spaces to survive.
|
|
CommandSpec {
|
|
program: "cmd",
|
|
args: vec!["/C".to_string(), "start".to_string(), String::new(), shown],
|
|
}
|
|
}
|
|
|
|
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
|
|
{
|
|
CommandSpec {
|
|
program: "xdg-open",
|
|
args: vec![shown],
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Runs a command and turns a refusal into a Node-shaped error.
|
|
///
|
|
/// `Command` is used without a shell, so a path is one argument no matter what
|
|
/// characters it contains; quoting and escaping are the platform's problem, not
|
|
/// a shell's.
|
|
fn run(spec: CommandSpec, path: &str) -> Result<(), ModuleError> {
|
|
let status = Command::new(spec.program)
|
|
.args(&spec.args)
|
|
.status()
|
|
// The program itself could not be started (missing, or not executable),
|
|
// which is an I/O failure on the path the caller asked about.
|
|
.map_err(|error| errors::from_io(error, path))?;
|
|
|
|
if status.success() {
|
|
return Ok(());
|
|
}
|
|
Err(errors::invalid(
|
|
"EIO",
|
|
format!(
|
|
"EIO: i/o error, {path} ({} exited with {status})",
|
|
spec.program
|
|
),
|
|
))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn a_default_path_that_names_a_file_splits_into_parent_and_name() {
|
|
let file = std::env::temp_dir().join("rn-gpui-fs-dialog-default.txt");
|
|
let (directory, name) = split_default_path(Some(&file.to_string_lossy()));
|
|
assert_eq!(directory.as_deref(), Some(std::env::temp_dir().as_path()));
|
|
assert_eq!(name.as_deref(), Some("rn-gpui-fs-dialog-default.txt"));
|
|
}
|
|
|
|
#[test]
|
|
fn a_default_path_that_names_an_existing_directory_stays_a_directory() {
|
|
let directory = std::env::temp_dir();
|
|
let (found, name) = split_default_path(Some(&directory.to_string_lossy()));
|
|
assert_eq!(found, Some(directory));
|
|
assert_eq!(name, None);
|
|
}
|
|
|
|
#[test]
|
|
fn a_bare_file_name_has_no_directory_to_open_in() {
|
|
let (directory, name) = split_default_path(Some("rn-gpui-fs-report.csv"));
|
|
assert_eq!(directory, None);
|
|
assert_eq!(name.as_deref(), Some("rn-gpui-fs-report.csv"));
|
|
}
|
|
|
|
#[test]
|
|
fn a_trailing_separator_marks_a_path_that_does_not_exist_as_a_directory() {
|
|
let missing = std::env::temp_dir().join("rn-gpui-fs-not-created");
|
|
let spelled = format!("{}/", missing.to_string_lossy());
|
|
let (directory, name) = split_default_path(Some(&spelled));
|
|
assert_eq!(directory, Some(missing));
|
|
assert_eq!(name, None);
|
|
}
|
|
|
|
#[test]
|
|
fn no_default_path_means_no_directory_and_no_name() {
|
|
assert_eq!(split_default_path(None), (None, None));
|
|
assert_eq!(split_default_path(Some(" ")), (None, None));
|
|
}
|
|
|
|
#[test]
|
|
fn reveal_targets_the_platform_file_manager() {
|
|
let spec = reveal_command(Path::new("/tmp/example.txt"));
|
|
#[cfg(target_os = "macos")]
|
|
{
|
|
assert_eq!(spec.program, "open");
|
|
assert_eq!(spec.args, ["-R", "/tmp/example.txt"]);
|
|
}
|
|
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
|
|
{
|
|
assert_eq!(spec.program, "xdg-open");
|
|
// Only the folder, because xdg-open cannot select an item in it.
|
|
assert_eq!(spec.args, ["/tmp"]);
|
|
}
|
|
#[cfg(target_os = "windows")]
|
|
{
|
|
assert_eq!(spec.program, "explorer");
|
|
assert_eq!(spec.args, ["/select,/tmp/example.txt"]);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn open_external_hands_the_path_to_the_default_application() {
|
|
let spec = open_external_command(Path::new("/tmp/example.txt"));
|
|
assert_eq!(
|
|
spec.args.last().map(String::as_str),
|
|
Some("/tmp/example.txt")
|
|
);
|
|
#[cfg(target_os = "macos")]
|
|
{
|
|
assert_eq!(spec.program, "open");
|
|
assert_eq!(spec.args.len(), 1);
|
|
}
|
|
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
|
|
assert_eq!(spec.program, "xdg-open");
|
|
#[cfg(target_os = "windows")]
|
|
{
|
|
assert_eq!(spec.program, "cmd");
|
|
// `/C start "" <path>`: the empty argument is the title `start`
|
|
// would otherwise take the path for.
|
|
assert_eq!(&spec.args[..3], ["/C", "start", ""]);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a_path_with_spaces_is_still_a_single_argument() {
|
|
let spec = open_external_command(Path::new("/tmp/two words.txt"));
|
|
assert_eq!(
|
|
spec.args.last().map(String::as_str),
|
|
Some("/tmp/two words.txt")
|
|
);
|
|
}
|
|
}
|