Skip to content

Commit b8056f4

Browse files
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 <[email protected]>
1 parent 5ac8fbb commit b8056f4

File tree

9 files changed

+178
-53
lines changed

9 files changed

+178
-53
lines changed
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"opener": 'minor:enhance'
3+
"opener-js": 'minor:enhance'
4+
---
5+
6+
Allow reveal multiple items in the file explorer.

plugins/opener/Cargo.toml

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,6 @@ tauri = { workspace = true }
3535
thiserror = { workspace = true }
3636
open = { version = "5", features = ["shellexecute-on-windows"] }
3737
glob = { workspace = true }
38-
39-
[target."cfg(windows)".dependencies]
4038
dunce = { workspace = true }
4139

4240
[target."cfg(windows)".dependencies.windows]

plugins/opener/README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,10 @@ await openPath('/path/to/file', 'firefox')
7575

7676
// Reveal a path with the system's default explorer
7777
await revealItemInDir('/path/to/file')
78+
79+
// Reveal multiple paths with the system's default explorer
80+
// Note: will be renamed to `revealItemsInDir` in the next major version
81+
await revealItemInDir(['/path/to/file', '/path/to/another/file'])
7882
```
7983

8084
### Usage from Rust
@@ -102,6 +106,9 @@ fn main() {
102106

103107
// Reveal a path with the system's default explorer
104108
opener.reveal_item_in_dir("/path/to/file")?;
109+
110+
// Reveal multiple paths with the system's default explorer
111+
opener.reveal_items_in_dir(["/path/to/file"])?;
105112
Ok(())
106113
})
107114
.run(tauri::generate_context!())

plugins/opener/api-iife.js

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

plugins/opener/guest-js/index.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -86,12 +86,14 @@ export async function openPath(path: string, openWith?: string): Promise<void> {
8686
* ```typescript
8787
* import { revealItemInDir } from '@tauri-apps/plugin-opener';
8888
* await revealItemInDir('/path/to/file');
89+
* await revealItemInDir([ '/path/to/file', '/path/to/another/file' ]);
8990
* ```
9091
*
9192
* @param path The path to reveal.
9293
*
9394
* @since 2.0.0
9495
*/
95-
export async function revealItemInDir(path: string) {
96-
return invoke('plugin:opener|reveal_item_in_dir', { path })
96+
export async function revealItemInDir(path: string | string[]): Promise<void> {
97+
const paths = typeof path === 'string' ? [path] : path
98+
return invoke('plugin:opener|reveal_item_in_dir', { paths })
9799
}

plugins/opener/src/commands.rs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,8 @@ pub async fn open_path<R: Runtime>(
6969
}
7070
}
7171

72+
/// TODO: in the next major version, rename to `reveal_items_in_dir`
7273
#[tauri::command]
73-
pub async fn reveal_item_in_dir(path: PathBuf) -> crate::Result<()> {
74-
crate::reveal_item_in_dir(path)
74+
pub async fn reveal_item_in_dir(paths: Vec<PathBuf>) -> crate::Result<()> {
75+
crate::reveal_items_in_dir(&paths)
7576
}

plugins/opener/src/error.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ pub enum Error {
3131
Win32Error(#[from] windows::core::Error),
3232
#[error("Path doesn't have a parent: {0}")]
3333
NoParent(PathBuf),
34+
#[error("Path is invalid: {0}")]
35+
InvalidPath(PathBuf),
3436
#[error("Failed to convert path to file:// url")]
3537
FailedToConvertPathToFileUrl,
3638
#[error(transparent)]

plugins/opener/src/lib.rs

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ pub use error::Error;
2525
type Result<T> = std::result::Result<T, Error>;
2626

2727
pub use open::{open_path, open_url};
28-
pub use reveal_item_in_dir::reveal_item_in_dir;
28+
pub use reveal_item_in_dir::{reveal_item_in_dir, reveal_items_in_dir};
2929

3030
pub struct Opener<R: Runtime> {
3131
// we use `fn() -> R` to silence the unused generic error
@@ -146,7 +146,15 @@ impl<R: Runtime> Opener<R> {
146146
}
147147

148148
pub fn reveal_item_in_dir<P: AsRef<Path>>(&self, p: P) -> Result<()> {
149-
crate::reveal_item_in_dir::reveal_item_in_dir(p)
149+
reveal_item_in_dir(p)
150+
}
151+
152+
pub fn reveal_items_in_dir<I, P>(&self, paths: I) -> Result<()>
153+
where
154+
I: IntoIterator<Item = P>,
155+
P: AsRef<Path>,
156+
{
157+
reveal_items_in_dir(paths)
150158
}
151159
}
152160

@@ -213,7 +221,7 @@ impl Builder {
213221
.invoke_handler(tauri::generate_handler![
214222
commands::open_url,
215223
commands::open_path,
216-
commands::reveal_item_in_dir
224+
commands::reveal_item_in_dir,
217225
]);
218226

219227
if self.open_js_links_on_click {

plugins/opener/src/reveal_item_in_dir.rs

Lines changed: 144 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ use std::path::Path;
1010
///
1111
/// - **Android / iOS:** Unsupported.
1212
pub fn reveal_item_in_dir<P: AsRef<Path>>(path: P) -> crate::Result<()> {
13-
let path = path.as_ref().canonicalize()?;
13+
let path = dunce::canonicalize(path.as_ref())?;
1414

1515
#[cfg(any(
1616
windows,
@@ -21,7 +21,47 @@ pub fn reveal_item_in_dir<P: AsRef<Path>>(path: P) -> crate::Result<()> {
2121
target_os = "netbsd",
2222
target_os = "openbsd"
2323
))]
24-
return imp::reveal_item_in_dir(&path);
24+
return imp::reveal_items_in_dir(&[path]);
25+
26+
#[cfg(not(any(
27+
windows,
28+
target_os = "macos",
29+
target_os = "linux",
30+
target_os = "dragonfly",
31+
target_os = "freebsd",
32+
target_os = "netbsd",
33+
target_os = "openbsd"
34+
)))]
35+
Err(crate::Error::UnsupportedPlatform)
36+
}
37+
38+
/// Reveal the paths the system's default explorer.
39+
///
40+
/// ## Platform-specific:
41+
///
42+
/// - **Android / iOS:** Unsupported.
43+
pub fn reveal_items_in_dir<I, P>(paths: I) -> crate::Result<()>
44+
where
45+
I: IntoIterator<Item = P>,
46+
P: AsRef<Path>,
47+
{
48+
let mut canonicalized = vec![];
49+
50+
for path in paths {
51+
let path = dunce::canonicalize(path.as_ref())?;
52+
canonicalized.push(path);
53+
}
54+
55+
#[cfg(any(
56+
windows,
57+
target_os = "macos",
58+
target_os = "linux",
59+
target_os = "dragonfly",
60+
target_os = "freebsd",
61+
target_os = "netbsd",
62+
target_os = "openbsd"
63+
))]
64+
return imp::reveal_items_in_dir(&canonicalized);
2565

2666
#[cfg(not(any(
2767
windows,
@@ -37,8 +77,10 @@ pub fn reveal_item_in_dir<P: AsRef<Path>>(path: P) -> crate::Result<()> {
3777

3878
#[cfg(windows)]
3979
mod imp {
40-
use super::*;
80+
use std::collections::HashMap;
81+
use std::path::{Path, PathBuf};
4182

83+
use windows::Win32::UI::Shell::Common::ITEMIDLIST;
4284
use windows::{
4385
core::{w, HSTRING, PCWSTR},
4486
Win32::{
@@ -54,56 +96,95 @@ mod imp {
5496
},
5597
};
5698

57-
pub fn reveal_item_in_dir(path: &Path) -> crate::Result<()> {
58-
let file = dunce::simplified(path);
59-
60-
let _ = unsafe { CoInitialize(None) };
61-
62-
let dir = file
63-
.parent()
64-
.ok_or_else(|| crate::Error::NoParent(file.to_path_buf()))?;
99+
pub fn reveal_items_in_dir(paths: &[PathBuf]) -> crate::Result<()> {
100+
if paths.is_empty() {
101+
return Ok(());
102+
}
65103

66-
let dir = HSTRING::from(dir);
67-
let dir_item = unsafe { ILCreateFromPathW(&dir) };
104+
let mut grouped_paths: HashMap<&Path, Vec<&Path>> = HashMap::new();
105+
for path in paths {
106+
let parent = path
107+
.parent()
108+
.ok_or_else(|| crate::Error::NoParent(path.to_path_buf()))?;
109+
grouped_paths.entry(parent).or_default().push(path);
110+
}
68111

69-
let file_h = HSTRING::from(file);
70-
let file_item = unsafe { ILCreateFromPathW(&file_h) };
112+
let _ = unsafe { CoInitialize(None) };
71113

72-
unsafe {
73-
if let Err(e) = SHOpenFolderAndSelectItems(dir_item, Some(&[file_item]), 0) {
114+
for (parent, to_reveals) in grouped_paths {
115+
let parent_item_id_list = OwnedItemIdList::new(parent)?;
116+
let to_reveals_item_id_list = to_reveals
117+
.iter()
118+
.map(|to_reveal| OwnedItemIdList::new(*to_reveal))
119+
.collect::<crate::Result<Vec<_>>>()?;
120+
if let Err(e) = unsafe {
121+
SHOpenFolderAndSelectItems(
122+
parent_item_id_list.item,
123+
Some(
124+
&to_reveals_item_id_list
125+
.iter()
126+
.map(|item| item.item)
127+
.collect::<Vec<_>>(),
128+
),
129+
0,
130+
)
131+
} {
74132
// from https://github.com/electron/electron/blob/10d967028af2e72382d16b7e2025d243b9e204ae/shell/common/platform_util_win.cc#L302
75133
// On some systems, the above call mysteriously fails with "file not
76134
// found" even though the file is there. In these cases, ShellExecute()
77135
// seems to work as a fallback (although it won't select the file).
136+
//
137+
// Note: we only handle the first file here if multiple of are present
78138
if e.code().0 == ERROR_FILE_NOT_FOUND.0 as i32 {
79-
let is_dir = file.is_dir();
139+
let first_path = to_reveals[0];
140+
let is_dir = first_path.is_dir();
80141
let mut info = SHELLEXECUTEINFOW {
81142
cbSize: std::mem::size_of::<SHELLEXECUTEINFOW>() as _,
82143
nShow: SW_SHOWNORMAL.0,
83-
lpFile: PCWSTR(dir.as_ptr()),
144+
lpFile: PCWSTR(parent_item_id_list.hstring.as_ptr()),
84145
lpClass: if is_dir { w!("folder") } else { PCWSTR::null() },
85146
lpVerb: if is_dir {
86147
w!("explore")
87148
} else {
88149
PCWSTR::null()
89150
},
90-
..std::mem::zeroed()
151+
..Default::default()
91152
};
92153

93-
ShellExecuteExW(&mut info).inspect_err(|_| {
94-
ILFree(Some(dir_item));
95-
ILFree(Some(file_item));
96-
})?;
154+
unsafe { ShellExecuteExW(&mut info) }?;
97155
}
98156
}
99157
}
100158

101-
unsafe {
102-
ILFree(Some(dir_item));
103-
ILFree(Some(file_item));
159+
Ok(())
160+
}
161+
162+
struct OwnedItemIdList {
163+
hstring: HSTRING,
164+
item: *const ITEMIDLIST,
165+
}
166+
167+
impl OwnedItemIdList {
168+
fn new(path: &Path) -> crate::Result<Self> {
169+
let path_hstring = HSTRING::from(path);
170+
let item_id_list = unsafe { ILCreateFromPathW(&path_hstring) };
171+
if item_id_list.is_null() {
172+
Err(crate::Error::InvalidPath(path.to_owned()))
173+
} else {
174+
Ok(Self {
175+
hstring: path_hstring,
176+
item: item_id_list,
177+
})
178+
}
104179
}
180+
}
105181

106-
Ok(())
182+
impl Drop for OwnedItemIdList {
183+
fn drop(&mut self) {
184+
if !self.item.is_null() {
185+
unsafe { ILFree(Some(self.item)) };
186+
}
187+
}
107188
}
108189
}
109190

@@ -115,24 +196,36 @@ mod imp {
115196
target_os = "openbsd"
116197
))]
117198
mod imp {
118-
119-
use std::collections::HashMap;
120-
121199
use super::*;
200+
use std::collections::HashMap;
201+
use std::path::PathBuf;
122202

123-
pub fn reveal_item_in_dir(path: &Path) -> crate::Result<()> {
203+
pub fn reveal_items_in_dir(paths: &[PathBuf]) -> crate::Result<()> {
124204
let connection = zbus::blocking::Connection::session()?;
125205

126-
reveal_with_filemanager1(path, &connection)
127-
.or_else(|_| reveal_with_open_uri_portal(path, &connection))
206+
reveal_with_filemanager1(paths, &connection).or_else(|e| {
207+
// Fallback to opening the directory of the first item if revealing multiple items fails.
208+
if let Some(first_path) = paths.first() {
209+
reveal_with_open_uri_portal(first_path, &connection)
210+
} else {
211+
Err(e)
212+
}
213+
})
128214
}
129215

130216
fn reveal_with_filemanager1(
131-
path: &Path,
217+
paths: &[PathBuf],
132218
connection: &zbus::blocking::Connection,
133219
) -> crate::Result<()> {
134-
let uri = url::Url::from_file_path(path)
135-
.map_err(|_| crate::Error::FailedToConvertPathToFileUrl)?;
220+
let uris: Result<Vec<_>, _> = paths
221+
.iter()
222+
.map(|path| {
223+
url::Url::from_file_path(path)
224+
.map_err(|_| crate::Error::FailedToConvertPathToFileUrl)
225+
})
226+
.collect();
227+
let uris = uris?;
228+
let uri_strs: Vec<&str> = uris.iter().map(|uri| uri.as_str()).collect();
136229

137230
#[zbus::proxy(
138231
interface = "org.freedesktop.FileManager1",
@@ -145,7 +238,7 @@ mod imp {
145238

146239
let proxy = FileManager1ProxyBlocking::new(connection)?;
147240

148-
proxy.ShowItems(vec![uri.as_str()], "")
241+
proxy.ShowItems(uri_strs, "")
149242
}
150243

151244
fn reveal_with_open_uri_portal(
@@ -177,14 +270,22 @@ mod imp {
177270

178271
#[cfg(target_os = "macos")]
179272
mod imp {
180-
use super::*;
181273
use objc2_app_kit::NSWorkspace;
182274
use objc2_foundation::{NSArray, NSString, NSURL};
183-
pub fn reveal_item_in_dir(path: &Path) -> crate::Result<()> {
275+
use std::path::PathBuf;
276+
277+
pub fn reveal_items_in_dir(paths: &[PathBuf]) -> crate::Result<()> {
184278
unsafe {
185-
let path = path.to_string_lossy();
186-
let path = NSString::from_str(&path);
187-
let urls = vec![NSURL::fileURLWithPath(&path)];
279+
let mut urls = Vec::new();
280+
281+
for path in paths {
282+
let path = path.to_string_lossy();
283+
let path = NSString::from_str(&path);
284+
let url = NSURL::fileURLWithPath(&path);
285+
286+
urls.push(url);
287+
}
288+
188289
let urls = NSArray::from_retained_slice(&urls);
189290

190291
let workspace = NSWorkspace::new();

0 commit comments

Comments
 (0)