@react-native-gpui/fs 0.0.0-stage → 0.0.1-alpha.1
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 +13 -0
- package/LICENSE +21 -0
- package/README.md +36 -2
- package/desktop/Cargo.toml +29 -0
- package/desktop/build.rs +3 -0
- package/desktop/src/dialogs.rs +394 -0
- package/desktop/src/dirs.rs +363 -0
- package/desktop/src/download.rs +329 -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 +286 -0
- package/desktop/src/links.rs +336 -0
- package/desktop/src/module.rs +192 -0
- package/desktop/src/paths.rs +231 -0
- package/desktop/src/upload.rs +237 -0
- package/desktop/src/watching.rs +392 -0
- package/package.json +38 -4
- package/react-native.config.js +9 -0
- package/src/NativeFs.ts +235 -0
- package/src/constants.ts +43 -0
- package/src/download.ts +110 -0
- package/src/index.ts +788 -0
- package/src/promises.ts +771 -0
- package/src/shared.ts +539 -0
- package/src/stats.ts +196 -0
- package/src/upload.ts +97 -0
- package/src/watch.ts +141 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to `@react-native-gpui/fs` are listed here. Each version's section becomes the notes of its GitHub release.
|
|
4
|
+
|
|
5
|
+
## 0.0.1-alpha.1 - 2026-11-12
|
|
6
|
+
|
|
7
|
+
- Add `upload(url, path, options)`, which streams a file, or a range of one, as an HTTP request body in small blocks, reports progress, honours an `AbortSignal` and resolves with the response status, headers and the start of its body.
|
|
8
|
+
- Add `download(url, path, options)`, which streams an HTTP response to a file through `<path>.part`, reports progress, honours an `AbortSignal` and rejects on a non-2xx status.
|
|
9
|
+
|
|
10
|
+
## 0.0.1-alpha.0 - 2026-11-01
|
|
11
|
+
|
|
12
|
+
- First release.
|
|
13
|
+
- File system access for desktop apps.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Streetwriters
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,37 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @react-native-gpui/fs
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Our own module: a file system API for desktop apps, shaped like Node's `node:fs`, with file dialogs and the operating system's well-known folders.
|
|
4
|
+
|
|
5
|
+
Part of [react-native-gpui](https://github.com/streetwriters/react-native-gpui), React Native for macOS, Windows and Linux.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
npm install @react-native-gpui/fs
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Usage
|
|
14
|
+
|
|
15
|
+
```js
|
|
16
|
+
import fs, { dialog, paths } from '@react-native-gpui/fs';
|
|
17
|
+
|
|
18
|
+
const text = fs.readFileSync(`${paths.documents}/notes.txt`, 'utf8');
|
|
19
|
+
await fs.promises.writeFile(
|
|
20
|
+
`${paths.appData}/state.json`,
|
|
21
|
+
JSON.stringify(state),
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
const [file] = await dialog.open({
|
|
25
|
+
filters: [{ name: 'Text', extensions: ['txt'] }],
|
|
26
|
+
});
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
The default export has the synchronous, callback and promise forms of the common `node:fs` calls, with Node's encodings, `Stats`, `Dirent` and error codes. The module runs on its own thread, so slow files never block JavaScript or the interface.
|
|
30
|
+
|
|
31
|
+
## Documentation
|
|
32
|
+
|
|
33
|
+
[File system](docs/file-system.md)
|
|
34
|
+
|
|
35
|
+
## License
|
|
36
|
+
|
|
37
|
+
MIT. See [LICENSE](LICENSE).
|
|
@@ -0,0 +1,29 @@
|
|
|
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
|
+
# Streams a download; rustls with the system certificate store, as the upload module does.
|
|
24
|
+
ureq = { version = "3", default-features = false, features = ["rustls", "platform-verifier"] }
|
|
25
|
+
# Hands a download's result back to the async method.
|
|
26
|
+
futures = "0.3"
|
|
27
|
+
|
|
28
|
+
[build-dependencies]
|
|
29
|
+
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
|
+
}
|