Compare commits

...
627 changed files with 146042 additions and 9 deletions
+3
View File
@@ -0,0 +1,3 @@
vendor/react-native-gpui/** linguist-vendored
packages/app/gpui/package-lock.json linguist-generated
packages/app/gpui/desktop/Cargo.lock linguist-generated
+6
View File
@@ -98,3 +98,9 @@ app.json
# Project-local Serena configuration and memories.
.serena/
# Vendored experimental React Native desktop runtime.
!vendor/
!vendor/react-native-gpui/
!vendor/react-native-gpui/**
!packages/app/gpui/app.json
+1 -1
View File
@@ -11,5 +11,5 @@
"arrowParens": "always",
"bracketSameLine": false,
"bracketSpacing": true,
"ignorePatterns": ["*.lock", "**/*.gen.ts", "**/*.gen.tsx"]
"ignorePatterns": ["*.lock", "**/*.gen.ts", "**/*.gen.tsx", "vendor/**"]
}
+1 -1
View File
@@ -3,7 +3,7 @@
"options": {
"typeAware": false
},
"ignorePatterns": [".dev/**"],
"ignorePatterns": [".dev/**", "vendor/**"],
"plugins": ["react", "react-perf", "unicorn", "typescript", "oxc", "import", "promise"],
"jsPlugins": ["./scripts/oxlint/paseo-plugin.mjs"],
"categories": {
+7
View File
@@ -29,6 +29,13 @@ the titlebar row. Production builds leave the variable unset and show no label.
`npm run dev` is only a shorthand for `npm run dev:server`. Keep `127.0.0.1:6767` for the packaged app and production-style `~/.paseo` state.
## Experimental GPUI client
The opt-in [GPUI target](../packages/app/gpui/README.md) runs the existing React
Native app on Linux. Its contributor guide covers setup, the renderer comparison,
unsupported features, native checks, and experiments to pick up. It connects to
an already-running daemon and does not replace the Electron dev command.
## Nix desktop package
The flake exposes `packages.<system>.desktop` on Linux and macOS:
+5 -1
View File
@@ -122,7 +122,11 @@
"release:promote": "npm run release:check && npm run version:all:promote && npm run release:publish && npm run release:push",
"release:patch": "npm run release:check && npm run version:all:patch && npm run release:publish && npm run release:push",
"release:minor": "npm run release:check && npm run version:all:minor && npm run release:publish && npm run release:push",
"release:major": "npm run release:check && npm run version:all:major && npm run release:publish && npm run release:push"
"release:major": "npm run release:check && npm run version:all:major && npm run release:publish && npm run release:push",
"gpui:install": "node scripts/gpui.mjs install",
"gpui:bundle": "node scripts/gpui.mjs bundle",
"build:gpui": "node scripts/gpui.mjs build",
"dev:gpui": "node scripts/gpui.mjs run"
},
"devDependencies": {
"@types/ws": "^8.5.14",
+3
View File
@@ -0,0 +1,3 @@
node_modules/
desktop/build/
desktop/autolink/
+3
View File
@@ -0,0 +1,3 @@
workspaces=false
legacy-peer-deps=true
install-links=true
+202
View File
@@ -0,0 +1,202 @@
# Experimental GPUI client
Run Paseo's existing React Native app in a native GPUI window on Linux. This is
a community starting point: the real workspace UI runs, but terminal/diff
renderers and several native services still need porting. The normal desktop
build remains Electron. No daemon changes or release installers are included.
## Run it
Start from the repository's [development setup](../../../docs/development.md).
A fresh checkout needs the root dependencies and workspace build outputs:
```sh
npm ci
npm run build:server
npm run gpui:install
EXPO_PUBLIC_LOCAL_DAEMON=localhost:6768 npm run dev:gpui
```
Use an already-running compatible daemon and replace `localhost:6768` with its
endpoint. These GPUI commands never start or restart a daemon. The address is
inlined at bundle time. The app's normal connection/authentication rules apply.
You also need Rust stable through rustup, CMake, Ninja, Clang, Linux X11/Wayland
development libraries, and Vulkan. The tested Linux x86_64 host already had
most build tools; it additionally needed `libxkbcommon-x11-dev`. A clean-machine
prerequisite check is still needed. The first native build downloads dependencies
and builds Rust/C++; allow several minutes and several GB of disk.
`dev:gpui` bundles the app, autolinks native modules, builds, and opens the window.
Restart after editing. It uses an offline production JS bundle; Fast Refresh is
not wired into this launcher. `build:gpui` builds without opening a window.
`gpui:bundle -- --dev false` only writes the JS bundle, source map, and assets to
`.artifacts/gpui/`. Bundle validation checks that the real routes are present and
that no `react-native-webview` modules were included.
## Approach and renderer choice
```text
Existing Paseo RootApp + Expo Router + state/client/plugin code
|
React in Hermes
|
React Native Fabric
|
Rust GPUI native renderer
|
Linux GPU window
Existing Paseo client <-- WebSocket --> separately running Node daemon
```
The separate npm project is deliberate: the selected runtime uses RN 0.87.1,
while Paseo's mobile app uses RN 0.86.3. Metro redirects native dependencies to
that installation and resolves the rest from the app. This avoids changing the
mobile dependency baseline just to try another renderer. It also creates a
version-maintenance cost that needs testing before this becomes a supported target.
The choice was to preserve the existing app's RN component and library contracts:
| Candidate | Integration model | Consequence for this experiment |
| ----------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| [Streetwriters react-native-gpui](https://github.com/streetwriters/react-native-gpui/tree/de11aa265e6fb36c80e8a30315bce6d34a9be7d6) | React Native Fabric, Hermes, and GPUI, with ports of screens, gestures, Reanimated/worklets, safe areas, and SVG | Chosen because it lets the existing RN app reach a real Linux window with targeted adapters. These ports are still experimental. |
| [natew/react-native-gpui](https://github.com/natew/react-native-gpui/tree/b2254543357f02fdd983d0d8a584d1d1bd93f5da) | Custom React reconciler with an RN-like component surface; its README targets macOS arm64/Metal | Useful implementation reference. Running this Linux app would also require platform work and compatibility with the RN libraries it imports. |
| [GPUIX](https://github.com/remorses/gpuix) | Custom React/Solid renderer, its own JSX primitives, and Node-API native bindings; supports Bun/Node execution | Also native GPUI, with interesting packaging and specialized surfaces. Adopting it would require replacing or adapting Paseo's RN component/library contracts. It remains an alternative worth testing, not a rejected renderer. |
| Existing Electron target | RN Web inside Chromium, with the desktop host bridge | Preserves the working desktop feature set, but does not answer the experiment's question of running the app through a native renderer. |
This is an integration choice, not a performance comparison. There are no
controlled CPU, memory, startup, or frame-time results against these alternatives.
## What belongs in the change
| Area | Why it is here |
| ---------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `vendor/react-native-gpui/` | Editable, pinned runtime source. This accounts for roughly 130,000 lines of the diff. GitHub marks it as vendored; inspect [UPSTREAM](../../../vendor/react-native-gpui/UPSTREAM) for provenance and the two local patches. |
| npm and Cargo lockfiles | Reproduce the experiment's dependencies. Roughly 15,000 generated lines, collapsed on GitHub. |
| `desktop/`, `index.js`, `routes.js`, Metro/Babel config, root launcher | Register the full app, select the native runtime, compile native services, and bundle the actual route graph. The explicit route context prevents an apparently successful build with no app routes. |
| `adapters/` | Desktop implementations of the app-used APIs, plus explicit unavailable-capability implementations for mobile features. About 700 lines; these are limited ports, not complete replacements for Expo/Unistyles. |
| Shared app edits | One capability gate for alpha-mask shimmer. GPUI shows the logo and labels without that effect. Existing web/iOS/Android paths keep their behavior. |
| Native tests and this guide | Make the working slice reproducible and give contributors bounded follow-up work. |
Upstream examples, its tester/testing package, website, unused fonts/vector-icon
ports, starter navigation dependencies, sample icon, and unused nested Cargo config
are omitted. Build outputs, native binaries, downloaded SDKs, logs, screenshots,
and the exploratory size-measurement copy stay out of Git.
The retained runtime still includes its build machinery, platform backends,
headers, and tests. Arbitrarily deleting those to reduce line count makes an
upstream refresh harder. Vendoring can later be replaced by a pinned dependency
once the local patches are upstreamed and distribution is reproducible.
### Editing the runtime
Edit `vendor/react-native-gpui`, then run `npm run gpui:install` before rebuilding.
npm copies local packages rather than symlinking them; the install command
refreshes those copies even when their versions have not changed.
There are two local renderer changes:
- `node_view.rs`: a `pointerEvents="box-none"` view must not install its own hit
area. Before the patch, Paseo's empty portal overlay intercepted sidebar clicks.
- `StatusBar.desktop.js`: desktop windows do not have the mobile OS status bar.
GPUI remains a pinned Cargo git dependency. Upstream tooling downloads Hermes
and React Native prebuilt libraries. The snapshot is not an offline or fully
source-built distribution, and release packaging has not been verified.
## Working slice and limits
Verified on Linux x86_64: all 24 app routes in the bundle, connection to the dev
daemon, the real project/workspace sidebar, opening an existing workspace,
existing message rendering, and native composer typing/deletion. The native
regression test restores the draft and does not send a message. Rust tests cover
SHA-256/random-byte bounds and real SQLite parameter binding/transaction rollback.
The following are not feature-parity claims:
| Area | Current state |
| ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Styling | Adapter evaluates existing Unistyles theme/style factories and breakpoints. No Nitro shadow-tree integration. Full theme/resize behavior needs QA. |
| Storage/crypto/localization | Native filesystem + SQLite + SHA-256 + OS randomness. Intl ships English locale data and IANA time zones. The Rust service contains Linux-specific OS access. |
| Clipboard/files | Text clipboard, native file dialogs, PNG/JPEG picking, and download/export adapters exist. End-to-end dialog/transfer coverage is still missing. |
| Voice/camera/push/power inhibition | Unavailable. Requests either report the missing capability or use the app's existing unavailable-capability implementation. |
| Image conversion/image clipboard | Unavailable. The picker accepts PNG/JPEG without conversion. |
| Mermaid/HTML/document previews | Mermaid and HTML show source. Document previews report unavailable. No embedded browser backend. |
| Terminals | The mobile view expects an Expo `PaseoTerminal` component. No GPUI implementation yet. |
| Diffs | The mobile surface uses React Native Skia. No GPUI drawing backend yet. |
| Plugins | The existing loader evaluates JS and injects host React/RN. No end-to-end plugin verification on GPUI yet. |
| Other platforms | The upstream runtime targets macOS/Windows too; this Paseo integration has only been exercised on Linux. |
The initial stripped development executable was about 68 MiB, with about 10 MiB
of Hermes/JSI libraries and a 21 MiB JS bundle, approximately 100 MiB uncompressed.
That measurement excludes the daemon, is not an optimized release installer, and
does not establish memory usage or a size advantage over another renderer.
## Experiments to pick up
Each experiment should preserve the real app and demonstrate a user workflow.
1. **Native terminal.** Implement a GPUI component for the existing
[`PaseoTerminalView`](../modules/paseo-terminal/index.tsx) frame contract.
Keep the JS headless xterm emulator, transport, and frame generation initially.
Verify resize/replay, keyboard/IME/paste, scrollback/selection, and the existing
Unicode corpus. [Terminal rendering](../../../docs/terminal-rendering.md)
explains why shaping must preserve xterm's cell columns.
2. **Diff surface.** Keep `src/git/diff-document`'s model and review actions;
add GPUI text measurement and painting instead of the Skia surface. Test
unified/split views, wrapping, scrolling, selection, and inline reviews on
large real diffs. The [C++ layout experiment](../modules/paseo-diff-prototype/README.md)
is a separate candidate for shared geometry; it is not a finished renderer.
3. **Plugins.** Load a small plugin into an isolated daemon, open its real screen
or workspace panel, call a plugin RPC, and reload it. Verify subscriptions and
cleanup. Track unsupported services explicitly instead of adding fake APIs.
4. **Native services and styling.** Exercise dialogs, downloads, clipboard,
persistence/restart, theme switching, resizing, and focus. Replace the narrow
adapters with complete implementations only where the app needs them.
5. **Portability and packaging.** Build on clean Linux, then macOS/Windows. Replace
Linux-only randomness/timezone access, check font/shaping and IME behavior, and
verify relocatable libraries/assets. Measure a release build and a controlled
workflow before claiming a performance or size improvement.
6. **Bundled daemon, independently.** First run the unchanged daemon under Bun
in isolation. Verify PTY/native dependencies, terminal-worker subprocesses,
provider launches, and dynamic plugins. Then compare standalone/AOT packaging
and size. This branch neither ports the daemon nor embeds it in the UI process.
A Rust/GPUI terminal or diff painter can share desktop implementation across the
upstream renderer's supported OSes. Reusing it on mobile still requires mobile
bindings and graphics integration; choosing Rust or C++ alone does not provide that.
## Verification
Run the normal checks from the root. GPUI adapters are excluded from the mobile
TypeScript project; its typecheck alone does not validate this target.
```sh
npm run typecheck
npm run lint
npm run format:check
```
For the native UI regression, use a daemon with an existing workspace and build
with the driver. Run the native app on an isolated display when testing on a
machine in use:
```sh
EXPO_PUBLIC_LOCAL_DAEMON=localhost:6768 npm run build:gpui -- --features test-driver,visual-capture
RN_GPUI_TEST_DRIVER=1 RN_GPUI_TEST_DRIVER_TOKEN=local-test \
RN_GPUI_DEV_MODE=0 RN_GPUI_BUNDLE="$PWD/.artifacts/gpui/paseo.bundle" \
RN_GPUI_ASSETS_DIR="$PWD/.artifacts/gpui/assets/assets" \
xvfb-run -a -s '-screen 0 2560x1600x24' \
packages/app/gpui/desktop/target/debug/react-native-gpui-paseo-gpui
```
Use the printed `RN_GPUI_TEST_DRIVER_PORT` in another shell:
```sh
PASEO_GPUI_TEST_URL=ws://127.0.0.1:PORT PASEO_GPUI_TEST_TOKEN=local-test \
npx vitest run packages/app/gpui/native.test.ts --bail=1
```
Without a driver URL the test is skipped. Put diagnostic logs/captures in
`.artifacts/gpui/`; do not commit them or publish private workspace contents as QA.
@@ -0,0 +1,73 @@
import { promises as fs, paths } from "@react-native-gpui/fs";
import Desktop from "../desktop/src/NativePaseoDesktop";
let writes = Promise.resolve();
const directory = () => `${paths.appData}/paseo-gpui/storage`;
const filename = (key) => `${directory()}/${Desktop.sha256(key)}.json`;
async function read(key) {
try {
return JSON.parse(await fs.readFile(filename(key), "utf8")).value;
} catch (error) {
if (error.code === "ENOENT") return null;
throw error;
}
}
function mutate(operation) {
const result = writes.then(operation);
writes = result.catch(() => {});
return result;
}
async function write(key, value) {
await fs.mkdir(directory(), { recursive: true });
const target = filename(key);
await fs.writeFile(`${target}.tmp`, JSON.stringify({ key, value }), "utf8");
await fs.rename(`${target}.tmp`, target);
}
async function keys() {
let entries;
try {
entries = await fs.readdir(directory());
} catch (error) {
if (error.code === "ENOENT") return [];
throw error;
}
return Promise.all(
entries
.filter((name) => name.endsWith(".json"))
.map(async (name) => JSON.parse(await fs.readFile(`${directory()}/${name}`, "utf8")).key),
);
}
const storage = {
async getItem(key) {
await writes;
return read(key);
},
setItem(key, value) {
return mutate(() => write(key, value));
},
removeItem(key) {
return mutate(() => fs.rm(filename(key), { force: true }));
},
async getAllKeys() {
await writes;
return keys();
},
async multiGet(items) {
await writes;
return Promise.all(items.map(async (key) => [key, await read(key)]));
},
multiSet(items) {
return mutate(async () => {
for (const [key, value] of items) await write(key, value);
});
},
multiRemove(items) {
return mutate(async () => {
for (const key of items) await fs.rm(filename(key), { force: true });
});
},
clear() {
return mutate(() => fs.rm(directory(), { recursive: true, force: true }));
},
};
export default storage;
+21
View File
@@ -0,0 +1,21 @@
// Preserve the voice model's lifecycle without loading mobile-only Expo audio
// during app startup. Requests fail explicitly until a Linux audio backend exists.
export function createAudioEngine() {
const unavailable = async () => {
throw new Error("Voice capture and playback are not available in the GPUI build yet.");
};
return {
initialize: unavailable,
startCapture: unavailable,
play: unavailable,
async destroy() {},
async stopCapture() {},
stop() {},
clearQueue() {},
isPlaying: () => false,
isMuted: () => false,
toggleMute() {
throw new Error("Voice capture is not available in the GPUI build yet.");
},
};
}
+16
View File
@@ -0,0 +1,16 @@
import { TurboModuleRegistry } from "react-native";
const clipboard = TurboModuleRegistry.getEnforcing("Clipboard");
export const getStringAsync = () => clipboard.getString();
export async function setStringAsync(value) {
clipboard.setString(value);
return true;
}
export async function hasStringAsync() {
return (await getStringAsync()).length > 0;
}
export async function getImageAsync() {
throw new Error("Image clipboard is not implemented for GPUI yet");
}
export async function setImageAsync() {
throw new Error("Image clipboard is not implemented for GPUI yet");
}
+24
View File
@@ -0,0 +1,24 @@
import Desktop from "../desktop/src/NativePaseoDesktop";
export function getRandomValues(array) {
if (
!ArrayBuffer.isView(array) ||
array instanceof DataView ||
array instanceof Float32Array ||
array instanceof Float64Array
) {
throw new TypeError("getRandomValues requires an integer typed array");
}
if (array.byteLength > 65536) throw new RangeError("Random request exceeds 65536 bytes");
new Uint8Array(array.buffer, array.byteOffset, array.byteLength).set(
Desktop.randomBytes(array.byteLength),
);
return array;
}
export function randomUUID() {
return globalThis.crypto.randomUUID();
}
export const CryptoDigestAlgorithm = { SHA256: "SHA-256" };
export async function digestStringAsync(algorithm, text) {
if (algorithm !== "SHA-256") throw new Error(`Unsupported digest algorithm: ${algorithm}`);
return Desktop.sha256(text);
}
@@ -0,0 +1,14 @@
import { dialog, promises } from "@react-native-gpui/fs";
export async function getDocumentAsync(options = {}) {
const paths = await dialog.open({ multiple: options.multiple ?? false });
return {
canceled: paths.length === 0,
assets: await Promise.all(
paths.map(async (path) => ({
uri: `file://${path}`,
name: path.split("/").at(-1),
size: (await promises.stat(path)).size,
})),
),
};
}
@@ -0,0 +1,19 @@
import React from "react";
import { ScrollView, Text, View } from "react-native";
import { HighlightedCodeBlock } from "../../src/components/highlighted-code-block";
export function FileHtmlPreview({ html, testID }) {
return (
<ScrollView testID={testID}>
<Text>HTML rendering is not available in GPUI. Showing source.</Text>
<HighlightedCodeBlock code={html} language="html" />
</ScrollView>
);
}
export function DocumentFrame({ testID }) {
return (
<View testID={testID}>
<Text>Document preview is not available in the GPUI build yet.</Text>
</View>
);
}
@@ -0,0 +1,11 @@
import {
EventEmitter,
NativeModule,
SharedObject,
SharedRef,
} from "expo-modules-core/src/polyfill/CoreModule";
export function ensureNativeModulesAreInstalled() {
if (globalThis.expo) return;
globalThis.expo = { EventEmitter, NativeModule, SharedObject, SharedRef, modules: {} };
}
ensureNativeModulesAreInstalled();
@@ -0,0 +1,30 @@
import { Linking } from "react-native";
import { NativeModule } from "expo-modules-core/src/polyfill/CoreModule";
class DesktopLinking extends NativeModule {
initialURL = null;
subscription;
constructor() {
super();
Linking.getInitialURL()
.then((url) => {
this.initialURL = url;
if (url) this.emit("onURLReceived", { url });
return url;
})
.catch((error) => console.error("[GPUI linking]", error));
}
getLinkingURL = () => this.initialURL;
clearInitialURL() {
this.initialURL = null;
}
startObserving() {
this.subscription = Linking.addEventListener("url", (event) =>
this.emit("onURLReceived", event),
);
}
stopObserving() {
this.subscription?.remove();
}
}
export default new DesktopLinking();
+129
View File
@@ -0,0 +1,129 @@
import fs, { promises, paths } from "@react-native-gpui/fs";
import { Buffer } from "buffer";
const pathOf = (value) => String(value?.uri ?? value).replace(/^file:\/\//, "");
const join = (parts) =>
parts
.map(pathOf)
.join("/")
.replace(/\/{2,}/g, "/");
export const cacheDirectory = `file://${paths.appCache}/paseo-gpui/`;
export const documentDirectory = `file://${paths.appData}/paseo-gpui/`;
export const EncodingType = { Base64: "base64", UTF8: "utf8" };
export const Paths = { cache: cacheDirectory, document: documentDirectory };
export async function getInfoAsync(uri) {
try {
const stat = await promises.stat(pathOf(uri));
return { exists: true, isDirectory: stat.isDirectory(), size: stat.size, uri };
} catch (error) {
if (error.code === "ENOENT") return { exists: false, uri };
throw error;
}
}
export const makeDirectoryAsync = (uri, options = {}) =>
promises.mkdir(pathOf(uri), { recursive: options.intermediates });
export const copyAsync = ({ from, to }) => promises.copyFile(pathOf(from), pathOf(to));
export const moveAsync = ({ from, to }) => promises.rename(pathOf(from), pathOf(to));
export const readAsStringAsync = (uri, options = {}) =>
promises.readFile(pathOf(uri), options.encoding ?? "utf8");
export const writeAsStringAsync = (uri, contents, options = {}) =>
promises.writeFile(pathOf(uri), contents, options.encoding ?? "utf8");
export const deleteAsync = (uri, options = {}) =>
promises.rm(pathOf(uri), { recursive: true, force: options.idempotent });
export const readDirectoryAsync = (uri) => promises.readdir(pathOf(uri));
export class File {
constructor(...parts) {
this.uri = `file://${join(parts)}`;
}
get name() {
return pathOf(this).split("/").at(-1);
}
get exists() {
return fs.existsSync(pathOf(this));
}
get size() {
return fs.statSync(pathOf(this)).size;
}
create({ overwrite = false, intermediates = false } = {}) {
if (intermediates)
fs.mkdirSync(pathOf(this).split("/").slice(0, -1).join("/"), { recursive: true });
fs.writeFileSync(pathOf(this), "", { flag: overwrite ? "w" : "wx" });
}
write(value) {
fs.writeFileSync(pathOf(this), value);
}
delete() {
fs.rmSync(pathOf(this));
}
async bytes() {
return promises.readFile(pathOf(this));
}
async arrayBuffer() {
const bytes = await this.bytes();
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
}
async base64() {
return Buffer.from(await this.bytes()).toString("base64");
}
async text() {
return promises.readFile(pathOf(this), "utf8");
}
move(destination) {
fs.renameSync(pathOf(this), pathOf(destination));
this.uri = destination.uri;
}
copy(destination) {
fs.copyFileSync(pathOf(this), pathOf(destination));
}
static async downloadFileAsync(url, destination) {
const response = await fetch(url);
if (!response.ok) throw new Error(`Download failed: ${response.status}`);
const file = destination instanceof File ? destination : new File(destination);
await promises.writeFile(pathOf(file), new Uint8Array(await response.arrayBuffer()));
return file;
}
}
export class Directory {
constructor(...parts) {
this.uri = `file://${join(parts)}`;
}
get exists() {
return fs.existsSync(pathOf(this));
}
create({ intermediates = false } = {}) {
fs.mkdirSync(pathOf(this), { recursive: intermediates });
}
delete() {
fs.rmSync(pathOf(this), { recursive: true });
}
list() {
return fs
.readdirSync(pathOf(this), { withFileTypes: true })
.map((entry) =>
entry.isDirectory() ? new Directory(this, entry.name) : new File(this, entry.name),
);
}
}
export function createDownloadResumable(url, uri, options = {}, onProgress) {
const controller = new AbortController();
return {
async downloadAsync() {
const response = await fetch(url, { headers: options.headers, signal: controller.signal });
if (!response.ok) throw new Error(`Download failed: ${response.status}`);
const bytes = new Uint8Array(await response.arrayBuffer());
const target = pathOf(uri);
await promises.mkdir(target.split("/").slice(0, -1).join("/"), { recursive: true });
await promises.writeFile(`${target}.part`, bytes);
await promises.rename(`${target}.part`, target);
onProgress?.({ totalBytesWritten: bytes.length, totalBytesExpectedToWrite: bytes.length });
return {
uri,
status: response.status,
headers: Object.fromEntries(response.headers.entries()),
};
},
async cancelAsync() {
controller.abort();
},
};
}
@@ -0,0 +1,8 @@
// The GPUI picker accepts JPEG/PNG directly. Other formats require a future
// native decoder; never claim conversion succeeded for an unsupported format.
export const SaveFormat = { PNG: "png", JPEG: "jpeg", WEBP: "webp" };
export const ImageManipulator = {
manipulate() {
throw new Error("Image conversion is not available in the GPUI build; select a PNG or JPEG.");
},
};
@@ -0,0 +1,28 @@
import { dialog, promises } from "@react-native-gpui/fs";
// Desktop access is granted for each selection by the system file dialog.
export const PermissionStatus = {
GRANTED: "granted",
DENIED: "denied",
UNDETERMINED: "undetermined",
};
const permission = { status: "granted", granted: true, canAskAgain: true, expires: "never" };
export const requestMediaLibraryPermissionsAsync = async () => permission;
export const useMediaLibraryPermissions = () => [permission, requestMediaLibraryPermissionsAsync];
export const getPendingResultAsync = async () => null;
export async function launchImageLibraryAsync(options = {}) {
const paths = await dialog.open({
multiple: options.allowsMultipleSelection,
filters: [{ name: "Images", extensions: ["png", "jpg", "jpeg"] }],
});
return {
canceled: !paths.length,
assets: await Promise.all(
paths.map(async (path) => ({
uri: `file://${path}`,
fileName: path.split("/").at(-1),
fileSize: (await promises.stat(path)).size,
mimeType: /\.png$/i.test(path) ? "image/png" : "image/jpeg",
})),
),
};
}
+16
View File
@@ -0,0 +1,16 @@
/* oxlint-disable import/no-unassigned-import -- These packages install Intl and locale data by side effect. */
// Hermes' Linux build omits Intl; install real locale algorithms before app code.
import "@formatjs/intl-getcanonicallocales/polyfill-force.js";
import "@formatjs/intl-locale/polyfill-force.js";
import "@formatjs/intl-pluralrules/polyfill-force.js";
import "@formatjs/intl-pluralrules/locale-data/en.js";
import "@formatjs/intl-numberformat/polyfill-force.js";
import "@formatjs/intl-numberformat/locale-data/en.js";
import "@formatjs/intl-datetimeformat/polyfill-force.js";
import "@formatjs/intl-datetimeformat/locale-data/en.js";
import "@formatjs/intl-datetimeformat/add-all-tz.js";
import "@formatjs/intl-displaynames/polyfill-force.js";
import "@formatjs/intl-displaynames/locale-data/en.js";
import "@formatjs/intl-segmenter/polyfill-force.js";
import Desktop from "../desktop/src/NativePaseoDesktop";
Intl.DateTimeFormat.__setDefaultTimeZone(Desktop.timezone());
+6
View File
@@ -0,0 +1,6 @@
// No desktop power-inhibition lease is acquired by this backend yet.
export const isAvailableAsync = async () => false;
export async function activateKeepAwakeAsync() {
throw new Error("Power inhibition is not available in the GPUI build.");
}
export async function deactivateKeepAwake() {}
+60
View File
@@ -0,0 +1,60 @@
import { useEffect } from "react";
import { Keyboard, ScrollView, useAnimatedValue } from "react-native";
import { useSharedValue } from "react-native-reanimated";
// A GPUI desktop window has no software-keyboard inset. RN still owns focus
// and physical keyboard events; the mobile layout API observes its events.
export function KeyboardProvider({ children }) {
return children;
}
export const KeyboardAwareScrollView = ScrollView;
export const KeyboardController = {
isVisible: () => Keyboard.isVisible(),
dismiss: () => Keyboard.dismiss(),
};
export const KeyboardEvents = { addListener: (...args) => Keyboard.addListener(...args) };
export function useReanimatedKeyboardAnimation() {
const height = useSharedValue(0);
const progress = useSharedValue(0);
useEffect(() => {
const shown = Keyboard.addListener("keyboardDidShow", (event) => {
height.value = -event.endCoordinates.height;
progress.value = 1;
});
const hidden = Keyboard.addListener("keyboardDidHide", () => {
height.value = 0;
progress.value = 0;
});
return () => {
shown.remove();
hidden.remove();
};
}, [height, progress]);
return { height, progress };
}
export function useKeyboardAnimation() {
const height = useAnimatedValue(0);
const progress = useAnimatedValue(0);
return { height, progress };
}
export function useGenericKeyboardHandler(handlers) {
useEffect(() => {
const subscriptions = [
Keyboard.addListener("keyboardDidShow", (event) => {
const value = {
height: event.endCoordinates.height,
progress: 1,
duration: event.duration,
};
handlers.onStart?.(value);
handlers.onEnd?.(value);
}),
Keyboard.addListener("keyboardDidHide", () => {
const value = { height: 0, progress: 0, duration: 0 };
handlers.onStart?.(value);
handlers.onEnd?.(value);
}),
];
return () => subscriptions.forEach((subscription) => subscription.remove());
}, [handlers]);
}
@@ -0,0 +1,5 @@
import Desktop from "../desktop/src/NativePaseoDesktop";
export function getLocales() {
const languageTag = Desktop.locale().split(".")[0].replaceAll("_", "-");
return [{ languageTag, languageCode: languageTag.split("-")[0] }];
}
+13
View File
@@ -0,0 +1,13 @@
import React from "react";
import { Text, View } from "react-native";
import { HighlightedCodeBlock } from "../../src/components/highlighted-code-block";
export function MermaidFenceHost(props) {
return (
<View>
<Text style={props.inheritedStyles}>
Diagram rendering is not available in the GPUI build yet.
</Text>
<HighlightedCodeBlock {...props} language="mermaid" />
</View>
);
}
@@ -0,0 +1,5 @@
import { Buffer } from "buffer";
import Desktop from "../desktop/src/NativePaseoDesktop";
export function randomBytes(length) {
return Buffer.from(Desktop.randomBytes(length));
}
@@ -0,0 +1,3 @@
// GPUI's native TextInput owns text paste, selection, and IME. The mobile
// PasteInput module's image-paste extension is not available on this target.
export { TextInput as default } from "react-native";
+11
View File
@@ -0,0 +1,11 @@
import { dialog, promises } from "@react-native-gpui/fs";
// Linux exports a downloaded file through the native Save dialog.
export const isAvailableAsync = async () => true;
export async function shareAsync(uri, options = {}) {
const source = uri.replace(/^file:\/\//, "");
const destination = await dialog.save({
title: options.dialogTitle ?? "Save file",
defaultName: source.split("/").at(-1),
});
if (destination) await promises.copyFile(source, destination);
}
+37
View File
@@ -0,0 +1,37 @@
import Desktop from "../desktop/src/NativePaseoDesktop";
let pending = Promise.resolve();
function enqueue(operation) {
const result = pending.then(operation);
pending = result.catch(() => {});
return result;
}
const connection = {
async execAsync(sql) {
Desktop.sqlite(sql, "batch");
},
async runAsync(sql, params = []) {
Desktop.sqlite(sql, JSON.stringify(params));
},
async getAllAsync(sql, params = []) {
return JSON.parse(Desktop.sqlite(sql, JSON.stringify(params)));
},
};
export async function openDatabaseAsync(name) {
if (name !== "paseo-replica-row-store.db") throw new Error(`Unknown GPUI database: ${name}`);
return {
execAsync: (...args) => enqueue(() => connection.execAsync(...args)),
runAsync: (...args) => enqueue(() => connection.runAsync(...args)),
getAllAsync: (...args) => enqueue(() => connection.getAllAsync(...args)),
withExclusiveTransactionAsync: (operation) =>
enqueue(async () => {
await connection.execAsync("BEGIN IMMEDIATE");
try {
await operation(connection);
await connection.execAsync("COMMIT");
} catch (error) {
await connection.execAsync("ROLLBACK");
throw error;
}
}),
};
}
@@ -0,0 +1,26 @@
import * as RN from "react-native";
import { createUnistylesElement } from "./unistyles";
export const View = createUnistylesElement(RN.View);
export const Text = createUnistylesElement(RN.Text);
export const ScrollView = createUnistylesElement(RN.ScrollView);
export const TextInput = createUnistylesElement(RN.TextInput);
export const Pressable = createUnistylesElement(RN.Pressable);
export const Image = createUnistylesElement(RN.Image);
export const ImageBackground = createUnistylesElement(RN.ImageBackground);
export const FlatList = createUnistylesElement(RN.FlatList);
export const SectionList = createUnistylesElement(RN.SectionList);
export const TouchableOpacity = createUnistylesElement(RN.TouchableOpacity);
export const TouchableHighlight = createUnistylesElement(RN.TouchableHighlight);
export const TouchableWithoutFeedback = createUnistylesElement(RN.TouchableWithoutFeedback);
export const ActivityIndicator = createUnistylesElement(RN.ActivityIndicator);
export const KeyboardAvoidingView = createUnistylesElement(RN.KeyboardAvoidingView);
export const SafeAreaView = createUnistylesElement(RN.SafeAreaView);
export const Switch = createUnistylesElement(RN.Switch);
export const StatusBar = RN.StatusBar;
export const Animated = {
...RN.Animated,
View: createUnistylesElement(RN.Animated.View),
Text: createUnistylesElement(RN.Animated.Text),
ScrollView: createUnistylesElement(RN.Animated.ScrollView),
Image: createUnistylesElement(RN.Animated.Image),
};
+140
View File
@@ -0,0 +1,140 @@
import React, { forwardRef, useSyncExternalStore } from "react";
import * as Native from "react-native";
// GPUI has Fabric but no Nitro/Unistyles shadow-tree bindings. Keep the app's
// style factories and theme API; this backend updates subscribed React views.
const listeners = new Set();
const resolveStyle = Symbol("paseo.gpui.style");
let revision = 0;
let themes = {};
let themeName = "dark";
let adaptive = true;
let breakpoints = {};
const emit = () => {
revision += 1;
for (const listener of listeners) listener();
};
const subscribe = (listener) => {
listeners.add(listener);
return () => listeners.delete(listener);
};
const snapshot = () => revision;
const theme = () => themes[themeName];
const runtime = () => ({
themeName,
colorScheme: Native.Appearance.getColorScheme() ?? "light",
screen: Native.Dimensions.get("window"),
insets: { top: 0, bottom: 0, left: 0, right: 0, ime: 0 },
pixelRatio: Native.PixelRatio.get(),
fontScale: Native.PixelRatio.getFontScale(),
breakpoint: Object.keys(breakpoints).findLast(
(key) => breakpoints[key] <= Native.Dimensions.get("window").width,
),
});
Native.Dimensions.addEventListener("change", emit);
Native.Appearance.addChangeListener(({ colorScheme }) => {
if (adaptive && themes[colorScheme]) themeName = colorScheme;
emit();
});
function responsive(value) {
if (Array.isArray(value)) return value.map(responsive);
if (!value || typeof value !== "object") return value;
if (Object.getPrototypeOf(value) !== Object.prototype) return value;
const keys = Object.keys(value);
if (keys.length && keys.every((key) => key in breakpoints)) {
const width = Native.Dimensions.get("window").width;
const key = keys
.sort((a, b) => breakpoints[a] - breakpoints[b])
.findLast((candidate) => breakpoints[candidate] <= width);
return key === undefined ? undefined : responsive(value[key]);
}
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, responsive(item)]));
}
function materialize(style) {
if (Array.isArray(style)) return style.map(materialize);
if (style && typeof style === "object" && style[resolveStyle]) return style[resolveStyle]();
return style;
}
function styledProps(props) {
const result = { ...props };
for (const key of ["style", "contentContainerStyle"]) {
if (typeof props[key] === "function")
result[key] = (...args) => materialize(props[key](...args));
else if (props[key]) result[key] = materialize(props[key]);
}
return result;
}
export const UnistylesRuntime = {
get themeName() {
return themeName;
},
get breakpoint() {
return runtime().breakpoint;
},
get colorScheme() {
return runtime().colorScheme;
},
getTheme: theme,
setTheme(name) {
if (!themes[name]) throw new Error(`Unknown GPUI theme: ${name}`);
themeName = name;
emit();
},
updateTheme(name, update) {
themes[name] = update(themes[name]);
emit();
},
setAdaptiveThemes(value) {
adaptive = value;
if (value && themes[runtime().colorScheme]) themeName = runtime().colorScheme;
emit();
},
};
export const StyleSheet = {
...Native.StyleSheet,
configure(config) {
themes = config.themes;
breakpoints = config.breakpoints;
adaptive = config.settings?.adaptiveThemes ?? false;
themeName =
config.settings?.initialTheme ?? (adaptive ? runtime().colorScheme : Object.keys(themes)[0]);
emit();
},
create(factory) {
const evaluate = () => (typeof factory === "function" ? factory(theme(), runtime()) : factory);
const initial = evaluate();
const sheet = {};
for (const key of Object.keys(initial)) {
const wrap = (args) => {
const resolve = () => {
const value = evaluate()[key];
return responsive(typeof value === "function" ? value(...args) : value);
};
return Object.defineProperty({ ...resolve() }, resolveStyle, { value: resolve });
};
sheet[key] = typeof initial[key] === "function" ? (...args) => wrap(args) : wrap([]);
}
return sheet;
},
};
// Existing consumers retain this API; new app code should use style factories.
export function useUnistyles() {
useSyncExternalStore(subscribe, snapshot, snapshot);
return { theme: theme(), rt: runtime() };
}
export function withUnistyles(Component, mappings) {
const Wrapped = forwardRef(function GpuiThemed(props, ref) {
useSyncExternalStore(subscribe, snapshot, snapshot);
const { uniProps, ...rest } = props;
return React.createElement(Component, {
...styledProps({
...mappings?.(theme(), runtime()),
...uniProps?.(theme(), runtime()),
...rest,
}),
ref,
});
});
return Wrapped;
}
export const createUnistylesElement = withUnistyles;
+1
View File
@@ -0,0 +1 @@
{ "name": "Paseo", "displayName": "Paseo GPUI" }
+7
View File
@@ -0,0 +1,7 @@
module.exports = function (api) {
api.cache(true);
return {
presets: [[require.resolve("babel-preset-expo"), { unstable_transformImportMeta: true }]],
plugins: [[require.resolve("react-native-unistyles/plugin"), { root: "../src" }]],
};
};
+4
View File
@@ -0,0 +1,4 @@
build/
.cache/
autolink/
target/
File diff suppressed because it is too large Load Diff
+55
View File
@@ -0,0 +1,55 @@
[package]
name = "react-native-gpui-paseo-gpui"
version = "0.1.0"
edition = "2021"
# Set CARGO_PROFILE_DEV_DEBUG=full to opt into full debug information.
[profile.dev]
debug = "line-tables-only"
split-debuginfo = "packed"
# Dependencies are optimized even in a dev build: GPUI rebuilds and paints the
# element tree on every frame, and they are only compiled once.
[profile.dev.package."*"]
debug = false
opt-level = 3
# The C++ bridge picks its CMake build type from this crate's opt-level, and a
# Release build of React Native has no dev server, inspector or Fast Refresh.
[profile.dev.package.rn-gpui-sys]
opt-level = 0
[profile.release]
debug = "line-tables-only"
# Cargo strips inside the linker, before dsymutil, so package-desktop writes the
# dSYM and then strips. Full LTO: 2 MB smaller for 1.4x the build time.
strip = "none"
split-debuginfo = "packed"
lto = "fat"
codegen-units = 1
[build-dependencies]
rn-gpui-module-build = { path = "../node_modules/@react-native-gpui/core/ReactGpui/rn-gpui-module-build" }
serde_json = "1"
[features]
visual-capture = ["react-native-gpui/visual-capture"]
test-driver = ["react-native-gpui/test-driver"]
[dependencies]
serde_json = "1"
sha2 = "0.10"
rusqlite = { version = "0.38", features = ["bundled"] }
rn-gpui-module = { path = "../node_modules/@react-native-gpui/core/ReactGpui/rn-gpui-module" }
rn-gpui-autolink = { path = "autolink" }
react-native-gpui = { path = "../node_modules/@react-native-gpui/core/ReactGpui/rn-gpui" }
[patch.crates-io]
# Managed by @react-native-gpui/cli autolink; do not edit between these markers.
react-native-gpui = { path = "../node_modules/@react-native-gpui/core/ReactGpui/rn-gpui" }
rn-gpui-sys = { path = "../node_modules/@react-native-gpui/core/ReactGpui/rn-gpui-sys" }
rn-gpui-module = { path = "../node_modules/@react-native-gpui/core/ReactGpui/rn-gpui-module" }
rn-gpui-module-build = { path = "../node_modules/@react-native-gpui/core/ReactGpui/rn-gpui-module-build" }
gpui = { git = "https://github.com/streetwriters/gpui-fast", rev = "8da7d89" }
gpui_platform = { git = "https://github.com/streetwriters/gpui-fast", rev = "8da7d89" }
# End managed by autolink.
+172
View File
@@ -0,0 +1,172 @@
fn main() {
// Forwards the engine's Node-API exports out of this executable, which is
// what lets a stock `.node` addon resolve them on Windows.
//
// A DLL records every import as a name *plus the file it comes from*, and a
// node-gyp addon records "from `node.exe`" — Windows has no global symbol
// scope for it to fall back on. Node's own answer is that the host process
// exports the Node-API functions, so that is what this does: the executable
// re-exports each of the VM's `napi_*`/`node_api_*` names, forwarded to
// `hermesvm.dll`. Addons that use node-gyp's delay-load hook (the default)
// then reach this process instead of looking for `node.exe` on disk.
//
// It is a no-op on macOS and Linux, where an addon's imports resolve against
// whatever is already loaded and need no forwarding. Declare it once, before
// the module list; it does not depend on anything else in this file.
rn_gpui_module_build::forward_node_api_exports();
// App-local Rust TurboModules: list one entry per `src/Native*.ts` spec,
// for example `&["src/NativeGreeting.ts"]`. Their glue is generated into
// this crate's OUT_DIR, and main.rs registers the modules.
rn_gpui_module_build::generate(&["src/NativePaseoDesktop.ts"]);
let desktop_root = std::path::PathBuf::from(
std::env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR is set"),
);
// `app.json` names the app; `desktop/desktop.json` carries the identifier
// and everything else the packaging step needs. The loader the CLI uses is
// JavaScript, so this reads the same file directly and applies the two
// defaults it would apply.
let project_root = desktop_root
.parent()
.expect("desktop folder has a React Native project root");
let app_json_path = project_root.join("app.json");
println!("cargo:rerun-if-changed={}", app_json_path.display());
let app_json: serde_json::Value = serde_json::from_slice(
&std::fs::read(&app_json_path)
.unwrap_or_else(|error| panic!("read {}: {error}", app_json_path.display())),
)
.unwrap_or_else(|error| panic!("parse {}: {error}", app_json_path.display()));
let app_name = app_json["name"]
.as_str()
.filter(|name| !name.is_empty())
.expect("app.json must define a nonempty name");
if app_json["desktop"]
.as_object()
.is_some_and(|desktop| !desktop.is_empty())
{
panic!("move `app.json` desktop.* into `desktop/desktop.json` (see the README)");
}
let desktop_json_path = project_root.join("desktop/desktop.json");
println!("cargo:rerun-if-changed={}", desktop_json_path.display());
let desktop_json: serde_json::Value = std::fs::read(&desktop_json_path)
.ok()
.and_then(|bytes| serde_json::from_slice(&bytes).ok())
.unwrap_or(serde_json::Value::Null);
let app_id = match desktop_json["identifier"].as_str() {
Some(identifier) if !identifier.is_empty() => identifier.to_string(),
_ => format!(
"com.example.{}",
app_name
.to_lowercase()
.chars()
.map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
.collect::<String>()
),
};
println!("cargo:rustc-env=RN_GPUI_APP_NAME={app_name}");
println!("cargo:rustc-env=RN_GPUI_APP_ID={app_id}");
// Only a debug build names the bundle it was developed against. A release
// binary must find the bundle beside its own executable, because the path
// baked in here is where this machine happened to build it — and on the
// machine the package is installed on, that path does not exist.
println!("cargo:rerun-if-env-changed=PROFILE");
if std::env::var("PROFILE").as_deref() == Ok("debug") {
println!(
"cargo:rustc-env=RN_GPUI_DEFAULT_BUNDLE={}",
desktop_root.join("build/index.bundle").display()
);
}
println!("cargo:rerun-if-env-changed=DEP_RN_GPUI_RUNTIME_RPATHS");
println!("cargo:rerun-if-env-changed=RN_GPUI_PACKAGING");
let Ok(paths) = std::env::var("DEP_RN_GPUI_RUNTIME_RPATHS") else {
return;
};
let dirs = paths.split('|').filter(|path| !path.is_empty());
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows") {
stage_shared_libraries(dirs);
return;
}
// `RN_GPUI_PACKAGING` means this binary is about to be installed somewhere
// else, so only the relative rpaths belong in it. Keeping the absolute ones
// would let a packaged app load this build tree's libraries in preference to
// its own on the machine that built it — which hides exactly the packaging
// bugs the packaged run exists to catch.
let packaging = std::env::var("RN_GPUI_PACKAGING").is_ok();
if !packaging {
for path in dirs {
println!("cargo:rustc-link-arg=-Wl,-rpath,{path}");
}
}
emit_relocatable_rpaths(app_name);
}
/// rpaths that survive being installed somewhere else.
///
/// The absolute paths above let a development build run from this tree. A
/// packaged app is copied to another machine, where those paths do not exist,
/// so a release build also carries paths relative to the executable: the
/// layout the installers produce.
fn emit_relocatable_rpaths(app_name: &str) {
if std::env::var("PROFILE").as_deref() != Ok("release") {
return;
}
let os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default();
let mut emit = |rpath: String| println!("cargo:rustc-link-arg=-Wl,-rpath,{rpath}");
match os.as_str() {
// An app bundle puts the executable in Contents/MacOS and the shipped
// libraries in Contents/Frameworks.
"macos" => emit("@executable_path/../Frameworks".into()),
// Linux installs the executable at `usr/bin` and ships its libraries
// under `usr/lib`:
// - the deb puts them in `usr/lib/<app>`;
// - an AppImage's libraries are flat in `usr/lib`, because that is the
// directory AppRun puts on the library path and the one linuxdeploy
// resolves dependencies from before the image is assembled.
// Both are covered, so one binary works in either layout.
"linux" => {
emit(format!("$ORIGIN/../lib/{app_name}"));
emit("$ORIGIN/../lib".into());
emit("$ORIGIN".into());
}
// Windows has no rpath: the DLLs are copied beside the executable.
_ => {}
}
}
/// Windows has no rpath: the loader finds a DLL next to the executable or on
/// `PATH`, so every directory the runtime reports as holding a shared library
/// (the Hermes binary directory in particular) is copied into the profile
/// directory that receives this app's executable. Without this the app builds
/// and then fails to start with "hermesvm.dll was not found".
fn stage_shared_libraries<'a>(dirs: impl Iterator<Item = &'a str>) {
let out_dir = std::path::PathBuf::from(std::env::var("OUT_DIR").expect("OUT_DIR is set"));
// <profile>/build/<package>-<hash>/out -> <profile>
let Some(profile_dir) = out_dir.ancestors().nth(3) else {
return;
};
for dir in dirs {
let Ok(entries) = std::fs::read_dir(dir) else {
continue;
};
for entry in entries.flatten() {
let source = entry.path();
let is_shared_library = source
.extension()
.and_then(|extension| extension.to_str())
.is_some_and(|extension| extension.eq_ignore_ascii_case("dll"));
if !is_shared_library {
continue;
}
let Some(name) = source.file_name() else {
continue;
};
println!("cargo:rerun-if-changed={}", source.display());
std::fs::copy(&source, profile_dir.join(name)).unwrap_or_else(|error| {
panic!("copy {} next to the executable: {error}", source.display())
});
}
}
}
+1
View File
@@ -0,0 +1 @@
{ "identifier": "sh.paseo.gpui" }
+83
View File
@@ -0,0 +1,83 @@
const path = require("node:path");
const { makeMetroConfig } = require("@react-native-gpui/core/metro.config");
const projectRoot = path.resolve(__dirname, "..");
process.env.EXPO_ROUTER_APP_ROOT = path.resolve(projectRoot, "../src/app");
const config = makeMetroConfig({ projectRoot });
const resolveRequest = config.resolver.resolveRequest;
config.watchFolders.push(path.resolve(projectRoot, "../../.."));
config.transformer.unstable_allowRequireContext = true;
config.resolver.disableHierarchicalLookup = false;
config.resolver.blockList = /[/\\]gpui[/\\]desktop[/\\](target|build|autolink)[/\\]/;
const nativePackages = [
"react",
"react-native-reanimated",
"react-native-worklets",
"react-native-gesture-handler",
"react-native-screens",
"react-native-safe-area-context",
"react-native-svg",
];
const adapters = {
"expo-keep-awake": "keep-awake.js",
"expo-image-picker": "image-picker.js",
"expo-image-manipulator": "image-manipulator.js",
"expo-sharing": "sharing.js",
"expo-document-picker": "document-picker.js",
"@mattermost/react-native-paste-input": "paste-input.js",
"@react-native-async-storage/async-storage": "async-storage.js",
"expo-localization": "localization.js",
"expo-clipboard": "clipboard.js",
"expo-file-system": "file-system.js",
"expo-file-system/legacy": "file-system.js",
"react-native-keyboard-controller": "keyboard.jsx",
crypto: "node-crypto.js",
"node:crypto": "node-crypto.js",
"expo-sqlite": "sqlite.js",
"expo-crypto": "crypto.js",
"react-native-unistyles": "unistyles.js",
};
const fileAdapters = {
"/audio/audio-engine.native.ts": "audio.js",
"/components/markdown/fence/mermaid/host.native.tsx": "mermaid.jsx",
"/file-panels/html-preview.tsx": "document-preview.jsx",
"/file-panels/document-frame/index.tsx": "document-preview.jsx",
};
config.resolver.resolveRequest = (context, name, platform) => {
if (adapters[name]) name = path.resolve(projectRoot, "adapters", adapters[name]);
if (
name.endsWith("/ensureNativeModulesAreInstalled") &&
context.originModulePath.includes("expo-modules-core")
)
name = path.resolve(projectRoot, "adapters/expo-install.js");
if (name === "./ExpoLinking" && context.originModulePath.includes("expo-linking"))
name = path.resolve(projectRoot, "adapters/expo-linking.js");
// Linux has no Expo mobile push-token or camera provider; reuse the app's
// existing unavailable-capability implementations.
if (name === "expo-notifications")
name = path.resolve(projectRoot, "../src/fdroid/expo-notifications.ts");
if (name === "expo-camera") name = path.resolve(projectRoot, "../src/fdroid/expo-camera.tsx");
if (name === "expo-router/_ctx") name = path.resolve(projectRoot, "routes.js");
if (name === "qrcode") name = "qrcode/lib/browser.js";
if (name.startsWith("react-native-unistyles/components/native/"))
name = path.resolve(projectRoot, "adapters/unistyles-components.js");
if (name.startsWith("@/")) name = path.resolve(projectRoot, "../src", name.slice(2));
if (
name.startsWith("react-native/") ||
nativePackages.some((pkg) => name === pkg || name.startsWith(`${pkg}/`))
) {
context = {
...context,
disableHierarchicalLookup: true,
nodeModulesPaths: [path.join(projectRoot, "node_modules")],
};
}
const result = resolveRequest(context, name, platform);
const replacement = Object.entries(fileAdapters).find(([suffix]) =>
result.filePath?.endsWith(suffix),
);
if (replacement)
return { type: "sourceFile", filePath: path.resolve(projectRoot, "adapters", replacement[1]) };
return result;
};
module.exports = config;
@@ -0,0 +1,9 @@
import { TurboModuleRegistry, type TurboModule } from "react-native";
export interface Spec extends TurboModule {
sqlite(sql: string, params: string): string;
locale(): string;
timezone(): string;
sha256(text: string): string;
randomBytes(length: number): Array<number>;
}
export default TurboModuleRegistry.getEnforcing<Spec>("PaseoDesktop");
+29
View File
@@ -0,0 +1,29 @@
rn_gpui_module::include_specs!();
mod services;
fn main() {
react_native_gpui::register_module(native_paseo_desktop_module::<services::PaseoDesktop>());
rn_gpui_autolink::ensure_linked();
// A debug build knows where its own bundle is; a release build does not, and
// must not: the path would name the machine that built it. A packaged release
// app resolves the bundle beside its executable instead.
#[cfg(debug_assertions)]
if std::env::var_os("RN_GPUI_BUNDLE").is_none() {
std::env::set_var("RN_GPUI_BUNDLE", env!("RN_GPUI_DEFAULT_BUNDLE"));
}
let config = react_native_gpui::DesktopAppConfig::from_env_with_defaults(
env!("RN_GPUI_APP_NAME"),
env!("RN_GPUI_APP_ID"),
)
.window_title("Paseo GPUI")
.window_size(1024.0, 768.0)
.dev_menu(true)
.configure_window(|options| {
// GPUI window options are public: titlebar, resizability, display,
// decorations, min size, and other native window behavior.
options.is_resizable = true;
});
react_native_gpui::run_with(config);
}
+174
View File
@@ -0,0 +1,174 @@
use crate::{NativePaseoDesktopEvents, NativePaseoDesktopSpec};
use rn_gpui_module::ModuleError;
use std::io::Read;
pub struct PaseoDesktop {
database: std::sync::Mutex<Option<rusqlite::Connection>>,
}
impl PaseoDesktop {
fn database(
&self,
) -> Result<std::sync::MutexGuard<'_, Option<rusqlite::Connection>>, ModuleError> {
let mut guard = self
.database
.lock()
.map_err(|e| ModuleError::new(e.to_string()))?;
if guard.is_none() {
let base = std::env::var("XDG_DATA_HOME")
.map(std::path::PathBuf::from)
.unwrap_or_else(|_| {
std::path::PathBuf::from(std::env::var("HOME").expect("HOME is set"))
.join(".local/share")
});
let dir = base.join("paseo-gpui");
std::fs::create_dir_all(&dir).map_err(|e| ModuleError::new(e.to_string()))?;
*guard = Some(
rusqlite::Connection::open(dir.join("replica.db"))
.map_err(|e| ModuleError::new(e.to_string()))?,
);
}
Ok(guard)
}
}
impl NativePaseoDesktopSpec for PaseoDesktop {
fn new(_events: NativePaseoDesktopEvents) -> Self {
Self {
database: std::sync::Mutex::new(None),
}
}
fn sqlite(&self, sql: String, params: String) -> Result<String, ModuleError> {
let fail = |e: rusqlite::Error| ModuleError::new(e.to_string());
let db = self.database()?;
let db = db.as_ref().unwrap();
if params == "batch" {
db.execute_batch(&sql).map_err(fail)?;
return Ok("[]".into());
}
let values: Vec<serde_json::Value> =
serde_json::from_str(&params).map_err(|e| ModuleError::new(e.to_string()))?;
let values: Vec<rusqlite::types::Value> = values
.into_iter()
.map(|v| match v {
serde_json::Value::Null => rusqlite::types::Value::Null,
serde_json::Value::String(v) => rusqlite::types::Value::Text(v),
serde_json::Value::Number(v) => v
.as_i64()
.map(rusqlite::types::Value::Integer)
.unwrap_or_else(|| rusqlite::types::Value::Real(v.as_f64().unwrap())),
_ => rusqlite::types::Value::Text(v.to_string()),
})
.collect();
let mut stmt = db.prepare(&sql).map_err(fail)?;
let names: Vec<String> = stmt.column_names().into_iter().map(str::to_owned).collect();
let mut rows = stmt
.query(rusqlite::params_from_iter(values))
.map_err(fail)?;
let mut results = Vec::new();
while let Some(row) = rows.next().map_err(fail)? {
let mut object = serde_json::Map::new();
for (index, name) in names.iter().enumerate() {
let value = match row.get_ref(index).map_err(fail)? {
rusqlite::types::ValueRef::Null => serde_json::Value::Null,
rusqlite::types::ValueRef::Integer(v) => v.into(),
rusqlite::types::ValueRef::Real(v) => serde_json::json!(v),
rusqlite::types::ValueRef::Text(v) => {
String::from_utf8_lossy(v).into_owned().into()
}
rusqlite::types::ValueRef::Blob(_) => {
return Err(ModuleError::new(
"Replica store does not support blob columns",
))
}
};
object.insert(name.clone(), value);
}
results.push(object);
}
serde_json::to_string(&results).map_err(|e| ModuleError::new(e.to_string()))
}
fn timezone(&self) -> Result<String, ModuleError> {
if let Ok(zone) = std::env::var("TZ") {
if !zone.is_empty() {
return Ok(zone.trim_start_matches(':').into());
}
}
if let Ok(path) = std::fs::read_link("/etc/localtime") {
if let Some((_, zone)) = path.to_string_lossy().split_once("/zoneinfo/") {
return Ok(zone.into());
}
}
Ok(std::fs::read_to_string("/etc/timezone")
.map(|s| s.trim().into())
.unwrap_or_else(|_| "UTC".into()))
}
fn locale(&self) -> Result<String, ModuleError> {
Ok(["LC_ALL", "LC_MESSAGES", "LANG"]
.iter()
.find_map(|key| std::env::var(key).ok().filter(|v| !v.is_empty()))
.unwrap_or_else(|| "en_US.UTF-8".into()))
}
fn sha256(&self, text: String) -> Result<String, ModuleError> {
use sha2::{Digest, Sha256};
Ok(format!("{:x}", Sha256::digest(text.as_bytes())))
}
fn random_bytes(&self, length: f64) -> Result<Vec<f64>, ModuleError> {
if !length.is_finite() || length < 0.0 || length > 65536.0 || length.fract() != 0.0 {
return Err(ModuleError::new("Invalid random byte count"));
}
let mut bytes = vec![0u8; length as usize];
std::fs::File::open("/dev/urandom")
.and_then(|mut source| source.read_exact(&mut bytes))
.map_err(|error| ModuleError::new(error.to_string()))?;
Ok(bytes.into_iter().map(f64::from).collect())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn service() -> PaseoDesktop {
PaseoDesktop {
database: std::sync::Mutex::new(Some(rusqlite::Connection::open_in_memory().unwrap())),
}
}
#[test]
fn crypto_matches_sha256_and_bounds_random_requests() {
let service = service();
assert_eq!(
service.sha256("abc".into()).unwrap(),
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
);
assert_eq!(service.random_bytes(32.0).unwrap().len(), 32);
assert!(service.random_bytes(65537.0).is_err());
assert!(service.random_bytes(-1.0).is_err());
}
#[test]
fn replica_transactions_bind_values_and_roll_back() {
let service = service();
service
.sqlite(
"CREATE TABLE rows (key TEXT PRIMARY KEY, value TEXT);".into(),
"batch".into(),
)
.unwrap();
service
.sqlite(
"INSERT INTO rows VALUES (?, ?)".into(),
r#"["key","quoted ' value"]"#.into(),
)
.unwrap();
service
.sqlite(
"BEGIN IMMEDIATE; DELETE FROM rows; ROLLBACK;".into(),
"batch".into(),
)
.unwrap();
let result = service
.sqlite("SELECT key, value FROM rows".into(), "[]".into())
.unwrap();
assert_eq!(
serde_json::from_str::<serde_json::Value>(&result).unwrap(),
serde_json::json!([{"key":"key","value":"quoted ' value"}])
);
}
}
+10
View File
@@ -0,0 +1,10 @@
/* oxlint-disable import/no-unassigned-import -- Install globals before app modules evaluate. */
import "./adapters/intl";
import "../src/polyfills/install-early";
import "../src/styles/unistyles";
import { AppRegistry } from "react-native";
import { RootApp } from "../src/root-app";
// GPUI owns the native surface. Keep Paseo's full router and providers, while
// leaving Expo's mobile registration and WebView-based dev overlay out.
AppRegistry.registerComponent("Paseo", () => RootApp);
+103
View File
@@ -0,0 +1,103 @@
import { afterAll, beforeAll, expect, test } from "vitest";
// Runs against the actual Paseo bundle in the compiled GPUI app. Start with
// RN_GPUI_TEST_DRIVER=1 and pass its loopback URL and token to this test.
const endpoint = process.env.PASEO_GPUI_TEST_URL;
const token = process.env.PASEO_GPUI_TEST_TOKEN;
let socket: WebSocket;
let nextId = 0;
interface Node {
tag: number;
component: string;
testID: string;
text: string;
children?: Node[];
}
const pending = new Map<number, { resolve(value: unknown): void; reject(error: Error): void }>();
function request<T = unknown>(method: string, params: Record<string, unknown> = {}): Promise<T> {
const id = ++nextId;
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
pending.delete(id);
reject(new Error(`GPUI ${method} timed out`));
}, 15_000);
pending.set(id, {
resolve: (value) => {
clearTimeout(timer);
resolve(value as T);
},
reject: (error) => {
clearTimeout(timer);
reject(error);
},
});
socket.send(JSON.stringify({ jsonrpc: "2.0", id, method, params: { ...params, token } }));
});
}
function flatten(node: Node): Node[] {
return [node, ...(node.children ?? []).flatMap(flatten)];
}
async function nodes() {
return (await request<{ children: Node[] }>("tree")).children.flatMap(flatten);
}
beforeAll(async () => {
if (!endpoint) return;
if (!token) throw new Error("PASEO_GPUI_TEST_TOKEN is required");
socket = new WebSocket(endpoint);
await new Promise<void>((resolve, reject) => {
socket.addEventListener("open", () => resolve(), { once: true });
socket.addEventListener("error", () => reject(new Error("GPUI driver connection failed")), {
once: true,
});
});
socket.addEventListener("message", (event) => {
const response = JSON.parse(String(event.data));
const waiter = pending.get(response.id);
if (!waiter) return;
pending.delete(response.id);
const error = response.error ?? response.result?.error;
if (error) waiter.reject(new Error(JSON.stringify(error)));
else waiter.resolve(response.result);
});
});
afterAll(() => socket?.close());
test.skipIf(!endpoint)(
"real Paseo portal passes workspace clicks and the native composer edits text",
async () => {
const workspace = (await nodes()).find((node) =>
node.testID.startsWith("sidebar-workspace-row-"),
);
expect(workspace, "connected daemon must have an existing workspace").toBeDefined();
// Regression: the upstream box-none portal hit area intercepted this click.
expect(await request("click", { tag: workspace!.tag })).toEqual({ clicked: true });
await expect
.poll(async () => (await nodes()).some((node) => node.testID === "workspace-header-title"), {
timeout: 15_000,
})
.toBe(true);
await expect
.poll(async () => (await nodes()).some((node) => node.component === "TextInput"), {
timeout: 15_000,
})
.toBe(true);
const input = (await nodes()).find((node) => node.component === "TextInput")!;
const original = (await request<{ text: string }>("getProps", { tag: input.tag })).text;
try {
await request("click", { tag: input.tag });
await request("press", { key: "ctrl-a" });
await request("press", { key: "backspace" });
expect(
await request("type", { tag: input.tag, text: "GPUI native input regression" }),
).toEqual({ typed: true });
await expect
.poll(async () => (await request<{ text: string }>("getProps", { tag: input.tag })).text)
.toBe("GPUI native input regression");
} finally {
await request("press", { key: "ctrl-a" });
await request("press", { key: "backspace" });
if (original) await request("type", { tag: input.tag, text: original });
}
},
45_000,
);
+6661
View File
File diff suppressed because it is too large Load Diff
+40
View File
@@ -0,0 +1,40 @@
{
"name": "paseo-gpui",
"version": "0.0.0",
"private": true,
"dependencies": {
"@formatjs/intl-datetimeformat": "^7.9.0",
"@formatjs/intl-displaynames": "^7.4.0",
"@formatjs/intl-getcanonicallocales": "^3.3.0",
"@formatjs/intl-locale": "^5.4.1",
"@formatjs/intl-numberformat": "^9.5.0",
"@formatjs/intl-pluralrules": "^6.4.0",
"@formatjs/intl-segmenter": "^12.3.0",
"@react-native-gpui/codegen": "file:../../../vendor/react-native-gpui/packages/codegen",
"@react-native-gpui/core": "file:../../../vendor/react-native-gpui/packages/core",
"@react-native-gpui/fs": "file:../../../vendor/react-native-gpui/modules/fs",
"@react-native-gpui/gesture-handler": "file:../../../vendor/react-native-gpui/modules/gesture-handler",
"@react-native-gpui/hermes": "file:../../../vendor/react-native-gpui/packages/hermes",
"@react-native-gpui/reanimated": "file:../../../vendor/react-native-gpui/modules/reanimated",
"@react-native-gpui/safe-area-context": "file:../../../vendor/react-native-gpui/modules/safe-area-context",
"@react-native-gpui/screens": "file:../../../vendor/react-native-gpui/modules/screens",
"@react-native-gpui/svg": "file:../../../vendor/react-native-gpui/modules/svg",
"@react-native-gpui/worklets": "file:../../../vendor/react-native-gpui/modules/worklets",
"@react-native/assets-registry": "^0.87.1",
"react": "19.2.3",
"react-native": "0.87.1",
"react-native-gesture-handler": "3.3.0",
"react-native-reanimated": "https://github.com/ammarahm-ed/react-native-reanimated/releases/download/4.7.1-desktop.1/react-native-reanimated-4.7.1.tgz",
"react-native-safe-area-context": "5.10.1",
"react-native-screens": "4.28.0",
"react-native-svg": "15.15.5",
"react-native-worklets": "https://github.com/ammarahm-ed/react-native-reanimated/releases/download/worklets-0.13.0-desktop.1/react-native-worklets-0.13.0.tgz"
},
"devDependencies": {
"@react-native-community/cli": "20.2.0",
"@react-native-gpui/cli": "file:../../../vendor/react-native-gpui/packages/cli",
"@react-native/babel-preset": "0.87.1",
"@react-native/metro-config": "0.87.1",
"typescript": "^5.8.3"
}
}
+2
View File
@@ -0,0 +1,2 @@
/// <reference types="@react-native-gpui/core/types" />
// Added by react-native-gpui. Do not edit: it is regenerated.
+17
View File
@@ -0,0 +1,17 @@
const path = require("node:path");
module.exports = {
// This target only launches desktop. RN CLI 20.2's built-in
// run-ios/run-android option lists conflict with the CLI's global verbose
// option, and this app has no native mobile projects to launch.
commands: ["build-android", "build-ios", "log-android", "log-ios", "run-android", "run-ios"].map(
(name) => ({
name,
description: `${name} is not configured for Paseo GPUI`,
func: () => {
throw new Error(`${name} is not configured for Paseo GPUI`);
},
}),
),
project: { desktop: {} },
reactNativePath: path.dirname(require.resolve("react-native/package.json")),
};
+7
View File
@@ -0,0 +1,7 @@
// Use the existing route tree. Expo's transformer normally supplies this root
// through a Babel caller field; the RN desktop transformer does not.
export const ctx = require.context(
"../src/app",
true,
/^(?:\.\/)(?!(?:(?:(?:.*\+api)|(?:\+(html|native-intent))))\.[tj]sx?$).*\.[tj]sx?$/,
);
+3
View File
@@ -0,0 +1,3 @@
[toolchain]
channel = "stable"
profile = "minimal"
+2 -2
View File
@@ -15,7 +15,7 @@ import { useRewindAgentMutation } from "@/components/rewind/use-rewind-agent-mut
import { TaskListRow } from "@/components/task-list-row";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { useIsCompactFormFactor } from "@/constants/layout";
import { isNative, isWeb } from "@/constants/platform";
import { isNative, isWeb, supportsMaskedEffects } from "@/constants/platform";
import { useRevealedText, type MarkdownPhase } from "@/hooks/use-revealed-text";
import { ICON_SIZE, type Theme } from "@/styles/theme";
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
@@ -1800,7 +1800,7 @@ function computeShimmerMetrics(input: {
// React Native Web only observes a node when onLayout exists at mount. Keep
// measuring while idle so a retained badge has dimensions when it starts loading.
const shouldMeasureWebShimmer = isWeb;
const shouldMeasureNativeShimmer = input.isLoading && isNative;
const shouldMeasureNativeShimmer = input.isLoading && isNative && supportsMaskedEffects;
const isNativeShimmer =
shouldMeasureNativeShimmer && input.labelRowWidth > 0 && input.labelRowHeight > 0;
const webShimmerSpanStartX = input.labelOffsetX;
+3
View File
@@ -25,6 +25,9 @@ export const isWeb = Platform.OS === "web";
/** iOS or Android — the JS runtime is React Native. */
export const isNative = Platform.OS !== "web";
/** Platforms with an alpha-mask compositor for decorative shimmer effects. */
export const supportsMaskedEffects = ["web", "ios", "android"].includes(Platform.OS);
/** Development build/runtime — true in Metro dev bundles, false in production. */
export const isDev = Boolean((globalThis as { __DEV__?: boolean }).__DEV__);
@@ -19,7 +19,7 @@ import { PaseoLogo } from "@/components/icons/paseo-logo";
import { Button } from "@/components/ui/button";
import { getDesktopDaemonLogs, type DesktopDaemonLogs } from "@/desktop/daemon/desktop-daemon";
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
import { isNative, isWeb } from "@/constants/platform";
import { isNative, isWeb, supportsMaskedEffects } from "@/constants/platform";
import { CODE_SURFACE_DATASET } from "@/styles/code-surface";
interface StartupSplashScreenProps {
@@ -86,7 +86,11 @@ function LogoShimmer() {
return <WebLogoShimmer color={theme.colors.foreground} />;
}
return <NativeLogoShimmer color={theme.colors.foreground} />;
return supportsMaskedEffects ? (
<NativeLogoShimmer color={theme.colors.foreground} />
) : (
<PaseoLogo size={LOGO_SIZE} color={theme.colors.foreground} />
);
}
function WebLogoShimmer({ color }: { color: string }) {
+2 -1
View File
@@ -13,5 +13,6 @@
"expo-env.d.ts",
"nativewind-env.d.ts",
"react-native-web.d.ts"
]
],
"exclude": ["gpui"]
}
+3
View File
@@ -56,6 +56,9 @@ function spawnRelayDevServer(port: number): ChildProcess {
wranglerCliPath,
"dev",
"--local",
// Exercise the local Durable Object, independent of production cutover routing.
"--var",
"PASEO_RELAY_UPSTREAM:",
"--ip",
"127.0.0.1",
"--port",
+87
View File
@@ -0,0 +1,87 @@
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import path from "node:path";
import { cpSync, mkdirSync, readFileSync } from "node:fs";
const root = fileURLToPath(new URL("..", import.meta.url));
const project = path.join(root, "packages/app/gpui");
const action = process.argv[2] ?? "run";
const extra = process.argv.slice(3);
const artifacts = path.join(root, ".artifacts/gpui");
mkdirSync(artifacts, { recursive: true });
function run(command, args, env = {}) {
const result = spawnSync(command, args, {
cwd: project,
stdio: "inherit",
env: {
...process.env,
PASEO_RENDERER: "gpui",
CARGO_BUILD_JOBS: process.env.CARGO_BUILD_JOBS ?? "4",
...env,
},
});
if (result.error) throw result.error;
if (result.status !== 0) process.exit(result.status ?? 1);
}
if (action === "install") {
run("npm", ["install", "--workspaces=false"]);
// npm copies file: packages. Refresh vendored source even when its version
// did not change, so edits to the native renderer reach the next Cargo build.
const manifest = JSON.parse(readFileSync(path.join(project, "package.json"), "utf8"));
for (const [name, spec] of Object.entries({
...manifest.dependencies,
...manifest.devDependencies,
})) {
if (spec.startsWith("file:"))
cpSync(path.resolve(project, spec.slice(5)), path.join(project, "node_modules", name), {
recursive: true,
preserveTimestamps: true,
});
}
} else if (action === "bundle") {
run("node", [
"node_modules/react-native/cli.js",
"bundle",
"--platform",
"desktop",
"--entry-file",
"index.js",
"--config",
"desktop/metro.config.js",
"--bundle-output",
path.join(root, ".artifacts/gpui/paseo.bundle"),
"--sourcemap-output",
path.join(artifacts, "paseo.bundle.map"),
"--assets-dest",
path.join(artifacts, "assets"),
"--max-workers",
"2",
...extra,
]);
const map = JSON.parse(readFileSync(path.join(artifacts, "paseo.bundle.map"), "utf8"));
const sources = map.sections
? map.sections.flatMap((section) => section.map.sources)
: map.sources;
if (sources.some((source) => source.includes("/react-native-webview/")))
throw new Error("GPUI bundle must not include WebView modules");
for (const route of [
"src/app/_layout.tsx",
"src/app/open-project.tsx",
"src/app/h/[serverId]/workspace/[workspaceId]/index.tsx",
]) {
if (!sources.some((source) => source.endsWith(route)))
throw new Error(`GPUI bundle is missing Paseo route: ${route}`);
}
} else if (action === "build" || action === "run") {
run(process.execPath, [fileURLToPath(import.meta.url), "bundle", "--dev", "false"]);
run("node", ["node_modules/@react-native-gpui/cli/bin/react-native-gpui.js", "autolink"]);
run("cargo", [action, "--manifest-path", "desktop/Cargo.toml", ...extra], {
RN_GPUI_AUTOLINK_MANIFEST: path.join(project, "desktop/autolink/autolink.json"),
RN_GPUI_AUTOLINK_CMAKE: path.join(project, "desktop/autolink/autolink.cmake"),
RN_GPUI_DEV_MODE: "0",
RN_GPUI_ASSETS_DIR: path.join(artifacts, "assets/assets"),
RN_GPUI_BUNDLE: path.join(root, ".artifacts/gpui/paseo.bundle"),
});
} else {
throw new Error(`Unknown GPUI action: ${action}`);
}
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Streetwriters
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+21
View File
@@ -0,0 +1,21 @@
Repository: https://github.com/streetwriters/react-native-gpui
Revision: de11aa265e6fb36c80e8a30315bce6d34a9be7d6
License: MIT; see LICENSE and the licenses retained within packages.
Snapshot: packages/{cli,codegen,core,hermes} and
modules/{fs,gesture-handler,reanimated,safe-area-context,screens,svg,worklets}.
Examples, tester, testing package, website, and unused fonts/vector-icons ports
are not included. Upstream tests and build sources within retained packages stay
with the snapshot so contributors can inspect and modify the renderer.
Local patches (all other retained files match the revision above):
- packages/core/ReactGpui/rn-gpui/src/node_view.rs: box-none views must not
install their own pointer area. Otherwise an empty portal overlay redirects
clicks to its parent, blocking Paseo's sidebar. native.test.ts exercises this
against the actual app.
- packages/core/src-gpui/Libraries/Components/StatusBar/StatusBar.desktop.js:
desktop StatusBar implementation, because there is no mobile OS status bar.
GPUI itself remains a pinned Cargo git dependency. Hermes/React Native prebuilt
libraries are downloaded by upstream build tooling; this is not an offline or
fully source-built distribution.
+3
View File
@@ -0,0 +1,3 @@
# fs
Rust TurboModule library for React Native GPUI. The spec is in src/NativeFs.ts and uses React Native's public TurboModuleRegistry API. The implementation is in desktop/src/lib.rs and is discovered through package metadata. Import the default export from @react-native-gpui/fs and call its typed greet(name) method.
+25
View File
@@ -0,0 +1,25 @@
[package]
name = "rn-gpui-fs"
version = "0.1.0"
edition = "2021"
[dependencies]
rn-gpui-module = { version = "0.1" }
# Dialogs with file-type filters and a default name on all three platforms, using
# the XDG portal on Linux so no GTK is required.
rfd = "0.15"
# The standard cross-platform watcher.
notify = "6"
# Well-known folders, which differ per platform in ways not worth re-deriving.
dirs = "5"
# `utimes` sets both timestamps of an existing file; `std` cannot.
filetime = "0.2"
# `mkdtemp` with Node's retry-on-collision behaviour.
tempfile = "3"
# `chmod` by descriptor, and the errno constants the error mapping reads.
libc = "0.2"
# `realpath` without the `\\?\` prefix on Windows.
dunce = "1"
[build-dependencies]
rn-gpui-module-build = { version = "0.1" }
+3
View File
@@ -0,0 +1,3 @@
fn main() {
rn_gpui_module_build::generate(&["../src/NativeFs.ts"]);
}
@@ -0,0 +1,394 @@
//! The native pickers, Finder/Explorer reveal, and "open with the default app".
//!
//! `rfd` owns the dialogs, so the platform differences (AppKit panels, the
//! Windows common dialogs, the XDG portal on Linux) stay out of this crate. All
//! of `rfd`'s dialog entry points are blocking, which is acceptable here: the
//! module has a thread of its own (`THREAD = Own`), so a panel left open blocks
//! neither JS nor a frame.
//!
//! # Open question: dialogs on macOS and the main thread
//!
//! The design flags that macOS may require the panel to be created on the main
//! thread, and that this has to be confirmed under GPUI's run loop before it can
//! be called done. What `rfd` 0.15's blocking API actually does on macOS is
//! dispatch the panel onto the main queue when the process already has a running
//! `NSApplication`, run it inline when the caller is already on the main thread,
//! and panic when neither holds. This module is on its own thread, so the first
//! branch is the one that applies, and the call should hop to the main thread and
//! block this module's thread until the panel closes.
//!
//! That is the theory, and it is **not yet verified** under GPUI's run loop. The
//! spike is: run the app, open a dialog from JS, and check that the panel
//! appears, that no main-thread warning is logged, that the module thread
//! resumes when it closes, and that the app does not deadlock while the panel is
//! up. If any of that fails, the fallback is the one the design names: a second
//! module, `GpuiFsDialogs`, with `THREAD = Main`, holding only the two pickers;
//! the rest of this module stays off the main thread. Attaching the panel to the
//! calling window (a sheet on macOS) is a further step that needs a host
//! function exposing the active window's raw handle for `rfd`'s `set_parent`,
//! which does not exist yet either.
use std::path::{Path, PathBuf};
use std::process::Command;
use rn_gpui_module::ModuleError;
use crate::errors;
use crate::{DialogFilter, OpenDialogOptions, SaveDialogOptions};
/// The open picker. An empty list means the user cancelled, which is what the JS
/// layer expects; a cancelled dialog is not a failure.
pub fn show_open(options: OpenDialogOptions) -> Result<Vec<String>, ModuleError> {
let dialog = configured(
options.title.as_deref(),
options.default_path.as_deref(),
&options.filters,
);
let picked = if options.directory {
// A folder picker cannot return files, and `pick_folder` selects a
// single folder — rfd's blocking folder call has no multi-select mode.
// So `multiple` is honoured only as far as the platform allows: a
// caller asking for `{ directory: true, multiple: true }` gets at most
// one path, which is better than refusing the call outright.
dialog.pick_folder().into_iter().collect()
} else if options.multiple {
dialog.pick_files().unwrap_or_default()
} else {
dialog.pick_file().into_iter().collect()
};
Ok(utf8_paths(picked))
}
/// The save picker. `None` means the user cancelled; no file is created here
/// either way, the caller decides what to write.
pub fn show_save(options: SaveDialogOptions) -> Result<Option<String>, ModuleError> {
let (_, from_path) = split_default_path(options.default_path.as_deref());
// `defaultName` wins over the name implied by `defaultPath`, but a path
// ending in a file name is still the only hint a caller may give.
let name = options
.default_name
.filter(|name| !name.trim().is_empty())
.or(from_path);
let mut dialog = configured(
options.title.as_deref(),
options.default_path.as_deref(),
&options.filters,
);
if let Some(name) = name {
dialog = dialog.set_file_name(name);
}
let picked = dialog.save_file();
// The picker never writes, so a chosen name the bridge cannot represent is
// reported as "nothing chosen" rather than as an error the user has no way
// to act on; the alternative is a file the caller would try to write at a
// path JS could not have named.
Ok(picked.as_deref().and_then(utf8_path))
}
/// Shows `path` in the platform's file manager, selected where the platform has
/// a verb for it.
pub fn reveal(path: String) -> Result<(), ModuleError> {
ensure_exists(&path)?;
run(reveal_command(Path::new(&path)), &path)
}
/// Opens `path` with whatever the platform associates with it.
pub fn open_external(path: String) -> Result<(), ModuleError> {
ensure_exists(&path)?;
run(open_external_command(Path::new(&path)), &path)
}
/// A dialog carrying everything the three calls configure the same way: title,
/// starting directory, and the extension filters.
fn configured(
title: Option<&str>,
default_path: Option<&str>,
filters: &[DialogFilter],
) -> rfd::FileDialog {
let mut dialog = rfd::FileDialog::new();
if let Some(title) = title {
dialog = dialog.set_title(title);
}
if let Some(directory) = split_default_path(default_path).0 {
dialog = dialog.set_directory(directory);
}
for filter in filters {
// An empty extension list carries no information, and on some backends
// a filter is applied as an allow-list, which would hide every file.
if !filter.extensions.is_empty() {
dialog = dialog.add_filter(filter.name.as_str(), &filter.extensions);
}
}
dialog
}
/// Splits `defaultPath` into the directory a picker should open in, and the file
/// name to prefill when the path names a file rather than a folder.
///
/// The two pickers need different halves: the open dialog only starts in the
/// directory, the save dialog also offers the name.
fn split_default_path(default_path: Option<&str>) -> (Option<PathBuf>, Option<String>) {
let Some(raw) = default_path.map(str::trim).filter(|path| !path.is_empty()) else {
return (None, None);
};
let path = Path::new(raw);
let is_directory = match std::fs::metadata(path) {
Ok(metadata) => metadata.is_dir(),
// A save dialog is usually given a path that does not exist yet, so fall
// back to the spelling: a trailing separator promises a directory, and
// anything else is a proposed file name whose parent is the start.
Err(_) => ends_with_separator(raw),
};
if is_directory {
(Some(path.to_path_buf()), None)
} else {
(
path.parent()
.filter(|parent| !parent.as_os_str().is_empty())
.map(Path::to_path_buf),
path.file_name()
.and_then(|name| name.to_str())
.map(str::to_string),
)
}
}
/// Whether a path is spelled as a directory on any of the platforms this module
/// runs on, so a Windows path still splits correctly when read elsewhere.
fn ends_with_separator(path: &str) -> bool {
path.ends_with('/') || path.ends_with('\\')
}
/// The picked paths as strings, dropping any the bridge cannot represent.
///
/// A Unix file name need not be UTF-8, but JS can only hold strings. Failing the
/// whole selection because one of several files has an unusual name would throw
/// away the files the user can use, so the others are returned instead.
fn utf8_paths(paths: Vec<PathBuf>) -> Vec<String> {
paths
.into_iter()
.filter_map(|path| utf8_path(&path))
.collect()
}
fn utf8_path(path: &Path) -> Option<String> {
path.to_str().map(str::to_string)
}
/// Fails with the same code `stat` would before a platform command can turn the
/// problem into an opaque non-zero exit.
fn ensure_exists(path: &str) -> Result<(), ModuleError> {
// `symlink_metadata` rather than `metadata`: a dangling symlink is still a
// row in Finder or Explorer, so revealing it is meaningful.
std::fs::symlink_metadata(path)
.map(|_| ())
.map_err(|error| errors::from_io(error, path))
}
/// A command line, kept separate from running it so the platform branches can be
/// asserted on without opening anything.
#[derive(Debug, Clone, PartialEq, Eq)]
struct CommandSpec {
program: &'static str,
args: Vec<String>,
}
/// The command that reveals `path` in the platform's file manager.
fn reveal_command(path: &Path) -> CommandSpec {
let shown = path.to_string_lossy().into_owned();
#[cfg(target_os = "macos")]
{
// `open -R` is the only thing that selects the item rather than opening
// its containing folder.
CommandSpec {
program: "open",
args: vec!["-R".to_string(), shown],
}
}
#[cfg(target_os = "windows")]
{
// `/select,<path>` is a single argument, comma included.
//
// Note: explorer.exe is known to return exit code 1 even when it does
// open the window, which the non-zero check below would report as a
// failure. That needs a check on a Windows machine; if it does bite,
// this branch should treat a successful spawn as success instead of
// reading the exit code.
CommandSpec {
program: "explorer",
args: vec![format!("/select,{shown}")],
}
}
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
{
// `xdg-open` has no portable "select this item" verb, so the containing
// folder is the closest equivalent. A path with no parent is its own
// directory and is opened as it is.
let folder = path.parent().unwrap_or(path);
CommandSpec {
program: "xdg-open",
args: vec![folder.to_string_lossy().into_owned()],
}
}
}
/// The command that opens `path` with the platform's default application.
fn open_external_command(path: &Path) -> CommandSpec {
let shown = path.to_string_lossy().into_owned();
#[cfg(target_os = "macos")]
{
CommandSpec {
program: "open",
args: vec![shown],
}
}
#[cfg(target_os = "windows")]
{
// `start` reads its first quoted argument as the window title, so the
// empty string is required for a path containing spaces to survive.
CommandSpec {
program: "cmd",
args: vec!["/C".to_string(), "start".to_string(), String::new(), shown],
}
}
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
{
CommandSpec {
program: "xdg-open",
args: vec![shown],
}
}
}
/// Runs a command and turns a refusal into a Node-shaped error.
///
/// `Command` is used without a shell, so a path is one argument no matter what
/// characters it contains; quoting and escaping are the platform's problem, not
/// a shell's.
fn run(spec: CommandSpec, path: &str) -> Result<(), ModuleError> {
let status = Command::new(spec.program)
.args(&spec.args)
.status()
// The program itself could not be started (missing, or not executable),
// which is an I/O failure on the path the caller asked about.
.map_err(|error| errors::from_io(error, path))?;
if status.success() {
return Ok(());
}
Err(errors::invalid(
"EIO",
format!(
"EIO: i/o error, {path} ({} exited with {status})",
spec.program
),
))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_default_path_that_names_a_file_splits_into_parent_and_name() {
let file = std::env::temp_dir().join("rn-gpui-fs-dialog-default.txt");
let (directory, name) = split_default_path(Some(&file.to_string_lossy()));
assert_eq!(directory.as_deref(), Some(std::env::temp_dir().as_path()));
assert_eq!(name.as_deref(), Some("rn-gpui-fs-dialog-default.txt"));
}
#[test]
fn a_default_path_that_names_an_existing_directory_stays_a_directory() {
let directory = std::env::temp_dir();
let (found, name) = split_default_path(Some(&directory.to_string_lossy()));
assert_eq!(found, Some(directory));
assert_eq!(name, None);
}
#[test]
fn a_bare_file_name_has_no_directory_to_open_in() {
let (directory, name) = split_default_path(Some("rn-gpui-fs-report.csv"));
assert_eq!(directory, None);
assert_eq!(name.as_deref(), Some("rn-gpui-fs-report.csv"));
}
#[test]
fn a_trailing_separator_marks_a_path_that_does_not_exist_as_a_directory() {
let missing = std::env::temp_dir().join("rn-gpui-fs-not-created");
let spelled = format!("{}/", missing.to_string_lossy());
let (directory, name) = split_default_path(Some(&spelled));
assert_eq!(directory, Some(missing));
assert_eq!(name, None);
}
#[test]
fn no_default_path_means_no_directory_and_no_name() {
assert_eq!(split_default_path(None), (None, None));
assert_eq!(split_default_path(Some(" ")), (None, None));
}
#[test]
fn reveal_targets_the_platform_file_manager() {
let spec = reveal_command(Path::new("/tmp/example.txt"));
#[cfg(target_os = "macos")]
{
assert_eq!(spec.program, "open");
assert_eq!(spec.args, ["-R", "/tmp/example.txt"]);
}
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
{
assert_eq!(spec.program, "xdg-open");
// Only the folder, because xdg-open cannot select an item in it.
assert_eq!(spec.args, ["/tmp"]);
}
#[cfg(target_os = "windows")]
{
assert_eq!(spec.program, "explorer");
assert_eq!(spec.args, ["/select,/tmp/example.txt"]);
}
}
#[test]
fn open_external_hands_the_path_to_the_default_application() {
let spec = open_external_command(Path::new("/tmp/example.txt"));
assert_eq!(
spec.args.last().map(String::as_str),
Some("/tmp/example.txt")
);
#[cfg(target_os = "macos")]
{
assert_eq!(spec.program, "open");
assert_eq!(spec.args.len(), 1);
}
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
assert_eq!(spec.program, "xdg-open");
#[cfg(target_os = "windows")]
{
assert_eq!(spec.program, "cmd");
// `/C start "" <path>`: the empty argument is the title `start`
// would otherwise take the path for.
assert_eq!(&spec.args[..3], ["/C", "start", ""]);
}
}
#[test]
fn a_path_with_spaces_is_still_a_single_argument() {
let spec = open_external_command(Path::new("/tmp/two words.txt"));
assert_eq!(
spec.args.last().map(String::as_str),
Some("/tmp/two words.txt")
);
}
}
+363
View File
@@ -0,0 +1,363 @@
//! Listing, creating and removing directories, and `mkdtemp`.
//!
//! `readdir` returns every entry as a plain `{name, kind, parentPath}` object,
//! which the JS layer turns into `Dirent` instances; there is no per-directory
//! cursor to hold open because the whole listing crosses the bridge at once.
use std::fs;
use std::path::{Path, PathBuf};
use rn_gpui_module::ModuleError;
use crate::errors;
use crate::module::kind;
use crate::DirEntry;
pub async fn readdir(path: String, recursive: bool) -> Result<Vec<DirEntry>, ModuleError> {
readdir_sync(path, recursive)
}
pub async fn mkdir(path: String, recursive: bool, mode: f64) -> Result<(), ModuleError> {
create_dir(&path, recursive, mode)
}
pub async fn rm(path: String, recursive: bool, force: bool) -> Result<(), ModuleError> {
remove(&path, recursive, force)
}
pub async fn mkdtemp(prefix: String) -> Result<String, ModuleError> {
let directory = tempfile::Builder::new()
.prefix(&prefix)
.tempdir_in(std::env::temp_dir())
.map_err(|error| errors::from_io(error, &prefix))?;
// The caller asked for a directory to keep, so it must outlive this frame;
// dropping the guard would delete it again.
Ok(path_string(directory.keep()))
}
pub fn readdir_sync(path: String, recursive: bool) -> Result<Vec<DirEntry>, ModuleError> {
let entries = list(Path::new(&path))?;
if !recursive {
return Ok(entries);
}
let mut collected = entries;
// Depth-first, appending each level as it is reached. Iterative rather than
// recursive: a deep tree should cost heap, not stack.
let mut pending: Vec<PathBuf> = collected
.iter()
.filter(|entry| entry.kind == kind::DIRECTORY)
.map(|entry| Path::new(&entry.parent_path).join(&entry.name))
.collect();
while let Some(directory) = pending.pop() {
let children = list(&directory)?;
pending.extend(
children
.iter()
.filter(|entry| entry.kind == kind::DIRECTORY)
.map(|entry| Path::new(&entry.parent_path).join(&entry.name)),
);
collected.extend(children);
}
Ok(collected)
}
pub fn mkdir_sync(path: String, recursive: bool, mode: f64) -> Result<bool, ModuleError> {
create_dir(&path, recursive, mode)?;
Ok(true)
}
pub fn rm_sync(path: String, recursive: bool, force: bool) -> Result<bool, ModuleError> {
remove(&path, recursive, force)?;
Ok(true)
}
/// The direct children of `path`, in the order the file system lists them.
///
/// `DirEntry::file_type` does not follow symbolic links, so a link is reported
/// as a link rather than as whatever it points at.
fn list(path: &Path) -> Result<Vec<DirEntry>, ModuleError> {
let name = path_string(path.to_path_buf());
let iterator = fs::read_dir(path).map_err(|error| errors::from_io(error, &name))?;
let mut entries = Vec::new();
for entry in iterator {
let entry = entry.map_err(|error| errors::from_io(error, &name))?;
let file_type = entry
.file_type()
.map_err(|error| errors::from_io(error, &name))?;
entries.push(DirEntry {
name: entry.file_name().to_string_lossy().into_owned(),
kind: entry_kind(&file_type),
parent_path: name.clone(),
});
}
Ok(entries)
}
fn entry_kind(file_type: &fs::FileType) -> f64 {
if file_type.is_file() {
kind::FILE
} else if file_type.is_dir() {
kind::DIRECTORY
} else if file_type.is_symlink() {
kind::SYMLINK
} else {
kind::OTHER
}
}
fn create_dir(path: &str, recursive: bool, mode: f64) -> Result<(), ModuleError> {
if recursive {
// `create_dir_all` succeeds on an existing directory, which is what
// Node's `{recursive: true}` promises.
return fs::create_dir_all(path).map_err(|error| errors::from_io(error, path));
}
#[cfg(unix)]
{
use std::os::unix::fs::DirBuilderExt as _;
let mut builder = fs::DirBuilder::new();
builder.mode(crate::module::mode(mode));
builder
.create(path)
.map_err(|error| errors::from_io(error, path))
}
// Windows has no mode for a new directory, and `create_dir` would reject
// the `DirBuilder` extension that only exists on Unix.
#[cfg(not(unix))]
{
let _ = mode;
fs::create_dir(path).map_err(|error| errors::from_io(error, path))
}
}
fn remove(path: &str, recursive: bool, force: bool) -> Result<(), ModuleError> {
// `symlink_metadata`, so a link is removed as a link instead of acting on
// its target.
let metadata = match fs::symlink_metadata(path) {
Ok(metadata) => metadata,
Err(error) if force && errors::code_for(&error) == "ENOENT" => return Ok(()),
Err(error) => return Err(errors::from_io(error, path)),
};
if recursive && metadata.is_dir() {
return fs::remove_dir_all(path).map_err(|error| errors::from_io(error, path));
}
if metadata.is_dir() {
// `remove_dir` fails with ENOTEMPTY on Unix and a folder-not-empty error
// on Windows, which the mapping turns into the same code; removing a
// file with it reports what Node calls ENOTDIR.
fs::remove_dir(path).map_err(|error| errors::from_io(error, path))
} else {
fs::remove_file(path).map_err(|error| errors::from_io(error, path))
}
}
fn path_string(path: PathBuf) -> String {
path.to_string_lossy().into_owned()
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
/// The async wrappers never suspend, so polling once with a no-op waker is
/// enough and keeps the tests independent of a runtime.
fn block_on<F: std::future::Future>(future: F) -> F::Output {
let mut future = std::pin::pin!(future);
let waker = std::task::Waker::noop();
let mut context = std::task::Context::from_waker(waker);
// One poll, no loop: these helpers do their work inline, so a `Pending`
// would mean the helper is not synchronous after all, which the test
// should fail loudly on rather than spin.
match future.as_mut().poll(&mut context) {
std::task::Poll::Ready(value) => value,
std::task::Poll::Pending => panic!("the wrappers are synchronous"),
}
}
fn tempdir() -> TempDir {
TempDir::new().expect("a temporary directory")
}
fn text(path: &Path) -> String {
path.to_string_lossy().into_owned()
}
/// The listed paths, with separators normalised to `/`.
///
/// The entries themselves carry the platform separator, which on Windows is
/// `\`; building the expected path with `/` and comparing would test the
/// separator rather than the listing.
fn names(entries: &[DirEntry]) -> Vec<String> {
let mut names: Vec<String> = entries
.iter()
.map(|entry| format!("{}/{}", entry.parent_path, entry.name).replace('\\', "/"))
.collect();
names.sort();
names
}
fn tree(root: &Path) {
fs::create_dir_all(root.join("nested").join("deep")).expect("nested");
fs::write(root.join("top.txt"), b"top").expect("seed");
fs::write(root.join("nested").join("middle.txt"), b"middle").expect("seed");
fs::write(root.join("nested").join("deep").join("leaf.txt"), b"leaf").expect("seed");
}
#[test]
fn readdir_lists_direct_children_only() {
let dir = tempdir();
tree(dir.path());
let entries = readdir_sync(text(dir.path()), false).expect("readdir");
// `names` normalises separators, so the expected paths are built from a
// normalised root rather than the platform one.
let root = text(dir.path()).replace('\\', "/");
assert_eq!(
names(&entries),
vec![format!("{root}/nested"), format!("{root}/top.txt")]
);
let top = entries
.iter()
.find(|entry| entry.name == "top.txt")
.expect("top.txt");
assert_eq!(top.kind, kind::FILE);
assert_eq!(top.parent_path, text(dir.path()));
let nested = entries
.iter()
.find(|entry| entry.name == "nested")
.expect("nested");
assert_eq!(nested.kind, kind::DIRECTORY);
}
#[test]
fn readdir_recursive_reaches_every_descendant() {
let dir = tempdir();
tree(dir.path());
let entries = block_on(readdir(text(dir.path()), true)).expect("readdir");
// Two entries per directory, three directories deep.
assert_eq!(entries.len(), 5);
let listed = names(&entries);
let root = text(dir.path()).replace('\\', "/");
assert!(listed.contains(&format!("{root}/nested/deep/leaf.txt")));
assert!(listed.contains(&format!("{root}/nested/deep")));
let deep = entries
.iter()
.find(|entry| entry.name == "deep")
.expect("deep");
assert_eq!(
deep.parent_path,
text(&dir.path().join("nested")),
"parentPath is the containing directory"
);
}
#[cfg(unix)]
#[test]
fn readdir_reports_a_link_as_a_link() {
let dir = tempdir();
let target = dir.path().join("target.txt");
fs::write(&target, b"x").expect("seed");
std::os::unix::fs::symlink(&target, dir.path().join("link.txt")).expect("symlink");
let entries = readdir_sync(text(dir.path()), false).expect("readdir");
let link = entries
.iter()
.find(|entry| entry.name == "link.txt")
.expect("link.txt");
assert_eq!(link.kind, kind::SYMLINK);
}
#[test]
fn mkdir_with_recursive_creates_the_chain_and_accepts_an_existing_dir() {
let dir = tempdir();
let nested = dir.path().join("a").join("b").join("c");
block_on(mkdir(text(&nested), true, f64::from(0o755u32))).expect("mkdir");
assert!(nested.is_dir());
let again = mkdir_sync(text(&nested), true, f64::from(0o755u32)).expect("mkdir again");
assert!(again, "recursive mkdir succeeds when the path exists");
}
#[test]
fn mkdir_without_recursive_fails_with_eexist() {
let dir = tempdir();
let path = dir.path().join("single");
assert!(mkdir_sync(text(&path), false, f64::from(0o755u32)).expect("mkdir"));
assert!(path.is_dir());
let error =
block_on(mkdir(text(&path), false, f64::from(0o755u32))).expect_err("mkdir twice");
assert_eq!(error.code.as_deref(), Some("EEXIST"));
let missing_parent = text(&dir.path().join("no").join("such").join("dir"));
let error =
mkdir_sync(missing_parent, false, f64::from(0o755u32)).expect_err("missing parent");
assert_eq!(error.code.as_deref(), Some("ENOENT"));
}
#[test]
fn rm_force_ignores_a_missing_path() {
let dir = tempdir();
let missing = text(&dir.path().join("absent"));
block_on(rm(missing.clone(), false, true)).expect("rm force");
assert!(rm_sync(missing.clone(), true, true).expect("rm sync force"));
let error = block_on(rm(missing, false, false)).expect_err("rm without force");
assert_eq!(error.code.as_deref(), Some("ENOENT"));
}
#[test]
fn rm_removes_a_file_and_a_tree() {
let dir = tempdir();
let file = dir.path().join("one.txt");
fs::write(&file, b"x").expect("seed");
block_on(rm(text(&file), false, false)).expect("rm file");
assert!(!file.exists());
let root = dir.path().join("tree");
tree(&root);
assert!(rm_sync(text(&root), true, false).expect("rm tree"));
assert!(!root.exists());
}
#[test]
fn rm_without_recursive_refuses_a_non_empty_directory() {
let dir = tempdir();
let root = dir.path().join("tree");
tree(&root);
let error = block_on(rm(text(&root), false, false)).expect_err("non-empty");
assert_eq!(error.code.as_deref(), Some("ENOTEMPTY"));
assert!(root.exists());
}
#[test]
fn mkdtemp_creates_a_random_directory_with_the_prefix() {
let created = block_on(mkdtemp("rn-gpui-fs-".to_string())).expect("mkdtemp");
let path = Path::new(&created);
let name = path
.file_name()
.expect("the returned path names a directory")
.to_string_lossy()
.into_owned();
assert!(name.starts_with("rn-gpui-fs-"), "name was {name}");
assert!(name.len() > "rn-gpui-fs-".len(), "the rest is random");
assert!(path.is_dir());
assert_eq!(path.parent(), Some(std::env::temp_dir().as_path()));
fs::remove_dir_all(path).expect("clean up");
}
#[test]
fn readdir_of_a_missing_directory_is_enoent() {
let dir = tempdir();
let error = readdir_sync(text(&dir.path().join("absent")), false).expect_err("readdir");
assert_eq!(error.code.as_deref(), Some("ENOENT"));
}
}
@@ -0,0 +1,149 @@
//! Node-compatible error mapping.
//!
//! Every failure a caller sees has to look like the one Node would have raised for
//! the same call: a `code` such as `ENOENT`, and a message in Node's format.
//! `std::io::Error` gives a kind and, on Unix, a raw errno; neither is a Node code,
//! so the translation lives here once rather than at each call site.
use rn_gpui_module::ModuleError;
/// The Node code for an `std::io::Error`.
///
/// The raw OS error is consulted before the kind because several distinct errnos
/// share a kind: `EEXIST` and `ENOTEMPTY` are both "already exists"-ish, and
/// `EACCES` versus `EPERM` matter to a caller deciding whether to try again.
pub fn code_for(error: &std::io::Error) -> &'static str {
#[cfg(unix)]
if let Some(errno) = error.raw_os_error() {
let code = match errno {
libc::ENOENT => "ENOENT",
libc::EACCES => "EACCES",
libc::EPERM => "EPERM",
libc::EEXIST => "EEXIST",
libc::ENOTDIR => "ENOTDIR",
libc::EISDIR => "EISDIR",
libc::ENOTEMPTY => "ENOTEMPTY",
libc::EINVAL => "EINVAL",
libc::ELOOP => "ELOOP",
libc::ENOSPC => "ENOSPC",
libc::EROFS => "EROFS",
libc::EMFILE => "EMFILE",
libc::ENAMETOOLONG => "ENAMETOOLONG",
// Windows' share-violation has no Unix equivalent; callers treat it
// as a permission problem, which is what Node's `EPERM` means there.
_ => return code_for_kind(error.kind()),
};
return code;
}
// Windows exposes `raw_os_error` too, but its values are Win32 codes rather
// than errnos, so the kind is the better signal there, with the few Win32
// codes whose kind is not enough named below.
#[cfg(not(unix))]
if let Some(code) = win32_code(error.raw_os_error()) {
return code;
}
code_for_kind(error.kind())
}
/// The Node code for a Win32 error whose `ErrorKind` is too coarse.
///
/// `ERROR_PRIVILEGE_NOT_HELD` (1314) has no kind of its own, so it would be `EIO`.
/// Creating a symbolic link without the privilege is the case that hits it, and
/// libuv, which Node is built on, reports it as `EPERM`.
#[cfg(not(unix))]
fn win32_code(raw: Option<i32>) -> Option<&'static str> {
match raw {
Some(1314) => Some("EPERM"),
_ => None,
}
}
fn code_for_kind(kind: std::io::ErrorKind) -> &'static str {
use std::io::ErrorKind;
match kind {
ErrorKind::NotFound => "ENOENT",
ErrorKind::PermissionDenied => "EACCES",
ErrorKind::AlreadyExists => "EEXIST",
ErrorKind::InvalidInput => "EINVAL",
ErrorKind::InvalidData => "EINVAL",
ErrorKind::WriteZero => "ENOSPC",
ErrorKind::StorageFull => "ENOSPC",
ErrorKind::ReadOnlyFilesystem => "EROFS",
ErrorKind::DirectoryNotEmpty => "ENOTEMPTY",
ErrorKind::IsADirectory => "EISDIR",
ErrorKind::NotADirectory => "ENOTDIR",
_ => "EIO",
}
}
/// The human half of a Node error message, by code.
///
/// Node's own table, because the tests compare against messages and because these
/// strings are what a developer recognises when a path is wrong.
fn description_for(code: &str) -> &'static str {
match code {
"ENOENT" => "no such file or directory",
"EACCES" => "permission denied",
"EPERM" => "operation not permitted",
"EEXIST" => "file already exists",
"ENOTDIR" => "not a directory",
"EISDIR" => "illegal operation on a directory",
"ENOTEMPTY" => "directory not empty",
"EINVAL" => "invalid argument",
"ELOOP" => "too many symbolic links encountered",
"ENOSPC" => "no space left on device",
"EROFS" => "read-only file system",
"EMFILE" => "too many open files",
"ENAMETOOLONG" => "name too long",
_ => "i/o error",
}
}
/// An `std::io::Error` as the `ModuleError` JS should receive.
///
/// `syscall` and `path` are attached by the JS layer, which knows the names the
/// caller used; the native side would have to guess between `open`, `lstat` and
/// the rest for what is already a single `stat` call.
pub fn from_io(error: std::io::Error, path: &str) -> ModuleError {
let code = code_for(&error);
ModuleError::with_code(code, format!("{code}: {}, {path}", description_for(code)))
}
/// A failure that has no `std::io::Error`, such as a closed file descriptor.
pub fn invalid(code: &str, message: impl Into<String>) -> ModuleError {
ModuleError::with_code(code, message)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_missing_file_is_enoent_with_nodes_message() {
let error = std::io::Error::new(std::io::ErrorKind::NotFound, "gone");
let mapped = from_io(error, "/nope");
assert_eq!(mapped.code.as_deref(), Some("ENOENT"));
assert_eq!(mapped.message, "ENOENT: no such file or directory, /nope");
}
#[test]
fn a_denied_file_is_eacces() {
let error = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "nope");
assert_eq!(from_io(error, "/x").code.as_deref(), Some("EACCES"));
}
#[cfg(windows)]
#[test]
fn a_missing_symlink_privilege_is_eperm_not_eio() {
// ERROR_PRIVILEGE_NOT_HELD: what `symlink_dir` returns without developer
// mode, and what Node reports as EPERM.
let error = std::io::Error::from_raw_os_error(1314);
assert_eq!(from_io(error, "C:\\link").code.as_deref(), Some("EPERM"));
}
#[test]
fn an_unknown_failure_still_has_a_code() {
let error = std::io::Error::new(std::io::ErrorKind::Other, "odd");
assert_eq!(from_io(error, "/x").code.as_deref(), Some("EIO"));
}
}
+640
View File
@@ -0,0 +1,640 @@
//! Reading, writing, copying, truncating, permissions and times.
//!
//! The async forms are thin wrappers over the sync ones: `std::fs` is blocking
//! either way, and this module has a thread of its own, so the only difference
//! is which thread the bridge calls the function on.
//!
//! Every failure goes through `errors::from_io`, so JS receives a Node code and
//! Node's message rather than an `std::io::Error` kind.
use std::fs;
use std::io::Write as _;
use std::path::Path;
use rn_gpui_module::{ArrayBuffer, ModuleError};
use crate::errors;
use crate::module::{kind, millis, open_options, system_time};
use crate::StatResult;
pub async fn read_file(path: String) -> Result<ArrayBuffer, ModuleError> {
read_file_sync(path)
}
pub async fn read_text_file(path: String) -> Result<String, ModuleError> {
read_text_file_sync(path)
}
pub async fn write_file(
path: String,
data: ArrayBuffer,
flags: f64,
mode: f64,
) -> Result<(), ModuleError> {
write_bytes(&path, data.as_slice(), flags, mode)
}
pub async fn write_text_file(
path: String,
data: String,
flags: f64,
mode: f64,
) -> Result<(), ModuleError> {
write_bytes(&path, data.as_bytes(), flags, mode)
}
pub async fn stat(path: String, follow_symlinks: bool) -> Result<StatResult, ModuleError> {
stat_of(&path, follow_symlinks)
}
pub async fn rename(from: String, to: String) -> Result<(), ModuleError> {
fs::rename(&from, &to).map_err(|error| errors::from_io(error, &from))
}
pub async fn copy(from: String, to: String, recursive: bool) -> Result<(), ModuleError> {
copy_path(&from, &to, recursive)
}
pub async fn truncate(path: String, length: f64) -> Result<(), ModuleError> {
let length = crate::module::length(length)?;
let file = fs::OpenOptions::new()
.write(true)
.open(&path)
.map_err(|error| errors::from_io(error, &path))?;
file.set_len(length as u64)
.map_err(|error| errors::from_io(error, &path))
}
pub async fn utimes(path: String, atime_ms: f64, mtime_ms: f64) -> Result<(), ModuleError> {
let atime = filetime::FileTime::from_system_time(system_time(atime_ms));
let mtime = filetime::FileTime::from_system_time(system_time(mtime_ms));
filetime::set_file_times(&path, atime, mtime).map_err(|error| errors::from_io(error, &path))
}
pub async fn chmod(path: String, mode: f64) -> Result<(), ModuleError> {
set_mode(&path, mode)
}
pub fn read_file_sync(path: String) -> Result<ArrayBuffer, ModuleError> {
fs::read(&path)
.map(ArrayBuffer::from)
.map_err(|error| errors::from_io(error, &path))
}
pub fn read_text_file_sync(path: String) -> Result<String, ModuleError> {
let bytes = fs::read(&path).map_err(|error| errors::from_io(error, &path))?;
// Node decodes with replacement characters instead of rejecting a file that
// is not valid UTF-8, so reading a mis-encoded file still yields text.
Ok(String::from_utf8_lossy(&bytes).into_owned())
}
pub fn write_file_sync(
path: String,
data: ArrayBuffer,
flags: f64,
mode: f64,
) -> Result<bool, ModuleError> {
write_bytes(&path, data.as_slice(), flags, mode)?;
Ok(true)
}
pub fn write_text_file_sync(
path: String,
data: String,
flags: f64,
mode: f64,
) -> Result<bool, ModuleError> {
write_bytes(&path, data.as_bytes(), flags, mode)?;
Ok(true)
}
/// `Ok(None)` for a path that is not there: that is what `existsSync` asks, and
/// only a missing path means "no". A permission failure is still an error.
pub fn stat_sync(path: String, follow_symlinks: bool) -> Result<Option<StatResult>, ModuleError> {
match stat_of(&path, follow_symlinks) {
Ok(result) => Ok(Some(result)),
Err(error) if error.code.as_deref() == Some("ENOENT") => Ok(None),
Err(error) => Err(error),
}
}
pub fn exists_sync(path: String) -> Result<bool, ModuleError> {
Ok(matches!(stat_sync(path, true), Ok(Some(_))))
}
pub fn rename_sync(from: String, to: String) -> Result<bool, ModuleError> {
fs::rename(&from, &to).map_err(|error| errors::from_io(error, &from))?;
Ok(true)
}
pub fn copy_file_sync(from: String, to: String) -> Result<bool, ModuleError> {
fs::copy(&from, &to).map_err(|error| errors::from_io(error, &from))?;
Ok(true)
}
/// The one place a write happens, so bytes and text share the flag and mode
/// translation exactly.
fn write_bytes(path: &str, data: &[u8], flags: f64, mode: f64) -> Result<(), ModuleError> {
let mut file = open_options(flags, crate::module::mode(mode))
.open(path)
.map_err(|error| errors::from_io(error, path))?;
file.write_all(data)
.map_err(|error| errors::from_io(error, path))
}
fn stat_of(path: &str, follow_symlinks: bool) -> Result<StatResult, ModuleError> {
let metadata = if follow_symlinks {
fs::metadata(path)
} else {
fs::symlink_metadata(path)
}
.map_err(|error| errors::from_io(error, path))?;
Ok(stat_result(&metadata))
}
/// The generated `StatResult`, filled from `std::fs::Metadata`.
///
/// Node's `Stats` names the Unix inode fields even on Windows, where they have
/// no meaning; zero is what Node itself reports there.
fn stat_result(metadata: &fs::Metadata) -> StatResult {
let file_type = metadata.file_type();
let kind = if file_type.is_file() {
kind::FILE
} else if file_type.is_dir() {
kind::DIRECTORY
} else if file_type.is_symlink() {
kind::SYMLINK
} else {
kind::OTHER
};
#[cfg(unix)]
let (mode, dev, ino, nlink, uid, gid) = {
use std::os::unix::fs::MetadataExt as _;
(
metadata.mode() as f64,
metadata.dev() as f64,
metadata.ino() as f64,
metadata.nlink() as f64,
metadata.uid() as f64,
metadata.gid() as f64,
)
};
#[cfg(not(unix))]
let (mode, dev, ino, nlink, uid, gid) = (0.0, 0.0, 0.0, 0.0, 0.0, 0.0);
// `ctime` is the inode change time on Unix; Windows has no such thing, and
// its creation time is the closest equivalent.
#[cfg(unix)]
let ctime_ms = {
use std::os::unix::fs::MetadataExt as _;
metadata.ctime() as f64 * 1000.0 + metadata.ctime_nsec() as f64 / 1_000_000.0
};
#[cfg(not(unix))]
let ctime_ms = metadata
.created()
.map(millis)
.unwrap_or_else(|_| metadata.modified().map(millis).unwrap_or(0.0));
// A file system that cannot report creation gives 0, as Node does, rather
// than pretending the modification time is the birth time.
let birthtime_ms = metadata.created().map(millis).unwrap_or(0.0);
StatResult {
size: metadata.len() as f64,
mode,
kind,
atime_ms: metadata.accessed().map(millis).unwrap_or(0.0),
mtime_ms: metadata.modified().map(millis).unwrap_or(0.0),
ctime_ms,
birthtime_ms,
dev,
ino,
nlink,
uid,
gid,
}
}
fn copy_path(from: &str, to: &str, recursive: bool) -> Result<(), ModuleError> {
let metadata = fs::symlink_metadata(from).map_err(|error| errors::from_io(error, from))?;
if !metadata.is_dir() {
fs::copy(from, to).map_err(|error| errors::from_io(error, from))?;
return Ok(());
}
if !recursive {
return Err(errors::invalid(
"EISDIR",
format!("EISDIR: illegal operation on a directory, {from}"),
));
}
copy_tree(Path::new(from), Path::new(to))
}
/// Refuse to copy a directory into itself or a directory beneath it.
///
/// The destination may not exist yet, so the comparison is against its nearest
/// existing ancestor: `a` into `a/b/c` has to be caught before `a/b/c` exists,
/// which is the whole point. Both sides are canonicalised so a symlinked path
/// cannot slip past the check.
///
/// Node's code and wording are reproduced rather than invented; `cpSync` reports
/// `ERR_FS_CP_EINVAL` with `Cannot copy /tmp/cpa/ to a subdirectory of self
/// /tmp/cpa/sub`, and callers match on both.
fn refuse_copy_into_self(from: &Path, to: &Path) -> Result<(), ModuleError> {
let resolve = |path: &Path| -> Option<std::path::PathBuf> {
let mut existing = path.to_path_buf();
let mut tail = Vec::new();
loop {
if let Ok(canonical) = existing.canonicalize() {
return Some(
tail.iter()
.rev()
.fold(canonical, |base, part| base.join(part)),
);
}
let name = existing.file_name().map(|name| name.to_owned())?;
tail.push(name);
if !existing.pop() {
return None;
}
}
};
let Some(source) = resolve(from) else {
return Ok(());
};
let Some(destination) = resolve(to) else {
return Ok(());
};
if destination == source || destination.starts_with(&source) {
return Err(errors::invalid(
"ERR_FS_CP_EINVAL",
format!(
"Cannot copy {} to a subdirectory of self {}",
from.display(),
to.display()
),
));
}
Ok(())
}
fn copy_tree(from: &Path, to: &Path) -> Result<(), ModuleError> {
let target = to.to_string_lossy().into_owned();
// Checked before anything is created: copying a directory into its own
// subtree would otherwise recurse forever, and the tree it built on the way
// down would have to be cleaned up by the caller.
refuse_copy_into_self(from, to)?;
fs::create_dir_all(to).map_err(|error| errors::from_io(error, &target))?;
let children = fs::read_dir(from).map_err(|error| errors::from_io(error, &source(from)))?;
for child in children {
let child = child.map_err(|error| errors::from_io(error, &source(from)))?;
let path = child.path();
let destination = to.join(child.file_name());
// `file_type` does not follow links, which keeps a link cycle in the
// source tree from turning into infinite recursion.
let file_type = child
.file_type()
.map_err(|error| errors::from_io(error, &source(&path)))?;
if file_type.is_dir() {
copy_tree(&path, &destination)?;
} else if file_type.is_symlink() {
copy_symlink(&path, &destination)?;
} else {
fs::copy(&path, &destination)
.map_err(|error| errors::from_io(error, &source(&path)))?;
}
}
Ok(())
}
#[cfg(unix)]
fn copy_symlink(from: &Path, to: &Path) -> Result<(), ModuleError> {
let target = fs::read_link(from).map_err(|error| errors::from_io(error, &source(from)))?;
std::os::unix::fs::symlink(&target, to).map_err(|error| errors::from_io(error, &source(from)))
}
/// Windows needs a privilege to create a symbolic link, so there the link is
/// followed and its target copied, which is what a caller copying a tree wants
/// to end up with anyway.
#[cfg(not(unix))]
fn copy_symlink(from: &Path, to: &Path) -> Result<(), ModuleError> {
fs::copy(from, to)
.map(|_| ())
.map_err(|error| errors::from_io(error, &source(from)))
}
fn source(path: &Path) -> String {
path.to_string_lossy().into_owned()
}
#[cfg(unix)]
fn set_mode(path: &str, mode: f64) -> Result<(), ModuleError> {
use std::os::unix::fs::PermissionsExt as _;
let bits = if mode.is_finite() && mode >= 0.0 {
mode as u32 & 0o7777
} else {
0o666
};
fs::set_permissions(path, fs::Permissions::from_mode(bits))
.map_err(|error| errors::from_io(error, path))
}
/// Windows has no permission bits. The closest equivalent is the read-only
/// attribute, so a mode without a write bit marks the file read-only and any
/// other mode clears it; nothing else about the mode survives.
#[cfg(not(unix))]
fn set_mode(path: &str, mode: f64) -> Result<(), ModuleError> {
let metadata = fs::metadata(path).map_err(|error| errors::from_io(error, path))?;
let mut permissions = metadata.permissions();
permissions.set_readonly(mode.is_finite() && mode > 0.0 && mode as u32 & 0o200 == 0);
fs::set_permissions(path, permissions).map_err(|error| errors::from_io(error, path))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::module::flags;
use tempfile::TempDir;
/// The async wrappers never actually suspend (they call `std::fs` directly),
/// so a single-poll executor is enough and keeps the tests free of a runtime.
fn block_on<F: std::future::Future>(future: F) -> F::Output {
let mut future = std::pin::pin!(future);
let waker = std::task::Waker::noop();
let mut context = std::task::Context::from_waker(waker);
// One poll, no loop: these helpers do their work inline, so a `Pending`
// would mean the helper is not synchronous after all, which the test
// should fail loudly on rather than spin.
match future.as_mut().poll(&mut context) {
std::task::Poll::Ready(value) => value,
std::task::Poll::Pending => panic!("the wrappers are synchronous"),
}
}
fn tempdir() -> TempDir {
TempDir::new().expect("a temporary directory")
}
fn text(path: &Path) -> String {
path.to_string_lossy().into_owned()
}
/// `'w'` in Node's table.
fn write_flags() -> f64 {
f64::from(flags::WRONLY | flags::CREAT | flags::TRUNC)
}
#[test]
fn text_round_trip() {
let dir = tempdir();
let path = dir.path().join("note.txt");
block_on(write_text_file(
text(&path),
"hello".to_string(),
write_flags(),
0.0,
))
.expect("write");
assert_eq!(
block_on(read_text_file(text(&path))).expect("read"),
"hello"
);
assert_eq!(
read_text_file_sync(text(&path)).expect("read sync"),
"hello"
);
assert!(
write_text_file_sync(text(&path), "again".to_string(), write_flags(), 0.0)
.expect("write sync")
);
assert_eq!(fs::read_to_string(&path).expect("on disk"), "again");
}
#[test]
fn bytes_round_trip() {
let dir = tempdir();
let path = dir.path().join("bytes.bin");
let bytes = ArrayBuffer::from(vec![0u8, 1, 254, 255]);
block_on(write_file(text(&path), bytes.clone(), write_flags(), 0.0)).expect("write");
assert_eq!(
block_on(read_file(text(&path))).expect("read").as_slice(),
&[0u8, 1, 254, 255]
);
assert_eq!(
read_file_sync(text(&path)).expect("read sync").into_vec(),
vec![0u8, 1, 254, 255]
);
}
#[test]
fn an_append_flag_keeps_the_existing_bytes() {
let dir = tempdir();
let path = dir.path().join("log.txt");
fs::write(&path, b"one").expect("seed");
block_on(write_text_file(
text(&path),
"two".to_string(),
f64::from(flags::WRONLY | flags::CREAT | flags::APPEND),
0.0,
))
.expect("append");
assert_eq!(fs::read_to_string(&path).expect("on disk"), "onetwo");
}
#[test]
fn stat_reports_kind_and_size() {
let dir = tempdir();
let file = dir.path().join("data.bin");
fs::write(&file, b"12345").expect("seed");
let stats = block_on(stat(text(&file), true)).expect("stat");
assert_eq!(stats.kind, kind::FILE);
assert_eq!(stats.size, 5.0);
assert!(stats.mtime_ms > 0.0);
#[cfg(unix)]
assert_ne!(
stats.mode as u32 & 0o777,
0,
"the mode bits come from MetadataExt"
);
let directory = block_on(stat(text(dir.path()), true)).expect("stat dir");
assert_eq!(directory.kind, kind::DIRECTORY);
}
#[cfg(unix)]
#[test]
fn stat_follows_links_only_when_asked() {
let dir = tempdir();
let target = dir.path().join("target.txt");
fs::write(&target, b"x").expect("seed");
let link = dir.path().join("link.txt");
std::os::unix::fs::symlink(&target, &link).expect("symlink");
assert_eq!(
block_on(stat(text(&link), false)).expect("lstat").kind,
kind::SYMLINK
);
assert_eq!(
block_on(stat(text(&link), true)).expect("stat").kind,
kind::FILE
);
}
#[test]
fn stat_sync_is_none_for_a_missing_path() {
let dir = tempdir();
let missing = text(&dir.path().join("absent.txt"));
assert!(stat_sync(missing, true).expect("stat").is_none());
}
#[test]
fn exists_sync_is_false_for_a_missing_path() {
let dir = tempdir();
assert!(!exists_sync(text(&dir.path().join("absent.txt"))).expect("exists"));
assert!(exists_sync(text(dir.path())).expect("exists"));
}
#[test]
fn a_missing_file_is_enoent() {
let dir = tempdir();
let missing = text(&dir.path().join("gone.txt"));
let error = block_on(read_text_file(missing.clone())).expect_err("read");
assert_eq!(error.code.as_deref(), Some("ENOENT"));
assert!(error
.message
.starts_with("ENOENT: no such file or directory"));
let error = read_file_sync(missing).expect_err("read sync");
assert_eq!(error.code.as_deref(), Some("ENOENT"));
}
#[test]
fn rename_moves_a_file() {
let dir = tempdir();
let from = dir.path().join("a.txt");
let to = dir.path().join("b.txt");
fs::write(&from, b"a").expect("seed");
block_on(rename(text(&from), text(&to))).expect("rename");
assert!(!from.exists());
assert_eq!(fs::read_to_string(&to).expect("on disk"), "a");
assert!(rename_sync(text(&to), text(&from)).expect("rename sync"));
assert!(from.exists());
}
#[test]
fn copy_copies_a_file() {
let dir = tempdir();
let from = dir.path().join("one.txt");
fs::write(&from, b"copy me").expect("seed");
let to = dir.path().join("two.txt");
block_on(copy(text(&from), text(&to), false)).expect("copy");
assert_eq!(fs::read_to_string(&to).expect("on disk"), "copy me");
let third = dir.path().join("three.txt");
assert!(copy_file_sync(text(&from), text(&third)).expect("copy sync"));
assert_eq!(fs::read_to_string(&third).expect("on disk"), "copy me");
}
#[test]
fn copy_copies_a_directory_tree_when_recursive() {
let dir = tempdir();
let from = dir.path().join("tree");
fs::create_dir_all(from.join("nested")).expect("tree");
fs::write(from.join("top.txt"), b"top").expect("seed");
fs::write(from.join("nested").join("deep.txt"), b"deep").expect("seed");
let to = dir.path().join("copy");
block_on(copy(text(&from), text(&to), true)).expect("copy tree");
assert_eq!(
fs::read_to_string(to.join("nested").join("deep.txt")).expect("on disk"),
"deep"
);
let error = block_on(copy(text(&from), text(&dir.path().join("nope")), false))
.expect_err("a directory without recursive");
assert_eq!(error.code.as_deref(), Some("EISDIR"));
}
#[test]
fn copying_a_directory_into_itself_is_refused() {
let dir = tempdir();
let from = dir.path().join("tree");
fs::create_dir_all(from.join("sub")).expect("tree");
fs::write(from.join("top.txt"), b"top").expect("seed");
// Into a directory beneath itself. This used to recurse until the path
// got too long, and left whatever it had built on the way down.
let into_self = from.join("sub");
let error = block_on(copy(text(&from), text(&into_self), true))
.expect_err("copy into a subdirectory of self");
assert_eq!(error.code.as_deref(), Some("ERR_FS_CP_EINVAL"));
assert!(
error.message.starts_with("Cannot copy"),
"{}",
error.message
);
// The check runs before anything is created, so the destination is still
// just the empty directory it was.
assert_eq!(
fs::read_dir(&into_self).expect("still there").count(),
0,
"nothing may be created when the copy is refused"
);
// The directory itself is the same case.
let error = block_on(copy(text(&from), text(&from), true)).expect_err("copy onto itself");
assert_eq!(error.code.as_deref(), Some("ERR_FS_CP_EINVAL"));
}
#[test]
fn truncate_changes_the_length() {
let dir = tempdir();
let path = dir.path().join("long.txt");
fs::write(&path, b"0123456789").expect("seed");
block_on(truncate(text(&path), 4.0)).expect("truncate");
assert_eq!(fs::read_to_string(&path).expect("on disk"), "0123");
}
#[test]
fn utimes_sets_both_times() {
let dir = tempdir();
let path = dir.path().join("stamp.txt");
fs::write(&path, b"x").expect("seed");
block_on(utimes(
text(&path),
1_000_000_000_000.0,
1_100_000_000_000.0,
))
.expect("utimes");
let stats = block_on(stat(text(&path), true)).expect("stat");
assert_eq!(stats.mtime_ms, 1_100_000_000_000.0);
}
#[cfg(unix)]
#[test]
fn chmod_sets_the_permission_bits() {
use std::os::unix::fs::PermissionsExt as _;
let dir = tempdir();
let path = dir.path().join("locked.txt");
fs::write(&path, b"x").expect("seed");
block_on(chmod(text(&path), f64::from(0o640u32))).expect("chmod");
let mode = fs::metadata(&path).expect("metadata").permissions().mode() & 0o777;
assert_eq!(mode, 0o640);
}
}
@@ -0,0 +1,697 @@
//! Open descriptors: `open`, `read`, `write`, `fstat`, `fsync`, `ftruncate`
//! and `close`.
//!
//! A descriptor is an id from the module's table plus a clone of the file that
//! shares its offset, which is what a descriptor is to the OS. The table owns
//! the original, so dropping the module closes every handle; ids are never
//! reused, so a stale `fd` that JS kept across a `close` fails with `EBADF`
//! instead of reaching an unrelated file.
//!
//! These functions are synchronous even though the trait methods around them are
//! `async`: the module has a thread of its own, so a blocking call here never
//! blocks JS or the UI.
use std::fs::File;
use std::io::{Read as _, Seek as _, Write as _};
use rn_gpui_module::{ArrayBuffer, ModuleError};
use crate::errors;
use crate::module::{bad_fd, descriptor, flags, kind, millis, open_options};
use crate::StatResult;
pub fn open(
module: &crate::RNFsModule,
path: String,
flags: f64,
mode: f64,
) -> Result<f64, ModuleError> {
// The table owns the file; everything the caller asks of it goes through
// `module.file(fd)`, which hands back a clone sharing the same offset.
let file = open_options(flags, crate::module::mode(mode))
.open(&path)
.map_err(|error| errors::from_io(error, &path))?;
let id = module.take_fd();
// `RDONLY` is zero, so "writable" is the presence of any write bit rather
// than the absence of a read bit.
let writable = (flags as i32) & (flags::WRONLY | flags::RDWR) != 0;
module
.files
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.insert(id, (file, writable));
Ok(f64::from(id))
}
pub fn read(
module: &crate::RNFsModule,
fd: f64,
length: f64,
position: f64,
) -> Result<ArrayBuffer, ModuleError> {
let id = descriptor(fd)?;
let length = crate::module::length(length)?;
let mut file = module.file(fd)?;
// Nothing larger than the file can be read, so the buffer is capped at what
// remains rather than at what was asked for. A caller that passes a huge
// `length` — which `readToEnd` used to do deliberately — would otherwise ask
// for that many bytes up front and get whatever the allocator did with it.
let cap = match offset(position)? {
Some(start) => file
.metadata()
.ok()
.and_then(|meta| meta.len().checked_sub(start))
.map_or(length, |remaining| length.min(remaining as usize)),
None => file
.metadata()
.ok()
.and_then(|meta| {
// A cursor read starts wherever the handle is, so the remainder
// is measured from there rather than from the start.
let current = file.stream_position().ok()?;
meta.len().checked_sub(current)
})
.map_or(length, |remaining| length.min(remaining as usize)),
};
let mut buffer = vec![0u8; cap];
let filled = match offset(position)? {
Some(start) => read_at(&mut file, &mut buffer, start, id)?,
None => read_cursor(&mut file, &mut buffer, id)?,
};
// A read that stopped at end of file is a short read, not a failure, so the
// buffer is shrunk to what the OS actually produced rather than padded.
buffer.truncate(filled);
Ok(ArrayBuffer::from(buffer))
}
pub fn write(
module: &crate::RNFsModule,
fd: f64,
data: ArrayBuffer,
position: f64,
) -> Result<f64, ModuleError> {
let id = descriptor(fd)?;
let (mut file, writable) = module.file_and_writability(fd)?;
// Checked from the flags the handle was opened with, not from what the OS
// says: a write to a read-only handle is `EBADF` in Node, `EACCES` on
// Windows and `EBADF` on Unix.
if !writable {
return Err(bad_fd(id));
}
let bytes = data.as_slice();
if bytes.is_empty() {
return Ok(0.0);
}
// Read before the borrow below: `positioned_write` needs `&mut File` so the
// Windows branch can seek, and a match guard borrowing the file would overlap.
let append_only = appends(&file);
let written = match offset(position)? {
// An append-mode descriptor appends whatever offset the caller passes:
// a cursor write does that because the file was opened with `O_APPEND`
// (or `FILE_APPEND_DATA`), and Linux also appends for a positioned
// write. Ignoring the offset keeps the platforms alike and is what
// callers of `appendFile` expect.
Some(start) if !append_only => write_at(&mut file, bytes, start, id)?,
_ => write_cursor(&mut file, bytes, id)?,
};
Ok(written as f64)
}
pub fn fstat(module: &crate::RNFsModule, fd: f64) -> Result<StatResult, ModuleError> {
let id = descriptor(fd)?;
let file = module.file(fd)?;
let metadata = file.metadata().map_err(|error| io_error(error, id))?;
Ok(stat_result(&metadata))
}
pub fn fsync(module: &crate::RNFsModule, fd: f64) -> Result<(), ModuleError> {
let id = descriptor(fd)?;
let file = module.file(fd)?;
file.sync_all().map_err(|error| io_error(error, id))
}
pub fn ftruncate(module: &crate::RNFsModule, fd: f64, length: f64) -> Result<(), ModuleError> {
let id = descriptor(fd)?;
let length = crate::module::length(length)?;
let file = module.file(fd)?;
file.set_len(length as u64)
.map_err(|error| io_error(error, id))
}
pub fn close(module: &crate::RNFsModule, fd: f64) -> Result<(), ModuleError> {
let id = descriptor(fd)?;
let mut files = module
.files
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
// Removing rather than merely dropping the entry is what makes a second
// close report `EBADF`: the id is gone, and it is never handed out again.
files.remove(&id).ok_or_else(|| bad_fd(id))?;
Ok(())
}
/// A failure from an operation on a descriptor.
///
/// The table may still know an id the kernel has already rejected — a write to a
/// read-only descriptor is the usual way — and Node reports that as `EBADF`, not
/// as a generic I/O error. Everything else goes through the shared mapping, with
/// the descriptor standing in for the path a Node message names.
fn io_error(error: std::io::Error, id: u32) -> ModuleError {
#[cfg(unix)]
if error.raw_os_error() == Some(libc::EBADF) {
return bad_fd(id);
}
errors::from_io(error, &id.to_string())
}
/// The byte offset a `position` argument names, or `None` for "the current
/// offset", which is what a negative position means to `node:fs`.
fn offset(position: f64) -> Result<Option<u64>, ModuleError> {
if !position.is_finite() {
return Err(errors::invalid(
"EINVAL",
format!("EINVAL: invalid argument, position {position}"),
));
}
if position < 0.0 {
return Ok(None);
}
Ok(Some(position as u64))
}
/// Whether the descriptor was opened for append.
///
/// The table stores only the `File`, so the bit is read back from the descriptor
/// itself with `F_GETFL`: that reports the flag the kernel actually holds, which
/// is more reliable than tracking a second copy beside the table.
#[cfg(unix)]
fn appends(file: &File) -> bool {
use std::os::unix::io::AsRawFd as _;
// SAFETY: `file` owns a valid descriptor for the duration of the call; `F_GETFL` takes no argument.
let flags = unsafe { libc::fcntl(file.as_raw_fd(), libc::F_GETFL) };
flags >= 0 && flags & libc::O_APPEND != 0
}
/// Windows keeps the append bit inside the handle's granted access, with no
/// portable way to read it back, so the answer is "no" here. Cursor writes still
/// append — `append(true)` opens with `FILE_APPEND_DATA`, which does that in the
/// kernel — and a write with an explicit offset lands at that offset, which is
/// what a caller using `pwrite` on Windows would expect.
#[cfg(not(unix))]
fn appends(_file: &File) -> bool {
false
}
fn read_at(file: &mut File, buffer: &mut [u8], offset: u64, id: u32) -> Result<usize, ModuleError> {
let mut filled = 0;
while filled < buffer.len() {
match positioned_read(file, &mut buffer[filled..], offset + filled as u64) {
Ok(0) => break,
Ok(count) => filled += count,
Err(error) if error.kind() == std::io::ErrorKind::Interrupted => continue,
Err(error) => return Err(io_error(error, id)),
}
}
Ok(filled)
}
fn read_cursor(file: &mut File, buffer: &mut [u8], id: u32) -> Result<usize, ModuleError> {
let mut filled = 0;
while filled < buffer.len() {
match file.read(&mut buffer[filled..]) {
Ok(0) => break,
Ok(count) => filled += count,
Err(error) if error.kind() == std::io::ErrorKind::Interrupted => continue,
Err(error) => return Err(io_error(error, id)),
}
}
Ok(filled)
}
fn write_at(file: &mut File, bytes: &[u8], offset: u64, id: u32) -> Result<usize, ModuleError> {
let mut written = 0;
while written < bytes.len() {
match positioned_write(file, &bytes[written..], offset + written as u64) {
// A regular file does not write zero bytes for a non-empty buffer;
// treating it as `EIO` keeps the loop from spinning forever.
Ok(0) => return Err(errors::invalid("EIO", format!("EIO: i/o error, {id}"))),
Ok(count) => written += count,
Err(error) if error.kind() == std::io::ErrorKind::Interrupted => continue,
Err(error) => return Err(io_error(error, id)),
}
}
Ok(written)
}
fn write_cursor(file: &mut File, bytes: &[u8], id: u32) -> Result<usize, ModuleError> {
file.write_all(bytes).map_err(|error| io_error(error, id))?;
Ok(bytes.len())
}
#[cfg(unix)]
fn positioned_read(file: &mut File, buffer: &mut [u8], offset: u64) -> std::io::Result<usize> {
use std::os::unix::fs::FileExt as _;
// `read_at` takes `&self`, but the call sites hold `&mut File` so the
// Windows branch — which has to seek — shares one signature.
(&*file).read_at(buffer, offset)
}
/// Windows' positional read, with the cursor left where it was.
///
/// `FileExt::seek_read` is documented as not affecting the cursor, but on a
/// synchronous handle — which is what `File::open` gives — the underlying
/// `ReadFile` with an `OVERLAPPED` offset keeps the file position as a side
/// effect. Node's positional forms leave the descriptor alone, and so does the
/// Unix branch above, so the position is saved and restored here. Without this a
/// positioned read moved the cursor, and a following cursor read started from the
/// wrong place.
#[cfg(windows)]
fn positioned_read(file: &mut File, buffer: &mut [u8], offset: u64) -> std::io::Result<usize> {
use std::io::Seek as _;
use std::os::windows::fs::FileExt as _;
let saved = file.stream_position();
let result = file.seek_read(buffer, offset);
if let Ok(position) = saved {
let _ = file.seek(std::io::SeekFrom::Start(position));
}
result
}
#[cfg(unix)]
fn positioned_write(file: &mut File, bytes: &[u8], offset: u64) -> std::io::Result<usize> {
use std::os::unix::fs::FileExt as _;
(&*file).write_at(bytes, offset)
}
/// Windows' positional write, with the cursor left where it was. Same reason as
/// `positioned_read` above.
#[cfg(windows)]
fn positioned_write(file: &mut File, bytes: &[u8], offset: u64) -> std::io::Result<usize> {
use std::io::Seek as _;
use std::os::windows::fs::FileExt as _;
let saved = file.stream_position();
let result = file.seek_write(bytes, offset);
if let Ok(position) = saved {
let _ = file.seek(std::io::SeekFrom::Start(position));
}
result
}
/// The generated `StatResult`, filled from `std::fs::Metadata`.
///
/// `files::stat` builds the same shape for a path. The two are deliberately
/// separate: `files` and `handles` are independent, and twenty duplicated lines
/// cost less than one file reaching into the other's internals.
fn stat_result(metadata: &std::fs::Metadata) -> StatResult {
let file_type = metadata.file_type();
let kind = if file_type.is_file() {
kind::FILE
} else if file_type.is_dir() {
kind::DIRECTORY
} else if file_type.is_symlink() {
kind::SYMLINK
} else {
kind::OTHER
};
// Node's `Stats` names the Unix inode fields even on Windows, where they
// have no meaning; zero is what Node reports there too.
#[cfg(unix)]
let (mode, dev, ino, nlink, uid, gid) = {
use std::os::unix::fs::MetadataExt as _;
(
metadata.mode() as f64,
metadata.dev() as f64,
metadata.ino() as f64,
metadata.nlink() as f64,
metadata.uid() as f64,
metadata.gid() as f64,
)
};
#[cfg(not(unix))]
let (mode, dev, ino, nlink, uid, gid) = (0.0, 0.0, 0.0, 0.0, 0.0, 0.0);
// `ctime` is the inode change time on Unix; Windows has no equivalent, and
// its creation time is the closest thing it has.
#[cfg(unix)]
let ctime_ms = {
use std::os::unix::fs::MetadataExt as _;
metadata.ctime() as f64 * 1000.0 + metadata.ctime_nsec() as f64 / 1_000_000.0
};
#[cfg(not(unix))]
let ctime_ms = metadata
.created()
.map(millis)
.unwrap_or_else(|_| metadata.modified().map(millis).unwrap_or(0.0));
// A file system that cannot report creation gives 0, as Node does, rather
// than pretending the modification time is the birth time.
StatResult {
size: metadata.len() as f64,
mode,
kind,
atime_ms: metadata.accessed().map(millis).unwrap_or(0.0),
mtime_ms: metadata.modified().map(millis).unwrap_or(0.0),
ctime_ms,
birthtime_ms: metadata.created().map(millis).unwrap_or(0.0),
dev,
ino,
nlink,
uid,
gid,
}
}
#[cfg(test)]
mod tests {
use std::path::Path;
use std::sync::Arc;
use rn_gpui_module::{EventEmitter, ModuleInstance, Value};
use tempfile::TempDir;
use super::*;
use crate::native_fs_module;
/// The async trait methods never actually suspend — they call these helpers
/// directly — so a single-poll executor drives the generated glue without a
/// runtime.
fn block_on<F: std::future::Future>(future: F) -> F::Output {
let mut future = std::pin::pin!(future);
let waker = std::task::Waker::noop();
let mut context = std::task::Context::from_waker(waker);
loop {
match future.as_mut().poll(&mut context) {
std::task::Poll::Ready(value) => return value,
std::task::Poll::Pending => std::thread::yield_now(),
}
}
}
/// `NativeFsEvents` has a private emitter and no constructor, so a test
/// cannot call `RNFsModule::create` itself. Building the instance through the
/// descriptor the bridge uses is the supported way in, and it exercises the
/// generated glue as a bonus.
fn module() -> Arc<dyn ModuleInstance> {
(native_fs_module::<crate::RNFsModule>().create)(EventEmitter::new(1))
}
fn index(name: &str) -> usize {
native_fs_module::<crate::RNFsModule>()
.methods
.iter()
.position(|method| method.name == name)
.unwrap_or_else(|| panic!("the spec has no method {name}"))
}
fn call(
module: &Arc<dyn ModuleInstance>,
name: &str,
args: Vec<Value>,
) -> Result<Value, ModuleError> {
block_on(module.clone().call_promise(index(name), args)?)
}
fn text(path: &Path) -> String {
path.to_string_lossy().into_owned()
}
fn open_fd(module: &Arc<dyn ModuleInstance>, path: &Path, flags_value: i32) -> f64 {
let value = call(
module,
"open",
vec![
Value::String(text(path)),
Value::Number(f64::from(flags_value)),
Value::Number(0.0),
],
)
.expect("open");
match value {
Value::Number(fd) => fd,
other => panic!("expected a descriptor, got {}", other.type_name()),
}
}
fn read_bytes(
module: &Arc<dyn ModuleInstance>,
fd: f64,
length: f64,
position: f64,
) -> Result<Vec<u8>, ModuleError> {
match call(
module,
"read",
vec![
Value::Number(fd),
Value::Number(length),
Value::Number(position),
],
)? {
Value::Buffer(bytes) => Ok(bytes),
other => panic!("expected bytes, got {}", other.type_name()),
}
}
fn write_bytes(
module: &Arc<dyn ModuleInstance>,
fd: f64,
bytes: &[u8],
position: f64,
) -> Result<f64, ModuleError> {
match call(
module,
"write",
vec![
Value::Number(fd),
Value::Buffer(bytes.to_vec()),
Value::Number(position),
],
)? {
Value::Number(written) => Ok(written),
other => panic!("expected a count, got {}", other.type_name()),
}
}
fn stat_size(module: &Arc<dyn ModuleInstance>, fd: f64) -> f64 {
let value = call(module, "fstat", vec![Value::Number(fd)]).expect("fstat");
let Value::Object(fields) = value else {
panic!("expected a stat object");
};
fields
.iter()
.find(|(name, _)| name == "size")
.and_then(|(_, value)| match value {
Value::Number(size) => Some(*size),
_ => None,
})
.expect("a size field")
}
fn close_fd(module: &Arc<dyn ModuleInstance>, fd: f64) -> Result<Value, ModuleError> {
call(module, "close", vec![Value::Number(fd)])
}
/// Open a fresh file containing `contents` and hand back its descriptor.
fn opened(module: &Arc<dyn ModuleInstance>, dir: &TempDir, contents: &str) -> (String, f64) {
let path = dir.path().join("data.txt");
std::fs::write(&path, contents).expect("write the fixture file");
let fd = open_fd(module, &path, flags::RDWR);
(text(&path), fd)
}
#[test]
fn a_handle_reads_back_what_it_wrote() {
let dir = TempDir::new().expect("a temporary directory");
let path = dir.path().join("note.txt");
let module = module();
let fd = open_fd(&module, &path, flags::WRONLY | flags::CREAT | flags::TRUNC);
assert_eq!(
write_bytes(&module, fd, b"hello", -1.0).expect("write"),
5.0
);
assert_eq!(stat_size(&module, fd), 5.0);
close_fd(&module, fd).expect("close");
let fd = open_fd(&module, &path, flags::RDONLY);
assert_eq!(read_bytes(&module, fd, 5.0, -1.0).expect("read"), b"hello");
close_fd(&module, fd).expect("close");
}
#[test]
fn a_positioned_read_leaves_the_cursor_alone() {
let dir = TempDir::new().expect("a temporary directory");
let module = module();
let (_, fd) = opened(&module, &dir, "abcdef");
assert_eq!(read_bytes(&module, fd, 2.0, 0.0).expect("read"), b"ab");
// The cursor never moved, so a cursor read starts at the beginning.
assert_eq!(read_bytes(&module, fd, 2.0, -1.0).expect("read"), b"ab");
assert_eq!(read_bytes(&module, fd, 2.0, -1.0).expect("read"), b"cd");
close_fd(&module, fd).expect("close");
}
#[test]
fn a_positioned_write_only_touches_its_range() {
let dir = TempDir::new().expect("a temporary directory");
let module = module();
let (path, fd) = opened(&module, &dir, "abcdef");
assert_eq!(write_bytes(&module, fd, b"XY", 2.0).expect("write"), 2.0);
assert_eq!(stat_size(&module, fd), 6.0);
assert_eq!(std::fs::read(&path).expect("on disk"), b"abXYef");
close_fd(&module, fd).expect("close");
}
#[test]
fn reading_past_the_end_is_a_short_read_not_an_error() {
let dir = TempDir::new().expect("a temporary directory");
let module = module();
let (_, fd) = opened(&module, &dir, "abc");
// Two bytes asked for, one left: the buffer holds exactly one byte.
assert_eq!(read_bytes(&module, fd, 2.0, 2.0).expect("read"), b"c");
// Nothing left at all: an empty buffer, still a success.
assert_eq!(read_bytes(&module, fd, 8.0, 10.0).expect("read"), b"");
assert_eq!(read_bytes(&module, fd, 8.0, -1.0).expect("read"), b"abc");
assert_eq!(read_bytes(&module, fd, 8.0, -1.0).expect("read"), b"");
close_fd(&module, fd).expect("close");
}
#[test]
fn a_huge_length_only_allocates_what_the_file_holds() {
let dir = TempDir::new().expect("a temporary directory");
let module = module();
let (_, fd) = opened(&module, &dir, "0123456789");
// The buffer is capped at what remains, so this returns rather than
// trying to allocate four gigabytes. Finishing at all is the check: an
// allocation of `u32::MAX` bytes would abort the test process instead.
let bytes = read_bytes(&module, fd, u32::MAX as f64, 0.0).expect("read");
assert_eq!(bytes, b"0123456789");
close_fd(&module, fd).expect("close");
}
#[test]
fn appending_ignores_the_position() {
let dir = TempDir::new().expect("a temporary directory");
let path = dir.path().join("log.txt");
std::fs::write(&path, b"ab").expect("write the fixture file");
let module = module();
let fd = open_fd(&module, &path, flags::APPEND | flags::WRONLY | flags::CREAT);
write_bytes(&module, fd, b"cd", -1.0).expect("append");
// An append-mode descriptor appends even when an offset is passed, which
// is what the kernel does on Linux and what `appendFile` means.
write_bytes(&module, fd, b"ef", 0.0).expect("append");
assert_eq!(stat_size(&module, fd), 6.0);
close_fd(&module, fd).expect("close");
assert_eq!(std::fs::read(&path).expect("on disk"), b"abcdef");
}
#[test]
fn truncating_by_descriptor_changes_the_size() {
let dir = TempDir::new().expect("a temporary directory");
let module = module();
let (_, fd) = opened(&module, &dir, "abcdef");
call(
&module,
"ftruncate",
vec![Value::Number(fd), Value::Number(3.0)],
)
.expect("ftruncate");
assert_eq!(stat_size(&module, fd), 3.0);
assert_eq!(read_bytes(&module, fd, 16.0, 0.0).expect("read"), b"abc");
close_fd(&module, fd).expect("close");
}
#[test]
fn syncing_a_handle_succeeds() {
let dir = TempDir::new().expect("a temporary directory");
let module = module();
let (_, fd) = opened(&module, &dir, "abc");
write_bytes(&module, fd, b"d", 3.0).expect("write");
call(&module, "fsync", vec![Value::Number(fd)]).expect("fsync");
close_fd(&module, fd).expect("close");
}
#[test]
fn a_second_close_is_ebadf() {
let dir = TempDir::new().expect("a temporary directory");
let module = module();
let (_, fd) = opened(&module, &dir, "abc");
close_fd(&module, fd).expect("the first close");
let error = close_fd(&module, fd).expect_err("the second close");
assert_eq!(error.code.as_deref(), Some("EBADF"));
}
#[test]
fn a_closed_descriptor_is_ebadf_for_every_op() {
let dir = TempDir::new().expect("a temporary directory");
let module = module();
let (_, fd) = opened(&module, &dir, "abc");
close_fd(&module, fd).expect("close");
let error = read_bytes(&module, fd, 1.0, -1.0).expect_err("read");
assert_eq!(error.code.as_deref(), Some("EBADF"));
let error = write_bytes(&module, fd, b"x", -1.0).expect_err("write");
assert_eq!(error.code.as_deref(), Some("EBADF"));
let error = call(&module, "fstat", vec![Value::Number(fd)]).expect_err("fstat");
assert_eq!(error.code.as_deref(), Some("EBADF"));
}
#[test]
fn opening_a_missing_file_is_enoent() {
let dir = TempDir::new().expect("a temporary directory");
let path = dir.path().join("gone.txt");
let module = module();
let error = call(
&module,
"open",
vec![
Value::String(text(&path)),
Value::Number(f64::from(flags::RDONLY)),
Value::Number(0.0),
],
)
.expect_err("open");
assert_eq!(error.code.as_deref(), Some("ENOENT"));
}
/// A descriptor the table still knows but the kernel rejects: Node says
/// `EBADF` here, and so must this module, rather than a generic `EIO`.
#[test]
fn writing_through_a_read_only_descriptor_is_ebadf() {
let dir = TempDir::new().expect("a temporary directory");
let module = module();
let path = dir.path().join("data.txt");
std::fs::write(&path, b"abc").expect("write the fixture file");
let fd = open_fd(&module, &path, flags::RDONLY);
let error = write_bytes(&module, fd, b"x", -1.0).expect_err("write");
assert_eq!(error.code.as_deref(), Some("EBADF"));
// Reading is still fine, and the file is untouched.
assert_eq!(read_bytes(&module, fd, 3.0, 0.0).expect("read"), b"abc");
close_fd(&module, fd).expect("close");
}
#[test]
fn a_non_finite_position_is_einval() {
let dir = TempDir::new().expect("a temporary directory");
let module = module();
let (_, fd) = opened(&module, &dir, "abc");
let error = read_bytes(&module, fd, 1.0, f64::NAN).expect_err("read");
assert_eq!(error.code.as_deref(), Some("EINVAL"));
let error = write_bytes(&module, fd, b"x", f64::INFINITY).expect_err("write");
assert_eq!(error.code.as_deref(), Some("EINVAL"));
close_fd(&module, fd).expect("close");
}
}
+258
View File
@@ -0,0 +1,258 @@
//! `@react-native-gpui/fs`: a Node-shaped file API for the desktop app.
//!
//! The TypeScript spec in `../src/NativeFs.ts` is the contract; the generator
//! turns it into the `NativeFsSpec` trait implemented here. That trait is the
//! only thing the C++ bridge knows about, so the API surface is edited there and
//! regenerated, never in this file.
//!
//! Rust needs a trait's methods in a single `impl` block, so this file holds that
//! block and hands each method to a helper in the file that owns the concern:
//!
//! - `files` — reading, writing, copying, truncating, permissions and times
//! - `dirs` — listing, creating, removing and `mkdtemp`
//! - `handles` — open descriptors, positioned reads and writes
//! - `links` — symbolic links, `realpath` and `access`
//! - `paths` — the well-known folders, created on first read
//! - `dialogs` — the pickers, `reveal` and `openExternal`
//! - `watching` — the watcher behind `watch` and `unwatch`
//!
//! Keeping the bodies in those files is what makes the crate readable; keeping
//! the `impl` here is what makes it compile.
rn_gpui_module::include_specs!();
mod dialogs;
mod dirs;
mod errors;
mod files;
mod handles;
mod links;
mod module;
mod paths;
mod watching;
pub use module::RNFsModule;
use rn_gpui_module::{ArrayBuffer, ModuleError};
impl NativeFsSpec for RNFsModule {
fn new(events: NativeFsEvents) -> Self {
Self::create(events)
}
async fn read_file(&self, path: String) -> Result<ArrayBuffer, ModuleError> {
files::read_file(path).await
}
async fn read_text_file(&self, path: String) -> Result<String, ModuleError> {
files::read_text_file(path).await
}
async fn write_file(
&self,
path: String,
data: ArrayBuffer,
flags: f64,
mode: f64,
) -> Result<(), ModuleError> {
files::write_file(path, data, flags, mode).await
}
async fn write_text_file(
&self,
path: String,
data: String,
flags: f64,
mode: f64,
) -> Result<(), ModuleError> {
files::write_text_file(path, data, flags, mode).await
}
async fn readdir(&self, path: String, recursive: bool) -> Result<Vec<DirEntry>, ModuleError> {
dirs::readdir(path, recursive).await
}
async fn stat(&self, path: String, follow_symlinks: bool) -> Result<StatResult, ModuleError> {
files::stat(path, follow_symlinks).await
}
async fn mkdir(&self, path: String, recursive: bool, mode: f64) -> Result<(), ModuleError> {
dirs::mkdir(path, recursive, mode).await
}
async fn rm(&self, path: String, recursive: bool, force: bool) -> Result<(), ModuleError> {
dirs::rm(path, recursive, force).await
}
async fn rename(&self, from: String, to: String) -> Result<(), ModuleError> {
files::rename(from, to).await
}
async fn copy(&self, from: String, to: String, recursive: bool) -> Result<(), ModuleError> {
files::copy(from, to, recursive).await
}
async fn access(&self, path: String, mode: f64) -> Result<(), ModuleError> {
links::access(path, mode)
}
async fn realpath(&self, path: String) -> Result<String, ModuleError> {
links::realpath(path)
}
async fn readlink(&self, path: String) -> Result<String, ModuleError> {
links::readlink(path)
}
async fn symlink(
&self,
target: String,
path: String,
kind: Option<String>,
) -> Result<(), ModuleError> {
links::symlink(target, path, kind.as_deref())
}
async fn truncate(&self, path: String, length: f64) -> Result<(), ModuleError> {
files::truncate(path, length).await
}
async fn utimes(&self, path: String, atime_ms: f64, mtime_ms: f64) -> Result<(), ModuleError> {
files::utimes(path, atime_ms, mtime_ms).await
}
async fn chmod(&self, path: String, mode: f64) -> Result<(), ModuleError> {
files::chmod(path, mode).await
}
async fn mkdtemp(&self, prefix: String) -> Result<String, ModuleError> {
dirs::mkdtemp(prefix).await
}
async fn open(&self, path: String, flags: f64, mode: f64) -> Result<f64, ModuleError> {
handles::open(self, path, flags, mode)
}
async fn read(&self, fd: f64, length: f64, position: f64) -> Result<ArrayBuffer, ModuleError> {
handles::read(self, fd, length, position)
}
async fn write(&self, fd: f64, data: ArrayBuffer, position: f64) -> Result<f64, ModuleError> {
handles::write(self, fd, data, position)
}
async fn fstat(&self, fd: f64) -> Result<StatResult, ModuleError> {
handles::fstat(self, fd)
}
async fn fsync(&self, fd: f64) -> Result<(), ModuleError> {
handles::fsync(self, fd)
}
async fn ftruncate(&self, fd: f64, length: f64) -> Result<(), ModuleError> {
handles::ftruncate(self, fd, length)
}
async fn close(&self, fd: f64) -> Result<(), ModuleError> {
handles::close(self, fd)
}
async fn show_open_dialog(
&self,
options: OpenDialogOptions,
) -> Result<Vec<String>, ModuleError> {
dialogs::show_open(options)
}
async fn show_save_dialog(
&self,
options: SaveDialogOptions,
) -> Result<Option<String>, ModuleError> {
dialogs::show_save(options)
}
async fn reveal(&self, path: String) -> Result<(), ModuleError> {
dialogs::reveal(path)
}
async fn open_external(&self, path: String) -> Result<(), ModuleError> {
dialogs::open_external(path)
}
async fn watch(&self, path: String, recursive: bool) -> Result<f64, ModuleError> {
watching::watch(self, path, recursive)
}
fn unwatch(&self, id: f64) {
watching::unwatch(self, id);
}
fn read_file_sync(&self, path: String) -> Result<ArrayBuffer, ModuleError> {
files::read_file_sync(path)
}
fn read_text_file_sync(&self, path: String) -> Result<String, ModuleError> {
files::read_text_file_sync(path)
}
fn write_file_sync(
&self,
path: String,
data: ArrayBuffer,
flags: f64,
mode: f64,
) -> Result<bool, ModuleError> {
files::write_file_sync(path, data, flags, mode)
}
fn write_text_file_sync(
&self,
path: String,
data: String,
flags: f64,
mode: f64,
) -> Result<bool, ModuleError> {
files::write_text_file_sync(path, data, flags, mode)
}
fn stat_sync(
&self,
path: String,
follow_symlinks: bool,
) -> Result<Option<StatResult>, ModuleError> {
files::stat_sync(path, follow_symlinks)
}
fn exists_sync(&self, path: String) -> Result<bool, ModuleError> {
files::exists_sync(path)
}
fn readdir_sync(&self, path: String, recursive: bool) -> Result<Vec<DirEntry>, ModuleError> {
dirs::readdir_sync(path, recursive)
}
fn mkdir_sync(&self, path: String, recursive: bool, mode: f64) -> Result<bool, ModuleError> {
dirs::mkdir_sync(path, recursive, mode)
}
fn rm_sync(&self, path: String, recursive: bool, force: bool) -> Result<bool, ModuleError> {
dirs::rm_sync(path, recursive, force)
}
fn rename_sync(&self, from: String, to: String) -> Result<bool, ModuleError> {
files::rename_sync(from, to)
}
fn copy_file_sync(&self, from: String, to: String) -> Result<bool, ModuleError> {
files::copy_file_sync(from, to)
}
fn get_paths(&self) -> Result<KnownPaths, ModuleError> {
paths::known_paths()
}
}
/// Called by the app's autolink shim, which keeps these modules linked in.
pub fn rn_gpui_register() {
rn_gpui_module::register(native_fs_module::<RNFsModule>());
}
+336
View File
@@ -0,0 +1,336 @@
//! Links and path checks: `access`, `realpath`, `readlink` and `symlink`.
//!
//! These are the calls whose exact meaning differs most between the platforms,
//! so each one is written against the platform's own primitive rather than
//! approximated from `std::fs` where that would change the answer.
use std::path::Path;
use std::path::PathBuf;
use rn_gpui_module::ModuleError;
use crate::errors;
use crate::module::access;
pub fn access(path: String, mode: f64) -> Result<(), ModuleError> {
let mode = access_mode(mode)?;
platform_access(&path, mode)
}
pub fn realpath(path: String) -> Result<String, ModuleError> {
let resolved = dunce::canonicalize(&path).map_err(|error| errors::from_io(error, &path))?;
// A `String` crosses the bridge as UTF-8, so a path that is not valid UTF-8
// has no faithful representation. Lossy-converting would hand JS a path it
// cannot open, which is worse than failing: `EINVAL` says the argument could
// not be represented rather than that the path does not exist.
resolved
.into_os_string()
.into_string()
.map_err(|unrepresentable| {
errors::invalid(
"EINVAL",
format!(
"EINVAL: invalid argument, path is not valid UTF-8, {}",
Path::new(&unrepresentable).display()
),
)
})
}
pub fn readlink(path: String) -> Result<String, ModuleError> {
let target = std::fs::read_link(&path).map_err(|error| errors::from_io(error, &path))?;
// As with `realpath`: the target is returned exactly as the file system
// stores it, and a target that is not UTF-8 cannot be a `String`.
target
.into_os_string()
.into_string()
.map_err(|unrepresentable| {
errors::invalid(
"EINVAL",
format!(
"EINVAL: invalid argument, link target is not valid UTF-8, {}",
Path::new(&unrepresentable).display()
),
)
})
}
pub fn symlink(target: String, path: String, kind: Option<&str>) -> Result<(), ModuleError> {
platform_symlink(&target, &path, kind)
}
/// Validate the bitmask Node numbers, returning it unchanged.
///
/// The caller gets Node's own bits back, not `access(2)`'s: `libc` 0.2 defines
/// `R_OK`, `W_OK` and `X_OK` only for Unix, so a Windows build that named them
/// failed with "cannot find value `R_OK` in crate `libc`". The translation to
/// `access(2)` happens in `platform_access`, which is already Unix-only, and the
/// Windows path reads the same Node bits directly.
///
/// `F_OK` is 0: the mode is not a flag set, and asking for nothing means "does
/// it exist".
fn access_mode(mode: f64) -> Result<i32, ModuleError> {
if !mode.is_finite() || mode < 0.0 || mode.fract() != 0.0 {
return Err(errors::invalid(
"EINVAL",
format!("EINVAL: invalid argument, mode {mode}"),
));
}
let requested = mode as i32;
let known = access::F_OK | access::R_OK | access::W_OK | access::X_OK;
if requested & !known != 0 {
return Err(errors::invalid(
"EINVAL",
format!("EINVAL: invalid argument, mode {requested}"),
));
}
Ok(requested)
}
/// `access(2)` is the only way to get Node's semantics — it asks the kernel about
/// *this* process's real uid and gid, including supplementary groups — so the
/// Unix implementation calls it rather than inspecting the mode bits, which
/// would give a different answer for a file the caller does not own.
#[cfg(unix)]
fn platform_access(path: &str, requested: i32) -> Result<(), ModuleError> {
use std::ffi::CString;
use std::os::unix::ffi::OsStrExt as _;
// `access(2)` uses the same values Node does for these three, but the
// translation is spelled out so a platform whose constants differ still asks
// the right question. This is the only place the `libc` constants are named.
let mut mask = libc::F_OK;
if requested & access::R_OK != 0 {
mask |= libc::R_OK;
}
if requested & access::W_OK != 0 {
mask |= libc::W_OK;
}
if requested & access::X_OK != 0 {
mask |= libc::X_OK;
}
// A path with an interior NUL cannot be passed to the C API at all; that is
// an invalid argument rather than a file-system failure.
let name = CString::new(std::ffi::OsStr::new(path).as_bytes()).map_err(|_| {
errors::invalid(
"EINVAL",
format!("EINVAL: invalid argument, path contains a null byte, {path}"),
)
})?;
// `access` reports failure by return value and sets `errno`, so the error is
// built from `last_os_error` and then mapped like any other.
// SAFETY: `name` is a NUL-terminated string that outlives the call.
let result = unsafe { libc::access(name.as_ptr(), mask) };
if result == 0 {
Ok(())
} else {
Err(errors::from_io(std::io::Error::last_os_error(), path))
}
}
/// Windows has no `access(2)`, so the checks are approximated from what the
/// platform does offer: `F_OK` and `R_OK` are a `metadata()` call (the read bit
/// is not expressible, and a file the process can stat is one it can normally
/// read), `W_OK` tries to open the file for writing, and `X_OK` uses the
/// executable extensions the shell recognises, because Windows has no execute
/// bit to test.
#[cfg(not(unix))]
fn platform_access(path: &str, requested: i32) -> Result<(), ModuleError> {
// `F_OK` asks only that the path resolves; `metadata` follows links, which
// matches `access`'s "does the name lead to something".
std::fs::metadata(path).map_err(|error| errors::from_io(error, path))?;
if requested & access::W_OK != 0 {
std::fs::OpenOptions::new()
.write(true)
.open(path)
.map_err(|error| errors::from_io(error, path))?;
}
if requested & access::X_OK != 0 && !is_executable(Path::new(path)) {
return Err(errors::invalid(
"EACCES",
format!("EACCES: permission denied, {path}"),
));
}
Ok(())
}
/// The extensions `PATHEXT` names, which is how Windows decides a file is a
/// program. A directory is executable for `access`'s purposes because the caller
/// can `chdir` into it.
#[cfg(not(unix))]
fn is_executable(path: &Path) -> bool {
if path.is_dir() {
return true;
}
let extensions = std::env::var("PATHEXT").unwrap_or_else(|_| ".COM;.EXE;.BAT;.CMD".to_string());
let Some(extension) = path.extension().and_then(|value| value.to_str()) else {
return false;
};
let extension = format!(".{}", extension.to_ascii_uppercase());
extensions
.split(';')
.any(|candidate| candidate.eq_ignore_ascii_case(&extension))
}
/// Unix ignores the link kind: `symlink(2)` does not have one, and the target is
/// resolved lazily on every traversal, so a link to a directory works without
/// being declared as one.
#[cfg(unix)]
fn platform_symlink(target: &str, path: &str, kind: Option<&str>) -> Result<(), ModuleError> {
let _ = kind;
std::os::unix::fs::symlink(target, path).map_err(|error| errors::from_io(error, path))
}
/// Windows needs a different call for a file link and a directory link, and it
/// needs the developer-mode privilege (or SeCreateSymbolicLinkPrivilege) for
/// either. The design says to report the OS error rather than fall back to
/// copying, so a caller without the privilege sees the real reason.
///
/// The kind decides which call, following Node's rules. It cannot be inferred
/// from the target alone: a link may name a path that does not exist yet, and
/// `metadata` would fail on exactly the case that matters. `'junction'` gets
/// `symlink_dir` — a directory symlink rather than a true NTFS junction, which
/// needs a different API and an absolute target; both are traversable, which is
/// what a caller wants.
#[cfg(not(unix))]
fn platform_symlink(target: &str, path: &str, kind: Option<&str>) -> Result<(), ModuleError> {
let directory = match kind {
Some("dir") | Some("junction") => true,
Some("file") => false,
_ => {
// Node's rule when no type is given: resolve the target relative to
// the link's parent — the shell does the same — and take the answer
// from what is there.
let resolved = match Path::new(path).parent() {
Some(parent) => parent.join(target),
None => PathBuf::from(target),
};
resolved.is_dir()
}
};
let result = if directory {
std::os::windows::fs::symlink_dir(target, path)
} else {
std::os::windows::fs::symlink_file(target, path)
};
result.map_err(|error| errors::from_io(error, path))
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn tempdir() -> TempDir {
TempDir::new().expect("a temporary directory")
}
fn text(path: &Path) -> String {
path.to_string_lossy().into_owned()
}
#[test]
fn access_finds_a_present_path_and_reports_a_missing_one() {
let dir = tempdir();
let present = dir.path().join("here.txt");
std::fs::write(&present, b"x").expect("write the fixture file");
let absent = dir.path().join("gone.txt");
access(text(&present), f64::from(access::F_OK)).expect("a present path exists");
access(text(&present), f64::from(access::R_OK | access::W_OK))
.expect("a file we just wrote is readable and writable");
let error = access(text(&absent), f64::from(access::F_OK)).expect_err("a missing path");
assert_eq!(error.code.as_deref(), Some("ENOENT"));
}
#[test]
fn access_rejects_a_mode_node_does_not_define() {
let dir = tempdir();
let error = access(text(dir.path()), 8.0).expect_err("an unknown bit");
assert_eq!(error.code.as_deref(), Some("EINVAL"));
}
/// `dunce::canonicalize` resolves the whole chain, so the result is the real
/// file rather than the link. Windows is skipped: creating a symlink there
/// needs developer mode, which a CI runner may not have.
#[cfg(unix)]
#[test]
fn realpath_resolves_a_symlink() {
let dir = tempdir();
let file = dir.path().join("real.txt");
std::fs::write(&file, b"x").expect("write the fixture file");
let link = dir.path().join("link.txt");
std::os::unix::fs::symlink(&file, &link).expect("create the link");
assert_eq!(
realpath(text(&link)).expect("realpath"),
realpath(text(&file)).expect("realpath")
);
assert_eq!(
realpath(text(&file)).expect("realpath"),
dunce::canonicalize(&file)
.expect("canonical")
.to_string_lossy()
);
}
#[test]
fn realpath_of_a_missing_path_is_enoent() {
let dir = tempdir();
let error = realpath(text(&dir.path().join("gone"))).expect_err("missing");
assert_eq!(error.code.as_deref(), Some("ENOENT"));
}
/// The link is read, not followed: the target comes back as it was stored,
/// relative or absolute.
#[cfg(unix)]
#[test]
fn readlink_returns_the_stored_target() {
let dir = tempdir();
let link = dir.path().join("link.txt");
std::os::unix::fs::symlink("some/relative/target", &link).expect("create the link");
assert_eq!(
readlink(text(&link)).expect("readlink"),
"some/relative/target"
);
let file = dir.path().join("real.txt");
std::fs::write(&file, b"x").expect("write the fixture file");
let error = readlink(text(&file)).expect_err("a plain file is not a link");
assert_eq!(error.code.as_deref(), Some("EINVAL"));
}
/// Windows is skipped because creating a symlink there needs developer mode;
/// the OS error would be the correct result, not a test failure.
#[cfg(unix)]
#[test]
fn symlink_round_trips_through_readlink() {
let dir = tempdir();
let target = dir.path().join("target.txt");
std::fs::write(&target, b"x").expect("write the fixture file");
let link = dir.path().join("link.txt");
symlink(text(&target), text(&link), None).expect("symlink");
assert_eq!(readlink(text(&link)).expect("readlink"), text(&target));
assert_eq!(std::fs::read(&link).expect("through the link"), b"x");
}
#[test]
fn symlinking_onto_an_existing_path_is_eexist() {
let dir = tempdir();
let one = dir.path().join("one.txt");
let two = dir.path().join("two.txt");
std::fs::write(&one, b"1").expect("write");
std::fs::write(&two, b"2").expect("write");
let error = symlink(text(&one), text(&two), None).expect_err("the name is taken");
#[cfg(unix)]
assert_eq!(error.code.as_deref(), Some("EEXIST"));
// Windows reports the same collision, but through a Win32 code whose
// kind is "already exists"; only the presence of an error is portable.
#[cfg(not(unix))]
assert!(error.code.is_some());
}
}
@@ -0,0 +1,187 @@
//! Shared state and helpers for the file system module.
//!
//! The trait's methods are split across sibling files by concern — `files`,
//! `dirs`, `handles`, `paths`, `dialogs`, `watching` — because they are large and
//! largely independent. Rust allows the trait to be implemented in several files
//! of one crate, so each file owns its own `impl` block.
//!
//! Everything they share is here: the open file table, the flag translation, and
//! the conversions the bridge's `f64` arguments need.
use std::fs::File;
use std::sync::atomic::AtomicU32;
use std::sync::Mutex;
use rn_gpui_module::{ModuleError, ModuleThread};
use crate::NativeFsEvents;
use crate::errors;
/// Flags, as `node:fs` numbers them: Node's table, not the platform's, so these
/// are translated rather than passed through to `OpenOptions`.
pub mod flags {
pub const RDONLY: i32 = 0;
pub const WRONLY: i32 = 1;
pub const RDWR: i32 = 2;
pub const CREAT: i32 = 64;
pub const EXCL: i32 = 128;
pub const TRUNC: i32 = 512;
pub const APPEND: i32 = 1024;
}
/// Access modes for `access`, matching `node:fs.constants`.
pub mod access {
pub const F_OK: i32 = 0;
pub const X_OK: i32 = 1;
pub const W_OK: i32 = 2;
pub const R_OK: i32 = 4;
}
/// The kind numbers the JS layer turns into `Stats` predicates.
pub mod kind {
pub const FILE: f64 = 0.0;
pub const DIRECTORY: f64 = 1.0;
pub const SYMLINK: f64 = 2.0;
pub const OTHER: f64 = 3.0;
}
pub struct RNFsModule {
pub events: NativeFsEvents,
/// Open files, keyed by the number JS holds. The table belongs to the
/// instance, so a reload closes every descriptor when it drops.
///
/// Each entry records whether the descriptor was opened writable. The OS
/// reports a write to a read-only handle as `EBADF` on Unix and `EACCES` on
/// Windows, and Node reports `EBADF` on both, so the answer is taken from what
/// the handle was opened with rather than from the errno.
pub files: Mutex<std::collections::HashMap<u32, (File, bool)>>,
/// Never reused, so a stale `fd` from JS fails instead of closing an
/// unrelated file.
pub next_fd: AtomicU32,
/// Live watchers, keyed the same way.
pub watchers: Mutex<std::collections::HashMap<u32, crate::watching::Watcher>>,
pub next_watcher: AtomicU32,
}
impl RNFsModule {
pub fn create(events: NativeFsEvents) -> Self {
Self {
events,
files: Mutex::new(std::collections::HashMap::new()),
next_fd: AtomicU32::new(1),
watchers: Mutex::new(std::collections::HashMap::new()),
next_watcher: AtomicU32::new(1),
}
}
pub fn take_fd(&self) -> u32 {
self.next_fd
.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
}
pub fn take_watcher_id(&self) -> u32 {
self.next_watcher
.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
}
/// A clone of an open file, sharing its offset — which is what a descriptor
/// is. Holding the lock only for the lookup keeps a slow write from blocking
/// every other call on this thread.
pub fn file(&self, fd: f64) -> Result<File, ModuleError> {
self.file_and_writability(fd).map(|(file, _)| file)
}
/// The handle and whether it can be written to.
pub fn file_and_writability(&self, fd: f64) -> Result<(File, bool), ModuleError> {
let id = descriptor(fd)?;
let files = self.files.lock().unwrap_or_else(|e| e.into_inner());
let (file, writable) = files.get(&id).ok_or_else(|| bad_fd(id))?;
let cloned = file
.try_clone()
.map_err(|error| errors::from_io(error, &id.to_string()))?;
Ok((cloned, *writable))
}
}
/// The error Node raises for a descriptor it does not know.
pub fn bad_fd(id: u32) -> ModuleError {
errors::invalid("EBADF", format!("EBADF: bad file descriptor, {id}"))
}
/// A descriptor from the bridge. Anything but a non-negative integer is a
/// programmer error, so it reports as a bad descriptor rather than a file error.
pub fn descriptor(value: f64) -> Result<u32, ModuleError> {
if value.is_finite() && value >= 0.0 && value.fract() == 0.0 && value <= u32::MAX as f64 {
Ok(value as u32)
} else {
Err(bad_fd(0))
}
}
/// A byte length from the bridge.
pub fn length(value: f64) -> Result<usize, ModuleError> {
if value.is_finite() && value >= 0.0 && value.fract() == 0.0 && value <= u32::MAX as f64 {
Ok(value as usize)
} else {
Err(errors::invalid(
"EINVAL",
format!("EINVAL: invalid argument, length {value}"),
))
}
}
/// A permission bitmask, clamped to the bits `std::fs` accepts. Zero means the
/// caller passed nothing, which Node treats as `0o666`.
pub fn mode(value: f64) -> u32 {
if value.is_finite() && value > 0.0 {
value as u32 & 0o7777
} else {
0o666
}
}
/// `node:fs` numeric flags as `OpenOptions`.
pub fn open_options(flags_value: f64, mode: u32) -> std::fs::OpenOptions {
let flags_value = flags_value as i32;
let mut options = std::fs::OpenOptions::new();
// Bit tests, not `matches!`: `matches!(value, A | B)` is the or-pattern
// `value == A || value == B`, which for Node's flags asks whether the whole
// mask equals 0 or 2. `'w'` is `WRONLY | CREAT | TRUNC` (577), so that test
// was false and every write opened read-only and failed `EINVAL`.
// `RDONLY` is zero, so "read" is the absence of `WRONLY` rather than a bit.
options
.read(flags_value & flags::WRONLY == 0)
.write(flags_value & (flags::WRONLY | flags::RDWR) != 0)
.create(flags_value & flags::CREAT != 0)
.create_new(flags_value & flags::EXCL != 0)
.truncate(flags_value & flags::TRUNC != 0)
.append(flags_value & flags::APPEND != 0);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt as _;
options.mode(mode);
}
#[cfg(not(unix))]
let _ = mode;
options
}
/// Milliseconds since the epoch, as the bridge sends them.
pub fn system_time(ms: f64) -> std::time::SystemTime {
if ms.is_finite() && ms >= 0.0 {
std::time::UNIX_EPOCH + std::time::Duration::from_millis(ms as u64)
} else {
std::time::UNIX_EPOCH
}
}
/// A `SystemTime` as milliseconds, which is what `StatResult` carries.
pub fn millis(time: std::time::SystemTime) -> f64 {
time.duration_since(std::time::UNIX_EPOCH)
.map(|elapsed| elapsed.as_millis() as f64)
.unwrap_or(0.0)
}
// The spec says `THREAD = Own`; this keeps the module compiled against that.
const _: ModuleThread = ModuleThread::Own;
+231
View File
@@ -0,0 +1,231 @@
//! The well-known folders, as one `KnownPaths` object.
//!
//! `dirs` answers for the folders every platform has. The per-application three
//! — `appData`, `appCache`, `appLogs` — are this module's own convention, because
//! the host does not yet tell modules which app they belong to; see
//! `app_id` below. All three are created on the first read, as the design asks,
//! so a caller can write a log file without a `mkdir` first.
use std::path::{Path, PathBuf};
use rn_gpui_module::ModuleError;
use crate::KnownPaths;
/// The public folders plus the three per-application ones, created if needed.
///
/// Nothing here fails: a folder that does not exist is reported as the temp
/// directory instead. The design treats these as conveniences, and a caller
/// asking where to put a log file is better served by a working stand-in than by
/// a thrown error it cannot recover from — the alternative is a crash on a
/// system where, say, `Desktop` was never created.
pub fn known_paths() -> Result<KnownPaths, ModuleError> {
let temp = std::env::temp_dir();
let id = app_id();
let app_data = dirs::data_dir().map(|dir| dir.join(&id));
let app_cache = dirs::cache_dir().map(|dir| dir.join(&id));
let app_logs = logs_dir(&id);
Ok(KnownPaths {
home: fallback(dirs::home_dir(), &temp),
documents: fallback(dirs::document_dir(), &temp),
downloads: fallback(dirs::download_dir(), &temp),
desktop: fallback(dirs::desktop_dir(), &temp),
temp: path_string(&temp),
app_data: ensure_directory(app_data, &temp),
app_cache: ensure_directory(app_cache, &temp),
app_logs: ensure_directory(app_logs, &temp),
})
}
/// A folder as a string, with the temp directory standing in when the platform
/// cannot name it.
fn fallback(found: Option<PathBuf>, temp: &Path) -> String {
path_string(found.as_deref().unwrap_or(temp))
}
/// A path as `KnownPaths` carries it. A non-UTF-8 path cannot be a JS string, so
/// the lossy form is used rather than failing: it is still a path the platform
/// will resolve, and the folder is only a suggestion to the caller.
fn path_string(path: &Path) -> String {
path.to_string_lossy().into_owned()
}
/// Creates a directory that the returned path promises exists, falling back to
/// the temp directory if it cannot be created.
///
/// A failure here means the platform gave no base directory at all, or that it
/// exists but is not writable; temp is the one directory a process can normally
/// count on, so the caller still gets somewhere to write.
fn ensure_directory(found: Option<PathBuf>, temp: &Path) -> String {
let Some(directory) = found else {
return path_string(temp);
};
match std::fs::create_dir_all(&directory) {
Ok(()) => path_string(&directory),
Err(_) => path_string(temp),
}
}
/// Where `appLogs` lives, which is the one app folder whose convention differs
/// by platform: macOS keeps logs under `~/Library/Logs`, the others under the
/// data directory. Both get the app id as their last component so two apps do
/// not share a log folder.
fn logs_dir(id: &str) -> Option<PathBuf> {
#[cfg(target_os = "macos")]
{
dirs::home_dir().map(|home| home.join("Library").join("Logs").join(id))
}
#[cfg(not(target_os = "macos"))]
{
dirs::data_dir().map(|dir| dir.join(id).join("logs"))
}
}
/// The id the three per-app folders are namespaced by.
///
/// This is a **placeholder**. The design wants these paths to be per-app and
/// says the host already knows the id (`DesktopAppConfig::app_id`), but no host
/// function exposes it to modules yet: `host_info().app_id` does not exist. Until
/// that API lands, `RN_GPUI_APP_ID` is honoured when the app sets it, and the
/// executable's file stem is the fallback — stable for a given binary, which is
/// enough to keep the folders apart and across runs.
///
/// When `host_info().app_id` is added, this function should read it and the two
/// fallbacks should go, because a renamed binary would otherwise move the app's
/// data and cache out from under the user.
fn app_id() -> String {
let from_environment = std::env::var("RN_GPUI_APP_ID").ok();
let from_executable = std::env::current_exe().ok().and_then(|exe| {
exe.file_stem()
.map(|stem| stem.to_string_lossy().into_owned())
});
pick_app_id(from_environment.as_deref(), from_executable.as_deref())
}
/// The selection half of `app_id`, split out so the precedence and the fallback
/// can be tested without touching the environment, which other tests share.
fn pick_app_id(from_environment: Option<&str>, from_executable: Option<&str>) -> String {
[from_environment, from_executable]
.into_iter()
.flatten()
.map(sanitise)
.find(|id| !id.is_empty())
// A process whose executable has no usable name still needs folders, and
// the name only has to be stable, not accurate.
.unwrap_or_else(|| "rn-gpui-app".to_string())
}
/// Strips the characters that cannot appear in a directory name on any of the
/// three platforms, so an id from the environment cannot escape its parent
/// directory or fail `create_dir_all` on Windows.
///
/// Leading and trailing dots and dashes go too: an id that was only separators
/// sanitises to nothing rather than to a directory named `-`, and one that began
/// with `..` cannot end up looking like a parent reference.
fn sanitise(id: &str) -> String {
id.trim()
.chars()
.map(|character| match character {
'/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' | '\0' => '-',
other => other,
})
.collect::<String>()
.trim_matches(|character| character == '.' || character == '-')
.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_known_path_is_a_non_empty_string() {
let paths = known_paths().expect("known paths");
for (name, value) in [
("home", &paths.home),
("documents", &paths.documents),
("downloads", &paths.downloads),
("desktop", &paths.desktop),
("temp", &paths.temp),
("appData", &paths.app_data),
("appCache", &paths.app_cache),
("appLogs", &paths.app_logs),
] {
assert!(!value.is_empty(), "{name} was empty");
}
}
#[test]
fn the_app_folders_exist_after_the_first_read() {
let paths = known_paths().expect("known paths");
for (name, value) in [
("appData", &paths.app_data),
("appCache", &paths.app_cache),
("appLogs", &paths.app_logs),
] {
assert!(
Path::new(value).is_dir(),
"{name} was not created at {value}"
);
}
}
#[test]
fn the_app_folders_are_distinct() {
let paths = known_paths().expect("known paths");
let app_data = Path::new(&paths.app_data);
let app_cache = Path::new(&paths.app_cache);
let app_logs = Path::new(&paths.app_logs);
assert_ne!(app_data, app_cache);
assert_ne!(app_data, app_logs);
assert_ne!(app_cache, app_logs);
}
#[test]
fn a_second_read_returns_the_same_folders() {
// Creating a directory that already exists has to keep succeeding, since
// the JS layer may call `getPaths()` more than once per run.
let first = known_paths().expect("known paths");
let second = known_paths().expect("known paths");
assert_eq!(first.app_data, second.app_data);
assert_eq!(first.app_cache, second.app_cache);
assert_eq!(first.app_logs, second.app_logs);
}
#[test]
fn an_id_from_the_environment_names_the_app_folders() {
// Only the pure part is asserted: mutating the environment would race
// with the other tests in this module, which read it concurrently.
assert_eq!(sanitise("my-app"), "my-app");
assert_eq!(sanitise(" ../escape "), "escape");
assert_eq!(sanitise("a/b\\c"), "a-b-c");
assert_eq!(sanitise(" "), "");
}
#[test]
fn the_environment_id_wins_over_the_executable_name() {
assert_eq!(pick_app_id(Some("from-env"), Some("from-exe")), "from-env");
}
#[test]
fn an_unusable_environment_id_falls_through_to_the_executable_name() {
assert_eq!(pick_app_id(Some(" "), Some("from-exe")), "from-exe");
// A separator-only id would escape its parent, which `sanitise` turns
// into a name rather than a path.
assert_eq!(pick_app_id(Some("/"), Some("from-exe")), "from-exe");
}
#[test]
fn no_id_at_all_still_yields_a_stable_name() {
assert_eq!(pick_app_id(None, None), "rn-gpui-app");
assert_eq!(pick_app_id(Some(""), Some("")), "rn-gpui-app");
}
#[test]
fn a_hostile_executable_name_cannot_escape_the_base_directory() {
assert_eq!(pick_app_id(None, Some("../../etc/passwd")), "etc-passwd");
}
}
@@ -0,0 +1,392 @@
//! The watcher behind `watch` and `unwatch`.
//!
//! `notify` gives each watcher a thread of its own and calls the handler on it.
//! That handler cannot borrow the module — it may outlive any single call and
//! runs on a thread the module does not own — so it is handed a clone of the
//! event emitter and an `Arc` of the debounce table instead. The module only
//! stores the `RecommendedWatcher`; dropping that value stops the OS watch, and
//! with it the thread, which is how a JS reload closes every watcher at once.
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use notify::event::EventKind;
use notify::Watcher as _;
use notify::{RecommendedWatcher, RecursiveMode};
use rn_gpui_module::ModuleError;
use crate::{errors, RNFsModule, WatchEvent};
/// How long a path stays quiet after being reported.
///
/// An editor saving a file usually writes it several times in a burst (write,
/// rename over the original, a truncated open), and a recursive watch on a build
/// directory can see dozens of events for one logical change. Fifty milliseconds
/// collapses a burst into the single event a caller can act on without making a
/// genuinely repeated edit invisible.
const DEBOUNCE: Duration = Duration::from_millis(50);
/// An installed watch, kept alive by the module's table.
///
/// The field is never read: it exists so that removing the entry drops it, which
/// is what stops notify's thread and the platform watch.
pub struct Watcher {
_watcher: RecommendedWatcher,
}
/// What was last reported for a path, and when.
#[derive(Clone, Copy)]
struct LastReport {
at: Instant,
kind: EventKindTag,
}
/// Which of the three stable names an event carries.
///
/// A copy of the mapping `kind_name` produces, so the debounce can compare two
/// events without allocating the string it will not send.
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
enum EventKindTag {
Create,
Modify,
Remove,
Other,
}
/// Per-path timestamps of the last event that was reported.
///
/// Shared with the handler through an `Arc` because the handler owns its state
/// for as long as the watcher lives, which is longer than any borrow of the
/// module. Paths that fall out of the window are pruned on every report, so a
/// long-running recursive watch cannot grow this without bound.
///
/// The rule, in one sentence: **a burst is collapsed per path and per kind, and a
/// removal is never collapsed away.** Keeping only the first event of a burst
/// lost the end of it — a create followed by a remove reported only `create`, so
/// a caller was told about a file that no longer existed. Two adjustments fix
/// that without giving up the collapsing that makes an editor's write burst
/// readable:
///
/// - a path's window is per kind, so a `remove` after a `create` is a different
/// event rather than a suppressed one;
/// - a removal always reports, whatever the window, because it is the last state
/// a path can reach and swallowing it leaves the caller's view permanently
/// wrong. The cost is a possible duplicate removal for one logical deletion,
/// which a watcher callback can tolerate; a missing one it cannot.
#[derive(Default)]
struct Debounce {
last: Mutex<HashMap<(PathBuf, EventKindTag), Instant>>,
}
impl Debounce {
/// Whether this event should be delivered now.
///
/// Takes `now` rather than reading the clock so the window can be tested
/// without sleeping.
fn should_report_at(&self, path: &Path, kind: EventKindTag, now: Instant) -> bool {
let mut last = self.last.lock().unwrap_or_else(|error| error.into_inner());
let key = (path.to_path_buf(), kind);
let report = match last.get(&key).copied() {
Some(previous) => {
kind == EventKindTag::Remove || now.saturating_duration_since(previous) >= DEBOUNCE
}
None => true,
};
if report {
last.retain(|_, previous| now.saturating_duration_since(*previous) < DEBOUNCE);
last.insert(key, now);
}
report
}
fn should_report(&self, path: &Path, kind: EventKindTag) -> bool {
self.should_report_at(path, kind, Instant::now())
}
}
/// The stable string the JS layer matches on.
///
/// Everything that is not a create, modify or remove — access events, and the
/// catch-all notify reports when a backend cannot be more precise — becomes
/// `other` rather than leaking a platform-specific spelling.
fn kind_name(kind: &EventKind) -> &'static str {
match kind {
EventKind::Create(_) => "create",
EventKind::Modify(_) => "modify",
EventKind::Remove(_) => "remove",
_ => "other",
}
}
/// The tag `kind_name` corresponds to, for the debounce to compare by.
fn kind_tag(kind: &EventKind) -> EventKindTag {
match kind {
EventKind::Create(_) => EventKindTag::Create,
EventKind::Modify(_) => EventKindTag::Modify,
EventKind::Remove(_) => EventKindTag::Remove,
_ => EventKindTag::Other,
}
}
/// An event path as the caller should see it.
///
/// Node reports the path relative to the watch root, and a name alone for a
/// single-file watch. The platform reports an absolute path, and on macOS a
/// *canonical* one: FSEvents resolves `/var` to `/private/var`, so a plain string
/// prefix check against the path the caller gave strips nothing and the callback
/// receives an absolute path. Both roots are therefore tried, and the given one
/// last so a caller watching through a symlink still gets a relative name.
fn relative_event_path(path: &Path, roots: &[PathBuf], watching_file: bool) -> String {
if watching_file {
return path
.file_name()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_else(|| path.to_string_lossy().into_owned());
}
for root in roots {
if let Ok(rest) = path.strip_prefix(root) {
// Normalised to `/`: Node reports relative paths with forward
// slashes on every platform, and a Windows caller would otherwise get
// `nested\file.txt` where Node gives `nested/file.txt`.
return rest.to_string_lossy().replace('\\', "/");
}
}
path.to_string_lossy().into_owned()
}
/// A notify failure as the error JS expects.
///
/// Most failures wrap an `io::Error` that already maps through `errors::from_io`;
/// the ones that do not are described well enough by notify to be reported with
/// the closest Node code.
fn watcher_error(error: notify::Error, path: &str) -> ModuleError {
use notify::ErrorKind;
let io = match error.kind {
ErrorKind::Io(error) => error,
ErrorKind::PathNotFound => std::io::Error::new(std::io::ErrorKind::NotFound, "not found"),
ErrorKind::MaxFilesWatch => {
std::io::Error::new(std::io::ErrorKind::Other, "OS file watch limit reached")
}
other => std::io::Error::new(std::io::ErrorKind::Other, format!("{other:?}")),
};
errors::from_io(io, path)
}
/// Starts watching `path`, returning the id events carry.
pub fn watch(module: &RNFsModule, path: String, recursive: bool) -> Result<f64, ModuleError> {
// Node fails a watch on a missing path immediately, and so must this: the
// platform watchers otherwise accept the path and then go silent, which is
// indistinguishable at the call site from a directory nothing ever touches.
let target = PathBuf::from(&path);
if !target.exists() {
return Err(errors::from_io(
std::io::Error::new(std::io::ErrorKind::NotFound, "not found"),
&path,
));
}
let id = module.take_watcher_id();
let debounce = Arc::new(Debounce::default());
let events = module.events.clone();
// The canonical root is what macOS actually reports inside, and the given
// root is what the caller passed; keeping both means the relative name
// survives either spelling.
let watching_file = target.is_file();
let roots: Vec<PathBuf> = {
let mut roots = Vec::new();
if let Ok(canonical) = target.canonicalize() {
roots.push(canonical);
}
roots.push(target.clone());
roots
};
let handler = move |result: notify::Result<notify::Event>| {
// A backend error has nowhere to go: it is not tied to a call, and
// reporting it through the event stream would make it look like a file
// change. notify's thread keeps running, so later events still arrive.
let Ok(event) = result else { return };
let kind = kind_name(&event.kind);
let tag = kind_tag(&event.kind);
// One platform event can name several paths — a directory move, for
// instance — and a caller wants one delivery per path.
for path in &event.paths {
if !debounce.should_report(path, tag) {
continue;
}
events.on_watch_event(WatchEvent {
id: f64::from(id),
kind: kind.to_string(),
path: relative_event_path(path, &roots, watching_file),
});
}
};
let mut watcher =
notify::recommended_watcher(handler).map_err(|error| watcher_error(error, &path))?;
let mode = if recursive {
RecursiveMode::Recursive
} else {
RecursiveMode::NonRecursive
};
watcher
.watch(&target, mode)
.map_err(|error| watcher_error(error, &path))?;
module
.watchers
.lock()
.unwrap_or_else(|error| error.into_inner())
.insert(id, Watcher { _watcher: watcher });
Ok(f64::from(id))
}
/// Stops a watcher. An id that is already gone is not a failure: JS may close a
/// watcher twice, or close one whose module has already dropped it.
pub fn unwatch(module: &RNFsModule, id: f64) {
let Ok(id) = crate::module::descriptor(id) else {
return;
};
// Removing the entry drops the watcher, which stops notify's thread.
module
.watchers
.lock()
.unwrap_or_else(|error| error.into_inner())
.remove(&id);
}
#[cfg(test)]
mod tests {
use super::*;
use notify::event::{CreateKind, ModifyKind, RemoveKind};
#[test]
fn event_kinds_map_to_stable_names() {
assert_eq!(kind_name(&EventKind::Create(CreateKind::File)), "create");
assert_eq!(kind_name(&EventKind::Create(CreateKind::Any)), "create");
assert_eq!(kind_name(&EventKind::Modify(ModifyKind::Any)), "modify");
assert_eq!(kind_name(&EventKind::Remove(RemoveKind::Any)), "remove");
assert_eq!(kind_name(&EventKind::Any), "other");
}
#[test]
fn the_first_event_for_a_path_is_reported() {
let debounce = Debounce::default();
let now = Instant::now();
assert!(debounce.should_report_at(Path::new("/a"), EventKindTag::Modify, now));
}
#[test]
fn a_burst_is_collapsed_into_one_event() {
let debounce = Debounce::default();
let start = Instant::now();
assert!(debounce.should_report_at(Path::new("/a"), EventKindTag::Modify, start));
assert!(!debounce.should_report_at(
Path::new("/a"),
EventKindTag::Modify,
start + Duration::from_millis(10)
));
assert!(!debounce.should_report_at(
Path::new("/a"),
EventKindTag::Modify,
start + Duration::from_millis(49)
));
assert!(debounce.should_report_at(
Path::new("/a"),
EventKindTag::Modify,
start + Duration::from_millis(50)
));
}
#[test]
fn a_remove_inside_a_burst_is_not_swallowed() {
let debounce = Debounce::default();
let start = Instant::now();
let path = Path::new("/a");
// A create and the removal that follows it are two states of the path,
// not one event seen twice: reporting only the create tells a caller
// about a file that is already gone.
assert!(debounce.should_report_at(path, EventKindTag::Create, start));
assert!(debounce.should_report_at(
path,
EventKindTag::Remove,
start + Duration::from_millis(5)
));
// A second removal for the same deletion may arrive; a missing one may
// not, so this is deliberately the lenient side of the trade.
assert!(debounce.should_report_at(
path,
EventKindTag::Remove,
start + Duration::from_millis(10)
));
}
#[test]
fn a_modify_burst_still_collapses() {
let debounce = Debounce::default();
let start = Instant::now();
let path = Path::new("/a");
assert!(debounce.should_report_at(path, EventKindTag::Modify, start));
// Two writes a millisecond apart are one logical change.
assert!(!debounce.should_report_at(
path,
EventKindTag::Modify,
start + Duration::from_millis(1)
));
}
#[test]
fn a_canonical_root_still_yields_a_relative_name() {
// macOS reports canonical paths, so a watch on `/var/...` sees
// `/private/var/...`. Both roots are tried, and the caller gets a name
// relative to the one they asked about either way.
let canonical = PathBuf::from("/private/var/folders/x/tmp");
let given = PathBuf::from("/var/folders/x/tmp");
let roots = vec![canonical.clone(), given];
let event = canonical.join("nested").join("file.txt");
assert_eq!(
relative_event_path(&event, &roots, false),
"nested/file.txt"
);
// A path outside every root is passed through rather than mangled.
assert_eq!(
relative_event_path(Path::new("/elsewhere/file.txt"), &roots, false),
"/elsewhere/file.txt"
);
// Watching a single file reports its name, which is what Node does.
assert_eq!(relative_event_path(&event, &roots, true), "file.txt");
}
#[test]
fn debouncing_is_per_path() {
let debounce = Debounce::default();
let start = Instant::now();
assert!(debounce.should_report_at(Path::new("/a"), EventKindTag::Modify, start));
assert!(debounce.should_report_at(
Path::new("/b"),
EventKindTag::Modify,
start + Duration::from_millis(1)
));
assert!(!debounce.should_report_at(
Path::new("/a"),
EventKindTag::Modify,
start + Duration::from_millis(2)
));
}
#[test]
fn paths_outside_the_window_are_forgotten() {
let debounce = Debounce::default();
let start = Instant::now();
assert!(debounce.should_report_at(Path::new("/a"), EventKindTag::Modify, start));
assert!(debounce.should_report_at(
Path::new("/b"),
EventKindTag::Modify,
start + Duration::from_millis(60)
));
let last = debounce.last.lock().unwrap();
assert_eq!(last.len(), 1, "the stale path should have been pruned");
assert!(last.contains_key(&(PathBuf::from("/b"), EventKindTag::Modify)));
}
}
+19
View File
@@ -0,0 +1,19 @@
{
"name": "@react-native-gpui/fs",
"version": "0.1.0",
"main": "src/index.ts",
"files": [
"src",
"desktop",
"react-native.config.js",
"README.md"
],
"codegenConfig": {
"name": "RNFsModuleSpec",
"type": "modules",
"jsSrcsDir": "src"
},
"peerDependencies": {
"react-native": ">=0.76"
}
}
@@ -0,0 +1,9 @@
module.exports = {
dependency: {
platforms: {
desktop: {
crate: 'rn-gpui-fs',
},
},
},
};
+162
View File
@@ -0,0 +1,162 @@
import type { CodegenTypes, TurboModule } from 'react-native';
import { TurboModuleRegistry } from 'react-native';
/**
* How a path entry is classified: 0 file, 1 directory, 2 symlink, 3 other.
*
* A plain number rather than a string or enum so it crosses the bridge without a
* lookup table and survives being read back out of a plain object in JS.
*/
export type FileKind = number;
export type StatResult = {
size: number;
mode: number;
kind: FileKind;
atimeMs: number;
mtimeMs: number;
ctimeMs: number;
birthtimeMs: number;
dev: number;
ino: number;
nlink: number;
uid: number;
gid: number;
};
export type DirEntry = {
name: string;
kind: FileKind;
parentPath: string;
};
export type DialogFilter = {
name: string;
extensions: Array<string>;
};
export type OpenDialogOptions = {
title?: string;
defaultPath?: string;
multiple: boolean;
directory: boolean;
filters: Array<DialogFilter>;
};
export type SaveDialogOptions = {
title?: string;
defaultPath?: string;
defaultName?: string;
filters: Array<DialogFilter>;
};
export type KnownPaths = {
home: string;
documents: string;
downloads: string;
desktop: string;
temp: string;
appData: string;
appCache: string;
appLogs: string;
};
export type WatchEvent = {
id: number;
kind: string;
path: string;
};
/**
* The file system module: one instance, on its own thread, so a slow file never
* blocks JS or the UI.
*
* A method that returns a value runs on the JS thread; `void` and `Promise`
* methods run on the module's thread. The writers below therefore return
* `boolean` — a `void` method would be moved off the JS thread, which is wrong
* for a `*Sync` call. They always return true and throw on failure.
*/
export interface Spec extends TurboModule {
// Text is separate from bytes so a `utf8` read or write needs no buffer.
readFile(path: string): Promise<ArrayBuffer>;
readTextFile(path: string): Promise<string>;
writeFile(
path: string,
data: ArrayBuffer,
flags: number,
mode: number,
): Promise<void>;
writeTextFile(
path: string,
data: string,
flags: number,
mode: number,
): Promise<void>;
readdir(path: string, recursive: boolean): Promise<Array<DirEntry>>;
// `followSymlinks` false is `lstat`.
stat(path: string, followSymlinks: boolean): Promise<StatResult>;
mkdir(path: string, recursive: boolean, mode: number): Promise<void>;
rm(path: string, recursive: boolean, force: boolean): Promise<void>;
rename(from: string, to: string): Promise<void>;
copy(from: string, to: string, recursive: boolean): Promise<void>;
access(path: string, mode: number): Promise<void>;
realpath(path: string): Promise<string>;
readlink(path: string): Promise<string>;
// `type` is Node's, and only Windows acts on it: `'dir'` and `'junction'`
// create a directory link, `null` decides from the resolved target, and a
// plain `'file'` is a file link. Unix ignores it, because the syscall does.
// Without it a Windows directory link was created as a file link and could
// not be traversed.
symlink(target: string, path: string, type: string | null): Promise<void>;
truncate(path: string, length: number): Promise<void>;
utimes(path: string, atimeMs: number, mtimeMs: number): Promise<void>;
chmod(path: string, mode: number): Promise<void>;
mkdtemp(prefix: string): Promise<string>;
// File handles. `position` -1 means "at the current offset".
open(path: string, flags: number, mode: number): Promise<number>;
read(fd: number, length: number, position: number): Promise<ArrayBuffer>;
write(fd: number, data: ArrayBuffer, position: number): Promise<number>;
fstat(fd: number): Promise<StatResult>;
fsync(fd: number): Promise<void>;
ftruncate(fd: number, length: number): Promise<void>;
close(fd: number): Promise<void>;
showOpenDialog(options: OpenDialogOptions): Promise<Array<string>>;
showSaveDialog(options: SaveDialogOptions): Promise<string | null>;
reveal(path: string): Promise<void>;
openExternal(path: string): Promise<void>;
// Returns the watcher id; events arrive through `onWatchEvent`.
watch(path: string, recursive: boolean): Promise<number>;
unwatch(id: number): void;
readFileSync(path: string): ArrayBuffer;
readTextFileSync(path: string): string;
writeFileSync(
path: string,
data: ArrayBuffer,
flags: number,
mode: number,
): boolean;
writeTextFileSync(
path: string,
data: string,
flags: number,
mode: number,
): boolean;
statSync(path: string, followSymlinks: boolean): StatResult | null;
existsSync(path: string): boolean;
readdirSync(path: string, recursive: boolean): Array<DirEntry>;
mkdirSync(path: string, recursive: boolean, mode: number): boolean;
rmSync(path: string, recursive: boolean, force: boolean): boolean;
renameSync(from: string, to: string): boolean;
copyFileSync(from: string, to: string): boolean;
getPaths(): KnownPaths;
readonly onWatchEvent: CodegenTypes.EventEmitter<WatchEvent>;
}
export default TurboModuleRegistry.getEnforcing<Spec>('RNFsModule');
+43
View File
@@ -0,0 +1,43 @@
/**
* `fs.constants`, with Node's numbers.
*
* The `O_*` values are Node's table rather than the platform's `open(2)` flags,
* because that is what the Rust side decodes: it translates them into
* `OpenOptions` instead of passing them through. The numbers here must stay in
* step with `modules/fs/desktop/src/module.rs`, which owns the other half of the
* contract.
*/
export const constants = {
// `access` modes.
F_OK: 0,
X_OK: 1,
W_OK: 2,
R_OK: 4,
// `open` flags.
O_RDONLY: 0,
O_WRONLY: 1,
O_RDWR: 2,
O_CREAT: 64,
O_EXCL: 128,
O_TRUNC: 512,
O_APPEND: 1024,
// `copyFile` modes.
COPYFILE_EXCL: 1,
// File-type bits, for the predicates on `Stats` that cannot be answered from
// the module's coarse `kind` alone (a socket and a block device are both
// "other" there).
S_IFMT: 0o170000,
S_IFREG: 0o100000,
S_IFDIR: 0o040000,
S_IFLNK: 0o120000,
S_IFBLK: 0o060000,
S_IFCHR: 0o020000,
S_IFIFO: 0o010000,
S_IFSOCK: 0o140000,
} as const;
export type Constants = typeof constants;
+769
View File
@@ -0,0 +1,769 @@
/**
* `@react-native-gpui/fs`: a Node-shaped file API for the desktop app.
*
* The native module underneath is one TurboModule on its own thread with
* `ArrayBuffer` arguments and plain-object results. Everything Node adds on top
* of that — encodings, `Stats` and `Dirent` instances, callback forms, the
* `fs/promises` object — lives in this package's JS layer, so the bridge stays a
* byte mover and the API stays recognisably Node's.
*
* The default export is the `node:fs` object; `dialog`, `paths` and `watch` are
* named exports because they have no `fs` counterpart to hang off.
*/
import { constants } from './constants';
import NativeFs from './NativeFs';
import {
Dirent,
FileHandle,
Stats,
promises,
readFileImpl,
type MkdirOptions,
type OpenOptions,
type RmOptions,
type WriteOptions,
} from './promises';
import {
decodeString,
flagFrom,
missingPath,
modeFrom,
normalizeEncoding,
relativeEntryName,
toArrayBuffer,
toPath,
withContext,
type BinaryData,
type Encoding,
type NodeError,
} from './shared';
import { watch } from './watch';
export type {
CpOptions,
MkdirOptions,
OpenOptions,
ReaddirOptions,
RmOptions,
TimeLike,
WriteOptions,
} from './promises';
export type { BinaryData, Encoding, NodeError } from './shared';
export type {
FSWatcher,
WatchEventType,
WatchListener,
WatchOptions,
} from './watch';
export { Dirent, Stats, FileHandle, promises, constants, watch };
/* -------------------------------------------------------------------------- */
/* Dialogs */
/* -------------------------------------------------------------------------- */
export interface DialogFilter {
name: string;
extensions: Array<string>;
}
export interface OpenDialogOptions {
title?: string;
defaultPath?: string;
multiple?: boolean;
directory?: boolean;
filters?: Array<DialogFilter>;
}
export interface SaveDialogOptions {
title?: string;
defaultPath?: string;
defaultName?: string;
filters?: Array<DialogFilter>;
}
/**
* The native pickers.
*
* Cancelling is not an error: `open` resolves with `[]` and `save` with `null`,
* which is what the design specifies and what libraries pairing the two expect.
*/
export const dialog = {
async open(options: OpenDialogOptions = {}): Promise<string[]> {
return NativeFs.showOpenDialog({
title: options.title,
defaultPath:
options.defaultPath === undefined
? undefined
: toPath(options.defaultPath),
multiple: options.multiple ?? false,
directory: options.directory ?? false,
filters: options.filters ?? [],
});
},
async save(options: SaveDialogOptions = {}): Promise<string | null> {
return NativeFs.showSaveDialog({
title: options.title,
defaultPath:
options.defaultPath === undefined
? undefined
: toPath(options.defaultPath),
defaultName: options.defaultName,
filters: options.filters ?? [],
});
},
};
/* -------------------------------------------------------------------------- */
/* Known paths */
/* -------------------------------------------------------------------------- */
/** The well-known directories, as the native side reports them. */
export interface KnownPaths {
home: string;
documents: string;
downloads: string;
desktop: string;
temp: string;
appData: string;
appCache: string;
appLogs: string;
}
const KNOWN_PATH_NAMES = [
'home',
'documents',
'downloads',
'desktop',
'temp',
'appData',
'appCache',
'appLogs',
] as const;
let cachedPaths: KnownPaths | null = null;
/**
* Reads the known paths once and remembers them.
*
* Lazy rather than at import time so that importing this package cannot fail: the
* native module is only asked when an app actually names a directory, and the
* native side creates the per-app folders on that first read.
*/
function knownPaths(): KnownPaths {
if (cachedPaths === null) {
cachedPaths = NativeFs.getPaths();
}
return cachedPaths;
}
/**
* `paths.home`, `paths.documents` and the rest.
*
* Each property is a getter over one cached native read, so destructuring works
* and the object can be exported before the module has been asked anything.
*/
export const paths: KnownPaths = Object.defineProperties(
{},
Object.fromEntries(
KNOWN_PATH_NAMES.map(name => [
name,
{ enumerable: true, get: () => knownPaths()[name] },
]),
),
) as KnownPaths;
/* -------------------------------------------------------------------------- */
/* Sync forms */
/* -------------------------------------------------------------------------- */
function readFileSync(path: string): Uint8Array;
function readFileSync(
path: string,
options: Encoding | { encoding: Encoding },
): string;
function readFileSync(
path: string,
options?: Encoding | { encoding?: Encoding | null } | null,
): Uint8Array | string {
const target = toPath(path);
const encoding =
typeof options === 'string' ? options : (options?.encoding ?? null);
try {
if (encoding === null) {
return new Uint8Array(NativeFs.readFileSync(target));
}
const normalized = normalizeEncoding(encoding) ?? 'utf8';
if (normalized === 'utf8') {
// The dedicated text method decodes in Rust instead of handing JS bytes
// to decode a second time.
return NativeFs.readTextFileSync(target);
}
return decodeString(
new Uint8Array(NativeFs.readFileSync(target)),
normalized,
);
} catch (error) {
throw withContext(error, 'open', target);
}
}
function writeFileSync(
path: string,
data: BinaryData,
options?: WriteOptions | Encoding,
): void {
const target = toPath(path);
const opts: WriteOptions =
typeof options === 'string' ? { encoding: options } : (options ?? {});
const flag = flagFrom(
opts.flag,
constants.O_WRONLY | constants.O_CREAT | constants.O_TRUNC,
);
const mode = modeFrom(opts.mode);
try {
if (typeof data === 'string') {
const encoding = normalizeEncoding(opts.encoding ?? 'utf8') ?? 'utf8';
if (encoding === 'utf8') {
NativeFs.writeTextFileSync(target, data, flag, mode);
return;
}
NativeFs.writeFileSync(target, toArrayBuffer(data, encoding), flag, mode);
return;
}
NativeFs.writeFileSync(target, toArrayBuffer(data), flag, mode);
} catch (error) {
throw withContext(error, 'open', target);
}
}
function appendFileSync(
path: string,
data: BinaryData,
options?: WriteOptions | Encoding,
): void {
const opts: WriteOptions =
typeof options === 'string' ? { encoding: options } : (options ?? {});
writeFileSync(path, data, {
...opts,
flag:
opts.flag ?? constants.O_WRONLY | constants.O_CREAT | constants.O_APPEND,
});
}
function existsSync(path: string): boolean {
try {
return NativeFs.existsSync(toPath(path));
} catch {
// `existsSync` answers a question rather than reporting a failure, so every
// error — a permission problem as much as a missing path — is a `false`.
return false;
}
}
function statSync(path: string): Stats {
const target = toPath(path);
try {
const result = NativeFs.statSync(target, true);
if (result === null) {
throw missingPath(target, 'stat');
}
return new Stats(result);
} catch (error) {
throw withContext(error, 'stat', target);
}
}
function lstatSync(path: string): Stats {
const target = toPath(path);
try {
const result = NativeFs.statSync(target, false);
if (result === null) {
throw missingPath(target, 'lstat');
}
return new Stats(result);
} catch (error) {
throw withContext(error, 'lstat', target);
}
}
function readdirSync(
path: string,
options?: { withFileTypes?: false; recursive?: boolean } | null,
): string[];
function readdirSync(
path: string,
options: { withFileTypes: true; recursive?: boolean },
): Dirent[];
function readdirSync(
path: string,
options?: { withFileTypes?: boolean; recursive?: boolean } | null,
): string[] | Dirent[] {
const target = toPath(path);
try {
const recursive = options?.recursive ?? false;
const entries = NativeFs.readdirSync(target, recursive);
return options?.withFileTypes
? entries.map(entry => new Dirent(entry))
: entries.map(entry =>
recursive
? relativeEntryName(target, entry.parentPath, entry.name)
: entry.name,
);
} catch (error) {
throw withContext(error, 'scandir', target);
}
}
function mkdirSync(path: string, options?: MkdirOptions | number): void {
const target = toPath(path);
const opts: MkdirOptions =
typeof options === 'number' ? { mode: options } : (options ?? {});
try {
NativeFs.mkdirSync(
target,
opts.recursive ?? false,
modeFrom(opts.mode, 0o777),
);
} catch (error) {
throw withContext(error, 'mkdir', target);
}
}
function rmSync(path: string, options?: RmOptions): void {
const target = toPath(path);
try {
NativeFs.rmSync(
target,
options?.recursive ?? false,
options?.force ?? false,
);
} catch (error) {
throw withContext(error, 'rm', target);
}
}
function renameSync(from: string, to: string): void {
const source = toPath(from);
const destination = toPath(to);
try {
NativeFs.renameSync(source, destination);
} catch (error) {
const wrapped = withContext(error, 'rename', source);
// Node's rename error names both ends; the second is `dest`, not `path`.
if (wrapped.dest === undefined) {
wrapped.dest = destination;
}
throw wrapped;
}
}
function copyFileSync(from: string, to: string): void {
const source = toPath(from);
const destination = toPath(to);
try {
NativeFs.copyFileSync(source, destination);
} catch (error) {
const wrapped = withContext(error, 'copyfile', source);
if (wrapped.dest === undefined) {
wrapped.dest = destination;
}
throw wrapped;
}
}
/* -------------------------------------------------------------------------- */
/* Callback forms */
/* -------------------------------------------------------------------------- */
/**
* The callback forms are wrappers over the promise implementations, as the
* design says, so there is one implementation of each operation.
*
* The trailing argument is the callback in every form; the optional argument
* before it is an options object (or an encoding, or a mode) when it is not a
* function. That is the whole of Node's overloading convention here.
*/
type NodeCallback<T> = (error: NodeError | null, value?: T) => void;
function callbackOf<T>(args: unknown[]): NodeCallback<T> {
const callback = args[args.length - 1];
if (typeof callback !== 'function') {
throw new TypeError('A callback function is required');
}
return callback as NodeCallback<T>;
}
/** The options argument, if the caller supplied one rather than the callback. */
function before<T>(args: unknown[], index: number, fallback: T): T {
const value = args[index];
return value === undefined || typeof value === 'function'
? fallback
: (value as T);
}
/**
* Reports a settled promise the Node way.
*
* A rejection that has a callback is delivered to it; nothing is thrown, because
* a throw from here would escape the caller's stack rather than the callback.
*/
function settle<T>(promise: Promise<T>, callback: NodeCallback<T>): void {
promise.then(
value => callback(null, value),
(error: NodeError) => callback(error),
);
}
function readFile(
path: string,
callback: NodeCallback<Uint8Array | string>,
): void;
function readFile(
path: string,
options: Encoding | { encoding?: Encoding | null } | null,
callback: NodeCallback<Uint8Array | string>,
): void;
function readFile(path: string, ...args: unknown[]): void {
// The promise overloads keep the result precise; the callback form goes
// through the coarse alias because at that point the encoding is a runtime
// value and either shape is possible.
const options = before(args, 0, undefined) as
Encoding | { encoding?: Encoding | null } | null | undefined;
settle(readFileImpl(path, options), callbackOf(args));
}
function writeFile(
path: string,
data: BinaryData,
callback: NodeCallback<void>,
): void;
function writeFile(
path: string,
data: BinaryData,
options: WriteOptions | Encoding | null,
callback: NodeCallback<void>,
): void;
function writeFile(path: string, data: BinaryData, ...args: unknown[]): void {
settle(
promises.writeFile(
path,
data,
before(args, 0, undefined) as WriteOptions | Encoding | undefined,
),
callbackOf(args),
);
}
function appendFile(
path: string,
data: BinaryData,
callback: NodeCallback<void>,
): void;
function appendFile(
path: string,
data: BinaryData,
options: WriteOptions | Encoding | null,
callback: NodeCallback<void>,
): void;
function appendFile(path: string, data: BinaryData, ...args: unknown[]): void {
settle(
promises.appendFile(
path,
data,
before(args, 0, undefined) as WriteOptions | Encoding | undefined,
),
callbackOf(args),
);
}
function readdir(
path: string,
callback: NodeCallback<string[] | Dirent[]>,
): void;
function readdir(
path: string,
options: { withFileTypes?: boolean; recursive?: boolean } | null,
callback: NodeCallback<string[] | Dirent[]>,
): void;
function readdir(path: string, ...args: unknown[]): void {
const options = before(args, 0, null) as {
withFileTypes?: boolean;
recursive?: boolean;
} | null;
const pending: Promise<string[] | Dirent[]> = options?.withFileTypes
? promises.readdir(path, { ...options, withFileTypes: true })
: promises.readdir(path, {
recursive: options?.recursive,
withFileTypes: false,
});
settle(pending, callbackOf(args));
}
function stat(path: string, callback: NodeCallback<Stats>): void;
function stat(path: string, ...args: unknown[]): void {
settle(promises.stat(path), callbackOf([...args]));
}
function lstat(path: string, callback: NodeCallback<Stats>): void;
function lstat(path: string, ...args: unknown[]): void {
settle(promises.lstat(path), callbackOf([...args]));
}
function mkdir(path: string, callback: NodeCallback<string | undefined>): void;
function mkdir(
path: string,
options: MkdirOptions | number | null,
callback: NodeCallback<string | undefined>,
): void;
function mkdir(path: string, ...args: unknown[]): void {
settle(
promises.mkdir(
path,
before(args, 0, undefined) as MkdirOptions | number | undefined,
),
callbackOf(args),
);
}
function rm(path: string, callback: NodeCallback<void>): void;
function rm(
path: string,
options: RmOptions | null,
callback: NodeCallback<void>,
): void;
function rm(path: string, ...args: unknown[]): void {
settle(
promises.rm(path, before(args, 0, undefined) as RmOptions | undefined),
callbackOf(args),
);
}
function rename(from: string, to: string, callback: NodeCallback<void>): void;
function rename(from: string, to: string, ...args: unknown[]): void {
settle(promises.rename(from, to), callbackOf([...args]));
}
function copyFile(from: string, to: string, callback: NodeCallback<void>): void;
function copyFile(
from: string,
to: string,
mode: number,
callback: NodeCallback<void>,
): void;
function copyFile(from: string, to: string, ...args: unknown[]): void {
settle(
promises.copyFile(
from,
to,
before(args, 0, undefined) as number | undefined,
),
callbackOf(args),
);
}
function cp(from: string, to: string, callback: NodeCallback<void>): void;
function cp(
from: string,
to: string,
options: { recursive?: boolean } | null,
callback: NodeCallback<void>,
): void;
function cp(from: string, to: string, ...args: unknown[]): void {
settle(
promises.cp(
from,
to,
before(args, 0, undefined) as { recursive?: boolean } | undefined,
),
callbackOf(args),
);
}
function access(path: string, callback: NodeCallback<void>): void;
function access(path: string, mode: number, callback: NodeCallback<void>): void;
function access(path: string, ...args: unknown[]): void {
settle(
promises.access(path, before(args, 0, undefined) as number | undefined),
callbackOf(args),
);
}
function realpath(path: string, callback: NodeCallback<string>): void;
function realpath(path: string, ...args: unknown[]): void {
settle(promises.realpath(path), callbackOf([...args]));
}
function readlink(path: string, callback: NodeCallback<string>): void;
function readlink(path: string, ...args: unknown[]): void {
settle(promises.readlink(path), callbackOf([...args]));
}
function symlink(
target: string,
path: string,
callback: NodeCallback<void>,
): void;
function symlink(
target: string,
path: string,
type: string,
callback: NodeCallback<void>,
): void;
function symlink(target: string, path: string, ...args: unknown[]): void {
settle(
promises.symlink(
target,
path,
before(args, 0, undefined) as 'file' | 'dir' | 'junction' | undefined,
),
callbackOf(args),
);
}
function truncate(path: string, callback: NodeCallback<void>): void;
function truncate(
path: string,
length: number,
callback: NodeCallback<void>,
): void;
function truncate(path: string, ...args: unknown[]): void {
settle(
promises.truncate(path, before(args, 0, undefined) as number | undefined),
callbackOf(args),
);
}
function utimes(
path: string,
atime: Date | number | string,
mtime: Date | number | string,
callback: NodeCallback<void>,
): void;
function utimes(
path: string,
atime: Date | number | string,
mtime: Date | number | string,
...args: unknown[]
): void {
settle(promises.utimes(path, atime, mtime), callbackOf([...args]));
}
function chmod(
path: string,
mode: number | string,
callback: NodeCallback<void>,
): void;
function chmod(path: string, mode: number | string, ...args: unknown[]): void {
settle(promises.chmod(path, mode), callbackOf([...args]));
}
function mkdtemp(prefix: string, callback: NodeCallback<string>): void;
function mkdtemp(
prefix: string,
options: Encoding | { encoding?: Encoding },
callback: NodeCallback<string>,
): void;
function mkdtemp(prefix: string, ...args: unknown[]): void {
settle(
promises.mkdtemp(
prefix,
before(args, 0, undefined) as
Encoding | { encoding?: Encoding } | undefined,
),
callbackOf(args),
);
}
function open(path: string, callback: NodeCallback<FileHandle>): void;
function open(
path: string,
flags: string | number | OpenOptions,
callback: NodeCallback<FileHandle>,
): void;
function open(
path: string,
flags: string | number | OpenOptions,
mode: number | string,
callback: NodeCallback<FileHandle>,
): void;
function open(path: string, ...args: unknown[]): void {
const flags = before(args, 0, undefined) as
string | number | OpenOptions | undefined;
const mode = before(args, 1, undefined) as number | string | undefined;
settle(
mode === undefined
? promises.open(path, flags)
: promises.open(path, flags, mode),
callbackOf(args),
);
}
function reveal(path: string, callback: NodeCallback<void>): void;
function reveal(path: string, ...args: unknown[]): void {
settle(promises.reveal(path), callbackOf([...args]));
}
function openExternal(path: string, callback: NodeCallback<void>): void;
function openExternal(path: string, ...args: unknown[]): void {
settle(promises.openExternal(path), callbackOf([...args]));
}
/* -------------------------------------------------------------------------- */
/* The `fs` object */
/* -------------------------------------------------------------------------- */
/**
* Node's `fs`, as far as this platform implements it.
*
* The callback forms wrap the promise implementations; the sync forms call the
* bridge's JS-thread methods directly; `promises` is the same object shape Node
* exposes as `fs.promises`.
*/
const fs = {
constants,
promises,
readFile,
writeFile,
appendFile,
readdir,
stat,
lstat,
mkdir,
rm,
rename,
copyFile,
cp,
access,
realpath,
readlink,
symlink,
truncate,
utimes,
chmod,
mkdtemp,
open,
reveal,
openExternal,
readFileSync,
writeFileSync,
appendFileSync,
existsSync,
statSync,
lstatSync,
readdirSync,
mkdirSync,
rmSync,
renameSync,
copyFileSync,
Stats,
Dirent,
FileHandle,
};
export default fs;
+771
View File
@@ -0,0 +1,771 @@
/**
* `fs.promises`: the Node-shaped asynchronous face of the module.
*
* Every call here is a thin translation. The native side does the work on its own
* thread and reports Node codes, so this layer's job is argument shapes, the
* encodings Node implements above the bytes, and the fields (`syscall`, `path`,
* `dest`) that only a wrapper can know.
*/
import NativeFs from './NativeFs';
import { constants } from './constants';
import {
checkBufferRange,
decodeString,
encodeString,
flagFrom,
modeFrom,
nodeError,
normalizeEncoding,
relativeEntryName,
toArrayBuffer,
toBytes,
toPath,
withContext,
type BinaryData,
type Encoding,
type NormalizedEncoding,
} from './shared';
import { Dirent, Stats, type DirEntryResult, type StatResult } from './stats';
/** An encoding, an options object, or nothing, as Node accepts for reads. */
export type ReadOptions =
Encoding | { encoding?: Encoding | null } | null | undefined;
export interface WriteOptions {
encoding?: Encoding | null;
mode?: number | string;
flag?: string | number;
}
export interface MkdirOptions {
recursive?: boolean;
mode?: number | string;
}
export interface RmOptions {
recursive?: boolean;
force?: boolean;
}
export interface ReaddirOptions {
withFileTypes?: boolean;
recursive?: boolean;
}
export interface CpOptions {
recursive?: boolean;
}
export interface OpenOptions {
flags?: string | number;
mode?: number | string;
}
/** The chunk size for a read that has no caller-supplied buffer. */
const READ_CHUNK = 64 * 1024;
/** A time as Node accepts one: `Date`, milliseconds, or a date string. */
export type TimeLike = Date | number | string;
function toMillis(value: TimeLike, name: string): number {
if (value instanceof Date) {
return value.getTime();
}
if (typeof value === 'number') {
return value;
}
const parsed = Date.parse(value);
if (Number.isNaN(parsed)) {
throw nodeError(
'ERR_INVALID_ARG_VALUE',
`The argument '${name}' is invalid time. Received '${value}'`,
);
}
return parsed;
}
function encodingOf(options: ReadOptions): NormalizedEncoding | null {
if (typeof options === 'string') {
return normalizeEncoding(options);
}
return normalizeEncoding(options?.encoding ?? null);
}
/**
* `readFile`, in whichever of the two shapes the caller asked for.
*
* UTF-8 goes through the dedicated text method: the native side decodes it while
* the bytes are still in Rust, so a text read never allocates an `ArrayBuffer`
* that JS then copies. Every other encoding is decoded here, because Node's
* `utf16le`, `latin1`, `base64` and `hex` are JS-level conventions rather than
* something `std::fs` has an opinion about.
*/
function readFile(path: string): Promise<Uint8Array>;
function readFile(
path: string,
options: { encoding?: null } | null | undefined,
): Promise<Uint8Array>;
function readFile(
path: string,
options: Encoding | { encoding: Encoding },
): Promise<string>;
async function readFile(
path: string,
options?: ReadOptions,
): Promise<Uint8Array | string> {
const target = toPath(path);
const encoding = encodingOf(options);
try {
if (encoding === 'utf8') {
return await NativeFs.readTextFile(target);
}
const buffer = await NativeFs.readFile(target);
const bytes = new Uint8Array(buffer);
return encoding === null ? bytes : decodeString(bytes, encoding);
} catch (error) {
throw withContext(error, 'open', target);
}
}
/**
* The same read, typed coarsely.
*
* `readFile`'s public overloads are what a caller sees; the callback layer calls
* through this alias because at that point the encoding is a runtime value and
* the result is genuinely either shape.
*/
export const readFileImpl: (
path: string,
options?: ReadOptions,
) => Promise<Uint8Array | string> = readFile;
/**
* `writeFile`.
*
* A string is written through `writeTextFile` when it is UTF-8; anything else —
* a different encoding, or binary data — is normalised to an `ArrayBuffer` and
* sent through the byte method.
*/
async function writeFile(
path: string,
data: BinaryData,
options?: WriteOptions | Encoding,
): Promise<void> {
const target = toPath(path);
const opts: WriteOptions =
typeof options === 'string' ? { encoding: options } : (options ?? {});
const flag = flagFrom(
opts.flag,
constants.O_WRONLY | constants.O_CREAT | constants.O_TRUNC,
);
const mode = modeFrom(opts.mode);
try {
if (typeof data === 'string') {
// `normalizeEncoding` answers `null` only when it is given nothing, and a
// default is supplied here, so the fallback to `utf8` is unreachable — it
// exists to keep the type honest instead of asserting it away.
const encoding = normalizeEncoding(opts.encoding ?? 'utf8') ?? 'utf8';
if (encoding === 'utf8') {
await NativeFs.writeTextFile(target, data, flag, mode);
} else {
await NativeFs.writeFile(
target,
toArrayBuffer(encodeString(data, encoding)),
flag,
mode,
);
}
return;
}
await NativeFs.writeFile(target, toArrayBuffer(data), flag, mode);
} catch (error) {
throw withContext(error, 'open', target);
}
}
async function appendFile(
path: string,
data: BinaryData,
options?: WriteOptions | Encoding,
): Promise<void> {
const opts: WriteOptions =
typeof options === 'string' ? { encoding: options } : (options ?? {});
return writeFile(path, data, {
...opts,
flag:
opts.flag ?? constants.O_WRONLY | constants.O_CREAT | constants.O_APPEND,
});
}
function readdir(
path: string,
options?: { withFileTypes?: false; recursive?: boolean } | null,
): Promise<string[]>;
function readdir(
path: string,
options: { withFileTypes: true; recursive?: boolean },
): Promise<Dirent[]>;
async function readdir(
path: string,
options?: ReaddirOptions | null,
): Promise<string[] | Dirent[]> {
const target = toPath(path);
try {
const recursive = options?.recursive ?? false;
const entries = await NativeFs.readdir(target, recursive);
return options?.withFileTypes
? entries.map(entry => new Dirent(entry as DirEntryResult))
: entries.map(entry =>
recursive
? relativeEntryName(target, entry.parentPath, entry.name)
: entry.name,
);
} catch (error) {
throw withContext(error, 'scandir', target);
}
}
async function stat(path: string): Promise<Stats> {
const target = toPath(path);
try {
return new Stats((await NativeFs.stat(target, true)) as StatResult);
} catch (error) {
throw withContext(error, 'stat', target);
}
}
async function lstat(path: string): Promise<Stats> {
const target = toPath(path);
try {
return new Stats((await NativeFs.stat(target, false)) as StatResult);
} catch (error) {
throw withContext(error, 'lstat', target);
}
}
async function mkdir(
path: string,
options?: MkdirOptions | number,
): Promise<string | undefined> {
const target = toPath(path);
const opts: MkdirOptions =
typeof options === 'number' ? { mode: options } : (options ?? {});
try {
await NativeFs.mkdir(
target,
opts.recursive ?? false,
modeFrom(opts.mode, 0o777),
);
return undefined;
} catch (error) {
throw withContext(error, 'mkdir', target);
}
}
async function rm(path: string, options?: RmOptions): Promise<void> {
const target = toPath(path);
try {
await NativeFs.rm(
target,
options?.recursive ?? false,
options?.force ?? false,
);
} catch (error) {
throw withContext(error, 'rm', target);
}
}
async function rename(from: string, to: string): Promise<void> {
const source = toPath(from);
const destination = toPath(to);
try {
await NativeFs.rename(source, destination);
} catch (error) {
// Node's rename error names both ends; the second is `dest`, not `path`.
const wrapped = withContext(error, 'rename', source);
if (wrapped.dest === undefined) {
wrapped.dest = destination;
}
throw wrapped;
}
}
async function copyFile(
from: string,
to: string,
mode?: number,
): Promise<void> {
const source = toPath(from);
const destination = toPath(to);
if (mode !== undefined && (mode & constants.COPYFILE_EXCL) !== 0) {
// The native copy has no exclusive flag, so the check is made here. It is
// advisory — a file created between the two calls still wins — but it gives
// the caller the error Node promises for the ordinary case.
try {
await NativeFs.stat(destination, false);
throw nodeError(
'EEXIST',
`EEXIST: file already exists, copyfile '${source}' -> '${destination}'`,
);
} catch (error) {
if ((error as { code?: string }).code !== 'ENOENT') {
throw error;
}
}
}
try {
await NativeFs.copy(source, destination, false);
} catch (error) {
const wrapped = withContext(error, 'copyfile', source);
if (wrapped.dest === undefined) {
wrapped.dest = destination;
}
throw wrapped;
}
}
async function cp(
from: string,
to: string,
options?: CpOptions,
): Promise<void> {
const source = toPath(from);
const destination = toPath(to);
try {
await NativeFs.copy(source, destination, options?.recursive ?? false);
} catch (error) {
const wrapped = withContext(error, 'cp', source);
if (wrapped.dest === undefined) {
wrapped.dest = destination;
}
throw wrapped;
}
}
async function access(path: string, mode?: number): Promise<void> {
const target = toPath(path);
try {
await NativeFs.access(target, mode ?? constants.F_OK);
} catch (error) {
throw withContext(error, 'access', target);
}
}
async function realpath(path: string): Promise<string> {
const target = toPath(path);
try {
return await NativeFs.realpath(target);
} catch (error) {
throw withContext(error, 'realpath', target);
}
}
async function readlink(path: string): Promise<string> {
const target = toPath(path);
try {
return await NativeFs.readlink(target);
} catch (error) {
throw withContext(error, 'readlink', target);
}
}
async function symlink(
target: string,
path: string,
type?: 'file' | 'dir' | 'junction',
): Promise<void> {
// `type` is passed through rather than dropped. It is the only way to ask for a
// directory link on Windows, where creating one as a file link leaves a path
// that cannot be traversed; Unix ignores it, as the syscall does.
const link = toPath(path);
try {
await NativeFs.symlink(toPath(target), link, type ?? null);
} catch (error) {
throw withContext(error, 'symlink', link);
}
}
async function truncate(path: string, length?: number): Promise<void> {
const target = toPath(path);
try {
await NativeFs.truncate(target, length ?? 0);
} catch (error) {
throw withContext(error, 'truncate', target);
}
}
async function utimes(
path: string,
atime: TimeLike,
mtime: TimeLike,
): Promise<void> {
const target = toPath(path);
try {
await NativeFs.utimes(
target,
toMillis(atime, 'atime'),
toMillis(mtime, 'mtime'),
);
} catch (error) {
throw withContext(error, 'utime', target);
}
}
async function chmod(path: string, mode: number | string): Promise<void> {
const target = toPath(path);
try {
await NativeFs.chmod(target, modeFrom(mode));
} catch (error) {
throw withContext(error, 'chmod', target);
}
}
async function mkdtemp(
prefix: string,
options?: Encoding | { encoding?: Encoding },
): Promise<string> {
// The native side creates the directory under the system temp directory and
// returns the path; the encoding only affects how a non-ASCII prefix arrives,
// and every string crosses the bridge as UTF-8.
options;
try {
return await NativeFs.mkdtemp(toPath(prefix));
} catch (error) {
throw withContext(error, 'mkdtemp', prefix);
}
}
/**
* A descriptor plus the operations Node puts on `FileHandle`.
*
* Positioned reads and writes do not touch the file's offset, which is what
* makes concurrent chunked I/O safe: two readers on one handle cannot move each
* other's cursor.
*/
export class FileHandle {
/** The native descriptor. */
readonly fd: number;
private closed = false;
constructor(fd: number) {
this.fd = fd;
}
private assertOpen(): void {
if (this.closed) {
throw nodeError('EBADF', `EBADF: bad file descriptor, ${this.fd}`);
}
}
/** Reads from the current offset when `position` is `-1`. */
async read(): Promise<{ bytesRead: number; buffer: Uint8Array }>;
async read(options: {
buffer?: Uint8Array;
offset?: number;
length?: number;
position?: number | null;
}): Promise<{ bytesRead: number; buffer: Uint8Array }>;
async read(
buffer: Uint8Array,
offset?: number,
length?: number,
position?: number | null,
): Promise<{ bytesRead: number; buffer: Uint8Array }>;
/**
* Node's positional form with the buffer implied: `read(size, offset, length,
* position)`. `offset` and `length` are a window into the freshly allocated
* buffer, and a missing `position` reads from the cursor.
*/
async read(
size: number,
offset?: number,
length?: number,
position?: number | null,
): Promise<{ bytesRead: number; buffer: Uint8Array }>;
async read(
first?:
| number
| Uint8Array
| {
buffer?: Uint8Array;
offset?: number;
length?: number;
position?: number | null;
},
second?: number,
third?: number,
fourth?: number | null,
): Promise<{ bytesRead: number; buffer: Uint8Array }> {
this.assertOpen();
let destination: Uint8Array;
let offset = 0;
let length: number;
let position: number;
// The two no-buffer forms mean "read to the end", which is a different
// operation from a single bounded read and is handled by `readToEnd`.
let toEnd = false;
if (first === undefined) {
toEnd = true;
destination = new Uint8Array(0);
length = 0;
position = -1;
} else if (typeof first === 'number') {
// A size instead of a buffer: allocate it and apply the same window
// arguments Node's buffer form takes.
destination = new Uint8Array(first);
offset = second ?? 0;
length = third ?? Math.max(destination.byteLength - offset, 0);
position = fourth ?? -1;
} else if (first instanceof Uint8Array) {
destination = first;
offset = second ?? 0;
length = third ?? destination.byteLength - offset;
position = fourth ?? -1;
} else {
destination = first.buffer ?? new Uint8Array(0);
offset = first.offset ?? 0;
length = first.length ?? Math.max(destination.byteLength - offset, 0);
position = first.position ?? -1;
toEnd = first.buffer === undefined;
}
try {
if (toEnd) {
const bytes = await this.readToEnd(position);
return { bytesRead: bytes.byteLength, buffer: bytes };
}
// Validated before the native call, not after. The native side reads
// `length` bytes and advances the cursor, so a window that overruns the
// buffer used to consume bytes and then drop them when the result was
// trimmed to fit. Node rejects the call instead, and so must this.
const window = checkBufferRange(destination, offset, length);
const buffer = await NativeFs.read(this.fd, window.length, position);
const bytes = new Uint8Array(buffer);
const bytesRead = Math.min(bytes.byteLength, window.length);
destination.set(bytes.subarray(0, bytesRead), window.offset);
return { bytesRead, buffer: destination };
} catch (error) {
throw withContext(error, 'read', undefined);
}
}
/**
* Reads until a short read, which is end of file.
*
* The native `read` returns at most what it was asked for, so "the rest of the
* file" has to be assembled: a single call with a huge length would ask Rust to
* allocate that much up front. A cursor read (`position` -1) advances the
* handle's offset, so the loop continues where the previous chunk ended.
*/
private async readToEnd(position: number): Promise<Uint8Array> {
const chunks: Uint8Array[] = [];
let total = 0;
let at = position;
for (;;) {
const buffer = await NativeFs.read(this.fd, READ_CHUNK, at);
const chunk = new Uint8Array(buffer);
if (chunk.byteLength === 0) {
break;
}
chunks.push(chunk);
total += chunk.byteLength;
if (chunk.byteLength < READ_CHUNK) {
break;
}
if (at >= 0) {
at += chunk.byteLength;
}
}
const all = new Uint8Array(total);
let cursor = 0;
for (const chunk of chunks) {
all.set(chunk, cursor);
cursor += chunk.byteLength;
}
return all;
}
async write(
data: BinaryData,
position?: number | null,
encoding?: Encoding,
): Promise<{ bytesWritten: number; buffer: Uint8Array }>;
async write(
buffer: Uint8Array,
offset: number,
length: number,
position?: number | null,
): Promise<{ bytesWritten: number; buffer: Uint8Array }>;
async write(
data: BinaryData,
second?: number | null,
third?: number | Encoding,
fourth?: number | null,
): Promise<{ bytesWritten: number; buffer: Uint8Array }> {
this.assertOpen();
let bytes: Uint8Array;
let position: number | null = null;
if (typeof data === 'string') {
// `write(string, position, encoding)`: here the number is a file position.
const encodingName =
typeof third === 'string' ? normalizeEncoding(third) : 'utf8';
bytes = encodeString(data, encodingName ?? 'utf8');
position = second ?? null;
} else if (
data instanceof Uint8Array &&
third !== undefined &&
typeof third !== 'number'
) {
// `write(buffer, { offset, length, position })`, which Node also accepts.
const options = third as unknown as {
offset?: number;
length?: number;
position?: number | null;
};
const window = checkBufferRange(data, options.offset, options.length);
bytes = data.subarray(window.offset, window.offset + window.length);
position = options.position ?? null;
} else if (
data instanceof Uint8Array &&
second !== undefined &&
second !== null
) {
// `write(buffer, offset[, length[, position]])` — the numeric argument is
// into the *buffer*, not into the file. Reading it as a file position
// wrote the whole buffer at that offset instead of a slice at the cursor.
const window = checkBufferRange(
data,
second,
typeof third === 'number' ? third : undefined,
);
bytes = data.subarray(window.offset, window.offset + window.length);
position = fourth ?? null;
} else {
bytes = toBytes(data);
position = typeof second === 'number' ? second : null;
}
try {
const bytesWritten = await NativeFs.write(
this.fd,
toArrayBuffer(bytes),
position ?? -1,
);
return { bytesWritten, buffer: bytes };
} catch (error) {
throw withContext(error, 'write', undefined);
}
}
async stat(): Promise<Stats> {
this.assertOpen();
try {
return new Stats((await NativeFs.fstat(this.fd)) as StatResult);
} catch (error) {
throw withContext(error, 'fstat', undefined);
}
}
async sync(): Promise<void> {
this.assertOpen();
try {
await NativeFs.fsync(this.fd);
} catch (error) {
throw withContext(error, 'fsync', undefined);
}
}
async truncate(length?: number): Promise<void> {
this.assertOpen();
try {
await NativeFs.ftruncate(this.fd, length ?? 0);
} catch (error) {
throw withContext(error, 'ftruncate', undefined);
}
}
async close(): Promise<void> {
if (this.closed) {
return;
}
this.closed = true;
try {
await NativeFs.close(this.fd);
} catch (error) {
throw withContext(error, 'close', undefined);
}
}
}
async function open(
path: string,
flags?: string | number | OpenOptions,
mode?: number | string,
): Promise<FileHandle> {
const target = toPath(path);
const opts: OpenOptions =
typeof flags === 'object' && flags !== null ? flags : { flags, mode };
const flag = flagFrom(opts.flags, constants.O_RDONLY);
try {
const fd = await NativeFs.open(target, flag, modeFrom(opts.mode));
return new FileHandle(fd);
} catch (error) {
throw withContext(error, 'open', target);
}
}
async function reveal(path: string): Promise<void> {
const target = toPath(path);
try {
await NativeFs.reveal(target);
} catch (error) {
throw withContext(error, 'reveal', target);
}
}
async function openExternal(path: string): Promise<void> {
const target = toPath(path);
try {
await NativeFs.openExternal(target);
} catch (error) {
throw withContext(error, 'openExternal', target);
}
}
/**
* The `fs/promises` object.
*
* The functions are declared separately and collected here so the callback layer
* in `index.ts` can wrap the same implementations rather than reimplementing
* them.
*/
export const promises = {
readFile,
writeFile,
appendFile,
readdir,
stat,
lstat,
mkdir,
rm,
rename,
copyFile,
cp,
access,
realpath,
readlink,
symlink,
truncate,
utimes,
chmod,
mkdtemp,
open,
reveal,
openExternal,
};
export type Promises = typeof promises;
/** Re-exported so `fs.stat` and `fs.promises.stat` share one class identity. */
export { Dirent, Stats };
export type { StatResult };
+539
View File
@@ -0,0 +1,539 @@
/**
* The pieces every face of the API needs: Node error objects, `file://` path
* conversion, the encodings Node supports, and the flag/mode translation.
*
* Nothing here touches the native module, so it stays usable from the sync,
* callback and promise layers without a cycle.
*/
/** Bytes in any of the shapes a caller may hand a writer. */
export type BinaryData = string | ArrayBuffer | ArrayBufferView;
/** The encodings this module implements, after aliasing. */
export type NormalizedEncoding =
'utf8' | 'utf16le' | 'latin1' | 'base64' | 'hex';
/** The encodings accepted in an options object, with Node's aliases. */
export type Encoding =
| 'utf8'
| 'utf-8'
| 'utf16le'
| 'utf-16le'
| 'ucs2'
| 'ucs-2'
| 'latin1'
| 'binary'
| 'ascii'
| 'base64'
| 'hex';
/** What a rejected call looks like: Node's `ErrnoException`. */
export interface NodeError extends Error {
code?: string;
errno?: number;
syscall?: string;
path?: string;
/** The second path of a two-path call, as Node names it on `rename` and `cp`. */
dest?: string;
}
/** Builds an error with a code but no OS call behind it. */
export function nodeError(code: string, message: string): NodeError {
const error = new Error(message) as NodeError;
error.code = code;
return error;
}
/** The error Node raises when a path is not there, in its message format. */
export function missingPath(path: string, syscall: string): NodeError {
return nodeError(
'ENOENT',
`ENOENT: no such file or directory, ${syscall} '${path}'`,
);
}
/**
* Adds the fields only the JS side knows to an error a native call rejected
* with.
*
* The Rust layer reports `code` and a Node-format message, but it deliberately
* does not guess `syscall` — one native `stat` serves `stat` and `lstat`, and it
* cannot know the spelling of the path the caller passed.
*/
export function withContext(
error: unknown,
syscall: string,
path?: string,
): NodeError {
const wrapped: NodeError =
error instanceof Error
? (error as NodeError)
: nodeError('EIO', String(error));
if (wrapped.syscall === undefined) {
wrapped.syscall = syscall;
}
if (path !== undefined && wrapped.path === undefined) {
wrapped.path = path;
}
return wrapped;
}
/**
* `file://` URLs to paths, leaving plain paths alone.
*
* Node accepts `URL` objects and `file:` strings interchangeably; this API takes
* strings, so the conversion is textual. Percent escapes are decoded because a
* path with a space arrives as `file:///a%20b`.
*/
export function toPath(path: string): string {
if (!path.startsWith('file://')) {
return path;
}
let rest = path.slice('file://'.length);
// An empty or `localhost` authority is the local machine.
if (rest.startsWith('localhost/')) {
rest = rest.slice('localhost'.length);
}
const decoded = decodeURIComponent(rest);
// `file:///C:/x` is a drive path on Windows, not a path under `/`.
if (/^\/[A-Za-z]:[\\/]/.test(decoded)) {
return decoded.slice(1);
}
return decoded;
}
/**
* Node's bounds check for a buffer window, as `RangeError` with
* `code = 'ERR_OUT_OF_RANGE'`.
*
* Both `read` and `write` take a window into a caller's buffer, and the native
* side reads or writes `length` bytes before the result is trimmed to fit — so an
* oversized `length` would consume bytes from the file and silently drop them
* rather than failing. Node rejects the call instead, and its wording is
* reproduced because callers match on it:
*
* The value of "length" is out of range. It must be <= 2. Received 8
*/
export function checkBufferRange(
buffer: Uint8Array,
offset: number | undefined,
length: number | undefined,
): { offset: number; length: number } {
const start = offset ?? 0;
if (!Number.isInteger(start) || start < 0 || start > buffer.byteLength) {
throw nodeError(
'ERR_OUT_OF_RANGE',
`The value of "offset" is out of range. It must be >= 0 && <= ${buffer.byteLength}. Received ${start}`,
);
}
const count = length ?? buffer.byteLength - start;
const available = buffer.byteLength - start;
if (!Number.isInteger(count) || count < 0 || count > available) {
throw nodeError(
'ERR_OUT_OF_RANGE',
`The value of "length" is out of range. It must be <= ${available}. Received ${count}`,
);
}
return { offset: start, length: count };
}
/**
* A recursive `readdir` entry as the path Node reports.
*
* Node's `readdir(dir, { recursive: true })` returns paths relative to `dir`
* — `nested/deep/leaf.txt`, not `leaf.txt` — so a caller can join them onto the
* root and get a real path. The native side reports each entry's absolute
* containing directory, so the relative name is what remains after removing the
* root. A non-recursive listing names the direct children only, which is already
* what `entry.name` holds, so this is used for the recursive case alone.
*
* The separator is taken from the paths themselves rather than from a platform
* check: the native side returns native separators, and a module that reaches
* for `process.platform` needs Node's types for a question the data already
* answers.
*/
export function relativeEntryName(
root: string,
parentPath: string,
name: string,
): string {
const separator = root.includes('\\') ? '\\' : '/';
const split = (value: string) => value.split(/[\\/]+/u).filter(Boolean);
const rootParts = split(root);
const parentParts = split(parentPath);
// Compare from the front. A parent that does not start with the root means the
// entry is somewhere the caller did not ask about, which should not happen, so
// the bare name is a safer answer than a fabricated path.
const inside = rootParts.every((part, index) => parentParts[index] === part);
if (!inside) {
return name;
}
return [...parentParts.slice(rootParts.length), name].join(separator);
}
/** The encoding name, folded to the set implemented here. */
export function normalizeEncoding(
encoding: string | null | undefined,
): NormalizedEncoding | null {
if (encoding === null || encoding === undefined) {
return null;
}
switch (encoding.toLowerCase()) {
case 'utf8':
case 'utf-8':
return 'utf8';
case 'utf16le':
case 'utf-16le':
case 'ucs2':
case 'ucs-2':
return 'utf16le';
case 'latin1':
case 'binary':
case 'ascii':
return 'latin1';
case 'base64':
return 'base64';
case 'hex':
return 'hex';
default:
throw nodeError(
'ERR_INVALID_ARG_VALUE',
`The argument 'encoding' is invalid encoding. Received '${encoding}'`,
);
}
}
const BASE64_ALPHABET =
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
const BASE64_VALUES: Record<string, number> = (() => {
const values: Record<string, number> = {};
for (let index = 0; index < BASE64_ALPHABET.length; index += 1) {
values[BASE64_ALPHABET[index]] = index;
}
// The URL-safe alphabet is accepted too: `Buffer` decodes either.
values['-'] = 62;
values._ = 63;
return values;
})();
function encodeBase64(bytes: Uint8Array): string {
let out = '';
for (let index = 0; index < bytes.byteLength; index += 3) {
const first = bytes[index];
const second = index + 1 < bytes.byteLength ? bytes[index + 1] : 0;
const third = index + 2 < bytes.byteLength ? bytes[index + 2] : 0;
const triple = (first << 16) | (second << 8) | third;
out += BASE64_ALPHABET[(triple >> 18) & 0x3f];
out += BASE64_ALPHABET[(triple >> 12) & 0x3f];
out +=
index + 1 < bytes.byteLength
? BASE64_ALPHABET[(triple >> 6) & 0x3f]
: '=';
out += index + 2 < bytes.byteLength ? BASE64_ALPHABET[triple & 0x3f] : '=';
}
return out;
}
function decodeBase64(text: string): Uint8Array {
const bytes: number[] = [];
let pending = 0;
let bits = 0;
for (const character of text) {
if (
character === '=' ||
character === '\n' ||
character === '\r' ||
character === ' '
) {
continue;
}
const value = BASE64_VALUES[character];
if (value === undefined) {
// Node stops at the first character that is not base64 rather than
// failing, so `Buffer.from('abc$', 'base64')` decodes the prefix.
break;
}
pending = (pending << 6) | value;
bits += 6;
if (bits >= 8) {
bits -= 8;
bytes.push((pending >> bits) & 0xff);
}
}
return Uint8Array.from(bytes);
}
function encodeHex(bytes: Uint8Array): string {
let out = '';
for (let index = 0; index < bytes.byteLength; index += 1) {
out += bytes[index].toString(16).padStart(2, '0');
}
return out;
}
function decodeHex(text: string): Uint8Array {
const bytes: number[] = [];
for (let index = 0; index + 1 < text.length; index += 2) {
const value = Number.parseInt(text.slice(index, index + 2), 16);
if (Number.isNaN(value)) {
break;
}
bytes.push(value);
}
return Uint8Array.from(bytes);
}
function encodeUtf8(text: string): Uint8Array {
const bytes: number[] = [];
for (let index = 0; index < text.length; index += 1) {
let code = text.charCodeAt(index);
// A surrogate pair is one code point; without this an emoji becomes two
// sequences of replacement characters.
if (code >= 0xd800 && code <= 0xdbff && index + 1 < text.length) {
const next = text.charCodeAt(index + 1);
if (next >= 0xdc00 && next <= 0xdfff) {
code = 0x10000 + ((code - 0xd800) << 10) + (next - 0xdc00);
index += 1;
}
}
if (code < 0x80) {
bytes.push(code);
} else if (code < 0x800) {
bytes.push(0xc0 | (code >> 6), 0x80 | (code & 0x3f));
} else if (code < 0x10000) {
bytes.push(
0xe0 | (code >> 12),
0x80 | ((code >> 6) & 0x3f),
0x80 | (code & 0x3f),
);
} else {
bytes.push(
0xf0 | (code >> 18),
0x80 | ((code >> 12) & 0x3f),
0x80 | ((code >> 6) & 0x3f),
0x80 | (code & 0x3f),
);
}
}
return Uint8Array.from(bytes);
}
function decodeUtf8(bytes: Uint8Array): string {
let out = '';
let index = 0;
while (index < bytes.byteLength) {
const first = bytes[index];
const width =
first < 0x80
? 1
: first >= 0xf0
? 4
: first >= 0xe0
? 3
: first >= 0xc0
? 2
: 0;
if (width === 0 || index + width > bytes.byteLength) {
// Node decodes malformed input with U+FFFD instead of throwing, so a
// file that is not valid UTF-8 still reads as text.
out += '\ufffd';
index += 1;
continue;
}
let code = width === 1 ? first : first & (0xff >> (width + 1));
let valid = true;
for (let offset = 1; offset < width; offset += 1) {
const continuation = bytes[index + offset];
if ((continuation & 0xc0) !== 0x80) {
valid = false;
break;
}
code = (code << 6) | (continuation & 0x3f);
}
if (!valid) {
out += '\ufffd';
index += 1;
continue;
}
out += String.fromCodePoint(code);
index += width;
}
return out;
}
function encodeUtf16le(text: string): Uint8Array {
const bytes = new Uint8Array(text.length * 2);
for (let index = 0; index < text.length; index += 1) {
const code = text.charCodeAt(index);
bytes[index * 2] = code & 0xff;
bytes[index * 2 + 1] = code >> 8;
}
return bytes;
}
function decodeUtf16le(bytes: Uint8Array): string {
const codes: number[] = [];
for (let index = 0; index + 1 < bytes.byteLength; index += 2) {
codes.push(bytes[index] | (bytes[index + 1] << 8));
}
return chunkedFromCharCode(codes);
}
function chunkedFromCharCode(codes: number[]): string {
let out = '';
// `apply` has an argument-count limit, so long strings are built in pieces.
for (let index = 0; index < codes.length; index += 8192) {
out += String.fromCharCode.apply(null, codes.slice(index, index + 8192));
}
return out;
}
/** Text to bytes for the encodings implemented in JS. */
export function encodeString(
text: string,
encoding: NormalizedEncoding,
): Uint8Array {
switch (encoding) {
case 'utf8':
return encodeUtf8(text);
case 'utf16le':
return encodeUtf16le(text);
case 'latin1': {
const bytes = new Uint8Array(text.length);
for (let index = 0; index < text.length; index += 1) {
bytes[index] = text.charCodeAt(index) & 0xff;
}
return bytes;
}
case 'base64':
return decodeBase64(text);
case 'hex':
return decodeHex(text);
}
}
/** Bytes to text for the encodings implemented in JS. */
export function decodeString(
bytes: Uint8Array,
encoding: NormalizedEncoding,
): string {
switch (encoding) {
case 'utf8':
return decodeUtf8(bytes);
case 'utf16le':
return decodeUtf16le(bytes);
case 'latin1': {
const codes: number[] = [];
for (let index = 0; index < bytes.byteLength; index += 1) {
codes.push(bytes[index]);
}
return chunkedFromCharCode(codes);
}
case 'base64':
return encodeBase64(bytes);
case 'hex':
return encodeHex(bytes);
}
}
/**
* Normalises any accepted data shape to an `ArrayBuffer` the bridge can send.
*
* A `Uint8Array` may be a window onto a larger allocation; slicing to the view's
* own range is what stops a writer from sending bytes that belong to something
* else in the same buffer.
*/
export function toArrayBuffer(
data: BinaryData,
encoding: NormalizedEncoding = 'utf8',
): ArrayBuffer {
if (typeof data === 'string') {
const bytes = encodeString(data, encoding);
return bytes.buffer.slice(
bytes.byteOffset,
bytes.byteOffset + bytes.byteLength,
) as ArrayBuffer;
}
if (data instanceof ArrayBuffer) {
return data;
}
const view = data as ArrayBufferView;
const buffer = view.buffer as ArrayBuffer;
if (view.byteOffset === 0 && view.byteLength === buffer.byteLength) {
return buffer;
}
return buffer.slice(view.byteOffset, view.byteOffset + view.byteLength);
}
/** A byte view of `toArrayBuffer`'s result, without copying. */
export function toBytes(
data: BinaryData,
encoding: NormalizedEncoding = 'utf8',
): Uint8Array {
return new Uint8Array(toArrayBuffer(data, encoding));
}
/** Node's numeric `open` flags, by their string spelling. */
const FLAG_STRINGS: Record<string, number> = {
r: 0,
'r+': 2,
rs: 0,
sr: 0,
w: 1 | 64 | 512,
wx: 1 | 64 | 512 | 128,
xw: 1 | 64 | 512 | 128,
'w+': 2 | 64 | 512,
'wx+': 2 | 64 | 512 | 128,
'xw+': 2 | 64 | 512 | 128,
a: 1 | 64 | 1024,
ax: 1 | 64 | 1024 | 128,
xa: 1 | 64 | 1024 | 128,
'a+': 2 | 64 | 1024,
'ax+': 2 | 64 | 1024 | 128,
'xa+': 2 | 64 | 1024 | 128,
as: 1 | 64 | 1024,
sa: 1 | 64 | 1024,
};
/** A `flag` option as the number the native side decodes. */
export function flagFrom(
flag: string | number | undefined,
fallback: number,
): number {
if (flag === undefined) {
return fallback;
}
if (typeof flag === 'number') {
return flag;
}
const value = FLAG_STRINGS[flag];
if (value === undefined) {
throw nodeError(
'ERR_INVALID_ARG_VALUE',
`The argument 'flag' is invalid. Received '${flag}'`,
);
}
return value;
}
/** A `mode` option as a number. A string is octal, as in Node. */
export function modeFrom(
mode: number | string | undefined,
fallback = 0o666,
): number {
if (mode === undefined) {
return fallback;
}
if (typeof mode === 'number') {
return mode;
}
const parsed = Number.parseInt(mode, 8);
if (Number.isNaN(parsed)) {
throw nodeError(
'ERR_INVALID_ARG_VALUE',
`The argument 'mode' is invalid. Received '${mode}'`,
);
}
return parsed;
}
+196
View File
@@ -0,0 +1,196 @@
/**
* `Stats` and `Dirent`, built over the plain objects the native module returns.
*
* The module crosses the bridge with numbers rather than methods — a generated
* TurboModule result cannot carry predicates — so the classes live here and
* reproduce the shape Node's callers test for. The `kind` field is the coarse
* classification the native side can make without a platform syscall; `mode` is
* there for the finer predicates, and is zero on Windows where there are no
* Unix mode bits at all.
*/
import { constants } from './constants';
/** The native `FileKind` numbers; must match `module::kind` in Rust. */
export const FILE_KIND = {
file: 0,
directory: 1,
symlink: 2,
other: 3,
} as const;
/** The shape `stat`, `lstat` and `fstat` return across the bridge. */
export interface StatResult {
size: number;
mode: number;
kind: number;
atimeMs: number;
mtimeMs: number;
ctimeMs: number;
birthtimeMs: number;
dev: number;
ino: number;
nlink: number;
uid: number;
gid: number;
}
/** The shape `readdir` returns across the bridge. */
export interface DirEntryResult {
name: string;
kind: number;
parentPath: string;
}
/** The type bits of a mode, or 0 when the platform does not report them. */
function fileTypeBits(mode: number): number {
return mode & constants.S_IFMT;
}
/**
* Node's `fs.Stats`.
*
* The fields are plain properties rather than getters: they are copied once from
* the native result, and a caller destructuring the object sees the same values
* Node's does.
*/
export class Stats {
readonly dev: number;
readonly ino: number;
readonly mode: number;
readonly nlink: number;
readonly uid: number;
readonly gid: number;
readonly rdev: number;
readonly size: number;
readonly blksize: number;
readonly blocks: number;
readonly atimeMs: number;
readonly mtimeMs: number;
readonly ctimeMs: number;
readonly birthtimeMs: number;
readonly atime: Date;
readonly mtime: Date;
readonly ctime: Date;
readonly birthtime: Date;
/** The native classification, kept because it answers predicates the mode
* cannot on platforms that report no type bits. */
private readonly kind: number;
constructor(result: StatResult) {
this.dev = result.dev;
this.ino = result.ino;
this.mode = result.mode;
this.nlink = result.nlink;
this.uid = result.uid;
this.gid = result.gid;
// The native result has no `rdev`, block size or block count; Node reports
// zero for them where the platform cannot say, so zero is the honest value.
this.rdev = 0;
this.size = result.size;
this.blksize = 0;
this.blocks = 0;
this.atimeMs = result.atimeMs;
this.mtimeMs = result.mtimeMs;
this.ctimeMs = result.ctimeMs;
this.birthtimeMs = result.birthtimeMs;
this.atime = new Date(result.atimeMs);
this.mtime = new Date(result.mtimeMs);
this.ctime = new Date(result.ctimeMs);
this.birthtime = new Date(result.birthtimeMs);
this.kind = result.kind;
}
isFile(): boolean {
const bits = fileTypeBits(this.mode);
return bits === 0
? this.kind === FILE_KIND.file
: bits === constants.S_IFREG;
}
isDirectory(): boolean {
const bits = fileTypeBits(this.mode);
return bits === 0
? this.kind === FILE_KIND.directory
: bits === constants.S_IFDIR;
}
isSymbolicLink(): boolean {
const bits = fileTypeBits(this.mode);
return bits === 0
? this.kind === FILE_KIND.symlink
: bits === constants.S_IFLNK;
}
isBlockDevice(): boolean {
return fileTypeBits(this.mode) === constants.S_IFBLK;
}
isCharacterDevice(): boolean {
return fileTypeBits(this.mode) === constants.S_IFCHR;
}
isFIFO(): boolean {
return fileTypeBits(this.mode) === constants.S_IFIFO;
}
isSocket(): boolean {
return fileTypeBits(this.mode) === constants.S_IFSOCK;
}
}
/**
* Node's `fs.Dirent`.
*
* `parentPath` is `''` when the caller already held the parent, matching what
* `readdir` with `{withFileTypes: true}` produces there.
*/
export class Dirent {
readonly name: string;
readonly parentPath: string;
readonly path: string;
private readonly kind: number;
constructor(result: DirEntryResult) {
this.name = result.name;
this.parentPath = result.parentPath;
// `path` is the older name for the same field, and libraries still read it.
this.path = result.parentPath;
this.kind = result.kind;
}
isFile(): boolean {
return this.kind === FILE_KIND.file;
}
isDirectory(): boolean {
return this.kind === FILE_KIND.directory;
}
isSymbolicLink(): boolean {
return this.kind === FILE_KIND.symlink;
}
isBlockDevice(): boolean {
return false;
}
isCharacterDevice(): boolean {
return false;
}
isFIFO(): boolean {
return false;
}
isSocket(): boolean {
return false;
}
}
+141
View File
@@ -0,0 +1,141 @@
/**
* `watch`: the JS half of the typed watcher event.
*
* The native module returns a watcher id; every event it produces carries that
* id, so one shared emitter serves every open watcher and each listener keeps
* only its own. The subscription is taken through the generated typed emitter on
* the module (`NativeFs.onWatchEvent`), which is the same mechanism the fixture's
* `onProgress` uses in `RustTurboModuleFixture.tsx`, rather than through
* `NativeEventEmitter`: the spec declares `onWatchEvent` as a typed
* `CodegenTypes.EventEmitter`, and the bridge installs a listener for exactly
* that; `NativeEventEmitter` would subscribe on the global device-emitter channel
* that only `sendEvent` writes to, so it would never fire.
*/
import NativeFs, { type WatchEvent } from './NativeFs';
import { toPath, withContext, type NodeError } from './shared';
/** The event names Node uses for the two things a watcher reports. */
export type WatchEventType = 'rename' | 'change';
export interface WatchOptions {
recursive?: boolean;
encoding?: string;
}
export interface FSWatcher {
close(): Promise<void>;
}
export type WatchListener = (
eventType: WatchEventType,
filename: string | null,
) => void;
/**
* Which of Node's two event types a native kind is.
*
* Node folds everything it sees into `rename` (the entry appeared, vanished or
* moved) and `change` (its contents or metadata changed). The caller's callback
* signature is Node's, so the mapping happens here while the native kind stays
* available to anyone who subscribes to the module directly.
*/
function eventTypeOf(kind: string): WatchEventType {
return kind === 'modify' ? 'change' : 'rename';
}
/** The file a path names, relative to the watched root where it is below it. */
/**
* The filename a listener is given.
*
* The native side already reported it relative to the watch root, and its own
* name for a single-file watch: it canonicalises the root, which is where macOS
* decides between `/var` and `/private/var`, and it strips whichever spelling the
* event actually carries. Stripping a prefix here as well would be a second,
* weaker guess at the same question — a relative root never matches, and Windows
* separates with `\` — so the event path is passed through unchanged.
*/
function fileNameOf(eventPath: string): string {
return eventPath;
}
/**
* Starts a watcher.
*
* The id arrives asynchronously, but the caller expects a handle it can close
* immediately, so the handle is created first and closing it before the id is
* known sets a flag the resolution honours. That is the one ordering hazard in
* this API, and it is invisible from the outside.
*
* Node reports a failure to start through an `error` event on the watcher. This
* handle has no event surface, so the failure is held and rejected from
* `close()` instead: it can then never become an unhandled rejection, and a
* caller that closes what it opened is guaranteed to see it.
*/
export function watch(path: string, listener: WatchListener): FSWatcher;
export function watch(
path: string,
options: WatchOptions | null,
listener: WatchListener,
): FSWatcher;
export function watch(
path: string,
optionsOrListener: WatchOptions | null | WatchListener,
maybeListener?: WatchListener,
): FSWatcher {
const options =
typeof optionsOrListener === 'function' ? {} : (optionsOrListener ?? {});
const listener =
typeof optionsOrListener === 'function' ? optionsOrListener : maybeListener;
if (typeof listener !== 'function') {
throw new TypeError('watch requires a listener function');
}
const target = toPath(path);
let id: number | null = null;
let closed = false;
let failure: NodeError | null = null;
let subscription: { remove(): void } | null = null;
const ready = NativeFs.watch(target, options.recursive ?? false)
.then(watcherId => {
id = watcherId;
if (closed) {
// `close()` ran while the id was in flight; stop the native watcher now
// that there is something to stop.
NativeFs.unwatch(watcherId);
return;
}
subscription = NativeFs.onWatchEvent((event: WatchEvent) => {
if (event.id !== id) {
return;
}
// The encoding option only affects how a non-ASCII name is decoded, and
// every name already arrives as a JS string.
listener(eventTypeOf(event.kind), fileNameOf(event.path));
});
})
.catch((error: unknown) => {
failure = withContext(error, 'watch', target);
});
return {
async close(): Promise<void> {
closed = true;
subscription?.remove();
subscription = null;
if (id !== null) {
NativeFs.unwatch(id);
id = null;
}
// Awaiting the start makes `close()` immediately after `watch()` still stop
// the native watcher rather than racing it.
await ready;
if (failure !== null) {
const error = failure;
failure = null;
throw error;
}
},
};
}
@@ -0,0 +1,19 @@
if(NOT RN_GPUI_PROJECT_ROOT)
message(FATAL_ERROR
"@react-native-gpui/gesture-handler was added by the platform; RN_GPUI_PROJECT_ROOT must be set by its build.rs")
endif()
rn_gpui_find_node_module(react-native-gesture-handler _gesture_root)
if(NOT EXISTS "${_gesture_root}/src/specs/NativeRNGestureHandlerModule.ts")
message(FATAL_ERROR "react-native-gesture-handler 3.3.x was not found at ${_gesture_root}")
endif()
file(READ "${_gesture_root}/package.json" _gesture_package_json)
string(JSON _gesture_version GET "${_gesture_package_json}" version)
if(NOT _gesture_version MATCHES "^3\\.3\\.")
message(FATAL_ERROR "@react-native-gpui/gesture-handler supports react-native-gesture-handler 3.3.x, found ${_gesture_version}")
endif()
set_property(GLOBAL APPEND PROPERTY RN_GPUI_APP_CPP_SOURCES
"${CMAKE_CURRENT_LIST_DIR}/cpp/GPUIGestureHandlerModule.cpp")
set_property(GLOBAL APPEND PROPERTY RN_GPUI_APP_CPP_INCLUDE_DIRS
"${RN_GPUI_SYS_CPP_DIR}")
@@ -0,0 +1,500 @@
#include "GpuiHost.h"
#include "GpuiModules.h"
#include <GPUIGestureHandlerSpec.h>
#include <ReactCommon/TurboModule.h>
#include <folly/dynamic.h>
#include <folly/json.h>
#include <glog/logging.h>
#include <algorithm>
#include <cmath>
#include <cstdlib>
#include <memory>
#include <mutex>
#include <string>
#include <unordered_map>
#include <utility>
#include <vector>
namespace {
using facebook::jsi::Runtime;
using facebook::react::TurboModule;
using rngpui::extension::RawPointerEvent;
using rngpui::extension::RecognizedGesture;
constexpr int32_t kUndetermined = 0;
constexpr int32_t kFailed = 1;
constexpr int32_t kBegan = 2;
constexpr int32_t kCancelled = 3;
constexpr int32_t kActive = 4;
constexpr int32_t kEnd = 5;
struct Handler {
std::string name;
folly::dynamic config{folly::dynamic::object};
int32_t viewTag{-1};
int32_t actionType{0};
int32_t state{kUndetermined};
float startX{0};
float startY{0};
float lastX{0};
float lastY{0};
double startTime{0};
double lastTime{0};
bool recognized{false};
std::vector<int32_t> simultaneousHandlers;
std::vector<int32_t> waitFor;
std::vector<int32_t> blocksHandlers;
};
std::mutex handlersMutex;
std::unordered_map<int32_t, Handler> handlers;
folly::dynamic configObject(folly::dynamic config) {
return config.isObject() ? std::move(config) : folly::dynamic::object();
}
float number(const folly::dynamic& object, const char* key, float fallback) {
if (!object.isObject() || !object.get_ptr(key))
return fallback;
const auto& value = object[key];
if (value.isNumber())
return static_cast<float>(value.asDouble());
return fallback;
}
bool enabled(const Handler& handler) {
const auto* value = handler.config.get_ptr("enabled");
return value == nullptr || !value->isBool() || value->asBool();
}
bool isPan(const Handler& handler) {
return handler.name == "Pan" || handler.name == "PanGestureHandler";
}
bool isTap(const Handler& handler) {
return handler.name == "Tap" || handler.name == "TapGestureHandler";
}
bool isLongPress(const Handler& handler) {
return handler.name == "LongPress" ||
handler.name == "LongPressGestureHandler";
}
bool gestureTracingEnabled() {
return std::getenv("RN_GPUI_DEBUG_GESTURES") != nullptr;
}
folly::dynamic payloadFor(
int32_t handlerTag,
const Handler& handler,
int32_t state,
int32_t oldState,
const RawPointerEvent& pointer,
bool stateChange) {
const float dx = pointer.x - handler.startX;
const float dy = pointer.y - handler.startY;
const double elapsed = std::max(1.0, pointer.timestamp - handler.lastTime);
folly::dynamic payload = folly::dynamic::object("handlerTag", handlerTag)(
"state", state)("x", pointer.x - handler.startX)(
"y", pointer.y - handler.startY)("absoluteX", pointer.x)(
"absoluteY", pointer.y)("translationX", dx)("translationY", dy)(
"velocityX", (pointer.x - handler.lastX) * 1000.0 / elapsed)(
"velocityY", (pointer.y - handler.lastY) * 1000.0 / elapsed)(
"numberOfPointers", 1)("pointerType", 2)(
"duration", pointer.timestamp - handler.startTime);
if (stateChange) {
payload["oldState"] = oldState;
}
return payload;
}
void appendEvent(
std::vector<RecognizedGesture>& events,
int32_t viewTag,
const char* type,
const folly::dynamic& payload) {
events.push_back({viewTag, type, folly::toJson(payload)});
}
void appendState(
std::vector<RecognizedGesture>& events,
int32_t handlerTag,
Handler& handler,
int32_t nextState,
const RawPointerEvent& pointer) {
if (handler.state == nextState)
return;
const auto oldState = handler.state;
handler.state = nextState;
if (nextState == kActive) {
handler.recognized = true;
}
appendEvent(
events,
handler.viewTag,
"topGestureHandlerStateChange",
payloadFor(handlerTag, handler, nextState, oldState, pointer, true));
}
std::vector<int32_t> relationTags(
const folly::dynamic& relations,
const char* key) {
std::vector<int32_t> tags;
const auto* values = relations.get_ptr(key);
if (values == nullptr || !values->isArray()) {
return tags;
}
tags.reserve(values->size());
for (const auto& value : *values) {
if (value.isInt()) {
tags.push_back(static_cast<int32_t>(value.asInt()));
}
}
return tags;
}
bool hasTag(const std::vector<int32_t>& tags, int32_t tag) {
return std::find(tags.begin(), tags.end(), tag) != tags.end();
}
bool hasRecognizedWaitFor(const Handler& handler) {
for (const auto tag : handler.waitFor) {
const auto dependency = handlers.find(tag);
if (dependency != handlers.end() && dependency->second.recognized) {
return true;
}
}
return false;
}
bool hasUnresolvedWaitFor(const Handler& handler) {
for (const auto tag : handler.waitFor) {
const auto dependency = handlers.find(tag);
if (dependency != handlers.end() && dependency->second.state == kBegan &&
!dependency->second.recognized) {
return true;
}
}
return false;
}
bool blockedByActiveHandler(int32_t handlerTag) {
for (const auto& [otherTag, other] : handlers) {
if (otherTag != handlerTag && other.state == kActive &&
hasTag(other.blocksHandlers, handlerTag)) {
return true;
}
}
return false;
}
std::vector<RecognizedGesture> processPointer(const RawPointerEvent& pointer) {
std::vector<RecognizedGesture> events;
std::lock_guard<std::mutex> lock(handlersMutex);
if (gestureTracingEnabled() && pointer.phase != 4) {
LOG(INFO) << "[gesture-handler] pointer phase=" << pointer.phase << " at ("
<< pointer.x << ',' << pointer.y
<< ") chain=" << pointer.targetChain.size() << " tags";
}
if (pointer.phase == 0) {
for (auto& [_, handler] : handlers) {
if (!enabled(handler) || handler.viewTag < 0 ||
std::find(
pointer.targetChain.begin(),
pointer.targetChain.end(),
handler.viewTag) == pointer.targetChain.end()) {
continue;
}
handler.state = kUndetermined;
handler.startX = pointer.x;
handler.startY = pointer.y;
handler.lastX = pointer.x;
handler.lastY = pointer.y;
handler.startTime = pointer.timestamp;
handler.lastTime = pointer.timestamp;
handler.recognized = false;
appendState(events, _, handler, kBegan, pointer);
if (gestureTracingEnabled()) {
LOG(INFO) << "[gesture-handler] began handler=" << _
<< " name=" << handler.name << " view=" << handler.viewTag;
}
}
return events;
}
if (pointer.phase == 2) {
// Resolve recognizers that cannot succeed on release before testing tap
// handlers that may be waiting for them to fail.
for (auto& [handlerTag, handler] : handlers) {
if (handler.state != kBegan || isTap(handler)) {
continue;
}
const float dx = pointer.x - handler.startX;
const float dy = pointer.y - handler.startY;
if (isLongPress(handler)) {
const auto elapsed = pointer.timestamp - handler.startTime;
const auto minDuration =
number(handler.config, "minDurationMs", 500.0f);
if (elapsed >= minDuration &&
std::hypot(dx, dy) <= number(handler.config, "maxDist", 10.0f) &&
!hasRecognizedWaitFor(handler) &&
!blockedByActiveHandler(handlerTag)) {
appendState(events, handlerTag, handler, kActive, pointer);
} else {
appendState(events, handlerTag, handler, kFailed, pointer);
}
} else {
appendState(events, handlerTag, handler, kFailed, pointer);
}
}
}
for (auto& [handlerTag, handler] : handlers) {
if (handler.state != kBegan && handler.state != kActive)
continue;
if (pointer.phase == 4) {
const float dx = pointer.x - handler.startX;
const float dy = pointer.y - handler.startY;
const auto elapsed = pointer.timestamp - handler.startTime;
const auto minDuration = number(handler.config, "minDurationMs", 500.0f);
if (gestureTracingEnabled() && handler.state == kBegan &&
isLongPress(handler) && elapsed >= minDuration) {
LOG(INFO) << "[gesture-handler] long-press threshold handler="
<< handlerTag << " elapsed=" << elapsed
<< " required=" << minDuration
<< " distance=" << std::hypot(dx, dy);
}
if (handler.state == kBegan && isLongPress(handler) &&
elapsed >= minDuration &&
std::hypot(dx, dy) <= number(handler.config, "maxDist", 10.0f)) {
if (hasRecognizedWaitFor(handler) ||
blockedByActiveHandler(handlerTag)) {
appendState(events, handlerTag, handler, kFailed, pointer);
} else if (!hasUnresolvedWaitFor(handler)) {
appendState(events, handlerTag, handler, kActive, pointer);
}
}
} else if (pointer.phase == 1) {
const float dx = pointer.x - handler.startX;
const float dy = pointer.y - handler.startY;
if (handler.state == kBegan && isPan(handler) &&
std::hypot(dx, dy) >= number(handler.config, "minDist", 3.0f)) {
if (hasRecognizedWaitFor(handler) ||
blockedByActiveHandler(handlerTag)) {
appendState(events, handlerTag, handler, kFailed, pointer);
} else if (!hasUnresolvedWaitFor(handler)) {
appendState(events, handlerTag, handler, kActive, pointer);
}
} else if (
handler.state == kBegan && isLongPress(handler) &&
pointer.timestamp - handler.startTime >=
number(handler.config, "minDurationMs", 500.0f) &&
std::hypot(dx, dy) <= number(handler.config, "maxDist", 10.0f)) {
if (hasRecognizedWaitFor(handler) ||
blockedByActiveHandler(handlerTag)) {
appendState(events, handlerTag, handler, kFailed, pointer);
} else if (!hasUnresolvedWaitFor(handler)) {
appendState(events, handlerTag, handler, kActive, pointer);
}
}
if (handler.state == kActive &&
(isPan(handler) || isLongPress(handler))) {
appendEvent(
events,
handler.viewTag,
"topGestureHandlerEvent",
payloadFor(
handlerTag,
handler,
handler.state,
kUndetermined,
pointer,
false));
}
} else if (pointer.phase == 2) {
const float dx = pointer.x - handler.startX;
const float dy = pointer.y - handler.startY;
if (isTap(handler) && handler.state == kBegan &&
pointer.timestamp - handler.startTime <=
number(handler.config, "maxDurationMs", 500.0f) &&
std::hypot(dx, dy) <= number(handler.config, "maxDist", 10.0f)) {
if (hasRecognizedWaitFor(handler) ||
blockedByActiveHandler(handlerTag)) {
appendState(events, handlerTag, handler, kFailed, pointer);
} else if (!hasUnresolvedWaitFor(handler)) {
appendState(events, handlerTag, handler, kActive, pointer);
} else {
appendState(events, handlerTag, handler, kFailed, pointer);
}
} else if (
isLongPress(handler) && handler.state == kBegan &&
pointer.timestamp - handler.startTime >=
number(handler.config, "minDurationMs", 500.0f) &&
std::hypot(dx, dy) <= number(handler.config, "maxDist", 10.0f)) {
appendState(events, handlerTag, handler, kActive, pointer);
} else if (handler.state == kBegan) {
appendState(events, handlerTag, handler, kFailed, pointer);
}
if (handler.state == kActive) {
if (isTap(handler) || isLongPress(handler)) {
appendEvent(
events,
handler.viewTag,
"topGestureHandlerEvent",
payloadFor(
handlerTag,
handler,
handler.state,
kUndetermined,
pointer,
false));
}
appendState(events, handlerTag, handler, kEnd, pointer);
}
handler.lastX = pointer.x;
handler.lastY = pointer.y;
handler.lastTime = pointer.timestamp;
} else if (pointer.phase == 3) {
appendState(
events,
handlerTag,
handler,
handler.state == kActive ? kCancelled : kFailed,
pointer);
}
}
if (gestureTracingEnabled() && (pointer.phase != 4 || !events.empty())) {
LOG(INFO) << "[gesture-handler] recognized events=" << events.size();
}
return events;
}
class GPUIGestureHandlerModule final
: public facebook::react::NativeRNGestureHandlerModuleSpec<
GPUIGestureHandlerModule> {
public:
explicit GPUIGestureHandlerModule(
std::shared_ptr<facebook::react::CallInvoker> jsInvoker)
: NativeRNGestureHandlerModuleSpec(std::move(jsInvoker)) {}
// The JS side queues operations and flushes them in order, so every method
// has to observe the same order the JS thread produced.
static constexpr rngpui::ModuleThread kThread = rngpui::ModuleThread::JS;
void createGestureHandler(
Runtime&,
std::string handlerName,
double handlerTag,
folly::dynamic config) {
Handler handler;
handler.name = std::move(handlerName);
handler.config = configObject(std::move(config));
std::lock_guard<std::mutex> lock(handlersMutex);
handlers.insert_or_assign(
static_cast<int32_t>(handlerTag), std::move(handler));
}
void attachGestureHandler(
Runtime&,
double handlerTag,
double viewTag,
double actionType) {
const auto tag = static_cast<int32_t>(handlerTag);
std::lock_guard<std::mutex> lock(handlersMutex);
auto found = handlers.find(tag);
if (found != handlers.end()) {
found->second.viewTag = static_cast<int32_t>(viewTag);
found->second.actionType = static_cast<int32_t>(actionType);
if (gestureTracingEnabled()) {
LOG(INFO) << "[gesture-handler] attached handler=" << tag
<< " view=" << found->second.viewTag
<< " action=" << found->second.actionType;
}
}
}
void setGestureHandlerConfig(
Runtime&,
double handlerTag,
folly::dynamic newConfig) {
const auto tag = static_cast<int32_t>(handlerTag);
auto config = configObject(std::move(newConfig));
std::lock_guard<std::mutex> lock(handlersMutex);
auto found = handlers.find(tag);
if (found != handlers.end())
found->second.config = std::move(config);
}
void updateGestureHandlerConfig(
Runtime&,
double handlerTag,
folly::dynamic newConfig) {
const auto tag = static_cast<int32_t>(handlerTag);
std::lock_guard<std::mutex> lock(handlersMutex);
auto found = handlers.find(tag);
if (found != handlers.end() && newConfig.isObject()) {
for (const auto& [key, value] : newConfig.items())
found->second.config[key] = value;
}
}
void
configureRelations(Runtime&, double handlerTag, folly::dynamic relations) {
const auto tag = static_cast<int32_t>(handlerTag);
std::lock_guard<std::mutex> lock(handlersMutex);
const auto found = handlers.find(tag);
if (found != handlers.end()) {
found->second.simultaneousHandlers =
relationTags(relations, "simultaneousHandlers");
found->second.waitFor = relationTags(relations, "waitFor");
found->second.blocksHandlers = relationTags(relations, "blocksHandlers");
if (gestureTracingEnabled()) {
auto formatTags = [](const std::vector<int32_t>& tags) {
std::string result;
for (const auto value : tags) {
if (!result.empty())
result += ",";
result += std::to_string(value);
}
return result;
};
LOG(INFO) << "[gesture-handler] relations handler=" << tag
<< " name=" << found->second.name << " simultaneous="
<< formatTags(found->second.simultaneousHandlers)
<< " waitFor=" << formatTags(found->second.waitFor)
<< " blocks=" << formatTags(found->second.blocksHandlers);
}
}
}
void dropGestureHandler(Runtime&, double handlerTag) {
std::lock_guard<std::mutex> lock(handlersMutex);
handlers.erase(static_cast<int32_t>(handlerTag));
}
void flushOperations(Runtime&) {}
bool installUIRuntimeBindings(Runtime&) {
return true;
}
};
} // namespace
std::shared_ptr<facebook::react::TurboModule>
create_gpui_gesture_handler_module(
const std::string& name,
std::shared_ptr<facebook::react::CallInvoker> jsInvoker) {
if (name != GPUIGestureHandlerModule::kModuleName) {
return nullptr;
}
rngpui::registerPointerObserver(
[](const rngpui::extension::RawPointerEvent& pointer) {
return processPointer(pointer);
});
if (gestureTracingEnabled()) {
LOG(INFO) << "[gesture-handler] native pointer processor installed";
}
return std::make_shared<GPUIGestureHandlerModule>(std::move(jsInvoker));
}
@@ -0,0 +1,22 @@
{
"name": "@react-native-gpui/gesture-handler",
"version": "0.1.0",
"description": "Native GPUI gesture recognition for react-native-gesture-handler",
"files": [
"desktop",
"react-native.config.js",
"src"
],
"peerDependencies": {
"react-native": "0.87.1",
"react-native-gesture-handler": "3.3.x",
"react-native-reanimated": "4.7.x",
"react-native-worklets": "0.13.x"
},
"codegenConfig": {
"name": "GPUIGestureHandlerSpec",
"type": "modules",
"jsSrcsDir": "src"
},
"license": "MIT"
}
@@ -0,0 +1,25 @@
module.exports = {
dependency: {
platforms: {
desktop: {
cmake: 'desktop',
cppProvider: 'create_gpui_gesture_handler_module',
componentCodegenPackage: 'react-native-gesture-handler',
componentDescriptors: [
'RNGestureHandlerButtonComponentDescriptor',
'RNGestureHandlerRootViewComponentDescriptor',
],
componentHeaders: [
'react/renderer/components/rngesturehandler_codegen/ComponentDescriptors.h',
],
// Metro: replace these files of installed packages for gpui.
sourceOverrides: {
'react-native-gesture-handler/src/handlers/customDirectEventTypes.ts':
'./src/customDirectEventTypes.desktop.ts',
'react-native-gesture-handler/src/v3/components/GestureButtons.tsx':
'./src/GestureButtons.desktop.tsx',
},
},
},
},
};
@@ -0,0 +1,71 @@
import React from 'react';
import './customDirectEventTypes.desktop';
import {
Pressable,
type ColorValue,
type PressableProps,
type StyleProp,
type ViewStyle,
} from 'react-native';
type DesktopButtonProps = Omit<PressableProps, 'style'> & {
style?: StyleProp<ViewStyle>;
activeOpacity?: number;
underlayColor?: ColorValue;
onActiveStateChange?: (active: boolean) => void;
pressedStyle?: ViewStyle;
borderless?: boolean;
rippleColor?: ColorValue | number | null;
rippleRadius?: number | null;
foreground?: boolean;
exclusive?: boolean;
cancelable?: boolean;
};
function DesktopButton({
activeOpacity = 0.75,
onActiveStateChange,
onPressIn,
onPressOut,
pressedStyle,
style,
...props
}: DesktopButtonProps) {
return (
<Pressable
{...props}
style={({ pressed }) => [
style,
pressed ? [{ opacity: activeOpacity }, pressedStyle] : undefined,
]}
onPressIn={event => {
onActiveStateChange?.(true);
onPressIn?.(event);
}}
onPressOut={event => {
onActiveStateChange?.(false);
onPressOut?.(event);
}}
/>
);
}
export const RectButton = ({ underlayColor, ...props }: DesktopButtonProps) => (
<DesktopButton
{...props}
pressedStyle={
underlayColor == null ? undefined : { backgroundColor: underlayColor }
}
/>
);
export const BorderlessButton = (props: DesktopButtonProps) => (
<DesktopButton {...props} />
);
export const RawButton = DesktopButton;
export const BaseButton = DesktopButton;
export const LegacyRectButton = RectButton;
export const LegacyBorderlessButton = BorderlessButton;
export const LegacyRawButton = RawButton;
export const LegacyBaseButton = BaseButton;
@@ -0,0 +1,32 @@
import type { CodegenTypes, TurboModule } from 'react-native';
import { TurboModuleRegistry } from 'react-native';
export interface Spec extends TurboModule {
createGestureHandler: (
handlerName: string,
handlerTag: CodegenTypes.Double,
config: Object,
) => void;
attachGestureHandler: (
handlerTag: CodegenTypes.Double,
viewTag: CodegenTypes.Double,
actionType: CodegenTypes.Double,
) => void;
setGestureHandlerConfig: (
handlerTag: CodegenTypes.Double,
newConfig: Object,
) => void;
updateGestureHandlerConfig: (
handlerTag: CodegenTypes.Double,
newConfig: Object,
) => void;
configureRelations: (
handlerTag: CodegenTypes.Double,
relations: Object,
) => void;
dropGestureHandler: (handlerTag: CodegenTypes.Double) => void;
flushOperations: () => void;
installUIRuntimeBindings: () => boolean;
}
export default TurboModuleRegistry.getEnforcing<Spec>('RNGestureHandlerModule');
@@ -0,0 +1,13 @@
// RNGH's legacy handler path registers only the continuous event in its JS
// module. GPUI dispatches the codegen state-change event directly, so expose
// both direct-event registrations from this platform package.
import { customDirectEventTypes } from 'react-native/Libraries/Renderer/shims/ReactNativeViewConfigRegistry';
customDirectEventTypes.topGestureHandlerEvent = {
registrationName: 'onGestureHandlerEvent',
};
customDirectEventTypes.topGestureHandlerStateChange = {
registrationName: 'onGestureHandlerStateChange',
};
export { customDirectEventTypes };
@@ -0,0 +1,94 @@
if(NOT RN_GPUI_PROJECT_ROOT)
message(FATAL_ERROR
"@react-native-gpui/reanimated was added by the platform; RN_GPUI_PROJECT_ROOT must be set by its build.rs")
endif()
set(_gpui_app_root "${RN_GPUI_PROJECT_ROOT}")
find_program(_gpui_node node REQUIRED)
execute_process(
COMMAND "${_gpui_node}" -e "process.stdout.write(require.resolve('react-native-reanimated/package.json', {paths: [process.argv[1]]}))" "${_gpui_app_root}"
RESULT_VARIABLE _reanimated_resolve_result
OUTPUT_VARIABLE _reanimated_package_json
OUTPUT_STRIP_TRAILING_WHITESPACE)
if(NOT _reanimated_resolve_result EQUAL 0 OR NOT EXISTS "${_reanimated_package_json}")
message(FATAL_ERROR "react-native-reanimated could not be resolved from ${_gpui_app_root}")
endif()
get_filename_component(_reanimated_root "${_reanimated_package_json}" DIRECTORY)
execute_process(
COMMAND "${_gpui_node}" -e "process.stdout.write(require.resolve('react-native/package.json', {paths: [process.argv[1]]}))" "${_gpui_app_root}"
RESULT_VARIABLE _react_native_resolve_result
OUTPUT_VARIABLE _react_native_package_json
OUTPUT_STRIP_TRAILING_WHITESPACE)
if(NOT _react_native_resolve_result EQUAL 0 OR NOT EXISTS "${_react_native_package_json}")
message(FATAL_ERROR "react-native could not be resolved from ${_gpui_app_root}")
endif()
get_filename_component(_react_native_root "${_react_native_package_json}" DIRECTORY)
execute_process(
COMMAND "${_gpui_node}" -e "process.stdout.write(require.resolve('react-native-worklets/package.json', {paths: [process.argv[1]]}))" "${_gpui_app_root}"
RESULT_VARIABLE _worklets_resolve_result
OUTPUT_VARIABLE _worklets_package_json
OUTPUT_STRIP_TRAILING_WHITESPACE)
if(NOT _worklets_resolve_result EQUAL 0 OR NOT EXISTS "${_worklets_package_json}")
message(FATAL_ERROR "react-native-worklets could not be resolved from ${_gpui_app_root}")
endif()
get_filename_component(_worklets_root "${_worklets_package_json}" DIRECTORY)
set(_reanimated_common "${_reanimated_root}/Common/cpp")
set(_worklets_common "${_worklets_root}/Common/cpp")
if(NOT EXISTS "${_reanimated_common}/reanimated/NativeModules/ReanimatedModuleProxy.h")
message(FATAL_ERROR "react-native-reanimated 4.7.x was not found in ${_gpui_app_root} or any ancestor node_modules directory")
endif()
execute_process(
COMMAND "${_gpui_node}" -e "process.stdout.write(require.resolve('@react-native-gpui/worklets/package.json', {paths: [process.argv[1]]}))" "${_gpui_app_root}"
RESULT_VARIABLE _gpui_worklets_resolve_result
OUTPUT_VARIABLE _gpui_worklets_package_json
OUTPUT_STRIP_TRAILING_WHITESPACE)
if(NOT _gpui_worklets_resolve_result EQUAL 0 OR NOT EXISTS "${_gpui_worklets_package_json}")
message(FATAL_ERROR "@react-native-gpui/worklets could not be resolved from ${_gpui_app_root}")
endif()
get_filename_component(_gpui_worklets_root "${_gpui_worklets_package_json}" DIRECTORY)
if(NOT EXISTS "${_worklets_common}/worklets/Compat/StableApi.h")
message(FATAL_ERROR "react-native-worklets 0.13.x is required by GPUI Reanimated")
endif()
# Match the static feature-flag definition normally supplied by Reanimated's
# CocoaPods/Gradle builds. Without this, FeatureFlags.h silently treats every
# flag as false, including defaults which change how animated props are
# committed through Fabric.
execute_process(
COMMAND "${_gpui_node}" -e
"const fs=require('fs'); const flags=JSON.parse(fs.readFileSync(process.argv[1],'utf8')); const app=JSON.parse(fs.readFileSync(process.argv[2],'utf8')); Object.assign(flags, app.reanimated?.staticFeatureFlags ?? {}); if(process.platform==='darwin' && !Object.hasOwn(app.reanimated?.staticFeatureFlags ?? {}, 'IOS_SYNCHRONOUSLY_UPDATE_UI_PROPS')) flags.IOS_SYNCHRONOUSLY_UPDATE_UI_PROPS=true; if(process.platform==='linux' && !Object.hasOwn(app.reanimated?.staticFeatureFlags ?? {}, 'LINUX_SYNCHRONOUSLY_UPDATE_UI_PROPS')) flags.LINUX_SYNCHRONOUSLY_UPDATE_UI_PROPS=true; process.stdout.write(Object.entries(flags).map(([key,value])=>'['+key+':'+value+']').join(''));"
"${_reanimated_root}/src/featureFlags/staticFlags.json"
"${_gpui_app_root}/package.json"
RESULT_VARIABLE _reanimated_flags_result
OUTPUT_VARIABLE _reanimated_feature_flags
OUTPUT_STRIP_TRAILING_WHITESPACE
)
if(NOT _reanimated_flags_result EQUAL 0 OR _reanimated_feature_flags STREQUAL "")
message(FATAL_ERROR "Failed to load Reanimated static feature flags for GPUI")
endif()
execute_process(
COMMAND "${_gpui_node}" "${_react_native_root}/scripts/generate-codegen-artifacts.js"
--path "${_gpui_app_root}"
--targetPlatform ios
--outputPath "${_gpui_app_root}/desktop/build/codegen"
--source app
RESULT_VARIABLE _reanimated_codegen_result
)
if(NOT _reanimated_codegen_result EQUAL 0)
message(FATAL_ERROR "React Native codegen failed while configuring GPUI Reanimated")
endif()
set(_rn_codegen "${_gpui_app_root}/desktop/build/codegen/build/generated/ios/ReactCodegen")
file(GLOB_RECURSE _reanimated_sources CONFIGURE_DEPENDS "${_reanimated_common}/reanimated/*.cpp")
set_property(GLOBAL APPEND PROPERTY RN_GPUI_APP_CPP_SOURCES
${_reanimated_sources}
"${CMAKE_CURRENT_LIST_DIR}/cpp/GPUIReanimatedModule.cpp")
set_property(GLOBAL APPEND PROPERTY RN_GPUI_APP_CPP_INCLUDE_DIRS
"${_reanimated_common}"
"${_reanimated_root}"
"${_worklets_common}"
"${_rn_codegen}"
"${_gpui_worklets_root}/desktop/cpp")
set_property(GLOBAL APPEND PROPERTY RN_GPUI_APP_CPP_DEFINITIONS
"REANIMATED_VERSION=4.7.1"
"REANIMATED_FEATURE_FLAGS=\"${_reanimated_feature_flags}\"")
@@ -0,0 +1,161 @@
#include "GPUIWorkletsModule.h"
#include "GpuiHost.h"
#include <GPUIReanimatedSpec.h>
#include <reanimated/NativeModules/ReanimatedModuleProxy.h>
#include <reanimated/RuntimeDecorators/RNRuntimeDecorator.h>
#include <reanimated/Tools/PlatformDepMethodsHolder.h>
#include <worklets/Compat/StableApi.h>
#include <glog/logging.h>
#include <atomic>
#include <chrono>
#include <cstdint>
#include <cstdlib>
#include <memory>
#include <utility>
namespace {
bool animationTracingEnabled() {
static const bool enabled = std::getenv("RN_GPUI_DEBUG_ANIMATION") != nullptr;
return enabled;
}
using facebook::jsi::Runtime;
using reanimated::PlatformDepMethodsHolder;
using reanimated::ReanimatedModuleProxy;
PlatformDepMethodsHolder platformMethods(
const std::shared_ptr<std::weak_ptr<ReanimatedModuleProxy>>& proxyHolder) {
PlatformDepMethodsHolder methods;
methods.requestRender = [](std::function<void(const double)> callback) {
static std::atomic<uint64_t> requests{0};
const auto count = ++requests;
if (animationTracingEnabled() && (count <= 5 || count % 60 == 0)) {
LOG(INFO) << "[gpui-reanimated] requestRender count=" << count;
}
auto module = gpui_worklets::currentModule();
if (module)
module->requestAnimationFrame(std::move(callback));
};
methods.synchronouslyUpdateUIPropsFunction = [](const int tag,
const folly::dynamic& props) {
rngpui::gpui_synchronously_update_props(tag, props);
};
methods.getAnimationTimestamp = [] {
return std::chrono::duration<double, std::milli>(
std::chrono::steady_clock::now().time_since_epoch())
.count();
};
methods.registerSensor =
[](int, int, int, std::function<void(double[], int)>) { return 0; };
methods.unregisterSensor = [](int) {};
methods.setGestureStateFunction = [](int, int) {};
methods.maybeFlushUIUpdatesQueueFunction = [proxyHolder] {
if (auto proxy = proxyHolder->lock()) {
proxy->performOperations();
}
};
methods.attachPseudoSelector = [](facebook::react::Tag,
reanimated::PseudoSelector,
std::function<void(bool)> callback) {
if (callback)
callback(false);
};
methods.detachPseudoSelector = [](facebook::react::Tag,
reanimated::PseudoSelector) {};
#if defined(__APPLE__)
methods.forceScreenSnapshotFunction = [](facebook::react::Tag) {
return false;
};
methods.readMountedViewPropsFunction =
[](facebook::react::Tag) -> facebook::react::Props::Shared {
return nullptr;
};
#endif
return methods;
}
class Module final
: public facebook::react::NativeReanimatedModuleSpec<Module> {
public:
explicit Module(std::shared_ptr<facebook::react::CallInvoker> jsInvoker)
: NativeReanimatedModuleSpec(std::move(jsInvoker)) {}
bool installTurboModule(Runtime& runtime) {
if (proxy_)
return true;
auto worklets = gpui_worklets::currentModule();
auto uiManager = rngpui::get_gpui_ui_manager();
if (!worklets || !worklets->uiRuntime() || !worklets->uiScheduler() ||
!uiManager) {
throw facebook::jsi::JSError(
runtime, "GPUI Reanimated requires initialized Worklets and Fabric");
}
// The platform flush callback is constructed before the proxy exists.
// Retain only a weak reference so the decorated UI runtime cannot keep
// the module alive after React Native tears it down.
auto proxyHolder = std::make_shared<std::weak_ptr<ReanimatedModuleProxy>>();
auto methods = platformMethods(proxyHolder);
proxy_ = std::make_shared<ReanimatedModuleProxy>(
worklets->uiRuntime(),
worklets->uiScheduler(),
runtime,
jsInvoker_,
methods,
false);
*proxyHolder = proxy_;
proxy_->init(methods);
proxy_->initializeFabric(uiManager);
LOG(INFO) << "[gpui-reanimated] module proxy initialized";
auto& uiRuntime =
worklets::getJSIRuntimeFromWorkletRuntime(worklets->uiRuntime());
reanimated::RNRuntimeDecorator::decorate(runtime, uiRuntime, proxy_);
std::weak_ptr<ReanimatedModuleProxy> weakProxy = proxy_;
eventListener_ = std::make_shared<const facebook::react::EventListener>(
[weakProxy](const facebook::react::RawEvent& event) {
auto proxy = weakProxy.lock();
if (!proxy)
return false;
const auto timestamp =
std::chrono::duration<double, std::milli>(
std::chrono::steady_clock::now().time_since_epoch())
.count();
static std::atomic<uint64_t> events{0};
const auto count = ++events;
if (animationTracingEnabled() &&
event.type.find("GestureHandler") != std::string::npos) {
const auto* target = event.eventTarget.get();
const auto tag = target ? target->getTag() : -1;
auto eventName = event.type;
if (eventName.rfind("top", 0) == 0) {
eventName = "on" + eventName.substr(3);
}
LOG(INFO) << "[gpui-reanimated] gesture lookup type=" << eventName
<< " tag=" << tag << " waiting="
<< proxy->isAnyHandlerWaitingForEvent(eventName, tag);
}
if (animationTracingEnabled() &&
(count <= 10 ||
event.type.find("GestureHandler") != std::string::npos)) {
LOG(INFO) << "[gpui-reanimated] raw event count=" << count
<< " type=" << event.type;
}
return proxy->handleRawEvent(event, timestamp);
});
rngpui::register_gpui_event_listener(eventListener_);
return true;
}
std::shared_ptr<ReanimatedModuleProxy> proxy_;
std::shared_ptr<const facebook::react::EventListener> eventListener_;
};
} // namespace
std::shared_ptr<facebook::react::TurboModule> create_gpui_reanimated_module(
const std::string& name,
std::shared_ptr<facebook::react::CallInvoker> jsInvoker) {
return rngpui::provideTurboModule<Module>(name, std::move(jsInvoker));
}
@@ -0,0 +1,22 @@
{
"name": "@react-native-gpui/reanimated",
"version": "0.1.0",
"description": "GPUI platform bindings for Reanimated 4",
"files": [
"desktop",
"react-native.config.js",
"src"
],
"peerDependencies": {
"react-native": "0.87.1",
"react-native-reanimated": "4.7.x",
"react-native-worklets": "0.13.x",
"@react-native-gpui/worklets": "0.1.x"
},
"codegenConfig": {
"name": "GPUIReanimatedSpec",
"type": "modules",
"jsSrcsDir": "src"
},
"license": "MIT"
}
@@ -0,0 +1,10 @@
module.exports = {
dependency: {
platforms: {
desktop: {
cmake: 'desktop',
cppProvider: 'create_gpui_reanimated_module',
},
},
},
};
@@ -0,0 +1,8 @@
import type { TurboModule } from 'react-native';
import { TurboModuleRegistry } from 'react-native';
export interface Spec extends TurboModule {
installTurboModule: () => boolean;
}
export default TurboModuleRegistry.get<Spec>('ReanimatedModule');
@@ -0,0 +1,33 @@
if(NOT RN_GPUI_PROJECT_ROOT)
message(FATAL_ERROR
"@react-native-gpui/safe-area-context was added by the platform; RN_GPUI_PROJECT_ROOT must be set by its build.rs")
endif()
rn_gpui_find_node_module(react-native-safe-area-context _safe_area_root)
set(_safe_area_common "${_safe_area_root}/common/cpp")
set(_safe_area_shadow_node "${_safe_area_common}/react/renderer/components/safeareacontext/RNCSafeAreaViewShadowNode.cpp")
set(_safe_area_package_json "${_safe_area_root}/package.json")
if(NOT EXISTS "${_safe_area_shadow_node}" OR NOT EXISTS "${_safe_area_package_json}")
message(FATAL_ERROR "react-native-safe-area-context 5.10.x was not found at ${_safe_area_root}")
endif()
file(READ "${_safe_area_package_json}" _safe_area_package_json_contents)
string(JSON _safe_area_version GET "${_safe_area_package_json_contents}" version)
if(NOT _safe_area_version MATCHES "^5\\.10\\.")
message(FATAL_ERROR "@react-native-gpui/safe-area-context supports react-native-safe-area-context 5.10.x, found ${_safe_area_version}")
endif()
set_property(GLOBAL APPEND PROPERTY RN_GPUI_APP_CPP_SOURCES
"${_safe_area_shadow_node}"
"${CMAKE_CURRENT_LIST_DIR}/cpp/GPUISafeAreaContextModule.cpp")
set_property(GLOBAL APPEND PROPERTY RN_GPUI_APP_CPP_INCLUDE_DIRS
"${_safe_area_common}"
"${RN_GPUI_REACT_CXX_PLATFORM_DIR}"
"${CMAKE_CURRENT_LIST_DIR}/cpp")
# Safe-area insets reach the mounting manager through this glue, which the
# platform compiles only because the package is installed.
set_property(GLOBAL APPEND PROPERTY RN_GPUI_PACKAGE_MODULE_SOURCES
"${CMAKE_CURRENT_LIST_DIR}/cpp/GpuiSafeAreaModule.cpp")
set_property(GLOBAL APPEND PROPERTY RN_GPUI_PACKAGE_MODULE_INSTALLERS safe_area)
set_property(GLOBAL APPEND PROPERTY RN_GPUI_PACKAGE_MODULE_INCLUDE_DIRS
"${CMAKE_CURRENT_LIST_DIR}/cpp"
"${RN_GPUI_PROJECT_ROOT}/desktop/autolink/codegen/safeareacontext")
@@ -0,0 +1,48 @@
#include <GPUISafeAreaContextSpec.h>
#include <react/coremodules/DeviceInfoModule.h>
#include "GPUISafeAreaInsets.h"
#include <utility>
namespace {
using facebook::react::TurboModule;
class SafeAreaContextModule final
: public facebook::react::NativeSafeAreaContextSpec<SafeAreaContextModule> {
public:
explicit SafeAreaContextModule(
std::shared_ptr<facebook::react::CallInvoker> jsInvoker)
: NativeSafeAreaContextSpec(std::move(jsInvoker)) {}
facebook::jsi::Object getConstants(facebook::jsi::Runtime& runtime) {
const auto windowSize = facebook::react::DeviceInfoModule::windowSize();
facebook::jsi::Object insets(runtime);
const auto safeInsets = gpui_safe_area::gpui_safe_area_insets(0);
insets.setProperty(runtime, "top", safeInsets.top);
insets.setProperty(runtime, "right", safeInsets.right);
insets.setProperty(runtime, "bottom", safeInsets.bottom);
insets.setProperty(runtime, "left", safeInsets.left);
facebook::jsi::Object frame(runtime);
frame.setProperty(runtime, "x", 0.0);
frame.setProperty(runtime, "y", 0.0);
frame.setProperty(runtime, "width", windowSize.width);
frame.setProperty(runtime, "height", windowSize.height);
facebook::jsi::Object metrics(runtime);
metrics.setProperty(runtime, "insets", std::move(insets));
metrics.setProperty(runtime, "frame", std::move(frame));
facebook::jsi::Object constants(runtime);
constants.setProperty(runtime, "initialWindowMetrics", std::move(metrics));
return constants;
}
};
} // namespace
std::shared_ptr<TurboModule> create_gpui_safe_area_context_module(
const std::string& name,
std::shared_ptr<facebook::react::CallInvoker> jsInvoker) {
return rngpui::provideTurboModule<SafeAreaContextModule>(
name, std::move(jsInvoker));
}
@@ -0,0 +1,22 @@
#pragma once
#include <atomic>
#include <cstdint>
namespace gpui_safe_area {
struct Insets {
double top;
double right;
double bottom;
double left;
};
inline std::atomic<double> windowTopInset{0.0};
inline Insets gpui_safe_area_insets(int32_t /*surfaceId*/) {
return {
.top = windowTopInset.load(), .right = 0.0, .bottom = 0.0, .left = 0.0};
}
} // namespace gpui_safe_area
@@ -0,0 +1,143 @@
#include "GPUISafeAreaInsets.h"
#include "GpuiModules.h"
#include <react/renderer/components/safeareacontext/RNCSafeAreaViewState.h>
#include <react/renderer/core/ConcreteState.h>
#include <folly/json.h>
#include <glog/logging.h>
#include <array>
#include <mutex>
#include <string_view>
#include <unordered_map>
namespace rngpui {
using namespace facebook::react;
namespace {
struct TrackedView {
std::array<float, 4> frame;
SharedEventEmitter emitter;
std::shared_ptr<const ConcreteState<RNCSafeAreaViewState>> state;
};
std::mutex trackedMutex;
std::unordered_map<Tag, TrackedView> tracked;
std::shared_ptr<const ConcreteState<RNCSafeAreaViewState>> safeAreaState(
const ShadowView& view) {
if (std::string_view(view.componentName) != "RNCSafeAreaView")
return nullptr;
return std::dynamic_pointer_cast<const ConcreteState<RNCSafeAreaViewState>>(
view.state);
}
void dispatchInsets(
const SharedEventEmitter& emitter,
const std::array<float, 4>& frame,
float top,
float right,
float bottom,
float left) {
if (!emitter)
return;
emitter->dispatchEvent(
"insetsChange",
folly::dynamic::object(
"insets",
folly::dynamic::object("top", top)("right", right)("bottom", bottom)(
"left", left))(
"frame",
folly::dynamic::object("x", frame[0])("y", frame[1])(
"width", frame[2])("height", frame[3])));
}
void applyInsets(float top, float right, float bottom, float left) {
const bool changed = gpui_safe_area::windowTopInset.exchange(top) != top;
std::vector<std::shared_ptr<const ConcreteState<RNCSafeAreaViewState>>>
states;
{
std::lock_guard<std::mutex> lock(trackedMutex);
for (const auto& [tag, entry] : tracked) {
(void)tag;
if (changed && entry.emitter)
dispatchInsets(entry.emitter, entry.frame, top, right, bottom, left);
if (!entry.state)
continue;
// The tracked state is the one the view had when its frame last changed.
// A state update does not move the frame, so it would stay at the old
// insets, and every batch would send the same update again: one commit
// per batch, for ever.
const auto latest =
std::dynamic_pointer_cast<const ConcreteState<RNCSafeAreaViewState>>(
entry.state->getMostRecentState());
const auto& current = latest ? latest : entry.state;
if (current->getData().insets.top != top)
states.push_back(current);
}
}
for (const auto& state : states) {
auto next = state->getData();
next.insets.top = top;
state->updateState(std::move(next));
}
}
} // namespace
void rn_gpui_install_safe_area_module() {
registerMountObserver(extension::MountObserver{
.onFrameChanged =
[](SurfaceId surfaceId, const ShadowView& view) {
const auto& frame = view.layoutMetrics.frame;
const std::array<float, 4> nextFrame = {
frame.origin.x,
frame.origin.y,
frame.size.width,
frame.size.height};
const bool isProvider =
std::string_view(view.componentName) == "RNCSafeAreaProvider";
if (!isProvider && !safeAreaState(view))
return;
{
std::lock_guard<std::mutex> lock(trackedMutex);
auto& entry = tracked[view.tag];
if (isProvider && entry.emitter && entry.frame == nextFrame)
return;
entry.frame = nextFrame;
entry.emitter = view.eventEmitter;
entry.state = safeAreaState(view);
}
if (!isProvider)
return;
const auto insets = gpui_safe_area::gpui_safe_area_insets(
static_cast<int32_t>(surfaceId));
LOG(INFO) << "[gpui-safe-area] onInsetsChange tag=" << view.tag
<< " surface=" << surfaceId << " frame=("
<< frame.origin.x << ',' << frame.origin.y << ' '
<< frame.size.width << 'x' << frame.size.height << ')';
dispatchInsets(
view.eventEmitter,
nextFrame,
insets.top,
insets.right,
insets.bottom,
insets.left);
},
.onDeleted =
[](Tag tag) {
std::lock_guard<std::mutex> lock(trackedMutex);
tracked.erase(tag);
},
.afterBatch =
[] {
applyInsets(
gpui_safe_area::windowTopInset.load(), 0.0f, 0.0f, 0.0f);
}});
registerWindowInsetsObserver(
[](float top, float right, float bottom, float left) {
applyInsets(top, right, bottom, left);
});
}
} // namespace rngpui
@@ -0,0 +1,20 @@
{
"name": "@react-native-gpui/safe-area-context",
"version": "0.1.0",
"description": "GPUI platform implementation for react-native-safe-area-context",
"files": [
"desktop",
"react-native.config.js",
"src"
],
"peerDependencies": {
"react-native": "0.87.1",
"react-native-safe-area-context": "5.10.x"
},
"codegenConfig": {
"name": "GPUISafeAreaContextSpec",
"type": "modules",
"jsSrcsDir": "src"
},
"license": "MIT"
}
@@ -0,0 +1,19 @@
module.exports = {
dependency: {
platforms: {
desktop: {
cmake: 'desktop',
cppProvider: 'create_gpui_safe_area_context_module',
componentCodegenPackage: 'react-native-safe-area-context',
componentDescriptors: [
'RNCSafeAreaProviderComponentDescriptor',
'RNCSafeAreaViewComponentDescriptor',
],
componentHeaders: [
'react/renderer/components/safeareacontext/ComponentDescriptors.h',
'react/renderer/components/safeareacontext/RNCSafeAreaViewComponentDescriptor.h',
],
},
},
},
};
@@ -0,0 +1,23 @@
import type { CodegenTypes, TurboModule } from 'react-native';
import { TurboModuleRegistry } from 'react-native';
export interface Spec extends TurboModule {
getConstants: () => {
initialWindowMetrics?: {
insets: {
top: CodegenTypes.Double;
right: CodegenTypes.Double;
bottom: CodegenTypes.Double;
left: CodegenTypes.Double;
};
frame: {
x: CodegenTypes.Double;
y: CodegenTypes.Double;
width: CodegenTypes.Double;
height: CodegenTypes.Double;
};
};
};
}
export default TurboModuleRegistry.get<Spec>('RNCSafeAreaContext');
@@ -0,0 +1,30 @@
if(NOT RN_GPUI_PROJECT_ROOT)
message(FATAL_ERROR
"@react-native-gpui/screens was added by the platform; RN_GPUI_PROJECT_ROOT must be set by its build.rs")
endif()
rn_gpui_find_node_module(react-native-screens _screens_root)
set(_screens_common "${_screens_root}/common/cpp")
set(_screens_component_dir "${_screens_common}/react/renderer/components/rnscreens")
set(_screens_package_json "${_screens_root}/package.json")
if(NOT EXISTS "${_screens_component_dir}/RNSScreenShadowNode.cpp" OR NOT EXISTS "${_screens_package_json}")
message(FATAL_ERROR "react-native-screens 4.28.x was not found at ${_screens_root}")
endif()
file(READ "${_screens_package_json}" _screens_package_json_contents)
string(JSON _screens_version GET "${_screens_package_json_contents}" version)
if(NOT _screens_version MATCHES "^4\\.28\\.")
message(FATAL_ERROR "@react-native-gpui/screens supports react-native-screens 4.28.x, found ${_screens_version}")
endif()
file(GLOB _screens_common_sources CONFIGURE_DEPENDS "${_screens_component_dir}/*.cpp")
set_property(GLOBAL APPEND PROPERTY RN_GPUI_APP_CPP_SOURCES ${_screens_common_sources})
set_property(GLOBAL APPEND PROPERTY RN_GPUI_APP_CPP_INCLUDE_DIRS "${_screens_common}")
# The mount pipeline knows nothing about screens: this glue fills the header and
# screen fields of a mount op, and the platform compiles it only because the
# package is installed.
set_property(GLOBAL APPEND PROPERTY RN_GPUI_PACKAGE_MODULE_SOURCES
"${CMAKE_CURRENT_LIST_DIR}/cpp/GpuiScreensModule.cpp")
set_property(GLOBAL APPEND PROPERTY RN_GPUI_PACKAGE_MODULE_INSTALLERS screens)
set_property(GLOBAL APPEND PROPERTY RN_GPUI_PACKAGE_MODULE_INCLUDE_DIRS
"${_screens_common}")

Some files were not shown because too many files have changed in this diff Show More