diff --git a/.changes/opener-reveal-multiple-items.md b/.changes/opener-reveal-multiple-items.md new file mode 100644 index 0000000000..9f1198626c --- /dev/null +++ b/.changes/opener-reveal-multiple-items.md @@ -0,0 +1,6 @@ +--- +"opener": 'minor:enhance' +"opener-js": 'minor:enhance' +--- + +Allow reveal multiple items in the file explorer. \ No newline at end of file diff --git a/plugins/opener/Cargo.toml b/plugins/opener/Cargo.toml index 323914e5e8..6d07078236 100644 --- a/plugins/opener/Cargo.toml +++ b/plugins/opener/Cargo.toml @@ -35,8 +35,6 @@ tauri = { workspace = true } thiserror = { workspace = true } open = { version = "5", features = ["shellexecute-on-windows"] } glob = { workspace = true } - -[target."cfg(windows)".dependencies] dunce = { workspace = true } [target."cfg(windows)".dependencies.windows] diff --git a/plugins/opener/README.md b/plugins/opener/README.md index 02050e932d..bcb9265ca4 100644 --- a/plugins/opener/README.md +++ b/plugins/opener/README.md @@ -75,6 +75,10 @@ await openPath('/path/to/file', 'firefox') // Reveal a path with the system's default explorer await revealItemInDir('/path/to/file') + +// Reveal multiple paths with the system's default explorer +// Note: will be renamed to `revealItemsInDir` in the next major version +await revealItemInDir(['/path/to/file', '/path/to/another/file']) ``` ### Usage from Rust @@ -102,6 +106,9 @@ fn main() { // Reveal a path with the system's default explorer opener.reveal_item_in_dir("/path/to/file")?; + + // Reveal multiple paths with the system's default explorer + opener.reveal_items_in_dir(["/path/to/file"])?; Ok(()) }) .run(tauri::generate_context!()) diff --git a/plugins/opener/api-iife.js b/plugins/opener/api-iife.js index 30415a61e3..dd976e5760 100644 --- a/plugins/opener/api-iife.js +++ b/plugins/opener/api-iife.js @@ -1 +1 @@ -if("__TAURI__"in window){var __TAURI_PLUGIN_OPENER__=function(n){"use strict";async function e(n,e={},_){return window.__TAURI_INTERNALS__.invoke(n,e,_)}return"function"==typeof SuppressedError&&SuppressedError,n.openPath=async function(n,_){await e("plugin:opener|open_path",{path:n,with:_})},n.openUrl=async function(n,_){await e("plugin:opener|open_url",{url:n,with:_})},n.revealItemInDir=async function(n){return e("plugin:opener|reveal_item_in_dir",{path:n})},n}({});Object.defineProperty(window.__TAURI__,"opener",{value:__TAURI_PLUGIN_OPENER__})} +if("__TAURI__"in window){var __TAURI_PLUGIN_OPENER__=function(n){"use strict";async function e(n,e={},r){return window.__TAURI_INTERNALS__.invoke(n,e,r)}return"function"==typeof SuppressedError&&SuppressedError,n.openPath=async function(n,r){await e("plugin:opener|open_path",{path:n,with:r})},n.openUrl=async function(n,r){await e("plugin:opener|open_url",{url:n,with:r})},n.revealItemInDir=async function(n){return e("plugin:opener|reveal_item_in_dir",{paths:"string"==typeof n?[n]:n})},n}({});Object.defineProperty(window.__TAURI__,"opener",{value:__TAURI_PLUGIN_OPENER__})} diff --git a/plugins/opener/guest-js/index.ts b/plugins/opener/guest-js/index.ts index b73ef53812..6b40da19ce 100644 --- a/plugins/opener/guest-js/index.ts +++ b/plugins/opener/guest-js/index.ts @@ -86,12 +86,14 @@ export async function openPath(path: string, openWith?: string): Promise { * ```typescript * import { revealItemInDir } from '@tauri-apps/plugin-opener'; * await revealItemInDir('/path/to/file'); + * await revealItemInDir([ '/path/to/file', '/path/to/another/file' ]); * ``` * * @param path The path to reveal. * * @since 2.0.0 */ -export async function revealItemInDir(path: string) { - return invoke('plugin:opener|reveal_item_in_dir', { path }) +export async function revealItemInDir(path: string | string[]): Promise { + const paths = typeof path === 'string' ? [path] : path + return invoke('plugin:opener|reveal_item_in_dir', { paths }) } diff --git a/plugins/opener/src/commands.rs b/plugins/opener/src/commands.rs index b00d5306e0..e22b41d418 100644 --- a/plugins/opener/src/commands.rs +++ b/plugins/opener/src/commands.rs @@ -69,7 +69,8 @@ pub async fn open_path( } } +/// TODO: in the next major version, rename to `reveal_items_in_dir` #[tauri::command] -pub async fn reveal_item_in_dir(path: PathBuf) -> crate::Result<()> { - crate::reveal_item_in_dir(path) +pub async fn reveal_item_in_dir(paths: Vec) -> crate::Result<()> { + crate::reveal_items_in_dir(&paths) } diff --git a/plugins/opener/src/error.rs b/plugins/opener/src/error.rs index 157922fc60..36d781b44c 100644 --- a/plugins/opener/src/error.rs +++ b/plugins/opener/src/error.rs @@ -31,6 +31,8 @@ pub enum Error { Win32Error(#[from] windows::core::Error), #[error("Path doesn't have a parent: {0}")] NoParent(PathBuf), + #[error("Path is invalid: {0}")] + InvalidPath(PathBuf), #[error("Failed to convert path to file:// url")] FailedToConvertPathToFileUrl, #[error(transparent)] diff --git a/plugins/opener/src/lib.rs b/plugins/opener/src/lib.rs index 6bf0e5b281..343e915580 100644 --- a/plugins/opener/src/lib.rs +++ b/plugins/opener/src/lib.rs @@ -25,7 +25,7 @@ pub use error::Error; type Result = std::result::Result; pub use open::{open_path, open_url}; -pub use reveal_item_in_dir::reveal_item_in_dir; +pub use reveal_item_in_dir::{reveal_item_in_dir, reveal_items_in_dir}; pub struct Opener { // we use `fn() -> R` to silence the unused generic error @@ -146,7 +146,15 @@ impl Opener { } pub fn reveal_item_in_dir>(&self, p: P) -> Result<()> { - crate::reveal_item_in_dir::reveal_item_in_dir(p) + reveal_item_in_dir(p) + } + + pub fn reveal_items_in_dir(&self, paths: I) -> Result<()> + where + I: IntoIterator, + P: AsRef, + { + reveal_items_in_dir(paths) } } @@ -213,7 +221,7 @@ impl Builder { .invoke_handler(tauri::generate_handler![ commands::open_url, commands::open_path, - commands::reveal_item_in_dir + commands::reveal_item_in_dir, ]); if self.open_js_links_on_click { diff --git a/plugins/opener/src/reveal_item_in_dir.rs b/plugins/opener/src/reveal_item_in_dir.rs index 6e3dfc2c80..1cea2641db 100644 --- a/plugins/opener/src/reveal_item_in_dir.rs +++ b/plugins/opener/src/reveal_item_in_dir.rs @@ -10,7 +10,7 @@ use std::path::Path; /// /// - **Android / iOS:** Unsupported. pub fn reveal_item_in_dir>(path: P) -> crate::Result<()> { - let path = path.as_ref().canonicalize()?; + let path = dunce::canonicalize(path.as_ref())?; #[cfg(any( windows, @@ -21,7 +21,47 @@ pub fn reveal_item_in_dir>(path: P) -> crate::Result<()> { target_os = "netbsd", target_os = "openbsd" ))] - return imp::reveal_item_in_dir(&path); + return imp::reveal_items_in_dir(&[path]); + + #[cfg(not(any( + windows, + target_os = "macos", + target_os = "linux", + target_os = "dragonfly", + target_os = "freebsd", + target_os = "netbsd", + target_os = "openbsd" + )))] + Err(crate::Error::UnsupportedPlatform) +} + +/// Reveal the paths the system's default explorer. +/// +/// ## Platform-specific: +/// +/// - **Android / iOS:** Unsupported. +pub fn reveal_items_in_dir(paths: I) -> crate::Result<()> +where + I: IntoIterator, + P: AsRef, +{ + let mut canonicalized = vec![]; + + for path in paths { + let path = dunce::canonicalize(path.as_ref())?; + canonicalized.push(path); + } + + #[cfg(any( + windows, + target_os = "macos", + target_os = "linux", + target_os = "dragonfly", + target_os = "freebsd", + target_os = "netbsd", + target_os = "openbsd" + ))] + return imp::reveal_items_in_dir(&canonicalized); #[cfg(not(any( windows, @@ -37,8 +77,10 @@ pub fn reveal_item_in_dir>(path: P) -> crate::Result<()> { #[cfg(windows)] mod imp { - use super::*; + use std::collections::HashMap; + use std::path::{Path, PathBuf}; + use windows::Win32::UI::Shell::Common::ITEMIDLIST; use windows::{ core::{w, HSTRING, PCWSTR}, Win32::{ @@ -54,56 +96,95 @@ mod imp { }, }; - pub fn reveal_item_in_dir(path: &Path) -> crate::Result<()> { - let file = dunce::simplified(path); - - let _ = unsafe { CoInitialize(None) }; - - let dir = file - .parent() - .ok_or_else(|| crate::Error::NoParent(file.to_path_buf()))?; + pub fn reveal_items_in_dir(paths: &[PathBuf]) -> crate::Result<()> { + if paths.is_empty() { + return Ok(()); + } - let dir = HSTRING::from(dir); - let dir_item = unsafe { ILCreateFromPathW(&dir) }; + let mut grouped_paths: HashMap<&Path, Vec<&Path>> = HashMap::new(); + for path in paths { + let parent = path + .parent() + .ok_or_else(|| crate::Error::NoParent(path.to_path_buf()))?; + grouped_paths.entry(parent).or_default().push(path); + } - let file_h = HSTRING::from(file); - let file_item = unsafe { ILCreateFromPathW(&file_h) }; + let _ = unsafe { CoInitialize(None) }; - unsafe { - if let Err(e) = SHOpenFolderAndSelectItems(dir_item, Some(&[file_item]), 0) { + for (parent, to_reveals) in grouped_paths { + let parent_item_id_list = OwnedItemIdList::new(parent)?; + let to_reveals_item_id_list = to_reveals + .iter() + .map(|to_reveal| OwnedItemIdList::new(*to_reveal)) + .collect::>>()?; + if let Err(e) = unsafe { + SHOpenFolderAndSelectItems( + parent_item_id_list.item, + Some( + &to_reveals_item_id_list + .iter() + .map(|item| item.item) + .collect::>(), + ), + 0, + ) + } { // from https://github.com/electron/electron/blob/10d967028af2e72382d16b7e2025d243b9e204ae/shell/common/platform_util_win.cc#L302 // On some systems, the above call mysteriously fails with "file not // found" even though the file is there. In these cases, ShellExecute() // seems to work as a fallback (although it won't select the file). + // + // Note: we only handle the first file here if multiple of are present if e.code().0 == ERROR_FILE_NOT_FOUND.0 as i32 { - let is_dir = file.is_dir(); + let first_path = to_reveals[0]; + let is_dir = first_path.is_dir(); let mut info = SHELLEXECUTEINFOW { cbSize: std::mem::size_of::() as _, nShow: SW_SHOWNORMAL.0, - lpFile: PCWSTR(dir.as_ptr()), + lpFile: PCWSTR(parent_item_id_list.hstring.as_ptr()), lpClass: if is_dir { w!("folder") } else { PCWSTR::null() }, lpVerb: if is_dir { w!("explore") } else { PCWSTR::null() }, - ..std::mem::zeroed() + ..Default::default() }; - ShellExecuteExW(&mut info).inspect_err(|_| { - ILFree(Some(dir_item)); - ILFree(Some(file_item)); - })?; + unsafe { ShellExecuteExW(&mut info) }?; } } } - unsafe { - ILFree(Some(dir_item)); - ILFree(Some(file_item)); + Ok(()) + } + + struct OwnedItemIdList { + hstring: HSTRING, + item: *const ITEMIDLIST, + } + + impl OwnedItemIdList { + fn new(path: &Path) -> crate::Result { + let path_hstring = HSTRING::from(path); + let item_id_list = unsafe { ILCreateFromPathW(&path_hstring) }; + if item_id_list.is_null() { + Err(crate::Error::InvalidPath(path.to_owned())) + } else { + Ok(Self { + hstring: path_hstring, + item: item_id_list, + }) + } } + } - Ok(()) + impl Drop for OwnedItemIdList { + fn drop(&mut self) { + if !self.item.is_null() { + unsafe { ILFree(Some(self.item)) }; + } + } } } @@ -115,24 +196,36 @@ mod imp { target_os = "openbsd" ))] mod imp { - - use std::collections::HashMap; - use super::*; + use std::collections::HashMap; + use std::path::PathBuf; - pub fn reveal_item_in_dir(path: &Path) -> crate::Result<()> { + pub fn reveal_items_in_dir(paths: &[PathBuf]) -> crate::Result<()> { let connection = zbus::blocking::Connection::session()?; - reveal_with_filemanager1(path, &connection) - .or_else(|_| reveal_with_open_uri_portal(path, &connection)) + reveal_with_filemanager1(paths, &connection).or_else(|e| { + // Fallback to opening the directory of the first item if revealing multiple items fails. + if let Some(first_path) = paths.first() { + reveal_with_open_uri_portal(first_path, &connection) + } else { + Err(e) + } + }) } fn reveal_with_filemanager1( - path: &Path, + paths: &[PathBuf], connection: &zbus::blocking::Connection, ) -> crate::Result<()> { - let uri = url::Url::from_file_path(path) - .map_err(|_| crate::Error::FailedToConvertPathToFileUrl)?; + let uris: Result, _> = paths + .iter() + .map(|path| { + url::Url::from_file_path(path) + .map_err(|_| crate::Error::FailedToConvertPathToFileUrl) + }) + .collect(); + let uris = uris?; + let uri_strs: Vec<&str> = uris.iter().map(|uri| uri.as_str()).collect(); #[zbus::proxy( interface = "org.freedesktop.FileManager1", @@ -145,7 +238,7 @@ mod imp { let proxy = FileManager1ProxyBlocking::new(connection)?; - proxy.ShowItems(vec![uri.as_str()], "") + proxy.ShowItems(uri_strs, "") } fn reveal_with_open_uri_portal( @@ -177,14 +270,22 @@ mod imp { #[cfg(target_os = "macos")] mod imp { - use super::*; use objc2_app_kit::NSWorkspace; use objc2_foundation::{NSArray, NSString, NSURL}; - pub fn reveal_item_in_dir(path: &Path) -> crate::Result<()> { + use std::path::PathBuf; + + pub fn reveal_items_in_dir(paths: &[PathBuf]) -> crate::Result<()> { unsafe { - let path = path.to_string_lossy(); - let path = NSString::from_str(&path); - let urls = vec![NSURL::fileURLWithPath(&path)]; + let mut urls = Vec::new(); + + for path in paths { + let path = path.to_string_lossy(); + let path = NSString::from_str(&path); + let url = NSURL::fileURLWithPath(&path); + + urls.push(url); + } + let urls = NSArray::from_retained_slice(&urls); let workspace = NSWorkspace::new();