@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.
@@ -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" }
@@ -0,0 +1,3 @@
1
+ fn main() {
2
+ rn_gpui_module_build::generate(&["../src/NativeFs.ts"]);
3
+ }
@@ -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
+ }