From 6841bd9dacdb79cb3b660c46912660df8616b8bc Mon Sep 17 00:00:00 2001 From: Pistonight Date: Wed, 21 Feb 2024 21:47:08 -0800 Subject: [PATCH 01/10] pure/result --- libs/pure/result/README.md | 283 ++++++++++++++++++++++++++++++++++++ libs/pure/result/index.d.ts | 102 +++++++++++++ libs/pure/result/index.js | 121 +++++++++++++++ web-client/tsconfig.json | 7 +- 4 files changed, 511 insertions(+), 2 deletions(-) create mode 100644 libs/pure/result/README.md create mode 100644 libs/pure/result/index.d.ts create mode 100644 libs/pure/result/index.js diff --git a/libs/pure/result/README.md b/libs/pure/result/README.md new file mode 100644 index 00000000..0d277508 --- /dev/null +++ b/libs/pure/result/README.md @@ -0,0 +1,283 @@ +# pure/result + +TypeScript based result return type, inspired by Rust, but not completely the same. + +This project is used internally in my own projects. If you want to depend on it, +simply copy the files over to your project. + +## Function that can fail +Instead of having functions throw, make it return instead. +```typescript +// Instead of +function doSomethingCanFail() { + if (Math.random() < 0.5) { + return; + } + throw "oops"; +} +// Do this (what ResultHandle and Result are will be explained below) +import type { ResultHandle, Result } from "pure/result"; + +function doSomethingCanFail(r: ResultHandle): Result { + if (Math.random() < 0.5) { + return r.voidOk(); + } + return r.putErr("oops"); +} +``` +This is similar to Rust: +```rust +fn do_something_can_fail() -> Result<(), String> { + if ... { + return Ok(()); + } + + Err("oops".to_string()) +} +``` + +## Calling function that can fail +A function that returns `Result` should take in `ResultHandle` as one of the parameters, +and use it to interact with the result system. + +The `ResultHandle` is actually the same object as the `Result`. The functions you call +are only for TypeScript magic. You can think of `ResultHandle` as uninitialized `Result`. + +This example below shows this interaction: +```typescript +function getParam(r: ResultHandle, name: string): Result { + if (name === "a") { + // `putOk` mutates r to contain an Ok value + return r.putOk(13); // the return expression has type Result + } + if (name === "b") { + return r.putOk(42); + } + // `putErr` mutates r to contain an Err value + return r.putErr(new Error("bad name")); +} + +function multiplyFormat( + r: ResultHandle, + name1: string, + name2: string, + prefix: string +): Result { + // breaking this down to individual steps so I can explain the TypeScript magic + r.put( + // when calling this, r has type ResultHandle + getParam(r, name1) // The return type is Result + ); // calling `put` will do nothing, but use TypeScript magic to make the type of r Result now + // (if you call `put` with a parameter that is not `this`, it will give a warning and try to copy the result over) + + // now that r is `Result`, you can use isOk and isErr to check the result + if (r.isErr()) { + // here the type of r is Err + console.error(r.error); // `error` property is only accessible after the check + + // ret() gives r back, but casted to the right type (can be casted to any Ok type) + // without ret(), r is Result, which is not assignable to Result + return r.ret(); + } + + // here, r is not Err + // so r is ResultHandle & UncheckedResult & Ok + // which means we can get the value + const v1 = r.value; + + // now we want to reuse r to handle the next call + // by calling r.erase(), it returns r back with ResultHandle type + // since that's is compatible with the function parameter, + // we can assign it and erase the previous handled information TypeScript knows + r.put(getParam(r = r.erase(), name2)); + if (r.isErr()) { + return r.ret(); + } + const v2 = r.value; + + const formatted = `${prefix}${v1 * v2}`; + return r.putOk(formatted); +} +``` + +You might be thinking, why all the TypeScript magic?? Why not just do this: +```typescript +type Result = { ok: true, value: T } | { ok: false, error: E }; +``` +I have 2 reasons: +1. Unlike Rust, you cannot redeclare a variable to shadow the previous declaration. With a naive implementation, you end up with: + ```typescript + function foo() { + const result1 = doSomething(); + if (!result1.ok) { + return result1; + } + const v1 = result1.value; + const result2 = doSomethingElse(); + if (!result2.ok) { + return result2; + } + const v2 = result2.value; + } + ``` + Note the temporary `result1` and `result2`, which doesn't look pretty. + +2. You need to constantly create and destructure objects. This could be a performance issue, but I never + benchmarked anything, so it could just be my imagination. (In fact, my approach could perform worse) + +## Holding on to result +One issue left is that since we are using the same `r` handle, we could run into concurrency issues. +Say the example above becomes async: +```typescript +async function getParam(r: ResultHandle, name: string): Promise> { + ... +} + +async function multiplyFormat( + r: ResultHandle, + name1: string, + name2: string, + prefix: string +): Promise> { + r.put(await getParam(r, name1)); + if (r.isErr()) { + return r.ret(); + } + const v1 = r.value; + + r.put(await getParam(r = r.erase(), name2)); + if (r.isErr()) { + return r.ret(); + } + const v2 = r.value; + + const formatted = `${prefix}${v1 * v2}`; + return r.putOk(formatted); +} +``` +The problem comes if we want to call both `getParam` first, then await together: +```typescript +async function multiplyFormatAsync( + r: ResultHandle, + name1: string, + name2: string, + prefix: string +): Promise> { + await Promise.all([getParam(r, name1), getParam(r = r.erase(), name2)]); + // since getParam will store the result in r directly, we lost one value +} +``` +To overcome this, `fork()` is provided. It creates an empty ResultHandle. +Despite the name, it will not contain any value from the original handle. +```typescript +async function multiplyFormatAsync( + r1: ResultHandle, + name1: string, + name2: string, + prefix: string +): Promise> { + const r2 = r1.fork(); + await Promise.all([ + r1.put(getParam(r1, name1)), + r2.put(getParam(r2, name2)) + ]); + if (r1.isErr()) { + return r1.ret(); + } + if (r2.isErr()) { + return r2.ret(); + } + const formatted = `${prefix}${r1.value * v2.value}`; + // you must use r1, not r2 here, since that's the parameter passed in + return r1.putOk(formatted); +} +``` + +## Outermost call site +The last question remains: how to get `ResultHandle` in the first place to pass +to a function? This library provides 4 utility functions to initiate the call. +```typescript +import { tryCatch, tryCatchAsync, tryInvoke, tryInvokeAsync } from "pure/result"; + +// 1. Use tryInvoke to get a handle for invoking functions that return result +const result = tryInvoke(r => multiplyFormat(r, "a", "b", "answer: ")); +// the type of result is StableResult +// you can call isOk and isErr on it, +// but cannot call putOk or putErr like you would with a ResultHandle +if (result.isOk()) { + console.log(result.value) // 42 * 13 = 546 +} + +// 2. Use tryInvoke to do the same, but async +// tryInvokeAsync takes in a (r: ResultHandle) => Promise> +const result = await tryInvokeAsync((r) => multiplyFormatAsync(r, "a", "b", "answer: ")); + +// 3. Use tryCatch to wrap a function that throws with try-catch +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 +async function doStuff() { + throw "oops"; +} +const result = await tryCatchAsync(doStuff()); +``` + +## Innermost call site +What if you need to call a throwing function inside a result-handling function? +Use `r.tryCatch` and `r.tryCatchAsync` +```typescript +import { ResultHandle, Result } from "pure/result"; + +function doSomethingThatCouldThrow(): FooType { + ... +} + +function foo(r: ResultHandle): Result { + // r.erase() is only needed if there are previous usage of r + // in this function + r.put(r.tryCatch(r = r.erase(), doSomethingThatCouldThrow)); + // type of r is Result +} + +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())); + // type of r is Result +} +``` + +## Why is there no `match`/`map`/`mapErr`, etc? + +If you are thinking this is a great idea: +```typescript +const result = tryInvoke(foo); +result.match( + (okValue) => { + // handle ok case + }, + (errValue) => { + // handle err case + }, +); +``` +The vanilla `if` doesn't allocate the closures, and has less code, and you can +control the flow properly inside the blocks with `return`/`break`/`continue` +```typescript +const result = tryInvoke(foo); +if (result.isOk()) { + // handle ok case +} else { + // handle err case +} +``` + +As for the other utility functions from Rust's Result type, they really only benefit +because you can early return with `?` AND those abstractions are zero-cost in Rust. +Neither is true in JavaScript. Please just handle it in the most straightforward way. diff --git a/libs/pure/result/index.d.ts b/libs/pure/result/index.d.ts new file mode 100644 index 00000000..237cdd00 --- /dev/null +++ b/libs/pure/result/index.d.ts @@ -0,0 +1,102 @@ +//! pure/result +//! +//! TypeScript based result return type. See README.md for more information. + +/// Handle used to interact with the result system +/// inside a function +/// +/// The functions here are just for TypeScript magic. See README.md +/// for how to use them +export interface ResultHandle { + /// Erase the types inferred by TypeScript + /// + /// The typical usage is `r = r.erase()` + /// NOTE: this does NOT actually erase the value!!! + erase: () => ResultHandle, + + /// Put a result into the handle for checking + put: (r: Result) => asserts this is Result, + + /// Call a throwing function inside a result-handling function, + /// capturing the result inside this handle + tryCatch: (r: ResultHandle, fn: () => T) => Result, + + /// Await a throwing promise inside a result-handling function, + /// capturing the result inside this handle + tryCatchAsync: (r: ResultHandle, promise: Promise) => Promise>, + + /// Put an ok value into this handle + /// + /// Typically used at return position (i.e. `return r.putOk(value)`) + putOk: (value: T) => Result, + + /// Put an ok value as void (undefined) + /// + /// Typically used at return position (i.e. `return r.voidOk()`) + voidOk: () => Result, + + /// Put an error value into this handle + /// + /// Typically used at return position (i.e. `return r.putErr(error)`) + putErr: (error: E) => Result, + + /// Create a new handle detached from this one + /// + /// See README.md for when this is needed + fork: () => ResultHandle, +} + +/// Type of result before it is checked +export interface UncheckedResult { + isOk: () => this is Ok, + isErr: () => this is Err, +} + + +/// Type of result used internally in functions that take ResultHandle +/// This can be converted back to ResultHandle by calling `erase()` +export type Result = ResultHandle & UncheckedResult & (Ok | Err); + +/// Result checked to be Ok +export interface Ok extends StableOk { + /// Cast the value back to a result with any error type + /// + /// Used to re-return the result. See README.md for more information + ret: () => Result, +} + +/// Result checked to be Err +interface Err extends StableErr { + /// Cast the value back to a result with any error type + /// + /// Used to re-return the result. See README.md for more information + ret: () => Result, +} + +/// Type of result returned by the tryXXX wrapper functions +/// +/// This result is detached from the handle and will not leak information. +/// For example, an Ok result will only contain the value, not temporary error +/// previous stored. +export type StableResult = StableUncheckedResult & (StableOk | StableErr); +export interface StableUncheckedResult { + isOk: () => this is StableOk, + isErr: () => this is StableErr, +} +export type StableOk = { value: T }; +export type StableErr = { error: E }; + +/// Invoke a function that takes a ResultHandle and return a Result +export function tryInvoke(fn: (r: ResultHandle) => Result): StableResult; + +/// Invoke an async function that takes a ResultHandle and return a Promsie +/// +/// Note that if the async function throws, it will NOT be captured +export function tryInvokeAsync(fn: (r: ResultHandle) => Promise>): Promise>; + +/// Wrap a function that may throw an error and return a Result, capturing the error +export function tryCatch(fn: () => T): StableResult; + +/// Wrap a promise that may throw when awaited and return a Result, capturing the error +export function tryCatchAsync(x: Promise): Promise>; + diff --git a/libs/pure/result/index.js b/libs/pure/result/index.js new file mode 100644 index 00000000..8d84c256 --- /dev/null +++ b/libs/pure/result/index.js @@ -0,0 +1,121 @@ +/// Implementation for ResultHandle and Result +class ResultImpl { + constructor() { + this.ok = false; + this.inner = undefined; + } + + erase() { return this; } + put(r) { + if (this !== r) { + console.warn("pure/result: Violation! You must pass the same handle to put() as the handle it's invoked from (i.e. x.put(x))!"); + this.ok = r.ok; + this.inner = r.inner; + } + } + + tryCatch(r, fn) { + if (this !== r) { + console.warn("pure/result: Violation! You must pass the same handle to tryCatch() as the handle it's invoked from (i.e. x.tryCatch(x))!"); + } + try { + r.putOk(fn(r)); + } catch (e) { + r.putErr(e); + } + return r; + } + + async tryCatchAsync(r, promise) { + 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); + } catch (e) { + r.putErr(e); + } + return r; + } + + + putOk(value) { + this.ok = true; + this.inner = value; + return this; + } + voidOk() { + this.ok = true; + this.inner = undefined; + return this; + } + putErr(error) { + this.ok = false; + this.inner = error; + return this; + } + fork() { + return new ResultImpl(); + } + + isOk() { return this.ok; } + isErr() { return !this.ok; } + ret() { return this; } + + // private + + toStable() { + if (this.ok) { + return new StableOk(this.inner); + } + return new StableErr(this.inner); + } +} + +/// Implementation for StableOk +class StableOk { + constructor(inner) { + this.inner = inner; + } + + get value() { return this.inner; } + isOk() { return true; } + isErr() { return false; } +} + +/// Implementation for StableOk +class StableErr { + constructor(inner) { + this.inner = inner; + } + + get error() { return this.inner; } + isOk() { return false; } + isErr() { return true; } +} + +/// Wrappers + +export function tryInvoke(fn) { + return fn(new ResultImpl()).toStable(); +} + +export async function tryInvokeAsync(fn) { + (await fn(new ResultImpl())).toStable(); +} + +export function tryCatch(fn) { + try { + return new StableOk(fn()); + } catch (e) { + return new StableErr(e); + } +} + +export async function tryCatchAsync(promise) { + try { + return new StableOk(await promise); + } catch (e) { + return new StableErr(e); + } +} diff --git a/web-client/tsconfig.json b/web-client/tsconfig.json index 84e59b69..b9f0a173 100644 --- a/web-client/tsconfig.json +++ b/web-client/tsconfig.json @@ -20,8 +20,11 @@ "noUnusedParameters": true, "noFallthroughCasesInSwitch": true, - "baseUrl": "src" + "baseUrl": "src", + "paths": { + "pure/result": ["../libs/pure/result"], + } }, - "include": ["src"], + "include": ["src", "../libs/pure/result"], "references": [{ "path": "./tsconfig.node.json" }] } From 882438f8ea36878dac7d003879e2a22527ad5ffd Mon Sep 17 00:00:00 2001 From: Pistonight Date: Wed, 21 Feb 2024 22:59:30 -0800 Subject: [PATCH 02/10] start migrating old result system --- libs/pure/result/index.d.ts | 2 +- web-client/src/core/kernel/AlertMgr.ts | 90 ++++-------------- web-client/src/core/kernel/Kernel.ts | 21 +++-- web-client/src/core/stage/state.ts | 9 -- web-client/src/low/fs/FileAccess.ts | 3 + web-client/src/low/fs/FileSys.ts | 9 +- web-client/src/low/fs/FsFile.ts | 2 +- web-client/src/low/fs/FsPath.ts | 33 ++++--- web-client/src/low/fs/FsResult.ts | 3 +- web-client/src/low/fs/create.ts | 6 +- web-client/src/low/utils/Alert.ts | 93 +++++++++++++++++++ web-client/src/low/utils/Pool.ts | 21 ----- web-client/src/low/utils/Result.d.ts | 41 -------- web-client/src/low/utils/Result.js | 68 -------------- web-client/src/low/utils/index.ts | 3 +- .../src/ui/toolbar/OpenCloseProject.tsx | 3 +- web-client/tsconfig.json | 2 +- 17 files changed, 161 insertions(+), 248 deletions(-) create mode 100644 web-client/src/low/utils/Alert.ts delete mode 100644 web-client/src/low/utils/Pool.ts delete mode 100644 web-client/src/low/utils/Result.d.ts delete mode 100644 web-client/src/low/utils/Result.js diff --git a/libs/pure/result/index.d.ts b/libs/pure/result/index.d.ts index 237cdd00..d25f4e0a 100644 --- a/libs/pure/result/index.d.ts +++ b/libs/pure/result/index.d.ts @@ -92,7 +92,7 @@ export function tryInvoke(fn: (r: ResultHandle) => Result): StableRe /// Invoke an async function that takes a ResultHandle and return a Promsie /// /// Note that if the async function throws, it will NOT be captured -export function tryInvokeAsync(fn: (r: ResultHandle) => Promise>): Promise>; +export function tryInvokeAsync(fn: (r: ResultHandle) => Promise>): Promise>; /// Wrap a function that may throw an error and return a Result, capturing the error export function tryCatch(fn: () => T): StableResult; diff --git a/web-client/src/core/kernel/AlertMgr.ts b/web-client/src/core/kernel/AlertMgr.ts index c7c09c62..96a94e47 100644 --- a/web-client/src/core/kernel/AlertMgr.ts +++ b/web-client/src/core/kernel/AlertMgr.ts @@ -1,71 +1,30 @@ //! Manager for modal alerts -import { AlertExtraAction, ModifyAlertActionPayload } from "core/stage"; -import { AppDispatcher, viewActions } from "core/store"; -import { Result, allocErr, allocOk, console } from "low/utils"; - -/// Options for showing a simple alert -export type AlertOptions = { - /// Title of the alert - title: string; - /// Message body of the alert - message: string; - /// Text for the ok button. Default is "Ok" - okButton?: string; - /// Text for the cancel button. Default is no cancel button. - cancelButton?: string; - /// Show a learn more link after the message - learnMoreLink?: string; - /// Extra actions besides ok and cancel - extraActions?: TExtra; -}; +import { Result, ResultHandle } from "pure/result"; -/// Options for showing a rich (react) alert -export type RichAlertOptions = { - /// Title of the alert - title: string; - /// Body component of the alert - component: React.ComponentType; - /// Text for the ok button. Default is "Ok" - okButton?: string; - /// Text for the cancel button. Default is "Cancel" - cancelButton?: string; - /// Extra actions besides ok and cancel - extraActions?: TExtra; -}; - -/// Options to show a blocking alert while another operation is running -export type BlockingAlertOptions = { - /// Title of the alert - title: string; - /// Body component of the alert - component: React.ComponentType; - /// Text for the cancel button. Default is "Cancel" - cancelButton?: string; -}; +import { AppDispatcher, viewActions } from "core/store"; +import { AlertExtraAction, AlertIds, AlertMgr, AlertOptions, BlockingAlertOptions, ModifyAlertActionPayload, RichAlertOptions, console } from "low/utils"; -type IdsOf = T[number]["id"]; type AlertCallback = (ok: boolean | string) => void; /// Timeout needed to clear the previous alert const ALERT_TIMEOUT = 100; -export class AlertMgr { +export class AlertMgrImpl implements AlertMgr { private store: AppDispatcher; private previousFocusedElement: Element | undefined = undefined; private alertCallback: AlertCallback | undefined = undefined; - private RichAlertComponent: React.ComponentType | undefined = undefined; + + public RichAlertComponent: React.ComponentType | undefined = undefined; constructor(store: AppDispatcher) { this.store = store; } - /// Show an alert dialog - /// - /// Returns a promise that resolves to true if the user - /// clicked ok and false if the user clicked cancel. - /// - /// If there are extra options, it may resolve to the id of the extra action + public onAction(response: boolean | string) { + this.alertCallback?.(response); + } + public show({ title, message, @@ -73,7 +32,7 @@ export class AlertMgr { cancelButton, learnMoreLink, extraActions, - }: AlertOptions): Promise> { + }: AlertOptions): Promise> { return new Promise((resolve) => { this.initAlert(resolve, undefined); this.store.dispatch( @@ -89,14 +48,13 @@ export class AlertMgr { }); } - /// Like `show`, but with a custom react component for the body public showRich({ title, component, okButton, cancelButton, extraActions, - }: RichAlertOptions): Promise> { + }: RichAlertOptions): Promise> { return new Promise((resolve) => { this.initAlert(resolve, component); this.store.dispatch( @@ -112,13 +70,8 @@ export class AlertMgr { }); } - /// Show a blocking alert and run f - /// - /// The promise will resolve to the result of f, or Err(false) if the user - /// cancels. - /// - /// If f throws, the alert will be cleared, and Err(e) will be returned. public showBlocking( + r: ResultHandle, { title, component, cancelButton }: BlockingAlertOptions, f: () => Promise, ): Promise> { @@ -129,7 +82,7 @@ export class AlertMgr { // it means cancel cancelled = true; console.info("user cancelled the operation"); - resolve(allocErr(false)); + resolve(r.putErr(false)); }, component); this.store.dispatch( viewActions.setAlert({ @@ -147,22 +100,19 @@ export class AlertMgr { .then((result) => { if (!cancelled) { this.clearAlertAndThen(() => - resolve(allocOk(result)), + resolve(r.putOk(result)), ); } }) .catch((e) => { if (!cancelled) { - this.clearAlertAndThen(() => resolve(allocErr(e))); + this.clearAlertAndThen(() => resolve(r.putErr(e))); } }); }, ALERT_TIMEOUT); }); } - /// Modify the current alert's actions - /// - /// Only effective if a dialog is showing public modifyActions(payload: ModifyAlertActionPayload) { if (this.alertCallback) { this.store.dispatch(viewActions.setAlertActions(payload)); @@ -197,12 +147,4 @@ export class AlertMgr { }, ALERT_TIMEOUT); } - /// Called from the alert dialog to notify the user action - public onAction(response: boolean | string) { - this.alertCallback?.(response); - } - - public getRichComponent() { - return this.RichAlertComponent; - } } diff --git a/web-client/src/core/kernel/Kernel.ts b/web-client/src/core/kernel/Kernel.ts index 832bc37a..1478ee3e 100644 --- a/web-client/src/core/kernel/Kernel.ts +++ b/web-client/src/core/kernel/Kernel.ts @@ -20,14 +20,14 @@ import { loadDocumentFromCurrentUrl, } from "core/doc"; import { ExpoDoc, ExportRequest } from "low/celerc"; -import { console, Logger, isInDarkMode, sleep } from "low/utils"; -import type { FileSys, FsResult } from "low/fs"; +import { console, Logger, isInDarkMode, sleep, AlertMgr } from "low/utils"; +import type { FileSys, FsResult, FsStableResult } from "low/fs"; import type { CompilerKernel } from "./compiler"; import type { EditorKernel, KernelAccess } from "./editor"; import { KeyMgr } from "./KeyMgr"; import { WindowMgr } from "./WindowMgr"; -import { AlertMgr } from "./AlertMgr"; +import { AlertMgrImpl } from "./AlertMgr"; type InitUiFunction = ( kernel: Kernel, @@ -68,7 +68,7 @@ export class Kernel implements KernelAccess { this.initReact = initReact; this.log.info("starting application"); this.store = this.initStore(); - this.alertMgr = new AlertMgr(this.store); + this.alertMgr = new AlertMgrImpl(this.store); } /// Initialize the store @@ -185,6 +185,8 @@ export class Kernel implements KernelAccess { return this.compiler; } + // put this in low/fs + /// Handle the result of opening a project /// /// This will show error message accordingly if the result is failure. @@ -192,12 +194,12 @@ export class Kernel implements KernelAccess { /// /// This function eats the error because alerts will be shown to the user public async handleOpenFileSysResult( - fileSysResult: FsResult, + fileSysResult: FsStableResult, ): Promise { console.info("opening file system..."); const { FsResultCodes } = await import("low/fs"); if (fileSysResult.isErr()) { - const code = fileSysResult.inner(); + const code = fileSysResult.error; if (code === FsResultCodes.UserAbort) { console.info("opening file system aborted."); return; @@ -213,7 +215,7 @@ export class Kernel implements KernelAccess { await this.getAlertMgr().show({ title: "Error", message: - "You dropped a file. Make sure you are dropping the project folder and not individual files.", + "You opened a file. Make sure you are opening the project folder and not individual files.", okButton: "Close", }); } else { @@ -226,7 +228,7 @@ export class Kernel implements KernelAccess { return; } console.info("initializing new file system..."); - const fileSys = fileSysResult.inner(); + const fileSys = fileSysResult.value; let result = await fileSys.init(); while (result.isErr()) { let retry = false; @@ -269,7 +271,6 @@ export class Kernel implements KernelAccess { return; } this.store.dispatch(settingsActions.setEditorMode("external")); - // make sure store is updated for the next check } } @@ -308,6 +309,7 @@ export class Kernel implements KernelAccess { await this.reloadDocumentFromServer(); } + // put this in editor kernel public async closeFileSys() { console.info("closing file system..."); this.store.dispatch(documentActions.setDocument(undefined)); @@ -319,6 +321,7 @@ export class Kernel implements KernelAccess { compiler.uninit(); } + // put this in editor kernel private updateRootPathInStore(fileSys: FileSys | undefined) { if (fileSys) { this.store.dispatch( diff --git a/web-client/src/core/stage/state.ts b/web-client/src/core/stage/state.ts index 22d69754..533900c2 100644 --- a/web-client/src/core/stage/state.ts +++ b/web-client/src/core/stage/state.ts @@ -12,15 +12,6 @@ export type StageViewState = { settingsTab: SettingsTab; isResizingWindow: boolean; }; -export type AlertExtraAction = { - id: string; - text: string; -}; -export type ModifyAlertActionPayload = { - okButton?: string; - cancelButton?: string; - extraActions?: AlertExtraAction[]; -}; export type StageMode = "view" | "edit"; diff --git a/web-client/src/low/fs/FileAccess.ts b/web-client/src/low/fs/FileAccess.ts index 6ad6f6ca..b7d1a08e 100644 --- a/web-client/src/low/fs/FileAccess.ts +++ b/web-client/src/low/fs/FileAccess.ts @@ -1,3 +1,5 @@ +import { ResultHandle } from "pure/result"; + import { FsResult } from "./FsResult"; /// Interface for the compiler to access files @@ -8,6 +10,7 @@ export interface FileAccess { /// pointed to by the path was changed since the last time getFileContent was called. /// If it was not changed, the implementation could return NotModified as the error code. getFileContent( + r: ResultHandle, path: string, checkChanged: boolean, ): Promise>; diff --git a/web-client/src/low/fs/FileSys.ts b/web-client/src/low/fs/FileSys.ts index 56b38cf7..fa6ad13d 100644 --- a/web-client/src/low/fs/FileSys.ts +++ b/web-client/src/low/fs/FileSys.ts @@ -1,3 +1,5 @@ +import { ResultHandle } from "pure/result"; + import { FsPath } from "./FsPath"; import { FsResult } from "./FsResult"; @@ -7,7 +9,7 @@ export interface FileSys { /// /// The FileSys implementation may need to do some async initialization. /// For example, request permission from the user. - init: () => Promise>; + init: (r: ResultHandle) => Promise>; /// Get the root path of the file system for display /// @@ -21,12 +23,12 @@ export interface FileSys { /// Directory names end with a slash. /// /// Returns Fail if the underlying file system operation fails. - listDir: (path: FsPath) => Promise>; + listDir: (r: ResultHandle, path: FsPath) => Promise>; /// Read the file as a File object /// /// Returns Fail if the underlying file system operation fails. - readFile: (path: FsPath) => Promise>; + readFile: (r: ResultHandle, path: FsPath) => Promise>; /// Returns if this implementation supports writing to a file isWritable: () => boolean; @@ -44,6 +46,7 @@ export interface FileSys { /// Returns Fail if the underlying file system operation fails. /// Returns NotSupported if the browser does not support this writeFile: ( + r: ResultHandle, path: FsPath, content: string | Uint8Array, ) => Promise>; diff --git a/web-client/src/low/fs/FsFile.ts b/web-client/src/low/fs/FsFile.ts index 59ea315d..9705c3d4 100644 --- a/web-client/src/low/fs/FsFile.ts +++ b/web-client/src/low/fs/FsFile.ts @@ -1,4 +1,4 @@ -import { console, allocErr, allocOk } from "low/utils"; +import { console } from "low/utils"; import { FsPath } from "./FsPath"; import { FileSys } from "./FileSys"; diff --git a/web-client/src/low/fs/FsPath.ts b/web-client/src/low/fs/FsPath.ts index 3ee26d09..0546a331 100644 --- a/web-client/src/low/fs/FsPath.ts +++ b/web-client/src/low/fs/FsPath.ts @@ -1,4 +1,5 @@ -import { allocErr, allocOk } from "low/utils"; +import { ResultHandle } from "pure/result"; + import { FsResult, FsResultCodes } from "./FsResult"; /// File system path @@ -20,7 +21,7 @@ export interface FsPath { /// If this path is the root directory, return IsRoot. /// /// This does not check if the path exists. - readonly parent: FsResult; + getParent(r: ResultHandle): FsResult; /// Get the name of this path. /// @@ -32,7 +33,7 @@ export interface FsPath { /// "/foo/bar" -> "bar" /// "/foo/bar/" -> "bar" /// "/" -> IsRoot - readonly name: FsResult; + getName(r: ResultHandle): FsResult; /// Get the full path as string representation. /// @@ -46,7 +47,7 @@ export interface FsPath { /// Resolve a sibling path. /// /// Returns IsRoot if this is the root directory. - resolveSibling(path: string): FsResult; + resolveSibling(r: ResultHandle, path: string): FsResult; } class FsPathImpl implements FsPath { @@ -64,28 +65,28 @@ class FsPathImpl implements FsPath { return this.underlying === ""; } - get parent(): FsResult { + getParent(r: ResultHandle): FsResult { if (this.underlying === "") { - return allocErr(FsResultCodes.IsRoot); + return r.putErr(FsResultCodes.IsRoot); } const i = this.underlying.lastIndexOf("/"); if (i < 0) { - return allocOk(fsRootPath); + return r.putOk(fsRootPath); } - return allocOk(new FsPathImpl(this.underlying.substring(0, i))); + return r.putOk(new FsPathImpl(this.underlying.substring(0, i))); } - get name(): FsResult { + getName(r: ResultHandle): FsResult { if (this.underlying === "") { - return allocErr(FsResultCodes.IsRoot); + return r.putErr(FsResultCodes.IsRoot); } const i = this.underlying.lastIndexOf("/"); if (i < 0) { - return allocOk(this.underlying); + return r.putOk(this.underlying); } - return allocOk(this.underlying.substring(i + 1)); + return r.putOk(this.underlying.substring(i + 1)); } get path(): string { @@ -102,8 +103,12 @@ class FsPathImpl implements FsPath { return new FsPathImpl(this.underlying + "/" + cleanPath(path)); } - public resolveSibling(path: string): FsResult { - return this.parent.map((parent) => parent.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)); } } diff --git a/web-client/src/low/fs/FsResult.ts b/web-client/src/low/fs/FsResult.ts index d6d6fc3f..077acfc0 100644 --- a/web-client/src/low/fs/FsResult.ts +++ b/web-client/src/low/fs/FsResult.ts @@ -1,4 +1,4 @@ -import { Result } from "low/utils"; +import { Result, StableResult } from "pure/result"; /// Result type for file system operations export const FsResultCodes = { @@ -25,3 +25,4 @@ export const FsResultCodes = { export type FsResultCode = (typeof FsResultCodes)[keyof typeof FsResultCodes]; export type FsResult = Result; +export type FsStableResult = StableResult; diff --git a/web-client/src/low/fs/create.ts b/web-client/src/low/fs/create.ts index 9f748e72..cf2f1f7f 100644 --- a/web-client/src/low/fs/create.ts +++ b/web-client/src/low/fs/create.ts @@ -1,5 +1,7 @@ //! Utils for creating FileSys -import { console, allocErr, allocOk } from "low/utils"; +import { ResultHandle } from "pure/result"; + +import { console } from "low/utils"; import { FileEntriesApiFileSys, @@ -13,7 +15,7 @@ import { import { FsResult, FsResultCodes } from "./FsResult"; import { FileApiFileSys } from "./FileApiFileSys"; -export const showDirectoryPicker = async (): Promise> => { +export async function showDirectoryPicker(r: ResultHandle): Promise> => { if (isFileSystemAccessApiSupported()) { try { // @ts-expect-error showDirectoryPicker is not in the TS lib diff --git a/web-client/src/low/utils/Alert.ts b/web-client/src/low/utils/Alert.ts new file mode 100644 index 00000000..229bd39c --- /dev/null +++ b/web-client/src/low/utils/Alert.ts @@ -0,0 +1,93 @@ +import { Result, ResultHandle } from "pure/result"; + +export type AlertExtraAction = { + id: string; + text: string; +}; +export type ModifyAlertActionPayload = { + okButton?: string; + cancelButton?: string; + extraActions?: AlertExtraAction[]; +}; + +/// Options for showing a simple alert +export type AlertOptions = { + /// Title of the alert + title: string; + /// Message body of the alert + message: string; + /// Text for the ok button. Default is "Ok" + okButton?: string; + /// Text for the cancel button. Default is no cancel button. + cancelButton?: string; + /// Show a learn more link after the message + learnMoreLink?: string; + /// Extra actions besides ok and cancel + extraActions?: TExtra; +}; + +/// Options for showing a rich (react) alert +export type RichAlertOptions = { + /// Title of the alert + title: string; + /// Body component of the alert + component: React.ComponentType; + /// Text for the ok button. Default is "Ok" + okButton?: string; + /// Text for the cancel button. Default is "Cancel" + cancelButton?: string; + /// Extra actions besides ok and cancel + extraActions?: TExtra; +}; + +/// Options to show a blocking alert while another operation is running +export type BlockingAlertOptions = { + /// Title of the alert + title: string; + /// Body component of the alert + component: React.ComponentType; + /// Text for the cancel button. Default is "Cancel" + cancelButton?: string; +}; + +export type AlertIds = T[number]["id"]; + +export interface AlertMgr { + /// Show an alert dialog + /// + /// Returns a promise that resolves to true if the user + /// clicked ok and false if the user clicked cancel. + /// + /// If there are extra options, it may resolve to the id of the extra action + show( + options: AlertOptions + ): Promise>; + + /// Like `show`, but with a custom react component for the body + showRich( + options: RichAlertOptions + ): Promise>; + + /// Show a blocking alert and run f + /// + /// The promise will resolve to the result of f, or Err(false) if the user + /// cancels. + /// + /// If f throws, the alert will be cleared, and Err(e) will be returned. + showBlocking( + r: ResultHandle, + options: BlockingAlertOptions, + fn: () => Promise + ): Promise>; + + /// Modify the current alert's actions + /// + /// Only effective if a dialog is showing + modifyActions(payload: ModifyAlertActionPayload): void; + + /// Called from the alert dialog to notify the user action + onAction(response: boolean | string): void; + + /// Get the rich component if a rich alert is showing + readonly RichAlertComponent?: React.ComponentType; +} diff --git a/web-client/src/low/utils/Pool.ts b/web-client/src/low/utils/Pool.ts deleted file mode 100644 index 3cce1a72..00000000 --- a/web-client/src/low/utils/Pool.ts +++ /dev/null @@ -1,21 +0,0 @@ -//! Memory Pool - -import Denque from "denque"; - -/// Basic implementation of a memory pool. -/// It does not clean the memory when an object is released. -export class Pool { - /// The pool of available objects - private availables: Denque = new Denque(); - - /// Returns an object from the pool. - /// If the pool is empty, returns undefined - public alloc(): T | undefined { - return this.availables.shift(); - } - - /// Returns an object to the pool. - public free(obj: T) { - this.availables.push(obj); - } -} diff --git a/web-client/src/low/utils/Result.d.ts b/web-client/src/low/utils/Result.d.ts deleted file mode 100644 index eae9f7e3..00000000 --- a/web-client/src/low/utils/Result.d.ts +++ /dev/null @@ -1,41 +0,0 @@ -export type Option = T | undefined; - -export type Result = UncheckedResult & (Ok | Err); - -export interface UncheckedResult { - /// Returns true if this is an Ok value - isOk: () => this is Ok; - - /// Returns true if this is an Err value - isErr: () => this is Err; - - /// Make this an Ok with the given value - /// May mutate `this`. The previous reference should no longer be used - makeOk: (value: T2) => Result; - - /// Make this an Err with the given value - /// May mutate `this`. The previous reference should no longer be used - makeErr: (value: E2) => Result; - - /// Apply fn to value if this is an Ok, leaving an Err unchanged - /// May mutate `this`. The previous reference should no longer be used - map: (fn: (value: T) => T2) => Result; - - /// Apply fn to value if this is an Err, leaving an Ok unchanged - /// May mutate `this`. The previous reference should no longer be used - mapErr: (fn: (value: E) => E2) => Result; -} - -export interface Ok extends UncheckedResult { - inner: () => T; -} - -export interface Err extends UncheckedResult { - inner: () => E; -} - -export function allocOk(value: T): Result; -export function allocOk(): Result; -export function allocErr(value: E): Result; -export function wrap(fn: () => T): Result; -export function wrapAsync(fn: () => Promise): Promise>; diff --git a/web-client/src/low/utils/Result.js b/web-client/src/low/utils/Result.js deleted file mode 100644 index 2455edc5..00000000 --- a/web-client/src/low/utils/Result.js +++ /dev/null @@ -1,68 +0,0 @@ -class ResultImpl { - constructor(ok, value) { - this.ok = ok; - this.value = value; - } - - isOk() { - return this.ok; - } - - isErr() { - return !this.ok; - } - - makeOk(value) { - this.ok = true; - this.value = value; - return this; - } - - makeErr(value) { - this.ok = false; - this.value = value; - return this; - } - - inner() { - return this.value; - } - - map(fn) { - if (this.ok) { - this.value = fn(this.value); - } - return this; - } - - mapErr(fn) { - if (!this.ok) { - this.value = fn(this.value); - } - return this; - } -} - -export function allocOk(value) { - return new ResultImpl(true, value); -} - -export function allocErr(value) { - return new ResultImpl(false, value); -} - -export function wrap(fn) { - try { - return allocOk(fn()); - } catch (e) { - return allocErr(e); - } -} - -export async function wrapAsync(fn) { - try { - return allocOk(await fn()); - } catch (e) { - return allocErr(e); - } -} diff --git a/web-client/src/low/utils/index.ts b/web-client/src/low/utils/index.ts index 939f15b2..b2f46282 100644 --- a/web-client/src/low/utils/index.ts +++ b/web-client/src/low/utils/index.ts @@ -2,15 +2,14 @@ //! //! Low level utilities that all layers can use +export * from "./Alert"; export * from "./IdleMgr"; export * from "./Debouncer"; export * from "./error"; export * from "./html"; export * from "./Logger"; -export * from "./Pool"; export * from "./FileSaver"; export * from "./ReentrantLock"; -export * from "./Result"; export * from "./WorkerHost"; export * from "./Yielder"; diff --git a/web-client/src/ui/toolbar/OpenCloseProject.tsx b/web-client/src/ui/toolbar/OpenCloseProject.tsx index 5554c84c..35316610 100644 --- a/web-client/src/ui/toolbar/OpenCloseProject.tsx +++ b/web-client/src/ui/toolbar/OpenCloseProject.tsx @@ -9,6 +9,7 @@ import { forwardRef, useCallback } from "react"; import { useSelector } from "react-redux"; import { MenuItem, ToolbarButton, Tooltip } from "@fluentui/react-components"; import { Dismiss20Regular, FolderOpen20Regular } from "@fluentui/react-icons"; +import { tryInvokeAsync } from "pure/result"; import { useKernel } from "core/kernel"; import { viewSelector } from "core/store"; @@ -61,7 +62,7 @@ const useOpenCloseProjectControl = () => { await kernel.closeFileSys(); } else { const { showDirectoryPicker } = await import("low/fs"); - const result = await showDirectoryPicker(); + const result = await tryInvokeAsync(showDirectoryPicker); await kernel.handleOpenFileSysResult(result); } }, [kernel, rootPath]); diff --git a/web-client/tsconfig.json b/web-client/tsconfig.json index b9f0a173..b5ba5afc 100644 --- a/web-client/tsconfig.json +++ b/web-client/tsconfig.json @@ -22,7 +22,7 @@ "baseUrl": "src", "paths": { - "pure/result": ["../libs/pure/result"], + "pure/result": ["../../libs/pure/result"], } }, "include": ["src", "../libs/pure/result"], From 7fad9f2e66e5db9775e4e70f13d9b93317b54b0a Mon Sep 17 00:00:00 2001 From: Pistonight Date: Thu, 22 Feb 2024 23:14:55 -0800 Subject: [PATCH 03/10] 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 Date: Sat, 24 Feb 2024 20:47:09 -0800 Subject: [PATCH 04/10] rewrite editor FileMgr with new fs module --- compiler-wasm/build/src/worker_init.js | 2 +- compiler-wasm/src/loader.rs | 18 +- libs/pure/src/fs/FsFile.ts | 16 +- libs/pure/src/fs/FsFileSystem.ts | 31 +- libs/pure/src/fs/README.md | 26 +- libs/pure/src/fs/error.ts | 6 +- libs/pure/src/fs/impl/FsFileMgr.ts | 29 + libs/pure/src/fs/impl/FsFileSystemInternal.ts | 24 + libs/pure/src/fs/impl/f.ts | 114 ++++ libs/pure/src/fs/impl/fe.ts | 143 +++++ libs/pure/src/fs/impl/file.ts | 111 ++-- libs/pure/src/fs/impl/fsa.ts | 181 +++++- libs/pure/src/fs/impl/index.ts | 2 + libs/pure/src/fs/index.ts | 5 + libs/pure/src/fs/open.ts | 281 ++++----- libs/pure/src/fs/path.ts | 31 +- libs/pure/src/result/README.md | 275 +++----- libs/pure/src/result/index.d.ts | 102 --- libs/pure/src/result/index.js | 121 ---- libs/pure/src/result/index.ts | 29 + libs/pure/src/utils/index.ts | 12 +- libs/pure/src/utils/lock.ts | 24 +- libs/tsconfig.json | 5 +- .../compiler/CompilerFileAccess.ts} | 7 +- .../{kernel => }/compiler/CompilerKernel.ts | 10 +- .../compiler/CompilerKernelImpl.ts | 111 ++-- .../src/core/{kernel => }/compiler/index.ts | 2 + .../{kernel => }/compiler/initCompiler.ts | 0 .../src/core/{kernel => }/compiler/utils.ts | 0 .../core/{kernel => }/editor/EditorKernel.ts | 21 +- .../EditorKernelAccess.ts} | 2 +- web-client/src/core/editor/FileMgr.ts | 587 ++++++++++++++++++ web-client/src/core/editor/dom.ts | 3 + web-client/src/core/editor/index.ts | 11 +- .../src/core/{kernel => }/editor/utils.ts | 15 +- web-client/src/core/kernel/Kernel.ts | 27 +- web-client/src/core/kernel/editor/FileMgr.ts | 429 ------------- .../src/core/kernel/editor/WebEditorKernel.ts | 2 +- web-client/src/core/kernel/editor/index.ts | 2 - web-client/src/core/store/editor/index.ts | 3 + .../{ => store}/editor/settingsReducers.ts | 0 .../src/core/{ => store}/editor/state.ts | 0 .../core/{ => store}/editor/viewReducers.ts | 0 web-client/src/core/store/index.ts | 2 + web-client/src/core/store/settings.ts | 5 +- web-client/src/core/store/view.ts | 6 +- web-client/src/low/fs/index.ts | 18 +- web-client/src/low/utils/error.ts | 2 - web-client/src/low/utils/index.ts | 1 - 49 files changed, 1553 insertions(+), 1301 deletions(-) create mode 100644 libs/pure/src/fs/impl/FsFileMgr.ts create mode 100644 libs/pure/src/fs/impl/FsFileSystemInternal.ts create mode 100644 libs/pure/src/fs/impl/f.ts create mode 100644 libs/pure/src/fs/impl/fe.ts delete mode 100644 libs/pure/src/result/index.d.ts delete mode 100644 libs/pure/src/result/index.js create mode 100644 libs/pure/src/result/index.ts rename web-client/src/{low/fs/FileAccess.ts => core/compiler/CompilerFileAccess.ts} (76%) rename web-client/src/core/{kernel => }/compiler/CompilerKernel.ts (81%) rename web-client/src/core/{kernel => }/compiler/CompilerKernelImpl.ts (82%) rename web-client/src/core/{kernel => }/compiler/index.ts (79%) rename web-client/src/core/{kernel => }/compiler/initCompiler.ts (100%) rename web-client/src/core/{kernel => }/compiler/utils.ts (100%) rename web-client/src/core/{kernel => }/editor/EditorKernel.ts (66%) rename web-client/src/core/{kernel/editor/KernelAccess.ts => editor/EditorKernelAccess.ts} (80%) create mode 100644 web-client/src/core/editor/FileMgr.ts create mode 100644 web-client/src/core/editor/dom.ts rename web-client/src/core/{kernel => }/editor/utils.ts (61%) delete mode 100644 web-client/src/core/kernel/editor/FileMgr.ts create mode 100644 web-client/src/core/store/editor/index.ts rename web-client/src/core/{ => store}/editor/settingsReducers.ts (100%) rename web-client/src/core/{ => store}/editor/state.ts (100%) rename web-client/src/core/{ => store}/editor/viewReducers.ts (100%) delete mode 100644 web-client/src/low/utils/error.ts diff --git a/compiler-wasm/build/src/worker_init.js b/compiler-wasm/build/src/worker_init.js index f5c57e5c..9d7bc5ba 100644 --- a/compiler-wasm/build/src/worker_init.js +++ b/compiler-wasm/build/src/worker_init.js @@ -46,7 +46,7 @@ async function __initWorker(HANDLERS) { if (msgId === "file") { // ["file", 0, path, [true, data]] // ["file", 0, path, [false]] - // ["file", 1, path, error] + // ["file", 1, path, FsError] if (!pendingFiles[args]) { return; } diff --git a/compiler-wasm/src/loader.rs b/compiler-wasm/src/loader.rs index aa7de5be..a61074b4 100644 --- a/compiler-wasm/src/loader.rs +++ b/compiler-wasm/src/loader.rs @@ -5,7 +5,7 @@ use std::cell::RefCell; -use js_sys::{Array, Function, Uint8Array}; +use js_sys::{Array, Function, Uint8Array, Reflect}; use log::info; use wasm_bindgen::prelude::*; @@ -110,22 +110,17 @@ pub enum LoadFileOutput { /// Load a file from using JS binding async fn load_file_internal(path: &str, check_changed: bool) -> ResResult { - // this is essentially - // try { Ok(await load_file(path, check_changed)) } catch (e) { Err(e) } let result = async { - LOAD_FILE - .with_borrow(|f| { + LOAD_FILE.with_borrow(|f| { f.call2( &JsValue::UNDEFINED, &JsValue::from(path), &JsValue::from(check_changed), ) })? - .into_future() - .await? + .into_future().await? .dyn_into::() - } - .await; + }.await; let result = result.and_then(|result| { let modified = result.get(0).as_bool().unwrap_or_default(); @@ -139,6 +134,11 @@ async fn load_file_internal(path: &str, check_changed: bool) -> ResResult Ok(output), Err(e) => { + if let Ok(value) = Reflect::get(&e, &JsValue::from("message")) { + if let Some(s) = value.as_string() { + return Err(ResError::FailToLoadFile( path.to_string(), s)); + } + } logger::raw_error(&e); Err(ResError::FailToLoadFile( path.to_string(), diff --git a/libs/pure/src/fs/FsFile.ts b/libs/pure/src/fs/FsFile.ts index 0d39cfd9..86ac90e2 100644 --- a/libs/pure/src/fs/FsFile.ts +++ b/libs/pure/src/fs/FsFile.ts @@ -1,5 +1,4 @@ -import { ResultHandle } from "../result"; -import { FsResult } from "./error"; +import { FsResult, FsVoid } from "./error"; /// Interface for operating on a file in the loaded file system export interface FsFile { @@ -10,17 +9,17 @@ export interface FsFile { isDirty(): boolean; /// Get the last modified time. May load it from file system if needed - getLastModified(r: ResultHandle): Promise>; + getLastModified(): 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>; + getText(): Promise>; /// Get the content of the file - getBytes(r: ResultHandle): Promise> + getBytes(): Promise> /// Set the content in memory. Does not save to disk. /// Does nothing if file is closed @@ -33,7 +32,7 @@ export interface FsFile { /// 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>; + loadIfNotDirty(): Promise; /// Load the file's content from FS. /// @@ -41,16 +40,15 @@ export interface FsFile { /// 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>; + load(): Promise; /// Save the file's content to FS if it is dirty. /// /// If not dirty, returns Ok - writeIfNewer(r: ResultHandle): Promise>; + writeIfNewer(): 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 index 2c23cce4..00a90d8d 100644 --- a/libs/pure/src/fs/FsFileSystem.ts +++ b/libs/pure/src/fs/FsFileSystem.ts @@ -1,4 +1,3 @@ -import { ResultHandle } from "../result"; import { FsFile } from "./FsFile"; import { FsResult } from "./error"; import { FsCapabilities } from "./support"; @@ -8,7 +7,7 @@ import { FsCapabilities } from "./support"; /// This is an internal type used inside fsOpen functions export interface FsFileSystemUninit { /// Initialize the file system - init(r: ResultHandle): Promise>; + init(): Promise>; } /// Initialized file system @@ -32,7 +31,7 @@ export interface FsFileSystem { /// Directory names end with a slash. /// /// Returns Fail if the underlying file system operation fails. - listDir: (r: ResultHandle, path: string) => Promise>; + listDir: (path: string) => Promise>; /// Get a file object for operations /// @@ -51,29 +50,3 @@ export interface FsFileSystem { 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 index ecfa670b..0d723131 100644 --- a/libs/pure/src/fs/README.md +++ b/libs/pure/src/fs/README.md @@ -8,9 +8,6 @@ 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. @@ -52,7 +49,6 @@ 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"); @@ -71,13 +67,13 @@ div.addEventListener("drop", async (e) => { return; } - const result = await tryInvokeAsync((r) => fsOpenReadWriteFrom(r, item)); - if (result.isErr()) { - console.error(result.error); + const result = await fsOpenReadWriteFrom(item); + if (result.err) { + console.error(result.err); return; } - const fs = result.value; + const fs = result.val; const { write, live } = fs.capabilities; // check capabilities and use fs // ... @@ -86,21 +82,21 @@ div.addEventListener("drop", async (e) => { ## 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. +The handler is async so you can ask user. ```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) { +async function shouldRetry(error: FsError, attempt: number): Promise> { + if (attempt < 10 && error === FsError.PermissionDenied) { alert("you must give permission to use this feature!"); - return r.putOk(true); + return { val: true }; } - return r.putOk(false); + return { val: false }; } -const result = await tryInvokeAsync((r) => fsOpenReadWrite(r, shouldRetry)); +const result = await fsOpenReadWrite(shouldRetry); ``` + diff --git a/libs/pure/src/fs/error.ts b/libs/pure/src/fs/error.ts index 9a3f255e..5fdf0951 100644 --- a/libs/pure/src/fs/error.ts +++ b/libs/pure/src/fs/error.ts @@ -1,4 +1,4 @@ -import { Result, StableResult } from "../result"; +import { Result, Void } from "pure/result"; /// Result type for file system operations export const FsErr = { @@ -26,6 +26,8 @@ export const FsErr = { InvalidPath: 11, /// Trying to operate on a file that has been closed Closed: 12, + /// The operation does not apply to a directory + IsDirectory: 5, } as const; export type FsErr = (typeof FsErr)[keyof typeof FsErr]; @@ -43,4 +45,4 @@ export function fsFail(message: string): FsError { } export type FsResult = Result; -export type FsStableResult = StableResult; +export type FsVoid = Void; diff --git a/libs/pure/src/fs/impl/FsFileMgr.ts b/libs/pure/src/fs/impl/FsFileMgr.ts new file mode 100644 index 00000000..711704c7 --- /dev/null +++ b/libs/pure/src/fs/impl/FsFileMgr.ts @@ -0,0 +1,29 @@ +import { FsFile } from "../FsFile"; +import { FsFileSystemInternal } from "./FsFileSystemInternal"; +import { fsFile } from "./file"; + +/// Internal class to track opened files +export class FsFileMgr { + private opened: { [path: string]: FsFile }; + + public constructor() { + this.opened = {}; + } + + public get(fs: FsFileSystemInternal, path: string): FsFile { + let file = this.opened[path]; + if (!file) { + file = fsFile(fs, path); + this.opened[path] = file; + } + return file; + } + + public close(path: string): void { + delete this.opened[path]; + } + + public getOpenedPaths(): string[] { + return Object.keys(this.opened); + } +} diff --git a/libs/pure/src/fs/impl/FsFileSystemInternal.ts b/libs/pure/src/fs/impl/FsFileSystemInternal.ts new file mode 100644 index 00000000..d706d5ed --- /dev/null +++ b/libs/pure/src/fs/impl/FsFileSystemInternal.ts @@ -0,0 +1,24 @@ +import { FsResult, FsVoid } from "../error"; + +/// Internal APIs for FsFileSystem +export interface FsFileSystemInternal { + /// Read the file as a File object + /// + /// Returns Fail if the underlying file system operation fails. + read: (path: string) => Promise>; + + /// Write content to a file on disk + /// + /// 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 + /// Returns PermissionDenied if the operation is supported, but permission is not given + write: (path: string, content: Uint8Array) => Promise; + + /// Forget about a file + closeFile: (path: string) => void; +} diff --git a/libs/pure/src/fs/impl/f.ts b/libs/pure/src/fs/impl/f.ts new file mode 100644 index 00000000..9a7dfe14 --- /dev/null +++ b/libs/pure/src/fs/impl/f.ts @@ -0,0 +1,114 @@ +import { FsFile } from "../FsFile"; +import { FsFileSystem, FsFileSystemUninit } from "../FsFileSystem"; +import { FsErr, FsResult, FsVoid, fsErr } from "../error"; +import { fsNormalize } from "../path"; +import { FsCapabilities } from "../support"; + +import { FsFileMgr } from "./FsFileMgr"; +import { FsFileSystemInternal } from "./FsFileSystemInternal"; + +/// FileSystem implementation that uses a list of Files +/// This is supported in all browsers, but it is stale. +/// It's used for Firefox when the File Entries API is not available +/// i.e. opened from +export class FsImplF implements FsFileSystemUninit, FsFileSystem, FsFileSystemInternal { + public root: string; + public capabilities: FsCapabilities; + + private files: Record; + private directories: Record; + private mgr: FsFileMgr; + + constructor(files: FileList) { + // this seems to also work for windows + this.root = files[0].webkitRelativePath.split("/", 1)[0]; + this.capabilities = { + write: false, + live: false + }; + this.files = {}; + this.directories = {}; + this.mgr = new FsFileMgr(); + + for (let i = 0; i < files.length; i++) { + const file = files[i]; + // remove "/" + const path = file.webkitRelativePath.slice(this.root.length + 1); + const normalized = fsNormalize(path); + if (normalized.err) { + // shouldn't happen since the path is from the File API + console.error("Invalid path: " + path); + continue; + } + this.files[normalized.val] = file; + } + } + + public init(): Promise> { + // no init needed + return Promise.resolve({ val: this }); + } + + public async listDir(path: string): Promise> { + const normalized = fsNormalize(path); + if (normalized.err) { + return normalized; + } + path = normalized.val; + + if (path in this.directories) { + return { val: this.directories[path] }; + } + + const set = new Set(); + const prefix = path + "/"; + + Object.keys(this.files).forEach((path) => { + if (!path.startsWith(prefix)) { + return; + } + const relPath = path.slice(prefix.length); + const slashIndex = relPath.indexOf("/"); + if (slashIndex < 0) { + // file + set.add(relPath); + } else { + // directory + const dir = relPath.slice(0, slashIndex + 1); + set.add(dir); + } + }); + + return { val: Array.from(set) }; + } + + public async read(path: string): Promise> { + const normalized = fsNormalize(path); + if (normalized.err) { + return normalized; + } + + const file = this.files[normalized.val]; + if (!file) { + const err = fsErr(FsErr.NotFound, "File not found: " + path); + return { err }; + } + + return { val: file }; + } + + public write(): Promise { + const err = fsErr(FsErr.NotSupported, "Write not supported in File API"); + return Promise.resolve({ err }); + } + + public getFile(path: string): FsFile { + return this.mgr.get(this, path); + } + public getOpenedPaths(): string[] { + return this.mgr.getOpenedPaths(); + } + public closeFile(path: string): void { + this.mgr.close(path); + } +} diff --git a/libs/pure/src/fs/impl/fe.ts b/libs/pure/src/fs/impl/fe.ts new file mode 100644 index 00000000..613dab3b --- /dev/null +++ b/libs/pure/src/fs/impl/fe.ts @@ -0,0 +1,143 @@ +import { Ok, tryAsync } from "pure/result"; +import { errstr } from "pure/utils"; + +import { FsErr, FsResult, FsVoid, fsErr, fsFail } from "../error"; +import { FsFileSystem, FsFileSystemUninit } from "../FsFileSystem"; +import { FsCapabilities } from "../support"; +import { FsFile } from "../FsFile"; +import { fsIsRoot, fsNormalize } from "../path"; + +import { FsFileMgr } from "./FsFileMgr"; +import { FsFileSystemInternal } from "./FsFileSystemInternal"; + +/// FsFileSystem implementation that uses FileEntry API +export class FsImplFe implements FsFileSystemUninit, FsFileSystem, FsFileSystemInternal { + public root: string; + public capabilities: FsCapabilities; + + private rootEntry: FileSystemDirectoryEntry; + + private mgr: FsFileMgr; + + constructor(root: string, rootEntry: FileSystemDirectoryEntry) { + this.root = root; + this.rootEntry = rootEntry; + this.capabilities = { + write: false, + live: true + }; + this.mgr = new FsFileMgr(); + } + + public init(): Promise> { + // no init needed + return Promise.resolve({ val: this }); + } + + public async listDir(path: string): Promise> { + const normalized = fsNormalize(path); + if (normalized.err) { + return normalized; + } + path = normalized.val; + + const entry = await this.resolveDir(path); + if (entry.err) { + return entry; + } + + const entries = await tryAsync(() => new Promise((resolve, reject) => { + entry.val.createReader().readEntries(resolve, reject); + })); + if ("err" in entries) { + const err = fsFail("Failed to list directory `" + path + "`: " + errstr(entries.err)); + return { err }; + } + + const names = entries.val.map(({isDirectory, name}) => { + if (isDirectory) { + return name + "/"; + } + return name; + }); + + return { val: names }; + } + + public async read(path: string): Promise> { + const normalized = fsNormalize(path); + if (normalized.err) { + return normalized; + } + path = normalized.val; + + const entry = await this.resolveFile(path); + if (entry.err) { + return entry; + } + + const file = await tryAsync(() => new Promise((resolve, reject) => { + entry.val.file(resolve, reject); + })); + if ("err" in file) { + const err = fsFail("Failed to read file `" + path + "`: " + errstr(file.err)); + return { err }; + } + + return file; + } + + public write(): Promise { + const err = fsErr(FsErr.NotSupported, "Write not supported in FileEntry API"); + return Promise.resolve({ err }); + } + + public getFile(path: string): FsFile { + return this.mgr.get(this, path); + } + public getOpenedPaths(): string[] { + return this.mgr.getOpenedPaths(); + } + public closeFile(path: string): void { + this.mgr.close(path); + } + + /// Resolve a directory entry. Path must be normalized + private async resolveDir(path: string): Promise> { + if (fsIsRoot(path)) { + return { val: this.rootEntry }; + } + const entry = await tryAsync(() => new Promise((resolve, reject) => { + this.rootEntry.getDirectory(path, {}, resolve, reject); + })); + if ("err" in entry) { + const err = fsFail("Failed to resolve directory `" + path + "`: " + errstr(entry.err)); + return { err }; + } + if (!entry.val.isDirectory) { + const err = fsErr(FsErr.IsFile, "Path `" + path + "` is not a directory"); + return { err }; + } + return entry as Ok; + } + + /// Resolve a file entry. Path must be normalized + private async resolveFile(path: string): Promise> { + if (fsIsRoot(path)) { + const err = fsErr(FsErr.IsDirectory, "Path `" + path + "` is not a file"); + return { err }; + } + const entry = await tryAsync(() => new Promise((resolve, reject) => { + this.rootEntry.getFile(path, {}, resolve, reject); + })); + if ("err" in entry) { + const err = fsFail("Failed to resolve file `" + path + "`: " + errstr(entry.err)); + return { err }; + } + if (!entry.val.isFile) { + const err = fsErr(FsErr.IsDirectory, "Path `" + path + "` is not a file"); + return { err }; + } + return entry as Ok; + } +} diff --git a/libs/pure/src/fs/impl/file.ts b/libs/pure/src/fs/impl/file.ts index 2a9a7926..aa2847cb 100644 --- a/libs/pure/src/fs/impl/file.ts +++ b/libs/pure/src/fs/impl/file.ts @@ -1,16 +1,17 @@ -import { ResultHandle } from "../../result"; -import { errstr } from "../../utils"; +import { errstr } from "pure/utils"; +import { tryAsync } from "pure/result"; + import { FsFile } from "../FsFile"; -import { FsFileSystemInternal } from "../FsFileSystem"; -import { FsErr, FsError, FsResult, fsErr, fsFail } from "../error"; +import { FsFileSystemInternal } from "./FsFileSystemInternal"; +import { FsErr, FsResult, FsVoid, 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"); +function errclosed() { + return { err: fsErr(FsErr.Closed, "File is closed")} as const; } class FsFileImpl implements FsFile { @@ -48,56 +49,59 @@ class FsFileImpl implements FsFile { public close(): void { this.closed = true; + this.fs.closeFile(this.path); } public isDirty(): boolean { return this.isBufferDirty || this.isContentNewer; } - public async getLastModified(r: ResultHandle): Promise> { + public async getLastModified(): Promise> { if (this.closed) { - return r.putErr(errclosed()); + return errclosed(); } if (this.lastModified === undefined) { - r.put(await this.loadIfNotDirty(r)); - if (r.isErr()) { - return r.ret(); + const r = await this.loadIfNotDirty(); + if (r.err) { + return r; } } - return r.putOk(this.lastModified ?? 0); + return { val: this.lastModified ?? 0 }; } - public async getText(r: ResultHandle): Promise> { + public async getText(): Promise> { if (this.closed) { - return r.putErr(errclosed()); + return errclosed(); } if (this.buffer === undefined) { - r.put(await this.load(r)); - if (r.isErr()) { - return r.ret(); + const r = await this.load(); + if (r.err) { + return r; } } if (!this.isText) { - return r.putErr(fsErr(FsErr.InvalidEncoding, "File is not valid UTF-8")); + const err = fsFail("File is not valid UTF-8"); + return { err }; } - return r.putOk(this.content ?? ""); + return { val: this.content ?? "" }; } - public async getBytes(r: ResultHandle): Promise> { + public async getBytes(): Promise> { if (this.closed) { - return r.putErr(errclosed()); + return errclosed(); } this.updateBuffer(); if (this.buffer === undefined) { - r.put(await this.load(r)); - if (r.isErr()) { - return r.ret(); + const r = await this.load(); + if (r.err) { + return r; } } if (this.buffer === undefined) { - return r.putErr(fsFail("Read was successful, but content was undefined")); + const err = fsFail("Read was successful, but content was undefined"); + return { err }; } - return r.putOk(this.buffer); + return { val: this.buffer }; } public setText(content: string): void { @@ -123,74 +127,71 @@ class FsFileImpl implements FsFile { this.lastModified = new Date().getTime(); } - public async loadIfNotDirty(r: ResultHandle): Promise> { + public async loadIfNotDirty(): Promise { if (this.closed) { - return r.putErr(errclosed()); + return errclosed(); } if (this.isDirty()) { - return r.voidOk(); + return {}; } - return await this.load(r); + return await this.load(); } - public async load(r: ResultHandle): Promise> { + public async load(): Promise { if (this.closed) { - return r.putErr(errclosed()); + return errclosed(); } - r.put(await this.fs.read(r, this.path)); - if (r.isErr()) { - return r.ret(); + const { val: file, err } = await this.fs.read(this.path); + if (err) { + return { err }; } - 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(); + return {} } } 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); + const buffer = await tryAsync(async () => new Uint8Array(await file.arrayBuffer())); + if ("err" in buffer) { + const err = fsFail(errstr(buffer.err)); + return { err }; } + this.buffer = buffer.val; this.isBufferDirty = false; // Try decoding the buffer as text this.decodeBuffer(); this.isContentNewer = false; - return r.ret(); + return {}; } - public async writeIfNewer(r: ResultHandle): Promise> { + public async writeIfNewer(): Promise { if (this.closed) { - return r.putErr(errclosed()); + return errclosed(); } if (!this.isDirty()) { - return r.voidOk(); + return {} } - return await this.write(r); + return await this.write(); } /// 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> { + private async write(): Promise { this.updateBuffer(); const buffer = this.buffer; if (this.content === undefined || buffer === undefined) { // file was never read or modified - return r.voidOk(); + return {}; } - r.put(await this.fs.write(r, this.path, buffer)); - if (r.isOk()) { - this.isBufferDirty = false; + const result = await this.fs.write(this.path, buffer); + if (result.err) { + return result; } - return r; + return {}; } private decodeBuffer() { diff --git a/libs/pure/src/fs/impl/fsa.ts b/libs/pure/src/fs/impl/fsa.ts index 0d8b373f..dcb70336 100644 --- a/libs/pure/src/fs/impl/fsa.ts +++ b/libs/pure/src/fs/impl/fsa.ts @@ -1,41 +1,198 @@ //! FsFileSystem implementation for FileSystemAccess API -import { ResultHandle } from "../../result"; +import { tryAsync } from "pure/result"; +import { errstr } from "pure/utils"; + import { FsFileSystem, FsFileSystemUninit } from "../FsFileSystem"; -import { FsErr, FsError, FsResult, fsErr } from "../error"; +import { FsErr, FsResult, FsVoid, fsErr, fsFail } from "../error"; +import { FsCapabilities } from "../support"; +import { FsFile } from "../FsFile"; +import { fsComponents, fsGetBase, fsGetName, fsIsRoot, fsNormalize } from "../path"; +import { FsFileMgr } from "./FsFileMgr"; +import { FsFileSystemInternal } from "./FsFileSystemInternal"; type PermissionStatus = "granted" | "denied" | "prompt"; -/// FileSys implementation that uses FileSystem Access API +/// FsFileSystem implementation that uses FileSystem Access API /// This is only supported in Chrome/Edge -export class FsImplFsa implements FsFileSystemUninit { +export class FsImplFsa implements FsFileSystemUninit, FsFileSystem, FsFileSystemInternal { + public root: string; + public capabilities: FsCapabilities; /// If app requested write access - private write: boolean; - private rootPath: string; + private writeMode: boolean; private rootHandle: FileSystemDirectoryHandle; private permissionStatus: PermissionStatus; + private mgr: FsFileMgr; + constructor( rootPath: string, rootHandle: FileSystemDirectoryHandle, write: boolean, ) { - this.rootPath = rootPath; + this.root = rootPath; this.rootHandle = rootHandle; - this.write = write; + this.writeMode = write; this.permissionStatus = "prompt"; + this.capabilities = { + write, + live: true, + }; + this.mgr = new FsFileMgr(); } - public async init(r: ResultHandle): Promise> { + public async init(): Promise> { // @ts-expect-error ts lib does not have requestPermission this.permissionStatus = await this.rootHandle.requestPermission({ - mode: this.write ? "readwrite" : "read", + mode: this.writeMode ? "readwrite" : "read", }); if (this.permissionStatus !== "granted") { - return r.putErr(fsErr(FsErr.PermissionDenied, "User denied permission")); + const err = fsErr(FsErr.PermissionDenied, "User denied permission"); + return { err }; + } + return { val: this }; + } + + public async listDir(path: string): Promise> { + const normalized = fsNormalize(path); + if (normalized.err) { + return normalized; + } + path = normalized.val; + + const handle = await this.resolveDir(path); + if (handle.err) { + return handle; } - return r.putOk(this); + + const entries = await tryAsync(async () => { + const entries: string[] = []; + // @ts-expect-error FileSystemDirectoryHandle.values() not in ts lib + for await (const entry of handle.values()) { + const { kind, name } = entry; + if (kind === "directory") { + entries.push(name + "/"); + } else { + entries.push(name); + } + } + return entries; + }); + if ("err" in entries) { + const err = fsFail("Error reading entries from directory `" + path + "`: " + errstr(entries.err)); + return { err }; + } + return entries; } + + public async read(path: string): Promise> { + const normalized = fsNormalize(path); + if (normalized.err) { + return normalized; + } + path = normalized.val; + + const handle = await this.resolveFile(path); + if (handle.err) { + return handle; + } + + const file = await tryAsync(() => handle.val.getFile()); + if ("err" in file) { + const err = fsFail("Failed to read file `" + path + "`: " + errstr(file.err)); + return { err }; + } + return file; + } + + public async write(path: string, content: Uint8Array): Promise { + if (!this.writeMode) { + const err = fsErr(FsErr.PermissionDenied, "Write mode not requested"); + return { err }; + } + const normalized = fsNormalize(path); + if (normalized.err) { + return normalized; + } + path = normalized.val; + + const handle = await this.resolveFile(path); + if (handle.err) { + return handle; + } + + const result = await tryAsync(async () => { + const file = await handle.val.createWritable(); + await file.write(content); + await file.close(); + return {}; + }); + if ("err" in result) { + const err = fsFail("Failed to write file `" + path + "`: " + errstr(result.err)); + return { err }; + } + return {}; + } + + public getFile(path: string): FsFile { + return this.mgr.get(this, path); + } + + public getOpenedPaths(): string[] { + return this.mgr.getOpenedPaths(); + } + public closeFile(path: string): void { + this.mgr.close(path); + } + + /// Resolve the FileSystemDirectoryHandle for a directory. + /// The path must be normalized + private async resolveDir(path: string): Promise> { + if (fsIsRoot(path)) { + return { val: this.rootHandle }; + } + let handle: FileSystemDirectoryHandle = this.rootHandle; + const parts: string[] = []; + for (const part of fsComponents(path)) { + parts.push(part); + const next = await tryAsync(() => handle.getDirectoryHandle(part)); + if ("err" in next) { + const dir = parts.join("/"); + const err = fsFail("Failed to resolve directory `" + dir + "`: " + errstr(next.err)); + return { err }; + } + handle = next.val; + } + + return { val: handle }; + } + + /// Resolve the FileSystemFileHandle for a file. + /// The path must be normalized + private async resolveFile(path: string): Promise> { + const parent = fsGetBase(path); + if (parent.err) { + return parent; + } + + const name = fsGetName(path); + if (name.err) { + return name; + } + + const handle = await this.resolveDir(parent.val); + if (handle.err) { + return handle; + } + + const file = await tryAsync(() => handle.val.getFileHandle(name.val)); + if ("err" in file) { + const err = fsFail("Failed to resolve file `" + path + "`: " + errstr(file.err)); + return { err }; + } + return file; + } + } diff --git a/libs/pure/src/fs/impl/index.ts b/libs/pure/src/fs/impl/index.ts index ad7a82c6..c7fe9e71 100644 --- a/libs/pure/src/fs/impl/index.ts +++ b/libs/pure/src/fs/impl/index.ts @@ -1 +1,3 @@ export * from "./fsa"; +export * from "./fe"; +export * from "./f"; diff --git a/libs/pure/src/fs/index.ts b/libs/pure/src/fs/index.ts index 493f2a74..9997c4b6 100644 --- a/libs/pure/src/fs/index.ts +++ b/libs/pure/src/fs/index.ts @@ -1 +1,6 @@ export * from "./support"; +export * from "./path"; +export * from "./open"; +export * from "./FsFileSystem"; +export * from "./FsFile"; +export * from "./error"; diff --git a/libs/pure/src/fs/open.ts b/libs/pure/src/fs/open.ts index d749cf4c..9de8fe85 100644 --- a/libs/pure/src/fs/open.ts +++ b/libs/pure/src/fs/open.ts @@ -1,88 +1,88 @@ -import { ResultHandle } from "../result"; -import { errstr } from "../utils"; -import { FsFileSystem, FsFileSystemUninit } from "./FsFileSystem"; +import { tryCatch, tryAsync } from "pure/result"; +import { errstr } from "pure/utils"; +import { FsFileSystem, FsFileSystemUninit } from "./FsFileSystem"; import { FsErr, FsError, FsResult, fsErr, fsFail } from "./error"; -import { FsImplFsa } from "./impl"; +import { FsImplFsa, FsImplFe, FsImplF } 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>; +export type FsOpenRetryHandler = (error: FsError, attempt: number) => Promise>; + +const MAX_RETRY = 10; /// 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(); +export async function fsOpenRead(retryHandler?: FsOpenRetryHandler): Promise> { + const fs = await createWithPicker(false, retryHandler); + if (fs.err) { + return fs; } - return await init(r, r.value, retryHandler); + return await init(fs.val, 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(); +export async function fsOpenReadWrite(retryHandler?: FsOpenRetryHandler): Promise> { + const fs = await createWithPicker(true, retryHandler); + if (fs.err) { + return fs; } - return await init(r, r.value, retryHandler); + return await init(fs.val, 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 + item: DataTransferItem, retryHandler?: FsOpenRetryHandler ): Promise> { - r.put(await createFromDataTransferItem(r, item, false, retryHandler)); - if (r.isErr()) { - return r.ret(); + const fs = await createFromDataTransferItem(item, false, retryHandler); + if (fs.err) { + return fs; } - return await init(r, r.value, retryHandler); + return await init(fs.val, 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 + item: DataTransferItem, retryHandler?: FsOpenRetryHandler ): Promise> { - r.put(await createFromDataTransferItem(r, item, true, retryHandler)); - if (r.isErr()) { - return r.ret(); + const fs = await createFromDataTransferItem(item, true, retryHandler); + if (fs.err) { + return fs; } - return await init(r, r.value, retryHandler); + return await init(fs.val, retryHandler); } async function createWithPicker( - r: ResultHandle, write: boolean, retryHandler: FsOpenRetryHandler | undefined + write: boolean, retryHandler: FsOpenRetryHandler | undefined ): Promise> { - let attempt = -1; - - while (true) { - attempt++; + for (let attempt = 1; attempt <= MAX_RETRY; attempt++) { const { implementation } = fsGetSupportStatus(); if (implementation === "FileSystemAccess") { - r.put(await r.tryCatchAsync(r, () => showDirectoryPicker(write))); - if (r.isErr()) { + const handle = await tryAsync(() => showDirectoryPicker(write)); + if (handle.val) { + return createFromFileSystemHandle(handle.val, write); + } + if (retryHandler) { // eslint-disable-next-line @typescript-eslint/no-explicit-any - const isAbort = r.error && (r.error as any).name === "AbortError"; + const isAbort = handle.err && (handle.err 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; - } + : fsFail(errstr(handle.err)); + const shouldRetry = await retryHandler(error, attempt); + if (shouldRetry.err) { + // retry handler failed + return shouldRetry; + } + if (!shouldRetry.val) { + // don't retry + return { err: error }; } - // don't retry - return r.putErr(error); } - const handle = r.value; - return createFromFileSystemHandle(r, handle, write); + // Retry with FileSystemAccess API + continue; } - // FileEntry API only supported through drag and drop, so fallback to File API + + // 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"; @@ -90,127 +90,104 @@ async function createWithPicker( inputElement.type = "file"; inputElement.webkitdirectory = true; - r.put(await new Promise>((resolve) => { + const fsUninit = 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; + const err = fsFail("Failed to get files from input element"); + return resolve({ err }); } - resolve(createFromFileList(r, files)); + resolve(createFromFileList(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; - } + if (fsUninit.val) { + return fsUninit; + } + + if (retryHandler) { + const shouldRetry = await retryHandler(fsUninit.err, attempt); + if (shouldRetry.err) { + // retry handler failed + return shouldRetry; + } + if (!shouldRetry.val) { + // don't retry + return fsUninit; } - // don't retry - return r.putErr(error); + // fall through to retry } - return r; } + return { err: fsFail("Max retry count reached") }; } async function createFromDataTransferItem( - r: ResultHandle, item: DataTransferItem, write: boolean, retryHandler: FsOpenRetryHandler | undefined ): Promise> { - let attempt = -1; - while (true) { - attempt++; + for (let attempt = 1;attempt <= MAX_RETRY; attempt++) { + let error: FsError | undefined = undefined; 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); + const handle = await tryAsync(() => getAsFileSystemHandle(item)); + if (handle.val) { + return createFromFileSystemHandle(handle.val, 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); + error = fsFail("Failed to get handle from DataTransferItem: "+ errstr(handle.err)); + } else if ("webkitGetAsEntry" in item && implementation === "FileEntry") { + const entry = tryCatch(() => webkitGetAsEntry(item)); + if (entry.val) { + return createFromFileSystemEntry(entry.val); } - // fall through + error = fsFail("Failed to get entry from DataTransferItem: "+ errstr(entry.err)); } - - // 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; - } + if (!error) { + const err = fsErr(FsErr.NotSupported, "No supported API found on the DataTransferItem"); + return { err }; + } + // handle error + if (retryHandler) { + const shouldRetry = await retryHandler(error, attempt); + if (shouldRetry.err) { + // retry handler failed + return shouldRetry; + }; + if (!shouldRetry.val) { // don't retry - return r.putErr(error); + return { err: error }; } + // fall through to retry } - break; } - - return r.putErr(fsErr(FsErr.NotSupported, "File system is not supported in the current environment")); + return { err: fsFail("Max retry count reached") }; } async function init( - r: ResultHandle, fs: FsFileSystemUninit, retryHandler: FsOpenRetryHandler | undefined + fs: FsFileSystemUninit, + retryHandler: FsOpenRetryHandler | undefined ): Promise> { let attempt = -1; while(true) { attempt++; - r.put(await fs.init(r)); - if (r.isOk()) { - return r; + const inited = await fs.init(); + if (!inited.err) { + return inited; } if (!retryHandler) { - return r; + return inited; } - const error = r.error; - r = r.erase(); - r.put(await retryHandler(r, error, attempt)); - if (r.isErr()) { + const shouldRetry = await retryHandler(inited.err, attempt); + if (shouldRetry.err) { // retry handler failed - return r.ret(); + return shouldRetry; } - if (!r.value) { + if (!shouldRetry.val) { // should not retry - return r.putErr(error); + return inited; } } } @@ -222,11 +199,11 @@ function showDirectoryPicker(write: boolean): Promise { } /// Wrapper for DataTransferItem.getAsFileSystemHandle -function getAsFileSystemHandle(item: DataTransferItem): FileSystemHandle { +async function getAsFileSystemHandle(item: DataTransferItem): Promise { // @ts-expect-error getAsFileSystemHandle is not in the TS lib - const handle = item.getAsFileSystemHandle(); + const handle = await item.getAsFileSystemHandle(); if (!handle) { - throw new Error("Failed to get handle from DataTransferItem"); + throw new Error("handle is null"); } return handle; } @@ -235,16 +212,15 @@ function getAsFileSystemHandle(item: DataTransferItem): FileSystemHandle { function webkitGetAsEntry(item: DataTransferItem): FileSystemEntry { const entry = item.webkitGetAsEntry(); if (!entry) { - throw new Error("Failed to get entry from DataTransferItem"); + throw new Error("entry is null"); } return entry; } -function createFromFileSystemHandle( - r: ResultHandle, handle: FileSystemHandle, write: boolean -): FsResult { +function createFromFileSystemHandle(handle: FileSystemHandle, write: boolean): FsResult { if (handle.kind !== "directory") { - return r.putErr(fsErr(FsErr.IsFile, "Expected directory")); + const err = fsErr(FsErr.IsFile, "Expected directory"); + return { err }; } const fs = new FsImplFsa( @@ -253,36 +229,25 @@ function createFromFileSystemHandle( write ); - return r.putOk(fs); + return { val: fs }; }; -function createFromFileSystemEntry( - r: ResultHandle, entry: FileSystemEntry, -): FsResult { +function createFromFileSystemEntry(entry: FileSystemEntry): FsResult { if (entry.isFile || !entry.isDirectory) { - return r.putErr(fsErr(FsErr.IsFile, "Expected directory")); + const err = fsErr(FsErr.IsFile, "Expected directory"); + return { err }; } - const fs = new FileEntriesApiFileSys( + const fs = new FsImplFe( entry.name, entry as FileSystemDirectoryEntry, ); - return r.putOk(fs); + return { val: fs }; } -function createFromFileList( - r: ResultHandle, files: FileList -): FsResult { +function createFromFileList(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 err = fsFail("Expected at least one file"); + return { err }; } - const fs = new FileApiFileSys(rootName, fileMap); - return r.putOk(fs); + return { val: new FsImplF(files) }; }; diff --git a/libs/pure/src/fs/path.ts b/libs/pure/src/fs/path.ts index 174b8b04..8a957fd2 100644 --- a/libs/pure/src/fs/path.ts +++ b/libs/pure/src/fs/path.ts @@ -7,7 +7,6 @@ //! - 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. @@ -31,15 +30,16 @@ export function fsIsRoot(p: string): boolean { /// 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 { +export function fsGetBase(p: string): FsResult { if (fsIsRoot(p)) { - return r.putErr(fsErr(FsErr.InvalidPath, "Trying to get the parent of root")); + const err = fsErr(FsErr.InvalidPath, "Trying to get the base of root"); + return { err }; } const i = Math.max(p.lastIndexOf("/"), p.lastIndexOf("\\")); if (i < 0) { - return r.putOk(fsRoot()); + return { val: fsRoot() }; } - return r.putOk(p.substring(0, i)); + return { val: p.substring(0, i) }; } /// Get the name of a path (i.e. the last component) @@ -48,35 +48,36 @@ export function fsGetBase(r: ResultHandle, p: string): FsResult { /// Does not include leading or trailing slashes. /// /// If this path is the root directory, return IsRoot. -export function fsGetName(r: ResultHandle, p: string): FsResult { +export function fsGetName(p: string): FsResult { p = stripTrailingSlashes(p); if (fsIsRoot(p)) { - return r.putErr(fsErr(FsErr.IsRoot, "Root directory has no name")); + const err = fsErr(FsErr.IsRoot, "Root directory has no name"); + return { err }; } const i = Math.max(p.lastIndexOf("/"), p.lastIndexOf("\\")); if (i < 0) { - return r.putOk(p); + return { val: p }; } - return r.putOk(p.substring(i + 1)); + return { val: 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 { +export function fsNormalize(p: string): FsResult { let s = fsRoot(); for (const comp of fsComponents(p)) { if (comp === "..") { - r.put(fsGetBase(r, s)); - if (r.isErr()) { - return r; + const base = fsGetBase(s); + if (base.err) { + return base; } - s = r.value; + s = base.val; continue; } s = fsJoin(s, comp); } - return r.putOk(s); + return { val: s }; } /// Join two paths diff --git a/libs/pure/src/result/README.md b/libs/pure/src/result/README.md index f70f75de..42ae3b5c 100644 --- a/libs/pure/src/result/README.md +++ b/libs/pure/src/result/README.md @@ -1,35 +1,40 @@ # pure/result -TypeScript based result return type, inspired by Rust, but not completely the same. +**I once had a fancy error object with TypeScript magic that tries +to reduce allocation while maintaining Result-safety. It turns out +that was slower than allocating plain objects for every return, because +of how V8 optimizes things.** -This project is used internally in my own projects. If you want to depend on it, -simply copy the files over to your project. +Don't even use `isErr()` helper functions to abstract. They are slower than +directly property access in my testing. + +Copy `index.ts` to somewhere in your project to use this library ## Function that can fail -Instead of having functions throw, make it return instead. +Instead of having functions `throw`, make it `return` instead. ```typescript // Instead of function doSomethingCanFail() { if (Math.random() < 0.5) { - return; + return 42; } throw "oops"; } -// Do this (what ResultHandle and Result are will be explained below) -import type { ResultHandle, Result } from "pure/result"; +// Do this +import type { Result } from "pure/result"; -function doSomethingCanFail(r: ResultHandle): Result { +function doSomethingCanFail(): Result { if (Math.random() < 0.5) { - return r.voidOk(); + return { val: 42 }; } - return r.putErr("oops"); + return { err: "oops" }; } ``` This is similar to Rust: ```rust -fn do_something_can_fail() -> Result<(), String> { +fn do_something_can_fail() -> Result { if ... { - return Ok(()); + return Ok(42); } Err("oops".to_string()) @@ -37,227 +42,91 @@ fn do_something_can_fail() -> Result<(), String> { ``` ## Calling function that can fail -A function that returns `Result` should take in `ResultHandle` as one of the parameters, -and use it to interact with the result system. - -The `ResultHandle` is actually the same object as the `Result`. The functions you call -are only for TypeScript magic. You can think of `ResultHandle` as uninitialized `Result`. +The recommended pattern is +```typescript +const x = doTheCall(); // x is Result; +if (x.err) { + // x.err is E, handle it + return ... +} +// x.val is T +// ... +``` +If your `E` type covers falsy values that are valid, use `"err" in x` instead of `x.err`. +A well-known case is `Result`. `if(r.err)` cannot narrow the else case to `Ok`, +but `if("err" in r)` can. -This example below shows this interaction: +A full example: ```typescript -function getParam(r: ResultHandle, name: string): Result { +function getParam(name: string): Result { if (name === "a") { - // `putOk` mutates r to contain an Ok value - return r.putOk(13); // the return expression has type Result + return { val: 13 }; } if (name === "b") { - return r.putOk(42); + return { val: 42 }; } - // `putErr` mutates r to contain an Err value - return r.putErr(new Error("bad name")); + return { err: new Error("bad name") }; } function multiplyFormat( - r: ResultHandle, name1: string, name2: string, prefix: string ): Result { - // breaking this down to individual steps so I can explain the TypeScript magic - r.put( - // when calling this, r has type ResultHandle - getParam(r, name1) // The return type is Result - ); // calling `put` will do nothing, but use TypeScript magic to make the type of r Result now - // (if you call `put` with a parameter that is not `this`, it will give a warning and try to copy the result over) - - // now that r is `Result`, you can use isOk and isErr to check the result - if (r.isErr()) { - // here the type of r is Err - console.error(r.error); // `error` property is only accessible after the check - - // ret() gives r back, but casted to the right type (can be casted to any Ok type) - // without ret(), r is Result, which is not assignable to Result - return r.ret(); + const v1 = getParam(name1); + if (v1.err) { + console.error(v1.err); + return v1; } - - // here, r is not Err - // so r is ResultHandle & UncheckedResult & Ok - // which means we can get the value - const v1 = r.value; - - // now we want to reuse r to handle the next call - // by calling r.erase(), it returns r back with ResultHandle type - // since that's is compatible with the function parameter, - // we can assign it and erase the previous handled information TypeScript knows - r.put(getParam(r = r.erase(), name2)); - if (r.isErr()) { - return r.ret(); + const v2 = getParam(name1); + if (v2.err) { + console.error(v2.err); + return v2; } - const v2 = r.value; - const formatted = `${prefix}${v1 * v2}`; - return r.putOk(formatted); + const formatted = `${prefix}${v1.val * v2.val}`; + return { val: formatted }; } ``` -You might be thinking, why all the TypeScript magic?? Why not just do this: -```typescript -type Result = { ok: true, value: T } | { ok: false, error: E }; -``` -I have 2 reasons: -1. Unlike Rust, you cannot redeclare a variable to shadow the previous declaration. With a naive implementation, you end up with: - ```typescript - function foo() { - const result1 = doSomething(); - if (!result1.ok) { - return result1; - } - const v1 = result1.value; - const result2 = doSomethingElse(); - if (!result2.ok) { - return result2; - } - const v2 = result2.value; - } - ``` - Note the temporary `result1` and `result2`, which doesn't look pretty. - -2. You need to constantly create and destructure objects. This could be a performance issue, but I never - benchmarked anything, so it could just be my imagination. (In fact, my approach could perform worse) +## Interop with throwing functions +This library also has `tryCatch` to interop with throwing functions, +and `tryAsync` for async functions. -## Holding on to result -One issue left is that since we are using the same `r` handle, we could run into concurrency issues. -Say the example above becomes async: ```typescript -async function getParam(r: ResultHandle, name: string): Promise> { - ... -} - -async function multiplyFormat( - r: ResultHandle, - name1: string, - name2: string, - prefix: string -): Promise> { - r.put(await getParam(r, name1)); - if (r.isErr()) { - return r.ret(); - } - const v1 = r.value; +import { tryCatch, tryAsync } from "pure/result"; - r.put(await getParam(r = r.erase(), name2)); - if (r.isErr()) { - return r.ret(); - } - const v2 = r.value; +// synchronous +const result1: Result = tryCatch(() => JSON.parse(...)); +// or you can specify the error type: +const result2 = tryCatch(() => JSON.parse(...)); - const formatted = `${prefix}${v1 * v2}`; - return r.putOk(formatted); -} -``` -The problem comes if we want to call both `getParam` first, then await together: -```typescript -async function multiplyFormatAsync( - r: ResultHandle, - name1: string, - name2: string, - prefix: string -): Promise> { - await Promise.all([getParam(r, name1), getParam(r = r.erase(), name2)]); - // since getParam will store the result in r directly, we lost one value -} -``` -To overcome this, `fork()` is provided. It creates an empty ResultHandle. -Despite the name, it will not contain any value from the original handle. -```typescript -async function multiplyFormatAsync( - r1: ResultHandle, - name1: string, - name2: string, - prefix: string -): Promise> { - const r2 = r1.fork(); - await Promise.all([ - r1.put(getParam(r1, name1)), - r2.put(getParam(r2, name2)) - ]); - if (r1.isErr()) { - return r1.ret(); - } - if (r2.isErr()) { - return r2.ret(); +// asynchronous +async function doSomethingCanFail() { + if (Math.random() < 0.5) { + return 42; } - const formatted = `${prefix}${r1.value * v2.value}`; - // you must use r1, not r2 here, since that's the parameter passed in - return r1.putOk(formatted); -} -``` - -## Outermost call site -The last question remains: how to get `ResultHandle` in the first place to pass -to a function? This library provides 4 utility functions to initiate the call. -```typescript -import { tryCatch, tryCatchAsync, tryInvoke, tryInvokeAsync } from "pure/result"; - -// 1. Use tryInvoke to get a handle for invoking functions that return result -const result = tryInvoke(r => multiplyFormat(r, "a", "b", "answer: ")); -// the type of result is StableResult -// you can call isOk and isErr on it, -// but cannot call putOk or putErr like you would with a ResultHandle -if (result.isOk()) { - console.log(result.value) // 42 * 13 = 546 -} - -// 2. Use tryInvoke to do the same, but async -// tryInvokeAsync takes in a (r: ResultHandle) => Promise> -const result = await tryInvokeAsync((r) => multiplyFormatAsync(r, "a", "b", "answer: ")); - -// 3. Use tryCatch to wrap a function that throws with try-catch -const result = tryCatch(() => JSON.parse(...)); -// result has type StableResult - -// 4. Use tryCatchAsync to wrap an async function that can throw -async function doStuff() { throw "oops"; } -const result = await tryCatchAsync(doStuff); +const result = await tryAsync(() => doStuff); ``` -## Innermost call site -What if you need to call a throwing function inside a result-handling function? -Use `r.tryCatch` and `r.tryCatchAsync` +## Returning void +Use `Void` as the return type if the function returns `void` on success ```typescript -import { ResultHandle, Result } from "pure/result"; - -function doSomethingThatCouldThrow(): FooType { - ... -} - -function foo(r: ResultHandle): Result { - // r.erase() is only needed if there are previous usage of r - // in this function - r.put(r.tryCatch(r = r.erase(), doSomethingThatCouldThrow)); - // type of r is Result -} - -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)); - // type of r is Result +const x = doSomethingThatVoidsOnSuccess(); +if (x.err) { + return x; } +// type of x is Record, i.e. empty object ``` ## Why is there no `match`/`map`/`mapErr`, etc? If you are thinking this is a great idea: ```typescript -const result = tryInvoke(foo); -result.match( +const result = foo(bar); +match(result, (okValue) => { // handle ok case }, @@ -269,14 +138,16 @@ result.match( The vanilla `if` doesn't allocate the closures, and has less code, and you can control the flow properly inside the blocks with `return`/`break`/`continue` ```typescript -const result = tryInvoke(foo); -if (result.isOk()) { - // handle ok case -} else { +const result = foo(bar); +if (result.err) { // handle err case +} else { + // handle ok case } ``` As for the other utility functions from Rust's Result type, they really only benefit because you can early return with `?` AND those abstractions are zero-cost in Rust. -Neither is true in JavaScript. Please just handle it in the most straightforward way. +Neither is true in JavaScript. + +You can also easily write them yourself if you really want to. diff --git a/libs/pure/src/result/index.d.ts b/libs/pure/src/result/index.d.ts deleted file mode 100644 index bc0f1903..00000000 --- a/libs/pure/src/result/index.d.ts +++ /dev/null @@ -1,102 +0,0 @@ -//! pure/result -//! -//! TypeScript based result return type. See README.md for more information. - -/// Handle used to interact with the result system -/// inside a function -/// -/// The functions here are just for TypeScript magic. See README.md -/// for how to use them -export interface ResultHandle { - /// Erase the types inferred by TypeScript - /// - /// The typical usage is `r = r.erase()` - /// NOTE: this does NOT actually erase the value!!! - erase: () => ResultHandle, - - /// Put a result into the handle for checking - put: (r: Result) => asserts this is Result, - - /// Call a throwing function inside a result-handling function, - /// capturing the result inside this handle - tryCatch: (r: ResultHandle, fn: () => T) => Result, - - /// Await a throwing promise inside a result-handling function, - /// capturing the result inside this handle - tryCatchAsync: (r: ResultHandle, fn: () => Promise) => Promise>, - - /// Put an ok value into this handle - /// - /// Typically used at return position (i.e. `return r.putOk(value)`) - putOk: (value: T) => Result, - - /// Put an ok value as void (undefined) - /// - /// Typically used at return position (i.e. `return r.voidOk()`) - voidOk: () => Result, - - /// Put an error value into this handle - /// - /// Typically used at return position (i.e. `return r.putErr(error)`) - putErr: (error: E) => Result, - - /// Create a new handle detached from this one - /// - /// See README.md for when this is needed - fork: () => ResultHandle, -} - -/// Type of result before it is checked -export interface UncheckedResult { - isOk: () => this is Ok, - isErr: () => this is Err, -} - - -/// Type of result used internally in functions that take ResultHandle -/// This can be converted back to ResultHandle by calling `erase()` -export type Result = ResultHandle & UncheckedResult & (Ok | Err); - -/// Result checked to be Ok -export interface Ok extends StableOk { - /// Cast the value back to a result with any error type - /// - /// Used to re-return the result. See README.md for more information - ret: () => Result, -} - -/// Result checked to be Err -interface Err extends StableErr { - /// Cast the value back to a result with any error type - /// - /// Used to re-return the result. See README.md for more information - ret: () => Result, -} - -/// Type of result returned by the tryXXX wrapper functions -/// -/// This result is detached from the handle and will not leak information. -/// For example, an Ok result will only contain the value, not temporary error -/// previous stored. -export type StableResult = StableUncheckedResult & (StableOk | StableErr); -export interface StableUncheckedResult { - isOk: () => this is StableOk, - isErr: () => this is StableErr, -} -export type StableOk = { value: T }; -export type StableErr = { error: E }; - -/// Invoke a function that takes a ResultHandle and return a Result -export function tryInvoke(fn: (r: ResultHandle) => Result): StableResult; - -/// Invoke an async function that takes a ResultHandle and return a Promsie -/// -/// Note that if the async function throws, it will NOT be captured -export function tryInvokeAsync(fn: (r: ResultHandle) => Promise>): Promise>; - -/// Wrap a function that may throw an error and return a Result, capturing the error -export function tryCatch(fn: () => T): StableResult; - -/// Wrap a promise that may throw when awaited and return a Result, capturing the error -export function tryCatchAsync(fn: () => Promise): Promise>; - diff --git a/libs/pure/src/result/index.js b/libs/pure/src/result/index.js deleted file mode 100644 index 0ccf6ecc..00000000 --- a/libs/pure/src/result/index.js +++ /dev/null @@ -1,121 +0,0 @@ -/// Implementation for ResultHandle and Result -class ResultImpl { - constructor() { - this.ok = false; - this.inner = undefined; - } - - erase() { return this; } - put(r) { - if (this !== r) { - console.warn("pure/result: Violation! You must pass the same handle to put() as the handle it's invoked from (i.e. x.put(x))!"); - this.ok = r.ok; - this.inner = r.inner; - } - } - - tryCatch(r, fn) { - if (this !== r) { - console.warn("pure/result: Violation! You must pass the same handle to tryCatch() as the handle it's invoked from (i.e. x.tryCatch(x))!"); - } - try { - r.putOk(fn(r)); - } catch (e) { - r.putErr(e); - } - return r; - } - - 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 fn()); - } catch (e) { - r.putErr(e); - } - return r; - } - - - putOk(value) { - this.ok = true; - this.inner = value; - return this; - } - voidOk() { - this.ok = true; - this.inner = undefined; - return this; - } - putErr(error) { - this.ok = false; - this.inner = error; - return this; - } - fork() { - return new ResultImpl(); - } - - isOk() { return this.ok; } - isErr() { return !this.ok; } - ret() { return this; } - - // private - - toStable() { - if (this.ok) { - return new StableOk(this.inner); - } - return new StableErr(this.inner); - } -} - -/// Implementation for StableOk -class StableOk { - constructor(inner) { - this.inner = inner; - } - - get value() { return this.inner; } - isOk() { return true; } - isErr() { return false; } -} - -/// Implementation for StableOk -class StableErr { - constructor(inner) { - this.inner = inner; - } - - get error() { return this.inner; } - isOk() { return false; } - isErr() { return true; } -} - -/// Wrappers - -export function tryInvoke(fn) { - return fn(new ResultImpl()).toStable(); -} - -export async function tryInvokeAsync(fn) { - (await fn(new ResultImpl())).toStable(); -} - -export function tryCatch(fn) { - try { - return new StableOk(fn()); - } catch (e) { - return new StableErr(e); - } -} - -export async function tryCatchAsync(fn) { - try { - return new StableOk(await fn()); - } catch (e) { - return new StableErr(e); - } -} diff --git a/libs/pure/src/result/index.ts b/libs/pure/src/result/index.ts new file mode 100644 index 00000000..570202b3 --- /dev/null +++ b/libs/pure/src/result/index.ts @@ -0,0 +1,29 @@ +//! pure/result +//! +//! TypeScript based result return type. See README.md for more information. + +// If these look weird, it's because TypeScript is weird +// This is to get type narrowing to work most of the time +export type Ok = { val: T, err?: never }; +export type Err = { err: E, val?: never }; +export type Void = { val?: never, err?: never } | { err: E }; + +export type Result = Ok | Err; + +/// Wrap a function with try-catch and return a Result. +export function tryCatch(fn: () => T): Result { + try { + return { val: fn() }; + } catch (e) { + return { err: e as E }; + } +} + +/// Wrap an async function with try-catch and return a Promise. +export async function tryAsync(fn: () => Promise): Promise> { + try { + return { val: await fn() }; + } catch (e) { + return { err: e as E }; + } +} diff --git a/libs/pure/src/utils/index.ts b/libs/pure/src/utils/index.ts index 94b31d27..902ffe26 100644 --- a/libs/pure/src/utils/index.ts +++ b/libs/pure/src/utils/index.ts @@ -1,12 +1,18 @@ +export * from "./lock"; /// 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; + if (e) { + if (typeof e === "object" && "message" in e) { + if (typeof e.message === "string") { + return e.message; + } + } + if (typeof e === "object" && "toString" in e) { + return e.toString(); } } return `${e}`; diff --git a/libs/pure/src/utils/lock.ts b/libs/pure/src/utils/lock.ts index 560d9ddd..4ac1aa06 100644 --- a/libs/pure/src/utils/lock.ts +++ b/libs/pure/src/utils/lock.ts @@ -3,20 +3,24 @@ 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; +/// +/// It can take a second type parameter to specify interface with write methods +export class RwLock { + /// This is public so inner object can be accessed directly + /// ONLY SAFE in sync context + public inner: TWrite; private readers: number = 0; private isWriting: boolean = false; private readWaiters: Deque<() => void> = new Deque(); private writeWaiters: Deque<() => void> = new Deque(); - constructor(t: T) { + constructor(t: TWrite) { 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 { + /// Acquire a read (shared) lock and call fn with the value. Release the lock when fn returns or throws. + public async scopedRead(fn: (t: TRead) => Promise): Promise { if (this.isWriting) { await new Promise((resolve) => { // need to check again to make sure it's not already done @@ -48,10 +52,10 @@ export class RwLock { } } - /// Acquire a write lock and call fn with the value. Release the lock when fn returns or throws. + /// Acquire a write (exclusive) 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 { + /// fn takes a setter function as second parameter, which you can use to update the value like `x = set(newX)` + public async scopedWrite(fn: (t: TWrite, setter: Setter) => Promise): Promise { if (this.isWriting || this.readers > 0) { await new Promise((resolve) => { // need to check again to make sure it's not already done @@ -65,7 +69,7 @@ export class RwLock { // acquired this.isWriting = true; try { - return await fn(this.inner, (t: T) => { + return await fn(this.inner, (t: TWrite) => { this.inner = t; return t; }); @@ -82,4 +86,4 @@ export class RwLock { } } -export type RwLockSetter = (t: T) => T; +export type Setter = (t: T) => T; diff --git a/libs/tsconfig.json b/libs/tsconfig.json index 65d3c4a6..338267fe 100644 --- a/libs/tsconfig.json +++ b/libs/tsconfig.json @@ -20,8 +20,9 @@ "noUnusedParameters": true, "noFallthroughCasesInSwitch": true, - "baseUrl": ".", + "paths": { + "pure/*": ["pure/src/*"] + } }, "include": ["pure/src"], - "references": [{ "path": "./tsconfig.node.json" }] } diff --git a/web-client/src/low/fs/FileAccess.ts b/web-client/src/core/compiler/CompilerFileAccess.ts similarity index 76% rename from web-client/src/low/fs/FileAccess.ts rename to web-client/src/core/compiler/CompilerFileAccess.ts index b7d1a08e..24f0bc62 100644 --- a/web-client/src/low/fs/FileAccess.ts +++ b/web-client/src/core/compiler/CompilerFileAccess.ts @@ -1,16 +1,13 @@ -import { ResultHandle } from "pure/result"; - -import { FsResult } from "./FsResult"; +import { FsResult } from "pure/fs"; /// Interface for the compiler to access files -export interface FileAccess { +export interface CompilerFileAccess { /// Get the content of a file /// /// If checkChanged is true, the implementation may check if the file /// pointed to by the path was changed since the last time getFileContent was called. /// If it was not changed, the implementation could return NotModified as the error code. getFileContent( - r: ResultHandle, path: string, checkChanged: boolean, ): Promise>; diff --git a/web-client/src/core/kernel/compiler/CompilerKernel.ts b/web-client/src/core/compiler/CompilerKernel.ts similarity index 81% rename from web-client/src/core/kernel/compiler/CompilerKernel.ts rename to web-client/src/core/compiler/CompilerKernel.ts index 99919f20..68b43e64 100644 --- a/web-client/src/core/kernel/compiler/CompilerKernel.ts +++ b/web-client/src/core/compiler/CompilerKernel.ts @@ -1,6 +1,8 @@ -import { EntryPointsSorted, ExpoDoc, ExportRequest } from "low/celerc"; -import { FileAccess } from "low/fs"; -import { Result } from "low/utils"; +import type { Result } from "pure/result"; + +import type { EntryPointsSorted, ExpoDoc, ExportRequest } from "low/celerc"; + +import type { CompilerFileAccess } from "./CompilerFileAccess"; /// Interface used to access the compiler /// @@ -9,7 +11,7 @@ import { Result } from "low/utils"; /// without importing the compiler module. export interface CompilerKernel { /// Initialize the compiler and bind it to a FileAccess implementation - init(fileAccess: FileAccess): Promise; + init(fileAccess: CompilerFileAccess): Promise; /// Unbind the compiler. /// diff --git a/web-client/src/core/kernel/compiler/CompilerKernelImpl.ts b/web-client/src/core/compiler/CompilerKernelImpl.ts similarity index 82% rename from web-client/src/core/kernel/compiler/CompilerKernelImpl.ts rename to web-client/src/core/compiler/CompilerKernelImpl.ts index e1000075..f7ce2aa5 100644 --- a/web-client/src/core/kernel/compiler/CompilerKernelImpl.ts +++ b/web-client/src/core/compiler/CompilerKernelImpl.ts @@ -1,3 +1,8 @@ +import { FsErr, FsError } from "pure/fs"; +import { Result, tryAsync } from "pure/result"; +import { errstr } from "pure/utils"; + +import { getRawPluginOptions } from "core/doc"; import { AppStore, documentActions, @@ -16,32 +21,26 @@ import { get_entry_points, set_plugin_options, } from "low/celerc"; -import { getRawPluginOptions } from "core/doc"; import { - wrapAsync, setWorker, registerWorkerHandler, - allocOk, - Result, sleep, - allocErr, ReentrantLock, - errorToString, } from "low/utils"; -import { FileAccess, FsResultCodes } from "low/fs"; import { CompilerKernel } from "./CompilerKernel"; import { CompilerLog } from "./utils"; +import { CompilerFileAccess } from "./CompilerFileAccess"; async function checkFileExists( - fileAccess: FileAccess, + fileAccess: CompilerFileAccess, path: string, ): Promise { - const result = await fileAccess.getFileContent(path, true); - if (result.isOk()) { + const content = await fileAccess.getFileContent(path, true); + if (content.val) { return true; } - if (result.inner() === FsResultCodes.NotModified) { + if (content.err.code === FsErr.NotModified) { return true; } return false; @@ -53,7 +52,7 @@ async function checkFileExists( /// It uses FileAccess interface to send files to the worker. export class CompilerKernelImpl implements CompilerKernel { private store: AppStore; - private fileAccess: FileAccess | undefined = undefined; + private fileAccess: CompilerFileAccess | undefined = undefined; private needCompile: boolean; /// Flag used to prevent multiple compilation to run at the same time @@ -89,7 +88,7 @@ export class CompilerKernelImpl implements CompilerKernel { this.compiling = false; } - public async init(fileAccess: FileAccess) { + public async init(fileAccess: CompilerFileAccess) { this.store.dispatch(viewActions.setCompilerReady(false)); CompilerLog.info("initializing compiler worker..."); this.fileAccess = fileAccess; @@ -103,29 +102,31 @@ export class CompilerKernelImpl implements CompilerKernel { "file", 1, path, - "file access not available", + { + code: FsErr.Fail, + message: "file access not available" + } satisfies FsError ]); return; } - const result = await this.fileAccess.getFileContent( + const bytes = await this.fileAccess.getFileContent( path, checkChanged, ); - if (result.isOk()) { - worker.postMessage([ - "file", - 0, - path, - [true, result.inner()], - ]); - } else { - const err = result.inner(); - if (err === FsResultCodes.NotModified) { + if (bytes.err) { + if (bytes.err.code === FsErr.NotModified) { worker.postMessage(["file", 0, path, [false]]); } else { - worker.postMessage(["file", 1, path, err]); + worker.postMessage(["file", 1, path, bytes.err]); } + return; } + worker.postMessage([ + "file", + 0, + path, + [true, bytes.val], + ]); }, ); @@ -136,12 +137,12 @@ export class CompilerKernelImpl implements CompilerKernel { public async getEntryPoints(): Promise> { if (!(await this.ensureReady())) { CompilerLog.error("worker not ready after max waiting"); - return allocOk([]); + return { val: [] }; } if (!this.fileAccess) { - return allocOk([]); + return { val: [] }; } - return await wrapAsync(get_entry_points); + return await tryAsync(get_entry_points); } /// Trigger compilation of the document @@ -165,12 +166,11 @@ export class CompilerKernelImpl implements CompilerKernel { return; } - const validatedEntryPathResult = await this.validateEntryPath(); - if (validatedEntryPathResult.isErr()) { + const validatedEntryPath = await this.validateEntryPath(); + if (validatedEntryPath.err) { CompilerLog.warn("entry path is invalid, skipping compile"); return; } - const validatedEntryPath = validatedEntryPathResult.inner(); this.store.dispatch(viewActions.setCompileInProgress(true)); @@ -196,18 +196,18 @@ export class CompilerKernelImpl implements CompilerKernel { await this.updatePluginOptions(); CompilerLog.info("invoking compiler..."); - const result = await wrapAsync(() => { + const result = await tryAsync(() => { return compile_document( - validatedEntryPath, + validatedEntryPath.val, compilerUseCachedPrepPhase, ); }); // yielding just in case other things need to update await sleep(0); - if (result.isErr()) { - CompilerLog.error(result.inner()); + if ("err" in result) { + CompilerLog.error(result.err); } else { - const doc = result.inner(); + const doc = result.val; if (this.fileAccess && doc !== undefined) { this.store.dispatch(documentActions.setDocument(doc)); } @@ -232,13 +232,12 @@ export class CompilerKernelImpl implements CompilerKernel { }; } - const validatedEntryPathResult = await this.validateEntryPath(); - if (validatedEntryPathResult.isErr()) { + const validatedEntryPath = await this.validateEntryPath(); + if ("err" in validatedEntryPath) { return { error: "Compiler entry path is invalid. Please check your settings.", }; } - const validatedEntryPath = validatedEntryPathResult.inner(); return await this.compilerLock.lockedScope(undefined, async () => { const { compilerUseCachedPrepPhase } = settingsSelector( @@ -247,19 +246,19 @@ export class CompilerKernelImpl implements CompilerKernel { await this.updatePluginOptions(); - const result = await wrapAsync(() => { + const result = await tryAsync(() => { return export_document( - validatedEntryPath, + validatedEntryPath.val, compilerUseCachedPrepPhase, request, ); }); - if (result.isErr()) { - CompilerLog.error(result.inner()); - return { error: errorToString(result.inner()) }; + if ("err" in result) { + CompilerLog.error(result.err); + return { error: errstr(result.err) }; } - return result.inner(); + return result.val; }); } @@ -289,7 +288,7 @@ export class CompilerKernelImpl implements CompilerKernel { Result > { if (!this.fileAccess) { - return allocErr(undefined); + return { err: undefined }; } // check if entry path is a valid file const { compilerEntryPath } = settingsSelector(this.store.getState()); @@ -312,13 +311,13 @@ export class CompilerKernelImpl implements CompilerKernel { this.store.dispatch( settingsActions.setCompilerEntryPath(newEntryPath), ); - return allocErr(undefined); + return { err: undefined }; } } // if entryPath is empty string, change it to undefined const validatedEntryPath = compilerEntryPath || undefined; - return allocOk(validatedEntryPath); + return { val: validatedEntryPath }; } /// Try to correct an invalid entry path @@ -327,11 +326,11 @@ export class CompilerKernelImpl implements CompilerKernel { /// The function will try to find a valid entry path from the current project. /// However, if the same entry path is found in the current project, that will be returned private async correctEntryPath(entryPath: string): Promise { - const entryPointsResult = await this.getEntryPoints(); - if (entryPointsResult.isErr()) { + const entryPoints = await this.getEntryPoints(); + if ("err" in entryPoints) { return ""; } - const newEntryPoints = entryPointsResult.inner(); + const newEntryPoints = entryPoints.val; if (newEntryPoints.length === 0) { return ""; } @@ -379,11 +378,9 @@ export class CompilerKernelImpl implements CompilerKernel { if (pluginOptions !== this.lastPluginOptions) { this.lastPluginOptions = pluginOptions; CompilerLog.info("updating plugin options..."); - const result = await wrapAsync(() => { - return set_plugin_options(pluginOptions); - }); - if (result.isErr()) { - CompilerLog.error(result.inner()); + const result = await tryAsync(() => set_plugin_options(pluginOptions)); + if ("err" in result) { + CompilerLog.error(result.err); CompilerLog.warn( "failed to set plugin options. The output may be wrong.", ); diff --git a/web-client/src/core/kernel/compiler/index.ts b/web-client/src/core/compiler/index.ts similarity index 79% rename from web-client/src/core/kernel/compiler/index.ts rename to web-client/src/core/compiler/index.ts index 0d2a1a22..adcfc0f3 100644 --- a/web-client/src/core/kernel/compiler/index.ts +++ b/web-client/src/core/compiler/index.ts @@ -1,5 +1,7 @@ import { CompilerLog } from "./utils"; + CompilerLog.info("loading compiler module"); +export * from "./CompilerFileAccess"; export * from "./CompilerKernel"; export * from "./initCompiler"; diff --git a/web-client/src/core/kernel/compiler/initCompiler.ts b/web-client/src/core/compiler/initCompiler.ts similarity index 100% rename from web-client/src/core/kernel/compiler/initCompiler.ts rename to web-client/src/core/compiler/initCompiler.ts diff --git a/web-client/src/core/kernel/compiler/utils.ts b/web-client/src/core/compiler/utils.ts similarity index 100% rename from web-client/src/core/kernel/compiler/utils.ts rename to web-client/src/core/compiler/utils.ts diff --git a/web-client/src/core/kernel/editor/EditorKernel.ts b/web-client/src/core/editor/EditorKernel.ts similarity index 66% rename from web-client/src/core/kernel/editor/EditorKernel.ts rename to web-client/src/core/editor/EditorKernel.ts index c75128df..362a934b 100644 --- a/web-client/src/core/kernel/editor/EditorKernel.ts +++ b/web-client/src/core/editor/EditorKernel.ts @@ -1,6 +1,8 @@ //! Editor logic that wraps monaco editor -import { FileAccess, FsResult } from "low/fs"; +import { FsResult, FsVoid } from "pure/fs"; + +import { CompilerFileAccess } from "core/compiler"; /// Interface used to access editor API /// @@ -11,9 +13,6 @@ export interface EditorKernel { /// Delete the editor instance delete(): void; - // /// Reset the editor with a new file system. Unsaved changes will be lost - // setFileSys(fs: FileSys): Promise; - /// Nofity the editor that the user is active notifyActivity(): void; @@ -21,10 +20,10 @@ export interface EditorKernel { /// /// See EditorTree for input/output format /// On failure this returns empty array. This function will not throw - listDir(path: string[]): Promise; + listDir(path: string): Promise; /// Open a file in the editor - openFile(path: string[]): Promise>; + openFile(path: string): Promise; /// Check if there are unsaved changes hasUnsavedChanges(): Promise; @@ -35,12 +34,12 @@ export interface EditorKernel { /// a synchronous context, like window.onbeforeunload hasUnsavedChangesSync(): boolean; - /// Load changes from the file system for the opened files - loadChangesFromFs(): Promise>; + /// Load changes from the file system for the opened non-dirty files + loadFromFs(): Promise; /// Save changes to the file system for the opened files - saveChangesToFs(): Promise>; + saveToFs(): Promise; - /// Get a FileAccess implementation - getFileAccess(): FileAccess; + /// Get a CompilerFileAccess implementation + getFileAccess(): CompilerFileAccess; } diff --git a/web-client/src/core/kernel/editor/KernelAccess.ts b/web-client/src/core/editor/EditorKernelAccess.ts similarity index 80% rename from web-client/src/core/kernel/editor/KernelAccess.ts rename to web-client/src/core/editor/EditorKernelAccess.ts index feebd4a1..6e1d4fb2 100644 --- a/web-client/src/core/kernel/editor/KernelAccess.ts +++ b/web-client/src/core/editor/EditorKernelAccess.ts @@ -1,5 +1,5 @@ /// Interface for editor to access kernel functions -export interface KernelAccess { +export interface EditorKernelAccess { /// Reload the document, either through compiler or from server reloadDocument(): Promise; } diff --git a/web-client/src/core/editor/FileMgr.ts b/web-client/src/core/editor/FileMgr.ts new file mode 100644 index 00000000..dc7215ef --- /dev/null +++ b/web-client/src/core/editor/FileMgr.ts @@ -0,0 +1,587 @@ +import * as monaco from "monaco-editor"; + +import { FsErr, FsError, FsFile, FsFileSystem, FsResult, fsErr } from "pure/fs"; +import { RwLock } from "pure/utils"; + +import { AppDispatcher, viewActions } from "core/store"; +import { CompilerFileAccess } from "core/compiler"; +import { + Yielder, + createYielder, + sleep, +} from "low/utils"; + +import { EditorContainerDOM } from "./dom"; +import { EditorLog, detectLanguageByFileName } from "./utils"; + +type IStandaloneCodeEditor = monaco.editor.IStandaloneCodeEditor; + +/// File manager +/// +/// This manages the opened files in the editor +export class FileMgr implements CompilerFileAccess { + /// Using a RwLock to ensure the tracked files don't change + /// while being iterated. Generall, write lock is needed + /// for close() and getFile() for unopened files + private fs: RwLock; + + private supportsSave: boolean; + + // /// Some operations need to block other operations, + // /// like saving and loading at the same time is probably bad + // /// + // /// Anything that changes files or currentFile or the monaco editor + // /// should lock the fs + // private fsLock: ReentrantLock; + // /// Opened files + // private files: Record = {}; + + private currentFile: FsFile | undefined; + private monacoDom: HTMLDivElement; + private monacoEditor: IStandaloneCodeEditor; + /// If the editor is open. Can be false even if currentFile is not undefined, if it's not a text file + private isEditorOpen = false; + + /// Yielder for file system operations + private fsYield: Yielder; + private dispatcher: AppDispatcher; + + constructor( + fs: FsFileSystem, + monacoDom: HTMLDivElement, + monacoEditor: IStandaloneCodeEditor, + dispatcher: AppDispatcher, + ) { + this.supportsSave = fs.capabilities.write; + this.fs = new RwLock(fs); + this.dispatcher = dispatcher; + this.monacoDom = monacoDom; + this.monacoEditor = monacoEditor; + this.fsYield = createYielder(64); + // this.fsLock = new ReentrantLock("file mgr"); + } + + // public setFileSys(fs: FsFileSystem): Promise { + // return this.fs.scopedWrite(async (thisFs, setFs) => { + // if (thisFs === fs) { + // return; + // } + // thisFs = setFs(fs); + // this.updateEditor(undefined, undefined, undefined); + // this.dispatcher.dispatch(viewActions.setUnsavedFiles([])); + // }); + // await this.fsLock.lockedScope(undefined, async (token) => { + // }); + // } + + public delete() { + // this.fsLock.lockedScope(undefined, async (token) => { + this.closeEditor(); + this.monacoEditor.dispose(); + // }); + } + + public async resizeEditor() { + // do this async for any UI size changes to finish + await sleep(0); + // Resize to 0,0 to force monaco to shrink if needed + this.monacoEditor.layout({ width: 0, height: 0 }); + this.monacoEditor.layout(); + } + + public listDir(path: string): Promise { + return this.fs.scopedRead((fs) => { + return this.listDirWithFs(fs, path); + }); + } + + private async listDirWithFs(fs: FsFileSystem, path: string): Promise { + const { val: entries, err } = await fs.listDir(path); + if (err) { + const { code, message } = err; + EditorLog.error(`listDir failed with code ${code}: ${message}`); + return []; + } + return entries; + } + + public openFile(path: string,): Promise { + EditorLog.info(`opening ${path}`); + return this.fs.scopedWrite(async (fs) => { + return this.openFileWithFs(fs, path); + }); + } + + private async openFileWithFs(fs: FsFileSystem, path: string): Promise { + const fsFile = fs.getFile(path); + const { val, err } = await fsFile.getText(); + if (err) { + const { code, message } = err; + EditorLog.error(`openFile failed with code ${code}: ${message}`); + this.updateEditor(fsFile, undefined); + return; + } + this.updateEditor(fsFile, val); + } + + public async loadFromFs(): Promise { + EditorLog.info("syncing files from file system to editor..."); + const handle = window.setTimeout(() => { + this.dispatcher.dispatch(viewActions.startFileSysLoad()); + }, 200); + // ensure editor changes is synced first, + // so the current file is marked dirty if needed + this.syncEditorToCurrentFile(); + const success = await this.fs.scopedWrite((fs) => { + return this.loadFromFsWithFs(fs); + }); + // const success = await this.fsLock.lockedScope( + // lockToken, + // async (token) => { + // let success = true; + // const _yield = createYielder(64); + // for (const id in this.files) { + // const fsFile = this.files[id]; + // const result = await this.loadChangesFromFsForFsFile( + // id, + // fsFile, + // token, + // ); + // if (result.isErr()) { + // success = false; + // } + // await _yield(); + // } + // return success; + // }, + // ); + window.clearTimeout(handle); + this.dispatcher.dispatch(viewActions.endFileSysLoad(success)); + EditorLog.info("sync completed"); + // return success ? allocOk() : allocErr(FsResultCodes.Fail); + } + + private async loadFromFsWithFs(fs: FsFileSystem): Promise { + const paths = fs.getOpenedPaths(); + let success = true; + for (let i = 0; i < paths.length; i++) { + if (!(await this.loadFromFsForPath(fs, paths[i]))) { + success = false; + } + + await this.fsYield(); + } + return success; + // const fsFile = this.files[id]; + // const result = await this.loadChangesFromFsForFsFile( + // id, + // fsFile, + // token, + // ); + // if (result.isErr()) { + // success = false; + // } + // await _yield(); + } + + private async loadFromFsForPath(fs: FsFileSystem, path: string): Promise { + const fsFile = fs.getFile(path); + // return await this.fsLock.lockedScope(lockToken, async (token) => { + const isCurrentFile = this.currentFile === fsFile; + + // let content: string | undefined = undefined; + let loadError: FsError | undefined = undefined; + + // load the file + const loadResult = await fsFile.loadIfNotDirty(); + if (loadResult.err) { + loadError = loadResult.err; + } else { + // load is fine, may need to update the content + if (isCurrentFile) { + const content = await fsFile.getText(); + if (content.err) { + loadError = content.err; + } else if (!fsFile.isDirty()) { + // if the file is not dirty, update the editor's content + this.updateEditor(fsFile, content.val); + } + } + } + + if (loadError) { + const { code, message } = loadError; + EditorLog.error(`sync failed with code ${code}: ${message}`); + if (!fsFile.isDirty()) { + // if the file is not dirty, we close the file + // in case it doesn't exist on disk anymore + // and to avoid error on the next save + EditorLog.info(`closing ${path} due to sync error`); + if (isCurrentFile) { + this.closeEditor(); + } + fsFile.close(); + } + } + + return loadError === undefined; + // + // let result = await fsFile.loadIfNotDirty(); + // + // if (result.isOk()) { + // if (isCurrentFile) { + // const contentResult = await fsFile.getText(); + // if (contentResult.isOk()) { + // content = contentResult.inner(); + // } else { + // result = contentResult; + // } + // } + // } + // if (result.isErr()) { + // EditorLog.error(`sync failed with code ${result}`); + // if (!fsFile.isNewerThanFs()) { + // EditorLog.info(`closing ${idPath}`); + // if (isCurrentFile) { + // await this.updateEditor( + // undefined, + // undefined, + // undefined, + // token, + // ); + // } + // delete this.files[idPath]; + // } + // } else { + // if (isCurrentFile) { + // await this.updateEditor(fsFile, idPath, content, token); + // } + // } + // return result; + // }); + } + + public hasUnsavedChanges(): Promise { + this.syncEditorToCurrentFile(); + return this.fs.scopedRead(async (fs) => { + const paths = fs.getOpenedPaths(); + for (let i = 0; i < paths.length; i++) { + const fsFile = fs.getFile(paths[i]); + if (fsFile.isDirty()) { + return true; + } + await this.fsYield(); + } + return false; + }); + // return await this.fsLock.lockedScope(lockToken, async (token) => { + // await this.syncEditorToCurrentFile(token); + // const yielder = createYielder(64); + // for (const id in this.files) { + // const fsFile = this.files[id]; + // if (fsFile.isNewerThanFs()) { + // return true; + // } + // await yielder(); + // } + // return false; + // }); + } + + public hasUnsavedChangesSync(): boolean { + this.syncEditorToCurrentFile(); + const fs = this.fs.inner; + const paths = fs.getOpenedPaths(); + for (let i = 0; i < paths.length; i++) { + const fsFile = fs.getFile(paths[i]); + if (fsFile.isDirty()) { + return true; + } + } + return false; + } + + public async saveToFs(): Promise { + if (!this.supportsSave) { + EditorLog.error("save not supported!"); + EditorLog.warn("saveToFs should only be called if save is supported"); + return false; + } + + EditorLog.info("saving changes..."); + const handle = window.setTimeout(() => { + this.dispatcher.dispatch(viewActions.startFileSysSave()); + }, 200); + // ensure editor changes is synced first, + // so the current file is marked dirty + this.syncEditorToCurrentFile(); + + const success = await this.fs.scopedWrite((fs) => { + return this.saveToFsWithFs(fs); + // + // let success = true; + // const _yield = createYielder(64); + // for (const id in this.files) { + // const fsFile = this.files[id]; + // const result = await this.saveChangesToFsForFsFile( + // id, + // fsFile, + // token, + // ); + // if (result.isErr()) { + // success = false; + // } + // await _yield(); + // } + // return success; + }); + + window.clearTimeout(handle); + this.dispatcher.dispatch(viewActions.endFileSysSave(success)); + EditorLog.info("save completed"); + return success; + } + + private async saveToFsWithFs(fs: FsFileSystem): Promise { + let success = true; + const paths = fs.getOpenedPaths(); + for (let i = 0; i < paths.length; i++) { + if (!(await this.saveToFsForPath(fs, paths[i]))) { + success = false; + } + await this.fsYield(); + } + return success; + } + + private async saveToFsForPath(fs: FsFileSystem, path: string): Promise { + // return await this.fsLock.lockedScope(lockToken, async () => { + const { err } = await fs.getFile(path).writeIfNewer(); + if (err) { + const { code, message } = err; + EditorLog.error(`save failed with code ${code}: ${message}`); + return false; + } + return true; + // + // const result = await fsFile.writeIfNewer(); + // if (result.isErr()) { + // } + // return result; + // }); + } + + // private async updateEditorLegacy( + // file: FsFile | undefined, + // path: string | undefined, + // content: string | undefined, + // lockToken?: number, + // ) { + // await this.fsLock.lockedScope(lockToken, async (token) => { + // // in case we are switching files, sync the current file first + // if (this.currentFile !== file) { + // await this.syncEditorToCurrentFile(token); + // this.currentFile = file; + // } + // const success = content !== undefined; + // this.dispatcher.dispatch( + // viewActions.updateOpenedFile({ + // openedFile: path, + // currentFileSupported: success, + // }), + // ); + // + // if (success && path !== undefined) { + // // this check is necessary because + // // some browsers rerenders the editor even if the content is the same (Firefox) + // // which causes annoying flickering + // if (this.monacoEditor.getValue() !== content) { + // this.monacoEditor.setValue(content); + // } + // + // // TODO #20: language feature support + // this.switchLanguage(detectLanguageByFileName(path)); + // + // await this.attachEditor(); + // this.isEditorOpen = true; + // } else { + // this.monacoDom.remove(); + // this.isEditorOpen = false; + // } + // }); + // } + // + private closeEditor() { + if (this.currentFile) { + this.syncEditorToCurrentFile(); + // note: don't close the file in memory, + // as it may have unsaved content. + this.currentFile = undefined; + } + this.dispatcher.dispatch( + viewActions.updateOpenedFile({ + openedFile: undefined, + currentFileSupported: false, + }), + ); + this.detachEditor(); + } + + private updateEditor(file: FsFile, content: string | undefined) { + // in case we are switching files, sync the current file first + if (this.currentFile !== file) { + this.syncEditorToCurrentFile(); + this.currentFile = file; + } + const currentFileSupported = content !== undefined; + this.dispatcher.dispatch( + viewActions.updateOpenedFile({ + openedFile: file.path, + currentFileSupported, + }), + ); + + if (!currentFileSupported) { + return; + } + + // this check is necessary because + // some browsers rerenders the editor even if the content is the same (Firefox) + // which causes annoying flickering + if (this.monacoEditor.getValue() !== content) { + this.monacoEditor.setValue(content); + } + + // TODO #20: language feature support + this.switchLanguage(detectLanguageByFileName(file.path)); + + setTimeout(() => this.attachEditor(), 0); + } + + private switchLanguage(languageId: string) { + const model = this.monacoEditor.getModel(); + if (!model) { + return; + } + if (model.getLanguageId() !== languageId) { + monaco.editor.setModelLanguage(model, languageId); + } + } + + // public async syncEditorToCurrentFileLegacy(lockToken?: number) { + // await this.fsLock.lockedScope(lockToken, async () => { + // if (this.currentFile && this.isEditorOpen) { + // this.currentFile.setContent(this.monacoEditor.getValue()); + // } + // }); + // } + + /// Sync the text from editor to the in memory file storage + public syncEditorToCurrentFile(): void { + if (this.currentFile && this.isEditorOpen) { + this.currentFile.setText(this.monacoEditor.getValue()); + } + } + + public async updateDirtyFileList(currentList: string[]) { + const unsavedFiles = await this.fs.scopedRead(async (fs) => { + const unsavedFiles = new Set(); + const paths = fs.getOpenedPaths(); + for (let i = 0; i < paths.length; i++) { + const fsFile = fs.getFile(paths[i]); + if (fsFile.isDirty()) { + unsavedFiles.add(paths[i]); + } + await this.fsYield(); + } + return unsavedFiles; + }); + + // don't update if the list is the same + // to prevent unnecessary rerenders + if (unsavedFiles.size === currentList.length) { + // new API in the future: + // const needsUpdate = currentSet.symmetricDifference(unsavedFiles).size; + let needsUpdate = false; + for (let i = 0; i < currentList.length; i++) { + if (!unsavedFiles.has(currentList[i])) { + needsUpdate = true; + break; + } + } + if (!needsUpdate) { + const currentSet = new Set(currentList); + for (const path of unsavedFiles) { + if (!currentSet.has(path)) { + needsUpdate = true; + break; + } + } + } + if (!needsUpdate) { + return; + } + } + const newList = Array.from(unsavedFiles); + this.dispatcher.dispatch(viewActions.setUnsavedFiles(newList)); + } + + private modifiedTimeWhenLastAccessed: { [path: string]: number } = {}; + public getFileContent( + path: string, + checkChanged: boolean, + ): Promise> { + return this.fs.scopedWrite((fs) => { + return this.getFileContentWithFs(fs, path, checkChanged); + }); + } + + private async getFileContentWithFs(fs: FsFileSystem, path: string, checkChanged: boolean): Promise> { + const fsFile = fs.getFile(path); + if (checkChanged) { + const modifiedTimeCurrent = await fsFile.getLastModified(); + if (modifiedTimeCurrent.err) { + return modifiedTimeCurrent; + } + const modifiedTimeLast = this.modifiedTimeWhenLastAccessed[path]; + this.modifiedTimeWhenLastAccessed[path] = modifiedTimeCurrent.val; + if ( + modifiedTimeLast && + modifiedTimeLast >= modifiedTimeCurrent.val + ) { + // 1. file was accessed before + // 2. file was not modified since last access + return { err: fsErr(FsErr.NotModified, "Not modified") }; + } + } + return await fsFile.getBytes(); + } + + private async attachEditor() { + let div = EditorContainerDOM.get(); + while (!div) { + EditorLog.warn("editor container not found. Will try again."); + await sleep(100); + div = EditorContainerDOM.get(); + } + let alreadyAttached = false; + div.childNodes.forEach((node) => { + if (node === this.monacoDom) { + alreadyAttached = true; + } else { + node.remove(); + } + }); + if (!alreadyAttached) { + div.appendChild(this.monacoDom); + await this.resizeEditor(); + EditorLog.info("editor attached"); + } + this.isEditorOpen = true; + } + + private detachEditor() { + this.monacoDom.remove(); + this.isEditorOpen = false; + } +} diff --git a/web-client/src/core/editor/dom.ts b/web-client/src/core/editor/dom.ts new file mode 100644 index 00000000..180ca46c --- /dev/null +++ b/web-client/src/core/editor/dom.ts @@ -0,0 +1,3 @@ +import { DOMId } from "low/utils"; + +export const EditorContainerDOM = new DOMId("editor-container"); diff --git a/web-client/src/core/editor/index.ts b/web-client/src/core/editor/index.ts index c5c1c3d7..53628eac 100644 --- a/web-client/src/core/editor/index.ts +++ b/web-client/src/core/editor/index.ts @@ -1,10 +1,9 @@ //! core/editor -//! Editor related state +//! Web Editor module -import { DOMId } from "low/utils"; +import { EditorLog } from "./utils"; -export * from "./state"; -export * as editorViewReducers from "./viewReducers"; -export * as editorSettingsReducers from "./settingsReducers"; +EditorLog.info("loading editor module"); -export const EditorContainerDOM = new DOMId("editor-container"); +export * from "./EditorKernelAccess"; +export * from "./EditorKernel"; diff --git a/web-client/src/core/kernel/editor/utils.ts b/web-client/src/core/editor/utils.ts similarity index 61% rename from web-client/src/core/kernel/editor/utils.ts rename to web-client/src/core/editor/utils.ts index 28968d4c..df07cac4 100644 --- a/web-client/src/core/kernel/editor/utils.ts +++ b/web-client/src/core/editor/utils.ts @@ -1,15 +1,14 @@ -import { FsPath, fsRootPath } from "low/fs"; import { Logger } from "low/utils"; export const EditorLog = new Logger("edt"); -export const toFsPath = (path: string[]): FsPath => { - let fsPath = fsRootPath; - for (let i = 0; i < path.length; i++) { - fsPath = fsPath.resolve(path[i]); - } - return fsPath; -}; +// export const toFsPath = (path: string[]): FsPath => { +// let fsPath = fsRootPath; +// for (let i = 0; i < path.length; i++) { +// fsPath = fsPath.resolve(path[i]); +// } +// return fsPath; +// }; export const detectLanguageByFileName = (fileName: string): string => { if (fileName.match(/\.(j|t)s$/i)) { diff --git a/web-client/src/core/kernel/Kernel.ts b/web-client/src/core/kernel/Kernel.ts index 9bf4bee8..c3c6cf28 100644 --- a/web-client/src/core/kernel/Kernel.ts +++ b/web-client/src/core/kernel/Kernel.ts @@ -1,5 +1,7 @@ import reduxWatch from "redux-watch"; +import { FsFileSystem } from "pure/fs"; + import { AppState, AppStore, @@ -19,12 +21,11 @@ import { isRecompileNeeded, loadDocumentFromCurrentUrl, } from "core/doc"; +import type { CompilerKernel } from "core/compiler"; +import type { EditorKernel, EditorKernelAccess } from "core/editor"; import { ExpoDoc, ExportRequest } from "low/celerc"; import { console, Logger, isInDarkMode, sleep, AlertMgr } from "low/utils"; -import type { FileSys, FsResult, FsStableResult } from "low/fs"; -import type { CompilerKernel } from "./compiler"; -import type { EditorKernel, KernelAccess } from "./editor"; import { KeyMgr } from "./KeyMgr"; import { WindowMgr } from "./WindowMgr"; import { AlertMgrImpl } from "./AlertMgr"; @@ -40,7 +41,7 @@ type InitUiFunction = ( /// The kernel owns all global resources like the redux store. /// It is also responsible for mounting react to the DOM and /// handles the routing. -export class Kernel implements KernelAccess { +export class Kernel implements EditorKernelAccess { /// The logger private log: Logger; /// The store @@ -121,9 +122,9 @@ export class Kernel implements KernelAccess { const path = window.location.pathname; if (path === "/edit") { document.title = "Celer Editor"; - const { initCompiler } = await import("./compiler"); - const compiler = initCompiler(this.store); - this.compiler = compiler; + // const { initCompiler } = await import("core/compiler"); + // const compiler = initCompiler(this.store); + // this.compiler = compiler; this.store.dispatch(viewActions.setStageMode("edit")); } else { @@ -178,7 +179,7 @@ export class Kernel implements KernelAccess { throw new Error("compiler is not available in view mode"); } if (!this.compiler) { - const { initCompiler } = await import("./compiler"); + const { initCompiler } = await import("core/compiler"); const compiler = initCompiler(this.store); this.compiler = compiler; } @@ -322,14 +323,8 @@ export class Kernel implements KernelAccess { } // put this in editor kernel - private updateRootPathInStore(fileSys: FileSys | undefined) { - if (fileSys) { - this.store.dispatch( - viewActions.updateFileSys(fileSys.getRootName()), - ); - } else { - this.store.dispatch(viewActions.updateFileSys(undefined)); - } + private updateRootPathInStore(fs: FsFileSystem | undefined) { + this.store.dispatch(viewActions.updateFileSys(fs?.root ?? undefined)); } public async export(request: ExportRequest): Promise { diff --git a/web-client/src/core/kernel/editor/FileMgr.ts b/web-client/src/core/kernel/editor/FileMgr.ts deleted file mode 100644 index 734223e2..00000000 --- a/web-client/src/core/kernel/editor/FileMgr.ts +++ /dev/null @@ -1,429 +0,0 @@ -import * as monaco from "monaco-editor"; - -import { AppDispatcher, viewActions } from "core/store"; -import { EditorContainerDOM } from "core/editor"; -import { - FileAccess, - FileSys, - FsFile, - FsPath, - FsResult, - FsResultCodes, -} from "low/fs"; -import { - ReentrantLock, - allocErr, - allocOk, - createYielder, - sleep, -} from "low/utils"; - -import { EditorLog, detectLanguageByFileName, toFsPath } from "./utils"; - -type IStandaloneCodeEditor = monaco.editor.IStandaloneCodeEditor; - -/// File manager -/// -/// This manages the opened files in the editor -export class FileMgr implements FileAccess { - private fs: FileSys; - - /// Some operations need to block other operations, - /// like saving and loading at the same time is probably bad - /// - /// Anything that changes files or currentFile or the monaco editor - /// should lock the fs - private fsLock: ReentrantLock; - /// Opened files - private files: Record = {}; - - private currentFile: FsFile | undefined; - private monacoDom: HTMLDivElement; - private monacoEditor: IStandaloneCodeEditor; - /// If the editor is open. Can be false even if currentFile is not undefined, if it's not a text file - private isEditorOpen = false; - - private dispatcher: AppDispatcher; - - constructor( - fileSys: FileSys, - monacoDom: HTMLDivElement, - monacoEditor: IStandaloneCodeEditor, - dispatcher: AppDispatcher, - ) { - this.fs = fileSys; - this.dispatcher = dispatcher; - this.monacoDom = monacoDom; - this.monacoEditor = monacoEditor; - this.fsLock = new ReentrantLock("file mgr"); - } - - public async setFileSys(fs: FileSys) { - await this.fsLock.lockedScope(undefined, async (token) => { - if (this.fs === fs) { - return; - } - this.fs = fs; - this.files = {}; - await this.updateEditor(undefined, undefined, undefined, token); - this.dispatcher.dispatch(viewActions.setUnsavedFiles([])); - }); - } - - public delete() { - this.fsLock.lockedScope(undefined, async (token) => { - this.updateEditor(undefined, undefined, undefined, token); - this.monacoEditor.dispose(); - }); - } - - public resizeEditor() { - // do this async for any UI size changes to finish - setTimeout(() => { - // Resize to 0,0 to force monaco to shrink if needed - this.monacoEditor.layout({ width: 0, height: 0 }); - this.monacoEditor.layout(); - }, 0); - } - - public async listDir( - path: string[], - lockToken?: number, - ): Promise { - return await this.fsLock.lockedScope(lockToken, async () => { - if (!this.fs) { - return []; - } - const fsPath = toFsPath(path); - const result = await this.fs.listDir(fsPath); - if (result.isErr()) { - EditorLog.error(`listDir failed with code ${result.inner()}`); - return []; - } - return result.inner(); - }); - } - - public async openFile( - path: FsPath, - lockToken?: number, - ): Promise> { - const idPath = path.path; - EditorLog.info(`opening ${idPath}`); - return await this.fsLock.lockedScope(lockToken, async (token) => { - let fsFile = this.files[idPath]; - if (!fsFile) { - fsFile = new FsFile(this.fs, path); - this.files[idPath] = fsFile; - } - const result = await fsFile.getText(); - if (result.isErr()) { - EditorLog.error(`openFile failed with code ${result.inner()}`); - await this.updateEditor(fsFile, idPath, undefined, token); - } else { - await this.updateEditor(fsFile, idPath, result.inner(), token); - } - return result.makeOk(undefined); - }); - } - - public async loadChangesFromFs( - lockToken?: number, - ): Promise> { - EditorLog.info("loading changes from file system..."); - const handle = window.setTimeout(() => { - this.dispatcher.dispatch(viewActions.startFileSysLoad()); - }, 200); - const success = await this.fsLock.lockedScope( - lockToken, - async (token) => { - // ensure editor changes is synced first, - // so the current file is marked dirty - await this.syncEditorToCurrentFile(token); - let success = true; - const _yield = createYielder(64); - for (const id in this.files) { - const fsFile = this.files[id]; - const result = await this.loadChangesFromFsForFsFile( - id, - fsFile, - token, - ); - if (result.isErr()) { - success = false; - } - await _yield(); - } - return success; - }, - ); - window.clearTimeout(handle); - this.dispatcher.dispatch(viewActions.endFileSysLoad(success)); - EditorLog.info("changes loaded from file system"); - return success ? allocOk() : allocErr(FsResultCodes.Fail); - } - - private async loadChangesFromFsForFsFile( - idPath: string, - fsFile: FsFile, - lockToken?: number, - ): Promise> { - return await this.fsLock.lockedScope(lockToken, async (token) => { - const isCurrentFile = this.currentFile === fsFile; - let content: string | undefined = undefined; - - let result = await fsFile.loadIfNotDirty(); - - if (result.isOk()) { - if (isCurrentFile) { - const contentResult = await fsFile.getText(); - if (contentResult.isOk()) { - content = contentResult.inner(); - } else { - result = contentResult; - } - } - } - if (result.isErr()) { - EditorLog.error(`sync failed with code ${result}`); - if (!fsFile.isNewerThanFs()) { - EditorLog.info(`closing ${idPath}`); - if (isCurrentFile) { - await this.updateEditor( - undefined, - undefined, - undefined, - token, - ); - } - delete this.files[idPath]; - } - } else { - if (isCurrentFile) { - await this.updateEditor(fsFile, idPath, content, token); - } - } - return result; - }); - } - - public async hasUnsavedChanges(lockToken?: number): Promise { - return await this.fsLock.lockedScope(lockToken, async (token) => { - await this.syncEditorToCurrentFile(token); - const yielder = createYielder(64); - for (const id in this.files) { - const fsFile = this.files[id]; - if (fsFile.isNewerThanFs()) { - return true; - } - await yielder(); - } - return false; - }); - } - - public hasUnsavedChangesSync(): boolean { - if (this.currentFile) { - this.currentFile.setContent(this.monacoEditor.getValue()); - } - for (const id in this.files) { - const fsFile = this.files[id]; - if (fsFile.isNewerThanFs()) { - return true; - } - } - return false; - } - - public async saveChangesToFs(lockToken?: number): Promise> { - if (!this.fs.isWritable()) { - return allocErr(FsResultCodes.NotSupported); - } - EditorLog.info("saving changes to file system..."); - const handle = window.setTimeout(() => { - this.dispatcher.dispatch(viewActions.startFileSysSave()); - }, 200); - const success = await this.fsLock.lockedScope( - lockToken, - async (token) => { - // ensure editor changes is synced first, - // so the current file is marked dirty - await this.syncEditorToCurrentFile(token); - let success = true; - const _yield = createYielder(64); - for (const id in this.files) { - const fsFile = this.files[id]; - const result = await this.saveChangesToFsForFsFile( - id, - fsFile, - token, - ); - if (result.isErr()) { - success = false; - } - await _yield(); - } - return success; - }, - ); - window.clearTimeout(handle); - this.dispatcher.dispatch(viewActions.endFileSysSave(success)); - EditorLog.info("changes saved to file system"); - return success ? allocOk() : allocErr(FsResultCodes.Fail); - } - - private async saveChangesToFsForFsFile( - idPath: string, - fsFile: FsFile, - lockToken?: number, - ): Promise> { - return await this.fsLock.lockedScope(lockToken, async () => { - const result = await fsFile.writeIfNewer(); - if (result.isErr()) { - EditorLog.error( - `save ${idPath} failed with code ${result.inner()}`, - ); - } - return result; - }); - } - - private async updateEditor( - file: FsFile | undefined, - path: string | undefined, - content: string | undefined, - lockToken?: number, - ) { - await this.fsLock.lockedScope(lockToken, async (token) => { - // in case we are switching files, sync the current file first - if (this.currentFile !== file) { - await this.syncEditorToCurrentFile(token); - this.currentFile = file; - } - const success = content !== undefined; - this.dispatcher.dispatch( - viewActions.updateOpenedFile({ - openedFile: path, - currentFileSupported: success, - }), - ); - - if (success && path !== undefined) { - // this check is necessary because - // some browsers rerenders the editor even if the content is the same (Firefox) - // which causes annoying flickering - if (this.monacoEditor.getValue() !== content) { - this.monacoEditor.setValue(content); - } - - // TODO #20: language feature support - this.switchLanguage(detectLanguageByFileName(path)); - - await this.attachEditor(); - this.isEditorOpen = true; - } else { - this.monacoDom.remove(); - this.isEditorOpen = false; - } - }); - } - - private switchLanguage(languageId: string) { - const model = this.monacoEditor.getModel(); - if (!model) { - return; - } - if (model.getLanguageId() !== languageId) { - monaco.editor.setModelLanguage(model, languageId); - } - } - - public async syncEditorToCurrentFile(lockToken?: number) { - await this.fsLock.lockedScope(lockToken, async () => { - if (this.currentFile && this.isEditorOpen) { - this.currentFile.setContent(this.monacoEditor.getValue()); - } - }); - } - - public updateDirtyFileList(currentList: string[]) { - const unsavedFiles: string[] = []; - const ids = Object.keys(this.files); - ids.sort(); - ids.forEach((id) => { - if (this.files[id].isNewerThanFs()) { - unsavedFiles.push(id); - } - }); - // don't update if the list is the same - // to prevent unnecessary rerenders - if (unsavedFiles.length === currentList.length) { - let needsUpdate = false; - for (let i = 0; i < unsavedFiles.length; i++) { - if (unsavedFiles[i] !== currentList[i]) { - needsUpdate = true; - break; - } - } - if (!needsUpdate) { - return; - } - } - this.dispatcher.dispatch(viewActions.setUnsavedFiles(unsavedFiles)); - } - - private modifiedTimeWhenLastAccessed: { [path: string]: number } = {}; - public async getFileContent( - path: string, - checkChanged: boolean, - ): Promise> { - return await this.fsLock.lockedScope(undefined, async () => { - if (!this.fs) { - return allocErr(FsResultCodes.Fail); - } - let fsFile = this.files[path]; - if (!fsFile) { - const fsPath = toFsPath(path.split("/")); - fsFile = new FsFile(this.fs, fsPath); - this.files[fsPath.path] = fsFile; - } - if (checkChanged) { - const modifiedTimeLast = - this.modifiedTimeWhenLastAccessed[path]; - const modifiedTimeCurrent = await fsFile.getLastModified(); - this.modifiedTimeWhenLastAccessed[path] = modifiedTimeCurrent; - if ( - modifiedTimeLast && - modifiedTimeLast >= modifiedTimeCurrent - ) { - // 1. file was accessed before - // 2. file was not modified since last access - return allocErr(FsResultCodes.NotModified); - } - } - return await fsFile.getBytes(); - }); - } - - private async attachEditor() { - let div = EditorContainerDOM.get(); - while (!div) { - EditorLog.warn("editor container not found. Will try again."); - await sleep(100); - div = EditorContainerDOM.get(); - } - let alreadyAttached = false; - div.childNodes.forEach((node) => { - if (node === this.monacoDom) { - alreadyAttached = true; - } else { - node.remove(); - } - }); - if (!alreadyAttached) { - div.appendChild(this.monacoDom); - this.resizeEditor(); - EditorLog.info("editor attached"); - } - } -} diff --git a/web-client/src/core/kernel/editor/WebEditorKernel.ts b/web-client/src/core/kernel/editor/WebEditorKernel.ts index 0071930d..357b631f 100644 --- a/web-client/src/core/kernel/editor/WebEditorKernel.ts +++ b/web-client/src/core/kernel/editor/WebEditorKernel.ts @@ -211,7 +211,7 @@ class WebEditorKernel implements EditorKernel { const { unsavedFiles } = viewSelector(this.store.getState()); // pull changes from monaco editor first to make sure current file is marked dirty if needed - await this.fileMgr.syncEditorToCurrentFile(); + this.fileMgr.syncEditorToCurrentFile(); if (isLong) { const { autoSaveEnabled } = settingsSelector(this.store.getState()); diff --git a/web-client/src/core/kernel/editor/index.ts b/web-client/src/core/kernel/editor/index.ts index 0f9a4cba..6213f3e7 100644 --- a/web-client/src/core/kernel/editor/index.ts +++ b/web-client/src/core/kernel/editor/index.ts @@ -1,6 +1,4 @@ import { EditorLog } from "./utils"; EditorLog.info("loading editor module"); -export * from "./EditorKernel"; -export * from "./KernelAccess"; export * from "./initEditor"; diff --git a/web-client/src/core/store/editor/index.ts b/web-client/src/core/store/editor/index.ts new file mode 100644 index 00000000..3ad4a2d8 --- /dev/null +++ b/web-client/src/core/store/editor/index.ts @@ -0,0 +1,3 @@ +export * from "./state"; +export * as editorViewReducers from "./viewReducers"; +export * as editorSettingsReducers from "./settingsReducers"; diff --git a/web-client/src/core/editor/settingsReducers.ts b/web-client/src/core/store/editor/settingsReducers.ts similarity index 100% rename from web-client/src/core/editor/settingsReducers.ts rename to web-client/src/core/store/editor/settingsReducers.ts diff --git a/web-client/src/core/editor/state.ts b/web-client/src/core/store/editor/state.ts similarity index 100% rename from web-client/src/core/editor/state.ts rename to web-client/src/core/store/editor/state.ts diff --git a/web-client/src/core/editor/viewReducers.ts b/web-client/src/core/store/editor/viewReducers.ts similarity index 100% rename from web-client/src/core/editor/viewReducers.ts rename to web-client/src/core/store/editor/viewReducers.ts diff --git a/web-client/src/core/store/index.ts b/web-client/src/core/store/index.ts index 082f73de..f2e46f4d 100644 --- a/web-client/src/core/store/index.ts +++ b/web-client/src/core/store/index.ts @@ -11,6 +11,8 @@ //! - The (combined) reducer for setting up the redux state //! - The actions for updating the state //! - The selector for picking out the state from the redux state + +export * from "./editor"; export * from "./document"; export * from "./init"; export * from "./settings"; diff --git a/web-client/src/core/store/settings.ts b/web-client/src/core/store/settings.ts index 49cdf698..ab1ab3ba 100644 --- a/web-client/src/core/store/settings.ts +++ b/web-client/src/core/store/settings.ts @@ -18,12 +18,13 @@ import { initialMapSettingsState, mapSettingsReducers, } from "core/map"; +import { configureSlice } from "low/store"; + import { EditorSettingsState, editorSettingsReducers, initialEditorSettingsState, -} from "core/editor"; -import { configureSlice } from "low/store"; +} from "./editor"; /// Local storage key const LOCAL_STORAGE_KEY = "Celer.Settings"; diff --git a/web-client/src/core/store/view.ts b/web-client/src/core/store/view.ts index 668b5060..00be475c 100644 --- a/web-client/src/core/store/view.ts +++ b/web-client/src/core/store/view.ts @@ -15,12 +15,14 @@ import { layoutViewReducers, } from "core/layout"; import { MapViewState, initialMapViewState, mapViewReducers } from "core/map"; + +import { configureSlice } from "low/store"; + import { EditorViewState, initialEditorViewState, editorViewReducers, -} from "core/editor"; -import { configureSlice } from "low/store"; +} from "./editor"; export type ViewState = LayoutViewState & MapViewState & diff --git a/web-client/src/low/fs/index.ts b/web-client/src/low/fs/index.ts index c5e7fe40..69f75f01 100644 --- a/web-client/src/low/fs/index.ts +++ b/web-client/src/low/fs/index.ts @@ -3,12 +3,12 @@ //! File System access // We log a message here to ensure that fs is only loaded when editor is used -import { console } from "low/utils"; -console.info("loading file system module"); - -export * from "./FileAccess"; -export * from "./FileSys"; -export * from "./FsResult"; -export * from "./FsFile"; -export * from "./FsPath"; -export * from "./open"; +// import { console } from "low/utils"; +// console.info("loading file system module"); +// +// export * from "./FileAccess"; +// export * from "./FileSys"; +// export * from "./FsResult"; +// export * from "./FsFile"; +// export * from "./FsPath"; +// export * from "./open"; diff --git a/web-client/src/low/utils/error.ts b/web-client/src/low/utils/error.ts deleted file mode 100644 index d7f16011..00000000 --- a/web-client/src/low/utils/error.ts +++ /dev/null @@ -1,2 +0,0 @@ -//! Error utilities - diff --git a/web-client/src/low/utils/index.ts b/web-client/src/low/utils/index.ts index b2f46282..150d8182 100644 --- a/web-client/src/low/utils/index.ts +++ b/web-client/src/low/utils/index.ts @@ -5,7 +5,6 @@ export * from "./Alert"; export * from "./IdleMgr"; export * from "./Debouncer"; -export * from "./error"; export * from "./html"; export * from "./Logger"; export * from "./FileSaver"; From e2ff3789ac0e4b40a1e5c0b145410adecf3ef177 Mon Sep 17 00:00:00 2001 From: Pistonight Date: Sun, 25 Feb 2024 13:25:22 -0800 Subject: [PATCH 05/10] editor and compiler refactor --- libs/package-lock.json | 17 +- libs/package.json | 6 +- libs/pure/README.md | 6 +- libs/pure/src/fs/index.ts | 1 + libs/pure/src/fs/save.ts | 10 + libs/pure/src/log/README.md | 1 + .../Logger.ts => libs/pure/src/log/index.ts | 37 +- web-client/src/core/editor/EditorKernel.ts | 4 +- .../src/core/editor/ExternalEditorKernel.ts | 134 ++++++ web-client/src/core/editor/FileMgr.ts | 68 +-- .../src/core/editor/ModifyTimeTracker.ts | 35 ++ .../{kernel => }/editor/WebEditorKernel.ts | 89 ++-- web-client/src/core/editor/index.ts | 7 +- .../core/{kernel => }/editor/initEditor.ts | 13 +- web-client/src/core/editor/openHandler.ts | 57 +++ web-client/src/core/editor/utils.ts | 24 - web-client/src/core/kernel/AlertMgr.ts | 24 +- web-client/src/core/kernel/Kernel.ts | 136 ++---- .../kernel/editor/ExternalEditorKernel.ts | 168 ------- web-client/src/core/kernel/editor/index.ts | 4 - web-client/src/low/fs/FileApiFileSys.ts | 134 +++--- .../src/low/fs/FileEntriesApiFileSys.ts | 312 ++++++------- web-client/src/low/fs/FileSys.ts | 106 ++--- .../src/low/fs/FileSystemAccessApiFileSys.ts | 414 +++++++++--------- web-client/src/low/fs/FsFile.ts | 380 ++++++++-------- web-client/src/low/fs/FsPath.ts | 238 +++++----- web-client/src/low/fs/FsResult.ts | 56 +-- web-client/src/low/fs/open.ts | 292 ++++++------ web-client/src/low/utils/Alert.ts | 3 +- .../src/low/utils/FileSaver/FileSaver.js | 225 ---------- web-client/src/low/utils/FileSaver/index.ts | 13 - web-client/src/low/utils/IdleMgr.ts | 31 +- web-client/src/low/utils/ReentrantLock.ts | 6 +- web-client/src/low/utils/WorkerHost.ts | 4 +- web-client/src/low/utils/index.ts | 4 +- web-client/src/low/utils/logging.ts | 29 ++ web-client/src/ui/editor/EditorDropZone.tsx | 21 +- .../src/ui/toolbar/OpenCloseProject.tsx | 18 +- web-client/tools/lint/non-logger-console.cjs | 2 +- 39 files changed, 1461 insertions(+), 1668 deletions(-) create mode 100644 libs/pure/src/fs/save.ts create mode 100644 libs/pure/src/log/README.md rename web-client/src/low/utils/Logger.ts => libs/pure/src/log/index.ts (52%) create mode 100644 web-client/src/core/editor/ExternalEditorKernel.ts create mode 100644 web-client/src/core/editor/ModifyTimeTracker.ts rename web-client/src/core/{kernel => }/editor/WebEditorKernel.ts (71%) rename web-client/src/core/{kernel => }/editor/initEditor.ts (75%) create mode 100644 web-client/src/core/editor/openHandler.ts delete mode 100644 web-client/src/core/editor/utils.ts delete mode 100644 web-client/src/core/kernel/editor/ExternalEditorKernel.ts delete mode 100644 web-client/src/core/kernel/editor/index.ts delete mode 100644 web-client/src/low/utils/FileSaver/FileSaver.js delete mode 100644 web-client/src/low/utils/FileSaver/index.ts create mode 100644 web-client/src/low/utils/logging.ts diff --git a/libs/package-lock.json b/libs/package-lock.json index 0d855045..ecb1ee2f 100644 --- a/libs/package-lock.json +++ b/libs/package-lock.json @@ -8,9 +8,19 @@ "name": "libs", "version": "0.0.0", "dependencies": { - "denque": "^2.1.0" + "denque": "^2.1.0", + "file-saver": "2.0.5" + }, + "devDependencies": { + "@types/file-saver": "^2.0.7" } }, + "node_modules/@types/file-saver": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/file-saver/-/file-saver-2.0.7.tgz", + "integrity": "sha512-dNKVfHd/jk0SkR/exKGj2ggkB45MAkzvWCaqLUUgkyjITkGNzH8H+yUwr+BLJUBjZOe9w8X3wgmXhZDRg1ED6A==", + "dev": true + }, "node_modules/denque": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", @@ -18,6 +28,11 @@ "engines": { "node": ">=0.10" } + }, + "node_modules/file-saver": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/file-saver/-/file-saver-2.0.5.tgz", + "integrity": "sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA==" } } } diff --git a/libs/package.json b/libs/package.json index d6570dee..8e23df8e 100644 --- a/libs/package.json +++ b/libs/package.json @@ -4,6 +4,10 @@ "version": "0.0.0", "type": "module", "dependencies": { - "denque": "^2.1.0" + "denque": "^2.1.0", + "file-saver": "2.0.5" + }, + "devDependencies": { + "@types/file-saver": "^2.0.7" } } diff --git a/libs/pure/README.md b/libs/pure/README.md index db3cb6aa..f4c02ce2 100644 --- a/libs/pure/README.md +++ b/libs/pure/README.md @@ -8,4 +8,8 @@ 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. +## Libraries +- `fs`: Browser File System Integration +- `log`: Logging library with log export ability +- `result`: Rust-list result handling library +- `utils`: I don't know where to put the stuff diff --git a/libs/pure/src/fs/index.ts b/libs/pure/src/fs/index.ts index 9997c4b6..6029a2ac 100644 --- a/libs/pure/src/fs/index.ts +++ b/libs/pure/src/fs/index.ts @@ -4,3 +4,4 @@ export * from "./open"; export * from "./FsFileSystem"; export * from "./FsFile"; export * from "./error"; +export * from "./save"; diff --git a/libs/pure/src/fs/save.ts b/libs/pure/src/fs/save.ts new file mode 100644 index 00000000..548542e4 --- /dev/null +++ b/libs/pure/src/fs/save.ts @@ -0,0 +1,10 @@ +import { saveAs } from "file-saver"; + +/// Save (download) a file using Blob +export function fsSave(content: string | Uint8Array, filename: string) { + const blob = new Blob([content], { + // maybe lying, but should be fine + type: "text/plain;charset=utf-8", + }); + saveAs(blob, filename); +} diff --git a/libs/pure/src/log/README.md b/libs/pure/src/log/README.md new file mode 100644 index 00000000..0adb19d1 --- /dev/null +++ b/libs/pure/src/log/README.md @@ -0,0 +1 @@ +# pure/log diff --git a/web-client/src/low/utils/Logger.ts b/libs/pure/src/log/index.ts similarity index 52% rename from web-client/src/low/utils/Logger.ts rename to libs/pure/src/log/index.ts index 220c5be0..89280720 100644 --- a/web-client/src/low/utils/Logger.ts +++ b/libs/pure/src/log/index.ts @@ -1,45 +1,25 @@ //! Client side log util import Denque from "denque"; -import { saveAs } from "./FileSaver"; +import { errstr } from "pure/utils"; + +const LIMIT = 500; + /// Global log queue const LogQueue = new Denque(); -const pushLog = (msg: string) => { - if (LogQueue.length > 500) { +function pushLog(msg: string) { + if (LogQueue.length > LIMIT) { LogQueue.shift(); } LogQueue.push(`[${new Date().toISOString()}]${msg}`); }; /// Get the current log -export const getLog = () => { +export function getLogLines() { return LogQueue.toArray(); }; -/// Save the current log to a file -export const saveLog = () => { - const result = - confirm(`You are about to download the client-side application log to a file. - -Celer does not automatically collect any user data. However, the client-side log may contain sensitive information such as the name of the files loaded in the application. - -Please make sure sensitive information are removed before sharing it with developers or others for diagonistics. - -Do you want to continue? - -`); - if (!result) { - return; - } - const log = getLog().join("\n"); - const filename = `celer_web-client_${new Date().toISOString()}.log`; - saveAs(log, filename); -}; - /// A general-purpose client side logger -/// -/// We are not collecting telemetry on server side, -/// so we want to have some logs/diagonistics on the client side export class Logger { /// The prefix of the logger private prefix: string; @@ -66,10 +46,9 @@ export class Logger { public error( msg: any /* eslint-disable-line @typescript-eslint/no-explicit-any */, ) { - const msgWithPrefix = `[${this.prefix}] ${msg}`; + const msgWithPrefix = `[${this.prefix}] ${errstr(msg)}`; window.console.error(msgWithPrefix); window.console.error(msg); pushLog(msgWithPrefix); } } -export const console = new Logger("low"); diff --git a/web-client/src/core/editor/EditorKernel.ts b/web-client/src/core/editor/EditorKernel.ts index 362a934b..48362c64 100644 --- a/web-client/src/core/editor/EditorKernel.ts +++ b/web-client/src/core/editor/EditorKernel.ts @@ -1,7 +1,5 @@ //! Editor logic that wraps monaco editor -import { FsResult, FsVoid } from "pure/fs"; - import { CompilerFileAccess } from "core/compiler"; /// Interface used to access editor API @@ -38,7 +36,7 @@ export interface EditorKernel { loadFromFs(): Promise; /// Save changes to the file system for the opened files - saveToFs(): Promise; + saveToFs(): Promise; /// Get a CompilerFileAccess implementation getFileAccess(): CompilerFileAccess; diff --git a/web-client/src/core/editor/ExternalEditorKernel.ts b/web-client/src/core/editor/ExternalEditorKernel.ts new file mode 100644 index 00000000..8c18bbf3 --- /dev/null +++ b/web-client/src/core/editor/ExternalEditorKernel.ts @@ -0,0 +1,134 @@ +//! Logic for external editor workflow + +import { FsFileSystem, FsResult, fsJoin, fsRoot } from "pure/fs"; + +import { CompilerFileAccess } from "core/compiler"; +import { IdleMgr, Yielder, createYielder, consoleEditor as console } from "low/utils"; + +import { EditorKernel } from "./EditorKernel"; +import { EditorKernelAccess } from "./EditorKernelAccess"; +import { ModifyTimeTracker } from "./ModifyTimeTracker"; + +console.info("loading external editor kernel"); + +export const initExternalEditor = (kernel: EditorKernelAccess, fs: FsFileSystem): EditorKernel => { + console.info("creating external editor"); + return new ExternalEditorKernel(kernel, fs); +}; + +class ExternalEditorKernel implements EditorKernel, CompilerFileAccess{ + private deleted = false; + private idleMgr: IdleMgr; + private fs: FsFileSystem; + private lastCompiledTime = 0; + + private kernel: EditorKernelAccess; + private fsYield: Yielder; + private modifyTracker: ModifyTimeTracker; + + constructor(kernel: EditorKernelAccess, fs: FsFileSystem) { + this.kernel = kernel; + this.fs = fs; + this.idleMgr = new IdleMgr( + 0, + 1000, + 2, + 20, + 8000, + this.recompileIfChanged.bind(this), + ); + this.fsYield = createYielder(64); + this.modifyTracker = new ModifyTimeTracker(); + this.idleMgr.start(); + } + + public delete(): void { + console.info("deleting external editor"); + if (this.deleted) { + console.warn("editor already deleted"); + return; + } + this.deleted = true; + this.idleMgr.stop(); + } + + public notifyActivity(): void { + this.idleMgr.notifyActivity(); + } + + private async recompileIfChanged() { + // locking is not needed because idle will be paused + // when an idle cycle is running + const changed = await this.checkDirectoryChanged(fsRoot()); + if (changed) { + this.lastCompiledTime = Date.now(); + this.notifyActivity(); + this.kernel.reloadDocument(); + } + } + + private async checkDirectoryChanged(path: string): Promise { + const entries = await this.fs.listDir(path); + if (entries.err) { + // error reading entry, something probably happened? + return true; + } + for (const entry of entries.val) { + const subPath = fsJoin(path, entry); + if (entry.endsWith("/")) { + const subDirChanged = await this.checkDirectoryChanged(subPath); + if (subDirChanged) { + return true; + } + } else { + const fileChanged = await this.checkFileChanged(subPath); + if (fileChanged) { + return true; + } + } + await this.fsYield(); + } + return false; + } + + private async checkFileChanged(path: string): Promise { + const fsFile = this.fs.getFile(path); + const lastModified = await fsFile.getLastModified(); + if (lastModified.err) { + return true; + } + return lastModified.val > this.lastCompiledTime; + } + + // === CompilerFileAccess === + public getFileAccess(): CompilerFileAccess { + return this; + } + + public async getFileContent( + path: string, + checkChanged: boolean, + ): Promise> { + const fsFile = this.fs.getFile(path); + if (checkChanged) { + const notModified = await this.modifyTracker.checkModifiedSinceLastAccess(fsFile); + if (notModified.err) { + return notModified; + } + } + + return await fsFile.getBytes(); + } + + // === Stub implementations === + public async listDir(): Promise { return []; } + public async openFile(): Promise { } + public async hasUnsavedChanges(): Promise { + return false; + } + public hasUnsavedChangesSync(): boolean { + return false; + } + public async loadFromFs(): Promise { } + public async saveToFs(): Promise { } +} diff --git a/web-client/src/core/editor/FileMgr.ts b/web-client/src/core/editor/FileMgr.ts index dc7215ef..7ae7326a 100644 --- a/web-client/src/core/editor/FileMgr.ts +++ b/web-client/src/core/editor/FileMgr.ts @@ -1,6 +1,6 @@ import * as monaco from "monaco-editor"; -import { FsErr, FsError, FsFile, FsFileSystem, FsResult, fsErr } from "pure/fs"; +import { FsError, FsFile, FsFileSystem, FsResult } from "pure/fs"; import { RwLock } from "pure/utils"; import { AppDispatcher, viewActions } from "core/store"; @@ -9,16 +9,17 @@ import { Yielder, createYielder, sleep, + consoleEditor as console, } from "low/utils"; import { EditorContainerDOM } from "./dom"; -import { EditorLog, detectLanguageByFileName } from "./utils"; +import { ModifyTimeTracker } from "./ModifyTimeTracker"; type IStandaloneCodeEditor = monaco.editor.IStandaloneCodeEditor; /// File manager /// -/// This manages the opened files in the editor +/// This manages the opened files in the web editor export class FileMgr implements CompilerFileAccess { /// Using a RwLock to ensure the tracked files don't change /// while being iterated. Generall, write lock is needed @@ -46,6 +47,8 @@ export class FileMgr implements CompilerFileAccess { private fsYield: Yielder; private dispatcher: AppDispatcher; + private modifyTracker: ModifyTimeTracker; + constructor( fs: FsFileSystem, monacoDom: HTMLDivElement, @@ -58,6 +61,7 @@ export class FileMgr implements CompilerFileAccess { this.monacoDom = monacoDom; this.monacoEditor = monacoEditor; this.fsYield = createYielder(64); + this.modifyTracker = new ModifyTimeTracker(); // this.fsLock = new ReentrantLock("file mgr"); } @@ -99,14 +103,14 @@ export class FileMgr implements CompilerFileAccess { const { val: entries, err } = await fs.listDir(path); if (err) { const { code, message } = err; - EditorLog.error(`listDir failed with code ${code}: ${message}`); + console.error(`listDir failed with code ${code}: ${message}`); return []; } return entries; } public openFile(path: string,): Promise { - EditorLog.info(`opening ${path}`); + console.info(`opening ${path}`); return this.fs.scopedWrite(async (fs) => { return this.openFileWithFs(fs, path); }); @@ -117,7 +121,7 @@ export class FileMgr implements CompilerFileAccess { const { val, err } = await fsFile.getText(); if (err) { const { code, message } = err; - EditorLog.error(`openFile failed with code ${code}: ${message}`); + console.error(`openFile failed with code ${code}: ${message}`); this.updateEditor(fsFile, undefined); return; } @@ -125,7 +129,7 @@ export class FileMgr implements CompilerFileAccess { } public async loadFromFs(): Promise { - EditorLog.info("syncing files from file system to editor..."); + console.info("syncing files from file system to editor..."); const handle = window.setTimeout(() => { this.dispatcher.dispatch(viewActions.startFileSysLoad()); }, 200); @@ -157,7 +161,7 @@ export class FileMgr implements CompilerFileAccess { // ); window.clearTimeout(handle); this.dispatcher.dispatch(viewActions.endFileSysLoad(success)); - EditorLog.info("sync completed"); + console.info("sync completed"); // return success ? allocOk() : allocErr(FsResultCodes.Fail); } @@ -211,12 +215,12 @@ export class FileMgr implements CompilerFileAccess { if (loadError) { const { code, message } = loadError; - EditorLog.error(`sync failed with code ${code}: ${message}`); + console.error(`sync failed with code ${code}: ${message}`); if (!fsFile.isDirty()) { // if the file is not dirty, we close the file // in case it doesn't exist on disk anymore // and to avoid error on the next save - EditorLog.info(`closing ${path} due to sync error`); + console.info(`closing ${path} due to sync error`); if (isCurrentFile) { this.closeEditor(); } @@ -303,12 +307,12 @@ export class FileMgr implements CompilerFileAccess { public async saveToFs(): Promise { if (!this.supportsSave) { - EditorLog.error("save not supported!"); - EditorLog.warn("saveToFs should only be called if save is supported"); + console.error("save not supported!"); + console.warn("saveToFs should only be called if save is supported"); return false; } - EditorLog.info("saving changes..."); + console.info("saving changes..."); const handle = window.setTimeout(() => { this.dispatcher.dispatch(viewActions.startFileSysSave()); }, 200); @@ -338,7 +342,7 @@ export class FileMgr implements CompilerFileAccess { window.clearTimeout(handle); this.dispatcher.dispatch(viewActions.endFileSysSave(success)); - EditorLog.info("save completed"); + console.info("save completed"); return success; } @@ -359,7 +363,7 @@ export class FileMgr implements CompilerFileAccess { const { err } = await fs.getFile(path).writeIfNewer(); if (err) { const { code, message } = err; - EditorLog.error(`save failed with code ${code}: ${message}`); + console.error(`save failed with code ${code}: ${message}`); return false; } return true; @@ -526,7 +530,6 @@ export class FileMgr implements CompilerFileAccess { this.dispatcher.dispatch(viewActions.setUnsavedFiles(newList)); } - private modifiedTimeWhenLastAccessed: { [path: string]: number } = {}; public getFileContent( path: string, checkChanged: boolean, @@ -539,19 +542,9 @@ export class FileMgr implements CompilerFileAccess { private async getFileContentWithFs(fs: FsFileSystem, path: string, checkChanged: boolean): Promise> { const fsFile = fs.getFile(path); if (checkChanged) { - const modifiedTimeCurrent = await fsFile.getLastModified(); - if (modifiedTimeCurrent.err) { - return modifiedTimeCurrent; - } - const modifiedTimeLast = this.modifiedTimeWhenLastAccessed[path]; - this.modifiedTimeWhenLastAccessed[path] = modifiedTimeCurrent.val; - if ( - modifiedTimeLast && - modifiedTimeLast >= modifiedTimeCurrent.val - ) { - // 1. file was accessed before - // 2. file was not modified since last access - return { err: fsErr(FsErr.NotModified, "Not modified") }; + const notModified = await this.modifyTracker.checkModifiedSinceLastAccess(fsFile); + if (notModified.err) { + return notModified; } } return await fsFile.getBytes(); @@ -560,7 +553,7 @@ export class FileMgr implements CompilerFileAccess { private async attachEditor() { let div = EditorContainerDOM.get(); while (!div) { - EditorLog.warn("editor container not found. Will try again."); + console.warn("editor container not found. Will try again."); await sleep(100); div = EditorContainerDOM.get(); } @@ -575,7 +568,7 @@ export class FileMgr implements CompilerFileAccess { if (!alreadyAttached) { div.appendChild(this.monacoDom); await this.resizeEditor(); - EditorLog.info("editor attached"); + console.info("editor attached"); } this.isEditorOpen = true; } @@ -585,3 +578,16 @@ export class FileMgr implements CompilerFileAccess { this.isEditorOpen = false; } } + +function detectLanguageByFileName(fileName: string): string { + if (fileName.match(/\.(j|t)s$/i)) { + return "typescript"; + } + if (fileName.match(/\.ya?ml/i)) { + return "yaml"; + } + if (fileName.match(/\.json/i)) { + return "json"; + } + return "text"; +} diff --git a/web-client/src/core/editor/ModifyTimeTracker.ts b/web-client/src/core/editor/ModifyTimeTracker.ts new file mode 100644 index 00000000..c1d06bb1 --- /dev/null +++ b/web-client/src/core/editor/ModifyTimeTracker.ts @@ -0,0 +1,35 @@ +import { FsErr, FsFile, FsVoid, fsErr } from "pure/fs"; + +/// Track if file was modified since last time it was accessed +export class ModifyTimeTracker { + /// Track the last modified time of a file + private modifiedTimeWhenLastAccessed: { [path: string]: number } = {}; + + /// Check if the file should be considered modified + /// since the last time this method was called with the same path + /// + /// Returns the NotModified error code if the file was not modified + public async checkModifiedSinceLastAccess(file: FsFile): Promise { + const path = file.path; + const modifiedTimeCurrent = await file.getLastModified(); + if (modifiedTimeCurrent.err) { + return modifiedTimeCurrent; + } + + const modifiedTimeLast = this.modifiedTimeWhenLastAccessed[path]; + if (!modifiedTimeLast) { + // will be undefined if we have never seen this file before + // so consider it modified + return {}; + } + if (modifiedTimeLast >= modifiedTimeCurrent.val) { + // file was not modified since last access + return notModified(); + } + return {}; + } +} + +function notModified(): FsVoid { + return { err: fsErr(FsErr.NotModified, "Not modified") }; +} diff --git a/web-client/src/core/kernel/editor/WebEditorKernel.ts b/web-client/src/core/editor/WebEditorKernel.ts similarity index 71% rename from web-client/src/core/kernel/editor/WebEditorKernel.ts rename to web-client/src/core/editor/WebEditorKernel.ts index 357b631f..178136dd 100644 --- a/web-client/src/core/kernel/editor/WebEditorKernel.ts +++ b/web-client/src/core/editor/WebEditorKernel.ts @@ -7,6 +7,8 @@ import jsonWorker from "monaco-editor/esm/vs/language/json/json.worker?worker"; import tsWorker from "monaco-editor/esm/vs/language/typescript/ts.worker?worker"; import reduxWatch from "redux-watch"; +import { FsFileSystem } from "pure/fs"; + import { AppStore, viewSelector, @@ -15,22 +17,22 @@ import { ViewState, SettingsState, } from "core/store"; -import { FileAccess, FileSys, FsResult } from "low/fs"; -import { isInDarkMode, IdleMgr, DOMId } from "low/utils"; +import { CompilerFileAccess } from "core/compiler"; +import { isInDarkMode, IdleMgr, DOMId, consoleEditor as console } from "low/utils"; -import { EditorKernel } from "./EditorKernel"; -import { EditorLog, toFsPath } from "./utils"; import { FileMgr } from "./FileMgr"; -import { KernelAccess } from "./KernelAccess"; -EditorLog.info("loading web editor kernel"); +import { EditorKernel } from "./EditorKernel"; +import { EditorKernelAccess } from "./EditorKernelAccess"; + +console.info("loading web editor kernel"); export const initWebEditor = ( - kernelAccess: KernelAccess, - fileSys: FileSys, + kernel: EditorKernelAccess, + fs: FsFileSystem, store: AppStore, ): EditorKernel => { - EditorLog.info("creating web editor"); + console.info("creating web editor"); window.MonacoEnvironment = { getWorker(_, label) { if (label === "json") { @@ -42,13 +44,10 @@ export const initWebEditor = ( return new editorWorker(); }, }; - return new WebEditorKernel(kernelAccess, fileSys, store); + return new WebEditorKernel(kernel, fs, store); }; const MonacoEditorDOM = new DOMId("monaco-editor"); -MonacoEditorDOM.style({ - height: "100%", -}); class WebEditorKernel implements EditorKernel { private deleted = false; @@ -58,13 +57,13 @@ class WebEditorKernel implements EditorKernel { private fileMgr: FileMgr; private shouldRecompile = false; - private kernelAccess: KernelAccess; + private kernel: EditorKernelAccess; private cleanup: () => void; - constructor(kernelAccess: KernelAccess, fileSys: FileSys, store: AppStore) { + constructor(kernel: EditorKernelAccess, fs: FsFileSystem, store: AppStore) { this.store = store; - this.kernelAccess = kernelAccess; + this.kernel = kernel; this.idleMgr = new IdleMgr( 5000, @@ -77,6 +76,7 @@ class WebEditorKernel implements EditorKernel { const monacoDom = document.createElement("div"); monacoDom.id = MonacoEditorDOM.id; + monacoDom.style.height = "100%"; const monacoEditor = monaco.editor.create(monacoDom, { theme: isInDarkMode() ? "vs-dark" : "vs", tabSize: 2, @@ -88,7 +88,7 @@ class WebEditorKernel implements EditorKernel { monacoEditor.onMouseDown(() => { this.idleMgr.notifyActivity(); }); - this.fileMgr = new FileMgr(fileSys, monacoDom, monacoEditor, store); + this.fileMgr = new FileMgr(fs, monacoDom, monacoEditor, store); const resizeHandler = this.onResize.bind(this); window.addEventListener("resize", resizeHandler); @@ -121,9 +121,9 @@ class WebEditorKernel implements EditorKernel { } public delete() { - EditorLog.info("deleting web editor"); + console.info("deleting web editor"); if (this.deleted) { - EditorLog.warn("editor already deleted"); + console.warn("editor already deleted"); return; } this.cleanup(); @@ -134,23 +134,22 @@ class WebEditorKernel implements EditorKernel { this.idleMgr.notifyActivity(); } - public async listDir(path: string[]): Promise { + public listDir(path: string): Promise { // probably fine with not locking idle mgr here - return await this.fileMgr.listDir(path); + // since it's read-only access + return this.fileMgr.listDir(path); } /// Open a file in the editor - public async openFile(path: string[]): Promise> { - const fsPath = toFsPath(path); - const result = await this.idleMgr.pauseIdleScope(async () => { - return await this.fileMgr.openFile(fsPath); + public openFile(path: string): Promise { + return this.idleMgr.pauseIdleScope(() => { + return this.fileMgr.openFile(path); }); - return result; } - public async hasUnsavedChanges(): Promise { - return await this.idleMgr.pauseIdleScope(async () => { - return await this.fileMgr.hasUnsavedChanges(); + public hasUnsavedChanges(): Promise { + return this.idleMgr.pauseIdleScope(() => { + return this.fileMgr.hasUnsavedChanges(); }); } @@ -158,28 +157,24 @@ class WebEditorKernel implements EditorKernel { return this.fileMgr.hasUnsavedChangesSync(); } - public async loadChangesFromFs(): Promise> { - const result = await this.idleMgr.pauseIdleScope(async () => { - return await this.fileMgr.loadChangesFromFs(); + public async loadFromFs(): Promise { + await this.idleMgr.pauseIdleScope(() => { + return this.fileMgr.loadFromFs(); }); - this.kernelAccess.reloadDocument(); - return result; + this.kernel.reloadDocument(); } - public async saveChangesToFs(): Promise> { - const result = await this.idleMgr.pauseIdleScope(async () => { - return await this.fileMgr.saveChangesToFs(); + public async saveToFs(): Promise { + await this.idleMgr.pauseIdleScope(() => { + return this.fileMgr.saveToFs(); }); - if (result.isErr()) { - return result; - } + const { unsavedFiles } = viewSelector(this.store.getState()); - this.fileMgr.updateDirtyFileList(unsavedFiles); - return result.makeOk(undefined); + await this.fileMgr.updateDirtyFileList(unsavedFiles); } - // === FileAccess === - public getFileAccess(): FileAccess { + // === CompilerFileAccess === + public getFileAccess(): CompilerFileAccess { return this.fileMgr; } @@ -218,7 +213,7 @@ class WebEditorKernel implements EditorKernel { let shouldRerenderFs = false; if (autoSaveEnabled) { - await this.saveChangesToFs(); + await this.saveToFs(); // make sure file system view is rerendered in case there are directory updates shouldRerenderFs = true; } @@ -228,9 +223,9 @@ class WebEditorKernel implements EditorKernel { } // do this last so we can get the latest save status after auto-save - this.fileMgr.updateDirtyFileList(unsavedFiles); + await this.fileMgr.updateDirtyFileList(unsavedFiles); if (this.shouldRecompile) { - this.kernelAccess.reloadDocument(); + this.kernel.reloadDocument(); this.shouldRecompile = false; } } diff --git a/web-client/src/core/editor/index.ts b/web-client/src/core/editor/index.ts index 53628eac..8afb7ed2 100644 --- a/web-client/src/core/editor/index.ts +++ b/web-client/src/core/editor/index.ts @@ -1,9 +1,10 @@ //! core/editor //! Web Editor module +import { consoleEditor as console } from "low/utils"; -import { EditorLog } from "./utils"; - -EditorLog.info("loading editor module"); +console.info("loading editor module"); export * from "./EditorKernelAccess"; export * from "./EditorKernel"; +export * from "./initEditor"; +export * from "./openHandler"; diff --git a/web-client/src/core/kernel/editor/initEditor.ts b/web-client/src/core/editor/initEditor.ts similarity index 75% rename from web-client/src/core/kernel/editor/initEditor.ts rename to web-client/src/core/editor/initEditor.ts index de9c3525..615be7bd 100644 --- a/web-client/src/core/kernel/editor/initEditor.ts +++ b/web-client/src/core/editor/initEditor.ts @@ -1,7 +1,8 @@ +import { FsFileSystem } from "pure/fs"; + import { AppStore, settingsSelector } from "core/store"; -import { FileSys } from "low/fs"; -import { KernelAccess } from "./KernelAccess"; +import { EditorKernelAccess } from "./EditorKernelAccess"; import { EditorKernel } from "./EditorKernel"; declare global { @@ -11,8 +12,8 @@ declare global { } export const initEditor = async ( - kernel: KernelAccess, - fileSys: FileSys, + kernel: EditorKernelAccess, + fs: FsFileSystem, store: AppStore, ): Promise => { deleteEditor(); @@ -20,10 +21,10 @@ export const initEditor = async ( let editor; if (editorMode === "web") { const { initWebEditor } = await import("./WebEditorKernel"); - editor = initWebEditor(kernel, fileSys, store); + editor = initWebEditor(kernel, fs, store); } else { const { initExternalEditor } = await import("./ExternalEditorKernel"); - editor = initExternalEditor(kernel, fileSys); + editor = initExternalEditor(kernel, fs); } window.__theEditorKernel = editor; diff --git a/web-client/src/core/editor/openHandler.ts b/web-client/src/core/editor/openHandler.ts new file mode 100644 index 00000000..d7e860a5 --- /dev/null +++ b/web-client/src/core/editor/openHandler.ts @@ -0,0 +1,57 @@ +import { FsErr, FsError, FsResult } from "pure/fs"; + +import { AlertMgr, consoleEditor as console } from "low/utils"; + +export function createRetryOpenHandler(alertMgr: AlertMgr) { + return async (err: FsError): Promise> => { + const { code, message } = err; + console.error(`open failed with code ${code}: ${message}`); + if (code === FsErr.PermissionDenied) { + const retry = await alertMgr.show({ + title: "Permission Denied", + message: + "You must given file system access permission to the app to use this feature. Please try again and grant the permission when prompted.", + okButton: "Grant Permission", + cancelButton: "Cancel", + }); + if (retry) { + console.info("retrying open after permission denied"); + } + return { val: retry }; + } + if (code === FsErr.UserAbort) { + // don't retry if user aborted + return { val: false }; + } + if (code === FsErr.NotSupported) { + // don't retry if not supported + await alertMgr.show({ + title: "Not Supported", + message: "Your browser does not support this feature.", + okButton: "Close", + learnMoreLink: "/docs/route/editor/web#browser-os-support", + }); + return { val: false }; + } + if (code === FsErr.IsFile) { + await alertMgr.show({ + title: "Error", + message: + "You opened a file. Make sure you are opening the project folder and not individual files.", + okButton: "Close", + }); + return { val: false }; + } + // if it's unknown error, let user know and ask if they want to retry + const retry = await alertMgr.show({ + title: "Cannot open project", + message: `File system operation failed with code ${code}: ${message}`, + okButton: "Retry", + cancelButton: "Cancel", + }); + if (retry) { + console.info("retrying open after error"); + } + return { val: retry }; + }; +} diff --git a/web-client/src/core/editor/utils.ts b/web-client/src/core/editor/utils.ts deleted file mode 100644 index df07cac4..00000000 --- a/web-client/src/core/editor/utils.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { Logger } from "low/utils"; - -export const EditorLog = new Logger("edt"); - -// export const toFsPath = (path: string[]): FsPath => { -// let fsPath = fsRootPath; -// for (let i = 0; i < path.length; i++) { -// fsPath = fsPath.resolve(path[i]); -// } -// return fsPath; -// }; - -export const detectLanguageByFileName = (fileName: string): string => { - if (fileName.match(/\.(j|t)s$/i)) { - return "typescript"; - } - if (fileName.match(/\.ya?ml/i)) { - return "yaml"; - } - if (fileName.match(/\.json/i)) { - return "json"; - } - return "text"; -}; diff --git a/web-client/src/core/kernel/AlertMgr.ts b/web-client/src/core/kernel/AlertMgr.ts index 96a94e47..35ff283c 100644 --- a/web-client/src/core/kernel/AlertMgr.ts +++ b/web-client/src/core/kernel/AlertMgr.ts @@ -1,6 +1,6 @@ //! Manager for modal alerts -import { Result, ResultHandle } from "pure/result"; +import { Result, tryAsync } from "pure/result"; import { AppDispatcher, viewActions } from "core/store"; import { AlertExtraAction, AlertIds, AlertMgr, AlertOptions, BlockingAlertOptions, ModifyAlertActionPayload, RichAlertOptions, console } from "low/utils"; @@ -71,7 +71,6 @@ export class AlertMgrImpl implements AlertMgr { } public showBlocking( - r: ResultHandle, { title, component, cancelButton }: BlockingAlertOptions, f: () => Promise, ): Promise> { @@ -82,7 +81,7 @@ export class AlertMgrImpl implements AlertMgr { // it means cancel cancelled = true; console.info("user cancelled the operation"); - resolve(r.putErr(false)); + resolve({ err: false }); }, component); this.store.dispatch( viewActions.setAlert({ @@ -95,20 +94,11 @@ export class AlertMgrImpl implements AlertMgr { }), ); // let the UI update first - setTimeout(() => { - f() - .then((result) => { - if (!cancelled) { - this.clearAlertAndThen(() => - resolve(r.putOk(result)), - ); - } - }) - .catch((e) => { - if (!cancelled) { - this.clearAlertAndThen(() => resolve(r.putErr(e))); - } - }); + setTimeout(async () => { + const result = await tryAsync(f); + if (!cancelled) { + resolve(result); + } }, ALERT_TIMEOUT); }); } diff --git a/web-client/src/core/kernel/Kernel.ts b/web-client/src/core/kernel/Kernel.ts index c3c6cf28..dc4bee2e 100644 --- a/web-client/src/core/kernel/Kernel.ts +++ b/web-client/src/core/kernel/Kernel.ts @@ -24,7 +24,7 @@ import { import type { CompilerKernel } from "core/compiler"; import type { EditorKernel, EditorKernelAccess } from "core/editor"; import { ExpoDoc, ExportRequest } from "low/celerc"; -import { console, Logger, isInDarkMode, sleep, AlertMgr } from "low/utils"; +import { consoleKernel as console, isInDarkMode, sleep, AlertMgr } from "low/utils"; import { KeyMgr } from "./KeyMgr"; import { WindowMgr } from "./WindowMgr"; @@ -42,8 +42,6 @@ type InitUiFunction = ( /// It is also responsible for mounting react to the DOM and /// handles the routing. export class Kernel implements EditorKernelAccess { - /// The logger - private log: Logger; /// The store /// /// The kernel owns the store. The store is shared @@ -65,16 +63,15 @@ export class Kernel implements EditorKernelAccess { private compiler: CompilerKernel | null = null; constructor(initReact: InitUiFunction) { - this.log = new Logger("ker"); this.initReact = initReact; - this.log.info("starting application"); + console.info("starting application"); this.store = this.initStore(); this.alertMgr = new AlertMgrImpl(this.store); } /// Initialize the store private initStore(): AppStore { - this.log.info("initializing store..."); + console.info("initializing store..."); const store = initStore(); const watchSettings = reduxWatch(() => @@ -84,7 +81,7 @@ export class Kernel implements EditorKernelAccess { store.subscribe( watchSettings((newVal: SettingsState, _oldVal: SettingsState) => { // save settings to local storage - this.log.info("saving settings..."); + console.info("saving settings..."); saveSettings(newVal); }), ); @@ -118,7 +115,7 @@ export class Kernel implements EditorKernelAccess { /// Initialize stage info based on window.location private async initStage() { - this.log.info("initializing stage..."); + console.info("initializing stage..."); const path = window.location.pathname; if (path === "/edit") { document.title = "Celer Editor"; @@ -137,9 +134,9 @@ export class Kernel implements EditorKernelAccess { /// Initialize UI related stuff private initUi() { - this.log.info("initializing ui..."); + console.info("initializing ui..."); if (this.cleanupUi) { - this.log.info("unmounting previous ui"); + console.info("unmounting previous ui"); this.cleanupUi(); } const isDarkMode = isInDarkMode(); @@ -173,7 +170,7 @@ export class Kernel implements EditorKernelAccess { const state = this.store.getState(); const stageMode = viewSelector(state).stageMode; if (stageMode !== "edit") { - this.log.error( + console.error( "compiler is not available in view mode. This is a bug!", ); throw new Error("compiler is not available in view mode"); @@ -188,78 +185,17 @@ export class Kernel implements EditorKernelAccess { // put this in low/fs - /// Handle the result of opening a project - /// - /// This will show error message accordingly if the result is failure. - /// On success, it will initialize the file system and the editor. + /// Open a project file system /// /// This function eats the error because alerts will be shown to the user - public async handleOpenFileSysResult( - fileSysResult: FsStableResult, - ): Promise { + public async openProjectFileSystem(fs: FsFileSystem): Promise { console.info("opening file system..."); - const { FsResultCodes } = await import("low/fs"); - if (fileSysResult.isErr()) { - const code = fileSysResult.error; - if (code === FsResultCodes.UserAbort) { - console.info("opening file system aborted."); - return; - } - if (code === FsResultCodes.NotSupported) { - await this.getAlertMgr().show({ - title: "Not Supported", - message: "Your browser does not support this feature.", - okButton: "Close", - learnMoreLink: "/docs/route/editor/web#browser-os-support", - }); - } else if (code === FsResultCodes.IsFile) { - await this.getAlertMgr().show({ - title: "Error", - message: - "You opened a file. Make sure you are opening the project folder and not individual files.", - okButton: "Close", - }); - } else { - await this.getAlertMgr().show({ - title: "Error", - message: `Cannot open the project. Make sure you have access to the folder or contact support. (Error code ${code}}`, - okButton: "Close", - }); - } - return; - } - console.info("initializing new file system..."); - const fileSys = fileSysResult.value; - let result = await fileSys.init(); - while (result.isErr()) { - let retry = false; - const code = result.inner(); - if (code === FsResultCodes.PermissionDenied) { - retry = await this.getAlertMgr().show({ - title: "Permission Denied", - message: - "You must given file system access permission to the app to use this feature. Please try again and grant the permission when prompted.", - okButton: "Grant Permission", - cancelButton: "Cancel", - }); - } else { - retry = await this.getAlertMgr().show({ - title: "Error", - message: `Failed to initialize the project. Please try again. (Error code ${code})`, - okButton: "Retry", - cancelButton: "Cancel", - }); - } - if (!retry) { - return; - } - result = await fileSys.init(); - } const { editorMode } = settingsSelector(this.store.getState()); + const { write, live } = fs.capabilities; if (editorMode === "web") { // must be able to save to use web editor - if (!fileSys.isWritable()) { + if (!write) { const yes = await this.getAlertMgr().show({ title: "Save not supported", message: @@ -275,7 +211,7 @@ export class Kernel implements EditorKernelAccess { } } - if (fileSys.isStale()) { + if (!live) { const yes = await this.getAlertMgr().show({ title: "Heads up!", message: @@ -289,10 +225,10 @@ export class Kernel implements EditorKernelAccess { } } - const { initEditor } = await import("./editor"); - const editor = await initEditor(this, fileSys, this.store); + const { initEditor } = await import("core/editor"); + const editor = await initEditor(this, fs, this.store); this.editor = editor; - this.updateRootPathInStore(fileSys); + this.updateRootPathInStore(fs); const compiler = await this.getCompiler(); await compiler.init(editor.getFileAccess()); @@ -301,32 +237,30 @@ export class Kernel implements EditorKernelAccess { console.info("project opened."); } - public async reloadDocument() { - if (viewSelector(this.store.getState()).stageMode === "edit") { - const compiler = await this.getCompiler(); - compiler.compile(); - return; - } - await this.reloadDocumentFromServer(); - } - - // put this in editor kernel - public async closeFileSys() { + public async closeProjectFileSystem() { console.info("closing file system..."); this.store.dispatch(documentActions.setDocument(undefined)); this.updateRootPathInStore(undefined); this.editor = null; - const { deleteEditor } = await import("./editor"); + const { deleteEditor } = await import("core/editor"); deleteEditor(); const compiler = await this.getCompiler(); compiler.uninit(); } - // put this in editor kernel private updateRootPathInStore(fs: FsFileSystem | undefined) { this.store.dispatch(viewActions.updateFileSys(fs?.root ?? undefined)); } + public async reloadDocument() { + if (viewSelector(this.store.getState()).stageMode === "edit") { + const compiler = await this.getCompiler(); + compiler.compile(); + return; + } + await this.reloadDocumentFromServer(); + } + public async export(request: ExportRequest): Promise { const splitExportConfigs = getSplitExportPluginConfigs(); if (splitExportConfigs.find((c) => c.use === request.pluginId)) { @@ -350,7 +284,7 @@ export class Kernel implements EditorKernelAccess { } } payload["split-types"] = injected; - this.log.info( + console.info( `injected ${injected.length} split types into export request payload.`, ); } @@ -380,12 +314,12 @@ export class Kernel implements EditorKernelAccess { let retry = true; while (retry) { - this.log.info("reloading document from server"); + console.info("reloading document from server"); const result = await loadDocumentFromCurrentUrl(); if (result.type === "failure") { this.store.dispatch(documentActions.setDocument(undefined)); - this.log.info("failed to load document from server"); - this.log.error(result.data); + console.info("failed to load document from server"); + console.error(result.data); retry = await this.getAlertMgr().show({ title: "Failed to load route", message: result.data, @@ -403,11 +337,11 @@ export class Kernel implements EditorKernelAccess { }); break; } - this.log.warn("retrying in 1s..."); + console.warn("retrying in 1s..."); await sleep(1000); continue; } - this.log.info("received document from server"); + console.info("received document from server"); const doc = result.data; try { const { title, version } = doc.execDoc.project; @@ -419,8 +353,8 @@ export class Kernel implements EditorKernelAccess { document.title = `${title} - ${version}`; } } catch (e) { - this.log.warn("failed to set document title"); - this.log.error(e); + console.warn("failed to set document title"); + console.error(e); document.title = "Celer Viewer"; } this.store.dispatch(documentActions.setDocument(doc)); diff --git a/web-client/src/core/kernel/editor/ExternalEditorKernel.ts b/web-client/src/core/kernel/editor/ExternalEditorKernel.ts deleted file mode 100644 index c7e8b0e7..00000000 --- a/web-client/src/core/kernel/editor/ExternalEditorKernel.ts +++ /dev/null @@ -1,168 +0,0 @@ -//! Logic for external editor workflow - -import { - FileAccess, - FileSys, - FsPath, - FsResult, - FsResultCodes, - fsRootPath, -} from "low/fs"; -import { IdleMgr, Yielder, createYielder, allocOk } from "low/utils"; - -import { EditorKernel } from "./EditorKernel"; -import { EditorLog, toFsPath } from "./utils"; -import { KernelAccess } from "./KernelAccess"; - -EditorLog.info("loading external editor kernel"); - -export const initExternalEditor = ( - kernelAccess: KernelAccess, - fileSys: FileSys, -): EditorKernel => { - EditorLog.info("creating external editor"); - return new ExternalEditorKernel(kernelAccess, fileSys); -}; - -class ExternalEditorKernel implements EditorKernel, FileAccess { - private deleted = false; - private idleMgr: IdleMgr; - private fs: FileSys; - private lastCompiledTime = 0; - - private kernelAccess: KernelAccess; - - constructor(kernelAccess: KernelAccess, fileSys: FileSys) { - this.kernelAccess = kernelAccess; - this.fs = fileSys; - this.idleMgr = new IdleMgr( - 0, - 1000, - 2, - 20, - 8000, - this.recompileIfChanged.bind(this), - ); - this.idleMgr.start(); - } - - public delete(): void { - EditorLog.info("deleting external editor"); - if (this.deleted) { - EditorLog.warn("editor already deleted"); - return; - } - this.deleted = true; - this.idleMgr.stop(); - } - - public notifyActivity(): void { - this.idleMgr.notifyActivity(); - } - - private async recompileIfChanged() { - const yielder = createYielder(64); - const changed = await this.checkDirectoryChanged(yielder, fsRootPath); - if (changed) { - this.lastCompiledTime = Date.now(); - this.notifyActivity(); - this.kernelAccess.reloadDocument(); - } - } - - private async checkDirectoryChanged( - yielder: Yielder, - fsPath: FsPath, - ): Promise { - const fsDirResult = await this.fs.listDir(fsPath); - if (fsDirResult.isErr()) { - return false; - } - const dirContent = fsDirResult.inner(); - for (const entry of dirContent) { - const subPath = fsPath.resolve(entry); - if (entry.endsWith("/")) { - const subDirChanged = await this.checkDirectoryChanged( - yielder, - subPath, - ); - if (subDirChanged) { - return true; - } - } else { - const fileChanged = await this.checkFileChanged(subPath); - if (fileChanged) { - return true; - } - } - await yielder(); - } - return false; - } - - private async checkFileChanged(fsPath: FsPath): Promise { - const fsFileResult = await this.fs.readFile(fsPath); - if (fsFileResult.isErr()) { - return false; - } - const modifiedTime = fsFileResult.inner().lastModified; - return modifiedTime > this.lastCompiledTime; - } - - // === FileAccess === - public getFileAccess(): FileAccess { - return this; - } - private cachedFileContent: { [path: string]: Uint8Array } = {}; - private modifiedTimeWhenLastAccessed: { [path: string]: number } = {}; - public async getFileContent( - path: string, - checkChanged: boolean, - ): Promise> { - const fsPath = toFsPath(path.split("/")); - const fsFileResult = await this.fs.readFile(fsPath); - if (fsFileResult.isErr()) { - return fsFileResult; - } - const file = fsFileResult.inner(); - const modifiedTimeLast = this.modifiedTimeWhenLastAccessed[path]; - const modifiedTimeCurrent = file.lastModified; - this.modifiedTimeWhenLastAccessed[path] = modifiedTimeCurrent; - if ( - path in this.cachedFileContent && - modifiedTimeLast && - modifiedTimeLast >= modifiedTimeCurrent - ) { - // 1. file was accessed before (and cached) - // 2. file was not modified since last access - if (checkChanged) { - return fsFileResult.makeErr(FsResultCodes.NotModified); - } - return fsFileResult.makeOk(this.cachedFileContent[path]); - } - // file was not accessed before or was modified since last access - const bytes = new Uint8Array(await file.arrayBuffer()); - this.cachedFileContent[path] = bytes; - return fsFileResult.makeOk(bytes); - } - - // === Stub implementations === - async listDir(): Promise { - return []; - } - async openFile(): Promise> { - return allocOk(undefined); - } - async hasUnsavedChanges(): Promise { - return false; - } - hasUnsavedChangesSync(): boolean { - return false; - } - async loadChangesFromFs(): Promise> { - return allocOk(undefined); - } - async saveChangesToFs(): Promise> { - return allocOk(undefined); - } -} diff --git a/web-client/src/core/kernel/editor/index.ts b/web-client/src/core/kernel/editor/index.ts deleted file mode 100644 index 6213f3e7..00000000 --- a/web-client/src/core/kernel/editor/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { EditorLog } from "./utils"; -EditorLog.info("loading editor module"); - -export * from "./initEditor"; diff --git a/web-client/src/low/fs/FileApiFileSys.ts b/web-client/src/low/fs/FileApiFileSys.ts index fc6623a2..8ad8886d 100644 --- a/web-client/src/low/fs/FileApiFileSys.ts +++ b/web-client/src/low/fs/FileApiFileSys.ts @@ -1,67 +1,67 @@ -import { allocErr, allocOk } from "low/utils"; -import { FsResult, FsResultCodes } from "./FsResult"; -import { FsPath } from "./FsPath"; -import { FileSys } from "./FileSys"; - -/// FileSystem implementation that uses a list of Files -/// This is supported in all browsers, but it is stale. -/// It's used for Firefox when the File Entries API is not available -/// i.e. opened from -export class FileApiFileSys implements FileSys { - private rootPath: string; - private files: Record; - - constructor(rootPath: string, files: Record) { - this.rootPath = rootPath; - this.files = files; - } - - public async init(): Promise> { - return allocOk(); - } - - public getRootName(): string { - return this.rootPath; - } - - public async listDir(path: FsPath): Promise> { - const set = new Set(); - const prefix = path.path; - Object.keys(this.files).forEach((path) => { - if (!path.startsWith(prefix)) { - return; - } - const relPath = path.slice(prefix.length); - if (!relPath.includes("/")) { - // file - set.add(relPath); - } else { - // directory - const dir = relPath.slice(0, relPath.indexOf("/") + 1); - set.add(dir); - } - }); - return allocOk(Array.from(set)); - } - - public async readFile(path: FsPath): Promise> { - const file = this.files[path.path]; - if (!file) { - return allocErr(FsResultCodes.NotFound); - } - return allocOk(file); - } - - public isWritable(): boolean { - return false; - } - - public isStale(): boolean { - return true; - } - - public async writeFile(): Promise> { - // File API does not support writing - return allocErr(FsResultCodes.NotSupported); - } -} +// import { allocErr, allocOk } from "low/utils"; +// import { FsResult, FsResultCodes } from "./FsResult"; +// import { FsPath } from "./FsPath"; +// import { FileSys } from "./FileSys"; +// +// /// FileSystem implementation that uses a list of Files +// /// This is supported in all browsers, but it is stale. +// /// It's used for Firefox when the File Entries API is not available +// /// i.e. opened from +// export class FileApiFileSys implements FileSys { +// private rootPath: string; +// private files: Record; +// +// constructor(rootPath: string, files: Record) { +// this.rootPath = rootPath; +// this.files = files; +// } +// +// public async init(): Promise> { +// return allocOk(); +// } +// +// public getRootName(): string { +// return this.rootPath; +// } +// +// public async listDir(path: FsPath): Promise> { +// const set = new Set(); +// const prefix = path.path; +// Object.keys(this.files).forEach((path) => { +// if (!path.startsWith(prefix)) { +// return; +// } +// const relPath = path.slice(prefix.length); +// if (!relPath.includes("/")) { +// // file +// set.add(relPath); +// } else { +// // directory +// const dir = relPath.slice(0, relPath.indexOf("/") + 1); +// set.add(dir); +// } +// }); +// return allocOk(Array.from(set)); +// } +// +// public async readFile(path: FsPath): Promise> { +// const file = this.files[path.path]; +// if (!file) { +// return allocErr(FsResultCodes.NotFound); +// } +// return allocOk(file); +// } +// +// public isWritable(): boolean { +// return false; +// } +// +// public isStale(): boolean { +// return true; +// } +// +// public async writeFile(): Promise> { +// // File API does not support writing +// return allocErr(FsResultCodes.NotSupported); +// } +// } diff --git a/web-client/src/low/fs/FileEntriesApiFileSys.ts b/web-client/src/low/fs/FileEntriesApiFileSys.ts index 6d4ef6f7..3d553da6 100644 --- a/web-client/src/low/fs/FileEntriesApiFileSys.ts +++ b/web-client/src/low/fs/FileEntriesApiFileSys.ts @@ -1,156 +1,156 @@ -import { console, allocErr, allocOk } from "low/utils"; -import { FileSys } from "./FileSys"; -import { FsPath } from "./FsPath"; -import { FsResult, FsResultCodes } from "./FsResult"; - -export const isFileEntriesApiSupported = (): boolean => { - if (!window) { - return false; - } - // Chrome/Edge has this but it's named DirectoryEntry - // However, it doesn't work properly. - if ( - navigator && - navigator.userAgent && - navigator.userAgent.includes("Chrome") - ) { - return false; - } - if (!window.FileSystemDirectoryEntry) { - return false; - } - - if (!window.FileSystemFileEntry) { - return false; - } - - if (!window.FileSystemDirectoryEntry.prototype.createReader) { - return false; - } - - if (!window.FileSystemDirectoryEntry.prototype.getFile) { - return false; - } - - if (!window.FileSystemFileEntry.prototype.file) { - return false; - } - - return true; -}; - -/// FileSys implementation that uses File Entries API -/// This is not supported in Chrome/Edge, but in Firefox -export class FileEntriesApiFileSys implements FileSys { - private rootPath: string; - private rootEntry: FileSystemDirectoryEntry; - - constructor(rootPath: string, rootEntry: FileSystemDirectoryEntry) { - this.rootPath = rootPath; - this.rootEntry = rootEntry; - } - - public async init(): Promise> { - return allocOk(); - } - - public isWritable(): boolean { - // Entries API does not support writing - return false; - } - - public isStale(): boolean { - // Entries API can scan directories - return false; - } - - public getRootName() { - return this.rootPath; - } - - public async listDir(path: FsPath): Promise> { - const result = await this.resolveDir(path); - if (result.isErr()) { - return result; - } - const dirEntry = result.inner(); - - try { - const entries: FileSystemEntry[] = await new Promise( - (resolve, reject) => { - dirEntry.createReader().readEntries(resolve, reject); - }, - ); - const names = entries.map((e) => { - if (e.isDirectory) { - return e.name + "/"; - } - return e.name; - }); - return result.makeOk(names); - } catch (e) { - console.error(e); - return result.makeErr(FsResultCodes.Fail); - } - } - - public async readFile(path: FsPath): Promise> { - const parentResult = path.parent; - if (parentResult.isErr()) { - return parentResult; - } - const nameResult = path.name; - if (nameResult.isErr()) { - return nameResult; - } - const result = await this.resolveDir(parentResult.inner()); - if (result.isErr()) { - return result; - } - const dirEntry = result.inner(); - - try { - const fileEntry = (await new Promise( - (resolve, reject) => { - dirEntry.getFile(nameResult.inner(), {}, resolve, reject); - }, - )) as FileSystemFileEntry; - const file = await new Promise((resolve, reject) => { - fileEntry.file(resolve, reject); - }); - return result.makeOk(file); - } catch (e) { - console.error(e); - return result.makeErr(FsResultCodes.Fail); - } - } - - public async writeFile(): Promise> { - // Entries API does not support writing - return allocErr(FsResultCodes.NotSupported); - } - - async resolveDir( - path: FsPath, - ): Promise> { - let entry: FileSystemEntry; - if (path.isRoot) { - entry = this.rootEntry; - } else { - const fullPath = path.path; - try { - entry = await new Promise((resolve, reject) => { - this.rootEntry.getDirectory(fullPath, {}, resolve, reject); - }); - } catch (e) { - console.error(e); - return allocErr(FsResultCodes.Fail); - } - } - - if (!entry.isDirectory) { - return allocErr(FsResultCodes.IsFile); - } - return allocOk(entry as FileSystemDirectoryEntry); - } -} +// import { console, allocErr, allocOk } from "low/utils"; +// import { FileSys } from "./FileSys"; +// import { FsPath } from "./FsPath"; +// import { FsResult, FsResultCodes } from "./FsResult"; +// +// export const isFileEntriesApiSupported = (): boolean => { +// if (!window) { +// return false; +// } +// // Chrome/Edge has this but it's named DirectoryEntry +// // However, it doesn't work properly. +// if ( +// navigator && +// navigator.userAgent && +// navigator.userAgent.includes("Chrome") +// ) { +// return false; +// } +// if (!window.FileSystemDirectoryEntry) { +// return false; +// } +// +// if (!window.FileSystemFileEntry) { +// return false; +// } +// +// if (!window.FileSystemDirectoryEntry.prototype.createReader) { +// return false; +// } +// +// if (!window.FileSystemDirectoryEntry.prototype.getFile) { +// return false; +// } +// +// if (!window.FileSystemFileEntry.prototype.file) { +// return false; +// } +// +// return true; +// }; +// +// /// FileSys implementation that uses File Entries API +// /// This is not supported in Chrome/Edge, but in Firefox +// export class FileEntriesApiFileSys implements FileSys { +// private rootPath: string; +// private rootEntry: FileSystemDirectoryEntry; +// +// constructor(rootPath: string, rootEntry: FileSystemDirectoryEntry) { +// this.rootPath = rootPath; +// this.rootEntry = rootEntry; +// } +// +// public async init(): Promise> { +// return allocOk(); +// } +// +// public isWritable(): boolean { +// // Entries API does not support writing +// return false; +// } +// +// public isStale(): boolean { +// // Entries API can scan directories +// return false; +// } +// +// public getRootName() { +// return this.rootPath; +// } +// +// public async listDir(path: FsPath): Promise> { +// const result = await this.resolveDir(path); +// if (result.isErr()) { +// return result; +// } +// const dirEntry = result.inner(); +// +// try { +// const entries: FileSystemEntry[] = await new Promise( +// (resolve, reject) => { +// dirEntry.createReader().readEntries(resolve, reject); +// }, +// ); +// const names = entries.map((e) => { +// if (e.isDirectory) { +// return e.name + "/"; +// } +// return e.name; +// }); +// return result.makeOk(names); +// } catch (e) { +// console.error(e); +// return result.makeErr(FsResultCodes.Fail); +// } +// } +// +// public async readFile(path: FsPath): Promise> { +// const parentResult = path.parent; +// if (parentResult.isErr()) { +// return parentResult; +// } +// const nameResult = path.name; +// if (nameResult.isErr()) { +// return nameResult; +// } +// const result = await this.resolveDir(parentResult.inner()); +// if (result.isErr()) { +// return result; +// } +// const dirEntry = result.inner(); +// +// try { +// const fileEntry = (await new Promise( +// (resolve, reject) => { +// dirEntry.getFile(nameResult.inner(), {}, resolve, reject); +// }, +// )) as FileSystemFileEntry; +// const file = await new Promise((resolve, reject) => { +// fileEntry.file(resolve, reject); +// }); +// return result.makeOk(file); +// } catch (e) { +// console.error(e); +// return result.makeErr(FsResultCodes.Fail); +// } +// } +// +// public async writeFile(): Promise> { +// // Entries API does not support writing +// return allocErr(FsResultCodes.NotSupported); +// } +// +// async resolveDir( +// path: FsPath, +// ): Promise> { +// let entry: FileSystemEntry; +// if (path.isRoot) { +// entry = this.rootEntry; +// } else { +// const fullPath = path.path; +// try { +// entry = await new Promise((resolve, reject) => { +// this.rootEntry.getDirectory(fullPath, {}, resolve, reject); +// }); +// } catch (e) { +// console.error(e); +// return allocErr(FsResultCodes.Fail); +// } +// } +// +// if (!entry.isDirectory) { +// return allocErr(FsResultCodes.IsFile); +// } +// return allocOk(entry as FileSystemDirectoryEntry); +// } +// } diff --git a/web-client/src/low/fs/FileSys.ts b/web-client/src/low/fs/FileSys.ts index fa6ad13d..c1410572 100644 --- a/web-client/src/low/fs/FileSys.ts +++ b/web-client/src/low/fs/FileSys.ts @@ -1,53 +1,53 @@ -import { ResultHandle } from "pure/result"; - -import { FsPath } from "./FsPath"; -import { FsResult } from "./FsResult"; - -/// Interface for using the browser's various file system API to access Files -export interface FileSys { - /// Async init function - /// - /// The FileSys implementation may need to do some async initialization. - /// For example, request permission from the user. - init: (r: ResultHandle) => Promise>; - - /// 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. - getRootName: () => string; - - /// List files in a 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: FsPath) => Promise>; - - /// Read the file as a File object - /// - /// Returns Fail if the underlying file system operation fails. - readFile: (r: ResultHandle, path: FsPath) => Promise>; - - /// Returns if this implementation supports writing to a file - isWritable: () => boolean; - - /// Returns if this implementation only keeps a static snapshot of the directory structure - isStale: () => boolean; - - /// 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 - writeFile: ( - r: ResultHandle, - path: FsPath, - content: string | Uint8Array, - ) => Promise>; -} +// import { ResultHandle } from "pure/result"; +// +// import { FsPath } from "./FsPath"; +// import { FsResult } from "./FsResult"; +// +// /// Interface for using the browser's various file system API to access Files +// export interface FileSys { +// /// Async init function +// /// +// /// The FileSys implementation may need to do some async initialization. +// /// For example, request permission from the user. +// init: (r: ResultHandle) => Promise>; +// +// /// 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. +// getRootName: () => string; +// +// /// List files in a 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: FsPath) => Promise>; +// +// /// Read the file as a File object +// /// +// /// Returns Fail if the underlying file system operation fails. +// readFile: (r: ResultHandle, path: FsPath) => Promise>; +// +// /// Returns if this implementation supports writing to a file +// isWritable: () => boolean; +// +// /// Returns if this implementation only keeps a static snapshot of the directory structure +// isStale: () => boolean; +// +// /// 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 +// writeFile: ( +// r: ResultHandle, +// path: FsPath, +// content: string | Uint8Array, +// ) => Promise>; +// } diff --git a/web-client/src/low/fs/FileSystemAccessApiFileSys.ts b/web-client/src/low/fs/FileSystemAccessApiFileSys.ts index 43421c4a..ddc3f6ba 100644 --- a/web-client/src/low/fs/FileSystemAccessApiFileSys.ts +++ b/web-client/src/low/fs/FileSystemAccessApiFileSys.ts @@ -1,207 +1,207 @@ -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"; - -export const isFileSystemAccessApiSupported = (): boolean => { - if (!window) { - return false; - } - if (!window.isSecureContext) { - // In Chrome, you can still access the APIs but they just crash the page entirely - console.warn("FileSystemAccessAPI is only available in secure context"); - return false; - } - if (!window.FileSystemDirectoryHandle) { - return false; - } - - if (!window.FileSystemFileHandle) { - return false; - } - - // @ts-expect-error FileSystemDirectoryHandle should have a values() method - if (!window.FileSystemDirectoryHandle.prototype.values) { - return false; - } - - if (!window.FileSystemFileHandle.prototype.createWritable) { - return false; - } - - // @ts-expect-error window should have showDirectoryPicker - if (!window.showDirectoryPicker) { - return false; - } - - return true; -}; - -type PermissionStatus = "granted" | "denied" | "prompt"; - -/// FileSys implementation that uses FileSystem Access API -/// This is only supported in Chrome/Edge -export class FileSystemAccessApiFileSys implements FileSys { - private rootPath: string; - private rootHandle: FileSystemDirectoryHandle; - private permissionStatus: PermissionStatus; - - constructor(rootPath: string, rootHandle: FileSystemDirectoryHandle) { - this.rootPath = rootPath; - this.rootHandle = rootHandle; - this.permissionStatus = "prompt"; - } - - public async init(): Promise> { - // @ts-expect-error ts lib does not have requestPermission - this.permissionStatus = await this.rootHandle.requestPermission({ - mode: "readwrite", - }); - if (this.permissionStatus !== "granted") { - return allocErr(FsResultCodes.PermissionDenied); - } - return allocOk(); - } - - public isWritable(): boolean { - return ( - isFileSystemAccessApiSupported() && - this.permissionStatus === "granted" - ); - } - - public isStale(): boolean { - return false; - } - - public getRootName() { - return this.rootPath; - } - - public async listDir(path: FsPath): Promise> { - const result = await this.resolveDir(path); - if (result.isErr()) { - return result; - } - const dir = result.inner(); - const entries: string[] = []; - - try { - // @ts-expect-error FileSystemDirectoryHandle should have a values() method - for await (const entry of dir.values()) { - if (entry.kind === "directory") { - entries.push(entry.name + "/"); - } else { - entries.push(entry.name); - } - } - } catch (e) { - console.error(e); - return result.makeErr(FsResultCodes.Fail); - } - - return result.makeOk(entries); - } - - private async resolveDir( - r: ResultHandle, - path: FsPath, - ): Promise> { - if (path.isRoot) { - return r.putOk(this.rootHandle); - } - - r.put(path.getParent(r)); - if (r.isErr()) { - return r.ret(); - } - const parentPath = r.value; - - r.put(await this.resolveDir(r = r.erase(), parentPath)); - if (r.isErr()) { - return r; - } - const parentDirHandle = r.value; - - r.put(path.getName(r = r.erase())); - if (r.isErr()) { - return r.ret(); - } - const pathName = r.value; - - 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 r.ret(); - } - - public async readFile(path: FsPath): Promise> { - const result = await this.resolveFile(path); - if (result.isErr()) { - return result; - } - try { - const file = await result.inner().getFile(); - return result.makeOk(file); - } catch (e) { - console.error(e); - return result.makeErr(FsResultCodes.Fail); - } - } - - public async writeFile( - path: FsPath, - content: string | Uint8Array, - ): Promise> { - const result = await this.resolveFile(path); - - if (result.isErr()) { - return result; - } - try { - const file = await result.inner().createWritable(); - await file.write(content); - await file.close(); - return result.makeOk(undefined); - } catch (e) { - console.error(e); - return result.makeErr(FsResultCodes.Fail); - } - } - - async resolveFile(path: FsPath): Promise> { - const parentDirResult = path.parent; - if (parentDirResult.isErr()) { - return parentDirResult; - } - - const parentDirHandleResult = await this.resolveDir( - parentDirResult.inner(), - ); - if (parentDirHandleResult.isErr()) { - return parentDirHandleResult; - } - - const result = path.name; - if (result.isErr()) { - return result; - } - - try { - const fileHandle = await parentDirHandleResult - .inner() - .getFileHandle(result.inner()); - return result.makeOk(fileHandle); - } catch (e) { - console.error(e); - return result.makeErr(FsResultCodes.Fail); - } - } -} +// 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"; +// +// export const isFileSystemAccessApiSupported = (): boolean => { +// if (!window) { +// return false; +// } +// if (!window.isSecureContext) { +// // In Chrome, you can still access the APIs but they just crash the page entirely +// console.warn("FileSystemAccessAPI is only available in secure context"); +// return false; +// } +// if (!window.FileSystemDirectoryHandle) { +// return false; +// } +// +// if (!window.FileSystemFileHandle) { +// return false; +// } +// +// // @ts-expect-error FileSystemDirectoryHandle should have a values() method +// if (!window.FileSystemDirectoryHandle.prototype.values) { +// return false; +// } +// +// if (!window.FileSystemFileHandle.prototype.createWritable) { +// return false; +// } +// +// // @ts-expect-error window should have showDirectoryPicker +// if (!window.showDirectoryPicker) { +// return false; +// } +// +// return true; +// }; +// +// type PermissionStatus = "granted" | "denied" | "prompt"; +// +// /// FileSys implementation that uses FileSystem Access API +// /// This is only supported in Chrome/Edge +// export class FileSystemAccessApiFileSys implements FileSys { +// private rootPath: string; +// private rootHandle: FileSystemDirectoryHandle; +// private permissionStatus: PermissionStatus; +// +// constructor(rootPath: string, rootHandle: FileSystemDirectoryHandle) { +// this.rootPath = rootPath; +// this.rootHandle = rootHandle; +// this.permissionStatus = "prompt"; +// } +// +// public async init(): Promise> { +// // @ts-expect-error ts lib does not have requestPermission +// this.permissionStatus = await this.rootHandle.requestPermission({ +// mode: "readwrite", +// }); +// if (this.permissionStatus !== "granted") { +// return allocErr(FsResultCodes.PermissionDenied); +// } +// return allocOk(); +// } +// +// public isWritable(): boolean { +// return ( +// isFileSystemAccessApiSupported() && +// this.permissionStatus === "granted" +// ); +// } +// +// public isStale(): boolean { +// return false; +// } +// +// public getRootName() { +// return this.rootPath; +// } +// +// public async listDir(path: FsPath): Promise> { +// const result = await this.resolveDir(path); +// if (result.isErr()) { +// return result; +// } +// const dir = result.inner(); +// const entries: string[] = []; +// +// try { +// // @ts-expect-error FileSystemDirectoryHandle should have a values() method +// for await (const entry of dir.values()) { +// if (entry.kind === "directory") { +// entries.push(entry.name + "/"); +// } else { +// entries.push(entry.name); +// } +// } +// } catch (e) { +// console.error(e); +// return result.makeErr(FsResultCodes.Fail); +// } +// +// return result.makeOk(entries); +// } +// +// private async resolveDir( +// r: ResultHandle, +// path: FsPath, +// ): Promise> { +// if (path.isRoot) { +// return r.putOk(this.rootHandle); +// } +// +// r.put(path.getParent(r)); +// if (r.isErr()) { +// return r.ret(); +// } +// const parentPath = r.value; +// +// r.put(await this.resolveDir(r = r.erase(), parentPath)); +// if (r.isErr()) { +// return r; +// } +// const parentDirHandle = r.value; +// +// r.put(path.getName(r = r.erase())); +// if (r.isErr()) { +// return r.ret(); +// } +// const pathName = r.value; +// +// 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 r.ret(); +// } +// +// public async readFile(path: FsPath): Promise> { +// const result = await this.resolveFile(path); +// if (result.isErr()) { +// return result; +// } +// try { +// const file = await result.inner().getFile(); +// return result.makeOk(file); +// } catch (e) { +// console.error(e); +// return result.makeErr(FsResultCodes.Fail); +// } +// } +// +// public async writeFile( +// path: FsPath, +// content: string | Uint8Array, +// ): Promise> { +// const result = await this.resolveFile(path); +// +// if (result.isErr()) { +// return result; +// } +// try { +// const file = await result.inner().createWritable(); +// await file.write(content); +// await file.close(); +// return result.makeOk(undefined); +// } catch (e) { +// console.error(e); +// return result.makeErr(FsResultCodes.Fail); +// } +// } +// +// async resolveFile(path: FsPath): Promise> { +// const parentDirResult = path.parent; +// if (parentDirResult.isErr()) { +// return parentDirResult; +// } +// +// const parentDirHandleResult = await this.resolveDir( +// parentDirResult.inner(), +// ); +// if (parentDirHandleResult.isErr()) { +// return parentDirHandleResult; +// } +// +// const result = path.name; +// if (result.isErr()) { +// return result; +// } +// +// try { +// const fileHandle = await parentDirHandleResult +// .inner() +// .getFileHandle(result.inner()); +// return result.makeOk(fileHandle); +// } catch (e) { +// console.error(e); +// return result.makeErr(FsResultCodes.Fail); +// } +// } +// } diff --git a/web-client/src/low/fs/FsFile.ts b/web-client/src/low/fs/FsFile.ts index 9705c3d4..1bc7a842 100644 --- a/web-client/src/low/fs/FsFile.ts +++ b/web-client/src/low/fs/FsFile.ts @@ -1,190 +1,190 @@ -import { console } from "low/utils"; - -import { FsPath } from "./FsPath"; -import { FileSys } from "./FileSys"; -import { FsResult, FsResultCodes } from "./FsResult"; - -/// A wrapper for the concept of a virtual, opened file. -/// -/// The file is lazy-loaded. It's content will only be loaded when getContent is called. -export class FsFile { - /// Reference to the file system so we can read/write - private fs: FileSys; - /// The path of the file - private path: FsPath; - /// 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: FileSys, path: FsPath) { - 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 getDisplayPath(): string { - return this.path.path; - } - - public isNewerThanFs(): boolean { - return this.isBufferDirty || this.isContentNewer; - } - - /// Get the last modified time. May load it from file system if needed - /// - /// If fails to load, returns 0 - public async getLastModified(): Promise { - if (this.lastModified === undefined) { - await this.loadIfNotDirty(); - } - return this.lastModified ?? 0; - } - - /// 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 - public async getText(): Promise> { - if (this.buffer === undefined) { - const result = await this.load(); - if (result.isErr()) { - return result; - } - } - if (!this.isText) { - return allocErr(FsResultCodes.InvalidEncoding); - } - return allocOk(this.content ?? ""); - } - - public async getBytes(): Promise> { - this.updateBuffer(); - if (this.buffer === undefined) { - const result = await this.load(); - if (result.isErr()) { - return result; - } - } - if (this.buffer === undefined) { - return allocErr(FsResultCodes.Fail); - } - return allocOk(this.buffer); - } - - /// Set the content in memory. Does not save to FS. - public setContent(content: string): void { - if (this.content === content) { - return; - } - this.content = content; - this.isContentNewer = true; - this.lastModified = new Date().getTime(); - } - - /// Load the file's content if it's not newer than fs - /// - /// Returns Ok if the file is newer than fs - public async loadIfNotDirty(): Promise> { - if (this.isNewerThanFs()) { - return allocOk(); - } - return await this.load(); - } - - /// Load the file's content from FS. - /// - /// Overwrites any unsaved changes only if the file has been - /// modified since it was last loaded. - /// - /// If it fails, the file's content will not be changed - public async load(): Promise> { - const result = await this.fs.readFile(this.path); - - if (result.isErr()) { - return result; - } - - const file = result.inner(); - // check if the file has been modified since last loaded - if (this.lastModified !== undefined) { - if (file.lastModified <= this.lastModified) { - return result.makeOk(undefined); - } - } - this.lastModified = file.lastModified; - // load the buffer - try { - this.buffer = new Uint8Array(await file.arrayBuffer()); - } catch (e) { - console.error(e); - return result.makeErr(FsResultCodes.Fail); - } - this.isBufferDirty = false; - // Try decoding the buffer as text - try { - this.content = new TextDecoder("utf-8", { fatal: true }).decode( - this.buffer, - ); - this.isText = true; - } catch (_) { - this.content = undefined; - this.isText = false; - } - this.isContentNewer = false; - return result.makeOk(undefined); - } - - /// Save the file's content to FS if it is newer. - /// - /// If not dirty, returns Ok - public async writeIfNewer(): Promise> { - if (!this.isNewerThanFs()) { - return allocOk(); - } - return await this.write(); - } - - /// 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(): Promise> { - this.updateBuffer(); - const buffer = this.buffer; - if (this.content === undefined || buffer === undefined) { - // file was never read or modified - return allocOk(); - } - const result = await this.fs.writeFile(this.path, buffer); - if (result.isErr()) { - return result; - } - this.isBufferDirty = false; - return result.makeOk(undefined); - } - - /// 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; - } -} +// import { console } from "low/utils"; +// +// import { FsPath } from "./FsPath"; +// import { FileSys } from "./FileSys"; +// import { FsResult, FsResultCodes } from "./FsResult"; +// +// /// A wrapper for the concept of a virtual, opened file. +// /// +// /// The file is lazy-loaded. It's content will only be loaded when getContent is called. +// export class FsFile { +// /// Reference to the file system so we can read/write +// private fs: FileSys; +// /// The path of the file +// private path: FsPath; +// /// 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: FileSys, path: FsPath) { +// 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 getDisplayPath(): string { +// return this.path.path; +// } +// +// public isNewerThanFs(): boolean { +// return this.isBufferDirty || this.isContentNewer; +// } +// +// /// Get the last modified time. May load it from file system if needed +// /// +// /// If fails to load, returns 0 +// public async getLastModified(): Promise { +// if (this.lastModified === undefined) { +// await this.loadIfNotDirty(); +// } +// return this.lastModified ?? 0; +// } +// +// /// 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 +// public async getText(): Promise> { +// if (this.buffer === undefined) { +// const result = await this.load(); +// if (result.isErr()) { +// return result; +// } +// } +// if (!this.isText) { +// return allocErr(FsResultCodes.InvalidEncoding); +// } +// return allocOk(this.content ?? ""); +// } +// +// public async getBytes(): Promise> { +// this.updateBuffer(); +// if (this.buffer === undefined) { +// const result = await this.load(); +// if (result.isErr()) { +// return result; +// } +// } +// if (this.buffer === undefined) { +// return allocErr(FsResultCodes.Fail); +// } +// return allocOk(this.buffer); +// } +// +// /// Set the content in memory. Does not save to FS. +// public setContent(content: string): void { +// if (this.content === content) { +// return; +// } +// this.content = content; +// this.isContentNewer = true; +// this.lastModified = new Date().getTime(); +// } +// +// /// Load the file's content if it's not newer than fs +// /// +// /// Returns Ok if the file is newer than fs +// public async loadIfNotDirty(): Promise> { +// if (this.isNewerThanFs()) { +// return allocOk(); +// } +// return await this.load(); +// } +// +// /// Load the file's content from FS. +// /// +// /// Overwrites any unsaved changes only if the file has been +// /// modified since it was last loaded. +// /// +// /// If it fails, the file's content will not be changed +// public async load(): Promise> { +// const result = await this.fs.readFile(this.path); +// +// if (result.isErr()) { +// return result; +// } +// +// const file = result.inner(); +// // check if the file has been modified since last loaded +// if (this.lastModified !== undefined) { +// if (file.lastModified <= this.lastModified) { +// return result.makeOk(undefined); +// } +// } +// this.lastModified = file.lastModified; +// // load the buffer +// try { +// this.buffer = new Uint8Array(await file.arrayBuffer()); +// } catch (e) { +// console.error(e); +// return result.makeErr(FsResultCodes.Fail); +// } +// this.isBufferDirty = false; +// // Try decoding the buffer as text +// try { +// this.content = new TextDecoder("utf-8", { fatal: true }).decode( +// this.buffer, +// ); +// this.isText = true; +// } catch (_) { +// this.content = undefined; +// this.isText = false; +// } +// this.isContentNewer = false; +// return result.makeOk(undefined); +// } +// +// /// Save the file's content to FS if it is newer. +// /// +// /// If not dirty, returns Ok +// public async writeIfNewer(): Promise> { +// if (!this.isNewerThanFs()) { +// return allocOk(); +// } +// return await this.write(); +// } +// +// /// 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(): Promise> { +// this.updateBuffer(); +// const buffer = this.buffer; +// if (this.content === undefined || buffer === undefined) { +// // file was never read or modified +// return allocOk(); +// } +// const result = await this.fs.writeFile(this.path, buffer); +// if (result.isErr()) { +// return result; +// } +// this.isBufferDirty = false; +// return result.makeOk(undefined); +// } +// +// /// 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/web-client/src/low/fs/FsPath.ts b/web-client/src/low/fs/FsPath.ts index 4f7120c5..1a7289ff 100644 --- a/web-client/src/low/fs/FsPath.ts +++ b/web-client/src/low/fs/FsPath.ts @@ -1,119 +1,119 @@ -import { ResultHandle } from "pure/result"; - -import { FsResult, FsResultCodes } from "./FsResult"; - -/// File system path -/// -/// This is an abstraction on path to a file/directory in a FileSys, -/// so that we can have consistency between path representation. -/// It always represents an absolute path, relative to the root directory. -/// -/// FsPath is immutable. Operations return new FsPath objects. -export interface FsPath { - /// Returns if this path is the root directory. - readonly isRoot: boolean; - - /// Get the parent directory of this path. - /// - /// For files, return the directory it is in. - /// For directories, return the parent directory. - /// - /// If this path is the root directory, return IsRoot. - /// - /// This does not check if the path exists. - getParent(r: ResultHandle): FsResult; - - /// Get the name of this path. - /// - /// Returns the last component of the path. - /// Does not include leading or trailing slashes. - // - /// - /// Examples: - /// "/foo/bar" -> "bar" - /// "/foo/bar/" -> "bar" - /// "/" -> IsRoot - getName(r: ResultHandle): FsResult; - - /// Get the full path as string representation. - /// - /// This does not come with a leading slash. - /// Returns an empty string for the root directory. - readonly path: string; - - /// Resolve a descendant path. - resolve(path: string): FsPath; - - /// Resolve a sibling path. - /// - /// Returns IsRoot if this is the root directory. - resolveSibling(r: ResultHandle, path: string): FsResult; -} - -class FsPathImpl implements FsPath { - /// Underlying path - /// - /// This is the full path, with no the leading or trailing slash. - /// For root, this is an empty string. - private underlying: string; - - constructor(path: string) { - this.underlying = path; - } - - get isRoot(): boolean { - return this.underlying === ""; - } - - getParent(r: ResultHandle): FsResult { - if (this.underlying === "") { - return r.putErr(FsResultCodes.IsRoot); - } - - const i = this.underlying.lastIndexOf("/"); - if (i < 0) { - return r.putOk(fsRootPath); - } - return r.putOk(new FsPathImpl(this.underlying.substring(0, i))); - } - - getName(r: ResultHandle): FsResult { - if (this.underlying === "") { - return r.putErr(FsResultCodes.IsRoot); - } - - const i = this.underlying.lastIndexOf("/"); - if (i < 0) { - return r.putOk(this.underlying); - } - return r.putOk(this.underlying.substring(i + 1)); - } - - get path(): string { - return this.underlying; - } - - public resolve(path: string): FsPath { - if (path === "") { - return this; - } - if (this.underlying === "") { - return new FsPathImpl(cleanPath(path)); - } - 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)); - // } -} - -const cleanPath = (path: string) => { - return path.replace(/^\/+|\/+$/g, ""); -}; - -export const fsRootPath: FsPath = new FsPathImpl(""); +// import { ResultHandle } from "pure/result"; +// +// import { FsResult, FsResultCodes } from "./FsResult"; +// +// /// File system path +// /// +// /// This is an abstraction on path to a file/directory in a FileSys, +// /// so that we can have consistency between path representation. +// /// It always represents an absolute path, relative to the root directory. +// /// +// /// FsPath is immutable. Operations return new FsPath objects. +// export interface FsPath { +// /// Returns if this path is the root directory. +// readonly isRoot: boolean; +// +// /// Get the parent directory of this path. +// /// +// /// For files, return the directory it is in. +// /// For directories, return the parent directory. +// /// +// /// If this path is the root directory, return IsRoot. +// /// +// /// This does not check if the path exists. +// getParent(r: ResultHandle): FsResult; +// +// /// Get the name of this path. +// /// +// /// Returns the last component of the path. +// /// Does not include leading or trailing slashes. +// // +// /// +// /// Examples: +// /// "/foo/bar" -> "bar" +// /// "/foo/bar/" -> "bar" +// /// "/" -> IsRoot +// getName(r: ResultHandle): FsResult; +// +// /// Get the full path as string representation. +// /// +// /// This does not come with a leading slash. +// /// Returns an empty string for the root directory. +// readonly path: string; +// +// /// Resolve a descendant path. +// resolve(path: string): FsPath; +// +// /// Resolve a sibling path. +// /// +// /// Returns IsRoot if this is the root directory. +// resolveSibling(r: ResultHandle, path: string): FsResult; +// } +// +// class FsPathImpl implements FsPath { +// /// Underlying path +// /// +// /// This is the full path, with no the leading or trailing slash. +// /// For root, this is an empty string. +// private underlying: string; +// +// constructor(path: string) { +// this.underlying = path; +// } +// +// get isRoot(): boolean { +// return this.underlying === ""; +// } +// +// getParent(r: ResultHandle): FsResult { +// if (this.underlying === "") { +// return r.putErr(FsResultCodes.IsRoot); +// } +// +// const i = this.underlying.lastIndexOf("/"); +// if (i < 0) { +// return r.putOk(fsRootPath); +// } +// return r.putOk(new FsPathImpl(this.underlying.substring(0, i))); +// } +// +// getName(r: ResultHandle): FsResult { +// if (this.underlying === "") { +// return r.putErr(FsResultCodes.IsRoot); +// } +// +// const i = this.underlying.lastIndexOf("/"); +// if (i < 0) { +// return r.putOk(this.underlying); +// } +// return r.putOk(this.underlying.substring(i + 1)); +// } +// +// get path(): string { +// return this.underlying; +// } +// +// public resolve(path: string): FsPath { +// if (path === "") { +// return this; +// } +// if (this.underlying === "") { +// return new FsPathImpl(cleanPath(path)); +// } +// 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)); +// // } +// } +// +// const cleanPath = (path: string) => { +// return path.replace(/^\/+|\/+$/g, ""); +// }; +// +// export const fsRootPath: FsPath = new FsPathImpl(""); diff --git a/web-client/src/low/fs/FsResult.ts b/web-client/src/low/fs/FsResult.ts index 077acfc0..312246d9 100644 --- a/web-client/src/low/fs/FsResult.ts +++ b/web-client/src/low/fs/FsResult.ts @@ -1,28 +1,28 @@ -import { Result, StableResult } from "pure/result"; - -/// Result type for file system operations -export const FsResultCodes = { - /// 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, -} as const; - -export type FsResultCode = (typeof FsResultCodes)[keyof typeof FsResultCodes]; - -export type FsResult = Result; -export type FsStableResult = StableResult; +// import { Result, StableResult } from "pure/result"; +// +// /// Result type for file system operations +// export const FsResultCodes = { +// /// 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, +// } as const; +// +// export type FsResultCode = (typeof FsResultCodes)[keyof typeof FsResultCodes]; +// +// export type FsResult = Result; +// export type FsStableResult = StableResult; diff --git a/web-client/src/low/fs/open.ts b/web-client/src/low/fs/open.ts index b1bad147..f4c8ee89 100644 --- a/web-client/src/low/fs/open.ts +++ b/web-client/src/low/fs/open.ts @@ -1,146 +1,146 @@ -//! Utils for opening FileSys - -import { ResultHandle } from "pure/result"; - -import { console } from "low/utils"; - -import { - FileEntriesApiFileSys, - isFileEntriesApiSupported, -} from "./FileEntriesApiFileSys"; -import { FileSys } from "./FileSys"; -import { - FileSystemAccessApiFileSys, - isFileSystemAccessApiSupported, -} from "./FileSystemAccessApiFileSys"; -import { FsResult, FsResultCodes } from "./FsResult"; -import { FileApiFileSys } from "./FileApiFileSys"; - -export async function showDirectoryPicker(r: ResultHandle): Promise> { - if (isFileSystemAccessApiSupported()) { - try { - // @ts-expect-error showDirectoryPicker is not in the TS lib - const handle = await window.showDirectoryPicker({ - mode: "readwrite", - }); - if (!handle) { - console.error("Failed to get handle from showDirectoryPicker"); - return allocErr(FsResultCodes.Fail); - } - return createFsFromFileSystemHandle(handle); - } catch (e) { - console.error(e); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - if (e && (e as any).name === "AbortError") { - return allocErr(FsResultCodes.UserAbort); - } - return allocErr(FsResultCodes.Fail); - } - } - // 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; - return await new Promise((resolve) => { - inputElement.addEventListener("change", (event) => { - const files = (event.target as HTMLInputElement).files; - if (!files) { - resolve(allocErr(FsResultCodes.Fail)); - return; - } - resolve(createFsFromFileList(files)); - }); - inputElement.click(); - }); -} - -function - -export const createFsFromDataTransferItem = async ( - item: DataTransferItem, -): Promise> => { - // Prefer File System Access API since it supports writing - if ("getAsFileSystemHandle" in item) { - if (isFileSystemAccessApiSupported()) { - try { - // @ts-expect-error getAsFileSystemHandle is not in the TS lib - const handle = await item.getAsFileSystemHandle(); - if (!handle) { - console.error("Failed to get handle from DataTransferItem"); - return allocErr(FsResultCodes.Fail); - } - return createFsFromFileSystemHandle(handle); - } catch (e) { - console.error(e); - } - } - } - console.warn( - "Failed to create FileSys with FileSystemAccessAPI. Trying FileEntriesAPI", - ); - if ("webkitGetAsEntry" in item) { - if (isFileEntriesApiSupported()) { - try { - const entry = item.webkitGetAsEntry(); - if (!entry) { - console.error("Failed to get entry from DataTransferItem"); - return allocErr(FsResultCodes.Fail); - } - return createFsFromFileSystemEntry(entry); - } catch (e) { - console.error(e); - } - } - } - console.warn( - "Failed to create FileSys with FileEntriesAPI. Editor is not supported", - ); - return allocErr(FsResultCodes.NotSupported); -}; - -const createFsFromFileSystemHandle = ( - handle: FileSystemHandle, -): FsResult => { - if (handle.kind !== "directory") { - return allocErr(FsResultCodes.IsFile); - } - - const fs = new FileSystemAccessApiFileSys( - handle.name, - handle as FileSystemDirectoryHandle, - ); - - return allocOk(fs); -}; - -const createFsFromFileSystemEntry = ( - entry: FileSystemEntry, -): FsResult => { - if (entry.isFile || !entry.isDirectory) { - return allocErr(FsResultCodes.IsFile); - } - const fs = new FileEntriesApiFileSys( - entry.name, - entry as FileSystemDirectoryEntry, - ); - return allocOk(fs); -}; - -const createFsFromFileList = (files: FileList): FsResult => { - if (!files.length) { - return allocErr(FsResultCodes.Fail); - } - 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 allocOk(fs); -}; +// //! Utils for opening FileSys +// +// import { ResultHandle } from "pure/result"; +// +// import { console } from "low/utils"; +// +// import { +// FileEntriesApiFileSys, +// isFileEntriesApiSupported, +// } from "./FileEntriesApiFileSys"; +// import { FileSys } from "./FileSys"; +// import { +// FileSystemAccessApiFileSys, +// isFileSystemAccessApiSupported, +// } from "./FileSystemAccessApiFileSys"; +// import { FsResult, FsResultCodes } from "./FsResult"; +// import { FileApiFileSys } from "./FileApiFileSys"; +// +// export async function showDirectoryPicker(r: ResultHandle): Promise> { +// if (isFileSystemAccessApiSupported()) { +// try { +// // @ts-expect-error showDirectoryPicker is not in the TS lib +// const handle = await window.showDirectoryPicker({ +// mode: "readwrite", +// }); +// if (!handle) { +// console.error("Failed to get handle from showDirectoryPicker"); +// return allocErr(FsResultCodes.Fail); +// } +// return createFsFromFileSystemHandle(handle); +// } catch (e) { +// console.error(e); +// // eslint-disable-next-line @typescript-eslint/no-explicit-any +// if (e && (e as any).name === "AbortError") { +// return allocErr(FsResultCodes.UserAbort); +// } +// return allocErr(FsResultCodes.Fail); +// } +// } +// // 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; +// return await new Promise((resolve) => { +// inputElement.addEventListener("change", (event) => { +// const files = (event.target as HTMLInputElement).files; +// if (!files) { +// resolve(allocErr(FsResultCodes.Fail)); +// return; +// } +// resolve(createFsFromFileList(files)); +// }); +// inputElement.click(); +// }); +// } +// +// function +// +// export const createFsFromDataTransferItem = async ( +// item: DataTransferItem, +// ): Promise> => { +// // Prefer File System Access API since it supports writing +// if ("getAsFileSystemHandle" in item) { +// if (isFileSystemAccessApiSupported()) { +// try { +// // @ts-expect-error getAsFileSystemHandle is not in the TS lib +// const handle = await item.getAsFileSystemHandle(); +// if (!handle) { +// console.error("Failed to get handle from DataTransferItem"); +// return allocErr(FsResultCodes.Fail); +// } +// return createFsFromFileSystemHandle(handle); +// } catch (e) { +// console.error(e); +// } +// } +// } +// console.warn( +// "Failed to create FileSys with FileSystemAccessAPI. Trying FileEntriesAPI", +// ); +// if ("webkitGetAsEntry" in item) { +// if (isFileEntriesApiSupported()) { +// try { +// const entry = item.webkitGetAsEntry(); +// if (!entry) { +// console.error("Failed to get entry from DataTransferItem"); +// return allocErr(FsResultCodes.Fail); +// } +// return createFsFromFileSystemEntry(entry); +// } catch (e) { +// console.error(e); +// } +// } +// } +// console.warn( +// "Failed to create FileSys with FileEntriesAPI. Editor is not supported", +// ); +// return allocErr(FsResultCodes.NotSupported); +// }; +// +// const createFsFromFileSystemHandle = ( +// handle: FileSystemHandle, +// ): FsResult => { +// if (handle.kind !== "directory") { +// return allocErr(FsResultCodes.IsFile); +// } +// +// const fs = new FileSystemAccessApiFileSys( +// handle.name, +// handle as FileSystemDirectoryHandle, +// ); +// +// return allocOk(fs); +// }; +// +// const createFsFromFileSystemEntry = ( +// entry: FileSystemEntry, +// ): FsResult => { +// if (entry.isFile || !entry.isDirectory) { +// return allocErr(FsResultCodes.IsFile); +// } +// const fs = new FileEntriesApiFileSys( +// entry.name, +// entry as FileSystemDirectoryEntry, +// ); +// return allocOk(fs); +// }; +// +// const createFsFromFileList = (files: FileList): FsResult => { +// if (!files.length) { +// return allocErr(FsResultCodes.Fail); +// } +// 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 allocOk(fs); +// }; diff --git a/web-client/src/low/utils/Alert.ts b/web-client/src/low/utils/Alert.ts index 229bd39c..fbba4c01 100644 --- a/web-client/src/low/utils/Alert.ts +++ b/web-client/src/low/utils/Alert.ts @@ -1,4 +1,4 @@ -import { Result, ResultHandle } from "pure/result"; +import { Result } from "pure/result"; export type AlertExtraAction = { id: string; @@ -75,7 +75,6 @@ export interface AlertMgr { /// /// If f throws, the alert will be cleared, and Err(e) will be returned. showBlocking( - r: ResultHandle, options: BlockingAlertOptions, fn: () => Promise ): Promise>; diff --git a/web-client/src/low/utils/FileSaver/FileSaver.js b/web-client/src/low/utils/FileSaver/FileSaver.js deleted file mode 100644 index 9e154227..00000000 --- a/web-client/src/low/utils/FileSaver/FileSaver.js +++ /dev/null @@ -1,225 +0,0 @@ -/* eslint-disable */ -/* - * FileSaver.js - * A saveAs() FileSaver implementation. - * - * By Eli Grey, http://eligrey.com - * - * License : https://github.com/eligrey/FileSaver.js/blob/master/LICENSE.md (MIT) - * source : http://purl.eligrey.com/github/FileSaver.js - */ - -// The one and only way of getting global scope in all environments -// https://stackoverflow.com/q/3277182/1008999 -var _global = - typeof window === "object" && window.window === window - ? window - : typeof window.self === "object" && window.self.self === window.self - ? window.self - : typeof window.global === "object" && - window.global.global === window.global - ? window.global - : this; - -function bom(blob, opts) { - if (typeof opts === "undefined") { - opts = { autoBom: false }; - } else if (typeof opts !== "object") { - window.console.error( - "Deprecated: Expected third argument to be a object", - ); - opts = { autoBom: !opts }; - } - - // prepend BOM for UTF-8 XML and text/* types (including HTML) - // note: your browser will automatically convert UTF-16 U+FEFF to EF BB BF - if ( - opts.autoBom && - /^\s*(?:text\/\S*|application\/xml|\S*\/\S*\+xml)\s*;.*charset\s*=\s*utf-8/i.test( - blob.type, - ) - ) { - return new Blob([String.fromCharCode(0xfeff), blob], { - type: blob.type, - }); - } - return blob; -} - -function download(url, name, opts) { - var xhr = new XMLHttpRequest(); - xhr.open("GET", url); - xhr.responseType = "blob"; - xhr.onload = function () { - saveAs(xhr.response, name, opts); - }; - xhr.onerror = function () { - window.console.error("could not download file"); - }; - xhr.send(); -} - -function corsEnabled(url) { - var xhr = new XMLHttpRequest(); - // use sync to avoid popup blocker - xhr.open("HEAD", url, false); - try { - xhr.send(); - } catch (e) {} - return xhr.status >= 200 && xhr.status <= 299; -} - -// `a.click()` doesn't work for all browsers (#465) -function click(node) { - try { - node.dispatchEvent(new MouseEvent("click")); - } catch (e) { - var evt = document.createEvent("MouseEvents"); - evt.initMouseEvent( - "click", - true, - true, - window, - 0, - 0, - 0, - 80, - 20, - false, - false, - false, - false, - 0, - null, - ); - node.dispatchEvent(evt); - } -} - -// Detect WebView inside a native macOS app by ruling out all browsers -// We just need to check for 'Safari' because all other browsers (besides Firefox) include that too -// https://www.whatismybrowser.com/guides/the-latest-user-agent/macos -var isMacOSWebView = - _global.navigator && - /Macintosh/.test(navigator.userAgent) && - /AppleWebKit/.test(navigator.userAgent) && - !/Safari/.test(navigator.userAgent); - -var saveAs = - _global.saveAs || - // probably in some web worker - (typeof window !== "object" || window !== _global - ? function saveAs() { - /* noop */ - } - : // Use download attribute first if possible (#193 Lumia mobile) unless this is a macOS WebView - "download" in HTMLAnchorElement.prototype && !isMacOSWebView - ? function saveAs(blob, name, opts) { - var URL = _global.URL || _global.webkitURL; - var a = document.createElement("a"); - name = name || blob.name || "download"; - - a.download = name; - a.rel = "noopener"; // tabnabbing - - // TODO: detect chrome extensions & packaged apps - // a.target = '_blank' - - if (typeof blob === "string") { - // Support regular links - a.href = blob; - if (a.origin !== window.location.origin) { - corsEnabled(a.href) - ? download(blob, name, opts) - : click(a, (a.target = "_blank")); - } else { - click(a); - } - } else { - // Support blobs - a.href = URL.createObjectURL(blob); - setTimeout(function () { - URL.revokeObjectURL(a.href); - }, 4e4); // 40s - setTimeout(function () { - click(a); - }, 0); - } - } - : // Use msSaveOrOpenBlob as a second approach - "msSaveOrOpenBlob" in navigator - ? function saveAs(blob, name, opts) { - name = name || blob.name || "download"; - - if (typeof blob === "string") { - if (corsEnabled(blob)) { - download(blob, name, opts); - } else { - var a = document.createElement("a"); - a.href = blob; - a.target = "_blank"; - setTimeout(function () { - click(a); - }); - } - } else { - navigator.msSaveOrOpenBlob(bom(blob, opts), name); - } - } - : // Fallback to using FileReader and a popup - function saveAs(blob, name, opts, popup) { - // Open a popup immediately do go around popup blocker - // Mostly only available on user interaction and the fileReader is async so... - popup = popup || window.open("", "_blank"); - if (popup) { - popup.document.title = popup.document.body.innerText = - "downloading..."; - } - - if (typeof blob === "string") { - return download(blob, name, opts); - } - - var force = blob.type === "application/octet-stream"; - var isSafari = - /constructor/i.test(_global.HTMLElement) || _global.safari; - var isChromeIOS = /CriOS\/[\d]+/.test(navigator.userAgent); - - if ( - (isChromeIOS || (force && isSafari) || isMacOSWebView) && - typeof FileReader !== "undefined" - ) { - // Safari doesn't allow downloading of blob URLs - var reader = new FileReader(); - reader.onloadend = function () { - var url = reader.result; - url = isChromeIOS - ? url - : url.replace( - /^data:[^;]*;/, - "data:attachment/file;", - ); - if (popup) { - popup.location.href = url; - } else { - window.location = url; - } - popup = null; // reverse-tabnabbing #460 - }; - reader.readAsDataURL(blob); - } else { - var URL = _global.URL || _global.webkitURL; - var url = URL.createObjectURL(blob); - if (popup) { - popup.location = url; - } else { - window.location.href = url; - } - popup = null; // reverse-tabnabbing #460 - setTimeout(function () { - URL.revokeObjectURL(url); - }, 4e4); // 40s - } - }); - -export default saveAs; diff --git a/web-client/src/low/utils/FileSaver/index.ts b/web-client/src/low/utils/FileSaver/index.ts deleted file mode 100644 index 9e73173a..00000000 --- a/web-client/src/low/utils/FileSaver/index.ts +++ /dev/null @@ -1,13 +0,0 @@ -// @ts-expect-error no types for this library -import FileSaverFunction from "./FileSaver"; - -export const saveAs = ( - content: string | Uint8Array, - filename: string, -): void => { - const blob = new Blob([content], { - // maybe lying, but should be fine - type: "text/plain;charset=utf-8", - }); - FileSaverFunction(blob, filename); -}; diff --git a/web-client/src/low/utils/IdleMgr.ts b/web-client/src/low/utils/IdleMgr.ts index 68cd260d..e297476c 100644 --- a/web-client/src/low/utils/IdleMgr.ts +++ b/web-client/src/low/utils/IdleMgr.ts @@ -35,6 +35,9 @@ export class IdleMgr { /// Like a semaphore. Will only fire events if this is 0 private pauseCount: number; + /// If an idle cycle is executing + private isExecuting: boolean; + constructor( longIdleTime: number, initialInterval: number, @@ -53,6 +56,7 @@ export class IdleMgr { this.currentInterval = this.initialInterval; this.eventsFiredInCurrentInterval = 0; this.idleDuration = 0; + this.isExecuting = false; } /// Start the idle manager. Events will only fire after calling this @@ -94,7 +98,13 @@ export class IdleMgr { private restartIdleTimer() { this.cancelPendingIdle(); - this.handle = window.setTimeout(() => { + if (this.isExecuting) { + // when execute finish, it will + // restart the idle timer. + return; + } + this.handle = window.setTimeout(async () => { + this.isExecuting = true; this.handle = undefined; if (this.pauseCount > 0) { return; @@ -112,14 +122,17 @@ export class IdleMgr { } // update duration this.idleDuration += this.currentInterval; - this.callback( - this.idleDuration >= this.longIdleTime, - this.idleDuration, - ) - .catch(console.error) - .finally(() => { - this.restartIdleTimer(); - }); + try { + await this.callback( + this.idleDuration >= this.longIdleTime, + this.idleDuration, + ) + } catch (e) { + console.error(e); + } finally { + this.isExecuting = false; + this.restartIdleTimer(); + } }, this.currentInterval); } diff --git a/web-client/src/low/utils/ReentrantLock.ts b/web-client/src/low/utils/ReentrantLock.ts index fb7016ae..6579cc06 100644 --- a/web-client/src/low/utils/ReentrantLock.ts +++ b/web-client/src/low/utils/ReentrantLock.ts @@ -1,4 +1,3 @@ -import { console } from "./Logger"; /// A lock to prevent concurrent async operations /// /// The lock uses a token to prevent deadlocks. It will not block @@ -22,12 +21,11 @@ export class ReentrantLock { ): Promise { if (this.lockingToken !== undefined && token !== this.lockingToken) { if (token !== undefined) { - console.error( + window.console.error( `invalid lock token passed to ${this.name} lock!`, ); } // someone else is holding the lock, wait for it to be released - console.info(`waiting for ${this.name} lock...`); await new Promise((resolve) => { if (this.lockingToken === undefined) { resolve(undefined); @@ -37,7 +35,7 @@ export class ReentrantLock { } if (this.lockingToken === undefined) { if (token !== undefined) { - console.error( + window.console.error( `invalid lock token passed to ${this.name} lock!`, ); } diff --git a/web-client/src/low/utils/WorkerHost.ts b/web-client/src/low/utils/WorkerHost.ts index 440179f1..d1e93a80 100644 --- a/web-client/src/low/utils/WorkerHost.ts +++ b/web-client/src/low/utils/WorkerHost.ts @@ -1,4 +1,6 @@ -import { Logger, console } from "./Logger"; +import { Logger } from "pure/log"; + +import { consoleCompiler as console } from "low/utils"; let worker: Worker; /* eslint-disable @typescript-eslint/no-explicit-any */ diff --git a/web-client/src/low/utils/index.ts b/web-client/src/low/utils/index.ts index 150d8182..ec340e53 100644 --- a/web-client/src/low/utils/index.ts +++ b/web-client/src/low/utils/index.ts @@ -6,11 +6,10 @@ export * from "./Alert"; export * from "./IdleMgr"; export * from "./Debouncer"; export * from "./html"; -export * from "./Logger"; -export * from "./FileSaver"; export * from "./ReentrantLock"; export * from "./WorkerHost"; export * from "./Yielder"; +export * from "./logging"; export const shallowArrayEqual = (a: T[], b: T[]): boolean => { if (a.length !== b.length) { @@ -23,3 +22,4 @@ export const shallowArrayEqual = (a: T[], b: T[]): boolean => { } return true; }; + diff --git a/web-client/src/low/utils/logging.ts b/web-client/src/low/utils/logging.ts new file mode 100644 index 00000000..7179c7a2 --- /dev/null +++ b/web-client/src/low/utils/logging.ts @@ -0,0 +1,29 @@ +import { fsSave } from "pure/fs"; +import { Logger, getLogLines } from "pure/log"; + +export const console = new Logger("log"); +export const consoleKernel = new Logger("krn"); +export const consoleDoc = new Logger("doc"); +export const consoleMap = new Logger("map"); +export const consoleEditor = new Logger("edt"); +export const consoleCompiler = new Logger("com"); + +/// Save the current log to a file +export const saveLog = () => { + const result = + confirm(`You are about to download the client-side application log to a file. + +Celer does not automatically collect any user data. However, the client-side log may contain sensitive information such as the name of the files loaded in the application. + +Please make sure sensitive information are removed before sharing it with developers or others for diagonistics. + +Do you want to continue? + +`); + if (!result) { + return; + } + const log = getLogLines().join("\n"); + const filename = `celera_${new Date().toISOString()}.log`; + fsSave(log, filename); +}; diff --git a/web-client/src/ui/editor/EditorDropZone.tsx b/web-client/src/ui/editor/EditorDropZone.tsx index a96ea808..5d0fdcda 100644 --- a/web-client/src/ui/editor/EditorDropZone.tsx +++ b/web-client/src/ui/editor/EditorDropZone.tsx @@ -1,8 +1,11 @@ import { useState } from "react"; import { Body2, mergeClasses } from "@fluentui/react-components"; -import { createFsFromDataTransferItem } from "low/fs"; +import { fsOpenReadWriteFrom } from "pure/fs"; + import { useKernel } from "core/kernel"; +import { createRetryOpenHandler } from "core/editor"; + import { useEditorStyles } from "./styles"; /// Shown when no project is loaded, for user to drag and drop a folder in @@ -38,10 +41,11 @@ export const EditorDropZone: React.FC = () => { e.preventDefault(); setIsDragging(false); setIsOpening(true); + const alertMgr = kernel.getAlertMgr(); const item = e.dataTransfer?.items[0]; if (!item) { - await kernel.getAlertMgr().show({ + await alertMgr.show({ title: "Error", message: "Cannot open the project. Make sure you are dropping the correct folder and try again.", @@ -49,8 +53,17 @@ export const EditorDropZone: React.FC = () => { }); return; } - const fileSysResult = await createFsFromDataTransferItem(item); - await kernel.handleOpenFileSysResult(fileSysResult); + + // create the retry handle to show error messages, + // and ask user if they want to retry + const retryHandler = createRetryOpenHandler(alertMgr); + const fs = await fsOpenReadWriteFrom(item, retryHandler); + if (fs.err) { + // ignore the error, because it has been alerted to the user + return; + } + + await kernel.openProjectFileSystem(fs.val); setIsOpening(false); }} > diff --git a/web-client/src/ui/toolbar/OpenCloseProject.tsx b/web-client/src/ui/toolbar/OpenCloseProject.tsx index 35316610..a24b138b 100644 --- a/web-client/src/ui/toolbar/OpenCloseProject.tsx +++ b/web-client/src/ui/toolbar/OpenCloseProject.tsx @@ -9,7 +9,8 @@ import { forwardRef, useCallback } from "react"; import { useSelector } from "react-redux"; import { MenuItem, ToolbarButton, Tooltip } from "@fluentui/react-components"; import { Dismiss20Regular, FolderOpen20Regular } from "@fluentui/react-icons"; -import { tryInvokeAsync } from "pure/result"; + +import { fsOpenReadWrite } from "pure/fs"; import { useKernel } from "core/kernel"; import { viewSelector } from "core/store"; @@ -59,11 +60,18 @@ const useOpenCloseProjectControl = () => { } } - await kernel.closeFileSys(); + await kernel.closeProjectFileSystem(); } else { - const { showDirectoryPicker } = await import("low/fs"); - const result = await tryInvokeAsync(showDirectoryPicker); - await kernel.handleOpenFileSysResult(result); + // open + // only import editor when needed, since + // header controls are initialized in view mode as well + const { createRetryOpenHandler } = await import("core/editor"); + const retryHandler = createRetryOpenHandler(kernel.getAlertMgr()); + const fs = await fsOpenReadWrite(retryHandler); + if (fs.err) { + return; + } + await kernel.openProjectFileSystem(fs.val); } }, [kernel, rootPath]); diff --git a/web-client/tools/lint/non-logger-console.cjs b/web-client/tools/lint/non-logger-console.cjs index 6992f4f0..c48e202c 100644 --- a/web-client/tools/lint/non-logger-console.cjs +++ b/web-client/tools/lint/non-logger-console.cjs @@ -52,7 +52,7 @@ function isImportConsoleFromLowUtils(include, line) { function getLowUtilsImportLocationFrom(file) { if (!file.replace(/^src\/low\/utils\//, "").includes("/")) { - return "./Logger"; + return "./logging"; } return file.replace(/^src\/low\//, "").includes("/") ? "low/utils" From 6c2b783f84ff52f25ac306c4c5b224792d19d41b Mon Sep 17 00:00:00 2001 From: Pistonight Date: Sun, 25 Feb 2024 22:16:09 -0800 Subject: [PATCH 06/10] fixed all typescript errors --- libs/pure/src/fs/path.ts | 3 + web-client/Taskfile.yml | 2 +- .../src/core/compiler/CompilerKernelImpl.ts | 59 +++--- web-client/src/core/compiler/index.ts | 8 +- web-client/src/core/compiler/initCompiler.ts | 4 +- web-client/src/core/compiler/utils.ts | 3 - web-client/src/core/doc/export.ts | 11 +- web-client/src/core/doc/loader.ts | 13 +- .../core/doc/useDocCurrentUserPluginConfig.ts | 5 +- .../src/core/editor/ExternalEditorKernel.ts | 27 ++- web-client/src/core/editor/FileMgr.ts | 193 +++++++++--------- web-client/src/core/editor/WebEditorKernel.ts | 7 +- web-client/src/core/editor/index.ts | 5 +- web-client/src/core/editor/openHandler.ts | 4 +- web-client/src/core/kernel/AlertMgr.ts | 12 +- web-client/src/core/kernel/Kernel.ts | 7 +- web-client/src/core/kernel/index.ts | 1 - web-client/src/core/stage/state.ts | 2 + web-client/src/core/stage/viewReducers.ts | 9 +- web-client/src/low/fs/open.ts | 2 +- web-client/src/low/utils/Alert.ts | 10 +- web-client/src/low/utils/IdleMgr.ts | 4 +- web-client/src/low/utils/WorkerHost.ts | 6 +- web-client/src/low/utils/index.ts | 1 - web-client/src/ui/app/AppAlert.tsx | 2 +- web-client/src/ui/doc/Doc.tsx | 4 +- web-client/src/ui/doc/DocController.ts | 31 ++- web-client/src/ui/doc/updateBannerWidths.ts | 5 +- web-client/src/ui/doc/updateDocTagsStyle.ts | 5 +- web-client/src/ui/doc/updateNotePositions.ts | 12 +- web-client/src/ui/doc/utils.ts | 4 +- web-client/src/ui/editor/EditorDropZone.tsx | 2 +- web-client/src/ui/editor/EditorTree.tsx | 150 +++++++++----- web-client/src/ui/editor/TreeItem.tsx | 15 +- web-client/src/ui/map/IconMarker.ts | 8 +- web-client/src/ui/map/MapContainerMgr.ts | 10 +- web-client/src/ui/map/MapLayerMgr.ts | 5 +- web-client/src/ui/map/MapState.ts | 12 +- web-client/src/ui/map/MapVisualMgr.ts | 12 +- web-client/src/ui/map/utils.ts | 26 --- web-client/src/ui/toolbar/Export.tsx | 41 ++-- web-client/src/ui/toolbar/SaveProject.tsx | 10 +- web-client/src/ui/toolbar/SyncProject.tsx | 21 +- .../src/ui/toolbar/getHeaderControls.ts | 2 +- .../ui/toolbar/settings/EditorSettings.tsx | 12 +- web-client/tools/lint/non-logger-console.cjs | 14 +- web-client/tsconfig.json | 2 +- 47 files changed, 423 insertions(+), 380 deletions(-) delete mode 100644 web-client/src/core/compiler/utils.ts diff --git a/libs/pure/src/fs/path.ts b/libs/pure/src/fs/path.ts index 8a957fd2..5fc97ac0 100644 --- a/libs/pure/src/fs/path.ts +++ b/libs/pure/src/fs/path.ts @@ -82,6 +82,9 @@ export function fsNormalize(p: string): FsResult { /// Join two paths export function fsJoin(p1: string, p2: string): string { + if (fsIsRoot(p1)) { + return p2; + } return p1 + "/" + p2; } diff --git a/web-client/Taskfile.yml b/web-client/Taskfile.yml index c451a964..c5d3266a 100644 --- a/web-client/Taskfile.yml +++ b/web-client/Taskfile.yml @@ -17,7 +17,6 @@ tasks: build: desc: Build web client assets cmds: - - npx tsc - npx vite build - node tools/post-build.cjs - rm dist/index.html @@ -27,6 +26,7 @@ tasks: aliases: [lint] cmds: - node tools/lint/run-custom-lints.cjs + - npx tsc - task: eslint vars: ESLINT_ARGS: "" diff --git a/web-client/src/core/compiler/CompilerKernelImpl.ts b/web-client/src/core/compiler/CompilerKernelImpl.ts index f7ce2aa5..712e8415 100644 --- a/web-client/src/core/compiler/CompilerKernelImpl.ts +++ b/web-client/src/core/compiler/CompilerKernelImpl.ts @@ -26,10 +26,10 @@ import { registerWorkerHandler, sleep, ReentrantLock, + consoleCompiler as console, } from "low/utils"; import { CompilerKernel } from "./CompilerKernel"; -import { CompilerLog } from "./utils"; import { CompilerFileAccess } from "./CompilerFileAccess"; async function checkFileExists( @@ -75,13 +75,13 @@ export class CompilerKernelImpl implements CompilerKernel { } public delete() { - CompilerLog.info("deleting compiler"); + console.info("deleting compiler"); this.uninit(); this.cleanup(); } public uninit() { - CompilerLog.info("uninitializing compiler..."); + console.info("uninitializing compiler..."); this.fileAccess = undefined; this.store.dispatch(viewActions.setCompilerReady(false)); this.store.dispatch(viewActions.setCompileInProgress(false)); @@ -90,7 +90,7 @@ export class CompilerKernelImpl implements CompilerKernel { public async init(fileAccess: CompilerFileAccess) { this.store.dispatch(viewActions.setCompilerReady(false)); - CompilerLog.info("initializing compiler worker..."); + console.info("initializing compiler worker..."); this.fileAccess = fileAccess; this.lastPluginOptions = undefined; const worker = new Worker("/celerc/worker.js"); @@ -104,8 +104,8 @@ export class CompilerKernelImpl implements CompilerKernel { path, { code: FsErr.Fail, - message: "file access not available" - } satisfies FsError + message: "file access not available", + } satisfies FsError, ]); return; } @@ -121,22 +121,17 @@ export class CompilerKernelImpl implements CompilerKernel { } return; } - worker.postMessage([ - "file", - 0, - path, - [true, bytes.val], - ]); + worker.postMessage(["file", 0, path, [true, bytes.val]]); }, ); - await setWorker(worker, CompilerLog); + await setWorker(worker, console); this.store.dispatch(viewActions.setCompilerReady(true)); } public async getEntryPoints(): Promise> { if (!(await this.ensureReady())) { - CompilerLog.error("worker not ready after max waiting"); + console.error("worker not ready after max waiting"); return { val: [] }; } if (!this.fileAccess) { @@ -155,12 +150,12 @@ export class CompilerKernelImpl implements CompilerKernel { // setting the needCompile flag to ensure this request is handled eventually this.needCompile = true; if (!this.fileAccess) { - CompilerLog.warn("file access not available, skipping compile"); + console.warn("file access not available, skipping compile"); return; } if (!(await this.ensureReady())) { - CompilerLog.warn( + console.warn( "worker not ready after max waiting, skipping compile", ); return; @@ -168,7 +163,7 @@ export class CompilerKernelImpl implements CompilerKernel { const validatedEntryPath = await this.validateEntryPath(); if (validatedEntryPath.err) { - CompilerLog.warn("entry path is invalid, skipping compile"); + console.warn("entry path is invalid, skipping compile"); return; } @@ -181,7 +176,7 @@ export class CompilerKernelImpl implements CompilerKernel { // check if another compilation is running // this is safe because there's no await between checking and setting (no other code can run) if (this.compiling) { - CompilerLog.warn("compilation already in progress, skipping"); + console.warn("compilation already in progress, skipping"); return; } this.compiling = true; @@ -195,7 +190,7 @@ export class CompilerKernelImpl implements CompilerKernel { await this.updatePluginOptions(); - CompilerLog.info("invoking compiler..."); + console.info("invoking compiler..."); const result = await tryAsync(() => { return compile_document( validatedEntryPath.val, @@ -205,7 +200,7 @@ export class CompilerKernelImpl implements CompilerKernel { // yielding just in case other things need to update await sleep(0); if ("err" in result) { - CompilerLog.error(result.err); + console.error(result.err); } else { const doc = result.val; if (this.fileAccess && doc !== undefined) { @@ -215,7 +210,7 @@ export class CompilerKernelImpl implements CompilerKernel { } this.store.dispatch(viewActions.setCompileInProgress(false)); this.compiling = false; - CompilerLog.info("finished compiling"); + console.info("finished compiling"); }); } @@ -255,7 +250,7 @@ export class CompilerKernelImpl implements CompilerKernel { }); if ("err" in result) { - CompilerLog.error(result.err); + console.error(result.err); return { error: errstr(result.err) }; } return result.val; @@ -273,7 +268,7 @@ export class CompilerKernelImpl implements CompilerKernel { if (viewSelector(this.store.getState()).compilerReady) { return true; } - CompilerLog.info("worker not ready, waiting..."); + console.info("worker not ready, waiting..."); await sleep(INTERVAL); acc += INTERVAL; } @@ -297,9 +292,7 @@ export class CompilerKernelImpl implements CompilerKernel { ? compilerEntryPath.substring(1) : compilerEntryPath; if (!(await checkFileExists(this.fileAccess, filePath))) { - CompilerLog.warn( - "entry path is invalid, attempting correction...", - ); + console.warn("entry path is invalid, attempting correction..."); } const newEntryPath = await this.correctEntryPath(compilerEntryPath); @@ -307,7 +300,7 @@ export class CompilerKernelImpl implements CompilerKernel { // update asynchronously to avoid infinite blocking loop // updating the entry path will trigger another compile await sleep(0); - CompilerLog.info(`set entry path to ${newEntryPath}`); + console.info(`set entry path to ${newEntryPath}`); this.store.dispatch( settingsActions.setCompilerEntryPath(newEntryPath), ); @@ -377,15 +370,17 @@ export class CompilerKernelImpl implements CompilerKernel { const pluginOptions = getRawPluginOptions(this.store.getState()); if (pluginOptions !== this.lastPluginOptions) { this.lastPluginOptions = pluginOptions; - CompilerLog.info("updating plugin options..."); - const result = await tryAsync(() => set_plugin_options(pluginOptions)); + console.info("updating plugin options..."); + const result = await tryAsync(() => + set_plugin_options(pluginOptions), + ); if ("err" in result) { - CompilerLog.error(result.err); - CompilerLog.warn( + console.error(result.err); + console.warn( "failed to set plugin options. The output may be wrong.", ); } else { - CompilerLog.info("plugin options updated"); + console.info("plugin options updated"); } } } diff --git a/web-client/src/core/compiler/index.ts b/web-client/src/core/compiler/index.ts index adcfc0f3..65ac77d1 100644 --- a/web-client/src/core/compiler/index.ts +++ b/web-client/src/core/compiler/index.ts @@ -1,7 +1,7 @@ -import { CompilerLog } from "./utils"; +import { consoleCompiler as console } from "low/utils"; -CompilerLog.info("loading compiler module"); +console.info("loading compiler module"); -export * from "./CompilerFileAccess"; -export * from "./CompilerKernel"; +export type * from "./CompilerFileAccess"; +export type * from "./CompilerKernel"; export * from "./initCompiler"; diff --git a/web-client/src/core/compiler/initCompiler.ts b/web-client/src/core/compiler/initCompiler.ts index 64a5f457..c7e77c74 100644 --- a/web-client/src/core/compiler/initCompiler.ts +++ b/web-client/src/core/compiler/initCompiler.ts @@ -1,8 +1,8 @@ import { AppStore } from "core/store"; +import { consoleCompiler as console } from "low/utils"; import { CompilerKernel } from "./CompilerKernel"; import { CompilerKernelImpl } from "./CompilerKernelImpl"; -import { CompilerLog } from "./utils"; declare global { interface Window { @@ -14,7 +14,7 @@ export const initCompiler = (store: AppStore): CompilerKernel => { if (window.__theCompilerKernel) { window.__theCompilerKernel.delete(); } - CompilerLog.info("creating compiler"); + console.info("creating compiler"); const compiler = new CompilerKernelImpl(store); window.__theCompilerKernel = compiler; return compiler; diff --git a/web-client/src/core/compiler/utils.ts b/web-client/src/core/compiler/utils.ts deleted file mode 100644 index 76c1c91e..00000000 --- a/web-client/src/core/compiler/utils.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { Logger } from "low/utils"; - -export const CompilerLog = new Logger("com"); diff --git a/web-client/src/core/doc/export.ts b/web-client/src/core/doc/export.ts index db3e4eb3..72734f14 100644 --- a/web-client/src/core/doc/export.ts +++ b/web-client/src/core/doc/export.ts @@ -1,7 +1,8 @@ import YAML from "js-yaml"; +import { Result, tryCatch } from "pure/result"; + import { ExportMetadata, ExportRequest } from "low/celerc"; -import { Result, allocErr, allocOk } from "low/utils"; import { DocSettingsState } from "./state"; @@ -44,17 +45,15 @@ export function createExportRequest( metadata: ExportMetadata, config: string, ): Result { - try { + return tryCatch(() => { const configPayload = YAML.load(config); const request: ExportRequest = { pluginId: metadata.pluginId, exportId: metadata.exportId || "", payload: configPayload, }; - return allocOk(request); - } catch (e) { - return allocErr(e); - } + return request; + }); } /// Get the plugin configs when the "Export Split" option is enabled diff --git a/web-client/src/core/doc/loader.ts b/web-client/src/core/doc/loader.ts index 14af1566..8c633e1e 100644 --- a/web-client/src/core/doc/loader.ts +++ b/web-client/src/core/doc/loader.ts @@ -2,7 +2,8 @@ import type { ExpoContext } from "low/celerc"; import { fetchAsJson, getApiUrl } from "low/fetch"; -import { console, wrapAsync } from "low/utils"; +import { consoleDoc as console } from "low/utils"; +import { tryAsync } from "pure/result"; export type LoadDocumentResult = | { @@ -90,16 +91,18 @@ export async function loadDocument( if (path) { url += `/${path}`; } - const result = await wrapAsync(async () => + const result = await tryAsync(() => fetchAsJson(getApiUrl(url)), ); - if (result.isErr()) { + if ("err" in result) { + const err = result.err; + console.error(err); return createLoadError( "There was an error loading the document from the server.", undefined, ); } - const response = result.inner(); + const response = result.val; const elapsed = Math.round(performance.now() - startTime); console.info(`received resposne in ${elapsed}ms`); if (response.type === "success") { @@ -110,7 +113,7 @@ export async function loadDocument( } } - return result.inner(); + return response; } function injectLoadTime(doc: ExpoContext, ms: number) { diff --git a/web-client/src/core/doc/useDocCurrentUserPluginConfig.ts b/web-client/src/core/doc/useDocCurrentUserPluginConfig.ts index e974dd60..ae90f0e1 100644 --- a/web-client/src/core/doc/useDocCurrentUserPluginConfig.ts +++ b/web-client/src/core/doc/useDocCurrentUserPluginConfig.ts @@ -2,9 +2,10 @@ import { useMemo } from "react"; import { useSelector } from "react-redux"; import YAML from "js-yaml"; +import { errstr } from "pure/utils"; + import { documentSelector, settingsSelector } from "core/store"; import { ExecDoc, Value } from "low/celerc"; -import { errorToString } from "low/utils"; type UserPluginOptionsResult = [Value[], undefined] | [undefined, string]; @@ -62,6 +63,6 @@ export const parseUserConfigOptions = ( } return [options, undefined]; } catch (e) { - return [undefined, errorToString(e)]; + return [undefined, errstr(e)]; } }; diff --git a/web-client/src/core/editor/ExternalEditorKernel.ts b/web-client/src/core/editor/ExternalEditorKernel.ts index 8c18bbf3..4f813f58 100644 --- a/web-client/src/core/editor/ExternalEditorKernel.ts +++ b/web-client/src/core/editor/ExternalEditorKernel.ts @@ -3,7 +3,12 @@ import { FsFileSystem, FsResult, fsJoin, fsRoot } from "pure/fs"; import { CompilerFileAccess } from "core/compiler"; -import { IdleMgr, Yielder, createYielder, consoleEditor as console } from "low/utils"; +import { + IdleMgr, + Yielder, + createYielder, + consoleEditor as console, +} from "low/utils"; import { EditorKernel } from "./EditorKernel"; import { EditorKernelAccess } from "./EditorKernelAccess"; @@ -11,12 +16,15 @@ import { ModifyTimeTracker } from "./ModifyTimeTracker"; console.info("loading external editor kernel"); -export const initExternalEditor = (kernel: EditorKernelAccess, fs: FsFileSystem): EditorKernel => { +export const initExternalEditor = ( + kernel: EditorKernelAccess, + fs: FsFileSystem, +): EditorKernel => { console.info("creating external editor"); return new ExternalEditorKernel(kernel, fs); }; -class ExternalEditorKernel implements EditorKernel, CompilerFileAccess{ +class ExternalEditorKernel implements EditorKernel, CompilerFileAccess { private deleted = false; private idleMgr: IdleMgr; private fs: FsFileSystem; @@ -111,7 +119,8 @@ class ExternalEditorKernel implements EditorKernel, CompilerFileAccess{ ): Promise> { const fsFile = this.fs.getFile(path); if (checkChanged) { - const notModified = await this.modifyTracker.checkModifiedSinceLastAccess(fsFile); + const notModified = + await this.modifyTracker.checkModifiedSinceLastAccess(fsFile); if (notModified.err) { return notModified; } @@ -121,14 +130,16 @@ class ExternalEditorKernel implements EditorKernel, CompilerFileAccess{ } // === Stub implementations === - public async listDir(): Promise { return []; } - public async openFile(): Promise { } + public async listDir(): Promise { + return []; + } + public async openFile(): Promise {} public async hasUnsavedChanges(): Promise { return false; } public hasUnsavedChangesSync(): boolean { return false; } - public async loadFromFs(): Promise { } - public async saveToFs(): Promise { } + public async loadFromFs(): Promise {} + public async saveToFs(): Promise {} } diff --git a/web-client/src/core/editor/FileMgr.ts b/web-client/src/core/editor/FileMgr.ts index 7ae7326a..6f770f6c 100644 --- a/web-client/src/core/editor/FileMgr.ts +++ b/web-client/src/core/editor/FileMgr.ts @@ -80,8 +80,8 @@ export class FileMgr implements CompilerFileAccess { public delete() { // this.fsLock.lockedScope(undefined, async (token) => { - this.closeEditor(); - this.monacoEditor.dispose(); + this.closeEditor(); + this.monacoEditor.dispose(); // }); } @@ -99,7 +99,10 @@ export class FileMgr implements CompilerFileAccess { }); } - private async listDirWithFs(fs: FsFileSystem, path: string): Promise { + private async listDirWithFs( + fs: FsFileSystem, + path: string, + ): Promise { const { val: entries, err } = await fs.listDir(path); if (err) { const { code, message } = err; @@ -109,14 +112,17 @@ export class FileMgr implements CompilerFileAccess { return entries; } - public openFile(path: string,): Promise { + public openFile(path: string): Promise { console.info(`opening ${path}`); return this.fs.scopedWrite(async (fs) => { return this.openFileWithFs(fs, path); }); } - private async openFileWithFs(fs: FsFileSystem, path: string): Promise { + private async openFileWithFs( + fs: FsFileSystem, + path: string, + ): Promise { const fsFile = fs.getFile(path); const { val, err } = await fsFile.getText(); if (err) { @@ -136,31 +142,19 @@ export class FileMgr implements CompilerFileAccess { // ensure editor changes is synced first, // so the current file is marked dirty if needed this.syncEditorToCurrentFile(); - const success = await this.fs.scopedWrite((fs) => { + let success = await this.fs.scopedWrite((fs) => { return this.loadFromFsWithFs(fs); }); - // const success = await this.fsLock.lockedScope( - // lockToken, - // async (token) => { - // let success = true; - // const _yield = createYielder(64); - // for (const id in this.files) { - // const fsFile = this.files[id]; - // const result = await this.loadChangesFromFsForFsFile( - // id, - // fsFile, - // token, - // ); - // if (result.isErr()) { - // success = false; - // } - // await _yield(); - // } - // return success; - // }, - // ); + if (!success) { + // failure could be due to project structure change. try again + console.warn("sync failed, retrying..."); + success = await this.fs.scopedWrite((fs) => { + return this.loadFromFsWithFs(fs); + }); + } window.clearTimeout(handle); this.dispatcher.dispatch(viewActions.endFileSysLoad(success)); + this.dispatcher.dispatch(viewActions.incFileSysSerial()); console.info("sync completed"); // return success ? allocOk() : allocErr(FsResultCodes.Fail); } @@ -176,19 +170,22 @@ export class FileMgr implements CompilerFileAccess { await this.fsYield(); } return success; - // const fsFile = this.files[id]; - // const result = await this.loadChangesFromFsForFsFile( - // id, - // fsFile, - // token, - // ); - // if (result.isErr()) { - // success = false; - // } - // await _yield(); + // const fsFile = this.files[id]; + // const result = await this.loadChangesFromFsForFsFile( + // id, + // fsFile, + // token, + // ); + // if (result.isErr()) { + // success = false; + // } + // await _yield(); } - private async loadFromFsForPath(fs: FsFileSystem, path: string): Promise { + private async loadFromFsForPath( + fs: FsFileSystem, + path: string, + ): Promise { const fsFile = fs.getFile(path); // return await this.fsLock.lockedScope(lockToken, async (token) => { const isCurrentFile = this.currentFile === fsFile; @@ -229,39 +226,39 @@ export class FileMgr implements CompilerFileAccess { } return loadError === undefined; - // - // let result = await fsFile.loadIfNotDirty(); - // - // if (result.isOk()) { - // if (isCurrentFile) { - // const contentResult = await fsFile.getText(); - // if (contentResult.isOk()) { - // content = contentResult.inner(); - // } else { - // result = contentResult; - // } - // } - // } - // if (result.isErr()) { - // EditorLog.error(`sync failed with code ${result}`); - // if (!fsFile.isNewerThanFs()) { - // EditorLog.info(`closing ${idPath}`); - // if (isCurrentFile) { - // await this.updateEditor( - // undefined, - // undefined, - // undefined, - // token, - // ); - // } - // delete this.files[idPath]; - // } - // } else { - // if (isCurrentFile) { - // await this.updateEditor(fsFile, idPath, content, token); - // } - // } - // return result; + // + // let result = await fsFile.loadIfNotDirty(); + // + // if (result.isOk()) { + // if (isCurrentFile) { + // const contentResult = await fsFile.getText(); + // if (contentResult.isOk()) { + // content = contentResult.inner(); + // } else { + // result = contentResult; + // } + // } + // } + // if (result.isErr()) { + // EditorLog.error(`sync failed with code ${result}`); + // if (!fsFile.isNewerThanFs()) { + // EditorLog.info(`closing ${idPath}`); + // if (isCurrentFile) { + // await this.updateEditor( + // undefined, + // undefined, + // undefined, + // token, + // ); + // } + // delete this.files[idPath]; + // } + // } else { + // if (isCurrentFile) { + // await this.updateEditor(fsFile, idPath, content, token); + // } + // } + // return result; // }); } @@ -322,22 +319,22 @@ export class FileMgr implements CompilerFileAccess { const success = await this.fs.scopedWrite((fs) => { return this.saveToFsWithFs(fs); - // - // let success = true; - // const _yield = createYielder(64); - // for (const id in this.files) { - // const fsFile = this.files[id]; - // const result = await this.saveChangesToFsForFsFile( - // id, - // fsFile, - // token, - // ); - // if (result.isErr()) { - // success = false; - // } - // await _yield(); - // } - // return success; + // + // let success = true; + // const _yield = createYielder(64); + // for (const id in this.files) { + // const fsFile = this.files[id]; + // const result = await this.saveChangesToFsForFsFile( + // id, + // fsFile, + // token, + // ); + // if (result.isErr()) { + // success = false; + // } + // await _yield(); + // } + // return success; }); window.clearTimeout(handle); @@ -358,7 +355,10 @@ export class FileMgr implements CompilerFileAccess { return success; } - private async saveToFsForPath(fs: FsFileSystem, path: string): Promise { + private async saveToFsForPath( + fs: FsFileSystem, + path: string, + ): Promise { // return await this.fsLock.lockedScope(lockToken, async () => { const { err } = await fs.getFile(path).writeIfNewer(); if (err) { @@ -367,11 +367,11 @@ export class FileMgr implements CompilerFileAccess { return false; } return true; - // - // const result = await fsFile.writeIfNewer(); - // if (result.isErr()) { - // } - // return result; + // + // const result = await fsFile.writeIfNewer(); + // if (result.isErr()) { + // } + // return result; // }); } @@ -539,10 +539,15 @@ export class FileMgr implements CompilerFileAccess { }); } - private async getFileContentWithFs(fs: FsFileSystem, path: string, checkChanged: boolean): Promise> { + private async getFileContentWithFs( + fs: FsFileSystem, + path: string, + checkChanged: boolean, + ): Promise> { const fsFile = fs.getFile(path); if (checkChanged) { - const notModified = await this.modifyTracker.checkModifiedSinceLastAccess(fsFile); + const notModified = + await this.modifyTracker.checkModifiedSinceLastAccess(fsFile); if (notModified.err) { return notModified; } diff --git a/web-client/src/core/editor/WebEditorKernel.ts b/web-client/src/core/editor/WebEditorKernel.ts index 178136dd..bb4aa10c 100644 --- a/web-client/src/core/editor/WebEditorKernel.ts +++ b/web-client/src/core/editor/WebEditorKernel.ts @@ -18,7 +18,12 @@ import { SettingsState, } from "core/store"; import { CompilerFileAccess } from "core/compiler"; -import { isInDarkMode, IdleMgr, DOMId, consoleEditor as console } from "low/utils"; +import { + isInDarkMode, + IdleMgr, + DOMId, + consoleEditor as console, +} from "low/utils"; import { FileMgr } from "./FileMgr"; diff --git a/web-client/src/core/editor/index.ts b/web-client/src/core/editor/index.ts index 8afb7ed2..5aebf123 100644 --- a/web-client/src/core/editor/index.ts +++ b/web-client/src/core/editor/index.ts @@ -4,7 +4,8 @@ import { consoleEditor as console } from "low/utils"; console.info("loading editor module"); -export * from "./EditorKernelAccess"; -export * from "./EditorKernel"; +export type * from "./EditorKernelAccess"; +export type * from "./EditorKernel"; export * from "./initEditor"; export * from "./openHandler"; +export * from "./dom"; diff --git a/web-client/src/core/editor/openHandler.ts b/web-client/src/core/editor/openHandler.ts index d7e860a5..b4e1a5e9 100644 --- a/web-client/src/core/editor/openHandler.ts +++ b/web-client/src/core/editor/openHandler.ts @@ -10,7 +10,7 @@ export function createRetryOpenHandler(alertMgr: AlertMgr) { const retry = await alertMgr.show({ title: "Permission Denied", message: - "You must given file system access permission to the app to use this feature. Please try again and grant the permission when prompted.", + "You must given file system access permission to the app to use this feature. Please try again and grant the permission when prompted.", okButton: "Grant Permission", cancelButton: "Cancel", }); @@ -37,7 +37,7 @@ export function createRetryOpenHandler(alertMgr: AlertMgr) { await alertMgr.show({ title: "Error", message: - "You opened a file. Make sure you are opening the project folder and not individual files.", + "You opened a file. Make sure you are opening the project folder and not individual files.", okButton: "Close", }); return { val: false }; diff --git a/web-client/src/core/kernel/AlertMgr.ts b/web-client/src/core/kernel/AlertMgr.ts index 35ff283c..208c0b68 100644 --- a/web-client/src/core/kernel/AlertMgr.ts +++ b/web-client/src/core/kernel/AlertMgr.ts @@ -3,7 +3,16 @@ import { Result, tryAsync } from "pure/result"; import { AppDispatcher, viewActions } from "core/store"; -import { AlertExtraAction, AlertIds, AlertMgr, AlertOptions, BlockingAlertOptions, ModifyAlertActionPayload, RichAlertOptions, console } from "low/utils"; +import { + AlertExtraAction, + AlertIds, + AlertMgr, + AlertOptions, + BlockingAlertOptions, + ModifyAlertActionPayload, + RichAlertOptions, + console, +} from "low/utils"; type AlertCallback = (ok: boolean | string) => void; @@ -136,5 +145,4 @@ export class AlertMgrImpl implements AlertMgr { cb(); }, ALERT_TIMEOUT); } - } diff --git a/web-client/src/core/kernel/Kernel.ts b/web-client/src/core/kernel/Kernel.ts index dc4bee2e..6fa01ace 100644 --- a/web-client/src/core/kernel/Kernel.ts +++ b/web-client/src/core/kernel/Kernel.ts @@ -24,7 +24,12 @@ import { import type { CompilerKernel } from "core/compiler"; import type { EditorKernel, EditorKernelAccess } from "core/editor"; import { ExpoDoc, ExportRequest } from "low/celerc"; -import { consoleKernel as console, isInDarkMode, sleep, AlertMgr } from "low/utils"; +import { + consoleKernel as console, + isInDarkMode, + sleep, + AlertMgr, +} from "low/utils"; import { KeyMgr } from "./KeyMgr"; import { WindowMgr } from "./WindowMgr"; diff --git a/web-client/src/core/kernel/index.ts b/web-client/src/core/kernel/index.ts index dc34bc3a..e8a8086b 100644 --- a/web-client/src/core/kernel/index.ts +++ b/web-client/src/core/kernel/index.ts @@ -6,4 +6,3 @@ export * from "./Kernel"; export * from "./AlertMgr"; export * from "./context"; -export type { EditorKernel } from "./editor"; diff --git a/web-client/src/core/stage/state.ts b/web-client/src/core/stage/state.ts index 533900c2..6c454380 100644 --- a/web-client/src/core/stage/state.ts +++ b/web-client/src/core/stage/state.ts @@ -1,5 +1,7 @@ //! Stage state slice +import { AlertExtraAction } from "low/utils"; + /// State type for stage view export type StageViewState = { stageMode: StageMode; diff --git a/web-client/src/core/stage/viewReducers.ts b/web-client/src/core/stage/viewReducers.ts index 38cab86a..db54325b 100644 --- a/web-client/src/core/stage/viewReducers.ts +++ b/web-client/src/core/stage/viewReducers.ts @@ -1,14 +1,9 @@ //! Reducers for stage view state import { ReducerDecl, withPayload } from "low/store"; +import { AlertExtraAction, ModifyAlertActionPayload } from "low/utils"; -import { - AlertExtraAction, - ModifyAlertActionPayload, - SettingsTab, - StageMode, - StageViewState, -} from "./state"; +import { SettingsTab, StageMode, StageViewState } from "./state"; export const setStageMode = withPayload( (state, mode) => { diff --git a/web-client/src/low/fs/open.ts b/web-client/src/low/fs/open.ts index f4c8ee89..f2d41f1c 100644 --- a/web-client/src/low/fs/open.ts +++ b/web-client/src/low/fs/open.ts @@ -57,7 +57,7 @@ // }); // } // -// function +// function // // export const createFsFromDataTransferItem = async ( // item: DataTransferItem, diff --git a/web-client/src/low/utils/Alert.ts b/web-client/src/low/utils/Alert.ts index fbba4c01..1341f6f9 100644 --- a/web-client/src/low/utils/Alert.ts +++ b/web-client/src/low/utils/Alert.ts @@ -59,13 +59,13 @@ export interface AlertMgr { /// clicked ok and false if the user clicked cancel. /// /// If there are extra options, it may resolve to the id of the extra action - show( - options: AlertOptions + show( + options: AlertOptions, ): Promise>; /// Like `show`, but with a custom react component for the body - showRich( - options: RichAlertOptions + showRich( + options: RichAlertOptions, ): Promise>; /// Show a blocking alert and run f @@ -76,7 +76,7 @@ export interface AlertMgr { /// If f throws, the alert will be cleared, and Err(e) will be returned. showBlocking( options: BlockingAlertOptions, - fn: () => Promise + fn: () => Promise, ): Promise>; /// Modify the current alert's actions diff --git a/web-client/src/low/utils/IdleMgr.ts b/web-client/src/low/utils/IdleMgr.ts index e297476c..5f53535c 100644 --- a/web-client/src/low/utils/IdleMgr.ts +++ b/web-client/src/low/utils/IdleMgr.ts @@ -1,3 +1,5 @@ +import { consoleEditor as console } from "./logging"; + /// Callback to execute an idle event export type IdleFunction = (isLong: boolean, duration: number) => Promise; @@ -126,7 +128,7 @@ export class IdleMgr { await this.callback( this.idleDuration >= this.longIdleTime, this.idleDuration, - ) + ); } catch (e) { console.error(e); } finally { diff --git a/web-client/src/low/utils/WorkerHost.ts b/web-client/src/low/utils/WorkerHost.ts index d1e93a80..1d725db1 100644 --- a/web-client/src/low/utils/WorkerHost.ts +++ b/web-client/src/low/utils/WorkerHost.ts @@ -1,7 +1,5 @@ import { Logger } from "pure/log"; -import { consoleCompiler as console } from "low/utils"; - let worker: Worker; /* eslint-disable @typescript-eslint/no-explicit-any */ const specialHandlers: { [key: string]: (data: any) => any } = {}; @@ -42,7 +40,7 @@ export function setWorker(w: Worker, logger: Logger) { // Event handler const handler = workerHandlers[handleId]; if (!handler) { - console.warn( + logger.warn( `no worker handler for handleId=${handleId}. This could possibly be due to a previous panic from the worker.`, ); return; @@ -58,7 +56,7 @@ export function setWorker(w: Worker, logger: Logger) { } }; worker.onerror = (e) => { - console.error(e); + logger.error(e); // we don't know where the error comes from, so we reject all handlers const handlers = workerHandlers; workerHandlers = {}; diff --git a/web-client/src/low/utils/index.ts b/web-client/src/low/utils/index.ts index ec340e53..c49c2db9 100644 --- a/web-client/src/low/utils/index.ts +++ b/web-client/src/low/utils/index.ts @@ -22,4 +22,3 @@ export const shallowArrayEqual = (a: T[], b: T[]): boolean => { } return true; }; - diff --git a/web-client/src/ui/app/AppAlert.tsx b/web-client/src/ui/app/AppAlert.tsx index 66a57fcb..9a2e2a52 100644 --- a/web-client/src/ui/app/AppAlert.tsx +++ b/web-client/src/ui/app/AppAlert.tsx @@ -29,7 +29,7 @@ export const AppAlert: React.FC = () => { } = useSelector(viewSelector); const alertMgr = useKernel().getAlertMgr(); const responseRef = useRef(false); - const RichAlertComponent = alertMgr.getRichComponent(); + const { RichAlertComponent } = alertMgr; if (!alertText && !RichAlertComponent) { return null; } diff --git a/web-client/src/ui/doc/Doc.tsx b/web-client/src/ui/doc/Doc.tsx index c7f9dee9..536c3bbd 100644 --- a/web-client/src/ui/doc/Doc.tsx +++ b/web-client/src/ui/doc/Doc.tsx @@ -10,10 +10,10 @@ import { settingsSelector, viewSelector, } from "core/store"; +import { consoleDoc as console } from "low/utils"; import { DocRootProps, DocRoot } from "./components"; import { initDocController } from "./DocController"; -import { DocLog } from "./utils"; export const Doc: React.FC = () => { const { stageMode, isEditingLayout, compileInProgress } = @@ -68,7 +68,7 @@ export const Doc: React.FC = () => { onScroll={() => controller.onScroll()} onRender={() => { // doing this so we can check for excessive re-renders - DocLog.info(`rendering document (serial=${serial})`); + console.info(`rendering document (serial=${serial})`); }} /> diff --git a/web-client/src/ui/doc/DocController.ts b/web-client/src/ui/doc/DocController.ts index df8e9520..e56303a6 100644 --- a/web-client/src/ui/doc/DocController.ts +++ b/web-client/src/ui/doc/DocController.ts @@ -15,11 +15,10 @@ import { viewActions, viewSelector, } from "core/store"; -import { Debouncer, sleep } from "low/utils"; +import { Debouncer, sleep, consoleDoc as console } from "low/utils"; import { GameCoord } from "low/celerc"; import { - DocLog, findLineByIndex, findNoteByIndex, findSectionByIndex, @@ -44,7 +43,7 @@ declare global { } } -DocLog.info("loading doc module"); +console.info("loading doc module"); /// Create the doc controller singleton export const initDocController = (store: AppStore): DocController => { @@ -52,7 +51,7 @@ export const initDocController = (store: AppStore): DocController => { window.__theDocController.delete(); } - DocLog.info("creating doc controller"); + console.info("creating doc controller"); const controller = new DocController(store); window.__theDocController = controller; @@ -98,7 +97,7 @@ export class DocController { } public delete() { - DocLog.info("deleting doc controller"); + console.info("deleting doc controller"); this.cleanup(); } @@ -175,11 +174,11 @@ export class DocController { newTag.href = newHref; head.appendChild(newTag); if (oldTags.length) { - DocLog.info(`re-prioritized theme stylesheet for ${theme}.`); + console.info(`re-prioritized theme stylesheet for ${theme}.`); // flickers without setTimeout setTimeout(() => oldTags.forEach((x) => x.remove()), 0); } else { - DocLog.info(`created theme stylesheet for ${theme}.`); + console.info(`created theme stylesheet for ${theme}.`); } } else { const oldTag = oldTags[0] as HTMLLinkElement; @@ -187,7 +186,7 @@ export class DocController { oldTag.href !== `${window.location.origin}${newHref}` && oldTag.href !== newHref ) { - DocLog.info(`switching theme to ${theme}...`); + console.info(`switching theme to ${theme}...`); oldTag.href = newHref; } } @@ -204,7 +203,7 @@ export class DocController { /// /// Triggered after layout or document change private async onFullUpdate() { - DocLog.info("fully updating document view..."); + console.info("fully updating document view..."); const eventId = ++this.currentUpdateEventId; updateBannerWidths(); await sleep(0); @@ -241,7 +240,7 @@ export class DocController { /// /// Returns if current line was updated private async onScrollUpdateInternal(_eventId: number): Promise { - DocLog.info("updating document view after scroll..."); + console.info("updating document view after scroll..."); const view = viewSelector(this.store.getState()); const scrollView = getScrollView(); if (!scrollView) { @@ -274,14 +273,14 @@ export class DocController { // current line is not visible visibleLines = findVisibleLines(); if (visibleLines.length === 0) { - DocLog.warn("cannot find any visible lines"); + console.warn("cannot find any visible lines"); return false; } // make center line current const centerLine = visibleLines[Math.floor(visibleLines.length / 2)]; const [section, line] = getLineLocationFromElement(centerLine); - DocLog.info( + console.info( `current line not visible, updating to ${section}-${line}...`, ); this.store.dispatch(viewActions.setDocLocation({ section, line })); @@ -326,7 +325,7 @@ export class DocController { /// Returns if the scroll was updated private async onLocationUpdateInternal(eventId: number): Promise { const newView = viewSelector(this.store.getState()); - DocLog.info( + console.info( `updating document view to ${newView.currentSection}-${newView.currentLine}...`, ); @@ -346,17 +345,17 @@ export class DocController { newCurrentLine = findSectionByIndex(newView.currentSection); if (!newCurrentLine) { if (retryCount < maxRetryCount) { - DocLog.warn( + console.warn( `cannot find current section: section=${newView.currentSection}. Will retry in 1s.`, ); } else if (retryCount === maxRetryCount) { - DocLog.warn( + console.warn( `cannot find current line after too many retries. Further warnings will be suppressed.`, ); } await sleep(1000); if (this.isEventObsolete(eventId)) { - DocLog.info("canceling previous update"); + console.info("canceling previous update"); return false; } retryCount++; diff --git a/web-client/src/ui/doc/updateBannerWidths.ts b/web-client/src/ui/doc/updateBannerWidths.ts index 590935e6..32803478 100644 --- a/web-client/src/ui/doc/updateBannerWidths.ts +++ b/web-client/src/ui/doc/updateBannerWidths.ts @@ -1,7 +1,6 @@ //! Logic for updating the width of banners upon updates -import { injectDOMStyle } from "low/utils"; -import { DocLog } from "./utils"; +import { injectDOMStyle, consoleDoc as console } from "low/utils"; import { DocContainer, DocContainerWidthVariable } from "./components"; export const updateBannerWidths = (): void => { @@ -12,5 +11,5 @@ export const updateBannerWidths = (): void => { const containerWidth = container.getBoundingClientRect().width; const style = `:root {${DocContainerWidthVariable.name}:${containerWidth}px;}`; injectDOMStyle("dynamic-banner-width", style); - DocLog.info("banner width css updated."); + console.info("banner width css updated."); }; diff --git a/web-client/src/ui/doc/updateDocTagsStyle.ts b/web-client/src/ui/doc/updateDocTagsStyle.ts index f4e67acb..ecf619a6 100644 --- a/web-client/src/ui/doc/updateDocTagsStyle.ts +++ b/web-client/src/ui/doc/updateDocTagsStyle.ts @@ -1,7 +1,6 @@ import { DocTag, DocTagColor } from "low/celerc"; -import { injectDOMStyle } from "low/utils"; +import { injectDOMStyle, consoleDoc as console } from "low/utils"; -import { DocLog } from "./utils"; import { RichTextVariables, getTagClassName } from "./components"; /// Update the styles/classes for rich tags @@ -77,7 +76,7 @@ export function updateDocTagsStyle(tags: Readonly>) { } injectDOMStyle("rich-text-us", underlineStrikethroughCss); - DocLog.info("rich text css updated."); + console.info("rich text css updated."); } function addColor( diff --git a/web-client/src/ui/doc/updateNotePositions.ts b/web-client/src/ui/doc/updateNotePositions.ts index 5746e19b..9246a3d6 100644 --- a/web-client/src/ui/doc/updateNotePositions.ts +++ b/web-client/src/ui/doc/updateNotePositions.ts @@ -4,9 +4,9 @@ //! is anchored to the line, while the above and below note blocks are attempted to be //! anchored to their corresponding lines, and pushed up/down if needed. -import { createYielder } from "low/utils"; +import { consoleDoc as console, createYielder } from "low/utils"; + import { - DocLog, getLineLocationFromElement, getScrollContainerOffsetY, findLineByIndex, @@ -27,7 +27,7 @@ export const updateNotePositions = async ( // Callback to check if the update should be cancelled shouldCancel: () => boolean, ): Promise => { - DocLog.info("updating note positions..."); + console.info("updating note positions..."); const intervals = findAvailableIntervals(); const noteContainer = DocNoteContainer.get(); if (!noteContainer || noteContainer.children.length === 0) { @@ -142,7 +142,7 @@ export const updateNotePositions = async ( // Run updates above and below concurrently await Promise.all(promises); - DocLog.info("finished updating note positions"); + console.info("finished updating note positions"); }; /// Layout notes always anchored to the line element they are for @@ -150,7 +150,7 @@ export const updateNotePositionsAnchored = async ( // Callback to check if the update should be cancelled shouldCancel: () => boolean, ): Promise => { - DocLog.info("updating note positions (anchored)..."); + console.info("updating note positions (anchored)..."); const noteContainer = DocNoteContainer.get(); if (!noteContainer || noteContainer.children.length === 0) { // no notes to layout @@ -188,7 +188,7 @@ const getPreferredTop = ( const [sectionIndex, lineIndex] = getLineLocationFromElement(noteBlock); const lineElement = findLineByIndex(sectionIndex, lineIndex); if (!lineElement) { - DocLog.warn( + console.warn( `cannot find line when updating note position: ${sectionIndex}-${lineIndex}`, ); return undefined; diff --git a/web-client/src/ui/doc/utils.ts b/web-client/src/ui/doc/utils.ts index c800a045..739c278f 100644 --- a/web-client/src/ui/doc/utils.ts +++ b/web-client/src/ui/doc/utils.ts @@ -1,6 +1,6 @@ //! Utilities -import { DOMId, Logger } from "low/utils"; +import { DOMId } from "low/utils"; import { DocLineContainerClass, DocSectionContainerClass, @@ -8,8 +8,6 @@ import { DocNoteContainerClass, } from "./components"; -export const DocLog = new Logger("doc"); - /// Scroll view type export type ScrollView = { /// Top of the scroll diff --git a/web-client/src/ui/editor/EditorDropZone.tsx b/web-client/src/ui/editor/EditorDropZone.tsx index 5d0fdcda..37bd5b16 100644 --- a/web-client/src/ui/editor/EditorDropZone.tsx +++ b/web-client/src/ui/editor/EditorDropZone.tsx @@ -54,7 +54,7 @@ export const EditorDropZone: React.FC = () => { return; } - // create the retry handle to show error messages, + // create the retry handle to show error messages, // and ask user if they want to retry const retryHandler = createRetryOpenHandler(alertMgr); const fs = await fsOpenReadWriteFrom(item, retryHandler); diff --git a/web-client/src/ui/editor/EditorTree.tsx b/web-client/src/ui/editor/EditorTree.tsx index 1dc22fe4..def50eb6 100644 --- a/web-client/src/ui/editor/EditorTree.tsx +++ b/web-client/src/ui/editor/EditorTree.tsx @@ -1,12 +1,17 @@ -import { useCallback, useEffect, useState } from "react"; +import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useSelector } from "react-redux"; +import { fsComponents, fsJoin, fsRoot } from "pure/fs"; + import { useKernel } from "core/kernel"; import { settingsSelector, viewSelector } from "core/store"; import { TreeItem } from "./TreeItem"; import { useEditorStyles } from "./styles"; +/// Tree view of the project opened in the editor. +/// +/// This component is connected to the store directly export const EditorTree: React.FC = () => { const kernel = useKernel(); const { serial, rootPath, openedFile, unsavedFiles } = @@ -15,32 +20,64 @@ export const EditorTree: React.FC = () => { const styles = useEditorStyles(); // We are using serial to signal when to update - // A new listDir reference will cause the tree to update + // A new listDir reference will cause the tree to reload the entries /* eslint-disable react-hooks/exhaustive-deps*/ const listDir = useCallback( - async (paths: string[]): Promise => { - const editor = kernel.getEditor(); - if (!editor) { - return []; - } - return editor.listDir(paths); + (path: string) => { + return kernel.getEditor()?.listDir(path) || Promise.resolve([]); }, [serial], ); /* eslint-enable react-hooks/exhaustive-deps*/ - const [expandedPaths, setExpandedPaths] = useState([""]); + const dirtyPaths = useMemo(() => { + const set = new Set(); + for (let i = 0; i < unsavedFiles.length; i++) { + const path = unsavedFiles[i]; + let temp = fsRoot(); + for (const part of fsComponents(path)) { + temp = fsJoin(temp, part); + set.add(temp); + } + } + if (set.size) { + set.add(fsRoot()); + } + return set; + }, [unsavedFiles]); + + // serial to manually update the component + const [stateSerial, setStateSerial] = useState(0); + const expandedPaths = useRef | undefined>(); + const setIsExpanded = useCallback((path: string, isExpanded: boolean) => { + if (isExpanded) { + if (!expandedPaths.current) { + expandedPaths.current = new Set(); + expandedPaths.current.add(path); + } else { + if (!expandedPaths.current.has(path)) { + expandedPaths.current.add(path); + setStateSerial((x) => x + 1); + } + } + return; + } + if (expandedPaths.current) { + expandedPaths.current.delete(path); + } + }, []); if (!showFileTree && openedFile) { return null; } + return (
{ const editor = kernel.getEditor(); @@ -48,53 +85,62 @@ export const EditorTree: React.FC = () => { return; } editor.notifyActivity(); - editor.openFile(path); + await editor.openFile(path); }} - getIsExpanded={(path) => expandedPaths.includes(path.join("/"))} - setIsExpanded={(path, isExpanded) => { - const pathStr = path.join("/"); - - if (isExpanded) { - setExpandedPaths((x) => [...x, pathStr]); - } else { - setExpandedPaths((x) => x.filter((p) => pathStr !== p)); - } - }} - level={0} + setIsExpanded={setIsExpanded} + expandedPaths={expandedPaths.current || new Set()} + dirtyPaths={dirtyPaths} + openedFile={openedFile} />
); }; type TreeDirNodeProps = { + /// Serial to signal when to update + serial: number; + /// Name of the entry, should end with / name: string; - path: string[]; - listDir: (path: string[]) => Promise; - onClickFile: (path: string[]) => void; + /// Full path of the directory entry, should end with / + path: string; + /// Level of in the tree this node is in. 0 is the root. level: number; - getIsExpanded: (path: string[]) => boolean; - setIsExpanded: (path: string[], isExpanded: boolean) => void; + /// Function to list the contents of a directory + listDir: (path: string) => Promise; + /// Callback for when a file is clicked + onClickFile: (path: string) => void; + /// Callback for toggling the expanded state of the node + setIsExpanded: (path: string, isExpanded: boolean) => void; + + /// Directory paths that are expanded + /// + /// All should end with / + expandedPaths: Set; + + /// File and directory paths that have unsaved changes + /// + /// Directories should end with / + dirtyPaths: Set; + /// The file currently opened in the editor openedFile: string | undefined; - unsavedFiles: string[]; }; -const TreeDirNode: React.FC = ({ +const TreeDirNodeInternal: React.FC = ({ + serial, name, path, listDir, onClickFile, level, - getIsExpanded, setIsExpanded, openedFile, - unsavedFiles, + expandedPaths, + dirtyPaths, }) => { const [entries, setEntries] = useState(undefined); - const isExpanded = getIsExpanded(path); + const isExpanded = expandedPaths.has(path); - // using path.join since path is an array - /* eslint-disable react-hooks/exhaustive-deps*/ useEffect(() => { if (!isExpanded) { return; @@ -105,8 +151,7 @@ const TreeDirNode: React.FC = ({ setEntries(entries); }; loadEntries(); - }, [path.join("/"), isExpanded, listDir]); - /* eslint-enable react-hooks/exhaustive-deps*/ + }, [path, isExpanded, listDir]); const isLoading = isExpanded && entries === undefined; @@ -122,43 +167,44 @@ const TreeDirNode: React.FC = ({ }} level={level} isLoading={isLoading} + isDirty={dirtyPaths.has(path)} /> {isExpanded && entries !== undefined && entries.map((entry, i) => { if (entry.endsWith("/")) { + // remove the trailing / from the entry + // since file/directory are displayed through icon const name = entry.slice(0, -1); return ( ); } else { - const pathStr = - path.length === 0 - ? entry - : `${path.join("/")}/${entry}`; + const filePath = fsJoin(path, entry); return ( { - onClickFile([...path, entry]); + onClickFile(filePath); }} level={level + 1} isLoading={false} - isDirty={unsavedFiles.includes(pathStr)} + isDirty={dirtyPaths.has(filePath)} /> ); } @@ -166,8 +212,10 @@ const TreeDirNode: React.FC = ({ ); }; +const TreeDirNode = memo(TreeDirNodeInternal); -const compareEntry = (a: string, b: string): number => { +/// Compare function for sorting entries in the file tree +function compareEntry(a: string, b: string): number { const isADir = a.endsWith("/"); const isBDir = b.endsWith("/"); if (isADir && !isBDir) { @@ -177,4 +225,4 @@ const compareEntry = (a: string, b: string): number => { return 1; } return a.localeCompare(b); -}; +} diff --git a/web-client/src/ui/editor/TreeItem.tsx b/web-client/src/ui/editor/TreeItem.tsx index bd55e7fe..4e97f77e 100644 --- a/web-client/src/ui/editor/TreeItem.tsx +++ b/web-client/src/ui/editor/TreeItem.tsx @@ -5,20 +5,25 @@ import { TreeIcon } from "./TreeIcon"; import { useEditorStyles } from "./styles"; export type TreeItemProps = { - // Displayed file name + /// Displayed file name file: string; - // Callback when the file is clicked + /// Callback when the file is clicked onClickFile: () => void; - // Level of the file in the tree. 0 is the root. + /// Level of the file in the tree. 0 is the root. level: number; - + /// If this entry is a directory isDirectory: boolean; + /// Should the entry be displayed as selected isSelected: boolean; + /// Should the entry be displayed as expanded isExpanded?: boolean; isLoading: boolean; - isDirty?: boolean; + isDirty: boolean; }; +/// A single directory or file entry in the file tree +/// +/// Does not display content of directory export const TreeItem: React.FC = ({ file, isDirectory, diff --git a/web-client/src/ui/map/IconMarker.ts b/web-client/src/ui/map/IconMarker.ts index c3a2b84b..7efbcaa3 100644 --- a/web-client/src/ui/map/IconMarker.ts +++ b/web-client/src/ui/map/IconMarker.ts @@ -3,7 +3,7 @@ import L from "leaflet"; import { LRUCache } from "lru-cache"; -import { MapLog } from "./utils"; +import { consoleMap as console } from "low/utils"; // hacks into implementation details of leaflet interface LLayer { @@ -90,7 +90,7 @@ export class IconMarker extends L.CircleMarker { private redrawInternal(retryCount: number) { if (!this.icon.loaded) { if (retryCount > 50) { - MapLog.warn( + console.warn( `resource is taking too long to load: ${this.icon.img.src}`, ); // give up on retrying @@ -112,14 +112,14 @@ export class IconMarker extends L.CircleMarker { } const p = layer._point; if (!p) { - MapLog.warn("invalid icon marker point"); + console.warn("invalid icon marker point"); return; } // check if renderer is valid const ctx = layer._renderer?._ctx; if (!ctx) { - MapLog.warn("invalid icon markder renderer"); + console.warn("invalid icon markder renderer"); return; } diff --git a/web-client/src/ui/map/MapContainerMgr.ts b/web-client/src/ui/map/MapContainerMgr.ts index 8fd0eea2..91f7043e 100644 --- a/web-client/src/ui/map/MapContainerMgr.ts +++ b/web-client/src/ui/map/MapContainerMgr.ts @@ -1,9 +1,7 @@ //! MapContainerMgr import L from "leaflet"; -import { DOMId } from "low/utils"; - -import { MapLog } from "./utils"; +import { DOMId, consoleMap as console } from "low/utils"; /// Container div id export const MapContainer = new DOMId("map-root"); @@ -35,11 +33,11 @@ export class MapContainerMgr { } if (attempt) { if (attempt === 10) { - MapLog.warn( + console.warn( "failed to attach to root container after max retries. Futher failures will be ignored", ); } else if (attempt < 10) { - MapLog.warn( + console.warn( "failed to attach to root container. Will retry in 1s", ); } @@ -74,7 +72,7 @@ export class MapContainerMgr { prevContainer.remove(); } - MapLog.info("attaching map to container"); + console.info("attaching map to container"); // Remove from the old place map.getContainer().remove(); diff --git a/web-client/src/ui/map/MapLayerMgr.ts b/web-client/src/ui/map/MapLayerMgr.ts index 252bce6c..597b7dd8 100644 --- a/web-client/src/ui/map/MapLayerMgr.ts +++ b/web-client/src/ui/map/MapLayerMgr.ts @@ -4,8 +4,9 @@ import "leaflet-rastercoords"; import { AppDispatcher, viewActions } from "core/store"; import { MapLayer, MapTilesetTransform, GameCoord } from "low/celerc"; +import { consoleMap as console } from "low/utils"; -import { MapLog, getAttributionHtml } from "./utils"; +import { getAttributionHtml } from "./utils"; /// Tile layer wrapper type MapTileLayer = { @@ -42,7 +43,7 @@ export class MapLayerMgr { mapLayers: MapLayer[], initialCoord: GameCoord, ) { - MapLog.info("initializing map layers"); + console.info("initializing map layers"); this.getActiveLayer()?.layer.remove(); // create new tileset layers this.layers = mapLayers.map((layer) => { diff --git a/web-client/src/ui/map/MapState.ts b/web-client/src/ui/map/MapState.ts index c3f45147..1a32adab 100644 --- a/web-client/src/ui/map/MapState.ts +++ b/web-client/src/ui/map/MapState.ts @@ -16,14 +16,14 @@ import { viewSelector, } from "core/store"; import { ExecDoc, GameCoord, MapLayer } from "low/celerc"; -import { Debouncer } from "low/utils"; +import { Debouncer, consoleMap as console } from "low/utils"; -import { MapLog, roughlyEquals } from "./utils"; +import { roughlyEquals } from "./utils"; import { MapContainerMgr } from "./MapContainerMgr"; import { MapLayerMgr } from "./MapLayerMgr"; import { MapVisualMgr } from "./MapVisualMgr"; -MapLog.info("loading map module"); +console.info("loading map module"); /// Storing map state as window global because HMR will cause the map to be recreated declare global { @@ -37,7 +37,7 @@ export const initMap = (store: AppStore): MapState => { if (window.__theMapState) { window.__theMapState.delete(); } - MapLog.info("creating map"); + console.info("creating map"); const map = new MapState(store); window.__theMapState = map; @@ -163,7 +163,7 @@ export class MapState { /// Delete the map state public delete() { - MapLog.info("deleting map"); + console.info("deleting map"); this.map.getContainer().remove(); this.map.remove(); this.cleanup(); @@ -263,7 +263,7 @@ export class MapState { const currentMapView = view.currentMapView; if (Array.isArray(currentMapView)) { if (currentMapView.length === 0) { - MapLog.warn("invalid map view"); + console.warn("invalid map view"); } else if (currentMapView.length === 1) { setTimeout(() => { const [center, layer] = this.layerMgr.unproject( diff --git a/web-client/src/ui/map/MapVisualMgr.ts b/web-client/src/ui/map/MapVisualMgr.ts index 7ca03324..a3626146 100644 --- a/web-client/src/ui/map/MapVisualMgr.ts +++ b/web-client/src/ui/map/MapVisualMgr.ts @@ -9,9 +9,9 @@ import { viewActions, } from "core/store"; import { ExecDoc, GameCoord, MapIcon } from "low/celerc"; +import { consoleMap as console } from "low/utils"; import { LayerMode, SectionMode, VisualSize } from "core/map"; -import { MapLog } from "./utils"; import { MapLayerMgr } from "./MapLayerMgr"; import { IconMarker } from "./IconMarker"; @@ -46,7 +46,7 @@ export class MapVisualMgr { view: ViewState, settings: SettingsState, ) { - MapLog.info("initializing map visuals"); + console.info("initializing map visuals"); this.initIcons(doc, view, settings); this.initMarkers(doc, view, settings); this.initLines(doc, view, settings); @@ -87,7 +87,7 @@ export class MapVisualMgr { .map((icon) => { const iconSrc = registeredIcons[icon.id]; if (!iconSrc) { - MapLog.warn(`Icon ${icon.id} is not registered`); + console.warn(`Icon ${icon.id} is not registered`); return undefined; } const size = getIconSizeWithSettings(icon, settings); @@ -127,7 +127,7 @@ export class MapVisualMgr { }, ); iconMarker.on("click", () => { - MapLog.info( + console.info( `clicked icon, section ${icon.sectionIndex}, line ${icon.lineIndex}, layer ${layer}`, ); this.setDocLocation( @@ -199,7 +199,7 @@ export class MapVisualMgr { pane: "markerPane", }); markerLayer.on("click", () => { - MapLog.info( + console.info( `clicked marker, section ${marker.sectionIndex}, line ${marker.lineIndex}, layer ${layer}`, ); this.setDocLocation( @@ -426,7 +426,7 @@ class MapVisualGroup { (i === undefined || i < 0 || i >= this.sectionLayers.length) ) { // Index is invalid, we will keep the map empty - MapLog.warn("Invalid section index: " + i); + console.warn("Invalid section index: " + i); return; } if (mode === SectionMode.None) { diff --git a/web-client/src/ui/map/utils.ts b/web-client/src/ui/map/utils.ts index 15fbcab0..f2ca0332 100644 --- a/web-client/src/ui/map/utils.ts +++ b/web-client/src/ui/map/utils.ts @@ -1,10 +1,6 @@ //! Utility for the map logic import { MapAttribution } from "low/celerc"; -import { Logger } from "low/utils"; - -/// Map module logger -export const MapLog = new Logger("map"); /// Epsilon for floating point comparison in the map const EPSILON = 1e-3; @@ -14,28 +10,6 @@ export const roughlyEquals = (a: number, b: number): boolean => { return Math.abs(a - b) < EPSILON; }; -// This should be part of the compiler TODO -// Convert a route coordinate to a game coordinate using the coordMap -// export const toGameCoord = ( -// routeCoord: number[], -// coordMap: MapCoordMap, -// ): GameCoord => { -// const coord: Record = { -// x: 0, -// y: 0, -// z: 0, -// }; -// -// const mapper = -// routeCoord[2] === undefined ? coordMap["2d"] : coordMap["3d"]; -// -// mapper.forEach((axis, i) => { -// coord[axis] = routeCoord[i] as number; -// }); -// -// return [coord.x, coord.y, coord.z]; -// }; - /// Get attribution html to be used in the map /// /// This uses `innerText` to sanitize the link. diff --git a/web-client/src/ui/toolbar/Export.tsx b/web-client/src/ui/toolbar/Export.tsx index 14533d74..09eec76a 100644 --- a/web-client/src/ui/toolbar/Export.tsx +++ b/web-client/src/ui/toolbar/Export.tsx @@ -32,23 +32,25 @@ import { } from "@fluentui/react-icons"; import { ungzip } from "pako"; -import { - AppStore, - documentSelector, - settingsActions, - settingsSelector, -} from "core/store"; -import { ExecDoc, ExpoDoc, ExportIcon, ExportMetadata } from "low/celerc"; +import { errstr } from "pure/utils"; +import { fsSave } from "pure/fs"; -import { Kernel, useKernel } from "core/kernel"; -import { console, errorToString, saveAs } from "low/utils"; +import { ErrorBar, PrismEditor } from "ui/shared"; import { createExportRequest, getExportConfig, getExportLabel, isConfigNeeded, } from "core/doc"; -import { ErrorBar, PrismEditor } from "ui/shared"; +import { + AppStore, + documentSelector, + settingsActions, + settingsSelector, +} from "core/store"; +import { Kernel, useKernel } from "core/kernel"; +import { ExecDoc, ExpoDoc, ExportIcon, ExportMetadata } from "low/celerc"; +import { console } from "low/utils"; import { useActions } from "low/store"; import { ControlComponentProps, ToolbarControl } from "./util"; @@ -365,28 +367,27 @@ async function runExportAndShowDialog( }, }, async (): Promise => { - const requestResult = createExportRequest(metadata, config); - if (requestResult.isErr()) { - return errorToString(requestResult.inner()); + const request = createExportRequest(metadata, config); + if ("err" in request) { + return errstr(request.err); } - const request = requestResult.inner(); - const expoDoc = await kernel.export(request); + const expoDoc = await kernel.export(request.val); if (cancelled) { return ""; } return downloadExport(expoDoc); }, ); - if (result.isErr()) { - const error = result.inner(); + if ("err" in result) { + const error = result.err; if (error === false) { // cancel cancelled = true; return ""; } - return errorToString(error); + return errstr(error); } - return result.inner(); + return result.val; } function downloadExport(expoDoc: ExpoDoc): string { @@ -411,7 +412,7 @@ function downloadExport(expoDoc: ExpoDoc): string { } } console.info(`saving file: ${fileName}`); - saveAs(data, fileName); + fsSave(data, fileName); return ""; } diff --git a/web-client/src/ui/toolbar/SaveProject.tsx b/web-client/src/ui/toolbar/SaveProject.tsx index ad858504..c9933b5c 100644 --- a/web-client/src/ui/toolbar/SaveProject.tsx +++ b/web-client/src/ui/toolbar/SaveProject.tsx @@ -83,15 +83,7 @@ const useSaveProjectControl = () => { } editor.notifyActivity(); - const result = await editor.saveChangesToFs(); - if (result.isErr()) { - await kernel.getAlertMgr().show({ - title: "Error", - message: - "Fail to save changes to file system. Please try again.", - okButton: "Close", - }); - } + await editor.saveToFs(); }, [kernel]); useEffect(() => { diff --git a/web-client/src/ui/toolbar/SyncProject.tsx b/web-client/src/ui/toolbar/SyncProject.tsx index 99583d87..ea6b4e39 100644 --- a/web-client/src/ui/toolbar/SyncProject.tsx +++ b/web-client/src/ui/toolbar/SyncProject.tsx @@ -15,9 +15,8 @@ import { FolderArrowUp20Regular } from "@fluentui/react-icons"; import { CommonStyles, useCommonStyles } from "ui/shared"; import { useKernel } from "core/kernel"; -import { settingsSelector, viewActions, viewSelector } from "core/store"; +import { settingsSelector, viewSelector } from "core/store"; -import { useActions } from "low/store"; import { ToolbarControl } from "./util"; export const SyncProject: ToolbarControl = { @@ -51,7 +50,6 @@ const useSyncProjectControl = () => { const { rootPath, loadInProgress, lastLoadError } = useSelector(viewSelector); const { editorMode } = useSelector(settingsSelector); - const { incFileSysSerial } = useActions(viewActions); const styles = useCommonStyles(); @@ -67,21 +65,8 @@ const useSyncProjectControl = () => { } editor.notifyActivity(); - const result = await editor.loadChangesFromFs(); - if (result.isErr()) { - // failure could be due to project structure change. try again - const result2 = await editor.loadChangesFromFs(); - if (result2.isErr()) { - await kernel.getAlertMgr().show({ - title: "Error", - message: - "Fail to load changes from file system. Please try again.", - okButton: "Close", - }); - } - } - incFileSysSerial(); - }, [kernel, incFileSysSerial]); + await editor.loadFromFs(); + }, [kernel]); return { tooltip, enabled, icon, handler }; }; diff --git a/web-client/src/ui/toolbar/getHeaderControls.ts b/web-client/src/ui/toolbar/getHeaderControls.ts index 83ad5f97..8d4d3168 100644 --- a/web-client/src/ui/toolbar/getHeaderControls.ts +++ b/web-client/src/ui/toolbar/getHeaderControls.ts @@ -1,4 +1,4 @@ -import { EditorMode } from "core/editor"; +import { EditorMode } from "core/store"; import { StageMode } from "core/stage"; import { HeaderControlList, ToolbarControl } from "./util"; diff --git a/web-client/src/ui/toolbar/settings/EditorSettings.tsx b/web-client/src/ui/toolbar/settings/EditorSettings.tsx index fc6e1923..77b89d72 100644 --- a/web-client/src/ui/toolbar/settings/EditorSettings.tsx +++ b/web-client/src/ui/toolbar/settings/EditorSettings.tsx @@ -5,8 +5,12 @@ import { Dropdown, Field, Switch, Option } from "@fluentui/react-components"; import { useSelector } from "react-redux"; import { useKernel } from "core/kernel"; -import { EditorMode } from "core/editor"; -import { settingsActions, settingsSelector, viewSelector } from "core/store"; +import { + settingsActions, + settingsSelector, + viewSelector, + EditorMode, +} from "core/store"; import { EntryPointsSorted } from "low/celerc"; import { useActions } from "low/store"; @@ -53,11 +57,11 @@ export const EditorSettings: React.FC = () => { } const compiler = await kernel.getCompiler(); const result = await compiler.getEntryPoints(); - if (!result.isOk()) { + if ("err" in result) { setEntryPoints([]); return; } - setEntryPoints(result.inner()); + setEntryPoints(result.val); })(); }, [kernel, rootPath]); diff --git a/web-client/tools/lint/non-logger-console.cjs b/web-client/tools/lint/non-logger-console.cjs index c48e202c..271009e2 100644 --- a/web-client/tools/lint/non-logger-console.cjs +++ b/web-client/tools/lint/non-logger-console.cjs @@ -21,10 +21,19 @@ function checkFile(file, content) { if (errors.length === 0) { return []; } + let temp = ""; for (const line of lines) { - if (isImportConsoleFromLowUtils(include, line)) { + if (line.startsWith("//")) { + continue; + } + if ((line.startsWith("import") || temp) && !line.endsWith(";")) { + temp += line; + continue; + } + if (isImportConsoleFromLowUtils(include, temp + line)) { return []; } + temp = ""; } errors.push( `Please import { console } from "${include}"; or remove the console statement.`, @@ -33,6 +42,9 @@ function checkFile(file, content) { } function containsConsole(line) { + if (line.startsWith("//")) { + return false; + } const lineReplaced = line.replace("window.console", "consoleignore"); for (const rule of rules) { if (lineReplaced.includes(rule)) { diff --git a/web-client/tsconfig.json b/web-client/tsconfig.json index b447adaf..58a8535b 100644 --- a/web-client/tsconfig.json +++ b/web-client/tsconfig.json @@ -22,7 +22,7 @@ "baseUrl": "src", "paths": { - "pure/*": ["../../libs/pure/src/*"], + "pure/*": ["../../libs/pure/src/*"] } }, "include": ["src", "../libs/pure/src"], From 2b6b79c0ff04545aec381a3b043159028061f90b Mon Sep 17 00:00:00 2001 From: Pistonight Date: Sun, 25 Feb 2024 22:18:24 -0800 Subject: [PATCH 07/10] cargo fmt --- compiler-wasm/src/loader.rs | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/compiler-wasm/src/loader.rs b/compiler-wasm/src/loader.rs index a61074b4..a0596d0f 100644 --- a/compiler-wasm/src/loader.rs +++ b/compiler-wasm/src/loader.rs @@ -5,7 +5,7 @@ use std::cell::RefCell; -use js_sys::{Array, Function, Uint8Array, Reflect}; +use js_sys::{Array, Function, Reflect, Uint8Array}; use log::info; use wasm_bindgen::prelude::*; @@ -111,16 +111,19 @@ pub enum LoadFileOutput { /// Load a file from using JS binding async fn load_file_internal(path: &str, check_changed: bool) -> ResResult { let result = async { - LOAD_FILE.with_borrow(|f| { + LOAD_FILE + .with_borrow(|f| { f.call2( &JsValue::UNDEFINED, &JsValue::from(path), &JsValue::from(check_changed), ) })? - .into_future().await? + .into_future() + .await? .dyn_into::() - }.await; + } + .await; let result = result.and_then(|result| { let modified = result.get(0).as_bool().unwrap_or_default(); @@ -136,7 +139,7 @@ async fn load_file_internal(path: &str, check_changed: bool) -> ResResult { if let Ok(value) = Reflect::get(&e, &JsValue::from("message")) { if let Some(s) = value.as_string() { - return Err(ResError::FailToLoadFile( path.to_string(), s)); + return Err(ResError::FailToLoadFile(path.to_string(), s)); } } logger::raw_error(&e); From 094b86682d8fcc81db197ed58657cc0312576e55 Mon Sep 17 00:00:00 2001 From: Pistonight Date: Sun, 25 Feb 2024 22:33:05 -0800 Subject: [PATCH 08/10] clean up 1 --- compiler-wasm/src/loader.rs | 2 + web-client/src/core/editor/FileMgr.ts | 154 ------------- web-client/src/core/kernel/Kernel.ts | 6 - web-client/src/low/fs/FileApiFileSys.ts | 67 ------ .../src/low/fs/FileEntriesApiFileSys.ts | 156 ------------- web-client/src/low/fs/FileSys.ts | 53 ----- .../src/low/fs/FileSystemAccessApiFileSys.ts | 207 ------------------ web-client/src/low/fs/FsFile.ts | 190 ---------------- web-client/src/low/fs/FsPath.ts | 119 ---------- web-client/src/low/fs/FsResult.ts | 28 --- web-client/src/low/fs/index.ts | 14 -- web-client/src/low/fs/open.ts | 146 ------------ 12 files changed, 2 insertions(+), 1140 deletions(-) delete mode 100644 web-client/src/low/fs/FileApiFileSys.ts delete mode 100644 web-client/src/low/fs/FileEntriesApiFileSys.ts delete mode 100644 web-client/src/low/fs/FileSys.ts delete mode 100644 web-client/src/low/fs/FileSystemAccessApiFileSys.ts delete mode 100644 web-client/src/low/fs/FsFile.ts delete mode 100644 web-client/src/low/fs/FsPath.ts delete mode 100644 web-client/src/low/fs/FsResult.ts delete mode 100644 web-client/src/low/fs/index.ts delete mode 100644 web-client/src/low/fs/open.ts diff --git a/compiler-wasm/src/loader.rs b/compiler-wasm/src/loader.rs index a0596d0f..71b954e4 100644 --- a/compiler-wasm/src/loader.rs +++ b/compiler-wasm/src/loader.rs @@ -110,6 +110,8 @@ pub enum LoadFileOutput { /// Load a file from using JS binding async fn load_file_internal(path: &str, check_changed: bool) -> ResResult { + // this is essentially + // try { Ok(await load_file(path, check_changed)) } catch (e) { Err(e) } let result = async { LOAD_FILE .with_borrow(|f| { diff --git a/web-client/src/core/editor/FileMgr.ts b/web-client/src/core/editor/FileMgr.ts index 6f770f6c..37f8199b 100644 --- a/web-client/src/core/editor/FileMgr.ts +++ b/web-client/src/core/editor/FileMgr.ts @@ -28,15 +28,6 @@ export class FileMgr implements CompilerFileAccess { private supportsSave: boolean; - // /// Some operations need to block other operations, - // /// like saving and loading at the same time is probably bad - // /// - // /// Anything that changes files or currentFile or the monaco editor - // /// should lock the fs - // private fsLock: ReentrantLock; - // /// Opened files - // private files: Record = {}; - private currentFile: FsFile | undefined; private monacoDom: HTMLDivElement; private monacoEditor: IStandaloneCodeEditor; @@ -62,27 +53,11 @@ export class FileMgr implements CompilerFileAccess { this.monacoEditor = monacoEditor; this.fsYield = createYielder(64); this.modifyTracker = new ModifyTimeTracker(); - // this.fsLock = new ReentrantLock("file mgr"); } - // public setFileSys(fs: FsFileSystem): Promise { - // return this.fs.scopedWrite(async (thisFs, setFs) => { - // if (thisFs === fs) { - // return; - // } - // thisFs = setFs(fs); - // this.updateEditor(undefined, undefined, undefined); - // this.dispatcher.dispatch(viewActions.setUnsavedFiles([])); - // }); - // await this.fsLock.lockedScope(undefined, async (token) => { - // }); - // } - public delete() { - // this.fsLock.lockedScope(undefined, async (token) => { this.closeEditor(); this.monacoEditor.dispose(); - // }); } public async resizeEditor() { @@ -156,7 +131,6 @@ export class FileMgr implements CompilerFileAccess { this.dispatcher.dispatch(viewActions.endFileSysLoad(success)); this.dispatcher.dispatch(viewActions.incFileSysSerial()); console.info("sync completed"); - // return success ? allocOk() : allocErr(FsResultCodes.Fail); } private async loadFromFsWithFs(fs: FsFileSystem): Promise { @@ -170,16 +144,6 @@ export class FileMgr implements CompilerFileAccess { await this.fsYield(); } return success; - // const fsFile = this.files[id]; - // const result = await this.loadChangesFromFsForFsFile( - // id, - // fsFile, - // token, - // ); - // if (result.isErr()) { - // success = false; - // } - // await _yield(); } private async loadFromFsForPath( @@ -187,10 +151,8 @@ export class FileMgr implements CompilerFileAccess { path: string, ): Promise { const fsFile = fs.getFile(path); - // return await this.fsLock.lockedScope(lockToken, async (token) => { const isCurrentFile = this.currentFile === fsFile; - // let content: string | undefined = undefined; let loadError: FsError | undefined = undefined; // load the file @@ -226,40 +188,6 @@ export class FileMgr implements CompilerFileAccess { } return loadError === undefined; - // - // let result = await fsFile.loadIfNotDirty(); - // - // if (result.isOk()) { - // if (isCurrentFile) { - // const contentResult = await fsFile.getText(); - // if (contentResult.isOk()) { - // content = contentResult.inner(); - // } else { - // result = contentResult; - // } - // } - // } - // if (result.isErr()) { - // EditorLog.error(`sync failed with code ${result}`); - // if (!fsFile.isNewerThanFs()) { - // EditorLog.info(`closing ${idPath}`); - // if (isCurrentFile) { - // await this.updateEditor( - // undefined, - // undefined, - // undefined, - // token, - // ); - // } - // delete this.files[idPath]; - // } - // } else { - // if (isCurrentFile) { - // await this.updateEditor(fsFile, idPath, content, token); - // } - // } - // return result; - // }); } public hasUnsavedChanges(): Promise { @@ -275,18 +203,6 @@ export class FileMgr implements CompilerFileAccess { } return false; }); - // return await this.fsLock.lockedScope(lockToken, async (token) => { - // await this.syncEditorToCurrentFile(token); - // const yielder = createYielder(64); - // for (const id in this.files) { - // const fsFile = this.files[id]; - // if (fsFile.isNewerThanFs()) { - // return true; - // } - // await yielder(); - // } - // return false; - // }); } public hasUnsavedChangesSync(): boolean { @@ -319,22 +235,6 @@ export class FileMgr implements CompilerFileAccess { const success = await this.fs.scopedWrite((fs) => { return this.saveToFsWithFs(fs); - // - // let success = true; - // const _yield = createYielder(64); - // for (const id in this.files) { - // const fsFile = this.files[id]; - // const result = await this.saveChangesToFsForFsFile( - // id, - // fsFile, - // token, - // ); - // if (result.isErr()) { - // success = false; - // } - // await _yield(); - // } - // return success; }); window.clearTimeout(handle); @@ -367,54 +267,8 @@ export class FileMgr implements CompilerFileAccess { return false; } return true; - // - // const result = await fsFile.writeIfNewer(); - // if (result.isErr()) { - // } - // return result; - // }); } - // private async updateEditorLegacy( - // file: FsFile | undefined, - // path: string | undefined, - // content: string | undefined, - // lockToken?: number, - // ) { - // await this.fsLock.lockedScope(lockToken, async (token) => { - // // in case we are switching files, sync the current file first - // if (this.currentFile !== file) { - // await this.syncEditorToCurrentFile(token); - // this.currentFile = file; - // } - // const success = content !== undefined; - // this.dispatcher.dispatch( - // viewActions.updateOpenedFile({ - // openedFile: path, - // currentFileSupported: success, - // }), - // ); - // - // if (success && path !== undefined) { - // // this check is necessary because - // // some browsers rerenders the editor even if the content is the same (Firefox) - // // which causes annoying flickering - // if (this.monacoEditor.getValue() !== content) { - // this.monacoEditor.setValue(content); - // } - // - // // TODO #20: language feature support - // this.switchLanguage(detectLanguageByFileName(path)); - // - // await this.attachEditor(); - // this.isEditorOpen = true; - // } else { - // this.monacoDom.remove(); - // this.isEditorOpen = false; - // } - // }); - // } - // private closeEditor() { if (this.currentFile) { this.syncEditorToCurrentFile(); @@ -472,14 +326,6 @@ export class FileMgr implements CompilerFileAccess { } } - // public async syncEditorToCurrentFileLegacy(lockToken?: number) { - // await this.fsLock.lockedScope(lockToken, async () => { - // if (this.currentFile && this.isEditorOpen) { - // this.currentFile.setContent(this.monacoEditor.getValue()); - // } - // }); - // } - /// Sync the text from editor to the in memory file storage public syncEditorToCurrentFile(): void { if (this.currentFile && this.isEditorOpen) { diff --git a/web-client/src/core/kernel/Kernel.ts b/web-client/src/core/kernel/Kernel.ts index 6fa01ace..c1fb5d96 100644 --- a/web-client/src/core/kernel/Kernel.ts +++ b/web-client/src/core/kernel/Kernel.ts @@ -124,10 +124,6 @@ export class Kernel implements EditorKernelAccess { const path = window.location.pathname; if (path === "/edit") { document.title = "Celer Editor"; - // const { initCompiler } = await import("core/compiler"); - // const compiler = initCompiler(this.store); - // this.compiler = compiler; - this.store.dispatch(viewActions.setStageMode("edit")); } else { setTimeout(() => { @@ -188,8 +184,6 @@ export class Kernel implements EditorKernelAccess { return this.compiler; } - // put this in low/fs - /// Open a project file system /// /// This function eats the error because alerts will be shown to the user diff --git a/web-client/src/low/fs/FileApiFileSys.ts b/web-client/src/low/fs/FileApiFileSys.ts deleted file mode 100644 index 8ad8886d..00000000 --- a/web-client/src/low/fs/FileApiFileSys.ts +++ /dev/null @@ -1,67 +0,0 @@ -// import { allocErr, allocOk } from "low/utils"; -// import { FsResult, FsResultCodes } from "./FsResult"; -// import { FsPath } from "./FsPath"; -// import { FileSys } from "./FileSys"; -// -// /// FileSystem implementation that uses a list of Files -// /// This is supported in all browsers, but it is stale. -// /// It's used for Firefox when the File Entries API is not available -// /// i.e. opened from -// export class FileApiFileSys implements FileSys { -// private rootPath: string; -// private files: Record; -// -// constructor(rootPath: string, files: Record) { -// this.rootPath = rootPath; -// this.files = files; -// } -// -// public async init(): Promise> { -// return allocOk(); -// } -// -// public getRootName(): string { -// return this.rootPath; -// } -// -// public async listDir(path: FsPath): Promise> { -// const set = new Set(); -// const prefix = path.path; -// Object.keys(this.files).forEach((path) => { -// if (!path.startsWith(prefix)) { -// return; -// } -// const relPath = path.slice(prefix.length); -// if (!relPath.includes("/")) { -// // file -// set.add(relPath); -// } else { -// // directory -// const dir = relPath.slice(0, relPath.indexOf("/") + 1); -// set.add(dir); -// } -// }); -// return allocOk(Array.from(set)); -// } -// -// public async readFile(path: FsPath): Promise> { -// const file = this.files[path.path]; -// if (!file) { -// return allocErr(FsResultCodes.NotFound); -// } -// return allocOk(file); -// } -// -// public isWritable(): boolean { -// return false; -// } -// -// public isStale(): boolean { -// return true; -// } -// -// public async writeFile(): Promise> { -// // File API does not support writing -// return allocErr(FsResultCodes.NotSupported); -// } -// } diff --git a/web-client/src/low/fs/FileEntriesApiFileSys.ts b/web-client/src/low/fs/FileEntriesApiFileSys.ts deleted file mode 100644 index 3d553da6..00000000 --- a/web-client/src/low/fs/FileEntriesApiFileSys.ts +++ /dev/null @@ -1,156 +0,0 @@ -// import { console, allocErr, allocOk } from "low/utils"; -// import { FileSys } from "./FileSys"; -// import { FsPath } from "./FsPath"; -// import { FsResult, FsResultCodes } from "./FsResult"; -// -// export const isFileEntriesApiSupported = (): boolean => { -// if (!window) { -// return false; -// } -// // Chrome/Edge has this but it's named DirectoryEntry -// // However, it doesn't work properly. -// if ( -// navigator && -// navigator.userAgent && -// navigator.userAgent.includes("Chrome") -// ) { -// return false; -// } -// if (!window.FileSystemDirectoryEntry) { -// return false; -// } -// -// if (!window.FileSystemFileEntry) { -// return false; -// } -// -// if (!window.FileSystemDirectoryEntry.prototype.createReader) { -// return false; -// } -// -// if (!window.FileSystemDirectoryEntry.prototype.getFile) { -// return false; -// } -// -// if (!window.FileSystemFileEntry.prototype.file) { -// return false; -// } -// -// return true; -// }; -// -// /// FileSys implementation that uses File Entries API -// /// This is not supported in Chrome/Edge, but in Firefox -// export class FileEntriesApiFileSys implements FileSys { -// private rootPath: string; -// private rootEntry: FileSystemDirectoryEntry; -// -// constructor(rootPath: string, rootEntry: FileSystemDirectoryEntry) { -// this.rootPath = rootPath; -// this.rootEntry = rootEntry; -// } -// -// public async init(): Promise> { -// return allocOk(); -// } -// -// public isWritable(): boolean { -// // Entries API does not support writing -// return false; -// } -// -// public isStale(): boolean { -// // Entries API can scan directories -// return false; -// } -// -// public getRootName() { -// return this.rootPath; -// } -// -// public async listDir(path: FsPath): Promise> { -// const result = await this.resolveDir(path); -// if (result.isErr()) { -// return result; -// } -// const dirEntry = result.inner(); -// -// try { -// const entries: FileSystemEntry[] = await new Promise( -// (resolve, reject) => { -// dirEntry.createReader().readEntries(resolve, reject); -// }, -// ); -// const names = entries.map((e) => { -// if (e.isDirectory) { -// return e.name + "/"; -// } -// return e.name; -// }); -// return result.makeOk(names); -// } catch (e) { -// console.error(e); -// return result.makeErr(FsResultCodes.Fail); -// } -// } -// -// public async readFile(path: FsPath): Promise> { -// const parentResult = path.parent; -// if (parentResult.isErr()) { -// return parentResult; -// } -// const nameResult = path.name; -// if (nameResult.isErr()) { -// return nameResult; -// } -// const result = await this.resolveDir(parentResult.inner()); -// if (result.isErr()) { -// return result; -// } -// const dirEntry = result.inner(); -// -// try { -// const fileEntry = (await new Promise( -// (resolve, reject) => { -// dirEntry.getFile(nameResult.inner(), {}, resolve, reject); -// }, -// )) as FileSystemFileEntry; -// const file = await new Promise((resolve, reject) => { -// fileEntry.file(resolve, reject); -// }); -// return result.makeOk(file); -// } catch (e) { -// console.error(e); -// return result.makeErr(FsResultCodes.Fail); -// } -// } -// -// public async writeFile(): Promise> { -// // Entries API does not support writing -// return allocErr(FsResultCodes.NotSupported); -// } -// -// async resolveDir( -// path: FsPath, -// ): Promise> { -// let entry: FileSystemEntry; -// if (path.isRoot) { -// entry = this.rootEntry; -// } else { -// const fullPath = path.path; -// try { -// entry = await new Promise((resolve, reject) => { -// this.rootEntry.getDirectory(fullPath, {}, resolve, reject); -// }); -// } catch (e) { -// console.error(e); -// return allocErr(FsResultCodes.Fail); -// } -// } -// -// if (!entry.isDirectory) { -// return allocErr(FsResultCodes.IsFile); -// } -// return allocOk(entry as FileSystemDirectoryEntry); -// } -// } diff --git a/web-client/src/low/fs/FileSys.ts b/web-client/src/low/fs/FileSys.ts deleted file mode 100644 index c1410572..00000000 --- a/web-client/src/low/fs/FileSys.ts +++ /dev/null @@ -1,53 +0,0 @@ -// import { ResultHandle } from "pure/result"; -// -// import { FsPath } from "./FsPath"; -// import { FsResult } from "./FsResult"; -// -// /// Interface for using the browser's various file system API to access Files -// export interface FileSys { -// /// Async init function -// /// -// /// The FileSys implementation may need to do some async initialization. -// /// For example, request permission from the user. -// init: (r: ResultHandle) => Promise>; -// -// /// 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. -// getRootName: () => string; -// -// /// List files in a 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: FsPath) => Promise>; -// -// /// Read the file as a File object -// /// -// /// Returns Fail if the underlying file system operation fails. -// readFile: (r: ResultHandle, path: FsPath) => Promise>; -// -// /// Returns if this implementation supports writing to a file -// isWritable: () => boolean; -// -// /// Returns if this implementation only keeps a static snapshot of the directory structure -// isStale: () => boolean; -// -// /// 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 -// writeFile: ( -// r: ResultHandle, -// path: FsPath, -// content: string | Uint8Array, -// ) => Promise>; -// } diff --git a/web-client/src/low/fs/FileSystemAccessApiFileSys.ts b/web-client/src/low/fs/FileSystemAccessApiFileSys.ts deleted file mode 100644 index ddc3f6ba..00000000 --- a/web-client/src/low/fs/FileSystemAccessApiFileSys.ts +++ /dev/null @@ -1,207 +0,0 @@ -// 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"; -// -// export const isFileSystemAccessApiSupported = (): boolean => { -// if (!window) { -// return false; -// } -// if (!window.isSecureContext) { -// // In Chrome, you can still access the APIs but they just crash the page entirely -// console.warn("FileSystemAccessAPI is only available in secure context"); -// return false; -// } -// if (!window.FileSystemDirectoryHandle) { -// return false; -// } -// -// if (!window.FileSystemFileHandle) { -// return false; -// } -// -// // @ts-expect-error FileSystemDirectoryHandle should have a values() method -// if (!window.FileSystemDirectoryHandle.prototype.values) { -// return false; -// } -// -// if (!window.FileSystemFileHandle.prototype.createWritable) { -// return false; -// } -// -// // @ts-expect-error window should have showDirectoryPicker -// if (!window.showDirectoryPicker) { -// return false; -// } -// -// return true; -// }; -// -// type PermissionStatus = "granted" | "denied" | "prompt"; -// -// /// FileSys implementation that uses FileSystem Access API -// /// This is only supported in Chrome/Edge -// export class FileSystemAccessApiFileSys implements FileSys { -// private rootPath: string; -// private rootHandle: FileSystemDirectoryHandle; -// private permissionStatus: PermissionStatus; -// -// constructor(rootPath: string, rootHandle: FileSystemDirectoryHandle) { -// this.rootPath = rootPath; -// this.rootHandle = rootHandle; -// this.permissionStatus = "prompt"; -// } -// -// public async init(): Promise> { -// // @ts-expect-error ts lib does not have requestPermission -// this.permissionStatus = await this.rootHandle.requestPermission({ -// mode: "readwrite", -// }); -// if (this.permissionStatus !== "granted") { -// return allocErr(FsResultCodes.PermissionDenied); -// } -// return allocOk(); -// } -// -// public isWritable(): boolean { -// return ( -// isFileSystemAccessApiSupported() && -// this.permissionStatus === "granted" -// ); -// } -// -// public isStale(): boolean { -// return false; -// } -// -// public getRootName() { -// return this.rootPath; -// } -// -// public async listDir(path: FsPath): Promise> { -// const result = await this.resolveDir(path); -// if (result.isErr()) { -// return result; -// } -// const dir = result.inner(); -// const entries: string[] = []; -// -// try { -// // @ts-expect-error FileSystemDirectoryHandle should have a values() method -// for await (const entry of dir.values()) { -// if (entry.kind === "directory") { -// entries.push(entry.name + "/"); -// } else { -// entries.push(entry.name); -// } -// } -// } catch (e) { -// console.error(e); -// return result.makeErr(FsResultCodes.Fail); -// } -// -// return result.makeOk(entries); -// } -// -// private async resolveDir( -// r: ResultHandle, -// path: FsPath, -// ): Promise> { -// if (path.isRoot) { -// return r.putOk(this.rootHandle); -// } -// -// r.put(path.getParent(r)); -// if (r.isErr()) { -// return r.ret(); -// } -// const parentPath = r.value; -// -// r.put(await this.resolveDir(r = r.erase(), parentPath)); -// if (r.isErr()) { -// return r; -// } -// const parentDirHandle = r.value; -// -// r.put(path.getName(r = r.erase())); -// if (r.isErr()) { -// return r.ret(); -// } -// const pathName = r.value; -// -// 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 r.ret(); -// } -// -// public async readFile(path: FsPath): Promise> { -// const result = await this.resolveFile(path); -// if (result.isErr()) { -// return result; -// } -// try { -// const file = await result.inner().getFile(); -// return result.makeOk(file); -// } catch (e) { -// console.error(e); -// return result.makeErr(FsResultCodes.Fail); -// } -// } -// -// public async writeFile( -// path: FsPath, -// content: string | Uint8Array, -// ): Promise> { -// const result = await this.resolveFile(path); -// -// if (result.isErr()) { -// return result; -// } -// try { -// const file = await result.inner().createWritable(); -// await file.write(content); -// await file.close(); -// return result.makeOk(undefined); -// } catch (e) { -// console.error(e); -// return result.makeErr(FsResultCodes.Fail); -// } -// } -// -// async resolveFile(path: FsPath): Promise> { -// const parentDirResult = path.parent; -// if (parentDirResult.isErr()) { -// return parentDirResult; -// } -// -// const parentDirHandleResult = await this.resolveDir( -// parentDirResult.inner(), -// ); -// if (parentDirHandleResult.isErr()) { -// return parentDirHandleResult; -// } -// -// const result = path.name; -// if (result.isErr()) { -// return result; -// } -// -// try { -// const fileHandle = await parentDirHandleResult -// .inner() -// .getFileHandle(result.inner()); -// return result.makeOk(fileHandle); -// } catch (e) { -// console.error(e); -// return result.makeErr(FsResultCodes.Fail); -// } -// } -// } diff --git a/web-client/src/low/fs/FsFile.ts b/web-client/src/low/fs/FsFile.ts deleted file mode 100644 index 1bc7a842..00000000 --- a/web-client/src/low/fs/FsFile.ts +++ /dev/null @@ -1,190 +0,0 @@ -// import { console } from "low/utils"; -// -// import { FsPath } from "./FsPath"; -// import { FileSys } from "./FileSys"; -// import { FsResult, FsResultCodes } from "./FsResult"; -// -// /// A wrapper for the concept of a virtual, opened file. -// /// -// /// The file is lazy-loaded. It's content will only be loaded when getContent is called. -// export class FsFile { -// /// Reference to the file system so we can read/write -// private fs: FileSys; -// /// The path of the file -// private path: FsPath; -// /// 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: FileSys, path: FsPath) { -// 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 getDisplayPath(): string { -// return this.path.path; -// } -// -// public isNewerThanFs(): boolean { -// return this.isBufferDirty || this.isContentNewer; -// } -// -// /// Get the last modified time. May load it from file system if needed -// /// -// /// If fails to load, returns 0 -// public async getLastModified(): Promise { -// if (this.lastModified === undefined) { -// await this.loadIfNotDirty(); -// } -// return this.lastModified ?? 0; -// } -// -// /// 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 -// public async getText(): Promise> { -// if (this.buffer === undefined) { -// const result = await this.load(); -// if (result.isErr()) { -// return result; -// } -// } -// if (!this.isText) { -// return allocErr(FsResultCodes.InvalidEncoding); -// } -// return allocOk(this.content ?? ""); -// } -// -// public async getBytes(): Promise> { -// this.updateBuffer(); -// if (this.buffer === undefined) { -// const result = await this.load(); -// if (result.isErr()) { -// return result; -// } -// } -// if (this.buffer === undefined) { -// return allocErr(FsResultCodes.Fail); -// } -// return allocOk(this.buffer); -// } -// -// /// Set the content in memory. Does not save to FS. -// public setContent(content: string): void { -// if (this.content === content) { -// return; -// } -// this.content = content; -// this.isContentNewer = true; -// this.lastModified = new Date().getTime(); -// } -// -// /// Load the file's content if it's not newer than fs -// /// -// /// Returns Ok if the file is newer than fs -// public async loadIfNotDirty(): Promise> { -// if (this.isNewerThanFs()) { -// return allocOk(); -// } -// return await this.load(); -// } -// -// /// Load the file's content from FS. -// /// -// /// Overwrites any unsaved changes only if the file has been -// /// modified since it was last loaded. -// /// -// /// If it fails, the file's content will not be changed -// public async load(): Promise> { -// const result = await this.fs.readFile(this.path); -// -// if (result.isErr()) { -// return result; -// } -// -// const file = result.inner(); -// // check if the file has been modified since last loaded -// if (this.lastModified !== undefined) { -// if (file.lastModified <= this.lastModified) { -// return result.makeOk(undefined); -// } -// } -// this.lastModified = file.lastModified; -// // load the buffer -// try { -// this.buffer = new Uint8Array(await file.arrayBuffer()); -// } catch (e) { -// console.error(e); -// return result.makeErr(FsResultCodes.Fail); -// } -// this.isBufferDirty = false; -// // Try decoding the buffer as text -// try { -// this.content = new TextDecoder("utf-8", { fatal: true }).decode( -// this.buffer, -// ); -// this.isText = true; -// } catch (_) { -// this.content = undefined; -// this.isText = false; -// } -// this.isContentNewer = false; -// return result.makeOk(undefined); -// } -// -// /// Save the file's content to FS if it is newer. -// /// -// /// If not dirty, returns Ok -// public async writeIfNewer(): Promise> { -// if (!this.isNewerThanFs()) { -// return allocOk(); -// } -// return await this.write(); -// } -// -// /// 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(): Promise> { -// this.updateBuffer(); -// const buffer = this.buffer; -// if (this.content === undefined || buffer === undefined) { -// // file was never read or modified -// return allocOk(); -// } -// const result = await this.fs.writeFile(this.path, buffer); -// if (result.isErr()) { -// return result; -// } -// this.isBufferDirty = false; -// return result.makeOk(undefined); -// } -// -// /// 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/web-client/src/low/fs/FsPath.ts b/web-client/src/low/fs/FsPath.ts deleted file mode 100644 index 1a7289ff..00000000 --- a/web-client/src/low/fs/FsPath.ts +++ /dev/null @@ -1,119 +0,0 @@ -// import { ResultHandle } from "pure/result"; -// -// import { FsResult, FsResultCodes } from "./FsResult"; -// -// /// File system path -// /// -// /// This is an abstraction on path to a file/directory in a FileSys, -// /// so that we can have consistency between path representation. -// /// It always represents an absolute path, relative to the root directory. -// /// -// /// FsPath is immutable. Operations return new FsPath objects. -// export interface FsPath { -// /// Returns if this path is the root directory. -// readonly isRoot: boolean; -// -// /// Get the parent directory of this path. -// /// -// /// For files, return the directory it is in. -// /// For directories, return the parent directory. -// /// -// /// If this path is the root directory, return IsRoot. -// /// -// /// This does not check if the path exists. -// getParent(r: ResultHandle): FsResult; -// -// /// Get the name of this path. -// /// -// /// Returns the last component of the path. -// /// Does not include leading or trailing slashes. -// // -// /// -// /// Examples: -// /// "/foo/bar" -> "bar" -// /// "/foo/bar/" -> "bar" -// /// "/" -> IsRoot -// getName(r: ResultHandle): FsResult; -// -// /// Get the full path as string representation. -// /// -// /// This does not come with a leading slash. -// /// Returns an empty string for the root directory. -// readonly path: string; -// -// /// Resolve a descendant path. -// resolve(path: string): FsPath; -// -// /// Resolve a sibling path. -// /// -// /// Returns IsRoot if this is the root directory. -// resolveSibling(r: ResultHandle, path: string): FsResult; -// } -// -// class FsPathImpl implements FsPath { -// /// Underlying path -// /// -// /// This is the full path, with no the leading or trailing slash. -// /// For root, this is an empty string. -// private underlying: string; -// -// constructor(path: string) { -// this.underlying = path; -// } -// -// get isRoot(): boolean { -// return this.underlying === ""; -// } -// -// getParent(r: ResultHandle): FsResult { -// if (this.underlying === "") { -// return r.putErr(FsResultCodes.IsRoot); -// } -// -// const i = this.underlying.lastIndexOf("/"); -// if (i < 0) { -// return r.putOk(fsRootPath); -// } -// return r.putOk(new FsPathImpl(this.underlying.substring(0, i))); -// } -// -// getName(r: ResultHandle): FsResult { -// if (this.underlying === "") { -// return r.putErr(FsResultCodes.IsRoot); -// } -// -// const i = this.underlying.lastIndexOf("/"); -// if (i < 0) { -// return r.putOk(this.underlying); -// } -// return r.putOk(this.underlying.substring(i + 1)); -// } -// -// get path(): string { -// return this.underlying; -// } -// -// public resolve(path: string): FsPath { -// if (path === "") { -// return this; -// } -// if (this.underlying === "") { -// return new FsPathImpl(cleanPath(path)); -// } -// 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)); -// // } -// } -// -// const cleanPath = (path: string) => { -// return path.replace(/^\/+|\/+$/g, ""); -// }; -// -// export const fsRootPath: FsPath = new FsPathImpl(""); diff --git a/web-client/src/low/fs/FsResult.ts b/web-client/src/low/fs/FsResult.ts deleted file mode 100644 index 312246d9..00000000 --- a/web-client/src/low/fs/FsResult.ts +++ /dev/null @@ -1,28 +0,0 @@ -// import { Result, StableResult } from "pure/result"; -// -// /// Result type for file system operations -// export const FsResultCodes = { -// /// 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, -// } as const; -// -// export type FsResultCode = (typeof FsResultCodes)[keyof typeof FsResultCodes]; -// -// export type FsResult = Result; -// export type FsStableResult = StableResult; diff --git a/web-client/src/low/fs/index.ts b/web-client/src/low/fs/index.ts deleted file mode 100644 index 69f75f01..00000000 --- a/web-client/src/low/fs/index.ts +++ /dev/null @@ -1,14 +0,0 @@ -//! low/fs -//! -//! File System access - -// We log a message here to ensure that fs is only loaded when editor is used -// import { console } from "low/utils"; -// console.info("loading file system module"); -// -// export * from "./FileAccess"; -// export * from "./FileSys"; -// export * from "./FsResult"; -// export * from "./FsFile"; -// export * from "./FsPath"; -// export * from "./open"; diff --git a/web-client/src/low/fs/open.ts b/web-client/src/low/fs/open.ts deleted file mode 100644 index f2d41f1c..00000000 --- a/web-client/src/low/fs/open.ts +++ /dev/null @@ -1,146 +0,0 @@ -// //! Utils for opening FileSys -// -// import { ResultHandle } from "pure/result"; -// -// import { console } from "low/utils"; -// -// import { -// FileEntriesApiFileSys, -// isFileEntriesApiSupported, -// } from "./FileEntriesApiFileSys"; -// import { FileSys } from "./FileSys"; -// import { -// FileSystemAccessApiFileSys, -// isFileSystemAccessApiSupported, -// } from "./FileSystemAccessApiFileSys"; -// import { FsResult, FsResultCodes } from "./FsResult"; -// import { FileApiFileSys } from "./FileApiFileSys"; -// -// export async function showDirectoryPicker(r: ResultHandle): Promise> { -// if (isFileSystemAccessApiSupported()) { -// try { -// // @ts-expect-error showDirectoryPicker is not in the TS lib -// const handle = await window.showDirectoryPicker({ -// mode: "readwrite", -// }); -// if (!handle) { -// console.error("Failed to get handle from showDirectoryPicker"); -// return allocErr(FsResultCodes.Fail); -// } -// return createFsFromFileSystemHandle(handle); -// } catch (e) { -// console.error(e); -// // eslint-disable-next-line @typescript-eslint/no-explicit-any -// if (e && (e as any).name === "AbortError") { -// return allocErr(FsResultCodes.UserAbort); -// } -// return allocErr(FsResultCodes.Fail); -// } -// } -// // 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; -// return await new Promise((resolve) => { -// inputElement.addEventListener("change", (event) => { -// const files = (event.target as HTMLInputElement).files; -// if (!files) { -// resolve(allocErr(FsResultCodes.Fail)); -// return; -// } -// resolve(createFsFromFileList(files)); -// }); -// inputElement.click(); -// }); -// } -// -// function -// -// export const createFsFromDataTransferItem = async ( -// item: DataTransferItem, -// ): Promise> => { -// // Prefer File System Access API since it supports writing -// if ("getAsFileSystemHandle" in item) { -// if (isFileSystemAccessApiSupported()) { -// try { -// // @ts-expect-error getAsFileSystemHandle is not in the TS lib -// const handle = await item.getAsFileSystemHandle(); -// if (!handle) { -// console.error("Failed to get handle from DataTransferItem"); -// return allocErr(FsResultCodes.Fail); -// } -// return createFsFromFileSystemHandle(handle); -// } catch (e) { -// console.error(e); -// } -// } -// } -// console.warn( -// "Failed to create FileSys with FileSystemAccessAPI. Trying FileEntriesAPI", -// ); -// if ("webkitGetAsEntry" in item) { -// if (isFileEntriesApiSupported()) { -// try { -// const entry = item.webkitGetAsEntry(); -// if (!entry) { -// console.error("Failed to get entry from DataTransferItem"); -// return allocErr(FsResultCodes.Fail); -// } -// return createFsFromFileSystemEntry(entry); -// } catch (e) { -// console.error(e); -// } -// } -// } -// console.warn( -// "Failed to create FileSys with FileEntriesAPI. Editor is not supported", -// ); -// return allocErr(FsResultCodes.NotSupported); -// }; -// -// const createFsFromFileSystemHandle = ( -// handle: FileSystemHandle, -// ): FsResult => { -// if (handle.kind !== "directory") { -// return allocErr(FsResultCodes.IsFile); -// } -// -// const fs = new FileSystemAccessApiFileSys( -// handle.name, -// handle as FileSystemDirectoryHandle, -// ); -// -// return allocOk(fs); -// }; -// -// const createFsFromFileSystemEntry = ( -// entry: FileSystemEntry, -// ): FsResult => { -// if (entry.isFile || !entry.isDirectory) { -// return allocErr(FsResultCodes.IsFile); -// } -// const fs = new FileEntriesApiFileSys( -// entry.name, -// entry as FileSystemDirectoryEntry, -// ); -// return allocOk(fs); -// }; -// -// const createFsFromFileList = (files: FileList): FsResult => { -// if (!files.length) { -// return allocErr(FsResultCodes.Fail); -// } -// 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 allocOk(fs); -// }; From 6fadf10a6e6abb2524115b07327171e3ea2b42b9 Mon Sep 17 00:00:00 2001 From: Pistonight Date: Mon, 26 Feb 2024 20:31:04 -0800 Subject: [PATCH 09/10] reorganize and fix editor bugs --- libs/package-lock.json | 38 ------ libs/package.json | 13 -- libs/pure/src/fs/impl/index.ts | 3 - libs/pure/src/fs/index.ts | 7 -- libs/tsconfig.json | 28 ----- server/src/api/view.rs | 2 +- web-client/.eslintrc.cjs | 3 +- web-client/Taskfile.yml | 7 +- {libs => web-client/libs}/pure/README.md | 0 .../libs/pure/fs/FsError.ts | 0 .../src => web-client/libs/pure}/fs/FsFile.ts | 2 +- .../libs/pure/fs/FsFileImpl.ts | 7 +- .../libs/pure/fs}/FsFileMgr.ts | 6 +- .../libs/pure}/fs/FsFileSystem.ts | 15 ++- .../libs/pure/fs}/FsFileSystemInternal.ts | 2 +- .../libs/pure/fs/FsImplEntryAPI.ts | 16 ++- .../libs/pure/fs/FsImplFileAPI.ts | 23 ++-- .../libs/pure/fs/FsImplHandleAPI.ts | 21 ++-- .../libs/pure/fs/FsOpen.ts | 22 ++-- .../libs/pure/fs/FsPath.ts | 2 +- .../libs/pure/fs/FsSave.ts | 0 .../libs/pure/fs/FsSupportStatus.ts | 12 +- .../src => web-client/libs/pure}/fs/README.md | 0 web-client/libs/pure/fs/index.ts | 24 ++++ .../libs/pure}/log/README.md | 0 .../src => web-client/libs/pure}/log/index.ts | 4 +- .../libs/pure}/result/README.md | 0 .../libs/pure}/result/index.ts | 0 .../libs/pure/utils/RwLock.ts | 4 +- .../libs/pure}/utils/index.ts | 2 +- web-client/package-lock.json | 25 ++++ web-client/package.json | 2 + .../src/core/compiler/CompilerKernelImpl.ts | 14 ++- web-client/src/core/editor/ChangeTracker.ts | 119 ++++++++++++++++++ .../src/core/editor/ExternalEditorKernel.ts | 85 +++++++++---- web-client/src/core/editor/FileMgr.ts | 8 +- .../src/core/editor/ModifyTimeTracker.ts | 35 ------ web-client/src/core/kernel/Kernel.ts | 4 +- web-client/src/low/utils/Yielder.ts | 4 +- web-client/src/ui/editor/EditorTree.tsx | 39 ++---- web-client/tsconfig.json | 4 +- web-client/vite.config.ts | 6 +- 42 files changed, 347 insertions(+), 261 deletions(-) delete mode 100644 libs/package-lock.json delete mode 100644 libs/package.json delete mode 100644 libs/pure/src/fs/impl/index.ts delete mode 100644 libs/pure/src/fs/index.ts delete mode 100644 libs/tsconfig.json rename {libs => web-client/libs}/pure/README.md (100%) rename libs/pure/src/fs/error.ts => web-client/libs/pure/fs/FsError.ts (100%) rename {libs/pure/src => web-client/libs/pure}/fs/FsFile.ts (97%) rename libs/pure/src/fs/impl/file.ts => web-client/libs/pure/fs/FsFileImpl.ts (96%) rename {libs/pure/src/fs/impl => web-client/libs/pure/fs}/FsFileMgr.ts (79%) rename {libs/pure/src => web-client/libs/pure}/fs/FsFileSystem.ts (80%) rename {libs/pure/src/fs/impl => web-client/libs/pure/fs}/FsFileSystemInternal.ts (94%) rename libs/pure/src/fs/impl/fe.ts => web-client/libs/pure/fs/FsImplEntryAPI.ts (90%) rename libs/pure/src/fs/impl/f.ts => web-client/libs/pure/fs/FsImplFileAPI.ts (83%) rename libs/pure/src/fs/impl/fsa.ts => web-client/libs/pure/fs/FsImplHandleAPI.ts (90%) rename libs/pure/src/fs/open.ts => web-client/libs/pure/fs/FsOpen.ts (95%) rename libs/pure/src/fs/path.ts => web-client/libs/pure/fs/FsPath.ts (98%) rename libs/pure/src/fs/save.ts => web-client/libs/pure/fs/FsSave.ts (100%) rename libs/pure/src/fs/support.ts => web-client/libs/pure/fs/FsSupportStatus.ts (89%) rename {libs/pure/src => web-client/libs/pure}/fs/README.md (100%) create mode 100644 web-client/libs/pure/fs/index.ts rename {libs/pure/src => web-client/libs/pure}/log/README.md (100%) rename {libs/pure/src => web-client/libs/pure}/log/index.ts (99%) rename {libs/pure/src => web-client/libs/pure}/result/README.md (100%) rename {libs/pure/src => web-client/libs/pure}/result/index.ts (100%) rename libs/pure/src/utils/lock.ts => web-client/libs/pure/utils/RwLock.ts (95%) rename {libs/pure/src => web-client/libs/pure}/utils/index.ts (92%) create mode 100644 web-client/src/core/editor/ChangeTracker.ts delete mode 100644 web-client/src/core/editor/ModifyTimeTracker.ts diff --git a/libs/package-lock.json b/libs/package-lock.json deleted file mode 100644 index ecb1ee2f..00000000 --- a/libs/package-lock.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "name": "libs", - "version": "0.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "libs", - "version": "0.0.0", - "dependencies": { - "denque": "^2.1.0", - "file-saver": "2.0.5" - }, - "devDependencies": { - "@types/file-saver": "^2.0.7" - } - }, - "node_modules/@types/file-saver": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/@types/file-saver/-/file-saver-2.0.7.tgz", - "integrity": "sha512-dNKVfHd/jk0SkR/exKGj2ggkB45MAkzvWCaqLUUgkyjITkGNzH8H+yUwr+BLJUBjZOe9w8X3wgmXhZDRg1ED6A==", - "dev": true - }, - "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" - } - }, - "node_modules/file-saver": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/file-saver/-/file-saver-2.0.5.tgz", - "integrity": "sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA==" - } - } -} diff --git a/libs/package.json b/libs/package.json deleted file mode 100644 index 8e23df8e..00000000 --- a/libs/package.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "name": "libs", - "private": true, - "version": "0.0.0", - "type": "module", - "dependencies": { - "denque": "^2.1.0", - "file-saver": "2.0.5" - }, - "devDependencies": { - "@types/file-saver": "^2.0.7" - } -} diff --git a/libs/pure/src/fs/impl/index.ts b/libs/pure/src/fs/impl/index.ts deleted file mode 100644 index c7fe9e71..00000000 --- a/libs/pure/src/fs/impl/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from "./fsa"; -export * from "./fe"; -export * from "./f"; diff --git a/libs/pure/src/fs/index.ts b/libs/pure/src/fs/index.ts deleted file mode 100644 index 6029a2ac..00000000 --- a/libs/pure/src/fs/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -export * from "./support"; -export * from "./path"; -export * from "./open"; -export * from "./FsFileSystem"; -export * from "./FsFile"; -export * from "./error"; -export * from "./save"; diff --git a/libs/tsconfig.json b/libs/tsconfig.json deleted file mode 100644 index 338267fe..00000000 --- a/libs/tsconfig.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "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, - - "paths": { - "pure/*": ["pure/src/*"] - } - }, - "include": ["pure/src"], -} diff --git a/server/src/api/view.rs b/server/src/api/view.rs index c2e6acdf..cd6d9fe8 100644 --- a/server/src/api/view.rs +++ b/server/src/api/view.rs @@ -174,7 +174,7 @@ async fn view_internal( repo_desc.push('/'); repo_desc.push_str(path); } - if !reference.is_empty() { + if !reference.is_empty() && reference != "main" { repo_desc.push_str(" ("); repo_desc.push_str(reference); repo_desc.push(')'); diff --git a/web-client/.eslintrc.cjs b/web-client/.eslintrc.cjs index b7f3f2b9..6bc9dc72 100644 --- a/web-client/.eslintrc.cjs +++ b/web-client/.eslintrc.cjs @@ -19,7 +19,7 @@ module.exports = { extensions: [".js", ".jsx", ".ts", ".tsx"], }, }, - "import/external-module-folders": ["node_modules", "src"], + "import/external-module-folders": ["node_modules", "src", "libs"], }, ignorePatterns: ["*.d.ts"], rules: { @@ -32,6 +32,7 @@ module.exports = { argsIgnorePattern: "_", }, ], + "no-constant-condition": ["error", { checkLoops: false }], "no-multiple-empty-lines": [ "warn", { diff --git a/web-client/Taskfile.yml b/web-client/Taskfile.yml index c5d3266a..0e7d46e6 100644 --- a/web-client/Taskfile.yml +++ b/web-client/Taskfile.yml @@ -9,6 +9,11 @@ tasks: cmds: - npm ci + ci:libs: + dir: + cmds: + + dev: desc: Start web client in watch mode cmds: @@ -17,6 +22,7 @@ tasks: build: desc: Build web client assets cmds: + - npx tsc - npx vite build - node tools/post-build.cjs - rm dist/index.html @@ -26,7 +32,6 @@ tasks: aliases: [lint] cmds: - node tools/lint/run-custom-lints.cjs - - npx tsc - task: eslint vars: ESLINT_ARGS: "" diff --git a/libs/pure/README.md b/web-client/libs/pure/README.md similarity index 100% rename from libs/pure/README.md rename to web-client/libs/pure/README.md diff --git a/libs/pure/src/fs/error.ts b/web-client/libs/pure/fs/FsError.ts similarity index 100% rename from libs/pure/src/fs/error.ts rename to web-client/libs/pure/fs/FsError.ts diff --git a/libs/pure/src/fs/FsFile.ts b/web-client/libs/pure/fs/FsFile.ts similarity index 97% rename from libs/pure/src/fs/FsFile.ts rename to web-client/libs/pure/fs/FsFile.ts index 86ac90e2..2dfad7ae 100644 --- a/libs/pure/src/fs/FsFile.ts +++ b/web-client/libs/pure/fs/FsFile.ts @@ -1,4 +1,4 @@ -import { FsResult, FsVoid } from "./error"; +import { FsResult, FsVoid } from "./FsError.ts"; /// Interface for operating on a file in the loaded file system export interface FsFile { diff --git a/libs/pure/src/fs/impl/file.ts b/web-client/libs/pure/fs/FsFileImpl.ts similarity index 96% rename from libs/pure/src/fs/impl/file.ts rename to web-client/libs/pure/fs/FsFileImpl.ts index aa2847cb..93f4cf1b 100644 --- a/libs/pure/src/fs/impl/file.ts +++ b/web-client/libs/pure/fs/FsFileImpl.ts @@ -1,9 +1,9 @@ import { errstr } from "pure/utils"; import { tryAsync } from "pure/result"; -import { FsFile } from "../FsFile"; -import { FsFileSystemInternal } from "./FsFileSystemInternal"; -import { FsErr, FsResult, FsVoid, fsErr, fsFail } from "../error"; +import { FsFile } from "./FsFile.ts"; +import { FsFileSystemInternal } from "./FsFileSystemInternal.ts"; +import { FsErr, FsResult, FsVoid, fsErr, fsFail } from "./FsError.ts"; /// Allocate a new file object export function fsFile(fs: FsFileSystemInternal, path: string): FsFile { @@ -191,6 +191,7 @@ class FsFileImpl implements FsFile { if (result.err) { return result; } + this.isBufferDirty = false; return {}; } diff --git a/libs/pure/src/fs/impl/FsFileMgr.ts b/web-client/libs/pure/fs/FsFileMgr.ts similarity index 79% rename from libs/pure/src/fs/impl/FsFileMgr.ts rename to web-client/libs/pure/fs/FsFileMgr.ts index 711704c7..891dd0c3 100644 --- a/libs/pure/src/fs/impl/FsFileMgr.ts +++ b/web-client/libs/pure/fs/FsFileMgr.ts @@ -1,6 +1,6 @@ -import { FsFile } from "../FsFile"; -import { FsFileSystemInternal } from "./FsFileSystemInternal"; -import { fsFile } from "./file"; +import { FsFile } from "./FsFile.ts"; +import { FsFileSystemInternal } from "./FsFileSystemInternal.ts"; +import { fsFile } from "./FsFileImpl.ts"; /// Internal class to track opened files export class FsFileMgr { diff --git a/libs/pure/src/fs/FsFileSystem.ts b/web-client/libs/pure/fs/FsFileSystem.ts similarity index 80% rename from libs/pure/src/fs/FsFileSystem.ts rename to web-client/libs/pure/fs/FsFileSystem.ts index 00a90d8d..09aa0cf0 100644 --- a/libs/pure/src/fs/FsFileSystem.ts +++ b/web-client/libs/pure/fs/FsFileSystem.ts @@ -1,6 +1,5 @@ -import { FsFile } from "./FsFile"; -import { FsResult } from "./error"; -import { FsCapabilities } from "./support"; +import { FsFile } from "./FsFile.ts"; +import { FsResult } from "./FsError.ts"; /// File system before it is initialized /// @@ -50,3 +49,13 @@ export interface FsFileSystem { getOpenedPaths: () => string[]; } + +/// Capabilities of the file system implementation +export type FsCapabilities = { + /// Can the browser directly write to the file system + write: boolean; + /// Can the browser detect live updates: + /// - Change of modified time + /// - Change of directory structure (new, renamed, deleted files) + live: boolean; +} diff --git a/libs/pure/src/fs/impl/FsFileSystemInternal.ts b/web-client/libs/pure/fs/FsFileSystemInternal.ts similarity index 94% rename from libs/pure/src/fs/impl/FsFileSystemInternal.ts rename to web-client/libs/pure/fs/FsFileSystemInternal.ts index d706d5ed..12b4b4d3 100644 --- a/libs/pure/src/fs/impl/FsFileSystemInternal.ts +++ b/web-client/libs/pure/fs/FsFileSystemInternal.ts @@ -1,4 +1,4 @@ -import { FsResult, FsVoid } from "../error"; +import { FsResult, FsVoid } from "./FsError.ts"; /// Internal APIs for FsFileSystem export interface FsFileSystemInternal { diff --git a/libs/pure/src/fs/impl/fe.ts b/web-client/libs/pure/fs/FsImplEntryAPI.ts similarity index 90% rename from libs/pure/src/fs/impl/fe.ts rename to web-client/libs/pure/fs/FsImplEntryAPI.ts index 613dab3b..e74db702 100644 --- a/libs/pure/src/fs/impl/fe.ts +++ b/web-client/libs/pure/fs/FsImplEntryAPI.ts @@ -1,17 +1,15 @@ import { Ok, tryAsync } from "pure/result"; import { errstr } from "pure/utils"; -import { FsErr, FsResult, FsVoid, fsErr, fsFail } from "../error"; -import { FsFileSystem, FsFileSystemUninit } from "../FsFileSystem"; -import { FsCapabilities } from "../support"; -import { FsFile } from "../FsFile"; -import { fsIsRoot, fsNormalize } from "../path"; - -import { FsFileMgr } from "./FsFileMgr"; -import { FsFileSystemInternal } from "./FsFileSystemInternal"; +import { FsErr, FsResult, FsVoid, fsErr, fsFail } from "./FsError.ts"; +import { FsFileSystem, FsFileSystemUninit, FsCapabilities } from "./FsFileSystem.ts"; +import { FsFile } from "./FsFile.ts"; +import { fsIsRoot, fsNormalize } from "./FsPath.ts"; +import { FsFileMgr } from "./FsFileMgr.ts"; +import { FsFileSystemInternal } from "./FsFileSystemInternal.ts"; /// FsFileSystem implementation that uses FileEntry API -export class FsImplFe implements FsFileSystemUninit, FsFileSystem, FsFileSystemInternal { +export class FsImplEntryAPI implements FsFileSystemUninit, FsFileSystem, FsFileSystemInternal { public root: string; public capabilities: FsCapabilities; diff --git a/libs/pure/src/fs/impl/f.ts b/web-client/libs/pure/fs/FsImplFileAPI.ts similarity index 83% rename from libs/pure/src/fs/impl/f.ts rename to web-client/libs/pure/fs/FsImplFileAPI.ts index 9a7dfe14..934b2f8b 100644 --- a/libs/pure/src/fs/impl/f.ts +++ b/web-client/libs/pure/fs/FsImplFileAPI.ts @@ -1,17 +1,15 @@ -import { FsFile } from "../FsFile"; -import { FsFileSystem, FsFileSystemUninit } from "../FsFileSystem"; -import { FsErr, FsResult, FsVoid, fsErr } from "../error"; -import { fsNormalize } from "../path"; -import { FsCapabilities } from "../support"; - -import { FsFileMgr } from "./FsFileMgr"; -import { FsFileSystemInternal } from "./FsFileSystemInternal"; +import { FsFile } from "./FsFile.ts"; +import { FsFileSystem, FsFileSystemUninit, FsCapabilities } from "./FsFileSystem.ts"; +import { FsErr, FsResult, FsVoid, fsErr } from "./FsError.ts"; +import { fsIsRoot, fsNormalize } from "./FsPath.ts"; +import { FsFileMgr } from "./FsFileMgr.ts"; +import { FsFileSystemInternal } from "./FsFileSystemInternal.ts"; /// FileSystem implementation that uses a list of Files /// This is supported in all browsers, but it is stale. /// It's used for Firefox when the File Entries API is not available /// i.e. opened from -export class FsImplF implements FsFileSystemUninit, FsFileSystem, FsFileSystemInternal { +export class FsImplFileAPI implements FsFileSystemUninit, FsFileSystem, FsFileSystemInternal { public root: string; public capabilities: FsCapabilities; @@ -61,7 +59,7 @@ export class FsImplF implements FsFileSystemUninit, FsFileSystem, FsFileSystemIn } const set = new Set(); - const prefix = path + "/"; + const prefix = fsIsRoot(path) ? "" : path + "/"; Object.keys(this.files).forEach((path) => { if (!path.startsWith(prefix)) { @@ -79,7 +77,10 @@ export class FsImplF implements FsFileSystemUninit, FsFileSystem, FsFileSystemIn } }); - return { val: Array.from(set) }; + const paths = Array.from(set); + this.directories[path] = paths; + + return { val: paths }; } public async read(path: string): Promise> { diff --git a/libs/pure/src/fs/impl/fsa.ts b/web-client/libs/pure/fs/FsImplHandleAPI.ts similarity index 90% rename from libs/pure/src/fs/impl/fsa.ts rename to web-client/libs/pure/fs/FsImplHandleAPI.ts index dcb70336..7f3f00d2 100644 --- a/libs/pure/src/fs/impl/fsa.ts +++ b/web-client/libs/pure/fs/FsImplHandleAPI.ts @@ -3,19 +3,18 @@ import { tryAsync } from "pure/result"; import { errstr } from "pure/utils"; -import { FsFileSystem, FsFileSystemUninit } from "../FsFileSystem"; -import { FsErr, FsResult, FsVoid, fsErr, fsFail } from "../error"; -import { FsCapabilities } from "../support"; -import { FsFile } from "../FsFile"; -import { fsComponents, fsGetBase, fsGetName, fsIsRoot, fsNormalize } from "../path"; -import { FsFileMgr } from "./FsFileMgr"; -import { FsFileSystemInternal } from "./FsFileSystemInternal"; +import { FsFileSystem, FsFileSystemUninit, FsCapabilities } from "./FsFileSystem.ts"; +import { FsErr, FsResult, FsVoid, fsErr, fsFail } from "./FsError.ts"; +import { FsFile } from "./FsFile.ts"; +import { fsComponents, fsGetBase, fsGetName, fsIsRoot, fsNormalize } from "./FsPath.ts"; +import { FsFileMgr } from "./FsFileMgr.ts"; +import { FsFileSystemInternal } from "./FsFileSystemInternal.ts"; type PermissionStatus = "granted" | "denied" | "prompt"; /// FsFileSystem implementation that uses FileSystem Access API /// This is only supported in Chrome/Edge -export class FsImplFsa implements FsFileSystemUninit, FsFileSystem, FsFileSystemInternal { +export class FsImplHandleAPI implements FsFileSystemUninit, FsFileSystem, FsFileSystemInternal { public root: string; public capabilities: FsCapabilities; /// If app requested write access @@ -67,8 +66,8 @@ export class FsImplFsa implements FsFileSystemUninit, FsFileSystem, FsFileSystem const entries = await tryAsync(async () => { const entries: string[] = []; - // @ts-expect-error FileSystemDirectoryHandle.values() not in ts lib - for await (const entry of handle.values()) { + // @ts-expect-error ts lib does not have values() + for await (const entry of handle.val.values()) { const { kind, name } = entry; if (kind === "directory") { entries.push(name + "/"); @@ -194,5 +193,3 @@ export class FsImplFsa implements FsFileSystemUninit, FsFileSystem, FsFileSystem } } - - diff --git a/libs/pure/src/fs/open.ts b/web-client/libs/pure/fs/FsOpen.ts similarity index 95% rename from libs/pure/src/fs/open.ts rename to web-client/libs/pure/fs/FsOpen.ts index 9de8fe85..93abfdde 100644 --- a/libs/pure/src/fs/open.ts +++ b/web-client/libs/pure/fs/FsOpen.ts @@ -1,10 +1,12 @@ import { tryCatch, tryAsync } from "pure/result"; import { errstr } from "pure/utils"; -import { FsFileSystem, FsFileSystemUninit } from "./FsFileSystem"; -import { FsErr, FsError, FsResult, fsErr, fsFail } from "./error"; -import { FsImplFsa, FsImplFe, FsImplF } from "./impl"; -import { fsGetSupportStatus } from "./support"; +import { FsFileSystem, FsFileSystemUninit } from "./FsFileSystem.ts"; +import { FsErr, FsError, FsResult, fsErr, fsFail } from "./FsError.ts"; +import { fsGetSupportStatus } from "./FsSupportStatus.ts"; +import { FsImplFileAPI } from "./FsImplFileAPI.ts"; +import { FsImplEntryAPI } from "./FsImplEntryAPI.ts"; +import { FsImplHandleAPI } from "./FsImplHandleAPI.ts"; /// Handle for handling top level open errors, and decide if the operation should be retried export type FsOpenRetryHandler = (error: FsError, attempt: number) => Promise>; @@ -155,7 +157,7 @@ async function createFromDataTransferItem( if (shouldRetry.err) { // retry handler failed return shouldRetry; - }; + } if (!shouldRetry.val) { // don't retry return { err: error }; @@ -223,21 +225,21 @@ function createFromFileSystemHandle(handle: FileSystemHandle, write: boolean): F return { err }; } - const fs = new FsImplFsa( + const fs = new FsImplHandleAPI( handle.name, handle as FileSystemDirectoryHandle, write ); return { val: fs }; -}; +} function createFromFileSystemEntry(entry: FileSystemEntry): FsResult { if (entry.isFile || !entry.isDirectory) { const err = fsErr(FsErr.IsFile, "Expected directory"); return { err }; } - const fs = new FsImplFe( + const fs = new FsImplEntryAPI( entry.name, entry as FileSystemDirectoryEntry, ); @@ -249,5 +251,5 @@ function createFromFileList(files: FileList): FsResult { const err = fsFail("Expected at least one file"); return { err }; } - return { val: new FsImplF(files) }; -}; + return { val: new FsImplFileAPI(files) }; +} diff --git a/libs/pure/src/fs/path.ts b/web-client/libs/pure/fs/FsPath.ts similarity index 98% rename from libs/pure/src/fs/path.ts rename to web-client/libs/pure/fs/FsPath.ts index 5fc97ac0..7dc880e9 100644 --- a/libs/pure/src/fs/path.ts +++ b/web-client/libs/pure/fs/FsPath.ts @@ -7,7 +7,7 @@ //! - Empty string denotes root //! - Paths cannot lead outside of root -import { FsErr, FsResult, fsErr } from "./error"; +import { FsErr, FsResult, fsErr } from "./FsError.ts"; /// Get the root path. Current implementation is empty string. export function fsRoot(): string { diff --git a/libs/pure/src/fs/save.ts b/web-client/libs/pure/fs/FsSave.ts similarity index 100% rename from libs/pure/src/fs/save.ts rename to web-client/libs/pure/fs/FsSave.ts diff --git a/libs/pure/src/fs/support.ts b/web-client/libs/pure/fs/FsSupportStatus.ts similarity index 89% rename from libs/pure/src/fs/support.ts rename to web-client/libs/pure/fs/FsSupportStatus.ts index b4cfe332..86de137d 100644 --- a/libs/pure/src/fs/support.ts +++ b/web-client/libs/pure/fs/FsSupportStatus.ts @@ -10,14 +10,6 @@ export type FsSupportStatus = { 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()) { @@ -68,7 +60,7 @@ function isFileSystemAccessSupported() { } return true; -}; +} function isFileEntrySupported(): boolean { if (!window) { @@ -95,4 +87,4 @@ function isFileEntrySupported(): boolean { } return true; -}; +} diff --git a/libs/pure/src/fs/README.md b/web-client/libs/pure/fs/README.md similarity index 100% rename from libs/pure/src/fs/README.md rename to web-client/libs/pure/fs/README.md diff --git a/web-client/libs/pure/fs/index.ts b/web-client/libs/pure/fs/index.ts new file mode 100644 index 00000000..273ca239 --- /dev/null +++ b/web-client/libs/pure/fs/index.ts @@ -0,0 +1,24 @@ +export { fsSave } from "./FsSave.ts"; +export { + fsOpenRead, + fsOpenReadWrite, + fsOpenReadFrom, + fsOpenReadWriteFrom +} from "./FsOpen.ts"; +export { fsGetSupportStatus } from "./FsSupportStatus.ts"; +export { + fsRoot, + fsIsRoot, + fsGetBase, + fsGetName, + fsNormalize, + fsJoin, + fsComponents, +} from "./FsPath.ts"; +export { FsErr, fsErr, fsFail } from "./FsError.ts"; + +export type { FsOpenRetryHandler } from "./FsOpen.ts"; +export type { FsSupportStatus } from "./FsSupportStatus.ts"; +export type { FsFileSystem, FsFileSystemUninit, FsCapabilities } from "./FsFileSystem.ts"; +export type { FsFile } from "./FsFile.ts"; +export type { FsError, FsResult, FsVoid } from "./FsError.ts"; diff --git a/libs/pure/src/log/README.md b/web-client/libs/pure/log/README.md similarity index 100% rename from libs/pure/src/log/README.md rename to web-client/libs/pure/log/README.md diff --git a/libs/pure/src/log/index.ts b/web-client/libs/pure/log/index.ts similarity index 99% rename from libs/pure/src/log/index.ts rename to web-client/libs/pure/log/index.ts index 89280720..c1131ca6 100644 --- a/libs/pure/src/log/index.ts +++ b/web-client/libs/pure/log/index.ts @@ -12,12 +12,12 @@ function pushLog(msg: string) { LogQueue.shift(); } LogQueue.push(`[${new Date().toISOString()}]${msg}`); -}; +} /// Get the current log export function getLogLines() { return LogQueue.toArray(); -}; +} /// A general-purpose client side logger export class Logger { diff --git a/libs/pure/src/result/README.md b/web-client/libs/pure/result/README.md similarity index 100% rename from libs/pure/src/result/README.md rename to web-client/libs/pure/result/README.md diff --git a/libs/pure/src/result/index.ts b/web-client/libs/pure/result/index.ts similarity index 100% rename from libs/pure/src/result/index.ts rename to web-client/libs/pure/result/index.ts diff --git a/libs/pure/src/utils/lock.ts b/web-client/libs/pure/utils/RwLock.ts similarity index 95% rename from libs/pure/src/utils/lock.ts rename to web-client/libs/pure/utils/RwLock.ts index 4ac1aa06..d811f7d8 100644 --- a/libs/pure/src/utils/lock.ts +++ b/web-client/libs/pure/utils/RwLock.ts @@ -55,7 +55,7 @@ export class RwLock { /// Acquire a write (exclusive) lock and call fn with the value. Release the lock when fn returns or throws. /// /// fn takes a setter function as second parameter, which you can use to update the value like `x = set(newX)` - public async scopedWrite(fn: (t: TWrite, setter: Setter) => Promise): Promise { + public async scopedWrite(fn: (t: TWrite, setter: (t: TWrite) => TWrite) => Promise): Promise { if (this.isWriting || this.readers > 0) { await new Promise((resolve) => { // need to check again to make sure it's not already done @@ -85,5 +85,3 @@ export class RwLock { } } } - -export type Setter = (t: T) => T; diff --git a/libs/pure/src/utils/index.ts b/web-client/libs/pure/utils/index.ts similarity index 92% rename from libs/pure/src/utils/index.ts rename to web-client/libs/pure/utils/index.ts index 902ffe26..aa0ce016 100644 --- a/libs/pure/src/utils/index.ts +++ b/web-client/libs/pure/utils/index.ts @@ -1,4 +1,4 @@ -export * from "./lock"; +export { RwLock } from "./RwLock.ts"; /// Try converting an error to a string export function errstr(e: unknown): string { diff --git a/web-client/package-lock.json b/web-client/package-lock.json index d1fde2bc..bb8f6d73 100644 --- a/web-client/package-lock.json +++ b/web-client/package-lock.json @@ -12,9 +12,11 @@ "@fluentui/react-icons": "^2.0.214", "@reduxjs/toolkit": "^1.9.5", "denque": "^2.1.0", + "file-saver": "^2.0.5", "immer": "^10.0.2", "is-equal": "^1.6.4", "js-yaml": "^4.1.0", + "jshashes": "^1.0.8", "leaflet": "^1.9.4", "leaflet-arrowheads": "^1.4.0", "leaflet-rastercoords": "^1.0.5", @@ -36,6 +38,7 @@ "@testing-library/jest-dom": "^5.17.0", "@testing-library/react": "^14.0.0", "@testing-library/user-event": "^14.4.3", + "@types/file-saver": "^2.0.7", "@types/js-yaml": "^4.0.9", "@types/leaflet": "^1.9.3", "@types/leaflet-rastercoords": "^1.0.2", @@ -5201,6 +5204,12 @@ "@babel/types": "^7.20.7" } }, + "node_modules/@types/file-saver": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/file-saver/-/file-saver-2.0.7.tgz", + "integrity": "sha512-dNKVfHd/jk0SkR/exKGj2ggkB45MAkzvWCaqLUUgkyjITkGNzH8H+yUwr+BLJUBjZOe9w8X3wgmXhZDRg1ED6A==", + "dev": true + }, "node_modules/@types/geojson": { "version": "7946.0.10", "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.10.tgz", @@ -7470,6 +7479,11 @@ "node": "^10.12.0 || >=12.0.0" } }, + "node_modules/file-saver": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/file-saver/-/file-saver-2.0.5.tgz", + "integrity": "sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA==" + }, "node_modules/fill-range": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", @@ -10424,6 +10438,17 @@ "node": ">=4" } }, + "node_modules/jshashes": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/jshashes/-/jshashes-1.0.8.tgz", + "integrity": "sha512-btmQZ/w1rj8Lb6nEwvhjM7nBYoj54yaEFo2PWh3RkxZ8qNwuvOxvQYN/JxVuwoMmdIluL+XwYVJ+pEEZoSYybQ==", + "bin": { + "hashes": "bin/hashes" + }, + "engines": { + "node": "*" + } + }, "node_modules/json-parse-even-better-errors": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", diff --git a/web-client/package.json b/web-client/package.json index 3d30d118..fabb0241 100644 --- a/web-client/package.json +++ b/web-client/package.json @@ -8,6 +8,7 @@ "@fluentui/react-icons": "^2.0.214", "@reduxjs/toolkit": "^1.9.5", "denque": "^2.1.0", + "file-saver": "^2.0.5", "immer": "^10.0.2", "is-equal": "^1.6.4", "js-yaml": "^4.1.0", @@ -32,6 +33,7 @@ "@testing-library/jest-dom": "^5.17.0", "@testing-library/react": "^14.0.0", "@testing-library/user-event": "^14.4.3", + "@types/file-saver": "^2.0.7", "@types/js-yaml": "^4.0.9", "@types/leaflet": "^1.9.3", "@types/leaflet-rastercoords": "^1.0.2", diff --git a/web-client/src/core/compiler/CompilerKernelImpl.ts b/web-client/src/core/compiler/CompilerKernelImpl.ts index 712e8415..e48ee076 100644 --- a/web-client/src/core/compiler/CompilerKernelImpl.ts +++ b/web-client/src/core/compiler/CompilerKernelImpl.ts @@ -62,12 +62,14 @@ export class CompilerKernelImpl implements CompilerKernel { private lastPluginOptions: PluginOptionsRaw | undefined; private cleanup: () => void; + private waiters: Array<(x: unknown) => void>; constructor(store: AppStore) { this.store = store; this.needCompile = false; this.compiling = false; this.compilerLock = new ReentrantLock("compiler"); + this.waiters = []; this.cleanup = () => { // no cleanup needed for now @@ -176,8 +178,13 @@ export class CompilerKernelImpl implements CompilerKernel { // check if another compilation is running // this is safe because there's no await between checking and setting (no other code can run) if (this.compiling) { - console.warn("compilation already in progress, skipping"); - return; + return await new Promise((resolve) => { + if (!this.compiling) { + resolve(undefined); + } + console.warn("compilation already in progress, skipping"); + this.waiters.push(resolve); + }); } this.compiling = true; while (this.needCompile) { @@ -212,6 +219,9 @@ export class CompilerKernelImpl implements CompilerKernel { this.compiling = false; console.info("finished compiling"); }); + const waiters = this.waiters; + this.waiters = []; + waiters.forEach((resolve) => resolve(undefined)); } public async export(request: ExportRequest): Promise { diff --git a/web-client/src/core/editor/ChangeTracker.ts b/web-client/src/core/editor/ChangeTracker.ts new file mode 100644 index 00000000..60f1b691 --- /dev/null +++ b/web-client/src/core/editor/ChangeTracker.ts @@ -0,0 +1,119 @@ +import { FsErr, FsFile, FsVoid, fsErr } from "pure/fs"; + +import { Yielder, consoleEditor as console, createYielder } from "low/utils"; + +/// Track if file was modified since last time it was accessed +export interface ChangeTracker { + /// Check if the file should be considered modified + /// since the last time this method was called with the same path + /// + /// Returns the NotModified error code if the file was not modified + checkModifiedSinceLastAccess(file: FsFile): Promise +} + +export function newModifyTimeBasedTracker(): ChangeTracker { + return new ModifyTimeTracker(); +} + +export function newHashBasedTracker(): ChangeTracker { + if (window.crypto.subtle) { + return new HashTracker(); + } + console.warn("hash based tracker requested but not supported by browser"); + return new NoopTracker(); +} + +class ModifyTimeTracker implements ChangeTracker { + /// Track the last modified time of a file + private modifiedTimeWhenLastAccessed: { [path: string]: number } = {}; + + public async checkModifiedSinceLastAccess(file: FsFile): Promise { + const path = file.path; + const modifiedTimeCurrent = await file.getLastModified(); + if (modifiedTimeCurrent.err) { + return modifiedTimeCurrent; + } + + const modifiedTimeLast = this.modifiedTimeWhenLastAccessed[path]; + this.modifiedTimeWhenLastAccessed[path] = modifiedTimeCurrent.val; + if (!modifiedTimeLast) { + // will be undefined if we have never seen this file before + // so consider it modified + return {}; + } + if (modifiedTimeLast >= modifiedTimeCurrent.val) { + // file was not modified since last access + return notModified(); + } + return {}; + } +} + +/// Track modified time against a static time stamp +export class StaticTimeTracker implements ChangeTracker { + /// Track the last modified time of a file + private lastTime: number; + constructor() { + this.lastTime = 0; + } + public setLastTime(time: number): void { + this.lastTime = time; + } + + public async checkModifiedSinceLastAccess(file: FsFile): Promise { + const modifiedTimeCurrent = await file.getLastModified(); + if (modifiedTimeCurrent.err) { + return modifiedTimeCurrent; + } + + if (this.lastTime > modifiedTimeCurrent.val) { + // file was not modified since last access + return notModified(); + } + return {}; + } +} + +class HashTracker implements ChangeTracker { + private hashYield: Yielder; + private hashWhenLastAccessed: { [path: string]: Uint32Array } = {}; + + constructor() { + // yield after digesting 10KB of data + this.hashYield = createYielder(10240); + } + + public async checkModifiedSinceLastAccess(file: FsFile): Promise { + const bytes = await file.getBytes(); + if (bytes.err) { + return bytes; + } + await this.hashYield(bytes.val.length); + const hashLast = this.hashWhenLastAccessed[file.path]; + const hashCurrent = new Uint32Array(await crypto.subtle.digest("SHA-256", bytes.val)); + this.hashWhenLastAccessed[file.path] = hashCurrent; + if (!hashLast) { + return {}; + } + + for (let i = 0; i < hashCurrent.length; i++) { + if (hashCurrent[i] !== hashLast[i]) { + console.info("file hash changed: " + file.path); + return {}; + } + } + return notModified(); + } + +} + +/// A stub tracker that doesn't know +class NoopTracker implements ChangeTracker { + public async checkModifiedSinceLastAccess(): Promise { + return notModified(); + } +} + +function notModified(): FsVoid { + return { err: fsErr(FsErr.NotModified, "Not modified") }; +} diff --git a/web-client/src/core/editor/ExternalEditorKernel.ts b/web-client/src/core/editor/ExternalEditorKernel.ts index 4f813f58..a3d2820d 100644 --- a/web-client/src/core/editor/ExternalEditorKernel.ts +++ b/web-client/src/core/editor/ExternalEditorKernel.ts @@ -1,6 +1,6 @@ //! Logic for external editor workflow -import { FsFileSystem, FsResult, fsJoin, fsRoot } from "pure/fs"; +import { FsErr, FsFileSystem, FsResult, fsJoin, fsRoot } from "pure/fs"; import { CompilerFileAccess } from "core/compiler"; import { @@ -12,7 +12,7 @@ import { import { EditorKernel } from "./EditorKernel"; import { EditorKernelAccess } from "./EditorKernelAccess"; -import { ModifyTimeTracker } from "./ModifyTimeTracker"; +import { ChangeTracker, StaticTimeTracker, newHashBasedTracker, newModifyTimeBasedTracker } from "./ChangeTracker"; console.info("loading external editor kernel"); @@ -28,11 +28,13 @@ class ExternalEditorKernel implements EditorKernel, CompilerFileAccess { private deleted = false; private idleMgr: IdleMgr; private fs: FsFileSystem; - private lastCompiledTime = 0; private kernel: EditorKernelAccess; private fsYield: Yielder; - private modifyTracker: ModifyTimeTracker; + /// Tracker used to track if file changed since last compile + private staticTimeTracker: StaticTimeTracker; // used when fs is live + private staticHashTracker: ChangeTracker; // used when fs is not live + private tracker: ChangeTracker; constructor(kernel: EditorKernelAccess, fs: FsFileSystem) { this.kernel = kernel; @@ -46,7 +48,16 @@ class ExternalEditorKernel implements EditorKernel, CompilerFileAccess { this.recompileIfChanged.bind(this), ); this.fsYield = createYielder(64); - this.modifyTracker = new ModifyTimeTracker(); + const { live } = this.fs.capabilities; + if (live) { + console.info("using modify time based change tracker"); + this.tracker = newModifyTimeBasedTracker(); + } else { + console.info("using hash based change tracker"); + this.tracker = newHashBasedTracker(); + } + this.staticTimeTracker = new StaticTimeTracker(); + this.staticHashTracker = newHashBasedTracker(); this.idleMgr.start(); } @@ -67,45 +78,68 @@ class ExternalEditorKernel implements EditorKernel, CompilerFileAccess { private async recompileIfChanged() { // locking is not needed because idle will be paused // when an idle cycle is running - const changed = await this.checkDirectoryChanged(fsRoot()); + const changed = await this.checkDirectoryChanged(fsRoot(), this.fs.capabilities.live); + if (changed) { - this.lastCompiledTime = Date.now(); + this.staticTimeTracker.setLastTime(Date.now()); this.notifyActivity(); - this.kernel.reloadDocument(); + await this.kernel.reloadDocument(); } } - private async checkDirectoryChanged(path: string): Promise { + private async checkDirectoryChanged(path: string, live: boolean): Promise { const entries = await this.fs.listDir(path); if (entries.err) { // error reading entry, something probably happened? return true; } + + // in non-live mode, we want to always iterate all files + // to make sure we track all changes at once + let changed = false; for (const entry of entries.val) { const subPath = fsJoin(path, entry); if (entry.endsWith("/")) { - const subDirChanged = await this.checkDirectoryChanged(subPath); - if (subDirChanged) { - return true; + const dirChanged = await this.checkDirectoryChanged(subPath, live); + if (dirChanged) { + changed = true; + if (live) { + return true; + } } } else { - const fileChanged = await this.checkFileChanged(subPath); + const fileChanged = await this.checkFileChanged(subPath, live); if (fileChanged) { - return true; + changed = true; + if (live) { + return true; + } } } await this.fsYield(); } - return false; + return changed; } - private async checkFileChanged(path: string): Promise { + private async checkFileChanged(path: string, live: boolean): Promise { + // close the file so we always get the latest modified time + // note that in web editor flow, we don't need to do this + // because the file system content always needs to be + // manually synced to the web editor, which updates the modified time + this.fs.getFile(path).close(); const fsFile = this.fs.getFile(path); - const lastModified = await fsFile.getLastModified(); - if (lastModified.err) { - return true; + let result; + if (live) { + result = await this.staticTimeTracker.checkModifiedSinceLastAccess(fsFile); + } else { + result = await this.staticHashTracker.checkModifiedSinceLastAccess(fsFile); + } + if (result.err) { + if (result.err.code === FsErr.NotModified) { + return false; + } } - return lastModified.val > this.lastCompiledTime; + return true; } // === CompilerFileAccess === @@ -120,13 +154,20 @@ class ExternalEditorKernel implements EditorKernel, CompilerFileAccess { const fsFile = this.fs.getFile(path); if (checkChanged) { const notModified = - await this.modifyTracker.checkModifiedSinceLastAccess(fsFile); + await this.tracker.checkModifiedSinceLastAccess(fsFile); if (notModified.err) { return notModified; } } - return await fsFile.getBytes(); + const bytes = await fsFile.getBytes(); + const { live } = this.fs.capabilities; + if (!live) { + // close the file so we always get the latest content from disk + // directly + fsFile.close(); + } + return bytes; } // === Stub implementations === diff --git a/web-client/src/core/editor/FileMgr.ts b/web-client/src/core/editor/FileMgr.ts index 37f8199b..9d668651 100644 --- a/web-client/src/core/editor/FileMgr.ts +++ b/web-client/src/core/editor/FileMgr.ts @@ -13,7 +13,7 @@ import { } from "low/utils"; import { EditorContainerDOM } from "./dom"; -import { ModifyTimeTracker } from "./ModifyTimeTracker"; +import { ChangeTracker, newModifyTimeBasedTracker } from "./ChangeTracker"; type IStandaloneCodeEditor = monaco.editor.IStandaloneCodeEditor; @@ -38,7 +38,7 @@ export class FileMgr implements CompilerFileAccess { private fsYield: Yielder; private dispatcher: AppDispatcher; - private modifyTracker: ModifyTimeTracker; + private tracker: ChangeTracker; constructor( fs: FsFileSystem, @@ -52,7 +52,7 @@ export class FileMgr implements CompilerFileAccess { this.monacoDom = monacoDom; this.monacoEditor = monacoEditor; this.fsYield = createYielder(64); - this.modifyTracker = new ModifyTimeTracker(); + this.tracker = newModifyTimeBasedTracker(); } public delete() { @@ -393,7 +393,7 @@ export class FileMgr implements CompilerFileAccess { const fsFile = fs.getFile(path); if (checkChanged) { const notModified = - await this.modifyTracker.checkModifiedSinceLastAccess(fsFile); + await this.tracker.checkModifiedSinceLastAccess(fsFile); if (notModified.err) { return notModified; } diff --git a/web-client/src/core/editor/ModifyTimeTracker.ts b/web-client/src/core/editor/ModifyTimeTracker.ts deleted file mode 100644 index c1d06bb1..00000000 --- a/web-client/src/core/editor/ModifyTimeTracker.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { FsErr, FsFile, FsVoid, fsErr } from "pure/fs"; - -/// Track if file was modified since last time it was accessed -export class ModifyTimeTracker { - /// Track the last modified time of a file - private modifiedTimeWhenLastAccessed: { [path: string]: number } = {}; - - /// Check if the file should be considered modified - /// since the last time this method was called with the same path - /// - /// Returns the NotModified error code if the file was not modified - public async checkModifiedSinceLastAccess(file: FsFile): Promise { - const path = file.path; - const modifiedTimeCurrent = await file.getLastModified(); - if (modifiedTimeCurrent.err) { - return modifiedTimeCurrent; - } - - const modifiedTimeLast = this.modifiedTimeWhenLastAccessed[path]; - if (!modifiedTimeLast) { - // will be undefined if we have never seen this file before - // so consider it modified - return {}; - } - if (modifiedTimeLast >= modifiedTimeCurrent.val) { - // file was not modified since last access - return notModified(); - } - return {}; - } -} - -function notModified(): FsVoid { - return { err: fsErr(FsErr.NotModified, "Not modified") }; -} diff --git a/web-client/src/core/kernel/Kernel.ts b/web-client/src/core/kernel/Kernel.ts index c1fb5d96..bf65d73c 100644 --- a/web-client/src/core/kernel/Kernel.ts +++ b/web-client/src/core/kernel/Kernel.ts @@ -214,7 +214,7 @@ export class Kernel implements EditorKernelAccess { const yes = await this.getAlertMgr().show({ title: "Heads up!", message: - "Your browser has limited support for file system access when opening a project from a dialog. Certain operations may not work! Please see the learn more link below for more information.", + "Your browser has limited support for file system access when opening a project from a dialog. Celer will not be able to detect new, renamed or deleted files! Please see the learn more link below for more information.", okButton: "Continue anyway", cancelButton: "Cancel", learnMoreLink: "/docs/route/editor/external#open-a-project", @@ -254,7 +254,7 @@ export class Kernel implements EditorKernelAccess { public async reloadDocument() { if (viewSelector(this.store.getState()).stageMode === "edit") { const compiler = await this.getCompiler(); - compiler.compile(); + await compiler.compile(); return; } await this.reloadDocumentFromServer(); diff --git a/web-client/src/low/utils/Yielder.ts b/web-client/src/low/utils/Yielder.ts index 0c1454ec..3f004a64 100644 --- a/web-client/src/low/utils/Yielder.ts +++ b/web-client/src/low/utils/Yielder.ts @@ -5,14 +5,14 @@ /// is still budget left export const createYielder = (budget: number) => { let currentBudget = budget; - return () => { + return (cost: number = 1) => { if (currentBudget <= 0) { currentBudget = budget; return new Promise((resolve) => { setTimeout(() => resolve(true), 0); }); } - currentBudget--; + currentBudget-=cost; return Promise.resolve(false); }; }; diff --git a/web-client/src/ui/editor/EditorTree.tsx b/web-client/src/ui/editor/EditorTree.tsx index def50eb6..ad414b90 100644 --- a/web-client/src/ui/editor/EditorTree.tsx +++ b/web-client/src/ui/editor/EditorTree.tsx @@ -1,4 +1,4 @@ -import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { useSelector } from "react-redux"; import { fsComponents, fsJoin, fsRoot } from "pure/fs"; @@ -46,24 +46,12 @@ export const EditorTree: React.FC = () => { return set; }, [unsavedFiles]); - // serial to manually update the component - const [stateSerial, setStateSerial] = useState(0); - const expandedPaths = useRef | undefined>(); + const [expandedPaths, setExpandedPaths] = useState([]); const setIsExpanded = useCallback((path: string, isExpanded: boolean) => { if (isExpanded) { - if (!expandedPaths.current) { - expandedPaths.current = new Set(); - expandedPaths.current.add(path); - } else { - if (!expandedPaths.current.has(path)) { - expandedPaths.current.add(path); - setStateSerial((x) => x + 1); - } - } - return; - } - if (expandedPaths.current) { - expandedPaths.current.delete(path); + setExpandedPaths((x) => [...x, path]); + } else { + setExpandedPaths((x) => x.filter((p) => p !== path)); } }, []); @@ -74,7 +62,6 @@ export const EditorTree: React.FC = () => { return (
{ await editor.openFile(path); }} setIsExpanded={setIsExpanded} - expandedPaths={expandedPaths.current || new Set()} + expandedPaths={expandedPaths} dirtyPaths={dirtyPaths} openedFile={openedFile} /> @@ -97,8 +84,6 @@ export const EditorTree: React.FC = () => { }; type TreeDirNodeProps = { - /// Serial to signal when to update - serial: number; /// Name of the entry, should end with / name: string; /// Full path of the directory entry, should end with / @@ -115,7 +100,7 @@ type TreeDirNodeProps = { /// Directory paths that are expanded /// /// All should end with / - expandedPaths: Set; + expandedPaths: string[]; /// File and directory paths that have unsaved changes /// @@ -125,8 +110,7 @@ type TreeDirNodeProps = { openedFile: string | undefined; }; -const TreeDirNodeInternal: React.FC = ({ - serial, +const TreeDirNode: React.FC = ({ name, path, listDir, @@ -139,7 +123,7 @@ const TreeDirNodeInternal: React.FC = ({ }) => { const [entries, setEntries] = useState(undefined); - const isExpanded = expandedPaths.has(path); + const isExpanded = expandedPaths.includes(path); useEffect(() => { if (!isExpanded) { @@ -178,10 +162,9 @@ const TreeDirNodeInternal: React.FC = ({ const name = entry.slice(0, -1); return ( = ({ ); }; -const TreeDirNode = memo(TreeDirNodeInternal); +// const TreeDirNode = memo(TreeDirNodeInternal); /// Compare function for sorting entries in the file tree function compareEntry(a: string, b: string): number { diff --git a/web-client/tsconfig.json b/web-client/tsconfig.json index 58a8535b..5be85fd4 100644 --- a/web-client/tsconfig.json +++ b/web-client/tsconfig.json @@ -22,9 +22,9 @@ "baseUrl": "src", "paths": { - "pure/*": ["../../libs/pure/src/*"] + "pure/*": ["../libs/pure/*"] } }, - "include": ["src", "../libs/pure/src"], + "include": ["src", "libs"], "references": [{ "path": "./tsconfig.node.json" }] } diff --git a/web-client/vite.config.ts b/web-client/vite.config.ts index 8c91be0b..3bd3f4bc 100644 --- a/web-client/vite.config.ts +++ b/web-client/vite.config.ts @@ -38,7 +38,9 @@ const https = createHttpsConfig(); export default defineConfig({ plugins: [ react(), - tsconfigPaths(), + tsconfigPaths( + // projects: ["./tsconfig.json", "../libs/tsconfig.json"], + ), removeRustStyleDocComments(), wasm(), topLevelAwait(), @@ -66,7 +68,7 @@ export default defineConfig({ output: { chunkFileNames: (info) => { for (let i = 0; i < info.moduleIds.length; i++) { - if (info.moduleIds[i].includes("DocRoot")) { + if (info.moduleIds[i].includes("DocController")) { return "assets/doc-[hash].js"; } if (info.moduleIds[i].includes("MapRoot")) { From 80edbcf2c2d06ea812f0dd479ee1ef9f8f676414 Mon Sep 17 00:00:00 2001 From: Pistonight Date: Mon, 26 Feb 2024 20:35:01 -0800 Subject: [PATCH 10/10] prettier --- web-client/libs/pure/fs/FsError.ts | 2 +- web-client/libs/pure/fs/FsFile.ts | 3 +- web-client/libs/pure/fs/FsFileImpl.ts | 18 ++- web-client/libs/pure/fs/FsFileSystem.ts | 4 +- web-client/libs/pure/fs/FsImplEntryAPI.ts | 100 +++++++++++----- web-client/libs/pure/fs/FsImplFileAPI.ts | 17 ++- web-client/libs/pure/fs/FsImplHandleAPI.ts | 66 ++++++++--- web-client/libs/pure/fs/FsOpen.ts | 109 ++++++++++++------ web-client/libs/pure/fs/FsPath.ts | 2 +- web-client/libs/pure/fs/FsSupportStatus.ts | 3 +- web-client/libs/pure/fs/index.ts | 10 +- web-client/libs/pure/result/index.ts | 12 +- web-client/libs/pure/utils/RwLock.ts | 6 +- web-client/src/core/editor/ChangeTracker.ts | 7 +- .../src/core/editor/ExternalEditorKernel.ts | 40 +++++-- web-client/src/core/editor/FileMgr.ts | 5 +- web-client/src/low/utils/Yielder.ts | 2 +- web-client/src/ui/toolbar/Export.tsx | 1 - .../ui/toolbar/settings/PluginSettings.tsx | 1 - web-client/tools/test/jest.config.cjs | 1 + web-client/vite.config.ts | 4 +- 21 files changed, 286 insertions(+), 127 deletions(-) diff --git a/web-client/libs/pure/fs/FsError.ts b/web-client/libs/pure/fs/FsError.ts index 5fdf0951..85b830df 100644 --- a/web-client/libs/pure/fs/FsError.ts +++ b/web-client/libs/pure/fs/FsError.ts @@ -34,7 +34,7 @@ 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 }; diff --git a/web-client/libs/pure/fs/FsFile.ts b/web-client/libs/pure/fs/FsFile.ts index 2dfad7ae..6062a7c2 100644 --- a/web-client/libs/pure/fs/FsFile.ts +++ b/web-client/libs/pure/fs/FsFile.ts @@ -19,7 +19,7 @@ export interface FsFile { getText(): Promise>; /// Get the content of the file - getBytes(): Promise> + getBytes(): Promise>; /// Set the content in memory. Does not save to disk. /// Does nothing if file is closed @@ -51,4 +51,3 @@ export interface FsFile { /// Further operations on the file will fail close(): void; } - diff --git a/web-client/libs/pure/fs/FsFileImpl.ts b/web-client/libs/pure/fs/FsFileImpl.ts index 93f4cf1b..8c951108 100644 --- a/web-client/libs/pure/fs/FsFileImpl.ts +++ b/web-client/libs/pure/fs/FsFileImpl.ts @@ -11,7 +11,7 @@ export function fsFile(fs: FsFileSystemInternal, path: string): FsFile { } function errclosed() { - return { err: fsErr(FsErr.Closed, "File is closed")} as const; + return { err: fsErr(FsErr.Closed, "File is closed") } as const; } class FsFileImpl implements FsFile { @@ -98,7 +98,9 @@ class FsFileImpl implements FsFile { } } if (this.buffer === undefined) { - const err = fsFail("Read was successful, but content was undefined"); + const err = fsFail( + "Read was successful, but content was undefined", + ); return { err }; } return { val: this.buffer }; @@ -149,12 +151,14 @@ class FsFileImpl implements FsFile { // check if the file has been modified since last loaded if (this.lastModified !== undefined) { if (file.lastModified <= this.lastModified) { - return {} + return {}; } } this.lastModified = file.lastModified; // load the buffer - const buffer = await tryAsync(async () => new Uint8Array(await file.arrayBuffer())); + const buffer = await tryAsync( + async () => new Uint8Array(await file.arrayBuffer()), + ); if ("err" in buffer) { const err = fsFail(errstr(buffer.err)); return { err }; @@ -172,7 +176,7 @@ class FsFileImpl implements FsFile { return errclosed(); } if (!this.isDirty()) { - return {} + return {}; } return await this.write(); } @@ -197,7 +201,9 @@ class FsFileImpl implements FsFile { private decodeBuffer() { try { - this.content = new TextDecoder("utf-8", { fatal: true }).decode(this.buffer); + this.content = new TextDecoder("utf-8", { fatal: true }).decode( + this.buffer, + ); this.isText = true; } catch (_) { this.content = undefined; diff --git a/web-client/libs/pure/fs/FsFileSystem.ts b/web-client/libs/pure/fs/FsFileSystem.ts index 09aa0cf0..fc77b689 100644 --- a/web-client/libs/pure/fs/FsFileSystem.ts +++ b/web-client/libs/pure/fs/FsFileSystem.ts @@ -11,7 +11,6 @@ export interface FsFileSystemUninit { /// 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. @@ -47,7 +46,6 @@ export interface FsFileSystem { /// Get all paths that `getFile` has been called with but not `close`d getOpenedPaths: () => string[]; - } /// Capabilities of the file system implementation @@ -58,4 +56,4 @@ export type FsCapabilities = { /// - Change of modified time /// - Change of directory structure (new, renamed, deleted files) live: boolean; -} +}; diff --git a/web-client/libs/pure/fs/FsImplEntryAPI.ts b/web-client/libs/pure/fs/FsImplEntryAPI.ts index e74db702..a886d07d 100644 --- a/web-client/libs/pure/fs/FsImplEntryAPI.ts +++ b/web-client/libs/pure/fs/FsImplEntryAPI.ts @@ -2,14 +2,20 @@ import { Ok, tryAsync } from "pure/result"; import { errstr } from "pure/utils"; import { FsErr, FsResult, FsVoid, fsErr, fsFail } from "./FsError.ts"; -import { FsFileSystem, FsFileSystemUninit, FsCapabilities } from "./FsFileSystem.ts"; +import { + FsFileSystem, + FsFileSystemUninit, + FsCapabilities, +} from "./FsFileSystem.ts"; import { FsFile } from "./FsFile.ts"; import { fsIsRoot, fsNormalize } from "./FsPath.ts"; import { FsFileMgr } from "./FsFileMgr.ts"; import { FsFileSystemInternal } from "./FsFileSystemInternal.ts"; /// FsFileSystem implementation that uses FileEntry API -export class FsImplEntryAPI implements FsFileSystemUninit, FsFileSystem, FsFileSystemInternal { +export class FsImplEntryAPI + implements FsFileSystemUninit, FsFileSystem, FsFileSystemInternal +{ public root: string; public capabilities: FsCapabilities; @@ -22,7 +28,7 @@ export class FsImplEntryAPI implements FsFileSystemUninit, FsFileSystem, FsFileS this.rootEntry = rootEntry; this.capabilities = { write: false, - live: true + live: true, }; this.mgr = new FsFileMgr(); } @@ -44,15 +50,23 @@ export class FsImplEntryAPI implements FsFileSystemUninit, FsFileSystem, FsFileS return entry; } - const entries = await tryAsync(() => new Promise((resolve, reject) => { - entry.val.createReader().readEntries(resolve, reject); - })); + const entries = await tryAsync( + () => + new Promise((resolve, reject) => { + entry.val.createReader().readEntries(resolve, reject); + }), + ); if ("err" in entries) { - const err = fsFail("Failed to list directory `" + path + "`: " + errstr(entries.err)); + const err = fsFail( + "Failed to list directory `" + + path + + "`: " + + errstr(entries.err), + ); return { err }; } - const names = entries.val.map(({isDirectory, name}) => { + const names = entries.val.map(({ isDirectory, name }) => { if (isDirectory) { return name + "/"; } @@ -74,11 +88,16 @@ export class FsImplEntryAPI implements FsFileSystemUninit, FsFileSystem, FsFileS return entry; } - const file = await tryAsync(() => new Promise((resolve, reject) => { - entry.val.file(resolve, reject); - })); + const file = await tryAsync( + () => + new Promise((resolve, reject) => { + entry.val.file(resolve, reject); + }), + ); if ("err" in file) { - const err = fsFail("Failed to read file `" + path + "`: " + errstr(file.err)); + const err = fsFail( + "Failed to read file `" + path + "`: " + errstr(file.err), + ); return { err }; } @@ -86,7 +105,10 @@ export class FsImplEntryAPI implements FsFileSystemUninit, FsFileSystem, FsFileS } public write(): Promise { - const err = fsErr(FsErr.NotSupported, "Write not supported in FileEntry API"); + const err = fsErr( + FsErr.NotSupported, + "Write not supported in FileEntry API", + ); return Promise.resolve({ err }); } @@ -101,39 +123,65 @@ export class FsImplEntryAPI implements FsFileSystemUninit, FsFileSystem, FsFileS } /// Resolve a directory entry. Path must be normalized - private async resolveDir(path: string): Promise> { + private async resolveDir( + path: string, + ): Promise> { if (fsIsRoot(path)) { return { val: this.rootEntry }; } - const entry = await tryAsync(() => new Promise((resolve, reject) => { - this.rootEntry.getDirectory(path, {}, resolve, reject); - })); + const entry = await tryAsync( + () => + new Promise((resolve, reject) => { + this.rootEntry.getDirectory(path, {}, resolve, reject); + }), + ); if ("err" in entry) { - const err = fsFail("Failed to resolve directory `" + path + "`: " + errstr(entry.err)); + const err = fsFail( + "Failed to resolve directory `" + + path + + "`: " + + errstr(entry.err), + ); return { err }; } if (!entry.val.isDirectory) { - const err = fsErr(FsErr.IsFile, "Path `" + path + "` is not a directory"); + const err = fsErr( + FsErr.IsFile, + "Path `" + path + "` is not a directory", + ); return { err }; } return entry as Ok; } /// Resolve a file entry. Path must be normalized - private async resolveFile(path: string): Promise> { + private async resolveFile( + path: string, + ): Promise> { if (fsIsRoot(path)) { - const err = fsErr(FsErr.IsDirectory, "Path `" + path + "` is not a file"); + const err = fsErr( + FsErr.IsDirectory, + "Path `" + path + "` is not a file", + ); return { err }; } - const entry = await tryAsync(() => new Promise((resolve, reject) => { - this.rootEntry.getFile(path, {}, resolve, reject); - })); + const entry = await tryAsync( + () => + new Promise((resolve, reject) => { + this.rootEntry.getFile(path, {}, resolve, reject); + }), + ); if ("err" in entry) { - const err = fsFail("Failed to resolve file `" + path + "`: " + errstr(entry.err)); + const err = fsFail( + "Failed to resolve file `" + path + "`: " + errstr(entry.err), + ); return { err }; } if (!entry.val.isFile) { - const err = fsErr(FsErr.IsDirectory, "Path `" + path + "` is not a file"); + const err = fsErr( + FsErr.IsDirectory, + "Path `" + path + "` is not a file", + ); return { err }; } return entry as Ok; diff --git a/web-client/libs/pure/fs/FsImplFileAPI.ts b/web-client/libs/pure/fs/FsImplFileAPI.ts index 934b2f8b..94d35e97 100644 --- a/web-client/libs/pure/fs/FsImplFileAPI.ts +++ b/web-client/libs/pure/fs/FsImplFileAPI.ts @@ -1,5 +1,9 @@ import { FsFile } from "./FsFile.ts"; -import { FsFileSystem, FsFileSystemUninit, FsCapabilities } from "./FsFileSystem.ts"; +import { + FsFileSystem, + FsFileSystemUninit, + FsCapabilities, +} from "./FsFileSystem.ts"; import { FsErr, FsResult, FsVoid, fsErr } from "./FsError.ts"; import { fsIsRoot, fsNormalize } from "./FsPath.ts"; import { FsFileMgr } from "./FsFileMgr.ts"; @@ -9,7 +13,9 @@ import { FsFileSystemInternal } from "./FsFileSystemInternal.ts"; /// This is supported in all browsers, but it is stale. /// It's used for Firefox when the File Entries API is not available /// i.e. opened from -export class FsImplFileAPI implements FsFileSystemUninit, FsFileSystem, FsFileSystemInternal { +export class FsImplFileAPI + implements FsFileSystemUninit, FsFileSystem, FsFileSystemInternal +{ public root: string; public capabilities: FsCapabilities; @@ -22,7 +28,7 @@ export class FsImplFileAPI implements FsFileSystemUninit, FsFileSystem, FsFileSy this.root = files[0].webkitRelativePath.split("/", 1)[0]; this.capabilities = { write: false, - live: false + live: false, }; this.files = {}; this.directories = {}; @@ -99,7 +105,10 @@ export class FsImplFileAPI implements FsFileSystemUninit, FsFileSystem, FsFileSy } public write(): Promise { - const err = fsErr(FsErr.NotSupported, "Write not supported in File API"); + const err = fsErr( + FsErr.NotSupported, + "Write not supported in File API", + ); return Promise.resolve({ err }); } diff --git a/web-client/libs/pure/fs/FsImplHandleAPI.ts b/web-client/libs/pure/fs/FsImplHandleAPI.ts index 7f3f00d2..bb1302a7 100644 --- a/web-client/libs/pure/fs/FsImplHandleAPI.ts +++ b/web-client/libs/pure/fs/FsImplHandleAPI.ts @@ -1,12 +1,22 @@ -//! FsFileSystem implementation for FileSystemAccess API +//! FsFileSystem implementation for FileSystemAccess API import { tryAsync } from "pure/result"; import { errstr } from "pure/utils"; -import { FsFileSystem, FsFileSystemUninit, FsCapabilities } from "./FsFileSystem.ts"; +import { + FsFileSystem, + FsFileSystemUninit, + FsCapabilities, +} from "./FsFileSystem.ts"; import { FsErr, FsResult, FsVoid, fsErr, fsFail } from "./FsError.ts"; import { FsFile } from "./FsFile.ts"; -import { fsComponents, fsGetBase, fsGetName, fsIsRoot, fsNormalize } from "./FsPath.ts"; +import { + fsComponents, + fsGetBase, + fsGetName, + fsIsRoot, + fsNormalize, +} from "./FsPath.ts"; import { FsFileMgr } from "./FsFileMgr.ts"; import { FsFileSystemInternal } from "./FsFileSystemInternal.ts"; @@ -14,7 +24,9 @@ type PermissionStatus = "granted" | "denied" | "prompt"; /// FsFileSystem implementation that uses FileSystem Access API /// This is only supported in Chrome/Edge -export class FsImplHandleAPI implements FsFileSystemUninit, FsFileSystem, FsFileSystemInternal { +export class FsImplHandleAPI + implements FsFileSystemUninit, FsFileSystem, FsFileSystemInternal +{ public root: string; public capabilities: FsCapabilities; /// If app requested write access @@ -58,7 +70,7 @@ export class FsImplHandleAPI implements FsFileSystemUninit, FsFileSystem, FsFile return normalized; } path = normalized.val; - + const handle = await this.resolveDir(path); if (handle.err) { return handle; @@ -78,7 +90,12 @@ export class FsImplHandleAPI implements FsFileSystemUninit, FsFileSystem, FsFile return entries; }); if ("err" in entries) { - const err = fsFail("Error reading entries from directory `" + path + "`: " + errstr(entries.err)); + const err = fsFail( + "Error reading entries from directory `" + + path + + "`: " + + errstr(entries.err), + ); return { err }; } return entries; @@ -98,7 +115,9 @@ export class FsImplHandleAPI implements FsFileSystemUninit, FsFileSystem, FsFile const file = await tryAsync(() => handle.val.getFile()); if ("err" in file) { - const err = fsFail("Failed to read file `" + path + "`: " + errstr(file.err)); + const err = fsFail( + "Failed to read file `" + path + "`: " + errstr(file.err), + ); return { err }; } return file; @@ -106,7 +125,10 @@ export class FsImplHandleAPI implements FsFileSystemUninit, FsFileSystem, FsFile public async write(path: string, content: Uint8Array): Promise { if (!this.writeMode) { - const err = fsErr(FsErr.PermissionDenied, "Write mode not requested"); + const err = fsErr( + FsErr.PermissionDenied, + "Write mode not requested", + ); return { err }; } const normalized = fsNormalize(path); @@ -124,10 +146,12 @@ export class FsImplHandleAPI implements FsFileSystemUninit, FsFileSystem, FsFile const file = await handle.val.createWritable(); await file.write(content); await file.close(); - return {}; + return {}; }); if ("err" in result) { - const err = fsFail("Failed to write file `" + path + "`: " + errstr(result.err)); + const err = fsFail( + "Failed to write file `" + path + "`: " + errstr(result.err), + ); return { err }; } return {}; @@ -144,9 +168,11 @@ export class FsImplHandleAPI implements FsFileSystemUninit, FsFileSystem, FsFile this.mgr.close(path); } - /// Resolve the FileSystemDirectoryHandle for a directory. + /// Resolve the FileSystemDirectoryHandle for a directory. /// The path must be normalized - private async resolveDir(path: string): Promise> { + private async resolveDir( + path: string, + ): Promise> { if (fsIsRoot(path)) { return { val: this.rootHandle }; } @@ -157,7 +183,12 @@ export class FsImplHandleAPI implements FsFileSystemUninit, FsFileSystem, FsFile const next = await tryAsync(() => handle.getDirectoryHandle(part)); if ("err" in next) { const dir = parts.join("/"); - const err = fsFail("Failed to resolve directory `" + dir + "`: " + errstr(next.err)); + const err = fsFail( + "Failed to resolve directory `" + + dir + + "`: " + + errstr(next.err), + ); return { err }; } handle = next.val; @@ -168,7 +199,9 @@ export class FsImplHandleAPI implements FsFileSystemUninit, FsFileSystem, FsFile /// Resolve the FileSystemFileHandle for a file. /// The path must be normalized - private async resolveFile(path: string): Promise> { + private async resolveFile( + path: string, + ): Promise> { const parent = fsGetBase(path); if (parent.err) { return parent; @@ -186,10 +219,11 @@ export class FsImplHandleAPI implements FsFileSystemUninit, FsFileSystem, FsFile const file = await tryAsync(() => handle.val.getFileHandle(name.val)); if ("err" in file) { - const err = fsFail("Failed to resolve file `" + path + "`: " + errstr(file.err)); + const err = fsFail( + "Failed to resolve file `" + path + "`: " + errstr(file.err), + ); return { err }; } return file; } - } diff --git a/web-client/libs/pure/fs/FsOpen.ts b/web-client/libs/pure/fs/FsOpen.ts index 93abfdde..7db075f4 100644 --- a/web-client/libs/pure/fs/FsOpen.ts +++ b/web-client/libs/pure/fs/FsOpen.ts @@ -9,12 +9,17 @@ import { FsImplEntryAPI } from "./FsImplEntryAPI.ts"; import { FsImplHandleAPI } from "./FsImplHandleAPI.ts"; /// Handle for handling top level open errors, and decide if the operation should be retried -export type FsOpenRetryHandler = (error: FsError, attempt: number) => Promise>; +export type FsOpenRetryHandler = ( + error: FsError, + attempt: number, +) => Promise>; const MAX_RETRY = 10; /// Open a file system for read-only access with a directory picker dialog -export async function fsOpenRead(retryHandler?: FsOpenRetryHandler): Promise> { +export async function fsOpenRead( + retryHandler?: FsOpenRetryHandler, +): Promise> { const fs = await createWithPicker(false, retryHandler); if (fs.err) { return fs; @@ -23,7 +28,9 @@ export async function fsOpenRead(retryHandler?: FsOpenRetryHandler): Promise> { +export async function fsOpenReadWrite( + retryHandler?: FsOpenRetryHandler, +): Promise> { const fs = await createWithPicker(true, retryHandler); if (fs.err) { return fs; @@ -33,7 +40,8 @@ export async function fsOpenReadWrite(retryHandler?: FsOpenRetryHandler): Promis /// Open a file system for read-only access from a DataTransferItem from a drag and drop event export async function fsOpenReadFrom( - item: DataTransferItem, retryHandler?: FsOpenRetryHandler + item: DataTransferItem, + retryHandler?: FsOpenRetryHandler, ): Promise> { const fs = await createFromDataTransferItem(item, false, retryHandler); if (fs.err) { @@ -44,7 +52,8 @@ export async function fsOpenReadFrom( /// Open a file system for read-write access from a DataTransferItem from a drag and drop event export async function fsOpenReadWriteFrom( - item: DataTransferItem, retryHandler?: FsOpenRetryHandler + item: DataTransferItem, + retryHandler?: FsOpenRetryHandler, ): Promise> { const fs = await createFromDataTransferItem(item, true, retryHandler); if (fs.err) { @@ -54,7 +63,8 @@ export async function fsOpenReadWriteFrom( } async function createWithPicker( - write: boolean, retryHandler: FsOpenRetryHandler | undefined + write: boolean, + retryHandler: FsOpenRetryHandler | undefined, ): Promise> { for (let attempt = 1; attempt <= MAX_RETRY; attempt++) { const { implementation } = fsGetSupportStatus(); @@ -65,8 +75,9 @@ async function createWithPicker( } if (retryHandler) { // eslint-disable-next-line @typescript-eslint/no-explicit-any - const isAbort = handle.err && (handle.err as any).name === "AbortError"; - const error = isAbort + const isAbort = + handle.err && (handle.err as any).name === "AbortError"; + const error = isAbort ? fsErr(FsErr.UserAbort, "User cancelled the operation") : fsFail(errstr(handle.err)); const shouldRetry = await retryHandler(error, attempt); @@ -92,19 +103,23 @@ async function createWithPicker( inputElement.type = "file"; inputElement.webkitdirectory = true; - const fsUninit = await new Promise>(resolve => { - inputElement.addEventListener("change", (event) => { - const files = (event.target as HTMLInputElement).files; - if (!files) { - const err = fsFail("Failed to get files from input element"); - return resolve({ err }); - } - resolve(createFromFileList(files)); - }); - inputElement.click(); - }); + const fsUninit = await new Promise>( + (resolve) => { + inputElement.addEventListener("change", (event) => { + const files = (event.target as HTMLInputElement).files; + if (!files) { + const err = fsFail( + "Failed to get files from input element", + ); + return resolve({ err }); + } + resolve(createFromFileList(files)); + }); + inputElement.click(); + }, + ); inputElement.remove(); - + if (fsUninit.val) { return fsUninit; } @@ -126,29 +141,44 @@ async function createWithPicker( } async function createFromDataTransferItem( - item: DataTransferItem, - write: boolean, - retryHandler: FsOpenRetryHandler | undefined + item: DataTransferItem, + write: boolean, + retryHandler: FsOpenRetryHandler | undefined, ): Promise> { - for (let attempt = 1;attempt <= MAX_RETRY; attempt++) { + for (let attempt = 1; attempt <= MAX_RETRY; attempt++) { let error: FsError | undefined = undefined; const { implementation } = fsGetSupportStatus(); // Prefer File System Access API since it supports writing - if ("getAsFileSystemHandle" in item && implementation === "FileSystemAccess") { + if ( + "getAsFileSystemHandle" in item && + implementation === "FileSystemAccess" + ) { const handle = await tryAsync(() => getAsFileSystemHandle(item)); if (handle.val) { return createFromFileSystemHandle(handle.val, write); } - error = fsFail("Failed to get handle from DataTransferItem: "+ errstr(handle.err)); - } else if ("webkitGetAsEntry" in item && implementation === "FileEntry") { + error = fsFail( + "Failed to get handle from DataTransferItem: " + + errstr(handle.err), + ); + } else if ( + "webkitGetAsEntry" in item && + implementation === "FileEntry" + ) { const entry = tryCatch(() => webkitGetAsEntry(item)); if (entry.val) { return createFromFileSystemEntry(entry.val); } - error = fsFail("Failed to get entry from DataTransferItem: "+ errstr(entry.err)); + error = fsFail( + "Failed to get entry from DataTransferItem: " + + errstr(entry.err), + ); } if (!error) { - const err = fsErr(FsErr.NotSupported, "No supported API found on the DataTransferItem"); + const err = fsErr( + FsErr.NotSupported, + "No supported API found on the DataTransferItem", + ); return { err }; } // handle error @@ -169,11 +199,11 @@ async function createFromDataTransferItem( } async function init( - fs: FsFileSystemUninit, - retryHandler: FsOpenRetryHandler | undefined + fs: FsFileSystemUninit, + retryHandler: FsOpenRetryHandler | undefined, ): Promise> { let attempt = -1; - while(true) { + while (true) { attempt++; const inited = await fs.init(); if (!inited.err) { @@ -201,7 +231,9 @@ function showDirectoryPicker(write: boolean): Promise { } /// Wrapper for DataTransferItem.getAsFileSystemHandle -async function getAsFileSystemHandle(item: DataTransferItem): Promise { +async function getAsFileSystemHandle( + item: DataTransferItem, +): Promise { // @ts-expect-error getAsFileSystemHandle is not in the TS lib const handle = await item.getAsFileSystemHandle(); if (!handle) { @@ -219,7 +251,10 @@ function webkitGetAsEntry(item: DataTransferItem): FileSystemEntry { return entry; } -function createFromFileSystemHandle(handle: FileSystemHandle, write: boolean): FsResult { +function createFromFileSystemHandle( + handle: FileSystemHandle, + write: boolean, +): FsResult { if (handle.kind !== "directory") { const err = fsErr(FsErr.IsFile, "Expected directory"); return { err }; @@ -228,13 +263,15 @@ function createFromFileSystemHandle(handle: FileSystemHandle, write: boolean): F const fs = new FsImplHandleAPI( handle.name, handle as FileSystemDirectoryHandle, - write + write, ); return { val: fs }; } -function createFromFileSystemEntry(entry: FileSystemEntry): FsResult { +function createFromFileSystemEntry( + entry: FileSystemEntry, +): FsResult { if (entry.isFile || !entry.isDirectory) { const err = fsErr(FsErr.IsFile, "Expected directory"); return { err }; diff --git a/web-client/libs/pure/fs/FsPath.ts b/web-client/libs/pure/fs/FsPath.ts index 7dc880e9..5437550d 100644 --- a/web-client/libs/pure/fs/FsPath.ts +++ b/web-client/libs/pure/fs/FsPath.ts @@ -1,7 +1,7 @@ //! Path utilities //! //! The library has the following path standard: -//! - All paths are relative (without leading /) to the root +//! - 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 diff --git a/web-client/libs/pure/fs/FsSupportStatus.ts b/web-client/libs/pure/fs/FsSupportStatus.ts index 86de137d..c81acc78 100644 --- a/web-client/libs/pure/fs/FsSupportStatus.ts +++ b/web-client/libs/pure/fs/FsSupportStatus.ts @@ -1,4 +1,3 @@ - /// What is supported by the current environment export type FsSupportStatus = { /// Returned by window.isSecureContext @@ -8,7 +7,7 @@ export type FsSupportStatus = { /// /// See README.md for more information implementation: "File" | "FileSystemAccess" | "FileEntry"; -} +}; /// Get which implementation will be used for the current environment export function fsGetSupportStatus(): FsSupportStatus { diff --git a/web-client/libs/pure/fs/index.ts b/web-client/libs/pure/fs/index.ts index 273ca239..124a1337 100644 --- a/web-client/libs/pure/fs/index.ts +++ b/web-client/libs/pure/fs/index.ts @@ -3,10 +3,10 @@ export { fsOpenRead, fsOpenReadWrite, fsOpenReadFrom, - fsOpenReadWriteFrom + fsOpenReadWriteFrom, } from "./FsOpen.ts"; export { fsGetSupportStatus } from "./FsSupportStatus.ts"; -export { +export { fsRoot, fsIsRoot, fsGetBase, @@ -19,6 +19,10 @@ export { FsErr, fsErr, fsFail } from "./FsError.ts"; export type { FsOpenRetryHandler } from "./FsOpen.ts"; export type { FsSupportStatus } from "./FsSupportStatus.ts"; -export type { FsFileSystem, FsFileSystemUninit, FsCapabilities } from "./FsFileSystem.ts"; +export type { + FsFileSystem, + FsFileSystemUninit, + FsCapabilities, +} from "./FsFileSystem.ts"; export type { FsFile } from "./FsFile.ts"; export type { FsError, FsResult, FsVoid } from "./FsError.ts"; diff --git a/web-client/libs/pure/result/index.ts b/web-client/libs/pure/result/index.ts index 570202b3..cfdbbba2 100644 --- a/web-client/libs/pure/result/index.ts +++ b/web-client/libs/pure/result/index.ts @@ -4,14 +4,14 @@ // If these look weird, it's because TypeScript is weird // This is to get type narrowing to work most of the time -export type Ok = { val: T, err?: never }; -export type Err = { err: E, val?: never }; -export type Void = { val?: never, err?: never } | { err: E }; +export type Ok = { val: T; err?: never }; +export type Err = { err: E; val?: never }; +export type Void = { val?: never; err?: never } | { err: E }; export type Result = Ok | Err; /// Wrap a function with try-catch and return a Result. -export function tryCatch(fn: () => T): Result { +export function tryCatch(fn: () => T): Result { try { return { val: fn() }; } catch (e) { @@ -20,7 +20,9 @@ export function tryCatch(fn: () => T): Result { } /// Wrap an async function with try-catch and return a Promise. -export async function tryAsync(fn: () => Promise): Promise> { +export async function tryAsync( + fn: () => Promise, +): Promise> { try { return { val: await fn() }; } catch (e) { diff --git a/web-client/libs/pure/utils/RwLock.ts b/web-client/libs/pure/utils/RwLock.ts index d811f7d8..13b07c4f 100644 --- a/web-client/libs/pure/utils/RwLock.ts +++ b/web-client/libs/pure/utils/RwLock.ts @@ -5,7 +5,7 @@ import Deque from "denque"; /// Only guaranteed if no one else has reference to the inner object /// /// It can take a second type parameter to specify interface with write methods -export class RwLock { +export class RwLock { /// This is public so inner object can be accessed directly /// ONLY SAFE in sync context public inner: TWrite; @@ -55,7 +55,9 @@ export class RwLock { /// Acquire a write (exclusive) lock and call fn with the value. Release the lock when fn returns or throws. /// /// fn takes a setter function as second parameter, which you can use to update the value like `x = set(newX)` - public async scopedWrite(fn: (t: TWrite, setter: (t: TWrite) => TWrite) => Promise): Promise { + public async scopedWrite( + fn: (t: TWrite, setter: (t: TWrite) => TWrite) => Promise, + ): Promise { if (this.isWriting || this.readers > 0) { await new Promise((resolve) => { // need to check again to make sure it's not already done diff --git a/web-client/src/core/editor/ChangeTracker.ts b/web-client/src/core/editor/ChangeTracker.ts index 60f1b691..71996f3e 100644 --- a/web-client/src/core/editor/ChangeTracker.ts +++ b/web-client/src/core/editor/ChangeTracker.ts @@ -8,7 +8,7 @@ export interface ChangeTracker { /// since the last time this method was called with the same path /// /// Returns the NotModified error code if the file was not modified - checkModifiedSinceLastAccess(file: FsFile): Promise + checkModifiedSinceLastAccess(file: FsFile): Promise; } export function newModifyTimeBasedTracker(): ChangeTracker { @@ -90,7 +90,9 @@ class HashTracker implements ChangeTracker { } await this.hashYield(bytes.val.length); const hashLast = this.hashWhenLastAccessed[file.path]; - const hashCurrent = new Uint32Array(await crypto.subtle.digest("SHA-256", bytes.val)); + const hashCurrent = new Uint32Array( + await crypto.subtle.digest("SHA-256", bytes.val), + ); this.hashWhenLastAccessed[file.path] = hashCurrent; if (!hashLast) { return {}; @@ -104,7 +106,6 @@ class HashTracker implements ChangeTracker { } return notModified(); } - } /// A stub tracker that doesn't know diff --git a/web-client/src/core/editor/ExternalEditorKernel.ts b/web-client/src/core/editor/ExternalEditorKernel.ts index a3d2820d..e0242c12 100644 --- a/web-client/src/core/editor/ExternalEditorKernel.ts +++ b/web-client/src/core/editor/ExternalEditorKernel.ts @@ -12,7 +12,12 @@ import { import { EditorKernel } from "./EditorKernel"; import { EditorKernelAccess } from "./EditorKernelAccess"; -import { ChangeTracker, StaticTimeTracker, newHashBasedTracker, newModifyTimeBasedTracker } from "./ChangeTracker"; +import { + ChangeTracker, + StaticTimeTracker, + newHashBasedTracker, + newModifyTimeBasedTracker, +} from "./ChangeTracker"; console.info("loading external editor kernel"); @@ -78,7 +83,10 @@ class ExternalEditorKernel implements EditorKernel, CompilerFileAccess { private async recompileIfChanged() { // locking is not needed because idle will be paused // when an idle cycle is running - const changed = await this.checkDirectoryChanged(fsRoot(), this.fs.capabilities.live); + const changed = await this.checkDirectoryChanged( + fsRoot(), + this.fs.capabilities.live, + ); if (changed) { this.staticTimeTracker.setLastTime(Date.now()); @@ -87,7 +95,10 @@ class ExternalEditorKernel implements EditorKernel, CompilerFileAccess { } } - private async checkDirectoryChanged(path: string, live: boolean): Promise { + private async checkDirectoryChanged( + path: string, + live: boolean, + ): Promise { const entries = await this.fs.listDir(path); if (entries.err) { // error reading entry, something probably happened? @@ -100,7 +111,10 @@ class ExternalEditorKernel implements EditorKernel, CompilerFileAccess { for (const entry of entries.val) { const subPath = fsJoin(path, entry); if (entry.endsWith("/")) { - const dirChanged = await this.checkDirectoryChanged(subPath, live); + const dirChanged = await this.checkDirectoryChanged( + subPath, + live, + ); if (dirChanged) { changed = true; if (live) { @@ -121,7 +135,10 @@ class ExternalEditorKernel implements EditorKernel, CompilerFileAccess { return changed; } - private async checkFileChanged(path: string, live: boolean): Promise { + private async checkFileChanged( + path: string, + live: boolean, + ): Promise { // close the file so we always get the latest modified time // note that in web editor flow, we don't need to do this // because the file system content always needs to be @@ -130,9 +147,13 @@ class ExternalEditorKernel implements EditorKernel, CompilerFileAccess { const fsFile = this.fs.getFile(path); let result; if (live) { - result = await this.staticTimeTracker.checkModifiedSinceLastAccess(fsFile); + result = await this.staticTimeTracker.checkModifiedSinceLastAccess( + fsFile, + ); } else { - result = await this.staticHashTracker.checkModifiedSinceLastAccess(fsFile); + result = await this.staticHashTracker.checkModifiedSinceLastAccess( + fsFile, + ); } if (result.err) { if (result.err.code === FsErr.NotModified) { @@ -153,8 +174,9 @@ class ExternalEditorKernel implements EditorKernel, CompilerFileAccess { ): Promise> { const fsFile = this.fs.getFile(path); if (checkChanged) { - const notModified = - await this.tracker.checkModifiedSinceLastAccess(fsFile); + const notModified = await this.tracker.checkModifiedSinceLastAccess( + fsFile, + ); if (notModified.err) { return notModified; } diff --git a/web-client/src/core/editor/FileMgr.ts b/web-client/src/core/editor/FileMgr.ts index 9d668651..6686df08 100644 --- a/web-client/src/core/editor/FileMgr.ts +++ b/web-client/src/core/editor/FileMgr.ts @@ -392,8 +392,9 @@ export class FileMgr implements CompilerFileAccess { ): Promise> { const fsFile = fs.getFile(path); if (checkChanged) { - const notModified = - await this.tracker.checkModifiedSinceLastAccess(fsFile); + const notModified = await this.tracker.checkModifiedSinceLastAccess( + fsFile, + ); if (notModified.err) { return notModified; } diff --git a/web-client/src/low/utils/Yielder.ts b/web-client/src/low/utils/Yielder.ts index 3f004a64..92229eb9 100644 --- a/web-client/src/low/utils/Yielder.ts +++ b/web-client/src/low/utils/Yielder.ts @@ -12,7 +12,7 @@ export const createYielder = (budget: number) => { setTimeout(() => resolve(true), 0); }); } - currentBudget-=cost; + currentBudget -= cost; return Promise.resolve(false); }; }; diff --git a/web-client/src/ui/toolbar/Export.tsx b/web-client/src/ui/toolbar/Export.tsx index 09eec76a..4bc47ef6 100644 --- a/web-client/src/ui/toolbar/Export.tsx +++ b/web-client/src/ui/toolbar/Export.tsx @@ -201,7 +201,6 @@ const runExportWizard = async ( exportMetadata[selection], settingsSelector(state), ); - // eslint-disable-next-line no-constant-condition while (true) { // show extra config dialog if needed if (enableConfig) { diff --git a/web-client/src/ui/toolbar/settings/PluginSettings.tsx b/web-client/src/ui/toolbar/settings/PluginSettings.tsx index 52151d6f..87b2f64f 100644 --- a/web-client/src/ui/toolbar/settings/PluginSettings.tsx +++ b/web-client/src/ui/toolbar/settings/PluginSettings.tsx @@ -180,7 +180,6 @@ const editUserPluginConfig = async ( ): Promise => { let config = userPluginConfig; let [_, error] = parseUserConfigOptions(config, document); - // eslint-disable-next-line no-constant-condition while (true) { const response = await kernel.getAlertMgr().showRich({ title: "User Plugins", diff --git a/web-client/tools/test/jest.config.cjs b/web-client/tools/test/jest.config.cjs index 3a54e093..eb63bdd5 100644 --- a/web-client/tools/test/jest.config.cjs +++ b/web-client/tools/test/jest.config.cjs @@ -9,6 +9,7 @@ module.exports = { "^ui/(.*)": "/src/ui/$1", "^core/(.*)": "/src/core/$1", "^low/(.*)": "/src/low/$1", + "^pure/(.*)": "/libs/pure/$1", "^@test$": "/tools/test", }, transform: { diff --git a/web-client/vite.config.ts b/web-client/vite.config.ts index 3bd3f4bc..c1831f10 100644 --- a/web-client/vite.config.ts +++ b/web-client/vite.config.ts @@ -38,9 +38,7 @@ const https = createHttpsConfig(); export default defineConfig({ plugins: [ react(), - tsconfigPaths( - // projects: ["./tsconfig.json", "../libs/tsconfig.json"], - ), + tsconfigPaths(), removeRustStyleDocComments(), wasm(), topLevelAwait(),