@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 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
- # Temporary Holding Version
1
+ # @react-native-gpui/fs
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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" }
@@ -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
+ }