@react-native-gpui/fs 0.0.0-stage → 0.0.1-alpha.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +8 -0
- package/LICENSE +21 -0
- package/README.md +36 -2
- package/desktop/Cargo.lock +1989 -0
- package/desktop/Cargo.toml +25 -0
- package/desktop/build.rs +3 -0
- package/desktop/src/dialogs.rs +394 -0
- package/desktop/src/dirs.rs +363 -0
- package/desktop/src/errors.rs +149 -0
- package/desktop/src/files.rs +640 -0
- package/desktop/src/handles.rs +697 -0
- package/desktop/src/lib.rs +258 -0
- package/desktop/src/links.rs +336 -0
- package/desktop/src/module.rs +187 -0
- package/desktop/src/paths.rs +231 -0
- package/desktop/src/watching.rs +392 -0
- package/package.json +37 -4
- package/react-native.config.js +9 -0
- package/src/NativeFs.ts +162 -0
- package/src/constants.ts +43 -0
- package/src/index.ts +769 -0
- package/src/promises.ts +771 -0
- package/src/shared.ts +539 -0
- package/src/stats.ts +196 -0
- package/src/watch.ts +141 -0
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
[package]
|
|
2
|
+
name = "rn-gpui-fs"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
edition = "2021"
|
|
5
|
+
|
|
6
|
+
[dependencies]
|
|
7
|
+
rn-gpui-module = { version = "0.1" }
|
|
8
|
+
# Dialogs with file-type filters and a default name on all three platforms, using
|
|
9
|
+
# the XDG portal on Linux so no GTK is required.
|
|
10
|
+
rfd = "0.15"
|
|
11
|
+
# The standard cross-platform watcher.
|
|
12
|
+
notify = "6"
|
|
13
|
+
# Well-known folders, which differ per platform in ways not worth re-deriving.
|
|
14
|
+
dirs = "5"
|
|
15
|
+
# `utimes` sets both timestamps of an existing file; `std` cannot.
|
|
16
|
+
filetime = "0.2"
|
|
17
|
+
# `mkdtemp` with Node's retry-on-collision behaviour.
|
|
18
|
+
tempfile = "3"
|
|
19
|
+
# `chmod` by descriptor, and the errno constants the error mapping reads.
|
|
20
|
+
libc = "0.2"
|
|
21
|
+
# `realpath` without the `\\?\` prefix on Windows.
|
|
22
|
+
dunce = "1"
|
|
23
|
+
|
|
24
|
+
[build-dependencies]
|
|
25
|
+
rn-gpui-module-build = { version = "0.1" }
|
package/desktop/build.rs
ADDED
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
//! The native pickers, Finder/Explorer reveal, and "open with the default app".
|
|
2
|
+
//!
|
|
3
|
+
//! `rfd` owns the dialogs, so the platform differences (AppKit panels, the
|
|
4
|
+
//! Windows common dialogs, the XDG portal on Linux) stay out of this crate. All
|
|
5
|
+
//! of `rfd`'s dialog entry points are blocking, which is acceptable here: the
|
|
6
|
+
//! module has a thread of its own (`THREAD = Own`), so a panel left open blocks
|
|
7
|
+
//! neither JS nor a frame.
|
|
8
|
+
//!
|
|
9
|
+
//! # Open question: dialogs on macOS and the main thread
|
|
10
|
+
//!
|
|
11
|
+
//! The design flags that macOS may require the panel to be created on the main
|
|
12
|
+
//! thread, and that this has to be confirmed under GPUI's run loop before it can
|
|
13
|
+
//! be called done. What `rfd` 0.15's blocking API actually does on macOS is
|
|
14
|
+
//! dispatch the panel onto the main queue when the process already has a running
|
|
15
|
+
//! `NSApplication`, run it inline when the caller is already on the main thread,
|
|
16
|
+
//! and panic when neither holds. This module is on its own thread, so the first
|
|
17
|
+
//! branch is the one that applies, and the call should hop to the main thread and
|
|
18
|
+
//! block this module's thread until the panel closes.
|
|
19
|
+
//!
|
|
20
|
+
//! That is the theory, and it is **not yet verified** under GPUI's run loop. The
|
|
21
|
+
//! spike is: run the app, open a dialog from JS, and check that the panel
|
|
22
|
+
//! appears, that no main-thread warning is logged, that the module thread
|
|
23
|
+
//! resumes when it closes, and that the app does not deadlock while the panel is
|
|
24
|
+
//! up. If any of that fails, the fallback is the one the design names: a second
|
|
25
|
+
//! module, `GpuiFsDialogs`, with `THREAD = Main`, holding only the two pickers;
|
|
26
|
+
//! the rest of this module stays off the main thread. Attaching the panel to the
|
|
27
|
+
//! calling window (a sheet on macOS) is a further step that needs a host
|
|
28
|
+
//! function exposing the active window's raw handle for `rfd`'s `set_parent`,
|
|
29
|
+
//! which does not exist yet either.
|
|
30
|
+
|
|
31
|
+
use std::path::{Path, PathBuf};
|
|
32
|
+
use std::process::Command;
|
|
33
|
+
|
|
34
|
+
use rn_gpui_module::ModuleError;
|
|
35
|
+
|
|
36
|
+
use crate::errors;
|
|
37
|
+
use crate::{DialogFilter, OpenDialogOptions, SaveDialogOptions};
|
|
38
|
+
|
|
39
|
+
/// The open picker. An empty list means the user cancelled, which is what the JS
|
|
40
|
+
/// layer expects; a cancelled dialog is not a failure.
|
|
41
|
+
pub fn show_open(options: OpenDialogOptions) -> Result<Vec<String>, ModuleError> {
|
|
42
|
+
let dialog = configured(
|
|
43
|
+
options.title.as_deref(),
|
|
44
|
+
options.default_path.as_deref(),
|
|
45
|
+
&options.filters,
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
let picked = if options.directory {
|
|
49
|
+
// A folder picker cannot return files, and `pick_folder` selects a
|
|
50
|
+
// single folder — rfd's blocking folder call has no multi-select mode.
|
|
51
|
+
// So `multiple` is honoured only as far as the platform allows: a
|
|
52
|
+
// caller asking for `{ directory: true, multiple: true }` gets at most
|
|
53
|
+
// one path, which is better than refusing the call outright.
|
|
54
|
+
dialog.pick_folder().into_iter().collect()
|
|
55
|
+
} else if options.multiple {
|
|
56
|
+
dialog.pick_files().unwrap_or_default()
|
|
57
|
+
} else {
|
|
58
|
+
dialog.pick_file().into_iter().collect()
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
Ok(utf8_paths(picked))
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/// The save picker. `None` means the user cancelled; no file is created here
|
|
65
|
+
/// either way, the caller decides what to write.
|
|
66
|
+
pub fn show_save(options: SaveDialogOptions) -> Result<Option<String>, ModuleError> {
|
|
67
|
+
let (_, from_path) = split_default_path(options.default_path.as_deref());
|
|
68
|
+
// `defaultName` wins over the name implied by `defaultPath`, but a path
|
|
69
|
+
// ending in a file name is still the only hint a caller may give.
|
|
70
|
+
let name = options
|
|
71
|
+
.default_name
|
|
72
|
+
.filter(|name| !name.trim().is_empty())
|
|
73
|
+
.or(from_path);
|
|
74
|
+
|
|
75
|
+
let mut dialog = configured(
|
|
76
|
+
options.title.as_deref(),
|
|
77
|
+
options.default_path.as_deref(),
|
|
78
|
+
&options.filters,
|
|
79
|
+
);
|
|
80
|
+
if let Some(name) = name {
|
|
81
|
+
dialog = dialog.set_file_name(name);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
let picked = dialog.save_file();
|
|
85
|
+
// The picker never writes, so a chosen name the bridge cannot represent is
|
|
86
|
+
// reported as "nothing chosen" rather than as an error the user has no way
|
|
87
|
+
// to act on; the alternative is a file the caller would try to write at a
|
|
88
|
+
// path JS could not have named.
|
|
89
|
+
Ok(picked.as_deref().and_then(utf8_path))
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/// Shows `path` in the platform's file manager, selected where the platform has
|
|
93
|
+
/// a verb for it.
|
|
94
|
+
pub fn reveal(path: String) -> Result<(), ModuleError> {
|
|
95
|
+
ensure_exists(&path)?;
|
|
96
|
+
run(reveal_command(Path::new(&path)), &path)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/// Opens `path` with whatever the platform associates with it.
|
|
100
|
+
pub fn open_external(path: String) -> Result<(), ModuleError> {
|
|
101
|
+
ensure_exists(&path)?;
|
|
102
|
+
run(open_external_command(Path::new(&path)), &path)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/// A dialog carrying everything the three calls configure the same way: title,
|
|
106
|
+
/// starting directory, and the extension filters.
|
|
107
|
+
fn configured(
|
|
108
|
+
title: Option<&str>,
|
|
109
|
+
default_path: Option<&str>,
|
|
110
|
+
filters: &[DialogFilter],
|
|
111
|
+
) -> rfd::FileDialog {
|
|
112
|
+
let mut dialog = rfd::FileDialog::new();
|
|
113
|
+
if let Some(title) = title {
|
|
114
|
+
dialog = dialog.set_title(title);
|
|
115
|
+
}
|
|
116
|
+
if let Some(directory) = split_default_path(default_path).0 {
|
|
117
|
+
dialog = dialog.set_directory(directory);
|
|
118
|
+
}
|
|
119
|
+
for filter in filters {
|
|
120
|
+
// An empty extension list carries no information, and on some backends
|
|
121
|
+
// a filter is applied as an allow-list, which would hide every file.
|
|
122
|
+
if !filter.extensions.is_empty() {
|
|
123
|
+
dialog = dialog.add_filter(filter.name.as_str(), &filter.extensions);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
dialog
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/// Splits `defaultPath` into the directory a picker should open in, and the file
|
|
130
|
+
/// name to prefill when the path names a file rather than a folder.
|
|
131
|
+
///
|
|
132
|
+
/// The two pickers need different halves: the open dialog only starts in the
|
|
133
|
+
/// directory, the save dialog also offers the name.
|
|
134
|
+
fn split_default_path(default_path: Option<&str>) -> (Option<PathBuf>, Option<String>) {
|
|
135
|
+
let Some(raw) = default_path.map(str::trim).filter(|path| !path.is_empty()) else {
|
|
136
|
+
return (None, None);
|
|
137
|
+
};
|
|
138
|
+
let path = Path::new(raw);
|
|
139
|
+
|
|
140
|
+
let is_directory = match std::fs::metadata(path) {
|
|
141
|
+
Ok(metadata) => metadata.is_dir(),
|
|
142
|
+
// A save dialog is usually given a path that does not exist yet, so fall
|
|
143
|
+
// back to the spelling: a trailing separator promises a directory, and
|
|
144
|
+
// anything else is a proposed file name whose parent is the start.
|
|
145
|
+
Err(_) => ends_with_separator(raw),
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
if is_directory {
|
|
149
|
+
(Some(path.to_path_buf()), None)
|
|
150
|
+
} else {
|
|
151
|
+
(
|
|
152
|
+
path.parent()
|
|
153
|
+
.filter(|parent| !parent.as_os_str().is_empty())
|
|
154
|
+
.map(Path::to_path_buf),
|
|
155
|
+
path.file_name()
|
|
156
|
+
.and_then(|name| name.to_str())
|
|
157
|
+
.map(str::to_string),
|
|
158
|
+
)
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/// Whether a path is spelled as a directory on any of the platforms this module
|
|
163
|
+
/// runs on, so a Windows path still splits correctly when read elsewhere.
|
|
164
|
+
fn ends_with_separator(path: &str) -> bool {
|
|
165
|
+
path.ends_with('/') || path.ends_with('\\')
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/// The picked paths as strings, dropping any the bridge cannot represent.
|
|
169
|
+
///
|
|
170
|
+
/// A Unix file name need not be UTF-8, but JS can only hold strings. Failing the
|
|
171
|
+
/// whole selection because one of several files has an unusual name would throw
|
|
172
|
+
/// away the files the user can use, so the others are returned instead.
|
|
173
|
+
fn utf8_paths(paths: Vec<PathBuf>) -> Vec<String> {
|
|
174
|
+
paths
|
|
175
|
+
.into_iter()
|
|
176
|
+
.filter_map(|path| utf8_path(&path))
|
|
177
|
+
.collect()
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
fn utf8_path(path: &Path) -> Option<String> {
|
|
181
|
+
path.to_str().map(str::to_string)
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/// Fails with the same code `stat` would before a platform command can turn the
|
|
185
|
+
/// problem into an opaque non-zero exit.
|
|
186
|
+
fn ensure_exists(path: &str) -> Result<(), ModuleError> {
|
|
187
|
+
// `symlink_metadata` rather than `metadata`: a dangling symlink is still a
|
|
188
|
+
// row in Finder or Explorer, so revealing it is meaningful.
|
|
189
|
+
std::fs::symlink_metadata(path)
|
|
190
|
+
.map(|_| ())
|
|
191
|
+
.map_err(|error| errors::from_io(error, path))
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/// A command line, kept separate from running it so the platform branches can be
|
|
195
|
+
/// asserted on without opening anything.
|
|
196
|
+
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
197
|
+
struct CommandSpec {
|
|
198
|
+
program: &'static str,
|
|
199
|
+
args: Vec<String>,
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/// The command that reveals `path` in the platform's file manager.
|
|
203
|
+
fn reveal_command(path: &Path) -> CommandSpec {
|
|
204
|
+
let shown = path.to_string_lossy().into_owned();
|
|
205
|
+
|
|
206
|
+
#[cfg(target_os = "macos")]
|
|
207
|
+
{
|
|
208
|
+
// `open -R` is the only thing that selects the item rather than opening
|
|
209
|
+
// its containing folder.
|
|
210
|
+
CommandSpec {
|
|
211
|
+
program: "open",
|
|
212
|
+
args: vec!["-R".to_string(), shown],
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
#[cfg(target_os = "windows")]
|
|
217
|
+
{
|
|
218
|
+
// `/select,<path>` is a single argument, comma included.
|
|
219
|
+
//
|
|
220
|
+
// Note: explorer.exe is known to return exit code 1 even when it does
|
|
221
|
+
// open the window, which the non-zero check below would report as a
|
|
222
|
+
// failure. That needs a check on a Windows machine; if it does bite,
|
|
223
|
+
// this branch should treat a successful spawn as success instead of
|
|
224
|
+
// reading the exit code.
|
|
225
|
+
CommandSpec {
|
|
226
|
+
program: "explorer",
|
|
227
|
+
args: vec![format!("/select,{shown}")],
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
|
|
232
|
+
{
|
|
233
|
+
// `xdg-open` has no portable "select this item" verb, so the containing
|
|
234
|
+
// folder is the closest equivalent. A path with no parent is its own
|
|
235
|
+
// directory and is opened as it is.
|
|
236
|
+
let folder = path.parent().unwrap_or(path);
|
|
237
|
+
CommandSpec {
|
|
238
|
+
program: "xdg-open",
|
|
239
|
+
args: vec![folder.to_string_lossy().into_owned()],
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/// The command that opens `path` with the platform's default application.
|
|
245
|
+
fn open_external_command(path: &Path) -> CommandSpec {
|
|
246
|
+
let shown = path.to_string_lossy().into_owned();
|
|
247
|
+
|
|
248
|
+
#[cfg(target_os = "macos")]
|
|
249
|
+
{
|
|
250
|
+
CommandSpec {
|
|
251
|
+
program: "open",
|
|
252
|
+
args: vec![shown],
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
#[cfg(target_os = "windows")]
|
|
257
|
+
{
|
|
258
|
+
// `start` reads its first quoted argument as the window title, so the
|
|
259
|
+
// empty string is required for a path containing spaces to survive.
|
|
260
|
+
CommandSpec {
|
|
261
|
+
program: "cmd",
|
|
262
|
+
args: vec!["/C".to_string(), "start".to_string(), String::new(), shown],
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
|
|
267
|
+
{
|
|
268
|
+
CommandSpec {
|
|
269
|
+
program: "xdg-open",
|
|
270
|
+
args: vec![shown],
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/// Runs a command and turns a refusal into a Node-shaped error.
|
|
276
|
+
///
|
|
277
|
+
/// `Command` is used without a shell, so a path is one argument no matter what
|
|
278
|
+
/// characters it contains; quoting and escaping are the platform's problem, not
|
|
279
|
+
/// a shell's.
|
|
280
|
+
fn run(spec: CommandSpec, path: &str) -> Result<(), ModuleError> {
|
|
281
|
+
let status = Command::new(spec.program)
|
|
282
|
+
.args(&spec.args)
|
|
283
|
+
.status()
|
|
284
|
+
// The program itself could not be started (missing, or not executable),
|
|
285
|
+
// which is an I/O failure on the path the caller asked about.
|
|
286
|
+
.map_err(|error| errors::from_io(error, path))?;
|
|
287
|
+
|
|
288
|
+
if status.success() {
|
|
289
|
+
return Ok(());
|
|
290
|
+
}
|
|
291
|
+
Err(errors::invalid(
|
|
292
|
+
"EIO",
|
|
293
|
+
format!(
|
|
294
|
+
"EIO: i/o error, {path} ({} exited with {status})",
|
|
295
|
+
spec.program
|
|
296
|
+
),
|
|
297
|
+
))
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
#[cfg(test)]
|
|
301
|
+
mod tests {
|
|
302
|
+
use super::*;
|
|
303
|
+
|
|
304
|
+
#[test]
|
|
305
|
+
fn a_default_path_that_names_a_file_splits_into_parent_and_name() {
|
|
306
|
+
let file = std::env::temp_dir().join("rn-gpui-fs-dialog-default.txt");
|
|
307
|
+
let (directory, name) = split_default_path(Some(&file.to_string_lossy()));
|
|
308
|
+
assert_eq!(directory.as_deref(), Some(std::env::temp_dir().as_path()));
|
|
309
|
+
assert_eq!(name.as_deref(), Some("rn-gpui-fs-dialog-default.txt"));
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
#[test]
|
|
313
|
+
fn a_default_path_that_names_an_existing_directory_stays_a_directory() {
|
|
314
|
+
let directory = std::env::temp_dir();
|
|
315
|
+
let (found, name) = split_default_path(Some(&directory.to_string_lossy()));
|
|
316
|
+
assert_eq!(found, Some(directory));
|
|
317
|
+
assert_eq!(name, None);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
#[test]
|
|
321
|
+
fn a_bare_file_name_has_no_directory_to_open_in() {
|
|
322
|
+
let (directory, name) = split_default_path(Some("rn-gpui-fs-report.csv"));
|
|
323
|
+
assert_eq!(directory, None);
|
|
324
|
+
assert_eq!(name.as_deref(), Some("rn-gpui-fs-report.csv"));
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
#[test]
|
|
328
|
+
fn a_trailing_separator_marks_a_path_that_does_not_exist_as_a_directory() {
|
|
329
|
+
let missing = std::env::temp_dir().join("rn-gpui-fs-not-created");
|
|
330
|
+
let spelled = format!("{}/", missing.to_string_lossy());
|
|
331
|
+
let (directory, name) = split_default_path(Some(&spelled));
|
|
332
|
+
assert_eq!(directory, Some(missing));
|
|
333
|
+
assert_eq!(name, None);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
#[test]
|
|
337
|
+
fn no_default_path_means_no_directory_and_no_name() {
|
|
338
|
+
assert_eq!(split_default_path(None), (None, None));
|
|
339
|
+
assert_eq!(split_default_path(Some(" ")), (None, None));
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
#[test]
|
|
343
|
+
fn reveal_targets_the_platform_file_manager() {
|
|
344
|
+
let spec = reveal_command(Path::new("/tmp/example.txt"));
|
|
345
|
+
#[cfg(target_os = "macos")]
|
|
346
|
+
{
|
|
347
|
+
assert_eq!(spec.program, "open");
|
|
348
|
+
assert_eq!(spec.args, ["-R", "/tmp/example.txt"]);
|
|
349
|
+
}
|
|
350
|
+
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
|
|
351
|
+
{
|
|
352
|
+
assert_eq!(spec.program, "xdg-open");
|
|
353
|
+
// Only the folder, because xdg-open cannot select an item in it.
|
|
354
|
+
assert_eq!(spec.args, ["/tmp"]);
|
|
355
|
+
}
|
|
356
|
+
#[cfg(target_os = "windows")]
|
|
357
|
+
{
|
|
358
|
+
assert_eq!(spec.program, "explorer");
|
|
359
|
+
assert_eq!(spec.args, ["/select,/tmp/example.txt"]);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
#[test]
|
|
364
|
+
fn open_external_hands_the_path_to_the_default_application() {
|
|
365
|
+
let spec = open_external_command(Path::new("/tmp/example.txt"));
|
|
366
|
+
assert_eq!(
|
|
367
|
+
spec.args.last().map(String::as_str),
|
|
368
|
+
Some("/tmp/example.txt")
|
|
369
|
+
);
|
|
370
|
+
#[cfg(target_os = "macos")]
|
|
371
|
+
{
|
|
372
|
+
assert_eq!(spec.program, "open");
|
|
373
|
+
assert_eq!(spec.args.len(), 1);
|
|
374
|
+
}
|
|
375
|
+
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
|
|
376
|
+
assert_eq!(spec.program, "xdg-open");
|
|
377
|
+
#[cfg(target_os = "windows")]
|
|
378
|
+
{
|
|
379
|
+
assert_eq!(spec.program, "cmd");
|
|
380
|
+
// `/C start "" <path>`: the empty argument is the title `start`
|
|
381
|
+
// would otherwise take the path for.
|
|
382
|
+
assert_eq!(&spec.args[..3], ["/C", "start", ""]);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
#[test]
|
|
387
|
+
fn a_path_with_spaces_is_still_a_single_argument() {
|
|
388
|
+
let spec = open_external_command(Path::new("/tmp/two words.txt"));
|
|
389
|
+
assert_eq!(
|
|
390
|
+
spec.args.last().map(String::as_str),
|
|
391
|
+
Some("/tmp/two words.txt")
|
|
392
|
+
);
|
|
393
|
+
}
|
|
394
|
+
}
|