From 7fad9f2e66e5db9775e4e70f13d9b93317b54b0a Mon Sep 17 00:00:00 2001 From: Pistonight Date: Thu, 22 Feb 2024 23:14:55 -0800 Subject: [PATCH] start rewriting fs and lock --- libs/package-lock.json | 23 ++ libs/package.json | 9 + libs/pure/README.md | 11 + libs/pure/src/fs/FsFile.ts | 56 ++++ libs/pure/src/fs/FsFileSystem.ts | 79 +++++ libs/pure/src/fs/README.md | 106 +++++++ libs/pure/src/fs/error.ts | 46 +++ libs/pure/src/fs/impl/file.ts | 216 +++++++++++++ libs/pure/src/fs/impl/fsa.ts | 41 +++ libs/pure/src/fs/impl/index.ts | 1 + libs/pure/src/fs/index.ts | 1 + libs/pure/src/fs/open.ts | 288 ++++++++++++++++++ libs/pure/src/fs/path.ts | 123 ++++++++ libs/pure/src/fs/support.ts | 98 ++++++ libs/pure/{ => src}/result/README.md | 7 +- libs/pure/{ => src}/result/index.d.ts | 4 +- libs/pure/{ => src}/result/index.js | 8 +- libs/pure/src/utils/index.ts | 13 + libs/pure/src/utils/lock.ts | 85 ++++++ libs/tsconfig.json | 27 ++ web-client/src/core/kernel/Kernel.ts | 2 +- .../src/low/fs/FileSystemAccessApiFileSys.ts | 43 +-- web-client/src/low/fs/FsPath.ts | 14 +- web-client/src/low/fs/index.ts | 2 +- web-client/src/low/fs/{create.ts => open.ts} | 9 +- web-client/src/low/utils/error.ts | 11 - web-client/tsconfig.json | 4 +- 27 files changed, 1273 insertions(+), 54 deletions(-) create mode 100644 libs/package-lock.json create mode 100644 libs/package.json create mode 100644 libs/pure/README.md create mode 100644 libs/pure/src/fs/FsFile.ts create mode 100644 libs/pure/src/fs/FsFileSystem.ts create mode 100644 libs/pure/src/fs/README.md create mode 100644 libs/pure/src/fs/error.ts create mode 100644 libs/pure/src/fs/impl/file.ts create mode 100644 libs/pure/src/fs/impl/fsa.ts create mode 100644 libs/pure/src/fs/impl/index.ts create mode 100644 libs/pure/src/fs/index.ts create mode 100644 libs/pure/src/fs/open.ts create mode 100644 libs/pure/src/fs/path.ts create mode 100644 libs/pure/src/fs/support.ts rename libs/pure/{ => src}/result/README.md (97%) rename libs/pure/{ => src}/result/index.d.ts (95%) rename libs/pure/{ => src}/result/index.js (93%) create mode 100644 libs/pure/src/utils/index.ts create mode 100644 libs/pure/src/utils/lock.ts create mode 100644 libs/tsconfig.json rename web-client/src/low/fs/{create.ts => open.ts} (98%) diff --git a/libs/package-lock.json b/libs/package-lock.json new file mode 100644 index 00000000..0d855045 --- /dev/null +++ b/libs/package-lock.json @@ -0,0 +1,23 @@ +{ + "name": "libs", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "libs", + "version": "0.0.0", + "dependencies": { + "denque": "^2.1.0" + } + }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "engines": { + "node": ">=0.10" + } + } + } +} diff --git a/libs/package.json b/libs/package.json new file mode 100644 index 00000000..d6570dee --- /dev/null +++ b/libs/package.json @@ -0,0 +1,9 @@ +{ + "name": "libs", + "private": true, + "version": "0.0.0", + "type": "module", + "dependencies": { + "denque": "^2.1.0" + } +} diff --git a/libs/pure/README.md b/libs/pure/README.md new file mode 100644 index 00000000..db3cb6aa --- /dev/null +++ b/libs/pure/README.md @@ -0,0 +1,11 @@ +# pure + +Collection of javascript libraries used internally in my projects. + +These are inherently unstable. However if you want, you can depend on it +either by copying them into your project or use other creative methods. + +Some libraries have external dependencies, which you want to state in the `package.json` +of your project. This repo doesn't have one :) + +All code are licensed under UNLICENSE unless otherwise stated. diff --git a/libs/pure/src/fs/FsFile.ts b/libs/pure/src/fs/FsFile.ts new file mode 100644 index 00000000..0d39cfd9 --- /dev/null +++ b/libs/pure/src/fs/FsFile.ts @@ -0,0 +1,56 @@ +import { ResultHandle } from "../result"; +import { FsResult } from "./error"; + +/// Interface for operating on a file in the loaded file system +export interface FsFile { + /// Path of the file relative to the root of the file system (the uploaded directory) + readonly path: string; + + /// Returns if the content of the file in memory is newer than the file on disk + isDirty(): boolean; + + /// Get the last modified time. May load it from file system if needed + getLastModified(r: ResultHandle): Promise>; + + /// Get the text content of the file + /// + /// If the file is not loaded, it will load it. + /// + /// If the file is not a text file, it will return InvalidEncoding + getText(r: ResultHandle): Promise>; + + /// Get the content of the file + getBytes(r: ResultHandle): Promise> + + /// Set the content in memory. Does not save to disk. + /// Does nothing if file is closed + setText(content: string): void; + + /// Set the content in memory. Does not save to disk. + /// Does nothing if file is closed + setBytes(content: Uint8Array): void; + + /// Load the file's content if it's not newer than fs + /// + /// Returns Ok if the file is newer than fs + loadIfNotDirty(r: ResultHandle): Promise>; + + /// Load the file's content from FS. + /// + /// Overwrites any unsaved changes in memory only if the file was modified + /// at a later time than the last in memory modification. + /// + /// If it fails, the file's content in memory will not be changed + load(r: ResultHandle): Promise>; + + /// Save the file's content to FS if it is dirty. + /// + /// If not dirty, returns Ok + writeIfNewer(r: ResultHandle): Promise>; + + /// Close the file. In memory content will be lost. + /// Further operations on the file will fail + close(): void; + +} + diff --git a/libs/pure/src/fs/FsFileSystem.ts b/libs/pure/src/fs/FsFileSystem.ts new file mode 100644 index 00000000..2c23cce4 --- /dev/null +++ b/libs/pure/src/fs/FsFileSystem.ts @@ -0,0 +1,79 @@ +import { ResultHandle } from "../result"; +import { FsFile } from "./FsFile"; +import { FsResult } from "./error"; +import { FsCapabilities } from "./support"; + +/// File system before it is initialized +/// +/// This is an internal type used inside fsOpen functions +export interface FsFileSystemUninit { + /// Initialize the file system + init(r: ResultHandle): Promise>; +} + +/// Initialized file system +export interface FsFileSystem { + + /// Get the root path of the file system for display + /// + /// The returned string has no significance in the file system itself. + /// It should only be used as an indicator to the user. + readonly root: string; + + /// Capabilities of this file system implementation + /// See README.md for more information + readonly capabilities: FsCapabilities; + + /// List files in a directory + /// + /// The input path should be relative to the root (of the uploaded directory). + /// + /// Returns a list of file names in the directory (not full paths). + /// Directory names end with a slash. + /// + /// Returns Fail if the underlying file system operation fails. + listDir: (r: ResultHandle, path: string) => Promise>; + + /// Get a file object for operations + /// + /// The returned object can store temporary state for the file, such + /// as newer content. Calling openFile with the same path will + /// return the same object. + /// + /// Note that opening a file doesn't actually block the file + /// from being modified by programs other than the browser. + /// + /// You can make the FsFileSystem forget about the file by + /// calling `close` on the file object. + getFile: (path: string) => FsFile; + + /// Get all paths that `getFile` has been called with but not `close`d + getOpenedPaths: () => string[]; + +} + +/// Internal APIs +export interface FsFileSystemInternal { + /// Read the file as a File object + /// + /// Returns Fail if the underlying file system operation fails. + read: (r: ResultHandle, path: string) => Promise>; + + /// Write content to a file + /// + /// Writes the content to the path specified. + /// If the content is a string, UTF-8 encoding is used. + /// + /// Will overwrite existing file. + /// + /// Returns Fail if the underlying file system operation fails. + /// Returns NotSupported if the browser does not support this + write: ( + r: ResultHandle, + path: string, + content: Uint8Array, + ) => Promise>; + + /// Forget about a file + closeFile: (path: string) => void; +} diff --git a/libs/pure/src/fs/README.md b/libs/pure/src/fs/README.md new file mode 100644 index 00000000..ecfa670b --- /dev/null +++ b/libs/pure/src/fs/README.md @@ -0,0 +1,106 @@ +# pure/fs + +High level browser to file system integration library. + +This library integrates the `File`, `FileEntry` and `FileSystemAccess` API +to provide different levels of integration with file system in web apps. + +Basically, user can select a directory as a mount point, and browser can access +read and sometimes write in the directory. + +## Dependency +This library depends on `pure/result` + +## Support +Use `fsGetSupportStatus()` to inspect which implementation will be used. + +```typescript +import { fsGetSupportStatus } from "pure/fs"; + +const { implementation, isSecureContext } = fsGetSupportStatus(); +``` + +`implementation` can be 3 values: +1. `FileSystemAccess`: This is used for Google Chrome and Edge, and possibly other browsers, under secure context. +2. `FileEntry`: This is used for Firefox when the FS is mounted through a drag-and-drop interface. +3. `File`: This is used for Firefox when the FS is mounted by a directory picker dialog + +The implementation is also chosen in this order and the first supported one is selected. If you are on Chrome/Edge and `FileSystemAccess` is not used, you can use `isSecureContext` to narrow down the reason. + +If you are wondering why Safari is not mentioned, it's because Apple made it so I have to buy a Mac to test, which I didn't. + +After you get an instance of `FsFileSystem`, you can use `capabilities` to inspect +what is and is not supported. + +See `FsCapabilities` for more info. This is the support matrix: +|Implementation|`write`?|`live`?| +|--------------|--------|-------| +|`FileSystemAccess`|Yes*|Yes | +|`FileEntry` |No |Yes | +|`File` |No |No | + +* - Need to request permission from user. + + +## Usage +First you need to get an instance of `FsFileSystem`. You can: +1. Call `fsOpenRead()` or `fsOpenReadWrite()` to show a directory picker, +2. Call `fsOpenReadFrom` or `fsOpenReadWriteFrom()` and pass in a `DataTransferItem` from a drag-and-drop interface. + +NOTE: `fsOpenReadWrite` does not guarantee the implementation supports writing. You should check +with `capabilities` afterward. + +This is an example drop zone implementation in TypeScript +```typescript +import { tryInvokeAsync } from "pure/result"; +import { fsOpenReadWriteFrom } from "pure/fs"; + +const div = document.createElement("div"); + +div.addEventListener("dragover", (e) => { + if (e.dataTransfer) { + // setting this will allow dropping + e.dataTransfer.dropEffect = "link"; + } +}); + +div.addEventListener("drop", async (e) => { + const item = e.dataTransfer?.items[0]; + if (!item) { + console.error("no item"); + return; + } + + const result = await tryInvokeAsync((r) => fsOpenReadWriteFrom(r, item)); + if (result.isErr()) { + console.error(result.error); + return; + } + + const fs = result.value; + const { write, live } = fs.capabilities; + // check capabilities and use fs + // ... +}); +``` + +## Retry open +You can pass in a retry handler and return true to retry, when opening fails. +The handler is async so you can ask user. It uses the `pure/result` library. + +```typescript +import { ResultHandle, tryInvokeAsync } from "pure/result"; +import { FsError, FsResult } from "pure/fs"; + +async function shouldRetry(r: ResultHandle, error: FsError, attempt: number): Promise> { + if (retryCount < 10 && error === FsError.PermissionDenied) { + alert("you must give permission to use this feature!"); + return r.putOk(true); + } + return r.putOk(false); +} + +const result = await tryInvokeAsync((r) => fsOpenReadWrite(r, shouldRetry)); +``` + + diff --git a/libs/pure/src/fs/error.ts b/libs/pure/src/fs/error.ts new file mode 100644 index 00000000..9a3f255e --- /dev/null +++ b/libs/pure/src/fs/error.ts @@ -0,0 +1,46 @@ +import { Result, StableResult } from "../result"; + +/// Result type for file system operations +export const FsErr = { + /// Generic error + Fail: 1, + /// The operation does not apply to the root directory + IsRoot: 2, + /// Invalid encoding + InvalidEncoding: 3, + /// Not supported + NotSupported: 4, + /// The operation does not apply to a file + IsFile: 5, + /// The file was not modified since the last check + NotModified: 6, + /// Permission error + PermissionDenied: 7, + /// User abort + UserAbort: 8, + /// Not found + NotFound: 9, + /// Trying to do stuff to a closed file + IsClosed: 10, + /// If the path is invalid, for example trying to get the parent of root + InvalidPath: 11, + /// Trying to operate on a file that has been closed + Closed: 12, +} as const; + +export type FsErr = (typeof FsErr)[keyof typeof FsErr]; +export type FsError = { + readonly code: FsErr; + readonly message: string; +} + +export function fsErr(code: FsErr, message: string): FsError { + return { code, message }; +} + +export function fsFail(message: string): FsError { + return fsErr(FsErr.Fail, message); +} + +export type FsResult = Result; +export type FsStableResult = StableResult; diff --git a/libs/pure/src/fs/impl/file.ts b/libs/pure/src/fs/impl/file.ts new file mode 100644 index 00000000..2a9a7926 --- /dev/null +++ b/libs/pure/src/fs/impl/file.ts @@ -0,0 +1,216 @@ +import { ResultHandle } from "../../result"; +import { errstr } from "../../utils"; +import { FsFile } from "../FsFile"; +import { FsFileSystemInternal } from "../FsFileSystem"; +import { FsErr, FsError, FsResult, fsErr, fsFail } from "../error"; + +/// Allocate a new file object +export function fsFile(fs: FsFileSystemInternal, path: string): FsFile { + return new FsFileImpl(fs, path); +} + +function errclosed(): FsError { + return fsErr(FsErr.Closed, "File is closed"); +} + +class FsFileImpl implements FsFile { + /// The path of the file + public path: string; + + private closed: boolean; + + /// Reference to the file system so we can read/write + private fs: FsFileSystemInternal; + /// If the file is text + private isText: boolean; + /// Bytes of the file + private buffer: Uint8Array | undefined; + /// If the content in the buffer is different from the content on FS + private isBufferDirty: boolean; + /// The content string of the file + private content: string | undefined; + /// If the content string is newer than the bytes + private isContentNewer: boolean; + /// The last modified time of the file + private lastModified: number | undefined; + + constructor(fs: FsFileSystemInternal, path: string) { + this.closed = false; + this.fs = fs; + this.path = path; + this.isText = false; + this.buffer = undefined; + this.isBufferDirty = false; + this.content = undefined; + this.isContentNewer = false; + this.lastModified = undefined; + } + + public close(): void { + this.closed = true; + } + + public isDirty(): boolean { + return this.isBufferDirty || this.isContentNewer; + } + + public async getLastModified(r: ResultHandle): Promise> { + if (this.closed) { + return r.putErr(errclosed()); + } + if (this.lastModified === undefined) { + r.put(await this.loadIfNotDirty(r)); + if (r.isErr()) { + return r.ret(); + } + } + return r.putOk(this.lastModified ?? 0); + } + + public async getText(r: ResultHandle): Promise> { + if (this.closed) { + return r.putErr(errclosed()); + } + if (this.buffer === undefined) { + r.put(await this.load(r)); + if (r.isErr()) { + return r.ret(); + } + } + if (!this.isText) { + return r.putErr(fsErr(FsErr.InvalidEncoding, "File is not valid UTF-8")); + } + return r.putOk(this.content ?? ""); + } + + public async getBytes(r: ResultHandle): Promise> { + if (this.closed) { + return r.putErr(errclosed()); + } + this.updateBuffer(); + if (this.buffer === undefined) { + r.put(await this.load(r)); + if (r.isErr()) { + return r.ret(); + } + } + if (this.buffer === undefined) { + return r.putErr(fsFail("Read was successful, but content was undefined")); + } + return r.putOk(this.buffer); + } + + public setText(content: string): void { + if (this.closed) { + return; + } + if (this.content === content) { + return; + } + this.content = content; + this.isContentNewer = true; + this.lastModified = new Date().getTime(); + } + + public setBytes(content: Uint8Array): void { + if (this.closed) { + return; + } + this.buffer = content; + this.isBufferDirty = true; + this.decodeBuffer(); + this.isContentNewer = true; + this.lastModified = new Date().getTime(); + } + + public async loadIfNotDirty(r: ResultHandle): Promise> { + if (this.closed) { + return r.putErr(errclosed()); + } + if (this.isDirty()) { + return r.voidOk(); + } + return await this.load(r); + } + + public async load(r: ResultHandle): Promise> { + if (this.closed) { + return r.putErr(errclosed()); + } + r.put(await this.fs.read(r, this.path)); + if (r.isErr()) { + return r.ret(); + } + + const file = r.value; + // check if the file has been modified since last loaded + if (this.lastModified !== undefined) { + if (file.lastModified <= this.lastModified) { + return r.voidOk(); + } + } + this.lastModified = file.lastModified; + // load the buffer + r = r.erase(); + r.put(await r.tryCatchAsync(r, async () => { + this.buffer = new Uint8Array(await file.arrayBuffer()); + })); + if (r.isErr()) { + const error = fsFail(errstr(r.error)); + return r.putErr(error); + } + this.isBufferDirty = false; + // Try decoding the buffer as text + this.decodeBuffer(); + this.isContentNewer = false; + return r.ret(); + } + + public async writeIfNewer(r: ResultHandle): Promise> { + if (this.closed) { + return r.putErr(errclosed()); + } + if (!this.isDirty()) { + return r.voidOk(); + } + return await this.write(r); + } + + /// Write the content without checking if it's dirty. Overwrites the file currently on FS + /// + /// This is private - outside code should only use writeIfDirty + private async write(r: ResultHandle): Promise> { + this.updateBuffer(); + const buffer = this.buffer; + if (this.content === undefined || buffer === undefined) { + // file was never read or modified + return r.voidOk(); + } + r.put(await this.fs.write(r, this.path, buffer)); + if (r.isOk()) { + this.isBufferDirty = false; + } + return r; + } + + private decodeBuffer() { + try { + this.content = new TextDecoder("utf-8", { fatal: true }).decode(this.buffer); + this.isText = true; + } catch (_) { + this.content = undefined; + this.isText = false; + } + } + + /// Encode the content to buffer if it is newer + private updateBuffer() { + if (!this.isContentNewer || this.content === undefined) { + return; + } + const encoder = new TextEncoder(); + this.buffer = encoder.encode(this.content); + this.isBufferDirty = true; + this.isContentNewer = false; + } +} diff --git a/libs/pure/src/fs/impl/fsa.ts b/libs/pure/src/fs/impl/fsa.ts new file mode 100644 index 00000000..0d8b373f --- /dev/null +++ b/libs/pure/src/fs/impl/fsa.ts @@ -0,0 +1,41 @@ +//! FsFileSystem implementation for FileSystemAccess API + +import { ResultHandle } from "../../result"; +import { FsFileSystem, FsFileSystemUninit } from "../FsFileSystem"; +import { FsErr, FsError, FsResult, fsErr } from "../error"; + +type PermissionStatus = "granted" | "denied" | "prompt"; + +/// FileSys implementation that uses FileSystem Access API +/// This is only supported in Chrome/Edge +export class FsImplFsa implements FsFileSystemUninit { + /// If app requested write access + private write: boolean; + private rootPath: string; + private rootHandle: FileSystemDirectoryHandle; + private permissionStatus: PermissionStatus; + + constructor( + rootPath: string, + rootHandle: FileSystemDirectoryHandle, + write: boolean, + ) { + this.rootPath = rootPath; + this.rootHandle = rootHandle; + this.write = write; + this.permissionStatus = "prompt"; + } + + public async init(r: ResultHandle): Promise> { + // @ts-expect-error ts lib does not have requestPermission + this.permissionStatus = await this.rootHandle.requestPermission({ + mode: this.write ? "readwrite" : "read", + }); + if (this.permissionStatus !== "granted") { + return r.putErr(fsErr(FsErr.PermissionDenied, "User denied permission")); + } + return r.putOk(this); + } +} + + diff --git a/libs/pure/src/fs/impl/index.ts b/libs/pure/src/fs/impl/index.ts new file mode 100644 index 00000000..ad7a82c6 --- /dev/null +++ b/libs/pure/src/fs/impl/index.ts @@ -0,0 +1 @@ +export * from "./fsa"; diff --git a/libs/pure/src/fs/index.ts b/libs/pure/src/fs/index.ts new file mode 100644 index 00000000..493f2a74 --- /dev/null +++ b/libs/pure/src/fs/index.ts @@ -0,0 +1 @@ +export * from "./support"; diff --git a/libs/pure/src/fs/open.ts b/libs/pure/src/fs/open.ts new file mode 100644 index 00000000..d749cf4c --- /dev/null +++ b/libs/pure/src/fs/open.ts @@ -0,0 +1,288 @@ +import { ResultHandle } from "../result"; +import { errstr } from "../utils"; +import { FsFileSystem, FsFileSystemUninit } from "./FsFileSystem"; + +import { FsErr, FsError, FsResult, fsErr, fsFail } from "./error"; +import { FsImplFsa } from "./impl"; +import { fsGetSupportStatus } from "./support"; + +/// Handle for handling top level open errors, and decide if the operation should be retried +export type FsOpenRetryHandler = (r: ResultHandle, error: FsError, attempt: number) => Promise>; + +/// Open a file system for read-only access with a directory picker dialog +export async function fsOpenRead(r: ResultHandle, retryHandler?: FsOpenRetryHandler): Promise> { + r.put(await createWithPicker(r, false, retryHandler)); + if (r.isErr()) { + return r.ret(); + } + return await init(r, r.value, retryHandler); +} + +/// Open a file system for read-write access with a directory picker dialog +export async function fsOpenReadWrite(r: ResultHandle, retryHandler?: FsOpenRetryHandler): Promise> { + r.put(await createWithPicker(r, true, retryHandler)); + if (r.isErr()) { + return r.ret(); + } + return await init(r, r.value, retryHandler); +} + +/// Open a file system for read-only access from a DataTransferItem from a drag and drop event +export async function fsOpenReadFrom( + r: ResultHandle, item: DataTransferItem, retryHandler?: FsOpenRetryHandler +): Promise> { + r.put(await createFromDataTransferItem(r, item, false, retryHandler)); + if (r.isErr()) { + return r.ret(); + } + return await init(r, r.value, retryHandler); +} + +/// Open a file system for read-write access from a DataTransferItem from a drag and drop event +export async function fsOpenReadWriteFrom( + r: ResultHandle, item: DataTransferItem, retryHandler?: FsOpenRetryHandler +): Promise> { + r.put(await createFromDataTransferItem(r, item, true, retryHandler)); + if (r.isErr()) { + return r.ret(); + } + return await init(r, r.value, retryHandler); +} + +async function createWithPicker( + r: ResultHandle, write: boolean, retryHandler: FsOpenRetryHandler | undefined +): Promise> { + let attempt = -1; + + while (true) { + attempt++; + const { implementation } = fsGetSupportStatus(); + if (implementation === "FileSystemAccess") { + r.put(await r.tryCatchAsync(r, () => showDirectoryPicker(write))); + if (r.isErr()) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const isAbort = r.error && (r.error as any).name === "AbortError"; + const error = isAbort + ? fsErr(FsErr.UserAbort, "User cancelled the operation") + : fsFail(errstr(r.error)); + if (retryHandler) { + r.put(await retryHandler(r = r.erase(), error, attempt)); + if (r.isErr()) { + // retry handler failed + return r.ret(); + }; + if (r.value) { + // should retry + continue; + } + } + // don't retry + return r.putErr(error); + } + const handle = r.value; + return createFromFileSystemHandle(r, handle, write); + } + // FileEntry API only supported through drag and drop, so fallback to File API + const inputElement = document.createElement("input"); + inputElement.id = "temp"; + inputElement.style.display = "none"; + document.body.appendChild(inputElement); + inputElement.type = "file"; + inputElement.webkitdirectory = true; + + r.put(await new Promise>((resolve) => { + inputElement.addEventListener("change", (event) => { + const files = (event.target as HTMLInputElement).files; + if (!files) { + resolve(r.putErr(fsFail("Failed to get files from input element"))); + return; + } + resolve(createFromFileList(r, files)); + }); + inputElement.click(); + })); + inputElement.remove(); + + if (r.isErr()) { + const error = r.error; + if (retryHandler) { + r.put(await retryHandler(r = r.erase(), error, attempt)); + if (r.isErr()) { + // retry handler failed + return r.ret(); + }; + if (r.value) { + // should retry + continue; + } + } + // don't retry + return r.putErr(error); + } + return r; + } +} + +async function createFromDataTransferItem( + r: ResultHandle, + item: DataTransferItem, + write: boolean, + retryHandler: FsOpenRetryHandler | undefined +): Promise> { + let attempt = -1; + while (true) { + attempt++; + const { implementation } = fsGetSupportStatus(); + // Prefer File System Access API since it supports writing + if ("getAsFileSystemHandle" in item && implementation === "FileSystemAccess") { + r.put(r.tryCatch(r, () => getAsFileSystemHandle(item))); + if (r.isOk()) { + const handle = r.value; + return createFromFileSystemHandle(r, handle, write); + } + // handle error + if (retryHandler) { + const error = fsFail("Failed to get handle from DataTransferItem"); + r.put(await retryHandler(r = r.erase(), error, attempt)); + if (r.isErr()) { + // retry handler failed + return r.ret(); + }; + if (r.value) { + // should retry + continue; + } + // don't retry + return r.putErr(error); + } + // fall through + } + + // if FileSystemAccess doesn't work, try FileEntry + + if ("webkitGetAsEntry" in item && implementation === "FileEntry") { + r.put(r.tryCatch(r, () => webkitGetAsEntry(item))); + if (r.isOk()) { + const entry = r.value; + return createFromFileSystemEntry(r, entry); + } + // handle error + if (retryHandler) { + const error = fsFail("Failed to get entry from DataTransferItem"); + r.put(await retryHandler(r = r.erase(), error, attempt)); + if (r.isErr()) { + // retry handler failed + return r.ret(); + }; + if (r.value) { + // should retry + continue; + } + // don't retry + return r.putErr(error); + } + } + break; + } + + return r.putErr(fsErr(FsErr.NotSupported, "File system is not supported in the current environment")); +} + +async function init( + r: ResultHandle, fs: FsFileSystemUninit, retryHandler: FsOpenRetryHandler | undefined +): Promise> { + let attempt = -1; + while(true) { + attempt++; + r.put(await fs.init(r)); + if (r.isOk()) { + return r; + } + if (!retryHandler) { + return r; + } + const error = r.error; + r = r.erase(); + r.put(await retryHandler(r, error, attempt)); + if (r.isErr()) { + // retry handler failed + return r.ret(); + } + if (!r.value) { + // should not retry + return r.putErr(error); + } + } +} + +/// Wrapper for window.showDirectoryPicker +function showDirectoryPicker(write: boolean): Promise { + // @ts-expect-error showDirectoryPicker is not in the TS lib + return window.showDirectoryPicker({ mode: write ? "readwrite" : "read" }); +} + +/// Wrapper for DataTransferItem.getAsFileSystemHandle +function getAsFileSystemHandle(item: DataTransferItem): FileSystemHandle { + // @ts-expect-error getAsFileSystemHandle is not in the TS lib + const handle = item.getAsFileSystemHandle(); + if (!handle) { + throw new Error("Failed to get handle from DataTransferItem"); + } + return handle; +} + +/// Wrapper for DataTransferItem.webkitGetAsEntry +function webkitGetAsEntry(item: DataTransferItem): FileSystemEntry { + const entry = item.webkitGetAsEntry(); + if (!entry) { + throw new Error("Failed to get entry from DataTransferItem"); + } + return entry; +} + +function createFromFileSystemHandle( + r: ResultHandle, handle: FileSystemHandle, write: boolean +): FsResult { + if (handle.kind !== "directory") { + return r.putErr(fsErr(FsErr.IsFile, "Expected directory")); + } + + const fs = new FsImplFsa( + handle.name, + handle as FileSystemDirectoryHandle, + write + ); + + return r.putOk(fs); +}; + +function createFromFileSystemEntry( + r: ResultHandle, entry: FileSystemEntry, +): FsResult { + if (entry.isFile || !entry.isDirectory) { + return r.putErr(fsErr(FsErr.IsFile, "Expected directory")); + } + const fs = new FileEntriesApiFileSys( + entry.name, + entry as FileSystemDirectoryEntry, + ); + return r.putOk(fs); +} + +function createFromFileList( + r: ResultHandle, files: FileList +): FsResult { + if (!files.length) { + return r.putErr(fsFail("Expected at least one file")); + } + const rootName = files[0].webkitRelativePath.split("/", 1)[0]; + const fileMap: Record = {}; + for (let i = 0; i < files.length; i++) { + const file = files[i]; + // remove "/" + const path = file.webkitRelativePath.slice(rootName.length + 1); + fileMap[path] = file; + } + const fs = new FileApiFileSys(rootName, fileMap); + return r.putOk(fs); +}; diff --git a/libs/pure/src/fs/path.ts b/libs/pure/src/fs/path.ts new file mode 100644 index 00000000..174b8b04 --- /dev/null +++ b/libs/pure/src/fs/path.ts @@ -0,0 +1,123 @@ +//! Path utilities +//! +//! The library has the following path standard: +//! - All paths are relative (without leading /) to the root +//! of the file system (i.e. the uploaded directory) +//! - Paths are always separated by / +//! - Empty string denotes root +//! - Paths cannot lead outside of root + +import { ResultHandle } from "../result"; +import { FsErr, FsResult, fsErr } from "./error"; + +/// Get the root path. Current implementation is empty string. +export function fsRoot(): string { + return ""; +} + +/// Check if a path is the root directory, also handles badly formatted paths like ".///../" +export function fsIsRoot(p: string): boolean { + if (!p) { + return true; + } + for (let i = 0; i < p.length; i++) { + if (p[i] !== "/" || p[i] !== "." || p[i] !== "\\") { + return false; + } + } + return true; +} + +/// Get the base name of a path (i.e. remove the last component) +/// +/// If this path is the root directory, return InvalidPath. +export function fsGetBase(r: ResultHandle, p: string): FsResult { + if (fsIsRoot(p)) { + return r.putErr(fsErr(FsErr.InvalidPath, "Trying to get the parent of root")); + } + const i = Math.max(p.lastIndexOf("/"), p.lastIndexOf("\\")); + if (i < 0) { + return r.putOk(fsRoot()); + } + return r.putOk(p.substring(0, i)); +} + +/// Get the name of a path (i.e. the last component) +/// +/// Returns the last component of the path. +/// Does not include leading or trailing slashes. +/// +/// If this path is the root directory, return IsRoot. +export function fsGetName(r: ResultHandle, p: string): FsResult { + p = stripTrailingSlashes(p); + if (fsIsRoot(p)) { + return r.putErr(fsErr(FsErr.IsRoot, "Root directory has no name")); + } + const i = Math.max(p.lastIndexOf("/"), p.lastIndexOf("\\")); + if (i < 0) { + return r.putOk(p); + } + return r.putOk(p.substring(i + 1)); +} + +/// Normalize .. and . in a path +/// +/// Returns InvalidPath if the path tries to escape the root directory. +export function fsNormalize(r: ResultHandle, p: string): FsResult { + let s = fsRoot(); + for (const comp of fsComponents(p)) { + if (comp === "..") { + r.put(fsGetBase(r, s)); + if (r.isErr()) { + return r; + } + s = r.value; + continue; + } + s = fsJoin(s, comp); + } + return r.putOk(s); +} + +/// Join two paths +export function fsJoin(p1: string, p2: string): string { + return p1 + "/" + p2; +} + +/// Iterate through the components of a path. Empty components and . are skipped +export function* fsComponents(p: string): Iterable { + let i = 0; + while (i < p.length) { + let nextSlash = p.indexOf("/", i); + if (nextSlash < 0) { + nextSlash = p.length; + } + let nextBackslash = p.indexOf("\\", i); + if (nextBackslash < 0) { + nextBackslash = p.length; + } + let j = Math.min(nextSlash, nextBackslash); + if (j < 0) { + j = p.length; + } + const c = p.substring(i, j); + if (c && c !== ".") { + yield c; + } + i = j + 1; + } +} + +/// Remove trailing slashes from a path +function stripTrailingSlashes(p: string): string { + let i = p.length - 1; + for (; i >= 0; i--) { + if (p[i] !== "/" && p[i] !== "\\") { + break; + } + } + if (i === p.length - 1) { + return p; + } + return p.substring(0, i + 1); +} diff --git a/libs/pure/src/fs/support.ts b/libs/pure/src/fs/support.ts new file mode 100644 index 00000000..b4cfe332 --- /dev/null +++ b/libs/pure/src/fs/support.ts @@ -0,0 +1,98 @@ + +/// What is supported by the current environment +export type FsSupportStatus = { + /// Returned by window.isSecureContext + isSecureContext: boolean; + + /// The implementation for FsFileSystem used + /// + /// See README.md for more information + implementation: "File" | "FileSystemAccess" | "FileEntry"; +} + +/// Capabilities of the file system implementation +export type FsCapabilities = { + /// Can the browser directly write to the file system + write: boolean; + /// Can the browser detect structure updates (file/directory creation/deletion) + live: boolean; +} + +/// Get which implementation will be used for the current environment +export function fsGetSupportStatus(): FsSupportStatus { + if (isFileSystemAccessSupported()) { + return { + isSecureContext: window.isSecureContext, + implementation: "FileSystemAccess", + }; + } + if (isFileEntrySupported()) { + return { + isSecureContext: window.isSecureContext, + implementation: "FileEntry", + }; + } + + return { + isSecureContext: !!window && window.isSecureContext, + implementation: "File", + }; +} + +function isFileSystemAccessSupported() { + if (!window) { + return false; + } + if (!window.isSecureContext) { + // In Chrome, you can still access the APIs but they just crash the page entirely + return false; + } + if (!window.FileSystemDirectoryHandle) { + return false; + } + + if (!window.FileSystemFileHandle) { + return false; + } + + // since TSlib doesn't have these, let's check here + + // @ts-expect-error FileSystemDirectoryHandle should have a values() method + if (!window.FileSystemDirectoryHandle.prototype.values) { + return false; + } + + // @ts-expect-error window should have showDirectoryPicker + if (!window.showDirectoryPicker) { + return false; + } + + return true; +}; + +function isFileEntrySupported(): boolean { + if (!window) { + return false; + } + + // Chrome/Edge has this but it's named DirectoryEntry + // AND, they don't work (I forgot how exactly they don't work) + + if ( + navigator && + navigator.userAgent && + navigator.userAgent.includes("Chrome") + ) { + return false; + } + + if (!window.FileSystemDirectoryEntry) { + return false; + } + + if (!window.FileSystemFileEntry) { + return false; + } + + return true; +}; diff --git a/libs/pure/result/README.md b/libs/pure/src/result/README.md similarity index 97% rename from libs/pure/result/README.md rename to libs/pure/src/result/README.md index 0d277508..f70f75de 100644 --- a/libs/pure/result/README.md +++ b/libs/pure/src/result/README.md @@ -216,12 +216,11 @@ const result = await tryInvokeAsync((r) => multiplyFormatAsync(r, "a", "b", "ans const result = tryCatch(() => JSON.parse(...)); // result has type StableResult -// 4. Use tryCatchAsync to await on a promise that can throw -// note that it takes the promise directly, not the function +// 4. Use tryCatchAsync to wrap an async function that can throw async function doStuff() { throw "oops"; } -const result = await tryCatchAsync(doStuff()); +const result = await tryCatchAsync(doStuff); ``` ## Innermost call site @@ -248,7 +247,7 @@ async function doSomethingThatCouldThrowAsync(): Promise { async function foo(r: ResultHandle): Promise> { // r.erase() is only needed if there are previous usage of r // in this function - r.put(await r.tryCatchAsync(r = r.erase(), doSomethingThatCouldThrowAsync())); + r.put(await r.tryCatchAsync(r = r.erase(), doSomethingThatCouldThrowAsync)); // type of r is Result } ``` diff --git a/libs/pure/result/index.d.ts b/libs/pure/src/result/index.d.ts similarity index 95% rename from libs/pure/result/index.d.ts rename to libs/pure/src/result/index.d.ts index d25f4e0a..bc0f1903 100644 --- a/libs/pure/result/index.d.ts +++ b/libs/pure/src/result/index.d.ts @@ -23,7 +23,7 @@ export interface ResultHandle { /// Await a throwing promise inside a result-handling function, /// capturing the result inside this handle - tryCatchAsync: (r: ResultHandle, promise: Promise) => Promise>, + tryCatchAsync: (r: ResultHandle, fn: () => Promise) => Promise>, /// Put an ok value into this handle /// @@ -98,5 +98,5 @@ export function tryInvokeAsync(fn: (r: ResultHandle) => Promise(fn: () => T): StableResult; /// Wrap a promise that may throw when awaited and return a Result, capturing the error -export function tryCatchAsync(x: Promise): Promise>; +export function tryCatchAsync(fn: () => Promise): Promise>; diff --git a/libs/pure/result/index.js b/libs/pure/src/result/index.js similarity index 93% rename from libs/pure/result/index.js rename to libs/pure/src/result/index.js index 8d84c256..0ccf6ecc 100644 --- a/libs/pure/result/index.js +++ b/libs/pure/src/result/index.js @@ -26,12 +26,12 @@ class ResultImpl { return r; } - async tryCatchAsync(r, promise) { + async tryCatchAsync(r, fn) { if (this !== r) { console.warn("pure/result: Violation! You must pass the same handle to tryCatchAsync() as the handle it's invoked from (i.e. x.tryCatch(x))!"); } try { - r.putOk(await promise); + r.putOk(await fn()); } catch (e) { r.putErr(e); } @@ -112,9 +112,9 @@ export function tryCatch(fn) { } } -export async function tryCatchAsync(promise) { +export async function tryCatchAsync(fn) { try { - return new StableOk(await promise); + return new StableOk(await fn()); } catch (e) { return new StableErr(e); } diff --git a/libs/pure/src/utils/index.ts b/libs/pure/src/utils/index.ts new file mode 100644 index 00000000..94b31d27 --- /dev/null +++ b/libs/pure/src/utils/index.ts @@ -0,0 +1,13 @@ + +/// Try converting an error to a string +export function errstr(e: unknown): string { + if (typeof e === "string") { + return e; + } + if (e && typeof e === "object" && "message" in e) { + if (typeof e.message === "string") { + return e.message; + } + } + return `${e}`; +} diff --git a/libs/pure/src/utils/lock.ts b/libs/pure/src/utils/lock.ts new file mode 100644 index 00000000..560d9ddd --- /dev/null +++ b/libs/pure/src/utils/lock.ts @@ -0,0 +1,85 @@ +import Deque from "denque"; + +/// Ensure you have exclusive access in concurrent code +/// +/// Only guaranteed if no one else has reference to the inner object +export class RwLock { + private inner: T; + + private readers: number = 0; + private isWriting: boolean = false; + private readWaiters: Deque<() => void> = new Deque(); + private writeWaiters: Deque<() => void> = new Deque(); + + constructor(t: T) { + this.inner = t; + } + + /// Acquire a read lock and call fn with the value. Release the lock when fn returns or throws. + public async scopedRead(fn: (t: T) => Promise): Promise { + if (this.isWriting) { + await new Promise((resolve) => { + // need to check again to make sure it's not already done + if (this.isWriting) { + this.readWaiters.push(resolve); + return; + } + resolve(); + }); + } + // acquired + this.readers++; + try { + return await fn(this.inner); + } finally { + this.readers--; + if (this.writeWaiters.length > 0) { + if (this.readers === 0) { + // notify one writer + this.writeWaiters.shift()!(); + } + // don't notify anyone if there are still readers + } else { + // notify all readers + while (this.readWaiters.length > 0) { + this.readWaiters.shift()!(); + } + } + } + } + + /// Acquire a write lock and call fn with the value. Release the lock when fn returns or throws. + /// + /// fn takes a setter function, which you can use to update the value like `x = set(newX)` + public async scopedWrite(fn: (t: T, setter: RwLockSetter) => Promise): Promise { + if (this.isWriting || this.readers > 0) { + await new Promise((resolve) => { + // need to check again to make sure it's not already done + if (this.isWriting || this.readers > 0) { + this.writeWaiters.push(resolve); + return; + } + resolve(); + }); + } + // acquired + this.isWriting = true; + try { + return await fn(this.inner, (t: T) => { + this.inner = t; + return t; + }); + } finally { + this.isWriting = false; + if (this.readWaiters.length > 0) { + // notify one reader + this.readWaiters.shift()!(); + } else if (this.writeWaiters.length > 0) { + // notify one writer + this.writeWaiters.shift()!(); + } + } + } +} + +export type RwLockSetter = (t: T) => T; diff --git a/libs/tsconfig.json b/libs/tsconfig.json new file mode 100644 index 00000000..65d3c4a6 --- /dev/null +++ b/libs/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "target": "ESNext", + "useDefineForClassFields": true, + "lib": ["ES2021", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + + "baseUrl": ".", + }, + "include": ["pure/src"], + "references": [{ "path": "./tsconfig.node.json" }] +} diff --git a/web-client/src/core/kernel/Kernel.ts b/web-client/src/core/kernel/Kernel.ts index 1478ee3e..9bf4bee8 100644 --- a/web-client/src/core/kernel/Kernel.ts +++ b/web-client/src/core/kernel/Kernel.ts @@ -293,7 +293,7 @@ export class Kernel implements KernelAccess { this.editor = editor; this.updateRootPathInStore(fileSys); const compiler = await this.getCompiler(); - await compiler.init(this.editor.getFileAccess()); + await compiler.init(editor.getFileAccess()); // trigger a first run when loading new project compiler.compile(); diff --git a/web-client/src/low/fs/FileSystemAccessApiFileSys.ts b/web-client/src/low/fs/FileSystemAccessApiFileSys.ts index c6b6b08c..43421c4a 100644 --- a/web-client/src/low/fs/FileSystemAccessApiFileSys.ts +++ b/web-client/src/low/fs/FileSystemAccessApiFileSys.ts @@ -1,4 +1,7 @@ +import { ResultHandle } from "pure/result"; + import { console, allocErr, allocOk, wrapAsync } from "low/utils"; + import { FileSys } from "./FileSys"; import { FsPath } from "./FsPath"; import { FsResult, FsResultCodes } from "./FsResult"; @@ -103,38 +106,40 @@ export class FileSystemAccessApiFileSys implements FileSys { return result.makeOk(entries); } - async resolveDir( + private async resolveDir( + r: ResultHandle, path: FsPath, ): Promise> { if (path.isRoot) { - return allocOk(this.rootHandle); + return r.putOk(this.rootHandle); } - const parentPathResult = path.parent; - if (parentPathResult.isErr()) { - return parentPathResult; + r.put(path.getParent(r)); + if (r.isErr()) { + return r.ret(); } + const parentPath = r.value; - const parentDirResult = await this.resolveDir(parentPathResult.inner()); - if (parentDirResult.isErr()) { - return parentDirResult; + r.put(await this.resolveDir(r = r.erase(), parentPath)); + if (r.isErr()) { + return r; } + const parentDirHandle = r.value; - const parentDirHandle = parentDirResult.inner(); - const pathNameResult = path.name; - if (pathNameResult.isErr()) { - return pathNameResult; + r.put(path.getName(r = r.erase())); + if (r.isErr()) { + return r.ret(); } + const pathName = r.value; - const result = await wrapAsync(() => { - return parentDirHandle.getDirectoryHandle(pathNameResult.inner()); - }); - if (result.isErr()) { - console.error(result.inner()); - return result.makeErr(FsResultCodes.Fail); + r = r.erase(); + r.put(await r.tryCatchAsync(r, parentDirHandle.getDirectoryHandle(pathName))); + if (r.isErr()) { + console.error(r.error); + return r.putErr(FsResultCodes.Fail); } - return result; + return r.ret(); } public async readFile(path: FsPath): Promise> { diff --git a/web-client/src/low/fs/FsPath.ts b/web-client/src/low/fs/FsPath.ts index 0546a331..4f7120c5 100644 --- a/web-client/src/low/fs/FsPath.ts +++ b/web-client/src/low/fs/FsPath.ts @@ -103,13 +103,13 @@ class FsPathImpl implements FsPath { return new FsPathImpl(this.underlying + "/" + cleanPath(path)); } - public resolveSibling(r: ResultHandle, path: string): FsResult { - r.put(this.getParent(r)); - if (r.isErr()) { - return r; - } - return r.putOk(r.value.resolve(path)); - } + // public resolveSibling(r: ResultHandle, path: string): FsResult { + // r.put(this.getParent(r)); + // if (r.isErr()) { + // return r; + // } + // return r.putOk(r.value.resolve(path)); + // } } const cleanPath = (path: string) => { diff --git a/web-client/src/low/fs/index.ts b/web-client/src/low/fs/index.ts index 85538200..c5e7fe40 100644 --- a/web-client/src/low/fs/index.ts +++ b/web-client/src/low/fs/index.ts @@ -11,4 +11,4 @@ export * from "./FileSys"; export * from "./FsResult"; export * from "./FsFile"; export * from "./FsPath"; -export * from "./create"; +export * from "./open"; diff --git a/web-client/src/low/fs/create.ts b/web-client/src/low/fs/open.ts similarity index 98% rename from web-client/src/low/fs/create.ts rename to web-client/src/low/fs/open.ts index cf2f1f7f..b1bad147 100644 --- a/web-client/src/low/fs/create.ts +++ b/web-client/src/low/fs/open.ts @@ -1,4 +1,5 @@ -//! Utils for creating FileSys +//! Utils for opening FileSys + import { ResultHandle } from "pure/result"; import { console } from "low/utils"; @@ -15,7 +16,7 @@ import { import { FsResult, FsResultCodes } from "./FsResult"; import { FileApiFileSys } from "./FileApiFileSys"; -export async function showDirectoryPicker(r: ResultHandle): Promise> => { +export async function showDirectoryPicker(r: ResultHandle): Promise> { if (isFileSystemAccessApiSupported()) { try { // @ts-expect-error showDirectoryPicker is not in the TS lib @@ -54,7 +55,9 @@ export async function showDirectoryPicker(r: ResultHandle): Promise