feat(opener): reveal multiple items in dir (#2897)

* feature: reveal multiple items in dir

* feature: reveal multiple items in dir

* feature: reveal multiple items in dir

* feature: reveal multiple items in dir

* feature: reveal multiple items in dir

* feature: reveal multiple items in dir

* feature: reveal multiple items in dir

* Support multiple roots on Windows

* feature: reveal multiple items in dir

* feature: reveal multiple items in dir

* feature: reveal multiple items in dir

* feature: reveal multiple items in dir

---------

Co-authored-by: Tony <legendmastertony@gmail.com>
This commit is contained in:
Petr
2025-08-09 02:06:56 +01:00
committed by GitHub
parent 5ac8fbb1fa
commit b8056f484c
9 changed files with 179 additions and 54 deletions

View File

@@ -0,0 +1,6 @@
---
"opener": 'minor:enhance'
"opener-js": 'minor:enhance'
---
Allow reveal multiple items in the file explorer.

View File

@@ -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]

View File

@@ -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!())

View File

@@ -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__})}

View File

@@ -86,12 +86,14 @@ export async function openPath(path: string, openWith?: string): Promise<void> {
* ```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<void> {
const paths = typeof path === 'string' ? [path] : path
return invoke('plugin:opener|reveal_item_in_dir', { paths })
}

View File

@@ -69,7 +69,8 @@ pub async fn open_path<R: Runtime>(
}
}
/// 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<PathBuf>) -> crate::Result<()> {
crate::reveal_items_in_dir(&paths)
}

View File

@@ -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)]

View File

@@ -25,7 +25,7 @@ pub use error::Error;
type Result<T> = std::result::Result<T, Error>;
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<R: Runtime> {
// we use `fn() -> R` to silence the unused generic error
@@ -146,7 +146,15 @@ impl<R: Runtime> Opener<R> {
}
pub fn reveal_item_in_dir<P: AsRef<Path>>(&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<I, P>(&self, paths: I) -> Result<()>
where
I: IntoIterator<Item = P>,
P: AsRef<Path>,
{
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 {

View File

@@ -10,7 +10,7 @@ use std::path::Path;
///
/// - **Android / iOS:** Unsupported.
pub fn reveal_item_in_dir<P: AsRef<Path>>(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<P: AsRef<Path>>(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<I, P>(paths: I) -> crate::Result<()>
where
I: IntoIterator<Item = P>,
P: AsRef<Path>,
{
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<P: AsRef<Path>>(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,57 +96,96 @@ mod imp {
},
};
pub fn reveal_item_in_dir(path: &Path) -> crate::Result<()> {
let file = dunce::simplified(path);
pub fn reveal_items_in_dir(paths: &[PathBuf]) -> crate::Result<()> {
if paths.is_empty() {
return Ok(());
}
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 _ = unsafe { CoInitialize(None) };
let dir = file
.parent()
.ok_or_else(|| crate::Error::NoParent(file.to_path_buf()))?;
let dir = HSTRING::from(dir);
let dir_item = unsafe { ILCreateFromPathW(&dir) };
let file_h = HSTRING::from(file);
let file_item = unsafe { ILCreateFromPathW(&file_h) };
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::<crate::Result<Vec<_>>>()?;
if let Err(e) = unsafe {
SHOpenFolderAndSelectItems(
parent_item_id_list.item,
Some(
&to_reveals_item_id_list
.iter()
.map(|item| item.item)
.collect::<Vec<_>>(),
),
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::<SHELLEXECUTEINFOW>() 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<Self> {
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,
})
}
}
}
impl Drop for OwnedItemIdList {
fn drop(&mut self) {
if !self.item.is_null() {
unsafe { ILFree(Some(self.item)) };
}
}
}
}
#[cfg(any(
@@ -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<Vec<_>, _> = 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();