snapshot of https://github.com/mountain-loop/yaak.git (main, 2026-09-23; blobs > 131072B omitted)
This commit is contained in:
76
packages/common-lib/debounce.test.ts
Normal file
76
packages/common-lib/debounce.test.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { debounce } from "./debounce";
|
||||
|
||||
describe("debounce", () => {
|
||||
beforeEach(() => vi.useFakeTimers());
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
it("calls once with the latest args after the delay", () => {
|
||||
const fn = vi.fn();
|
||||
const d = debounce(fn, 100);
|
||||
|
||||
d("a");
|
||||
d("b");
|
||||
d("c");
|
||||
expect(fn).not.toHaveBeenCalled();
|
||||
|
||||
vi.advanceTimersByTime(100);
|
||||
expect(fn).toHaveBeenCalledExactlyOnceWith("c");
|
||||
});
|
||||
|
||||
it("flush invokes a pending call immediately", () => {
|
||||
const fn = vi.fn();
|
||||
const d = debounce(fn, 100);
|
||||
|
||||
d("a");
|
||||
d("b");
|
||||
d.flush();
|
||||
expect(fn).toHaveBeenCalledExactlyOnceWith("b");
|
||||
|
||||
// The scheduled call was cancelled, so waiting out the delay adds nothing
|
||||
vi.advanceTimersByTime(100);
|
||||
expect(fn).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("flush is a no-op with nothing pending", () => {
|
||||
const fn = vi.fn();
|
||||
const d = debounce(fn, 100);
|
||||
|
||||
d.flush();
|
||||
expect(fn).not.toHaveBeenCalled();
|
||||
|
||||
d("a");
|
||||
vi.advanceTimersByTime(100);
|
||||
d.flush();
|
||||
expect(fn).toHaveBeenCalledExactlyOnceWith("a");
|
||||
});
|
||||
|
||||
it("cancel drops the pending call and its args", () => {
|
||||
const fn = vi.fn();
|
||||
const d = debounce(fn, 100);
|
||||
|
||||
d("a");
|
||||
d.cancel();
|
||||
vi.advanceTimersByTime(100);
|
||||
expect(fn).not.toHaveBeenCalled();
|
||||
|
||||
// A cancelled call must not leak its args into the next one
|
||||
d.flush();
|
||||
expect(fn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("starts a fresh delay after firing", () => {
|
||||
const fn = vi.fn();
|
||||
const d = debounce(fn, 100);
|
||||
|
||||
d("a");
|
||||
vi.advanceTimersByTime(100);
|
||||
d("b");
|
||||
vi.advanceTimersByTime(99);
|
||||
expect(fn).toHaveBeenCalledOnce();
|
||||
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(fn).toHaveBeenCalledTimes(2);
|
||||
expect(fn).toHaveBeenLastCalledWith("b");
|
||||
});
|
||||
});
|
||||
32
packages/common-lib/debounce.ts
Normal file
32
packages/common-lib/debounce.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
// oxlint-disable-next-line no-explicit-any
|
||||
export function debounce(fn: (...args: any[]) => void, delay = 500) {
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
// oxlint-disable-next-line no-explicit-any
|
||||
let lastArgs: any[] | null = null;
|
||||
// oxlint-disable-next-line no-explicit-any
|
||||
const result = (...args: any[]) => {
|
||||
lastArgs = args;
|
||||
if (timer != null) clearTimeout(timer);
|
||||
timer = setTimeout(() => {
|
||||
timer = null;
|
||||
const argsToUse = lastArgs ?? [];
|
||||
lastArgs = null;
|
||||
fn(...argsToUse);
|
||||
}, delay);
|
||||
};
|
||||
result.cancel = () => {
|
||||
if (timer != null) clearTimeout(timer);
|
||||
timer = null;
|
||||
lastArgs = null;
|
||||
};
|
||||
// Invoke a pending call immediately instead of waiting out the delay
|
||||
result.flush = () => {
|
||||
if (timer == null) return;
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
const argsToUse = lastArgs ?? [];
|
||||
lastArgs = null;
|
||||
fn(...argsToUse);
|
||||
};
|
||||
return result;
|
||||
}
|
||||
36
packages/common-lib/eagerDebounceAsync.ts
Normal file
36
packages/common-lib/eagerDebounceAsync.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
export function eagerDebounceAsync(fn: () => Promise<void>, delay: number) {
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
let inFlight: Promise<void> | null = null;
|
||||
let runAfterInFlight = false;
|
||||
|
||||
const run = async () => {
|
||||
if (inFlight != null) {
|
||||
runAfterInFlight = true;
|
||||
return;
|
||||
}
|
||||
|
||||
runAfterInFlight = false;
|
||||
inFlight = fn()
|
||||
.catch(console.error)
|
||||
.finally(() => {
|
||||
inFlight = null;
|
||||
if (runAfterInFlight && timer == null) {
|
||||
void run();
|
||||
}
|
||||
});
|
||||
await inFlight;
|
||||
};
|
||||
|
||||
return () => {
|
||||
if (timer == null) {
|
||||
void run();
|
||||
} else {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
|
||||
timer = setTimeout(() => {
|
||||
timer = null;
|
||||
void run();
|
||||
}, delay);
|
||||
};
|
||||
}
|
||||
20
packages/common-lib/formatSize.ts
Normal file
20
packages/common-lib/formatSize.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
export function formatSize(bytes: number): string {
|
||||
let num: number;
|
||||
let unit: string;
|
||||
|
||||
if (bytes > 1000 * 1000 * 1000) {
|
||||
num = bytes / 1000 / 1000 / 1000;
|
||||
unit = "GB";
|
||||
} else if (bytes > 1000 * 1000) {
|
||||
num = bytes / 1000 / 1000;
|
||||
unit = "MB";
|
||||
} else if (bytes > 1000) {
|
||||
num = bytes / 1000;
|
||||
unit = "KB";
|
||||
} else {
|
||||
num = bytes;
|
||||
unit = "B";
|
||||
}
|
||||
|
||||
return `${Math.round(num * 10) / 10} ${unit}`;
|
||||
}
|
||||
4
packages/common-lib/index.ts
Normal file
4
packages/common-lib/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from "./debounce";
|
||||
export * from "./eagerDebounceAsync";
|
||||
export * from "./formatSize";
|
||||
export * from "./templateFunction";
|
||||
39
packages/common-lib/jsonPath.ts
Normal file
39
packages/common-lib/jsonPath.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
/** Literal steps into a JSON response, distinct from JSONPath operators. */
|
||||
export type JsonPathSegment =
|
||||
| { readonly kind: "key"; readonly key: string }
|
||||
| { readonly kind: "index"; readonly index: number };
|
||||
|
||||
const PATH_SEGMENT = /^(?:\.([A-Za-z_$][A-Za-z0-9_$]*)|\[(0|[1-9]\d*)\]|\[("(?:[^"\\]|\\.)*")\])/;
|
||||
|
||||
/**
|
||||
* Parse a single location with JSON-quoted keys (`$.a[0]["b.c"]`). Other
|
||||
* JSONPath expressions belong to the filter engine, not to the breadcrumb trail.
|
||||
*/
|
||||
export function jsonPathToSegments(path: string): JsonPathSegment[] | null {
|
||||
let rest = path.trim();
|
||||
if (!rest.startsWith("$")) return null;
|
||||
rest = rest.slice(1);
|
||||
|
||||
const segments: JsonPathSegment[] = [];
|
||||
while (rest.length > 0) {
|
||||
const match = PATH_SEGMENT.exec(rest);
|
||||
if (match == null) return null;
|
||||
const [whole, bareKey, index, quotedKey] = match;
|
||||
if (bareKey != null) {
|
||||
if (bareKey === "$") return null; // The library's root operator, not a literal key.
|
||||
segments.push({ kind: "key", key: bareKey });
|
||||
} else if (index != null) {
|
||||
const value = Number(index);
|
||||
if (!Number.isSafeInteger(value)) return null;
|
||||
segments.push({ kind: "index", index: value });
|
||||
} else if (quotedKey != null) {
|
||||
try {
|
||||
segments.push({ kind: "key", key: JSON.parse(quotedKey) as string });
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
rest = rest.slice(whole.length);
|
||||
}
|
||||
return segments;
|
||||
}
|
||||
6
packages/common-lib/package.json
Normal file
6
packages/common-lib/package.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "@yaakapp-internal/lib",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"main": "index.ts"
|
||||
}
|
||||
49
packages/common-lib/templateFunction.ts
Normal file
49
packages/common-lib/templateFunction.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import type {
|
||||
CallTemplateFunctionArgs,
|
||||
JsonPrimitive,
|
||||
TemplateFunctionArg,
|
||||
} from "@yaakapp-internal/plugins";
|
||||
|
||||
export function validateTemplateFunctionArgs(
|
||||
fnName: string,
|
||||
args: TemplateFunctionArg[],
|
||||
values: CallTemplateFunctionArgs["values"],
|
||||
): string | null {
|
||||
for (const arg of args) {
|
||||
if ("inputs" in arg && arg.inputs) {
|
||||
// Recurse down
|
||||
const err = validateTemplateFunctionArgs(fnName, arg.inputs, values);
|
||||
if (err) return err;
|
||||
}
|
||||
if (!("name" in arg)) continue;
|
||||
if (arg.optional) continue;
|
||||
if (arg.defaultValue != null) continue;
|
||||
if (arg.hidden) continue;
|
||||
if (values[arg.name] != null) continue;
|
||||
|
||||
return `Missing required argument "${arg.label || arg.name}" for template function ${fnName}()`;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Recursively apply form input defaults to a set of values */
|
||||
export function applyFormInputDefaults(
|
||||
inputs: TemplateFunctionArg[],
|
||||
values: { [p: string]: JsonPrimitive | undefined },
|
||||
) {
|
||||
let newValues: { [p: string]: JsonPrimitive | undefined } = { ...values };
|
||||
for (const input of inputs) {
|
||||
if ("defaultValue" in input && values[input.name] === undefined) {
|
||||
newValues[input.name] = input.defaultValue;
|
||||
}
|
||||
if (input.type === "checkbox" && values[input.name] === undefined) {
|
||||
newValues[input.name] = false;
|
||||
}
|
||||
// Recurse down to all child inputs
|
||||
if ("inputs" in input) {
|
||||
newValues = applyFormInputDefaults(input.inputs ?? [], newValues);
|
||||
}
|
||||
}
|
||||
return newValues;
|
||||
}
|
||||
11
packages/model-store/package.json
Normal file
11
packages/model-store/package.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "@yaakapp-internal/model-store",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"peerDependencies": {
|
||||
"jotai": "^2.18.0"
|
||||
}
|
||||
}
|
||||
94
packages/model-store/src/index.ts
Normal file
94
packages/model-store/src/index.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { atom } from "jotai";
|
||||
import { selectAtom } from "jotai/utils";
|
||||
|
||||
/** Model map: each key is a model type name, each value is the model shape (must have id). */
|
||||
type ModelMap = Record<string, { id: string }>;
|
||||
|
||||
/** The store data shape derived from the model map. */
|
||||
type StoreData<M extends ModelMap> = {
|
||||
[K in keyof M]: Record<string, M[K]>;
|
||||
};
|
||||
|
||||
export type ModelChange = { type: "upsert" } | { type: "delete" };
|
||||
|
||||
function emptyStore<M extends ModelMap>(keys: (keyof M)[]): StoreData<M> {
|
||||
const data = {} as StoreData<M>;
|
||||
for (const k of keys) {
|
||||
data[k] = {} as Record<string, M[typeof k]>;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
export function createModelStore<M extends ModelMap>(modelTypes: (keyof M & string)[]) {
|
||||
const dataAtom = atom<StoreData<M>>(emptyStore<M>(modelTypes));
|
||||
|
||||
/** Apply a single upsert or delete to the store. */
|
||||
function applyChange<K extends keyof M & string>(
|
||||
prev: StoreData<M>,
|
||||
modelType: K,
|
||||
model: M[K],
|
||||
change: ModelChange,
|
||||
): StoreData<M> {
|
||||
if (change.type === "upsert") {
|
||||
return {
|
||||
...prev,
|
||||
[modelType]: { ...prev[modelType], [model.id]: model },
|
||||
};
|
||||
} else {
|
||||
const bucket = { ...prev[modelType] };
|
||||
delete bucket[model.id];
|
||||
return { ...prev, [modelType]: bucket };
|
||||
}
|
||||
}
|
||||
|
||||
/** Atom that selects all models of a given type as an array. */
|
||||
function listAtom<K extends keyof M & string>(modelType: K) {
|
||||
return selectAtom(
|
||||
dataAtom,
|
||||
(data) => Object.values(data[modelType] ?? {}) as M[K][],
|
||||
shallowEqual,
|
||||
);
|
||||
}
|
||||
|
||||
/** Atom that selects all models of a given type, sorted by a field. */
|
||||
function orderedListAtom<K extends keyof M & string>(
|
||||
modelType: K,
|
||||
field: keyof M[K],
|
||||
order: "asc" | "desc",
|
||||
) {
|
||||
return selectAtom(
|
||||
dataAtom,
|
||||
(data) => {
|
||||
const vals = Object.values(data[modelType] ?? {}) as M[K][];
|
||||
return vals.sort((a, b) => {
|
||||
const n = a[field] > b[field] ? 1 : -1;
|
||||
return order === "desc" ? -n : n;
|
||||
});
|
||||
},
|
||||
shallowEqual,
|
||||
);
|
||||
}
|
||||
|
||||
/** Replace all models of a given type. Used for initial hydration. */
|
||||
function replaceAll<K extends keyof M & string>(
|
||||
prev: StoreData<M>,
|
||||
modelType: K,
|
||||
models: M[K][],
|
||||
): StoreData<M> {
|
||||
const bucket = {} as Record<string, M[K]>;
|
||||
for (const m of models) {
|
||||
bucket[m.id] = m;
|
||||
}
|
||||
return { ...prev, [modelType]: bucket };
|
||||
}
|
||||
|
||||
return { dataAtom, applyChange, replaceAll, listAtom, orderedListAtom };
|
||||
}
|
||||
|
||||
function shallowEqual<T>(a: T[], b: T[]): boolean {
|
||||
if (a.length !== b.length) return false;
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
if (a[i] !== b[i]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
23
packages/platform/package.json
Normal file
23
packages/platform/package.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "@yaakapp-internal/platform",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"scripts": {
|
||||
"lint": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@yaakapp-internal/models": "^1.0.0",
|
||||
"@yaakapp-internal/rpc-schema": "^1.0.0",
|
||||
"@yaakapp-internal/web": "^1.0.0",
|
||||
"@yaakapp-internal/wasm": "^1.0.0",
|
||||
"@tauri-apps/api": "^2.11.0",
|
||||
"@tauri-apps/plugin-clipboard-manager": "^2.3.2",
|
||||
"@tauri-apps/plugin-dialog": "^2.7.1",
|
||||
"@tauri-apps/plugin-fs": "^2.5.1",
|
||||
"@tauri-apps/plugin-opener": "^2.5.4",
|
||||
"@tauri-apps/plugin-os": "^2.3.2"
|
||||
}
|
||||
}
|
||||
14
packages/platform/src/capabilities.ts
Normal file
14
packages/platform/src/capabilities.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { platform } from "./registry";
|
||||
import type { CapabilityName } from "./types";
|
||||
|
||||
/**
|
||||
* Whether the current host supports a feature.
|
||||
*
|
||||
* A hook so components can gate on it directly, and so this can become reactive
|
||||
* later — a browser tab gains and loses capabilities when the local bridge comes
|
||||
* and goes. Capabilities are fixed for the life of the desktop app, so today it
|
||||
* is a plain read.
|
||||
*/
|
||||
export function useCapability(name: CapabilityName): boolean {
|
||||
return platform.capabilities[name];
|
||||
}
|
||||
15
packages/platform/src/index.ts
Normal file
15
packages/platform/src/index.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { setPlatform } from "./registry";
|
||||
import { createTauriPlatform } from "./tauri";
|
||||
|
||||
// The desktop entry. Installed unconditionally and synchronously — several
|
||||
// modules call commands while the module graph is still evaluating, so there is
|
||||
// no later moment to do this in.
|
||||
//
|
||||
// This line is the swap point, and a browser build swaps it by resolving the
|
||||
// package to `index.web.ts` instead of this file. Because nothing else in the
|
||||
// app imports a host directly, that is the whole change.
|
||||
setPlatform(createTauriPlatform());
|
||||
|
||||
export * from "./capabilities";
|
||||
export { platform, setPlatform } from "./registry";
|
||||
export * from "./types";
|
||||
23
packages/platform/src/index.web.ts
Normal file
23
packages/platform/src/index.web.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { setPlatform } from "./registry";
|
||||
import { createWebPlatform } from "./web";
|
||||
|
||||
/**
|
||||
* The package entry for browser builds, selected by aliasing
|
||||
* `@yaakapp-internal/platform` to this file (see `YAAK_TARGET=web` in the
|
||||
* client's vite.config.ts).
|
||||
*
|
||||
* A separate entry rather than a branch inside `index.ts`, because a branch
|
||||
* would still leave `import "@tauri-apps/api"` in the module graph: the folded
|
||||
* `if` disappears, but the imports it guarded do not, and those modules cannot
|
||||
* be proven side-effect free. Splitting the entry means a web build never
|
||||
* mentions Tauri at all — and it keeps `index.ts` exactly as the desktop has
|
||||
* always had it.
|
||||
*
|
||||
* Like `index.ts`, this must install the host eagerly and synchronously:
|
||||
* boot-time modules call commands while the module graph is still evaluating.
|
||||
*/
|
||||
setPlatform(createWebPlatform());
|
||||
|
||||
export * from "./capabilities";
|
||||
export { platform, setPlatform } from "./registry";
|
||||
export * from "./types";
|
||||
66
packages/platform/src/registry.ts
Normal file
66
packages/platform/src/registry.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import type { Platform } from "./types";
|
||||
|
||||
let installed: Platform | null = null;
|
||||
|
||||
/**
|
||||
* Install the host implementation. Called once at import time by this package's
|
||||
* entry point, before any consumer's module body runs.
|
||||
*
|
||||
* It stays swappable at runtime because a browser build has to choose between
|
||||
* talking to a local bridge and running in-page, and it can only find out which
|
||||
* after it has tried to reach the bridge.
|
||||
*/
|
||||
export function setPlatform(next: Platform): void {
|
||||
installed = next;
|
||||
}
|
||||
|
||||
function host(): Platform {
|
||||
if (installed == null) {
|
||||
throw new Error("No platform installed. Import @yaakapp-internal/platform before using it.");
|
||||
}
|
||||
return installed;
|
||||
}
|
||||
|
||||
/**
|
||||
* The host, for everyone else to use.
|
||||
*
|
||||
* This is a fixed object that forwards to whatever is installed, so modules can
|
||||
* import it at any time — including at module scope, which several boot-time
|
||||
* modules do — without capturing a stale implementation.
|
||||
*
|
||||
* A host that has to connect before it can work (a WebSocket to the bridge)
|
||||
* buffers inside its own `rpc`; nothing here waits on a connection, because the
|
||||
* app's boot sequence calls commands while the module graph is still evaluating
|
||||
* and cannot be made to wait.
|
||||
*/
|
||||
export const platform: Platform = {
|
||||
get httpProxyUrl() {
|
||||
return host().httpProxyUrl;
|
||||
},
|
||||
get capabilities() {
|
||||
return host().capabilities;
|
||||
},
|
||||
get window() {
|
||||
return host().window;
|
||||
},
|
||||
get clipboard() {
|
||||
return host().clipboard;
|
||||
},
|
||||
get dialog() {
|
||||
return host().dialog;
|
||||
},
|
||||
get files() {
|
||||
return host().files;
|
||||
},
|
||||
get blobs() {
|
||||
return host().blobs;
|
||||
},
|
||||
rpc: (cmd, payload) => host().rpc(cmd, payload),
|
||||
rpcStream: (cmd, payload, onMessage) => host().rpcStream(cmd, payload, onMessage),
|
||||
listen: (event, callback) => host().listen(event, callback),
|
||||
emit: (event, payload) => host().emit(event, payload),
|
||||
openUrl: (url) => host().openUrl(url),
|
||||
revealItemInDir: (path) => host().revealItemInDir(path),
|
||||
osType: () => host().osType(),
|
||||
appIdentifier: () => host().appIdentifier(),
|
||||
};
|
||||
228
packages/platform/src/tauri/index.ts
Normal file
228
packages/platform/src/tauri/index.ts
Normal file
@@ -0,0 +1,228 @@
|
||||
import { getIdentifier } from "@tauri-apps/api/app";
|
||||
import { convertFileSrc, invoke } from "@tauri-apps/api/core";
|
||||
import { emit as tauriEmit, listen as tauriListen } from "@tauri-apps/api/event";
|
||||
import { basename, resolveResource } from "@tauri-apps/api/path";
|
||||
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
|
||||
import { clear, readText, writeText } from "@tauri-apps/plugin-clipboard-manager";
|
||||
import { open, save as saveDialog } from "@tauri-apps/plugin-dialog";
|
||||
import { readDir, readFile, readTextFile } from "@tauri-apps/plugin-fs";
|
||||
import { openUrl, revealItemInDir } from "@tauri-apps/plugin-opener";
|
||||
import { type as osType } from "@tauri-apps/plugin-os";
|
||||
import type {
|
||||
DragDropEvent,
|
||||
OpenDialogOptions,
|
||||
Platform,
|
||||
PlatformCapabilities,
|
||||
PlatformWindow,
|
||||
RpcPayload,
|
||||
RpcStreamHandle,
|
||||
Unsubscribe,
|
||||
} from "../types";
|
||||
|
||||
/**
|
||||
* The desktop host: the yaak-rpc envelope carried by Tauri's `invoke` and
|
||||
* window events. Every command goes through the single `rpc` Tauri command
|
||||
* into the `RpcRouter` on the Rust side.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Tauri hands back an unsubscribe function asynchronously; the platform hands
|
||||
* back one immediately, so callers can unsubscribe from a React cleanup without
|
||||
* awaiting. Unsubscribing before the subscription lands is honoured once it does.
|
||||
*/
|
||||
function toSyncUnsubscribe(pending: Promise<Unsubscribe>): Unsubscribe {
|
||||
let unsubscribe: Unsubscribe | null = null;
|
||||
let cancelled = false;
|
||||
|
||||
pending
|
||||
.then((fn) => {
|
||||
if (cancelled) fn();
|
||||
else unsubscribe = fn;
|
||||
})
|
||||
.catch(console.error);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
unsubscribe?.();
|
||||
unsubscribe = null;
|
||||
};
|
||||
}
|
||||
|
||||
const ALL_CAPABILITIES: PlatformCapabilities = {
|
||||
httpSending: true,
|
||||
grpc: true,
|
||||
websocket: true,
|
||||
git: true,
|
||||
sync: true,
|
||||
tlsOptions: true,
|
||||
cookieJar: true,
|
||||
localFiles: true,
|
||||
timeline: true,
|
||||
multiWindow: true,
|
||||
windowChrome: true,
|
||||
interfaceZoom: true,
|
||||
plugins: true,
|
||||
encryption: true,
|
||||
updater: true,
|
||||
clipboardRead: true,
|
||||
systemFonts: true,
|
||||
license: true,
|
||||
};
|
||||
|
||||
function createWindow(): PlatformWindow {
|
||||
const webview = getCurrentWebviewWindow();
|
||||
|
||||
return {
|
||||
label: webview.label,
|
||||
show: () => webview.show(),
|
||||
close: () => webview.close(),
|
||||
minimize: () => webview.minimize(),
|
||||
maximize: () => webview.maximize(),
|
||||
unmaximize: () => webview.unmaximize(),
|
||||
isMaximized: () => webview.isMaximized(),
|
||||
isFullscreen: () => webview.isFullscreen(),
|
||||
setZoom: (scale) => webview.setZoom(scale),
|
||||
theme: () => webview.theme(),
|
||||
onThemeChanged: (callback) =>
|
||||
toSyncUnsubscribe(webview.onThemeChanged((e) => callback(e.payload))),
|
||||
onFocusChanged: (callback) =>
|
||||
toSyncUnsubscribe(webview.onFocusChanged((e) => callback(e.payload))),
|
||||
onDragDrop: (callback) =>
|
||||
toSyncUnsubscribe(webview.onDragDropEvent((e) => callback(e.payload as DragDropEvent))),
|
||||
};
|
||||
}
|
||||
|
||||
async function rpc<T>(cmd: string, payload?: RpcPayload): Promise<T> {
|
||||
try {
|
||||
// Host plugin commands (`plugin:yaak-license|check`, ...) are registered by
|
||||
// their Tauri plugins and ride outside the envelope. Everything else goes
|
||||
// through the single `rpc` command and the RpcRouter behind it.
|
||||
if (cmd.startsWith("plugin:")) {
|
||||
return await invoke<T>(cmd, payload);
|
||||
}
|
||||
return await invoke<T>("rpc", { cmd, payload: payload ?? {} });
|
||||
} catch (err) {
|
||||
console.warn("Platform command error", cmd, err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Where this host keeps the bytes stored under an id, or null if it has none.
|
||||
*
|
||||
* The desktop stores them as files the engine wrote, so answering means asking
|
||||
* the backend. Callers hold ids and nothing else; the path exists for exactly
|
||||
* as long as it takes to open the file, which is the one thing a desktop host
|
||||
* can do that a tab cannot.
|
||||
*/
|
||||
function storedBodyPath(id: string): Promise<string | null> {
|
||||
return rpc<string | null>("cmd_http_response_body_path", { responseId: id });
|
||||
}
|
||||
|
||||
/**
|
||||
* Base64 for the trip over IPC, in chunks so a few megabytes don't blow the
|
||||
* argument limit. The engine's write command speaks base64; only this host has
|
||||
* to care.
|
||||
*/
|
||||
function toBase64(bytes: Uint8Array): string {
|
||||
let binary = "";
|
||||
for (let i = 0; i < bytes.length; i += 0x8000) {
|
||||
binary += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
export function createTauriPlatform(): Platform {
|
||||
const window = createWindow();
|
||||
|
||||
return {
|
||||
capabilities: ALL_CAPABILITIES,
|
||||
httpProxyUrl: null,
|
||||
window,
|
||||
|
||||
clipboard: {
|
||||
writeText: (text) => writeText(text),
|
||||
readText: () => readText(),
|
||||
clear: () => clear(),
|
||||
},
|
||||
|
||||
dialog: {
|
||||
// Overloaded on the interface; one implementation covers both shapes.
|
||||
open: ((options?: OpenDialogOptions) => open(options)) as Platform["dialog"]["open"],
|
||||
save: (options) => saveDialog(options ?? {}),
|
||||
},
|
||||
|
||||
files: {
|
||||
readDir: (path) => readDir(path),
|
||||
readText: (path) => readTextFile(path),
|
||||
url: (path) => convertFileSrc(path),
|
||||
basename: (path) => basename(path),
|
||||
resolveResource: (path) => resolveResource(path),
|
||||
|
||||
async save(suggestedName, content, filters) {
|
||||
const path = await saveDialog({ defaultPath: suggestedName, filters });
|
||||
if (path == null) return null;
|
||||
// Not the fs plugin: its ACL is read-only and scoped to the app's own
|
||||
// directories, and a path the user just picked is neither. The engine
|
||||
// writes it, which is where the desktop has always written it — and it
|
||||
// speaks base64, so content that is already base64 goes straight over.
|
||||
const data = content instanceof Uint8Array ? toBase64(content) : content.base64;
|
||||
await rpc("cmd_save_base64_to_binary", { filepath: path, data });
|
||||
return path;
|
||||
},
|
||||
},
|
||||
|
||||
blobs: {
|
||||
async read(id) {
|
||||
const path = await storedBodyPath(id);
|
||||
return path == null ? null : readFile(path);
|
||||
},
|
||||
|
||||
async url(id) {
|
||||
const path = await storedBodyPath(id);
|
||||
return path == null ? null : convertFileSrc(path);
|
||||
},
|
||||
},
|
||||
|
||||
rpc,
|
||||
|
||||
async rpcStream<T, M>(
|
||||
cmd: string,
|
||||
payload: RpcPayload,
|
||||
onMessage: (message: M) => void,
|
||||
): Promise<RpcStreamHandle<T>> {
|
||||
// The caller mints the stream id, and the subscription is awaited before
|
||||
// the command dispatches: registration is its own IPC round trip, and the
|
||||
// command may emit its first message while it runs.
|
||||
const streamId = crypto.randomUUID();
|
||||
const unlisten = await tauriListen<M>(`stream_${streamId}`, (e) => onMessage(e.payload), {
|
||||
target: { kind: "Window", label: window.label },
|
||||
});
|
||||
try {
|
||||
const result = await rpc<T>(cmd, { ...payload, streamId });
|
||||
return { result, unlisten };
|
||||
} catch (err) {
|
||||
unlisten();
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
listen<T>(event: string, callback: (payload: T) => void): Unsubscribe {
|
||||
return toSyncUnsubscribe(
|
||||
tauriListen<T>(event, (e) => callback(e.payload), {
|
||||
// Receives events broadcast to every window as well as ones addressed
|
||||
// to this one, which is how the backend sends both kinds today.
|
||||
target: { kind: "Window", label: window.label },
|
||||
}),
|
||||
);
|
||||
},
|
||||
|
||||
emit: (event, payload) => tauriEmit(event, payload),
|
||||
|
||||
openUrl: (url) => openUrl(url),
|
||||
revealItemInDir: (path) => revealItemInDir(path),
|
||||
|
||||
osType: () => osType(),
|
||||
appIdentifier: () => getIdentifier(),
|
||||
};
|
||||
}
|
||||
318
packages/platform/src/types.ts
Normal file
318
packages/platform/src/types.ts
Normal file
@@ -0,0 +1,318 @@
|
||||
/**
|
||||
* The complete surface the app is allowed to use to reach its host.
|
||||
*
|
||||
* Everything the UI needs from outside the page goes through this interface, so
|
||||
* that a host other than Tauri (a browser tab talking to the local bridge, a
|
||||
* self-hosted or hosted server) can be added by writing one more implementation
|
||||
* instead of touching call sites. Nothing outside `src/tauri` may import
|
||||
* `@tauri-apps/*` — that is the whole point of the package.
|
||||
*
|
||||
* Commands and events are the yaak-rpc envelope: a command name plus a JSON
|
||||
* payload in, a JSON payload out, and named events pushed the other way. The
|
||||
* Tauri host puts that envelope inside `invoke`; a WebSocket host will put it on
|
||||
* the wire unchanged.
|
||||
*/
|
||||
|
||||
/** Cancels a subscription. Safe to call more than once. */
|
||||
export type Unsubscribe = () => void;
|
||||
|
||||
/** Matches the values `@tauri-apps/plugin-os` reports so hosts agree on spelling. */
|
||||
export type OsType =
|
||||
| "android"
|
||||
| "dragonfly"
|
||||
| "freebsd"
|
||||
| "ios"
|
||||
| "linux"
|
||||
| "macos"
|
||||
| "netbsd"
|
||||
| "openbsd"
|
||||
| "solaris"
|
||||
| "windows";
|
||||
|
||||
export type PlatformAppearance = "light" | "dark";
|
||||
|
||||
/** Command arguments. Serialized to JSON, so only JSON values belong here. */
|
||||
export type RpcPayload = Record<string, unknown>;
|
||||
|
||||
/** A streaming command's result, plus the teardown for its subscription. */
|
||||
export interface RpcStreamHandle<T> {
|
||||
result: T;
|
||||
unlisten: Unsubscribe;
|
||||
}
|
||||
|
||||
/**
|
||||
* What to save: the bytes, or base64 of them for a caller that already has it
|
||||
* that way.
|
||||
*
|
||||
* Both forms exist because both hosts want a different one, and a value that
|
||||
* arrives base64 should not be decoded and re-encoded to get back where it
|
||||
* started. Whichever a caller has is the one to pass; each host converts only
|
||||
* when it has to.
|
||||
*/
|
||||
export type SaveContent = Uint8Array | { base64: string };
|
||||
|
||||
export interface DialogFilter {
|
||||
name: string;
|
||||
extensions: string[];
|
||||
}
|
||||
|
||||
export interface OpenDialogOptions {
|
||||
title?: string;
|
||||
defaultPath?: string;
|
||||
directory?: boolean;
|
||||
multiple?: boolean;
|
||||
filters?: DialogFilter[];
|
||||
}
|
||||
|
||||
export interface SaveDialogOptions {
|
||||
title?: string;
|
||||
defaultPath?: string;
|
||||
filters?: DialogFilter[];
|
||||
}
|
||||
|
||||
export interface DirEntry {
|
||||
name: string;
|
||||
isDirectory: boolean;
|
||||
isFile: boolean;
|
||||
isSymlink: boolean;
|
||||
}
|
||||
|
||||
export interface DragDropPosition {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* A native drag-and-drop over the window. Distinct from the DOM's drag events:
|
||||
* the host reports window coordinates, not a DOM target, so listeners hit-test
|
||||
* against their own bounding box.
|
||||
*/
|
||||
export type DragDropEvent =
|
||||
| { type: "enter"; paths: string[]; position: DragDropPosition }
|
||||
| { type: "over"; position: DragDropPosition }
|
||||
| { type: "drop"; paths: string[]; position: DragDropPosition }
|
||||
| { type: "leave" };
|
||||
|
||||
/**
|
||||
* The window (desktop) or tab (browser) this UI is running in.
|
||||
*
|
||||
* Every method a host must provide is named here on purpose. The build spike
|
||||
* replaced the Tauri window object wholesale and a single missing method
|
||||
* (`onDragDropEvent`) threw mid-render with nothing to catch it at compile time;
|
||||
* an explicit interface makes that class of failure a type error instead.
|
||||
*/
|
||||
export interface PlatformWindow {
|
||||
/**
|
||||
* Identity of this window among all the app's windows.
|
||||
*
|
||||
* Model writes carry the label of the window that made them so the others can
|
||||
* tell an echo of their own write from someone else's. A browser host can
|
||||
* satisfy this with a per-tab id and a BroadcastChannel.
|
||||
*/
|
||||
readonly label: string;
|
||||
|
||||
show(): Promise<void>;
|
||||
close(): Promise<void>;
|
||||
minimize(): Promise<void>;
|
||||
maximize(): Promise<void>;
|
||||
unmaximize(): Promise<void>;
|
||||
isMaximized(): Promise<boolean>;
|
||||
isFullscreen(): Promise<boolean>;
|
||||
|
||||
/** Scale the whole window's contents. 1 is unscaled. */
|
||||
setZoom(scale: number): Promise<void>;
|
||||
|
||||
/** The host's current appearance, or null if it has no opinion and CSS should decide. */
|
||||
theme(): Promise<PlatformAppearance | null>;
|
||||
|
||||
onThemeChanged(callback: (appearance: PlatformAppearance) => void): Unsubscribe;
|
||||
onFocusChanged(callback: (focused: boolean) => void): Unsubscribe;
|
||||
onDragDrop(callback: (event: DragDropEvent) => void): Unsubscribe;
|
||||
}
|
||||
|
||||
export interface PlatformClipboard {
|
||||
writeText(text: string): Promise<void>;
|
||||
readText(): Promise<string>;
|
||||
clear(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface PlatformDialog {
|
||||
open(options: OpenDialogOptions & { multiple: true }): Promise<string[] | null>;
|
||||
open(options?: OpenDialogOptions): Promise<string | null>;
|
||||
save(options?: SaveDialogOptions): Promise<string | null>;
|
||||
}
|
||||
|
||||
/**
|
||||
* File-ish operations, keyed by paths the backend handed us.
|
||||
*
|
||||
* A path here is an opaque handle, not something to parse or construct: the UI
|
||||
* only ever passes one back to `url` or `readDir`. A host without a filesystem
|
||||
* can mint handles of its own (a blob id, a URL) and stay compatible.
|
||||
*/
|
||||
export interface PlatformFiles {
|
||||
readDir(path: string): Promise<DirEntry[]>;
|
||||
|
||||
/**
|
||||
* The text of a file the user picked, decoded as UTF-8.
|
||||
*
|
||||
* Only for paths the host itself handed us — a dialog result or a drag-drop
|
||||
* payload — never one the page assembled. A host without a filesystem reads
|
||||
* whatever its own handle points at.
|
||||
*/
|
||||
readText(path: string): Promise<string>;
|
||||
|
||||
/** A URL the page can load a file from, for `<img>`, `<video>`, and friends. */
|
||||
url(path: string): string;
|
||||
|
||||
basename(path: string): Promise<string>;
|
||||
|
||||
/** Resolve a path bundled with the app itself, rather than one from the backend. */
|
||||
resolveResource(path: string): Promise<string>;
|
||||
|
||||
/**
|
||||
* Put bytes somewhere the user chooses. Returns where they went — a path, a
|
||||
* filename, whatever this host can say — or null if the user backed out.
|
||||
*
|
||||
* The caller supplies the bytes, which is the whole point: the alternative
|
||||
* shape, where a dialog mints a path and a separate backend command writes to
|
||||
* it, can only work on a host that has both a filesystem and a backend. A tab
|
||||
* has neither, so every one of those call sites was a dead control. Whoever
|
||||
* produces the bytes already knows what they are; the host only has to know
|
||||
* where they go.
|
||||
*
|
||||
* `suggestedName` is what the save dialog opens with, extension included.
|
||||
*/
|
||||
save(
|
||||
suggestedName: string,
|
||||
content: SaveContent,
|
||||
filters?: DialogFilter[],
|
||||
): Promise<string | null>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Content the backend stored, addressed by the id it stored it under.
|
||||
*
|
||||
* Where those bytes actually live is the one thing every host answers
|
||||
* differently — a file the engine wrote, a row in a database, a URL on the
|
||||
* other end of a socket — so finding them is the host's job and nobody else's.
|
||||
* The caller passes an id and never a location, which is also what keeps a page
|
||||
* from naming something the backend never wrote.
|
||||
*
|
||||
* What the content *means* is the app's business, not this package's.
|
||||
*/
|
||||
export interface PlatformBlobs {
|
||||
/** The bytes, or null if the host has nothing stored under that id. */
|
||||
read(id: string): Promise<Uint8Array<ArrayBuffer> | null>;
|
||||
|
||||
/**
|
||||
* A URL an element can load the content from, for `<img>`, `<video>` and
|
||||
* friends, or null if the host has nothing stored under that id.
|
||||
*
|
||||
* Asynchronous because the host may have to ask its backend where the bytes
|
||||
* are, and it only does that the moment before it reads them.
|
||||
*/
|
||||
url(id: string): Promise<string | null>;
|
||||
}
|
||||
|
||||
export interface Platform {
|
||||
readonly capabilities: PlatformCapabilities;
|
||||
/** Web request relay, or null when this host executes requests itself. */
|
||||
readonly httpProxyUrl: string | null;
|
||||
readonly window: PlatformWindow;
|
||||
readonly clipboard: PlatformClipboard;
|
||||
readonly dialog: PlatformDialog;
|
||||
readonly files: PlatformFiles;
|
||||
readonly blobs: PlatformBlobs;
|
||||
|
||||
/** Call a backend command and await its result. */
|
||||
rpc<T>(cmd: string, payload?: RpcPayload): Promise<T>;
|
||||
|
||||
/**
|
||||
* Call a command that streams messages back while it runs.
|
||||
*
|
||||
* Resolves once the command itself completes, with its result and an
|
||||
* `unlisten` that tears down the local subscription. The host guarantees the
|
||||
* subscription is live before the command is dispatched, so a stream that
|
||||
* emits immediately cannot lose its first message.
|
||||
*/
|
||||
rpcStream<T, M>(
|
||||
cmd: string,
|
||||
payload: RpcPayload,
|
||||
onMessage: (message: M) => void,
|
||||
): Promise<RpcStreamHandle<T>>;
|
||||
|
||||
/** Subscribe to a backend event addressed to this window. */
|
||||
listen<T>(event: string, callback: (payload: T) => void): Unsubscribe;
|
||||
|
||||
/** Send an event to the backend. Used for replies and for long-lived streams. */
|
||||
emit(event: string, payload?: unknown): Promise<void>;
|
||||
|
||||
openUrl(url: string): Promise<void>;
|
||||
revealItemInDir(path: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Which OS the UI is running on. Synchronous because layout decisions
|
||||
* (traffic-light padding, modifier key labels) are made during first render.
|
||||
*/
|
||||
osType(): OsType;
|
||||
|
||||
/** The host application's identifier, eg. `app.yaak.desktop`. */
|
||||
appIdentifier(): Promise<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* What this host can actually do.
|
||||
*
|
||||
* Read these instead of testing for a platform: "does this host have a cookie
|
||||
* jar" is the real question, and it stays answerable when there are three hosts.
|
||||
* The Tauri host hardcodes every capability on; Rust hosts will report theirs
|
||||
* from the cargo features they were built with.
|
||||
*/
|
||||
export interface PlatformCapabilities {
|
||||
/** Send HTTP requests and see the whole response: every header, the redirect chain, timing. */
|
||||
httpSending: boolean;
|
||||
/** Send gRPC requests. Needs HTTP/2 trailers, so it needs a real backend. */
|
||||
grpc: boolean;
|
||||
/** Send WebSocket requests with custom headers and auth. */
|
||||
websocket: boolean;
|
||||
/** Git-backed workspaces. */
|
||||
git: boolean;
|
||||
/** Two-way sync between a workspace and a directory of files. */
|
||||
sync: boolean;
|
||||
/** Client certificates, custom CAs, and disabling certificate validation. */
|
||||
tlsOptions: boolean;
|
||||
/** A cookie jar the user can read and edit. */
|
||||
cookieJar: boolean;
|
||||
/** Paths the backend can read: file bodies, proto files, sync directories. */
|
||||
localFiles: boolean;
|
||||
/** Per-request timing and connection detail. */
|
||||
timeline: boolean;
|
||||
/** More than one window or tab on the same data. */
|
||||
multiWindow: boolean;
|
||||
/**
|
||||
* The page is the window's titlebar: it draws the drag region and window
|
||||
* controls, and leaves room for macOS traffic lights. False when something
|
||||
* else owns the frame around the page (a browser tab), so none of that
|
||||
* chrome should be reserved or drawn.
|
||||
*/
|
||||
windowChrome: boolean;
|
||||
/**
|
||||
* The app zooms its own interface, and so owns Cmd/Ctrl `+`, `-` and `0`.
|
||||
* False in a browser, where those keys are already the browser's.
|
||||
*/
|
||||
interfaceZoom: boolean;
|
||||
/** The plugin runtime. */
|
||||
plugins: boolean;
|
||||
/** Workspace encryption backed by a key the host keeps. */
|
||||
encryption: boolean;
|
||||
/** In-app update checks and installs. */
|
||||
updater: boolean;
|
||||
/** Reading the clipboard without the user pasting first. */
|
||||
clipboardRead: boolean;
|
||||
/** Enumerating the fonts installed on the machine. */
|
||||
systemFonts: boolean;
|
||||
/** Commercial licence activation. */
|
||||
license: boolean;
|
||||
}
|
||||
|
||||
export type CapabilityName = keyof PlatformCapabilities;
|
||||
228
packages/platform/src/web/README.md
Normal file
228
packages/platform/src/web/README.md
Normal file
@@ -0,0 +1,228 @@
|
||||
# The browser host
|
||||
|
||||
Yaak running in a plain tab: no install, no local process. The desktop's own
|
||||
model layer — `yaak-models`, SQLite included — runs compiled to wasm inside a
|
||||
worker the tab talks to, so a browser stores exactly what a desktop install
|
||||
stores, migrations and all.
|
||||
|
||||
Select it at build time. `vp run web:dev` does that and starts the send server
|
||||
alongside the frontend:
|
||||
|
||||
```shell
|
||||
vp run web:dev
|
||||
```
|
||||
|
||||
The flag resolves `@yaakapp-internal/platform` to `../index.web.ts`, which
|
||||
installs this host instead of the Tauri one. It is a separate entry rather than
|
||||
a branch inside `index.ts` so that a web build never pulls `@tauri-apps/*` into
|
||||
the module graph at all — a folded branch would drop the code but keep the
|
||||
imports it guarded.
|
||||
|
||||
Desktop builds are untouched: without the flag, `packages/platform/src/index.ts`
|
||||
installs the Tauri host exactly as before.
|
||||
|
||||
## How it fits together
|
||||
|
||||
```
|
||||
tab (index.ts, commands.ts) ──MessagePort──▶ worker.ts ──▶ @yaakapp-internal/web (wasm)
|
||||
│ ◀── model_writes ── crates/yaak-wasm → yaak-models → SQLite
|
||||
│ └─ pages in IndexedDB
|
||||
└── send.ts ──POST rendered request──▶ yaak-web (crates-server) ──▶ the internet
|
||||
◀── NDJSON: events, response, body, cookies ──
|
||||
```
|
||||
|
||||
| File | What it is |
|
||||
| --------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `index.ts` | The `Platform` implementation. |
|
||||
| `commands.ts` | The command table: model commands forward to the worker; the rest is fixed answers and refusals-with-a-reason. |
|
||||
| `connection.ts` | A tab's end of the wire: request/response over a `MessagePort`, event delivery, and the tab's identity (`label`). |
|
||||
| `send.ts` | Sending: the worker renders (`prepare_http_send`), the server executes, this file stores what comes back where the desktop stores it. |
|
||||
| `server.ts` | Where the Yaak server is, and the wire shapes it speaks (generated from `crates-server/yaak-web/src/wire.rs`). |
|
||||
| `worker.ts` | The process that owns the database. Loads the wasm, opens the DB once, answers each port, fans `model_writes` out to every port. |
|
||||
| `protocol.ts` | The message shapes both sides import. |
|
||||
| `errors.ts` | `UnsupportedCommandError`, the structured refusal. |
|
||||
| `storage.ts` | `navigator.storage.persist()`. |
|
||||
|
||||
The Rust side is `crates/yaak-wasm` (`@yaakapp-internal/wasm`): `boot()`,
|
||||
`rpc(cmd, payload, label)` returning `{ result, events }`, blob get/put, and
|
||||
`prepare_http_send(payload)` — the database half of a send (environment chain,
|
||||
inherited headers and auth, request settings, cookie jar, rendering), which is
|
||||
`yaak_models::render::render_http_request`, the same function the desktop
|
||||
renders with.
|
||||
Its `pkg/` is committed; rebuilding needs a clang with a WebAssembly backend
|
||||
(`brew install llvm`), and `build-wasm.cjs` skips with a notice when there
|
||||
isn't one, so a desktop `npm run bootstrap` never depends on it.
|
||||
|
||||
Behaviours worth knowing before changing anything:
|
||||
|
||||
- **The worker is a `SharedWorker`, and only that.** The browser hands every
|
||||
tab on the origin the same one, which is what makes "one database owner"
|
||||
true without anyone coordinating — and makes the browser look like the
|
||||
desktop: one process holds the data, every window talks to it, it pushes
|
||||
writes to all of them. It still takes a Web Lock before opening, for the one
|
||||
overlap the browser doesn't rule out (a reloading tab's dying predecessor).
|
||||
There is deliberately no fallback to a per-tab worker: two kinds of worker
|
||||
that can both come up is a race. Every current browser, desktop and mobile,
|
||||
has `SharedWorker` and Web Locks (Chrome for Android since 148, April 2026);
|
||||
older ones get a clear "unsupported browser" message rather than a second
|
||||
SQLite over the same pages.
|
||||
- **Every write is stamped with the calling tab's `label`** as
|
||||
`UpdateSource::Window`, exactly like a desktop window label, so the frontend
|
||||
store's echo handling is unchanged.
|
||||
- **Cascade rules, duplicate naming, id generation, serde defaults, and the
|
||||
lazy first-run bootstrap are all the Rust code's.** Nothing about what a
|
||||
model _is_ is decided in TypeScript.
|
||||
- **Persistence is `relaxed-idb`**: SQLite pages live in IndexedDB, writes land
|
||||
in memory and flush shortly after. A tab closing mid-flush loses at most the
|
||||
last few writes.
|
||||
|
||||
## Commands
|
||||
|
||||
109 commands are declared in `@yaakapp-internal/rpc-schema`. This host answers
|
||||
32, declines 43 by name with a reason, and refuses the remaining 34 generically.
|
||||
|
||||
### Implemented (32)
|
||||
|
||||
| Group | Commands |
|
||||
| ------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Models | `models_workspace_models`, `models_upsert`, `models_delete`, `models_duplicate`, `models_get_settings`, `models_get_graphql_introspection`, `models_upsert_graphql_introspection`, `models_grpc_events`, `models_websocket_events` |
|
||||
| Sending | `cmd_send_http_request` (through the Yaak server; see below) |
|
||||
| App | `cmd_metadata`, `cmd_get_workspace_meta`, `cmd_default_headers`, `cmd_get_themes`, `cmd_check_for_updates`, `cmd_dismiss_notification`, `cmd_plugin_init_errors` |
|
||||
| Bodies | `cmd_http_response_body`, `cmd_http_response_body_path`, `cmd_http_request_body`, `cmd_get_http_response_events`, `cmd_get_sse_events` |
|
||||
| Plugin surfaces (empty results) | `cmd_http_request_actions`, `cmd_websocket_request_actions`, `cmd_grpc_request_actions`, `cmd_workspace_actions`, `cmd_folder_actions`, `cmd_template_function_summaries`, `cmd_get_http_authentication_summaries`, `cmd_get_http_authentication_config` |
|
||||
| Text | `cmd_format_json`, `cmd_render_template` |
|
||||
|
||||
Some of these answer honestly rather than fully, and the difference matters:
|
||||
|
||||
- `cmd_render_template` returns the template **unrendered**. Resolving variables
|
||||
and calling template functions is plugin work. The preview shows the raw
|
||||
`${[…]}` rather than a wrong value.
|
||||
- `cmd_get_http_authentication_summaries` returns the auth methods Yaak ships as
|
||||
plugins, so the picker is truthful about the product — but
|
||||
`cmd_get_http_authentication_config` returns an empty form, because the plugin
|
||||
that defines the form isn't running.
|
||||
- `cmd_template_function_summaries` returns one provider contributing no
|
||||
functions. Both summary commands are polled every second until they return
|
||||
something, so an empty list is a poll that never stops rather than a quiet no.
|
||||
- `cmd_metadata` reports empty strings for the data, log, plugin and project
|
||||
directories. There is no filesystem behind this host.
|
||||
|
||||
### Declined by name (43)
|
||||
|
||||
Each returns an `UnsupportedCommandError` carrying `cmd`, a user-facing
|
||||
`message`, and the `capability` a caller should have checked. The UI turns it
|
||||
into a toast.
|
||||
|
||||
| Reason | Commands |
|
||||
| -------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Sending, the parts not wired yet | `cmd_send_ephemeral_request`, `cmd_delete_send_history`, `cmd_delete_all_http_responses`, `cmd_import_url` |
|
||||
| No plugin runtime | `cmd_reload_plugins`, `cmd_plugin_info`, `cmd_plugins_search`, `cmd_plugins_install`, `cmd_plugins_install_from_directory`, `cmd_plugins_uninstall`, `cmd_plugins_updates`, `cmd_plugins_update_all`, `cmd_template_function_config`, `cmd_template_tokens_to_string`, `cmd_call_http_request_action`, `cmd_call_websocket_request_action`, `cmd_call_grpc_request_action`, `cmd_call_workspace_action`, `cmd_call_folder_action`, `cmd_call_http_authentication_action`, `cmd_curl_to_request`, `cmd_format_graphql` |
|
||||
| No filesystem | `cmd_import_data`, `cmd_export_data`, `cmd_save_response`, `cmd_save_base64_to_binary` |
|
||||
| Needs a real socket | `cmd_grpc_reflect`, `cmd_grpc_go`, `cmd_delete_all_grpc_connections`, `cmd_ws_connect`, `cmd_ws_send`, `cmd_ws_close`, `cmd_ws_delete_connections` |
|
||||
| Workspace encryption | `cmd_enable_encryption`, `cmd_disable_encryption`, `cmd_reveal_workspace_key`, `cmd_set_workspace_key`, `cmd_secure_template`, `cmd_decrypt_template` |
|
||||
| One tab, no windows | `cmd_new_child_window`, `cmd_new_main_window`, `cmd_restart` |
|
||||
| Other | `cmd_send_feedback` |
|
||||
|
||||
### Refused generically (34)
|
||||
|
||||
The 30 `cmd_git_*` commands and `cmd_sync_calculate`, `cmd_sync_calculate_fs`,
|
||||
`cmd_sync_apply`, `cmd_sync_watch`. Nothing in the app reaches them unless a
|
||||
workspace has a sync directory, which a browser tab cannot set.
|
||||
|
||||
Anything added to the schema later also lands here, and the error names the
|
||||
command — an unlisted command is a gap in `commands.ts`, and whoever hits it
|
||||
should be able to see which.
|
||||
|
||||
## Capabilities
|
||||
|
||||
Reported honestly, so callers gate on the question rather than on the host:
|
||||
|
||||
| True | False |
|
||||
| -------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `httpSending`, `timeline`, `cookieJar` | `grpc`, `websocket`, `git`, `sync`, `tlsOptions`, `localFiles`, `multiWindow`, `windowChrome`, `interfaceZoom`, `plugins`, `encryption`, `updater`, `clipboardRead`, `systemFonts`, `license` |
|
||||
|
||||
`interfaceZoom: false` leaves Cmd/Ctrl `+`, `-` and `0` to the browser instead
|
||||
of swallowing them, and drops those three rows from the hotkeys screen.
|
||||
|
||||
`multiWindow: false` means the host cannot open a _second window_ on demand —
|
||||
what `cmd_new_child_window` does for Settings and workspace switching. It is not
|
||||
a claim that nothing else is looking: other tabs may well be open on the same
|
||||
worker, and it pushes every write to all of them regardless.
|
||||
|
||||
`windowChrome: false` means the browser owns the frame around the page, so the
|
||||
header draws no window controls and reserves no room for macOS traffic lights.
|
||||
|
||||
## Multiple tabs
|
||||
|
||||
Each tab mints a label at load (`tab_xxxxxxxx`) and sends it with every command;
|
||||
the worker stamps writes with it as `UpdateSource::Window { label }`, standing in
|
||||
for the desktop's window label. The worker fans each write out to every
|
||||
connected tab, and the receiving tab's store applies or ignores it exactly as a
|
||||
desktop window would.
|
||||
|
||||
The label is deliberately _not_ kept in `sessionStorage`: duplicating a tab
|
||||
copies session storage, and two tabs sharing one identity would each mistake the
|
||||
other's writes for an echo of their own and drop them.
|
||||
|
||||
## Known gaps
|
||||
|
||||
- **Storage persistence is requested, not guaranteed.** `navigator.storage.persist()`
|
||||
runs at boot; browsers grant it on their own heuristics and often decline on
|
||||
`localhost`.
|
||||
- **`pkg/yaak_web_bg.wasm` is 3.8 MB and committed** (no `wasm-opt`, matching
|
||||
`yaak-templates`). It will churn on every model-layer change; a CI-built
|
||||
artifact is the real answer.
|
||||
- **Settings opens in the same tab** and is left with the browser's Back button.
|
||||
- **Settings shows Data Directory / Logs Directory rows** with empty values; the
|
||||
Create Workspace dialog offers directory sync and encryption. Should be gated
|
||||
on `localFiles` / `sync` / `encryption`.
|
||||
- **`cmd_render_template` returns the template unrendered.** Resolving variables
|
||||
and calling template functions is plugin work.
|
||||
- **A declined command logs an unhandled rejection** next to its toast — the
|
||||
app's own `createFastMutation.mutate`, same on desktop.
|
||||
- **`yaak-rpc-schema` does not come to wasm** (it pulls the git/gRPC/plugin
|
||||
crates for their types), so the crate declares the handful of request shapes
|
||||
it needs locally, and `commands.ts` stays typed against `RpcSchema`.
|
||||
|
||||
## Sending
|
||||
|
||||
A page cannot see a response the way a desktop app can — CORS exposes a handful
|
||||
of headers, redirects are followed silently, there is no timeline — so the
|
||||
network half of a send runs on a small stateless server,
|
||||
`crates-server/yaak-web`. This layer stays the only place data lives:
|
||||
|
||||
1. `send.ts` creates the `http_response` row (state `initialized`), as the
|
||||
desktop does, so anything that goes wrong lands in the response pane.
|
||||
2. The worker resolves and renders the request (`prepare_http_send`): the
|
||||
environment chain, inherited headers and auth, request settings, the cookie
|
||||
jar. This is the desktop's `HttpSendInputs`, in Rust, on the same model layer,
|
||||
with `yaak_models::render::render_http_request`. Variables (`${[ name ]}`)
|
||||
render here with no plugins involved.
|
||||
3. The rendered request, the settings and the jar's cookies are POSTed to the
|
||||
server. It streams back timeline events, the response head, body chunks and a
|
||||
terminal frame carrying the jar as the send left it.
|
||||
4. Each frame is written where the desktop writes it: the response row as it
|
||||
progresses, `http_response_event` rows for the timeline (which is why
|
||||
`timeline` is true), the body under the response id via `blob_put`, and the
|
||||
cookie jar through `models_upsert`. Every write fans out to every tab.
|
||||
|
||||
**What sends today:** any saved request whose templates are variables and whose
|
||||
authentication is none, or an inline header. Sending a request that needs a
|
||||
template _function_ (`${[ timestamp() ]}`) or an authentication plugin (bearer,
|
||||
basic, OAuth, …) is refused before anything leaves the tab, with a message naming
|
||||
what it needs; those light up when plugins run in the browser. Requests with a
|
||||
file body or multipart file fields are refused by the server (it has no access to
|
||||
your files, and must not read its own). And on a public instance a request to
|
||||
`localhost` or a LAN address can't work: the server runs elsewhere and refuses
|
||||
private ranges outright — that is what the desktop app is for. A self-hosted
|
||||
server on your own network can be started with `--allow-private-networks`, which
|
||||
is the one case where those addresses are the user's to reach.
|
||||
|
||||
**Where the tab sends** (`server.ts`): its own origin, in development as well as
|
||||
production, so there is no address to learn and no CORS in the loop. A
|
||||
production build is served by the sender itself (`yaak-web --serve
|
||||
dist/apps/yaak-client`, which is what the `ghcr.io/mountain-loop/yaak-web`
|
||||
image runs). In development the Vite server passes `/v1` through to `yaak-web`
|
||||
instead, following `PORT` to find it — `vp run web:dev` starts both.
|
||||
`VITE_YAAK_WEB_URL` overrides this, for a deployment that keeps the app and the
|
||||
server apart.
|
||||
373
packages/platform/src/web/commands.ts
Normal file
373
packages/platform/src/web/commands.ts
Normal file
@@ -0,0 +1,373 @@
|
||||
/**
|
||||
* The command table: what this host answers, and what it declines and why.
|
||||
*
|
||||
* The model commands are forwarded to the worker, where the desktop's own
|
||||
* model layer answers them — same queries, same migrations, same cascade
|
||||
* rules — so nothing about *what a model is* is decided in this file. What is
|
||||
* decided here is the rest of the desktop's command surface: a handful of
|
||||
* fixed answers that are true of a browser tab, and the refusals. The refusals
|
||||
* are the important half: a command that silently returns nothing leaves the
|
||||
* UI showing something that isn't true, whereas a refusal with a reason becomes
|
||||
* a toast the user can act on. So each unsupported command is listed by name
|
||||
* with the reason, and anything not listed at all is refused generically
|
||||
* rather than guessed at.
|
||||
*
|
||||
* The command names are `keyof RpcSchema`, the same generated wire schema the
|
||||
* desktop's router is built from, so a command renamed or added in Rust shows
|
||||
* up here as a type error rather than as a runtime surprise.
|
||||
*/
|
||||
|
||||
import type { RpcSchema } from "@yaakapp-internal/rpc-schema";
|
||||
import type { CapabilityName, RpcPayload } from "../types";
|
||||
import type { WorkerConnection } from "./connection";
|
||||
import { unsupported } from "./errors";
|
||||
import { sendHttpRequest } from "./send";
|
||||
|
||||
export type AppCmd = keyof RpcSchema;
|
||||
|
||||
type Handler = (payload: RpcPayload, db: WorkerConnection) => Promise<unknown>;
|
||||
|
||||
/** Placeholder shown wherever the desktop would show a real filesystem path. */
|
||||
const NO_PATH = "";
|
||||
|
||||
function str(payload: RpcPayload, key: string): string | null {
|
||||
const value = payload[key];
|
||||
return typeof value === "string" && value !== "" ? value : null;
|
||||
}
|
||||
|
||||
/** Like `str`, but for fields where an empty string is a legitimate value. */
|
||||
function text(payload: RpcPayload, key: string): string {
|
||||
const value = payload[key];
|
||||
return typeof value === "string" ? value : "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Commands this host answers itself.
|
||||
*
|
||||
* Anything here either reads and writes the browser's own database, or is a
|
||||
* fixed answer that is true of this host — not a stub standing in for something
|
||||
* that should work.
|
||||
*/
|
||||
const HANDLERS: Partial<Record<AppCmd, Handler>> = {
|
||||
/* ------------------------------- models -------------------------------- */
|
||||
|
||||
// Answered by the model layer itself, in the worker. The payload goes over
|
||||
// untouched and the answer comes back untouched: this file has no opinion
|
||||
// about models, and it would be wrong for it to grow one.
|
||||
models_workspace_models: (payload, db) => db.rpc("models_workspace_models", payload),
|
||||
models_upsert: (payload, db) => db.rpc("models_upsert", payload),
|
||||
models_delete: (payload, db) => db.rpc("models_delete", payload),
|
||||
models_duplicate: (payload, db) => db.rpc("models_duplicate", payload),
|
||||
models_get_settings: (payload, db) => db.rpc("models_get_settings", payload),
|
||||
models_get_graphql_introspection: (payload, db) =>
|
||||
db.rpc("models_get_graphql_introspection", payload),
|
||||
models_upsert_graphql_introspection: (payload, db) =>
|
||||
db.rpc("models_upsert_graphql_introspection", payload),
|
||||
models_grpc_events: (payload, db) => db.rpc("models_grpc_events", payload),
|
||||
models_websocket_events: (payload, db) => db.rpc("models_websocket_events", payload),
|
||||
cmd_get_workspace_meta: (payload, db) => db.rpc("cmd_get_workspace_meta", payload),
|
||||
cmd_delete_all_http_responses: (payload, db) => db.rpc("cmd_delete_all_http_responses", payload),
|
||||
cmd_delete_send_history: (payload, db) => db.rpc("cmd_delete_send_history", payload),
|
||||
|
||||
/* ------------------------------- sending ------------------------------- */
|
||||
|
||||
// The tab renders and stores; a stateless server puts the bytes on the wire.
|
||||
// See send.ts for the whole shape of it.
|
||||
cmd_send_http_request: (payload, db) => {
|
||||
const requestId = str(payload, "requestId");
|
||||
if (requestId == null) throw new Error("cmd_send_http_request needs a requestId");
|
||||
return sendHttpRequest(
|
||||
db,
|
||||
requestId,
|
||||
str(payload, "environmentId"),
|
||||
str(payload, "cookieJarId"),
|
||||
);
|
||||
},
|
||||
|
||||
/* -------------------------------- app ---------------------------------- */
|
||||
|
||||
async cmd_metadata() {
|
||||
return {
|
||||
isDev: true,
|
||||
version: "0.0.0-web",
|
||||
cliVersion: null,
|
||||
name: "Yaak",
|
||||
// The desktop hands out real directories here and the UI offers to open
|
||||
// them. There is no filesystem behind this host, and the capability flags
|
||||
// are what the UI should be gating those affordances on.
|
||||
appDataDir: NO_PATH,
|
||||
appLogDir: NO_PATH,
|
||||
vendoredPluginDir: NO_PATH,
|
||||
defaultProjectDir: NO_PATH,
|
||||
featureUpdater: false,
|
||||
featureLicense: false,
|
||||
};
|
||||
},
|
||||
|
||||
// The theme package ships its own defaults, so an empty list is a complete
|
||||
// answer rather than a degraded one — themes beyond those come from plugins.
|
||||
async cmd_get_themes() {
|
||||
return [];
|
||||
},
|
||||
|
||||
async cmd_default_headers() {
|
||||
// Mirrors `default_headers()` in crates/yaak-models/src/queries/workspaces.rs
|
||||
return [
|
||||
{ enabled: true, name: "User-Agent", value: "yaak", id: null },
|
||||
{ enabled: true, name: "Accept", value: "*/*", id: null },
|
||||
];
|
||||
},
|
||||
|
||||
async cmd_plugin_init_errors() {
|
||||
return [];
|
||||
},
|
||||
|
||||
async cmd_check_for_updates() {
|
||||
return false;
|
||||
},
|
||||
|
||||
async cmd_dismiss_notification() {
|
||||
return null;
|
||||
},
|
||||
|
||||
// Plugin-contributed menus. Empty is honest: no plugin runtime, no actions.
|
||||
async cmd_http_request_actions() {
|
||||
return [];
|
||||
},
|
||||
async cmd_websocket_request_actions() {
|
||||
return [];
|
||||
},
|
||||
async cmd_grpc_request_actions() {
|
||||
return [];
|
||||
},
|
||||
async cmd_workspace_actions() {
|
||||
return [];
|
||||
},
|
||||
async cmd_folder_actions() {
|
||||
return [];
|
||||
},
|
||||
|
||||
/**
|
||||
* Both of these are polled once a second until they answer with something, so
|
||||
* an empty list is not a quiet no — it is a poll that never stops.
|
||||
*
|
||||
* The auth list names what Yaak actually offers, so the picker tells the
|
||||
* truth about the product even though the form behind each entry stays empty
|
||||
* until plugins run here. Template functions get the opposite treatment: one
|
||||
* provider contributing no functions. That settles the poll while putting
|
||||
* nothing in the autocomplete, which is the honest answer — a function the
|
||||
* user could insert but nothing could evaluate would be worse than none.
|
||||
*/
|
||||
async cmd_get_http_authentication_summaries() {
|
||||
return HTTP_AUTHENTICATION_SUMMARIES;
|
||||
},
|
||||
async cmd_template_function_summaries() {
|
||||
return [{ pluginRefId: "web", functions: [] }];
|
||||
},
|
||||
|
||||
async cmd_get_http_authentication_config() {
|
||||
return { args: [], pluginRefId: "web" };
|
||||
},
|
||||
|
||||
async cmd_format_json(payload) {
|
||||
const source = text(payload, "text");
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(source), null, 2);
|
||||
} catch {
|
||||
// Formatting invalid JSON is a no-op, not an error: the editor calls this
|
||||
// while the user is still typing.
|
||||
return source;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Rendering resolves variables and calls template functions, and the
|
||||
* functions live in plugins. Handing the template back unrendered is what the
|
||||
* preview then shows — the raw `${[...]}`, which is at least the thing the
|
||||
* user typed rather than a wrong value.
|
||||
*/
|
||||
async cmd_render_template(payload) {
|
||||
return text(payload, "template");
|
||||
},
|
||||
|
||||
/* ------------------------------- bodies -------------------------------- */
|
||||
|
||||
async cmd_http_response_body(payload, db) {
|
||||
const responseId = str(payload, "responseId");
|
||||
if (responseId == null) return { content: "" };
|
||||
|
||||
if (str(payload, "filter") != null) {
|
||||
return {
|
||||
content: "",
|
||||
error: "Response filters come from a plugin, which this host doesn't run yet",
|
||||
};
|
||||
}
|
||||
|
||||
const bytes = await db.blobGet(responseId);
|
||||
return { content: bytes == null ? "" : new TextDecoder().decode(bytes) };
|
||||
},
|
||||
|
||||
// Bodies live in this database, not on a disk, so there is no path to give.
|
||||
async cmd_http_response_body_path() {
|
||||
return null;
|
||||
},
|
||||
|
||||
async cmd_http_request_body(payload, db) {
|
||||
const responseId = str(payload, "responseId");
|
||||
if (responseId == null) return null;
|
||||
// Keyed the way the desktop keys it: the request bytes belong to the
|
||||
// response that recorded them.
|
||||
const bytes = await db.blobGet(`${responseId}.request`);
|
||||
return bytes == null ? null : Array.from(bytes);
|
||||
},
|
||||
|
||||
// The rows the sender wrote for that response, same table as the desktop.
|
||||
cmd_get_http_response_events: (payload, db) => db.rpc("cmd_get_http_response_events", payload),
|
||||
|
||||
/* ------------------------------- export -------------------------------- */
|
||||
|
||||
// Built by the model layer, exactly as the desktop and the CLI build it. Where
|
||||
// the document goes is not decided here: the caller hands it to `files.save`,
|
||||
// which is the one thing each host answers differently.
|
||||
cmd_export_data: (payload, db) => db.rpc("cmd_export_data", payload),
|
||||
|
||||
// The same example the desktop creates, built by the model layer in the worker.
|
||||
cmd_create_example_workspace: (payload, db) => db.rpc("cmd_create_example_workspace", payload),
|
||||
|
||||
async cmd_get_sse_events() {
|
||||
return [];
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* The auth methods Yaak ships as plugins today (plugins/auth-*). Listed so the
|
||||
* picker is truthful about the product; choosing one currently yields an empty
|
||||
* config form, because the plugin that defines the form isn't running.
|
||||
*/
|
||||
const HTTP_AUTHENTICATION_SUMMARIES = [
|
||||
{ name: "apikey", label: "API Key", shortLabel: "API Key" },
|
||||
{ name: "aws", label: "AWS SigV4", shortLabel: "AWS" },
|
||||
{ name: "basic", label: "Basic Auth", shortLabel: "Basic" },
|
||||
{ name: "bearer", label: "Bearer Token", shortLabel: "Bearer" },
|
||||
{ name: "digest", label: "Digest Auth", shortLabel: "Digest" },
|
||||
{ name: "jwt", label: "JWT Bearer", shortLabel: "JWT" },
|
||||
{ name: "ntlm", label: "NTLM", shortLabel: "NTLM" },
|
||||
{ name: "oauth1", label: "OAuth 1.0", shortLabel: "OAuth 1" },
|
||||
{ name: "oauth2", label: "OAuth 2.0", shortLabel: "OAuth 2" },
|
||||
];
|
||||
|
||||
/**
|
||||
* Commands this host declines, each with the reason a user would need.
|
||||
*
|
||||
* Naming them individually rather than letting them fall through to a generic
|
||||
* refusal is deliberate: "sending is not available yet" and "Yaak in a browser
|
||||
* has no filesystem" are different situations, and the second is permanent
|
||||
* while the first is a slice away.
|
||||
*/
|
||||
const DECLINED: Partial<Record<AppCmd, [reason: string, capability: CapabilityName | null]>> = {
|
||||
// Saved requests send through the server (see send.ts). Ephemeral sends — the
|
||||
// ones nothing stores, used for GraphQL introspection — take the same road but
|
||||
// return the body inline; not wired yet.
|
||||
cmd_send_ephemeral_request: ["Sending unsaved requests isn't available in the browser yet", null],
|
||||
cmd_curl_to_request: ["Importing from cURL needs a plugin, which this host doesn't run", null],
|
||||
|
||||
// Protocols that need a real socket.
|
||||
cmd_grpc_reflect: ["gRPC isn't available in the browser", "grpc"],
|
||||
cmd_grpc_go: ["gRPC isn't available in the browser", "grpc"],
|
||||
cmd_delete_all_grpc_connections: ["gRPC isn't available in the browser", "grpc"],
|
||||
cmd_ws_connect: ["WebSocket requests aren't available in the browser yet", "websocket"],
|
||||
cmd_ws_send: ["WebSocket requests aren't available in the browser yet", "websocket"],
|
||||
cmd_ws_close: ["WebSocket requests aren't available in the browser yet", "websocket"],
|
||||
cmd_ws_delete_connections: [
|
||||
"WebSocket requests aren't available in the browser yet",
|
||||
"websocket",
|
||||
],
|
||||
|
||||
// Anything that needs files the page can't reach.
|
||||
cmd_import_data: [
|
||||
"Importing from a file needs a filesystem, which a browser tab has no",
|
||||
"localFiles",
|
||||
],
|
||||
cmd_import_url: ["Importing from a URL needs the Yaak server, which isn't available yet", null],
|
||||
cmd_commit_import: ["Importing needs a plugin, which this host doesn't run", null],
|
||||
cmd_list_import_sources: ["Importing isn't available in the browser yet", null],
|
||||
cmd_import_sources_for_origin: ["Importing isn't available in the browser yet", null],
|
||||
cmd_save_response: ["Saving a response to disk isn't available in the browser", "localFiles"],
|
||||
cmd_save_base64_to_binary: ["Saving to disk isn't available in the browser", "localFiles"],
|
||||
cmd_format_graphql: ["Formatting GraphQL needs a plugin, which this host doesn't run", null],
|
||||
|
||||
// Windows. A tab is the window, and there is only ever one of it.
|
||||
cmd_new_child_window: ["Yaak in a browser uses one tab", "multiWindow"],
|
||||
cmd_new_main_window: ["Yaak in a browser uses one tab", "multiWindow"],
|
||||
cmd_restart: ["Reload the page to restart Yaak", null],
|
||||
|
||||
// Workspace encryption is backed by a key the host keeps for you; a page has
|
||||
// nowhere to keep one that a page couldn't also read.
|
||||
cmd_enable_encryption: ["Workspace encryption isn't available in the browser", "encryption"],
|
||||
cmd_disable_encryption: ["Workspace encryption isn't available in the browser", "encryption"],
|
||||
cmd_reveal_workspace_key: ["Workspace encryption isn't available in the browser", "encryption"],
|
||||
cmd_set_workspace_key: ["Workspace encryption isn't available in the browser", "encryption"],
|
||||
cmd_secure_template: ["Workspace encryption isn't available in the browser", "encryption"],
|
||||
cmd_decrypt_template: ["Workspace encryption isn't available in the browser", "encryption"],
|
||||
|
||||
// The plugin runtime is a Node process. Nothing here runs one.
|
||||
cmd_reload_plugins: ["Plugins aren't available in the browser yet", "plugins"],
|
||||
cmd_plugin_info: ["Plugins aren't available in the browser yet", "plugins"],
|
||||
cmd_plugins_search: ["Plugins aren't available in the browser yet", "plugins"],
|
||||
cmd_plugins_install: ["Plugins aren't available in the browser yet", "plugins"],
|
||||
cmd_plugins_install_from_directory: ["Plugins aren't available in the browser yet", "plugins"],
|
||||
cmd_plugins_uninstall: ["Plugins aren't available in the browser yet", "plugins"],
|
||||
cmd_plugins_updates: ["Plugins aren't available in the browser yet", "plugins"],
|
||||
cmd_plugins_update_all: ["Plugins aren't available in the browser yet", "plugins"],
|
||||
cmd_template_function_config: [
|
||||
"Template functions come from plugins, which this host doesn't run",
|
||||
"plugins",
|
||||
],
|
||||
cmd_template_tokens_to_string: [
|
||||
"Template functions come from plugins, which this host doesn't run",
|
||||
"plugins",
|
||||
],
|
||||
cmd_call_http_request_action: ["Plugins aren't available in the browser yet", "plugins"],
|
||||
cmd_call_websocket_request_action: ["Plugins aren't available in the browser yet", "plugins"],
|
||||
cmd_call_grpc_request_action: ["Plugins aren't available in the browser yet", "plugins"],
|
||||
cmd_call_workspace_action: ["Plugins aren't available in the browser yet", "plugins"],
|
||||
cmd_call_folder_action: ["Plugins aren't available in the browser yet", "plugins"],
|
||||
cmd_call_http_authentication_action: ["Plugins aren't available in the browser yet", "plugins"],
|
||||
|
||||
cmd_send_feedback: ["Feedback goes through the desktop app for now", null],
|
||||
};
|
||||
|
||||
/**
|
||||
* The support table, for documentation and for the console.
|
||||
*
|
||||
* Derived from the two maps above rather than written alongside them, so it
|
||||
* cannot drift from what the host actually does.
|
||||
*/
|
||||
export function commandSupport(): {
|
||||
implemented: string[];
|
||||
declined: { cmd: string; reason: string; capability: CapabilityName | null }[];
|
||||
} {
|
||||
return {
|
||||
implemented: Object.keys(HANDLERS).sort(),
|
||||
declined: Object.entries(DECLINED)
|
||||
.map(([cmd, [reason, capability]]) => ({ cmd, reason, capability }))
|
||||
.sort((a, b) => a.cmd.localeCompare(b.cmd)),
|
||||
};
|
||||
}
|
||||
|
||||
export async function runCommand(
|
||||
cmd: string,
|
||||
payload: RpcPayload,
|
||||
db: WorkerConnection,
|
||||
): Promise<unknown> {
|
||||
const handler = HANDLERS[cmd as AppCmd];
|
||||
if (handler != null) return handler(payload, db);
|
||||
|
||||
const declined = DECLINED[cmd as AppCmd];
|
||||
if (declined != null) throw unsupported(cmd, declined[0], declined[1]);
|
||||
|
||||
// Git and sync land here, along with anything added to the schema since. The
|
||||
// message names the command because an unlisted one is a gap in this file,
|
||||
// and whoever hits it should be able to see which.
|
||||
throw unsupported(cmd, `\`${cmd}\` isn't available when Yaak runs in a browser`);
|
||||
}
|
||||
236
packages/platform/src/web/connection.ts
Normal file
236
packages/platform/src/web/connection.ts
Normal file
@@ -0,0 +1,236 @@
|
||||
/**
|
||||
* A tab's end of the wire to the worker that owns the database.
|
||||
*
|
||||
* Constructible synchronously and usable immediately, which is the hard
|
||||
* requirement: boot-time modules call commands while the module graph is still
|
||||
* evaluating, so there is no later moment to connect in. Messages posted before
|
||||
* the worker has opened the database sit in the port until it has, and the
|
||||
* app's own top-level await then doubles as the boot gate — nothing renders
|
||||
* until the first command has answered, and it can only answer once the
|
||||
* database is open.
|
||||
*/
|
||||
|
||||
import type { Unsubscribe } from "../types";
|
||||
import { randomId } from "./ids";
|
||||
import { type FromWorker, type ToWorker, WORKER_NAME } from "./protocol";
|
||||
|
||||
/**
|
||||
* How long a freshly connected worker gets to say hello.
|
||||
*
|
||||
* A live worker answers in the same turn it is connected — the worker script
|
||||
* is tiny and imports the model layer lazily, so this measures liveness, not
|
||||
* load time. It only has to be longer than a cold fetch of that small script;
|
||||
* a worker silent past this is not coming, and a message beats a blank page.
|
||||
*/
|
||||
const HELLO_TIMEOUT_MS = 3000;
|
||||
|
||||
const WORKER_FAILED = "Yaak's database worker could not be started. Reload the page to try again";
|
||||
|
||||
const UNSUPPORTED =
|
||||
"This browser can't run Yaak: it needs shared workers and Web Locks to keep your data safe across tabs. Every current browser has both.";
|
||||
|
||||
type Pending = { resolve: (value: unknown) => void; reject: (reason: Error) => void };
|
||||
|
||||
export class WorkerConnection {
|
||||
private port: MessagePort | null;
|
||||
private readonly pending = new Map<number, Pending>();
|
||||
private readonly listeners = new Map<string, Set<(payload: unknown) => void>>();
|
||||
private nextId = 1;
|
||||
private bootError: string | null = null;
|
||||
|
||||
/**
|
||||
* This tab's identity, standing in for the desktop's window label. Stamped
|
||||
* on every write this tab makes, so the store can tell an echo of its own
|
||||
* write from another tab's. Minted per page load, not kept in
|
||||
* `sessionStorage`, on purpose: duplicating a tab copies session storage,
|
||||
* and two tabs claiming one identity would each drop the other's writes as
|
||||
* echoes.
|
||||
*/
|
||||
readonly label = `tab_${randomId(4)}`;
|
||||
|
||||
/** True once the worker has said anything at all. */
|
||||
private heard = false;
|
||||
|
||||
constructor() {
|
||||
// Both are required and neither is faked. Without a shared worker every
|
||||
// tab would need its own SQLite over the same pages; without Web Locks
|
||||
// nothing can promise there is only one even so. Every current browser,
|
||||
// desktop and mobile, has both (Chrome for Android since 148); the ones
|
||||
// that don't get told, not corrupted.
|
||||
if (typeof SharedWorker === "undefined" || typeof navigator.locks === "undefined") {
|
||||
this.port = null;
|
||||
this.bootError = UNSUPPORTED;
|
||||
showBootError(UNSUPPORTED);
|
||||
return;
|
||||
}
|
||||
|
||||
this.port = this.connect();
|
||||
|
||||
// Let the worker forget this port. Not load-bearing — a port that never
|
||||
// says goodbye is a leaked entry in a Set — but tidy.
|
||||
window.addEventListener("pagehide", () => this.post({ type: "goodbye" }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to the origin's one database worker.
|
||||
*
|
||||
* The browser hands every tab the same SharedWorker for this name and URL,
|
||||
* which is what makes "one database owner" true without anyone coordinating.
|
||||
*
|
||||
* `new URL("./worker.ts", import.meta.url)` is written out inline on purpose:
|
||||
* that exact syntax is what the bundler pattern-matches to know it must
|
||||
* bundle a worker entry. Hoisted into a variable it becomes an asset URL and
|
||||
* ships as raw TypeScript.
|
||||
*/
|
||||
private connect(): MessagePort {
|
||||
const worker = new SharedWorker(new URL("./worker.ts", import.meta.url), {
|
||||
type: "module",
|
||||
name: WORKER_NAME,
|
||||
});
|
||||
// A worker whose script fails to load fires `error` on the SharedWorker
|
||||
// object and nothing else — the port just goes quiet.
|
||||
worker.onerror = () => {
|
||||
if (!this.heard) this.failEverything(`${WORKER_FAILED} (its script failed to load).`);
|
||||
};
|
||||
worker.port.onmessage = (e: MessageEvent<FromWorker>) => this.receive(e.data);
|
||||
worker.port.start();
|
||||
|
||||
// The worker says hello synchronously on connect. Silence past the timeout
|
||||
// means this port is attached to nothing that will ever answer, and the
|
||||
// user should see that rather than a blank page. It is not retried: the
|
||||
// one way this used to happen (a module worker missing connects during a
|
||||
// top-level-await import) is fixed at the source by importing the wasm
|
||||
// lazily, and a reload is the right remedy for anything else.
|
||||
setTimeout(() => {
|
||||
if (!this.heard) this.failEverything(`${WORKER_FAILED} (it never answered).`);
|
||||
}, HELLO_TIMEOUT_MS);
|
||||
|
||||
return worker.port;
|
||||
}
|
||||
|
||||
private failEverything(message: string): void {
|
||||
this.bootError = message;
|
||||
showBootError(message);
|
||||
for (const [id, p] of this.pending) {
|
||||
this.pending.delete(id);
|
||||
p.reject(new Error(message));
|
||||
}
|
||||
}
|
||||
|
||||
private post(message: ToWorker, transfer: Transferable[] = []): void {
|
||||
this.port?.postMessage(message, transfer);
|
||||
}
|
||||
|
||||
private receive(message: FromWorker): void {
|
||||
this.heard = true;
|
||||
switch (message.type) {
|
||||
case "hello":
|
||||
case "ready":
|
||||
return;
|
||||
case "boot_error":
|
||||
// Nothing will ever answer, and the app cannot render without an
|
||||
// answer, so say what happened where the user can see it. This is the
|
||||
// page's whole content at this point.
|
||||
this.failEverything(message.message);
|
||||
return;
|
||||
case "result": {
|
||||
const p = this.pending.get(message.id);
|
||||
this.pending.delete(message.id);
|
||||
p?.resolve(message.result);
|
||||
return;
|
||||
}
|
||||
case "error": {
|
||||
const p = this.pending.get(message.id);
|
||||
this.pending.delete(message.id);
|
||||
p?.reject(new Error(message.message));
|
||||
return;
|
||||
}
|
||||
case "event":
|
||||
this.deliver(message.event, message.payload);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private request<T>(build: (id: number) => ToWorker, transfer: Transferable[] = []): Promise<T> {
|
||||
if (this.bootError != null) return Promise.reject(new Error(this.bootError));
|
||||
const id = this.nextId++;
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
this.pending.set(id, { resolve: resolve as (v: unknown) => void, reject });
|
||||
this.post(build(id), transfer);
|
||||
});
|
||||
}
|
||||
|
||||
rpc<T>(cmd: string, payload: unknown): Promise<T> {
|
||||
return this.request<T>((id) => ({ type: "rpc", id, cmd, payload, label: this.label }));
|
||||
}
|
||||
|
||||
/** See `prepare_http_send` in crates/yaak-wasm: the database half of a send. */
|
||||
prepareHttpSend<T>(payload: unknown): Promise<T> {
|
||||
return this.request<T>((id) => ({ type: "prepare_http_send", id, payload }));
|
||||
}
|
||||
|
||||
async blobGet(blobId: string): Promise<Uint8Array<ArrayBuffer> | null> {
|
||||
const buf = await this.request<ArrayBuffer | null>((id) => ({ type: "blob_get", id, blobId }));
|
||||
return buf == null ? null : new Uint8Array(buf);
|
||||
}
|
||||
|
||||
blobPut(blobId: string, bytes: Uint8Array): Promise<void> {
|
||||
// Copied so the caller's buffer isn't detached out from under it, then
|
||||
// transferred so the copy isn't copied again crossing to the worker.
|
||||
const copy = new Uint8Array(bytes.byteLength);
|
||||
copy.set(bytes);
|
||||
return this.request<void>(
|
||||
(id) => ({ type: "blob_put", id, blobId, bytes: copy.buffer }),
|
||||
[copy.buffer],
|
||||
);
|
||||
}
|
||||
|
||||
blobDelete(blobId: string): Promise<void> {
|
||||
return this.request<void>((id) => ({ type: "blob_delete", id, blobId }));
|
||||
}
|
||||
|
||||
/* ------------------------------- events -------------------------------- */
|
||||
|
||||
listen(event: string, callback: (payload: unknown) => void): Unsubscribe {
|
||||
let set = this.listeners.get(event);
|
||||
if (set == null) {
|
||||
set = new Set();
|
||||
this.listeners.set(event, set);
|
||||
}
|
||||
set.add(callback);
|
||||
return () => {
|
||||
set.delete(callback);
|
||||
if (set.size === 0) this.listeners.delete(event);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Deliver an event to this tab's listeners.
|
||||
*
|
||||
* Used for what the worker pushes, and for the app's own local emits (a
|
||||
* plugin round trip, a stream teardown). Local emits stay local: every
|
||||
* emitter in the app is replying to something *this* tab is doing.
|
||||
*/
|
||||
deliver(event: string, payload: unknown): void {
|
||||
const set = this.listeners.get(event);
|
||||
if (set == null) return;
|
||||
// Copied because a listener may unsubscribe itself while being called
|
||||
for (const callback of Array.from(set)) {
|
||||
try {
|
||||
callback(payload);
|
||||
} catch (err) {
|
||||
console.error(`Listener for \`${event}\` threw`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function showBootError(message: string): void {
|
||||
const root = document.getElementById("root");
|
||||
if (root == null || root.childElementCount > 0) return;
|
||||
const el = document.createElement("div");
|
||||
el.style.cssText =
|
||||
"font: 15px/1.5 system-ui, sans-serif; max-width: 32rem; margin: 20vh auto; padding: 0 1rem; color: inherit";
|
||||
el.textContent = message;
|
||||
root.appendChild(el);
|
||||
}
|
||||
32
packages/platform/src/web/errors.ts
Normal file
32
packages/platform/src/web/errors.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import type { CapabilityName } from "../types";
|
||||
|
||||
/**
|
||||
* What this host says when it is asked for something it doesn't have.
|
||||
*
|
||||
* A rejected command reaches the user as a toast built from `message`, so the
|
||||
* message is the user-facing text and has to read like one. The structured
|
||||
* fields alongside it are for code: `capability` names the switch a caller
|
||||
* should have checked first, and `cmd` identifies the command without anyone
|
||||
* having to parse prose back out of the message.
|
||||
*/
|
||||
export class UnsupportedCommandError extends Error {
|
||||
readonly name = "UnsupportedCommandError";
|
||||
/** Stable discriminator, so a caller can branch without matching on text. */
|
||||
readonly code = "unsupported_command";
|
||||
readonly cmd: string;
|
||||
readonly capability: CapabilityName | null;
|
||||
|
||||
constructor(cmd: string, message: string, capability: CapabilityName | null = null) {
|
||||
super(message);
|
||||
this.cmd = cmd;
|
||||
this.capability = capability;
|
||||
}
|
||||
}
|
||||
|
||||
export function unsupported(
|
||||
cmd: string,
|
||||
reason: string,
|
||||
capability: CapabilityName | null = null,
|
||||
): UnsupportedCommandError {
|
||||
return new UnsupportedCommandError(cmd, reason, capability);
|
||||
}
|
||||
14
packages/platform/src/web/ids.ts
Normal file
14
packages/platform/src/web/ids.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Random ids that do not need a secure context.
|
||||
*
|
||||
* `crypto.randomUUID` exists only on HTTPS and localhost, so a tab opened at
|
||||
* `http://some-host:1424` — a self-hosted instance reached from another machine —
|
||||
* throws on it before anything renders. `crypto.getRandomValues` carries no such
|
||||
* restriction, and nothing here needs UUID semantics: these ids identify a tab and
|
||||
* a stream within one page, and only have to not collide with each other.
|
||||
*/
|
||||
export function randomId(bytes = 8): string {
|
||||
const buf = new Uint8Array(bytes);
|
||||
crypto.getRandomValues(buf);
|
||||
return Array.from(buf, (b) => b.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
383
packages/platform/src/web/index.ts
Normal file
383
packages/platform/src/web/index.ts
Normal file
@@ -0,0 +1,383 @@
|
||||
/**
|
||||
* The browser host: Yaak in a tab, with no install and nothing running locally.
|
||||
*
|
||||
* The desktop host forwards to a Rust process. This one forwards to a worker
|
||||
* running the same model layer compiled to wasm, over a `MessagePort` instead
|
||||
* of Tauri's IPC. The worker owns the database and is shared by every tab on
|
||||
* the origin, so two tabs stay coherent for the same reason two desktop windows
|
||||
* do: one process holds the data and pushes every write to all of them.
|
||||
*
|
||||
* Sending goes through a small stateless server, because a page cannot see a
|
||||
* response the way a desktop app can (see send.ts). What a page genuinely
|
||||
* cannot do is not faked: there is no file dialog, no second window, no
|
||||
* clipboard read without a prompt. Those report false through `capabilities`
|
||||
* and refuse with a reason if called anyway, so a missing feature shows up as a
|
||||
* disabled control or a toast that explains itself, never as a silent no-op.
|
||||
*/
|
||||
|
||||
import type {
|
||||
DialogFilter,
|
||||
DragDropEvent,
|
||||
OsType,
|
||||
Platform,
|
||||
PlatformCapabilities,
|
||||
PlatformWindow,
|
||||
RpcPayload,
|
||||
RpcStreamHandle,
|
||||
SaveContent,
|
||||
Unsubscribe,
|
||||
} from "../types";
|
||||
import { commandSupport, runCommand } from "./commands";
|
||||
import { WorkerConnection } from "./connection";
|
||||
import { unsupported } from "./errors";
|
||||
import { randomId } from "./ids";
|
||||
import { serverBaseUrl } from "./server";
|
||||
import { requestPersistence } from "./storage";
|
||||
|
||||
/** What this host can do, reported honestly. */
|
||||
function capabilitiesFor(): PlatformCapabilities {
|
||||
return {
|
||||
// Through the Yaak server: the tab renders, the server executes, the tab
|
||||
// stores. Requests needing plugin auth or template functions are refused
|
||||
// with the reason until plugins run here.
|
||||
httpSending: true,
|
||||
grpc: false,
|
||||
websocket: false,
|
||||
git: false,
|
||||
sync: false,
|
||||
// Certificates and proxies are decided by whoever puts the bytes on the
|
||||
// wire, and the Yaak server uses its own.
|
||||
tlsOptions: false,
|
||||
cookieJar: true,
|
||||
localFiles: false,
|
||||
// The server streams the engine's events back and the sender stores them.
|
||||
timeline: true,
|
||||
// Whether the host can put a *second window* on this data on demand — what
|
||||
// `cmd_new_child_window` does for Settings and workspace switching. A tab
|
||||
// can't, so those open in place instead. This is not a claim that nothing
|
||||
// else is looking: other tabs may well be open on the same worker, and it
|
||||
// pushes every write to all of them regardless of this flag.
|
||||
multiWindow: false,
|
||||
// The browser draws the frame around the page. There are no traffic lights
|
||||
// to leave room for and no window controls to draw.
|
||||
windowChrome: false,
|
||||
// The browser already zooms the page, on the same keys, and remembers it
|
||||
// per site. The app stays out of the way.
|
||||
interfaceZoom: false,
|
||||
plugins: false,
|
||||
encryption: false,
|
||||
updater: false,
|
||||
// Reading needs a permission prompt at first paint, which is a bad ask for
|
||||
// an app people paste bearer tokens into. Pasting still works everywhere.
|
||||
clipboardRead: false,
|
||||
systemFonts: false,
|
||||
license: false,
|
||||
};
|
||||
}
|
||||
|
||||
/** Match `@tauri-apps/plugin-os` spellings so layout code needs no new branch. */
|
||||
function detectOsType(): OsType {
|
||||
const ua = navigator.userAgent;
|
||||
if (/Mac|iPhone|iPad|iPod/.test(ua)) return "macos";
|
||||
if (/Win/.test(ua)) return "windows";
|
||||
if (/Android/.test(ua)) return "android";
|
||||
return "linux";
|
||||
}
|
||||
|
||||
/**
|
||||
* The Tauri host-plugin commands, which ride outside the RPC envelope.
|
||||
*
|
||||
* `set_title` has a real browser equivalent. `set_theme` paints the native
|
||||
* window frame behind the webview, which a tab has neither of. The license and
|
||||
* font plugins answer with "nothing", which is true and keeps the settings
|
||||
* screens rendering instead of erroring.
|
||||
*/
|
||||
async function hostPluginCommand<T>(cmd: string, payload?: RpcPayload): Promise<T> {
|
||||
switch (cmd) {
|
||||
case "plugin:yaak-mac-window|set_title": {
|
||||
const title = payload?.title;
|
||||
document.title = typeof title === "string" ? title : "Yaak";
|
||||
return undefined as T;
|
||||
}
|
||||
case "plugin:yaak-mac-window|set_theme":
|
||||
return undefined as T;
|
||||
case "plugin:yaak-fonts|list":
|
||||
// Enumerating installed fonts is a fingerprinting surface browsers don't
|
||||
// offer. The pickers fall back to their bundled families.
|
||||
return { editorFonts: [], uiFonts: [] } as T;
|
||||
default:
|
||||
throw unsupported(cmd, `\`${cmd}\` isn't available when Yaak runs in a browser`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The File System Access API, where the browser has it. Chromium does; Firefox
|
||||
* and Safari do not, and fall through to a download below.
|
||||
*/
|
||||
interface SaveFilePicker {
|
||||
(options: {
|
||||
suggestedName?: string;
|
||||
types?: { description: string; accept: Record<string, string[]> }[];
|
||||
}): Promise<{
|
||||
name: string;
|
||||
createWritable(): Promise<{ write(data: Uint8Array): Promise<void>; close(): Promise<void> }>;
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Saving, the only way a page can: a real save dialog where the browser offers
|
||||
* one, and a download everywhere else.
|
||||
*
|
||||
* The return value is a name rather than a path because a name is all a tab
|
||||
* ever learns. Callers show it back to the user and nothing more, which is why
|
||||
* the interface promises "where they went" rather than a path.
|
||||
*/
|
||||
function toBytes(content: SaveContent): Uint8Array {
|
||||
if (content instanceof Uint8Array) return content;
|
||||
const binary = atob(content.base64);
|
||||
const out = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i);
|
||||
return out;
|
||||
}
|
||||
|
||||
async function saveBytes(
|
||||
suggestedName: string,
|
||||
content: SaveContent,
|
||||
filters?: DialogFilter[],
|
||||
): Promise<string | null> {
|
||||
const picker = (window as unknown as { showSaveFilePicker?: SaveFilePicker }).showSaveFilePicker;
|
||||
const bytes = toBytes(content);
|
||||
|
||||
if (picker != null) {
|
||||
let handle;
|
||||
try {
|
||||
handle = await picker({
|
||||
suggestedName,
|
||||
types: filters?.map((f) => ({
|
||||
description: f.name,
|
||||
// The API keys accepted extensions by MIME type. Nothing here knows
|
||||
// the real one, and it only labels the dialog's filter, so the
|
||||
// catch-all is honest enough.
|
||||
accept: { "application/octet-stream": f.extensions.map((e) => `.${e}`) },
|
||||
})),
|
||||
});
|
||||
} catch (err) {
|
||||
// Only choosing a file is allowed to fall back. Backing out is not a
|
||||
// failure and must not become a download nobody asked for; anything else
|
||||
// here is a browser that won't show the dialog, which a download covers.
|
||||
if ((err as { name?: string } | null)?.name === "AbortError") return null;
|
||||
console.warn("Save dialog unavailable, falling back to a download", err);
|
||||
}
|
||||
|
||||
// Past the dialog, the user has named a destination. A write that fails
|
||||
// there is a failed save, and saying so beats quietly putting the file
|
||||
// somewhere else and reporting success.
|
||||
if (handle != null) {
|
||||
const writable = await handle.createWritable();
|
||||
await writable.write(bytes);
|
||||
await writable.close();
|
||||
return handle.name;
|
||||
}
|
||||
}
|
||||
|
||||
// No dialog: the file lands wherever the browser puts downloads, under the
|
||||
// name we suggested. The user is not asked, so there is nothing to cancel.
|
||||
const url = URL.createObjectURL(new Blob([bytes as BlobPart]));
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = suggestedName;
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
anchor.remove();
|
||||
// Revoked on a later turn: revoking while the browser is still reading the
|
||||
// blob cancels the download in some of them.
|
||||
setTimeout(() => URL.revokeObjectURL(url), 30_000);
|
||||
return suggestedName;
|
||||
}
|
||||
|
||||
function createWindow(db: WorkerConnection): PlatformWindow {
|
||||
const noop = async () => {};
|
||||
|
||||
return {
|
||||
// Stands in for the desktop's window label: the identity model writes carry
|
||||
// so a tab can tell its own echo from another tab's write.
|
||||
label: db.label,
|
||||
|
||||
// A tab manages its own frame. These exist because the interface names
|
||||
// them; the UI only reaches for them behind `multiWindow`.
|
||||
show: noop,
|
||||
close: noop,
|
||||
minimize: noop,
|
||||
maximize: noop,
|
||||
unmaximize: noop,
|
||||
isMaximized: async () => false,
|
||||
isFullscreen: async () => document.fullscreenElement != null,
|
||||
setZoom: noop,
|
||||
|
||||
// Null means "no opinion, let CSS decide". The desktop returns a real value
|
||||
// because applying a theme forces the window appearance and poisons the
|
||||
// media query; nothing does that here, so `prefers-color-scheme` is the
|
||||
// honest answer and the theme package already falls back to it.
|
||||
theme: async () => null,
|
||||
|
||||
onThemeChanged(callback) {
|
||||
const media = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
const listener = () => callback(media.matches ? "dark" : "light");
|
||||
media.addEventListener("change", listener);
|
||||
return () => media.removeEventListener("change", listener);
|
||||
},
|
||||
|
||||
onFocusChanged(callback) {
|
||||
const onFocus = () => callback(true);
|
||||
const onBlur = () => callback(false);
|
||||
window.addEventListener("focus", onFocus);
|
||||
window.addEventListener("blur", onBlur);
|
||||
return () => {
|
||||
window.removeEventListener("focus", onFocus);
|
||||
window.removeEventListener("blur", onBlur);
|
||||
};
|
||||
},
|
||||
|
||||
// Native drag-and-drop reports OS paths, which a page never sees. The DOM's
|
||||
// own drag events are a different thing, and the components that need those
|
||||
// use them directly.
|
||||
onDragDrop(_callback: (event: DragDropEvent) => void): Unsubscribe {
|
||||
return () => {};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createWebPlatform(): Platform {
|
||||
const db = new WorkerConnection();
|
||||
const capabilities = capabilitiesFor();
|
||||
|
||||
// Without this, IndexedDB is best-effort storage and a browser reclaiming
|
||||
// space may drop someone's workspaces. Asking is all we can do, and there is
|
||||
// nothing useful to do about a refusal.
|
||||
void requestPersistence();
|
||||
|
||||
// Enough to answer "what does this host actually do?" from the console
|
||||
// without reading the source.
|
||||
(window as unknown as Record<string, unknown>).__YAAK_WEB__ = {
|
||||
label: db.label,
|
||||
capabilities,
|
||||
commands: commandSupport,
|
||||
};
|
||||
|
||||
return {
|
||||
capabilities,
|
||||
httpProxyUrl: serverBaseUrl() || window.location.origin,
|
||||
window: createWindow(db),
|
||||
|
||||
clipboard: {
|
||||
writeText: (text) => navigator.clipboard.writeText(text),
|
||||
readText: async () => {
|
||||
throw unsupported(
|
||||
"clipboard.readText",
|
||||
"Paste instead — Yaak in a browser can't read the clipboard on its own",
|
||||
"clipboardRead",
|
||||
);
|
||||
},
|
||||
clear: async () => {
|
||||
throw unsupported(
|
||||
"clipboard.clear",
|
||||
"Yaak in a browser can't modify the clipboard",
|
||||
"clipboardRead",
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
// Returning null rather than throwing: null is what a cancelled dialog
|
||||
// returns, which every caller already handles.
|
||||
dialog: {
|
||||
open: (async () => null) as Platform["dialog"]["open"],
|
||||
save: async () => null,
|
||||
},
|
||||
|
||||
files: {
|
||||
readDir: async () => {
|
||||
throw unsupported(
|
||||
"files.readDir",
|
||||
"A browser tab can't browse your filesystem",
|
||||
"localFiles",
|
||||
);
|
||||
},
|
||||
readText: async () => {
|
||||
throw unsupported("files.readText", "A browser tab can't read local files", "localFiles");
|
||||
},
|
||||
// No filesystem here, so a path is just a string this host echoes back.
|
||||
url: (path) => path,
|
||||
basename: async (path) => path.split(/[/\\]/).pop() ?? path,
|
||||
resolveResource: async (path) => path,
|
||||
|
||||
save: saveBytes,
|
||||
},
|
||||
|
||||
/**
|
||||
* Bodies live in the worker's blob database, addressed by the id they were
|
||||
* stored under. A page hands over an id and never a location, which is what
|
||||
* keeps it from naming bytes the app never wrote.
|
||||
*/
|
||||
blobs: {
|
||||
read: (id) => db.blobGet(id),
|
||||
|
||||
async url(id) {
|
||||
const bytes = await db.blobGet(id);
|
||||
// An object URL, the tab's equivalent of Tauri's `convertFileSrc`. The
|
||||
// caller keys a query on it and drops it on the next response, so it is
|
||||
// left to be reclaimed when the document goes rather than revoked here
|
||||
// while an <img> may still be loading it.
|
||||
return bytes == null ? null : URL.createObjectURL(new Blob([bytes]));
|
||||
},
|
||||
},
|
||||
|
||||
rpc<T>(cmd: string, payload?: RpcPayload): Promise<T> {
|
||||
// `plugin:` commands are Tauri host plugins, not engine commands, and
|
||||
// never reached the router even on the desktop.
|
||||
if (cmd.startsWith("plugin:")) return hostPluginCommand<T>(cmd, payload);
|
||||
return runCommand(cmd, payload ?? {}, db) as Promise<T>;
|
||||
},
|
||||
|
||||
async rpcStream<T, M>(
|
||||
cmd: string,
|
||||
payload: RpcPayload,
|
||||
onMessage: (message: M) => void,
|
||||
): Promise<RpcStreamHandle<T>> {
|
||||
// Same shape as the desktop — subscribe first, then dispatch — so that a
|
||||
// command which grows the ability to stream here needs no caller changes.
|
||||
const streamId = randomId();
|
||||
const unlisten = db.listen(`stream_${streamId}`, (p) => onMessage(p as M));
|
||||
try {
|
||||
const result = (await runCommand(cmd, { ...payload, streamId }, db)) as T;
|
||||
return { result, unlisten };
|
||||
} catch (err) {
|
||||
unlisten();
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
listen<T>(event: string, callback: (payload: T) => void): Unsubscribe {
|
||||
return db.listen(event, (payload) => callback(payload as T));
|
||||
},
|
||||
|
||||
// Local only. Every emitter in the app is replying to something this tab is
|
||||
// doing — a plugin round trip, a stream teardown — and telling other tabs
|
||||
// about it would answer a question they never asked.
|
||||
emit: async (event, payload) => db.deliver(event, payload),
|
||||
|
||||
openUrl: async (url) => {
|
||||
window.open(url, "_blank", "noopener,noreferrer");
|
||||
},
|
||||
|
||||
revealItemInDir: async () => {
|
||||
throw unsupported(
|
||||
"revealItemInDir",
|
||||
"A browser tab can't open your file manager",
|
||||
"localFiles",
|
||||
);
|
||||
},
|
||||
|
||||
osType: detectOsType,
|
||||
appIdentifier: async () => "app.yaak.web",
|
||||
};
|
||||
}
|
||||
53
packages/platform/src/web/protocol.ts
Normal file
53
packages/platform/src/web/protocol.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* The messages that cross between a tab and the worker that owns the database.
|
||||
*
|
||||
* Declared once and imported from both sides, so a change to the shape is a
|
||||
* type error in whichever side forgot. Kept deliberately small: commands in,
|
||||
* results or errors out, and events pushed the other way — the same envelope
|
||||
* the desktop's IPC uses, because that is what the frontend is written against.
|
||||
*/
|
||||
|
||||
/** Tab → worker */
|
||||
export type ToWorker =
|
||||
| { type: "rpc"; id: number; cmd: string; payload: unknown; label: string }
|
||||
/**
|
||||
* The prepare half of a send: resolve, inherit and render a request against
|
||||
* the database. Its own message rather than an `rpc` command because it is
|
||||
* async in the engine (rendering is), where every `rpc` command is not.
|
||||
*/
|
||||
| { type: "prepare_http_send"; id: number; payload: unknown }
|
||||
| { type: "blob_get"; id: number; blobId: string }
|
||||
| { type: "blob_put"; id: number; blobId: string; bytes: ArrayBuffer }
|
||||
| { type: "blob_delete"; id: number; blobId: string }
|
||||
/** The tab is going away; the worker can forget its port. */
|
||||
| { type: "goodbye" };
|
||||
|
||||
/** Worker → tab */
|
||||
export type FromWorker =
|
||||
/**
|
||||
* Sent synchronously the moment a port connects, before anything else. Its
|
||||
* only job is to prove the worker is alive: a tab that connects during a
|
||||
* shared worker's teardown gets a port that is accepted and then never
|
||||
* serviced, and this is how it tells that apart from a slow boot.
|
||||
*/
|
||||
| { type: "hello" }
|
||||
/** The database is open. Sent to each port once boot has finished. */
|
||||
| { type: "ready" }
|
||||
/** The database could not be opened; every command will fail with this. */
|
||||
| { type: "boot_error"; message: string }
|
||||
| { type: "result"; id: number; result: unknown }
|
||||
| { type: "error"; id: number; message: string }
|
||||
/** A backend event for the app — today only `model_writes`. Sent to every port. */
|
||||
| { type: "event"; event: string; payload: unknown };
|
||||
|
||||
/** What the worker registers itself under. Tabs on one origin share it. */
|
||||
export const WORKER_NAME = "yaak-db";
|
||||
|
||||
/**
|
||||
* The Web Lock the worker takes before opening the database.
|
||||
*
|
||||
* The browser already guarantees one SharedWorker per origin for this name.
|
||||
* The lock covers the one overlap it doesn't rule out: a tab reloading itself,
|
||||
* whose old worker may still be letting go while the new one comes up.
|
||||
*/
|
||||
export const DB_LOCK_NAME = "yaak-db";
|
||||
352
packages/platform/src/web/send.ts
Normal file
352
packages/platform/src/web/send.ts
Normal file
@@ -0,0 +1,352 @@
|
||||
/**
|
||||
* Sending an HTTP request from a tab.
|
||||
*
|
||||
* A tab can't see a response the way the desktop can — CORS hides most headers,
|
||||
* redirects are followed silently, there is no timeline — so the network half of
|
||||
* a send happens on a small stateless server (`crates-server/yaak-web`).
|
||||
* Everything else happens here, against this tab's own database, in the same
|
||||
* order the desktop does it:
|
||||
*
|
||||
* 1. create the `http_response` row (state: initialized);
|
||||
* 2. resolve and render the request in the worker (`prepare_http_send`: the
|
||||
* environment chain, inherited headers and auth, request settings, cookie
|
||||
* jar — the desktop's `HttpSendInputs`, in Rust, on the same model layer);
|
||||
* 3. POST the rendered request to the server and consume its stream: timeline
|
||||
* events, the response head, body chunks, and a terminal frame;
|
||||
* 4. write what comes back where the desktop writes it — the response row as
|
||||
* it progresses, `http_response_event` rows for the timeline, the body
|
||||
* blob under the response id, the cookie jar with the server's changes.
|
||||
*
|
||||
* The server keeps nothing. Every byte it sees comes from this tab and every
|
||||
* byte it returns is stored by this tab.
|
||||
*/
|
||||
|
||||
// Types only: the models package imports this one at runtime, and a type import
|
||||
// is erased, so there is no cycle.
|
||||
import type {
|
||||
Cookie,
|
||||
CookieJar,
|
||||
HttpRequest,
|
||||
HttpResponse,
|
||||
HttpResponseEventData,
|
||||
HttpSendSettings,
|
||||
} from "@yaakapp-internal/models";
|
||||
import type { Frame, SendRequest } from "@yaakapp-internal/web";
|
||||
import type { WorkerConnection } from "./connection";
|
||||
import { serverIdentity, serverSendUrl, readFrames } from "./server";
|
||||
|
||||
/* -------------------------------- shapes --------------------------------- */
|
||||
|
||||
/**
|
||||
* The response row as this file knows it: what identifies it, plus whatever
|
||||
* has been written so far. Every other field is optional and takes the model
|
||||
* layer's default when absent, the same way the desktop's row does — defaults
|
||||
* live in Rust, once.
|
||||
*/
|
||||
type ResponseRow = Pick<HttpResponse, "model" | "requestId" | "workspaceId"> &
|
||||
Partial<Omit<HttpResponse, "model" | "requestId" | "workspaceId">>;
|
||||
|
||||
type ResponsePatch = Partial<HttpResponse>;
|
||||
|
||||
/** What `prepare_http_send` (crates/yaak-wasm) hands back. */
|
||||
interface PreparedHttpSend {
|
||||
request: HttpRequest;
|
||||
settings: HttpSendSettings;
|
||||
settingEvents: HttpResponseEventData[];
|
||||
cookieJar: CookieJar | null;
|
||||
}
|
||||
|
||||
/** The desktop writes progress at most this often while a body streams in. */
|
||||
const PROGRESS_INTERVAL_MS = 100;
|
||||
|
||||
/* --------------------------------- send ---------------------------------- */
|
||||
|
||||
export async function sendHttpRequest(
|
||||
db: WorkerConnection,
|
||||
requestId: string,
|
||||
environmentId: string | null,
|
||||
cookieJarId: string | null,
|
||||
): Promise<ResponseRow> {
|
||||
// The response row exists before anything can go wrong, as on the desktop, so
|
||||
// a failure to render or to reach the server lands in the response pane as
|
||||
// that response's error rather than as a toast that names no request.
|
||||
const workspaceId = await workspaceIdOfRequest(db, requestId);
|
||||
const response = new ResponseWriter(db, { model: "http_response", requestId, workspaceId });
|
||||
await response.create();
|
||||
|
||||
const cancel = new AbortController();
|
||||
const unlistenCancel = db.listen(`cancel_http_response_${response.id}`, () => cancel.abort());
|
||||
|
||||
try {
|
||||
await runSend(db, response, requestId, environmentId, cookieJarId, cancel.signal);
|
||||
} catch (err) {
|
||||
const message = cancel.signal.aborted ? "Request canceled" : errorMessage(err);
|
||||
await response.finish({ error: message });
|
||||
} finally {
|
||||
unlistenCancel();
|
||||
}
|
||||
return response.current();
|
||||
}
|
||||
|
||||
async function runSend(
|
||||
db: WorkerConnection,
|
||||
response: ResponseWriter,
|
||||
requestId: string,
|
||||
environmentId: string | null,
|
||||
cookieJarId: string | null,
|
||||
signal: AbortSignal,
|
||||
): Promise<void> {
|
||||
const prepared = await db.prepareHttpSend<PreparedHttpSend>({
|
||||
requestId,
|
||||
environmentId,
|
||||
cookieJarId,
|
||||
});
|
||||
await response.patch({ url: prepared.request.url });
|
||||
|
||||
// The first line of the timeline says what did the sending and where. A
|
||||
// request through a proxy shows a different origin to the server than the
|
||||
// user's machine, and this is where that should be visible.
|
||||
const timeline = new TimelineWriter(db, response.id, response.workspaceId);
|
||||
timeline.push([{ type: "info", message: `Executed by ${await serverIdentity()}` }]);
|
||||
timeline.push(prepared.settingEvents);
|
||||
|
||||
const body: SendRequest = {
|
||||
request: prepared.request,
|
||||
settings: prepared.settings,
|
||||
cookies: prepared.cookieJar?.cookies ?? null,
|
||||
};
|
||||
const startedAt = performance.now();
|
||||
const res = await fetch(serverSendUrl(), {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
signal,
|
||||
}).catch((err: unknown) => {
|
||||
if (signal.aborted) throw err;
|
||||
throw new Error(`Couldn't reach the Yaak server at ${serverSendUrl()}: ${errorMessage(err)}`);
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
// A refusal, not a failed send: bad destination, rate limit, a body the
|
||||
// server can't build. It comes as JSON with the reason.
|
||||
const text = await res.text();
|
||||
let reason = text;
|
||||
try {
|
||||
reason = (JSON.parse(text) as { error?: string }).error ?? text;
|
||||
} catch {
|
||||
/* not JSON; the text is the reason */
|
||||
}
|
||||
throw new Error(reason || `The Yaak server answered ${res.status}`);
|
||||
}
|
||||
if (res.body == null) throw new Error("The Yaak server sent no body");
|
||||
|
||||
const chunks: Uint8Array[] = [];
|
||||
let received = 0;
|
||||
let lastProgress = startedAt;
|
||||
let terminal: Frame | null = null;
|
||||
|
||||
for await (const frame of readFrames(res.body)) {
|
||||
switch (frame.type) {
|
||||
case "event":
|
||||
timeline.push([frame.event]);
|
||||
break;
|
||||
case "response":
|
||||
await response.patch(headOf(frame));
|
||||
break;
|
||||
case "body": {
|
||||
const bytes = base64ToBytes(frame.data);
|
||||
chunks.push(bytes);
|
||||
received += bytes.byteLength;
|
||||
const now = performance.now();
|
||||
if (now - lastProgress >= PROGRESS_INTERVAL_MS) {
|
||||
lastProgress = now;
|
||||
await response.patch({
|
||||
contentLength: received,
|
||||
elapsed: Math.round(now - startedAt),
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "done":
|
||||
case "error":
|
||||
terminal = frame;
|
||||
break;
|
||||
}
|
||||
if (terminal != null) break;
|
||||
}
|
||||
|
||||
// Everything the server said about the timeline is in the database before the
|
||||
// response is marked closed, so a reader that wakes on "closed" sees all of it.
|
||||
await timeline.flush();
|
||||
|
||||
if (terminal == null) {
|
||||
throw new Error("The Yaak server closed the stream without finishing");
|
||||
}
|
||||
|
||||
// Cookies come back on both outcomes: a hop before the failing one may have
|
||||
// set some, and the desktop keeps those too.
|
||||
if (prepared.cookieJar != null && terminal.cookies != null) {
|
||||
await persistCookies(db, prepared.cookieJar, terminal.cookies);
|
||||
}
|
||||
|
||||
if (terminal.type === "error") {
|
||||
throw new Error(terminal.message);
|
||||
}
|
||||
|
||||
// The body is written under the response id, which is how every reader —
|
||||
// `cmd_http_response_body`, the image viewer, the download button — asks for
|
||||
// it. One write, once the whole body is here: the worker's blob store has no
|
||||
// append, and a body larger than memory is over the server's cap anyway.
|
||||
await db.blobPut(response.id, concat(chunks, received));
|
||||
await response.finish({
|
||||
contentLength: terminal.contentLength,
|
||||
contentLengthCompressed: terminal.contentLengthCompressed,
|
||||
elapsed: terminal.elapsed,
|
||||
});
|
||||
}
|
||||
|
||||
function headOf(frame: Extract<Frame, { type: "response" }>): ResponsePatch {
|
||||
return {
|
||||
state: "connected",
|
||||
status: frame.status,
|
||||
statusReason: frame.statusReason,
|
||||
url: frame.url,
|
||||
remoteAddr: frame.remoteAddr,
|
||||
version: frame.version,
|
||||
headers: frame.headers,
|
||||
requestHeaders: frame.requestHeaders,
|
||||
contentLength: frame.contentLength,
|
||||
elapsedHeaders: frame.elapsedHeaders,
|
||||
elapsedDns: frame.elapsedDns,
|
||||
};
|
||||
}
|
||||
|
||||
/* ------------------------------- helpers --------------------------------- */
|
||||
|
||||
/**
|
||||
* The response row, written the way the desktop writes it: created empty,
|
||||
* patched as the send progresses, closed at the end. Each write goes through
|
||||
* `models_upsert`, so every tab on this database sees the response land.
|
||||
*/
|
||||
class ResponseWriter {
|
||||
private state: ResponseRow;
|
||||
|
||||
constructor(
|
||||
private readonly db: WorkerConnection,
|
||||
initial: ResponseRow,
|
||||
) {
|
||||
this.state = initial;
|
||||
}
|
||||
|
||||
get id(): string {
|
||||
return this.state.id ?? "";
|
||||
}
|
||||
|
||||
get workspaceId(): string {
|
||||
return this.state.workspaceId;
|
||||
}
|
||||
|
||||
current(): ResponseRow {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
/** Create the row. Everything but its identity is the model layer's default. */
|
||||
async create(): Promise<void> {
|
||||
const id = await this.db.rpc<string>("models_upsert", { model: this.state });
|
||||
this.state = { ...this.state, id };
|
||||
}
|
||||
|
||||
async patch(patch: ResponsePatch): Promise<void> {
|
||||
// Structured clone carries `undefined` across to the worker as a present
|
||||
// key, and the model layer reads that as "wrong type" and refuses the whole
|
||||
// model. Nothing here should produce one, but a missing wire field must
|
||||
// not take the response row down with it.
|
||||
const defined = Object.fromEntries(Object.entries(patch).filter(([, v]) => v !== undefined));
|
||||
this.state = { ...this.state, ...defined };
|
||||
await this.db.rpc("models_upsert", { model: this.state });
|
||||
}
|
||||
|
||||
async finish(patch: ResponsePatch): Promise<void> {
|
||||
await this.patch({ ...patch, state: "closed" });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Timeline events, written in the order they arrived. Writes are chained rather
|
||||
* than awaited inline so a burst of `header_down` events doesn't serialise the
|
||||
* body read behind a database round trip each, and `flush()` is the point at
|
||||
* which the whole timeline is known to be in the database.
|
||||
*/
|
||||
class TimelineWriter {
|
||||
private queue: HttpResponseEventData[] = [];
|
||||
private inFlight: Promise<void> = Promise.resolve();
|
||||
|
||||
constructor(
|
||||
private readonly db: WorkerConnection,
|
||||
private readonly responseId: string,
|
||||
private readonly workspaceId: string,
|
||||
) {}
|
||||
|
||||
push(events: HttpResponseEventData[]): void {
|
||||
if (events.length === 0) return;
|
||||
this.queue.push(...events);
|
||||
this.inFlight = this.inFlight.then(() => this.drain());
|
||||
}
|
||||
|
||||
private async drain(): Promise<void> {
|
||||
if (this.queue.length === 0) return;
|
||||
const events = this.queue;
|
||||
this.queue = [];
|
||||
await this.db.rpc("web_insert_http_response_events", {
|
||||
responseId: this.responseId,
|
||||
workspaceId: this.workspaceId,
|
||||
events,
|
||||
});
|
||||
}
|
||||
|
||||
flush(): Promise<void> {
|
||||
return this.inFlight;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Carry the send's cookie changes into the jar. The worker applies them as a
|
||||
* difference against the jar as it is now (see `apply_cookie_changes` in
|
||||
* yaak-models), so an edit made while the send was in flight survives rather
|
||||
* than being written over by the send's stale snapshot.
|
||||
*/
|
||||
async function persistCookies(db: WorkerConnection, jar: CookieJar, cookies: Cookie[]): Promise<void> {
|
||||
await db.rpc("web_persist_send_cookies", { cookieJarId: jar.id, before: jar.cookies, after: cookies });
|
||||
}
|
||||
|
||||
/**
|
||||
* The request's workspace, needed to create the response row before the worker
|
||||
* has resolved the request (which is where a render refusal would land).
|
||||
*/
|
||||
async function workspaceIdOfRequest(db: WorkerConnection, requestId: string): Promise<string> {
|
||||
const req = await db.rpc<{ workspaceId: string }>("web_get_http_request", { requestId });
|
||||
return req.workspaceId;
|
||||
}
|
||||
|
||||
function errorMessage(err: unknown): string {
|
||||
if (err instanceof Error) return err.message;
|
||||
return String(err);
|
||||
}
|
||||
|
||||
function base64ToBytes(data: string): Uint8Array {
|
||||
const bin = atob(data);
|
||||
const out = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
||||
return out;
|
||||
}
|
||||
|
||||
function concat(chunks: Uint8Array[], total: number): Uint8Array {
|
||||
if (chunks.length === 1) return chunks[0]!;
|
||||
const out = new Uint8Array(total);
|
||||
let offset = 0;
|
||||
for (const c of chunks) {
|
||||
out.set(c, offset);
|
||||
offset += c.byteLength;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
83
packages/platform/src/web/server.ts
Normal file
83
packages/platform/src/web/server.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* The wire to the Yaak server: where it is, and how to read what comes back.
|
||||
*
|
||||
* The shapes themselves are generated from `crates-server/yaak-web/src/wire.rs`
|
||||
* into `@yaakapp-internal/web`, so the two sides cannot drift silently.
|
||||
*/
|
||||
|
||||
import type { Frame } from "@yaakapp-internal/web";
|
||||
|
||||
/* ------------------------------- location -------------------------------- */
|
||||
|
||||
/**
|
||||
* Where the tab sends.
|
||||
*
|
||||
* Empty means "this origin": the server can serve the app itself
|
||||
* (`yaak-web --serve`), and then a send is a request to a path on the
|
||||
* page's own origin — no CORS, and nothing for a self-hoster to configure.
|
||||
*
|
||||
* `VITE_YAAK_WEB_URL` overrides it at build time, for a deployment that keeps the
|
||||
* two apart. Nothing else needs to: the dev server passes `/v1` through to
|
||||
* `yaak-web` (see the client's vite config), so a dev build and a production
|
||||
* build are both talking to their own origin, and neither has an address to
|
||||
* learn. A tab opened from another machine therefore works unchanged.
|
||||
*/
|
||||
export function serverBaseUrl(): string {
|
||||
const env = (import.meta as unknown as { env?: Record<string, string | undefined> }).env;
|
||||
const configured = env?.VITE_YAAK_WEB_URL?.trim();
|
||||
return configured ? configured.replace(/\/+$/, "") : "";
|
||||
}
|
||||
|
||||
export function serverSendUrl(): string {
|
||||
return `${serverBaseUrl()}/v1/http/send`;
|
||||
}
|
||||
|
||||
let identity: Promise<string> | null = null;
|
||||
|
||||
/** The server's location as a person reads it, since "" means "this origin". */
|
||||
function serverLocation(): string {
|
||||
return serverBaseUrl() || globalThis.location?.origin || "this origin";
|
||||
}
|
||||
|
||||
/**
|
||||
* Who does the sending, for the timeline: `yaak-web 0.1.0 at http://…`.
|
||||
* Asked of `/v1/health` once per page load; if the server can't be reached the
|
||||
* URL alone is the answer, and the send itself will say why shortly after.
|
||||
*/
|
||||
export function serverIdentity(): Promise<string> {
|
||||
identity ??= fetch(`${serverBaseUrl()}/v1/health`)
|
||||
.then((res) => res.json() as Promise<{ version?: string }>)
|
||||
.then((health) => `yaak-web ${health.version ?? ""} at ${serverLocation()}`.replace(" ", " "))
|
||||
.catch(() => {
|
||||
identity = null; // try again next send
|
||||
return `Yaak server at ${serverLocation()}`;
|
||||
});
|
||||
return identity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Yield frames from an NDJSON stream as they arrive. A partial trailing line is
|
||||
* held until its newline comes; anything left when the stream ends is dropped,
|
||||
* because a frame without its newline is a frame the server didn't finish writing.
|
||||
*/
|
||||
export async function* readFrames(stream: ReadableStream<Uint8Array>): AsyncGenerator<Frame> {
|
||||
const reader = stream.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
try {
|
||||
while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
let newline = buffer.indexOf("\n");
|
||||
while (newline !== -1) {
|
||||
const line = buffer.slice(0, newline);
|
||||
buffer = buffer.slice(newline + 1);
|
||||
if (line.trim() !== "") yield JSON.parse(line) as Frame;
|
||||
newline = buffer.indexOf("\n");
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
}
|
||||
18
packages/platform/src/web/storage.ts
Normal file
18
packages/platform/src/web/storage.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Ask the browser not to evict this origin's data under storage pressure.
|
||||
*
|
||||
* Without it IndexedDB — where the worker's SQLite pages live — is "best
|
||||
* effort", and a browser clearing space can drop a user's workspaces. Granting
|
||||
* is the browser's call; it typically says yes once a site looks installed or
|
||||
* engaged, and often says no on localhost. This is a request, not a guarantee,
|
||||
* and there is nothing useful to do when it declines.
|
||||
*/
|
||||
export async function requestPersistence(): Promise<boolean> {
|
||||
try {
|
||||
if (navigator.storage?.persist == null) return false;
|
||||
if (await navigator.storage.persisted()) return true;
|
||||
return await navigator.storage.persist();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
196
packages/platform/src/web/worker.ts
Normal file
196
packages/platform/src/web/worker.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
/// <reference lib="webworker" />
|
||||
|
||||
/**
|
||||
* The process that owns the database.
|
||||
*
|
||||
* On the desktop that is the Rust binary: it holds SQLite, every window talks
|
||||
* to it, and it pushes model writes to all of them. In a browser this worker
|
||||
* plays that part. It loads the model layer compiled to wasm, opens the one
|
||||
* database, answers each tab's commands over its port, and fans every
|
||||
* `model_writes` out to every port — so two tabs are coherent for the same
|
||||
* reason two desktop windows are, not because of a side channel.
|
||||
*
|
||||
* It is a SharedWorker, and only that: the browser hands every tab on the
|
||||
* origin the same one, which is what makes "one database owner" true without
|
||||
* anyone coordinating. It still takes a Web Lock before opening the database,
|
||||
* for the one overlap the browser doesn't rule out — a tab reloading itself,
|
||||
* whose old worker may still be letting go while the new one comes up.
|
||||
*/
|
||||
|
||||
import { DB_LOCK_NAME, type FromWorker, type ToWorker } from "./protocol";
|
||||
|
||||
/**
|
||||
* The wasm is imported lazily, inside `boot()`, rather than at the top of the
|
||||
* module. That keeps this script's own evaluation instant, so a tab's connect
|
||||
* gets its `hello` immediately regardless of how long the model layer takes to
|
||||
* download and compile — and the tab can therefore tell "this worker is dead"
|
||||
* from "this worker is busy" with a short timeout.
|
||||
*/
|
||||
type Engine = typeof import("@yaakapp-internal/wasm");
|
||||
let engine: Engine | null = null;
|
||||
|
||||
const ports = new Set<MessagePort>();
|
||||
|
||||
/** Resolves once `boot()` has, or rejects with why it couldn't. */
|
||||
let booted: Promise<void> | null = null;
|
||||
let bootError: string | null = null;
|
||||
|
||||
function send(port: MessagePort, message: FromWorker, transfer: Transferable[] = []): void {
|
||||
port.postMessage(message, transfer);
|
||||
}
|
||||
|
||||
function broadcast(message: FromWorker): void {
|
||||
for (const port of ports) send(port, message);
|
||||
}
|
||||
|
||||
function errorMessage(err: unknown): string {
|
||||
if (err instanceof Error) return err.message;
|
||||
return String(err);
|
||||
}
|
||||
|
||||
/**
|
||||
* How long to wait for the database lock before concluding someone else has it
|
||||
* for good. The wait exists for one case: a tab reloading itself. Its old
|
||||
* worker still holds the lock while it is torn down, and the new worker only
|
||||
* needs it to let go — which takes milliseconds, not seconds. Anything longer
|
||||
* is a live worker in another tab, and the honest answer is to say so.
|
||||
*/
|
||||
const LOCK_TIMEOUT_MS = 3000;
|
||||
|
||||
const ALREADY_OPEN =
|
||||
"Yaak's database is held by another worker that isn't letting go. Close Yaak's other tabs and reload.";
|
||||
|
||||
/**
|
||||
* Take the lock, or explain why not.
|
||||
*
|
||||
* Held for the life of the worker: the callback's promise never settles, so
|
||||
* the browser keeps the lock until this worker is gone. Requested with a
|
||||
* timeout rather than `ifAvailable`, so a dying predecessor's brief hold is
|
||||
* waited out but a live one is reported.
|
||||
*/
|
||||
function acquireDatabaseLock(): Promise<void> {
|
||||
// The tab checked for Web Locks before it ever connected; a worker without
|
||||
// them would be a browser lying about its own features.
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
navigator.locks
|
||||
.request(DB_LOCK_NAME, { signal: AbortSignal.timeout(LOCK_TIMEOUT_MS) }, () => {
|
||||
resolve();
|
||||
return new Promise<void>(() => {});
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
const name = (err as { name?: string } | null)?.name;
|
||||
reject(name === "AbortError" || name === "TimeoutError" ? new Error(ALREADY_OPEN) : err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the database, once, for everyone.
|
||||
*
|
||||
* Lock first, then load the model layer, then open — in that order, so a
|
||||
* worker that will never own the database also never downloads and compiles
|
||||
* the wasm for it.
|
||||
*/
|
||||
function bootOnce(): Promise<void> {
|
||||
if (booted != null) return booted;
|
||||
|
||||
booted = (async () => {
|
||||
await acquireDatabaseLock();
|
||||
const loaded = await import("@yaakapp-internal/wasm");
|
||||
await loaded.boot();
|
||||
engine = loaded;
|
||||
})();
|
||||
|
||||
booted.catch((err) => {
|
||||
bootError = errorMessage(err);
|
||||
});
|
||||
|
||||
return booted;
|
||||
}
|
||||
|
||||
async function handle(port: MessagePort, message: ToWorker): Promise<void> {
|
||||
if (message.type === "goodbye") {
|
||||
ports.delete(port);
|
||||
return;
|
||||
}
|
||||
|
||||
// Every command waits for boot rather than the tab having to. Tabs post
|
||||
// the moment they load; the port queues; this drains once the DB is open.
|
||||
try {
|
||||
await booted;
|
||||
} catch {
|
||||
send(port, { type: "error", id: message.id, message: bootError ?? "Database failed to open" });
|
||||
return;
|
||||
}
|
||||
const { rpc, blob_get, blob_put, blob_delete, prepare_http_send } = engine!;
|
||||
|
||||
try {
|
||||
switch (message.type) {
|
||||
case "rpc": {
|
||||
const outcome = rpc(message.cmd, message.payload, message.label) as {
|
||||
result: unknown;
|
||||
events: unknown[];
|
||||
};
|
||||
// Result first, to the caller; then the writes, to everyone including
|
||||
// the caller. The store applies its own echo the same as any other
|
||||
// window's, so it must arrive — and the caller's `await` resolving
|
||||
// before its echo lands is fine, because it resolves on the same tick
|
||||
// and the store reads on the next.
|
||||
send(port, { type: "result", id: message.id, result: outcome.result });
|
||||
if (outcome.events.length > 0) {
|
||||
broadcast({ type: "event", event: "model_writes", payload: outcome.events });
|
||||
}
|
||||
return;
|
||||
}
|
||||
case "prepare_http_send": {
|
||||
const prepared = await prepare_http_send(message.payload);
|
||||
send(port, { type: "result", id: message.id, result: prepared });
|
||||
return;
|
||||
}
|
||||
case "blob_get": {
|
||||
const bytes = blob_get(message.blobId);
|
||||
if (bytes == null) {
|
||||
send(port, { type: "result", id: message.id, result: null });
|
||||
} else {
|
||||
// Copy into a fresh buffer we can transfer: the wasm's memory
|
||||
// cannot leave the worker.
|
||||
const out = new Uint8Array(bytes.byteLength);
|
||||
out.set(bytes);
|
||||
send(port, { type: "result", id: message.id, result: out.buffer }, [out.buffer]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
case "blob_put": {
|
||||
blob_put(message.blobId, new Uint8Array(message.bytes));
|
||||
send(port, { type: "result", id: message.id, result: null });
|
||||
return;
|
||||
}
|
||||
case "blob_delete": {
|
||||
blob_delete(message.blobId);
|
||||
send(port, { type: "result", id: message.id, result: null });
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
send(port, { type: "error", id: message.id, message: errorMessage(err) });
|
||||
}
|
||||
}
|
||||
|
||||
function attach(port: MessagePort): void {
|
||||
ports.add(port);
|
||||
port.onmessage = (e: MessageEvent<ToWorker>) => void handle(port, e.data);
|
||||
port.start?.();
|
||||
|
||||
// Proof of life, before boot: the tab is timing this.
|
||||
send(port, { type: "hello" });
|
||||
|
||||
bootOnce().then(
|
||||
() => send(port, { type: "ready" }),
|
||||
(err) => send(port, { type: "boot_error", message: errorMessage(err) }),
|
||||
);
|
||||
}
|
||||
|
||||
// Each tab arrives as a connect event with its own port.
|
||||
(self as unknown as SharedWorkerGlobalScope).onconnect = (e: MessageEvent) => {
|
||||
attach(e.ports[0]!);
|
||||
};
|
||||
12
packages/platform/tsconfig.json
Normal file
12
packages/platform/tsconfig.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es2021",
|
||||
"lib": ["DOM", "DOM.Iterable", "ESNext"],
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
2
packages/plugin-runtime-types/.gitignore
vendored
Normal file
2
packages/plugin-runtime-types/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
lib
|
||||
node_modules
|
||||
28
packages/plugin-runtime-types/README.md
Normal file
28
packages/plugin-runtime-types/README.md
Normal file
@@ -0,0 +1,28 @@
|
||||
# Yaak Plugin API
|
||||
|
||||
Yaak is a desktop [API client](https://yaak.app/blog/yet-another-api-client) for
|
||||
interacting with REST, GraphQL, Server Sent Events (SSE), WebSocket, and gRPC APIs. It's
|
||||
built using Tauri, Rust, and ReactJS.
|
||||
|
||||
Plugins can be created in TypeScript, which are executed alongside Yaak in a NodeJS
|
||||
runtime. This package contains the TypeScript type definitions required to make building
|
||||
Yaak plugins a breeze.
|
||||
|
||||
## Quick Start
|
||||
|
||||
The easiest way to get started is by generating a plugin with the Yaak CLI:
|
||||
|
||||
```shell
|
||||
npx @yaakapp/cli generate
|
||||
```
|
||||
|
||||
For more details on creating plugins, check out
|
||||
the [Quick Start Guide](https://yaak.app/docs/plugin-development/plugins-quick-start)
|
||||
|
||||
## Installation
|
||||
|
||||
If you prefer starting from scratch, manually install the types package:
|
||||
|
||||
```shell
|
||||
npm install -D @yaakapp/api
|
||||
```
|
||||
38
packages/plugin-runtime-types/package.json
Normal file
38
packages/plugin-runtime-types/package.json
Normal file
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"name": "@yaakapp/api",
|
||||
"version": "0.9.0",
|
||||
"keywords": [
|
||||
"api-client",
|
||||
"bruno-alternative",
|
||||
"insomnia-alternative",
|
||||
"postman-alternative"
|
||||
],
|
||||
"homepage": "https://yaak.app",
|
||||
"bugs": {
|
||||
"url": "https://feedback.yaak.app"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/mountain-loop/yaak"
|
||||
},
|
||||
"files": [
|
||||
"lib/**/*"
|
||||
],
|
||||
"main": "lib/index.js",
|
||||
"typings": "./lib/index.d.ts",
|
||||
"scripts": {
|
||||
"bootstrap": "npm run build",
|
||||
"build": "run-s build:copy-types build:tsc",
|
||||
"build:tsc": "tsc",
|
||||
"build:copy-types": "run-p build:copy-types:*",
|
||||
"build:copy-types:root": "cpy --flat ../../crates/yaak-plugins/bindings/*.ts ./src/bindings",
|
||||
"build:copy-types:next": "cpy --flat ../../crates/yaak-plugins/bindings/serde_json/*.ts ./src/bindings/serde_json",
|
||||
"prepublishOnly": "npm run build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/node": "^24.0.13"
|
||||
},
|
||||
"devDependencies": {
|
||||
"cpy-cli": "^7.0.0"
|
||||
}
|
||||
}
|
||||
8
packages/plugin-runtime-types/src/bindings/gen_api.ts
generated
Normal file
8
packages/plugin-runtime-types/src/bindings/gen_api.ts
generated
Normal file
@@ -0,0 +1,8 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { PluginVersion } from "./gen_search";
|
||||
|
||||
export type PluginNameVersion = { name: string, version: string, };
|
||||
|
||||
export type PluginSearchResponse = { plugins: Array<PluginVersion>, };
|
||||
|
||||
export type PluginUpdatesResponse = { plugins: Array<PluginNameVersion>, };
|
||||
663
packages/plugin-runtime-types/src/bindings/gen_events.ts
generated
Normal file
663
packages/plugin-runtime-types/src/bindings/gen_events.ts
generated
Normal file
File diff suppressed because one or more lines are too long
514
packages/plugin-runtime-types/src/bindings/gen_models.ts
generated
Normal file
514
packages/plugin-runtime-types/src/bindings/gen_models.ts
generated
Normal file
@@ -0,0 +1,514 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type AnyModel =
|
||||
| CookieJar
|
||||
| Environment
|
||||
| Folder
|
||||
| GraphQlIntrospection
|
||||
| GrpcConnection
|
||||
| GrpcEvent
|
||||
| GrpcRequest
|
||||
| HttpRequest
|
||||
| HttpResponse
|
||||
| HttpResponseEvent
|
||||
| ImportSource
|
||||
| KeyValue
|
||||
| Plugin
|
||||
| Settings
|
||||
| SyncState
|
||||
| WebsocketConnection
|
||||
| WebsocketEvent
|
||||
| WebsocketRequest
|
||||
| Workspace
|
||||
| WorkspaceMeta;
|
||||
|
||||
export type ClientCertificate = {
|
||||
host: string;
|
||||
port: number | null;
|
||||
crtFile: string | null;
|
||||
keyFile: string | null;
|
||||
pfxFile: string | null;
|
||||
passphrase: string | null;
|
||||
enabled?: boolean;
|
||||
};
|
||||
|
||||
export type Cookie = {
|
||||
name: string;
|
||||
value: string;
|
||||
domain: CookieDomain;
|
||||
expires: CookieExpires;
|
||||
path: string;
|
||||
secure: boolean;
|
||||
httpOnly: boolean;
|
||||
sameSite: CookieSameSite | null;
|
||||
};
|
||||
|
||||
export type CookieDomain = { HostOnly: string } | { Suffix: string } | "NotPresent" | "Empty";
|
||||
|
||||
export type CookieExpires = { AtUtc: string } | "SessionEnd";
|
||||
|
||||
export type CookieJar = {
|
||||
model: "cookie_jar";
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
workspaceId: string;
|
||||
cookies: Array<Cookie>;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type CookieSameSite = "Strict" | "Lax" | "None";
|
||||
|
||||
export type DnsOverride = {
|
||||
hostname: string;
|
||||
ipv4: Array<string>;
|
||||
ipv6: Array<string>;
|
||||
enabled?: boolean;
|
||||
};
|
||||
|
||||
export type EditorKeymap = "default" | "vim" | "vscode" | "emacs";
|
||||
|
||||
export type EncryptedKey = { encryptedKey: string };
|
||||
|
||||
export type Environment = {
|
||||
model: "environment";
|
||||
id: string;
|
||||
workspaceId: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
name: string;
|
||||
public: boolean;
|
||||
parentModel: string;
|
||||
parentId: string | null;
|
||||
/**
|
||||
* Variables defined in this environment scope.
|
||||
* Child environments override parent variables by name.
|
||||
*/
|
||||
variables: Array<EnvironmentVariable>;
|
||||
color: string | null;
|
||||
sortPriority: number;
|
||||
};
|
||||
|
||||
export type EnvironmentVariable = { enabled?: boolean; name: string; value: string; id?: string };
|
||||
|
||||
export type Folder = {
|
||||
model: "folder";
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
workspaceId: string;
|
||||
folderId: string | null;
|
||||
authentication: Record<string, any>;
|
||||
authenticationType: string | null;
|
||||
description: string;
|
||||
headers: Array<HttpRequestHeader>;
|
||||
name: string;
|
||||
sortPriority: number;
|
||||
settingSendCookies: InheritedBoolSetting;
|
||||
settingStoreCookies: InheritedBoolSetting;
|
||||
settingValidateCertificates: InheritedBoolSetting;
|
||||
settingFollowRedirects: InheritedBoolSetting;
|
||||
settingRequestTimeout: InheritedIntSetting;
|
||||
settingRequestMessageSize: InheritedIntSetting;
|
||||
settingHttpVersion: InheritedHttpVersionSetting;
|
||||
};
|
||||
|
||||
export type GraphQlIntrospection = {
|
||||
model: "graphql_introspection";
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
workspaceId: string;
|
||||
requestId: string;
|
||||
content: string | null;
|
||||
};
|
||||
|
||||
export type GrpcConnection = {
|
||||
model: "grpc_connection";
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
workspaceId: string;
|
||||
requestId: string;
|
||||
elapsed: number;
|
||||
error: string | null;
|
||||
method: string;
|
||||
service: string;
|
||||
status: number;
|
||||
state: GrpcConnectionState;
|
||||
trailers: { [key in string]?: string };
|
||||
url: string;
|
||||
};
|
||||
|
||||
export type GrpcConnectionState = "initialized" | "connected" | "closed";
|
||||
|
||||
export type GrpcEvent = {
|
||||
model: "grpc_event";
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
workspaceId: string;
|
||||
requestId: string;
|
||||
connectionId: string;
|
||||
content: string;
|
||||
error: string | null;
|
||||
eventType: GrpcEventType;
|
||||
metadata: { [key in string]?: string };
|
||||
status: number | null;
|
||||
};
|
||||
|
||||
export type GrpcEventType =
|
||||
| "info"
|
||||
| "error"
|
||||
| "client_message"
|
||||
| "server_message"
|
||||
| "connection_start"
|
||||
| "connection_end";
|
||||
|
||||
export type GrpcRequest = {
|
||||
model: "grpc_request";
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
workspaceId: string;
|
||||
folderId: string | null;
|
||||
authenticationType: string | null;
|
||||
authentication: Record<string, any>;
|
||||
description: string;
|
||||
message: string;
|
||||
metadata: Array<HttpRequestHeader>;
|
||||
method: string | null;
|
||||
name: string;
|
||||
service: string | null;
|
||||
sortPriority: number;
|
||||
/**
|
||||
* Server URL (http for plaintext or https for secure)
|
||||
*/
|
||||
url: string;
|
||||
settingValidateCertificates: InheritedBoolSetting;
|
||||
settingRequestMessageSize: InheritedIntSetting;
|
||||
};
|
||||
|
||||
export type HttpRequest = {
|
||||
model: "http_request";
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
workspaceId: string;
|
||||
folderId: string | null;
|
||||
authentication: Record<string, any>;
|
||||
authenticationType: string | null;
|
||||
body: Record<string, any>;
|
||||
bodyType: string | null;
|
||||
description: string;
|
||||
headers: Array<HttpRequestHeader>;
|
||||
method: string;
|
||||
name: string;
|
||||
sortPriority: number;
|
||||
url: string;
|
||||
/**
|
||||
* URL parameters used for both path placeholders (`:id`) and query string entries.
|
||||
*/
|
||||
urlParameters: Array<HttpUrlParameter>;
|
||||
settingSendCookies: InheritedBoolSetting;
|
||||
settingStoreCookies: InheritedBoolSetting;
|
||||
settingValidateCertificates: InheritedBoolSetting;
|
||||
settingFollowRedirects: InheritedBoolSetting;
|
||||
settingRequestTimeout: InheritedIntSetting;
|
||||
settingHttpVersion: InheritedHttpVersionSetting;
|
||||
};
|
||||
|
||||
export type HttpRequestHeader = { enabled?: boolean; name: string; value: string; id?: string };
|
||||
|
||||
export type HttpResponse = {
|
||||
model: "http_response";
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
workspaceId: string;
|
||||
requestId: string;
|
||||
contentLength: number | null;
|
||||
contentLengthCompressed: number | null;
|
||||
elapsed: number;
|
||||
elapsedHeaders: number;
|
||||
elapsedDns: number;
|
||||
error: string | null;
|
||||
headers: Array<HttpResponseHeader>;
|
||||
remoteAddr: string | null;
|
||||
requestContentLength: number | null;
|
||||
requestHeaders: Array<HttpResponseHeader>;
|
||||
status: number;
|
||||
statusReason: string | null;
|
||||
state: HttpResponseState;
|
||||
url: string;
|
||||
version: string | null;
|
||||
};
|
||||
|
||||
export type HttpResponseEvent = {
|
||||
model: "http_response_event";
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
workspaceId: string;
|
||||
responseId: string;
|
||||
event: HttpResponseEventData;
|
||||
};
|
||||
|
||||
/**
|
||||
* Serializable representation of HTTP response events for DB storage.
|
||||
* This mirrors `yaak_http::sender::HttpResponseEvent` but with serde support.
|
||||
* The `From` impl is in yaak-http to avoid circular dependencies.
|
||||
*/
|
||||
export type HttpResponseEventData =
|
||||
| {
|
||||
type: "setting";
|
||||
name: string;
|
||||
value: string;
|
||||
source_model?: string;
|
||||
source_id?: string;
|
||||
source_name?: string;
|
||||
}
|
||||
| { type: "info"; message: string }
|
||||
| {
|
||||
type: "redirect";
|
||||
url: string;
|
||||
status: number;
|
||||
behavior: string;
|
||||
dropped_body: boolean;
|
||||
dropped_headers: Array<string>;
|
||||
}
|
||||
| {
|
||||
type: "send_url";
|
||||
method: string;
|
||||
scheme: string;
|
||||
username: string;
|
||||
password: string;
|
||||
host: string;
|
||||
port: number;
|
||||
path: string;
|
||||
query: string;
|
||||
fragment: string;
|
||||
}
|
||||
| { type: "receive_url"; version: string; status: string }
|
||||
| { type: "header_up"; name: string; value: string }
|
||||
| { type: "header_down"; name: string; value: string }
|
||||
| { type: "chunk_sent"; bytes: number }
|
||||
| { type: "chunk_received"; bytes: number }
|
||||
| {
|
||||
type: "dns_resolved";
|
||||
hostname: string;
|
||||
addresses: Array<string>;
|
||||
duration: bigint;
|
||||
overridden: boolean;
|
||||
};
|
||||
|
||||
export type HttpResponseHeader = { name: string; value: string };
|
||||
|
||||
export type HttpResponseState = "initialized" | "connected" | "closed";
|
||||
|
||||
export type HttpUrlParameter = {
|
||||
enabled?: boolean;
|
||||
/**
|
||||
* Colon-prefixed parameters are treated as path parameters if they match, like `/users/:id`
|
||||
* Other entries are appended as query parameters
|
||||
*/
|
||||
name: string;
|
||||
value: string;
|
||||
id?: string;
|
||||
};
|
||||
|
||||
export type HttpVersion = "auto" | "http1" | "http2";
|
||||
|
||||
export type ImportSource = {
|
||||
model: "import_source";
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
workspaceId: string;
|
||||
importer: string;
|
||||
origin: string;
|
||||
originLabel: string;
|
||||
lastImportedAt: string;
|
||||
};
|
||||
|
||||
export type InheritedBoolSetting = { enabled?: boolean; value: boolean };
|
||||
|
||||
export type InheritedHttpVersionSetting = { enabled?: boolean; value: HttpVersion };
|
||||
|
||||
export type InheritedIntSetting = { enabled?: boolean; value: number };
|
||||
|
||||
export type KeyValue = {
|
||||
model: "key_value";
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
key: string;
|
||||
namespace: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
export type Plugin = {
|
||||
model: "plugin";
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
checkedAt: string | null;
|
||||
directory: string;
|
||||
enabled: boolean;
|
||||
url: string | null;
|
||||
source: PluginSource;
|
||||
};
|
||||
|
||||
export type PluginSource = "bundled" | "filesystem" | "registry";
|
||||
|
||||
export type ProxySetting =
|
||||
| {
|
||||
type: "enabled";
|
||||
http: string;
|
||||
https: string;
|
||||
auth: ProxySettingAuth | null;
|
||||
bypass: string;
|
||||
disabled: boolean;
|
||||
}
|
||||
| { type: "disabled" };
|
||||
|
||||
export type ProxySettingAuth = { user: string; password: string };
|
||||
|
||||
export type Settings = {
|
||||
model: "settings";
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
appearance: string;
|
||||
clientCertificates: Array<ClientCertificate>;
|
||||
coloredMethods: boolean;
|
||||
editorFont: string | null;
|
||||
editorFontSize: number;
|
||||
editorKeymap: EditorKeymap;
|
||||
editorSoftWrap: boolean;
|
||||
hideWindowControls: boolean;
|
||||
useNativeTitlebar: boolean;
|
||||
interfaceFont: string | null;
|
||||
interfaceFontSize: number;
|
||||
interfaceScale: number;
|
||||
openWorkspaceNewWindow: boolean | null;
|
||||
proxy: ProxySetting | null;
|
||||
themeDark: string;
|
||||
themeLight: string;
|
||||
updateChannel: string;
|
||||
hideLicenseBadge: boolean;
|
||||
promptFeedback: boolean;
|
||||
autoupdate: boolean;
|
||||
autoDownloadUpdates: boolean;
|
||||
checkNotifications: boolean;
|
||||
hotkeys: { [key in string]?: Array<string> };
|
||||
};
|
||||
|
||||
export type SyncState = {
|
||||
model: "sync_state";
|
||||
id: string;
|
||||
workspaceId: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
flushedAt: string;
|
||||
modelId: string;
|
||||
checksum: string;
|
||||
relPath: string;
|
||||
syncDir: string;
|
||||
};
|
||||
|
||||
export type WebsocketConnection = {
|
||||
model: "websocket_connection";
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
workspaceId: string;
|
||||
requestId: string;
|
||||
elapsed: number;
|
||||
error: string | null;
|
||||
headers: Array<HttpResponseHeader>;
|
||||
state: WebsocketConnectionState;
|
||||
status: number;
|
||||
url: string;
|
||||
};
|
||||
|
||||
export type WebsocketConnectionState = "initialized" | "connected" | "closing" | "closed";
|
||||
|
||||
export type WebsocketEvent = {
|
||||
model: "websocket_event";
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
workspaceId: string;
|
||||
requestId: string;
|
||||
connectionId: string;
|
||||
isServer: boolean;
|
||||
message: Array<number>;
|
||||
messageType: WebsocketEventType;
|
||||
};
|
||||
|
||||
export type WebsocketEventType =
|
||||
| "binary"
|
||||
| "close"
|
||||
| "error"
|
||||
| "frame"
|
||||
| "open"
|
||||
| "ping"
|
||||
| "pong"
|
||||
| "text";
|
||||
|
||||
export type WebsocketRequest = {
|
||||
model: "websocket_request";
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
workspaceId: string;
|
||||
folderId: string | null;
|
||||
authentication: Record<string, any>;
|
||||
authenticationType: string | null;
|
||||
description: string;
|
||||
headers: Array<HttpRequestHeader>;
|
||||
message: string;
|
||||
name: string;
|
||||
sortPriority: number;
|
||||
url: string;
|
||||
/**
|
||||
* URL parameters used for both path placeholders (`:id`) and query string entries.
|
||||
*/
|
||||
urlParameters: Array<HttpUrlParameter>;
|
||||
settingSendCookies: InheritedBoolSetting;
|
||||
settingStoreCookies: InheritedBoolSetting;
|
||||
settingValidateCertificates: InheritedBoolSetting;
|
||||
settingRequestMessageSize: InheritedIntSetting;
|
||||
};
|
||||
|
||||
export type Workspace = {
|
||||
model: "workspace";
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
authentication: Record<string, any>;
|
||||
authenticationType: string | null;
|
||||
description: string;
|
||||
headers: Array<HttpRequestHeader>;
|
||||
name: string;
|
||||
encryptionKeyChallenge: string | null;
|
||||
settingValidateCertificates: boolean;
|
||||
settingFollowRedirects: boolean;
|
||||
settingRequestTimeout: number;
|
||||
settingRequestMessageSize: number;
|
||||
settingDnsOverrides: Array<DnsOverride>;
|
||||
settingSendCookies: boolean;
|
||||
settingStoreCookies: boolean;
|
||||
settingHttpVersion: HttpVersion;
|
||||
};
|
||||
|
||||
export type WorkspaceMeta = {
|
||||
model: "workspace_meta";
|
||||
id: string;
|
||||
workspaceId: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
encryptionKey: EncryptedKey | null;
|
||||
settingSyncDir: string | null;
|
||||
};
|
||||
5
packages/plugin-runtime-types/src/bindings/gen_search.ts
generated
Normal file
5
packages/plugin-runtime-types/src/bindings/gen_search.ts
generated
Normal file
@@ -0,0 +1,5 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type PluginMetadata = { version: string, name: string, displayName: string, description: string | null, homepageUrl: string | null, repositoryUrl: string | null, };
|
||||
|
||||
export type PluginVersion = { id: string, version: string, url: string, description: string | null, name: string, displayName: string, homepageUrl: string | null, repositoryUrl: string | null, checksum: string, readme: string | null, yanked: boolean, };
|
||||
@@ -0,0 +1,3 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type JsonValue = number | string | boolean | Array<JsonValue> | { [key in string]?: JsonValue } | null;
|
||||
2
packages/plugin-runtime-types/src/helpers.ts
Normal file
2
packages/plugin-runtime-types/src/helpers.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export type AtLeast<T, K extends keyof T> = Partial<T> & Pick<T, K>;
|
||||
export type MaybePromise<T> = Promise<T> | T;
|
||||
9
packages/plugin-runtime-types/src/index.ts
Normal file
9
packages/plugin-runtime-types/src/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export type * from "./plugins";
|
||||
export type * from "./themes";
|
||||
|
||||
export * from "./bindings/gen_models";
|
||||
export * from "./bindings/gen_events";
|
||||
|
||||
// Some extras for utility
|
||||
|
||||
export type { PartialImportResources } from "./plugins/ImporterPlugin";
|
||||
@@ -0,0 +1,45 @@
|
||||
import type {
|
||||
CallHttpAuthenticationActionArgs,
|
||||
CallHttpAuthenticationRequest,
|
||||
CallHttpAuthenticationResponse,
|
||||
FormInput,
|
||||
GetHttpAuthenticationSummaryResponse,
|
||||
HttpAuthenticationAction,
|
||||
} from "../bindings/gen_events";
|
||||
import type { MaybePromise } from "../helpers";
|
||||
import type { Context } from "./Context";
|
||||
|
||||
type AddDynamicMethod<T> = {
|
||||
dynamic?: (
|
||||
ctx: Context,
|
||||
args: CallHttpAuthenticationActionArgs,
|
||||
) => MaybePromise<Partial<T> | null | undefined>;
|
||||
};
|
||||
|
||||
// oxlint-disable-next-line no-explicit-any -- distributive conditional type pattern
|
||||
type AddDynamic<T> = T extends any
|
||||
? T extends { inputs?: FormInput[] }
|
||||
? Omit<T, "inputs"> & {
|
||||
inputs: Array<AddDynamic<FormInput>>;
|
||||
dynamic?: (
|
||||
ctx: Context,
|
||||
args: CallHttpAuthenticationActionArgs,
|
||||
) => MaybePromise<
|
||||
Partial<Omit<T, "inputs"> & { inputs: Array<AddDynamic<FormInput>> }> | null | undefined
|
||||
>;
|
||||
}
|
||||
: T & AddDynamicMethod<T>
|
||||
: never;
|
||||
|
||||
export type DynamicAuthenticationArg = AddDynamic<FormInput>;
|
||||
|
||||
export type AuthenticationPlugin = GetHttpAuthenticationSummaryResponse & {
|
||||
args: DynamicAuthenticationArg[];
|
||||
onApply(
|
||||
ctx: Context,
|
||||
args: CallHttpAuthenticationRequest,
|
||||
): MaybePromise<CallHttpAuthenticationResponse>;
|
||||
actions?: (HttpAuthenticationAction & {
|
||||
onSelect(ctx: Context, args: CallHttpAuthenticationActionArgs): Promise<void> | void;
|
||||
})[];
|
||||
};
|
||||
236
packages/plugin-runtime-types/src/plugins/Context.ts
Normal file
236
packages/plugin-runtime-types/src/plugins/Context.ts
Normal file
@@ -0,0 +1,236 @@
|
||||
import type {
|
||||
FindHttpResponsesRequest,
|
||||
FindHttpResponsesResponse,
|
||||
FormInput,
|
||||
GetCookieValueRequest,
|
||||
GetCookieValueResponse,
|
||||
GetHttpRequestByIdRequest,
|
||||
GetHttpRequestByIdResponse,
|
||||
GetHttpResponseBodyInfoRequest,
|
||||
JsonPrimitive,
|
||||
ListCookieNamesResponse,
|
||||
ListFoldersRequest,
|
||||
ListFoldersResponse,
|
||||
ListHttpRequestsRequest,
|
||||
ListHttpRequestsResponse,
|
||||
OpenWindowRequest,
|
||||
PromptFormRequest,
|
||||
PromptFormResponse,
|
||||
PromptTextRequest,
|
||||
PromptTextResponse,
|
||||
RenderGrpcRequestRequest,
|
||||
RenderGrpcRequestResponse,
|
||||
RenderHttpRequestRequest,
|
||||
RenderHttpRequestResponse,
|
||||
SendHttpRequestRequest,
|
||||
ShowToastRequest,
|
||||
TemplateRenderRequest,
|
||||
WorkspaceInfo,
|
||||
} from "../bindings/gen_events.ts";
|
||||
import type { Folder, HttpRequest, HttpResponse } from "../bindings/gen_models.ts";
|
||||
import type { JsonValue } from "../bindings/serde_json/JsonValue";
|
||||
import type { MaybePromise } from "../helpers";
|
||||
|
||||
export type CallPromptFormDynamicArgs = {
|
||||
values: { [key in string]?: JsonPrimitive };
|
||||
};
|
||||
|
||||
type AddDynamicMethod<T> = {
|
||||
dynamic?: (
|
||||
ctx: Context,
|
||||
args: CallPromptFormDynamicArgs,
|
||||
) => MaybePromise<Partial<T> | null | undefined>;
|
||||
};
|
||||
|
||||
// oxlint-disable-next-line no-explicit-any -- distributive conditional type pattern
|
||||
type AddDynamic<T> = T extends any
|
||||
? T extends { inputs?: FormInput[] }
|
||||
? Omit<T, "inputs"> & {
|
||||
inputs: Array<AddDynamic<FormInput>>;
|
||||
dynamic?: (
|
||||
ctx: Context,
|
||||
args: CallPromptFormDynamicArgs,
|
||||
) => MaybePromise<
|
||||
Partial<Omit<T, "inputs"> & { inputs: Array<AddDynamic<FormInput>> }> | null | undefined
|
||||
>;
|
||||
}
|
||||
: T & AddDynamicMethod<T>
|
||||
: never;
|
||||
|
||||
export type DynamicPromptFormArg = AddDynamic<FormInput>;
|
||||
|
||||
type DynamicPromptFormRequest = Omit<PromptFormRequest, "inputs"> & {
|
||||
inputs: DynamicPromptFormArg[];
|
||||
};
|
||||
|
||||
export type WorkspaceHandle = Pick<WorkspaceInfo, "id" | "name">;
|
||||
|
||||
export interface ReadHttpResponseBodyOptions {
|
||||
/**
|
||||
* Refuse to buffer a body larger than this, in bytes. Defaults to 32 MiB.
|
||||
* Pass `Infinity` to read whatever is there.
|
||||
*/
|
||||
maxBytes?: number;
|
||||
|
||||
/** Bytes to pull from the host at a time. Defaults to 1 MiB. */
|
||||
chunkSize?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* A response body, read back from wherever the host stored it.
|
||||
*
|
||||
* The accessors are named after `fetch`'s and behave the same way against a
|
||||
* response that is still arriving: they wait for the rest, and one that never
|
||||
* finishes is never finished reading — `chunks()` is the way to consume that.
|
||||
* Unlike `fetch`, the body is not used up by reading it: the bytes are in
|
||||
* durable storage, so every accessor can be called as many times as you like,
|
||||
* in any order.
|
||||
*/
|
||||
export interface HttpResponseBody {
|
||||
/** The response these bytes belong to. */
|
||||
readonly responseId: string;
|
||||
|
||||
/**
|
||||
* How many bytes were stored when this body was opened, which is not
|
||||
* necessarily what the `Content-Length` header claimed. Zero when the
|
||||
* response has no body. Final only if `complete`.
|
||||
*/
|
||||
readonly contentLength: number;
|
||||
|
||||
/** The response's `Content-Type` header, verbatim, or null if it had none. */
|
||||
readonly contentType: string | null;
|
||||
|
||||
/**
|
||||
* Whether the response had finished arriving when this body was opened.
|
||||
* When false, the accessors below will wait for the rest of it.
|
||||
*/
|
||||
readonly complete: boolean;
|
||||
|
||||
/**
|
||||
* The whole body decoded to a string, using the charset from `contentType`
|
||||
* and falling back to UTF-8. Waits for a response still arriving. Throws
|
||||
* once more than `maxBytes` has been read.
|
||||
*/
|
||||
text(options?: ReadHttpResponseBodyOptions): Promise<string>;
|
||||
|
||||
/** `text()`, parsed as JSON. */
|
||||
json<T = unknown>(options?: ReadHttpResponseBodyOptions): Promise<T>;
|
||||
|
||||
/** The whole body as raw bytes. Waits and throws as `text()` does. */
|
||||
arrayBuffer(options?: ReadHttpResponseBodyOptions): Promise<ArrayBuffer>;
|
||||
|
||||
/**
|
||||
* The raw bytes, a chunk at a time, so a body of any size can be read
|
||||
* without holding all of it at once. Not subject to `maxBytes`.
|
||||
*
|
||||
* Follows a response that is still arriving, yielding as it comes, and ends
|
||||
* when the response does. Break out of the loop to stop early.
|
||||
*/
|
||||
chunks(options?: Pick<ReadHttpResponseBodyOptions, "chunkSize">): AsyncIterable<Uint8Array>;
|
||||
}
|
||||
|
||||
/** What a send came back with. */
|
||||
export interface SentHttpRequest {
|
||||
httpResponse: HttpResponse;
|
||||
|
||||
/**
|
||||
* The response's body.
|
||||
*
|
||||
* Handed over here rather than looked up later, because a request with no id
|
||||
* is not saved and this is the only copy of its body. Reading it is the same
|
||||
* either way, so nothing has to know which kind of send it made.
|
||||
*/
|
||||
body: HttpResponseBody;
|
||||
}
|
||||
|
||||
export interface Context {
|
||||
clipboard: {
|
||||
copyText(text: string): Promise<void>;
|
||||
};
|
||||
toast: {
|
||||
show(args: ShowToastRequest): Promise<void>;
|
||||
};
|
||||
prompt: {
|
||||
text(args: PromptTextRequest): Promise<PromptTextResponse["value"]>;
|
||||
form(args: DynamicPromptFormRequest): Promise<PromptFormResponse["values"]>;
|
||||
};
|
||||
store: {
|
||||
set<T>(key: string, value: T): Promise<void>;
|
||||
get<T>(key: string): Promise<T | undefined>;
|
||||
delete(key: string): Promise<boolean>;
|
||||
};
|
||||
window: {
|
||||
requestId(): Promise<string | null>;
|
||||
workspaceId(): Promise<string | null>;
|
||||
environmentId(): Promise<string | null>;
|
||||
openUrl(
|
||||
args: OpenWindowRequest & {
|
||||
onNavigate?: (args: { url: string }) => void;
|
||||
onClose?: () => void;
|
||||
},
|
||||
): Promise<{ close: () => void }>;
|
||||
openExternalUrl(url: string): Promise<void>;
|
||||
};
|
||||
cookies: {
|
||||
listNames(): Promise<ListCookieNamesResponse["names"]>;
|
||||
getValue(args: GetCookieValueRequest): Promise<GetCookieValueResponse["value"]>;
|
||||
};
|
||||
grpcRequest: {
|
||||
render(args: RenderGrpcRequestRequest): Promise<RenderGrpcRequestResponse["grpcRequest"]>;
|
||||
};
|
||||
httpRequest: {
|
||||
/**
|
||||
* Send a request and wait for the response and its body.
|
||||
*
|
||||
* The body comes back with the response because a request with no id is
|
||||
* never saved, and there would be nothing to look up afterwards.
|
||||
*/
|
||||
send(args: SendHttpRequestRequest): Promise<SentHttpRequest>;
|
||||
getById(args: GetHttpRequestByIdRequest): Promise<GetHttpRequestByIdResponse["httpRequest"]>;
|
||||
render(args: RenderHttpRequestRequest): Promise<RenderHttpRequestResponse["httpRequest"]>;
|
||||
list(args?: ListHttpRequestsRequest): Promise<ListHttpRequestsResponse["httpRequests"]>;
|
||||
create(
|
||||
args: Omit<Partial<HttpRequest>, "id" | "model" | "createdAt" | "updatedAt"> &
|
||||
Pick<HttpRequest, "workspaceId" | "url">,
|
||||
): Promise<HttpRequest>;
|
||||
update(
|
||||
args: Omit<Partial<HttpRequest>, "model" | "createdAt" | "updatedAt"> &
|
||||
Pick<HttpRequest, "id">,
|
||||
): Promise<HttpRequest>;
|
||||
delete(args: { id: string }): Promise<HttpRequest>;
|
||||
};
|
||||
folder: {
|
||||
list(args?: ListFoldersRequest): Promise<ListFoldersResponse["folders"]>;
|
||||
getById(args: { id: string }): Promise<Folder | null>;
|
||||
create(
|
||||
args: Omit<Partial<Folder>, "id" | "model" | "createdAt" | "updatedAt"> &
|
||||
Pick<Folder, "workspaceId" | "name">,
|
||||
): Promise<Folder>;
|
||||
update(
|
||||
args: Omit<Partial<Folder>, "model" | "createdAt" | "updatedAt"> & Pick<Folder, "id">,
|
||||
): Promise<Folder>;
|
||||
delete(args: { id: string }): Promise<Folder>;
|
||||
};
|
||||
httpResponse: {
|
||||
find(args: FindHttpResponsesRequest): Promise<FindHttpResponsesResponse["httpResponses"]>;
|
||||
/**
|
||||
* Read a saved response's body by id. Where the host keeps the bytes —
|
||||
* files on a desktop, rows in a database, somewhere else later — is not
|
||||
* something a plugin sees or should depend on.
|
||||
*
|
||||
* Ids come from `find`. A response that was never saved has none to look
|
||||
* up, so its body arrives with the send that made it instead.
|
||||
*/
|
||||
body(args: GetHttpResponseBodyInfoRequest): Promise<HttpResponseBody>;
|
||||
};
|
||||
templates: {
|
||||
render<T extends JsonValue>(args: TemplateRenderRequest & { data: T }): Promise<T>;
|
||||
};
|
||||
plugin: {
|
||||
reload(): void;
|
||||
};
|
||||
workspace: {
|
||||
list(): Promise<WorkspaceHandle[]>;
|
||||
withContext(handle: WorkspaceHandle): Context;
|
||||
};
|
||||
}
|
||||
11
packages/plugin-runtime-types/src/plugins/FilterPlugin.ts
Normal file
11
packages/plugin-runtime-types/src/plugins/FilterPlugin.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { FilterResponse } from "../bindings/gen_events";
|
||||
import type { Context } from "./Context";
|
||||
|
||||
export type FilterPlugin = {
|
||||
name: string;
|
||||
description?: string;
|
||||
onFilter(
|
||||
ctx: Context,
|
||||
args: { payload: string; filter: string; mimeType: string },
|
||||
): Promise<FilterResponse> | FilterResponse;
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { CallFolderActionArgs, FolderAction } from "../bindings/gen_events";
|
||||
import type { Context } from "./Context";
|
||||
|
||||
export type FolderActionPlugin = FolderAction & {
|
||||
onSelect(ctx: Context, args: CallFolderActionArgs): Promise<void> | void;
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { CallGrpcRequestActionArgs, GrpcRequestAction } from "../bindings/gen_events";
|
||||
import type { Context } from "./Context";
|
||||
|
||||
export type GrpcRequestActionPlugin = GrpcRequestAction & {
|
||||
onSelect(ctx: Context, args: CallGrpcRequestActionArgs): Promise<void> | void;
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { CallHttpRequestActionArgs, HttpRequestAction } from "../bindings/gen_events";
|
||||
import type { Context } from "./Context";
|
||||
|
||||
export type HttpRequestActionPlugin = HttpRequestAction & {
|
||||
onSelect(ctx: Context, args: CallHttpRequestActionArgs): Promise<void> | void;
|
||||
};
|
||||
31
packages/plugin-runtime-types/src/plugins/ImporterPlugin.ts
Normal file
31
packages/plugin-runtime-types/src/plugins/ImporterPlugin.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import type { ImportResources, ImportResponse } from "../bindings/gen_events";
|
||||
import type { AtLeast, MaybePromise } from "../helpers";
|
||||
import type { Context } from "./Context";
|
||||
|
||||
type RootFields = "name" | "id" | "model";
|
||||
type CommonFields = RootFields | "workspaceId";
|
||||
|
||||
export type PartialImportResources = {
|
||||
workspaces: Array<AtLeast<ImportResources["workspaces"][0], RootFields>>;
|
||||
environments: Array<AtLeast<ImportResources["environments"][0], CommonFields>>;
|
||||
folders: Array<AtLeast<ImportResources["folders"][0], CommonFields>>;
|
||||
httpRequests: Array<AtLeast<ImportResources["httpRequests"][0], CommonFields>>;
|
||||
grpcRequests: Array<AtLeast<ImportResources["grpcRequests"][0], CommonFields>>;
|
||||
websocketRequests: Array<AtLeast<ImportResources["websocketRequests"][0], CommonFields>>;
|
||||
};
|
||||
|
||||
/** `importer` is omitted because the host fills it in from the plugin's own name. */
|
||||
export type ImportPluginResponse =
|
||||
| null
|
||||
| (Omit<ImportResponse, "importer" | "resources"> & {
|
||||
resources: PartialImportResources;
|
||||
});
|
||||
|
||||
export type ImporterPlugin = {
|
||||
name: string;
|
||||
description?: string;
|
||||
onImport(
|
||||
ctx: Context,
|
||||
args: { text: string },
|
||||
): MaybePromise<ImportPluginResponse | null | undefined>;
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { CallTemplateFunctionArgs, FormInput, TemplateFunction } from "../bindings/gen_events";
|
||||
import type { MaybePromise } from "../helpers";
|
||||
import type { Context } from "./Context";
|
||||
|
||||
type AddDynamicMethod<T> = {
|
||||
dynamic?: (
|
||||
ctx: Context,
|
||||
args: CallTemplateFunctionArgs,
|
||||
) => MaybePromise<Partial<T> | null | undefined>;
|
||||
};
|
||||
|
||||
// oxlint-disable-next-line no-explicit-any -- distributive conditional type pattern
|
||||
type AddDynamic<T> = T extends any
|
||||
? T extends { inputs?: FormInput[] }
|
||||
? Omit<T, "inputs"> & {
|
||||
inputs: Array<AddDynamic<FormInput>>;
|
||||
dynamic?: (
|
||||
ctx: Context,
|
||||
args: CallTemplateFunctionArgs,
|
||||
) => MaybePromise<
|
||||
Partial<Omit<T, "inputs"> & { inputs: Array<AddDynamic<FormInput>> }> | null | undefined
|
||||
>;
|
||||
}
|
||||
: T & AddDynamicMethod<T>
|
||||
: never;
|
||||
|
||||
export type DynamicTemplateFunctionArg = AddDynamic<FormInput>;
|
||||
|
||||
export type TemplateFunctionPlugin = Omit<TemplateFunction, "args"> & {
|
||||
args: DynamicTemplateFunctionArg[];
|
||||
onRender(ctx: Context, args: CallTemplateFunctionArgs): Promise<string | null>;
|
||||
};
|
||||
3
packages/plugin-runtime-types/src/plugins/ThemePlugin.ts
Normal file
3
packages/plugin-runtime-types/src/plugins/ThemePlugin.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import type { Theme } from "../bindings/gen_events";
|
||||
|
||||
export type ThemePlugin = Theme;
|
||||
@@ -0,0 +1,9 @@
|
||||
import type {
|
||||
CallWebsocketRequestActionArgs,
|
||||
WebsocketRequestAction,
|
||||
} from "../bindings/gen_events";
|
||||
import type { Context } from "./Context";
|
||||
|
||||
export type WebsocketRequestActionPlugin = WebsocketRequestAction & {
|
||||
onSelect(ctx: Context, args: CallWebsocketRequestActionArgs): Promise<void> | void;
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { CallWorkspaceActionArgs, WorkspaceAction } from "../bindings/gen_events";
|
||||
import type { Context } from "./Context";
|
||||
|
||||
export type WorkspaceActionPlugin = WorkspaceAction & {
|
||||
onSelect(ctx: Context, args: CallWorkspaceActionArgs): Promise<void> | void;
|
||||
};
|
||||
44
packages/plugin-runtime-types/src/plugins/index.ts
Normal file
44
packages/plugin-runtime-types/src/plugins/index.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import type { AuthenticationPlugin } from "./AuthenticationPlugin";
|
||||
|
||||
import type { Context } from "./Context";
|
||||
import type { FilterPlugin } from "./FilterPlugin";
|
||||
import type { FolderActionPlugin } from "./FolderActionPlugin";
|
||||
import type { GrpcRequestActionPlugin } from "./GrpcRequestActionPlugin";
|
||||
import type { HttpRequestActionPlugin } from "./HttpRequestActionPlugin";
|
||||
import type { ImporterPlugin } from "./ImporterPlugin";
|
||||
import type { TemplateFunctionPlugin } from "./TemplateFunctionPlugin";
|
||||
import type { ThemePlugin } from "./ThemePlugin";
|
||||
import type { WebsocketRequestActionPlugin } from "./WebsocketRequestActionPlugin";
|
||||
import type { WorkspaceActionPlugin } from "./WorkspaceActionPlugin";
|
||||
|
||||
export type { Context };
|
||||
export type { DynamicAuthenticationArg } from "./AuthenticationPlugin";
|
||||
export type {
|
||||
CallPromptFormDynamicArgs,
|
||||
DynamicPromptFormArg,
|
||||
HttpResponseBody,
|
||||
ReadHttpResponseBodyOptions,
|
||||
SentHttpRequest,
|
||||
} from "./Context";
|
||||
export type { DynamicTemplateFunctionArg } from "./TemplateFunctionPlugin";
|
||||
export type { TemplateFunctionPlugin };
|
||||
export type { FolderActionPlugin } from "./FolderActionPlugin";
|
||||
export type { WorkspaceActionPlugin } from "./WorkspaceActionPlugin";
|
||||
|
||||
/**
|
||||
* The global structure of a Yaak plugin
|
||||
*/
|
||||
export type PluginDefinition = {
|
||||
init?: (ctx: Context) => void | Promise<void>;
|
||||
dispose?: () => void | Promise<void>;
|
||||
importer?: ImporterPlugin;
|
||||
themes?: ThemePlugin[];
|
||||
filter?: FilterPlugin;
|
||||
authentication?: AuthenticationPlugin;
|
||||
httpRequestActions?: HttpRequestActionPlugin[];
|
||||
websocketRequestActions?: WebsocketRequestActionPlugin[];
|
||||
workspaceActions?: WorkspaceActionPlugin[];
|
||||
folderActions?: FolderActionPlugin[];
|
||||
grpcRequestActions?: GrpcRequestActionPlugin[];
|
||||
templateFunctions?: TemplateFunctionPlugin[];
|
||||
};
|
||||
44
packages/plugin-runtime-types/src/themes/index.ts
Normal file
44
packages/plugin-runtime-types/src/themes/index.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
export type Colors = {
|
||||
surface: string;
|
||||
surfaceHighlight?: string;
|
||||
surfaceActive?: string;
|
||||
|
||||
text: string;
|
||||
textSubtle?: string;
|
||||
textSubtlest?: string;
|
||||
|
||||
border?: string;
|
||||
borderSubtle?: string;
|
||||
borderFocus?: string;
|
||||
|
||||
shadow?: string;
|
||||
backdrop?: string;
|
||||
selection?: string;
|
||||
|
||||
primary?: string;
|
||||
secondary?: string;
|
||||
info?: string;
|
||||
success?: string;
|
||||
notice?: string;
|
||||
warning?: string;
|
||||
danger?: string;
|
||||
};
|
||||
|
||||
export type Index = Colors & {
|
||||
id: string;
|
||||
name: string;
|
||||
components?: Partial<{
|
||||
dialog: Partial<Colors>;
|
||||
menu: Partial<Colors>;
|
||||
toast: Partial<Colors>;
|
||||
sidebar: Partial<Colors>;
|
||||
responsePane: Partial<Colors>;
|
||||
appHeader: Partial<Colors>;
|
||||
button: Partial<Colors>;
|
||||
banner: Partial<Colors>;
|
||||
placeholder: Partial<Colors>;
|
||||
urlBar: Partial<Colors>;
|
||||
editor: Partial<Colors>;
|
||||
input: Partial<Colors>;
|
||||
}>;
|
||||
};
|
||||
14
packages/plugin-runtime-types/tsconfig.json
Normal file
14
packages/plugin-runtime-types/tsconfig.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "node16",
|
||||
"target": "es6",
|
||||
"lib": ["es2021", "dom"],
|
||||
"declaration": true,
|
||||
"declarationDir": "./lib",
|
||||
"rootDir": "./src",
|
||||
"outDir": "./lib",
|
||||
"strict": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"files": ["src/index.ts"]
|
||||
}
|
||||
1
packages/plugin-runtime/.node-version
Normal file
1
packages/plugin-runtime/.node-version
Normal file
@@ -0,0 +1 @@
|
||||
24.11.1
|
||||
16
packages/plugin-runtime/package.json
Normal file
16
packages/plugin-runtime/package.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "@yaakapp-internal/plugin-runtime",
|
||||
"scripts": {
|
||||
"bootstrap": "npm run build",
|
||||
"build": "run-p build:*",
|
||||
"build:main": "esbuild src/index.ts --bundle --platform=node --outfile=../../crates-tauri/yaak-app-client/vendored/plugin-runtime/index.cjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"ws": "^8.21.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.0.13",
|
||||
"@types/ws": "^8.5.13",
|
||||
"esbuild": "^0.28.0"
|
||||
}
|
||||
}
|
||||
19
packages/plugin-runtime/src/EventChannel.ts
Normal file
19
packages/plugin-runtime/src/EventChannel.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import type { InternalEvent } from "@yaakapp/api";
|
||||
|
||||
export class EventChannel {
|
||||
#listeners = new Set<(event: InternalEvent) => void>();
|
||||
|
||||
emit(e: InternalEvent) {
|
||||
for (const l of this.#listeners) {
|
||||
l(e);
|
||||
}
|
||||
}
|
||||
|
||||
listen(cb: (e: InternalEvent) => void) {
|
||||
this.#listeners.add(cb);
|
||||
}
|
||||
|
||||
unlisten(cb: (e: InternalEvent) => void) {
|
||||
this.#listeners.delete(cb);
|
||||
}
|
||||
}
|
||||
26
packages/plugin-runtime/src/PluginHandle.ts
Normal file
26
packages/plugin-runtime/src/PluginHandle.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import type { BootRequest, InternalEvent } from "@yaakapp/api";
|
||||
import type { PluginContext } from "@yaakapp-internal/plugins";
|
||||
import type { EventChannel } from "./EventChannel";
|
||||
import { PluginInstance, type PluginWorkerData } from "./PluginInstance";
|
||||
|
||||
export class PluginHandle {
|
||||
#instance: PluginInstance;
|
||||
|
||||
constructor(
|
||||
pluginRefId: string,
|
||||
context: PluginContext,
|
||||
bootRequest: BootRequest,
|
||||
pluginToAppEvents: EventChannel,
|
||||
) {
|
||||
const workerData: PluginWorkerData = { pluginRefId, context, bootRequest };
|
||||
this.#instance = new PluginInstance(workerData, pluginToAppEvents);
|
||||
}
|
||||
|
||||
sendToWorker(event: InternalEvent) {
|
||||
this.#instance.postMessage(event);
|
||||
}
|
||||
|
||||
async terminate() {
|
||||
await this.#instance.terminate();
|
||||
}
|
||||
}
|
||||
1099
packages/plugin-runtime/src/PluginInstance.ts
Normal file
1099
packages/plugin-runtime/src/PluginInstance.ts
Normal file
File diff suppressed because it is too large
Load Diff
75
packages/plugin-runtime/src/common.ts
Normal file
75
packages/plugin-runtime/src/common.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import type {
|
||||
CallPromptFormDynamicArgs,
|
||||
Context,
|
||||
DynamicAuthenticationArg,
|
||||
DynamicPromptFormArg,
|
||||
DynamicTemplateFunctionArg,
|
||||
} from "@yaakapp/api";
|
||||
import type {
|
||||
CallHttpAuthenticationActionArgs,
|
||||
CallTemplateFunctionArgs,
|
||||
} from "@yaakapp-internal/plugins";
|
||||
|
||||
type AnyDynamicArg = DynamicTemplateFunctionArg | DynamicAuthenticationArg | DynamicPromptFormArg;
|
||||
type AnyCallArgs =
|
||||
| CallTemplateFunctionArgs
|
||||
| CallHttpAuthenticationActionArgs
|
||||
| CallPromptFormDynamicArgs;
|
||||
|
||||
export async function applyDynamicFormInput(
|
||||
ctx: Context,
|
||||
args: DynamicTemplateFunctionArg[],
|
||||
callArgs: CallTemplateFunctionArgs,
|
||||
): Promise<DynamicTemplateFunctionArg[]>;
|
||||
|
||||
export async function applyDynamicFormInput(
|
||||
ctx: Context,
|
||||
args: DynamicAuthenticationArg[],
|
||||
callArgs: CallHttpAuthenticationActionArgs,
|
||||
): Promise<DynamicAuthenticationArg[]>;
|
||||
|
||||
export async function applyDynamicFormInput(
|
||||
ctx: Context,
|
||||
args: DynamicPromptFormArg[],
|
||||
callArgs: CallPromptFormDynamicArgs,
|
||||
): Promise<DynamicPromptFormArg[]>;
|
||||
|
||||
export async function applyDynamicFormInput(
|
||||
ctx: Context,
|
||||
args: AnyDynamicArg[],
|
||||
callArgs: AnyCallArgs,
|
||||
): Promise<AnyDynamicArg[]> {
|
||||
const resolvedArgs: AnyDynamicArg[] = [];
|
||||
for (const { dynamic, ...arg } of args) {
|
||||
const dynamicResult =
|
||||
typeof dynamic === "function"
|
||||
? await dynamic(
|
||||
ctx,
|
||||
callArgs as CallTemplateFunctionArgs &
|
||||
CallHttpAuthenticationActionArgs &
|
||||
CallPromptFormDynamicArgs,
|
||||
)
|
||||
: undefined;
|
||||
|
||||
const newArg = {
|
||||
...arg,
|
||||
...dynamicResult,
|
||||
} as AnyDynamicArg;
|
||||
|
||||
if ("inputs" in newArg && Array.isArray(newArg.inputs)) {
|
||||
try {
|
||||
newArg.inputs = await applyDynamicFormInput(
|
||||
ctx,
|
||||
newArg.inputs as DynamicTemplateFunctionArg[],
|
||||
callArgs as CallTemplateFunctionArgs &
|
||||
CallHttpAuthenticationActionArgs &
|
||||
CallPromptFormDynamicArgs,
|
||||
);
|
||||
} catch (e) {
|
||||
console.error("Failed to apply dynamic form input", e);
|
||||
}
|
||||
}
|
||||
resolvedArgs.push(newArg);
|
||||
}
|
||||
return resolvedArgs;
|
||||
}
|
||||
73
packages/plugin-runtime/src/index.ts
Normal file
73
packages/plugin-runtime/src/index.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import type { InternalEvent } from "@yaakapp/api";
|
||||
import WebSocket from "ws";
|
||||
import { EventChannel } from "./EventChannel";
|
||||
import { PluginHandle } from "./PluginHandle";
|
||||
|
||||
const port = process.env.PORT;
|
||||
if (!port) {
|
||||
throw new Error("Plugin runtime missing PORT");
|
||||
}
|
||||
|
||||
const host = process.env.HOST;
|
||||
if (!host) {
|
||||
throw new Error("Plugin runtime missing HOST");
|
||||
}
|
||||
|
||||
const pluginToAppEvents = new EventChannel();
|
||||
const plugins: Record<string, PluginHandle> = {};
|
||||
|
||||
const ws = new WebSocket(`ws://${host}:${port}`);
|
||||
|
||||
ws.on("message", async (e: Buffer) => {
|
||||
try {
|
||||
await handleIncoming(e.toString());
|
||||
} catch (err) {
|
||||
console.log("Failed to handle incoming plugin event", err);
|
||||
}
|
||||
});
|
||||
ws.on("open", () => console.log("Plugin runtime connected to websocket"));
|
||||
ws.on("error", (err: unknown) => console.error("Plugin runtime websocket error", err));
|
||||
ws.on("close", (code: number) => console.log("Plugin runtime websocket closed", code));
|
||||
|
||||
// Listen for incoming events from plugins
|
||||
pluginToAppEvents.listen((e) => {
|
||||
const eventStr = JSON.stringify(e);
|
||||
ws.send(eventStr);
|
||||
});
|
||||
|
||||
async function handleIncoming(msg: string) {
|
||||
const pluginEvent: InternalEvent = JSON.parse(msg);
|
||||
// Handle special event to bootstrap plugin
|
||||
if (pluginEvent.payload.type === "boot_request") {
|
||||
const plugin = new PluginHandle(
|
||||
pluginEvent.pluginRefId,
|
||||
pluginEvent.context,
|
||||
pluginEvent.payload,
|
||||
pluginToAppEvents,
|
||||
);
|
||||
plugins[pluginEvent.pluginRefId] = plugin;
|
||||
}
|
||||
|
||||
// Once booted, forward all events to the plugin worker
|
||||
const plugin = plugins[pluginEvent.pluginRefId];
|
||||
if (!plugin) {
|
||||
console.warn("Failed to get plugin for event by", pluginEvent.pluginRefId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (pluginEvent.payload.type === "terminate_request") {
|
||||
await plugin.terminate();
|
||||
console.log("Terminated plugin worker", pluginEvent.pluginRefId);
|
||||
delete plugins[pluginEvent.pluginRefId];
|
||||
}
|
||||
|
||||
plugin.sendToWorker(pluginEvent);
|
||||
}
|
||||
|
||||
process.on("unhandledRejection", (reason, promise) => {
|
||||
console.error("Unhandled Rejection at:", promise, "reason:", reason);
|
||||
});
|
||||
|
||||
process.on("uncaughtException", (error) => {
|
||||
console.error("Uncaught Exception:", error);
|
||||
});
|
||||
29
packages/plugin-runtime/src/interceptStdout.ts
Normal file
29
packages/plugin-runtime/src/interceptStdout.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
/* oxlint-disable unbound-method */
|
||||
import process from "node:process";
|
||||
|
||||
export function interceptStdout(intercept: (text: string) => string) {
|
||||
const old_stdout_write = process.stdout.write;
|
||||
const old_stderr_write = process.stderr.write;
|
||||
|
||||
process.stdout.write = ((write) =>
|
||||
((text: string, ...args: never[]) => {
|
||||
write.call(process.stdout, interceptor(text, intercept), ...args);
|
||||
return true;
|
||||
}) as typeof process.stdout.write)(process.stdout.write);
|
||||
|
||||
process.stderr.write = ((write) =>
|
||||
((text: string, ...args: never[]) => {
|
||||
write.call(process.stderr, interceptor(text, intercept), ...args);
|
||||
return true;
|
||||
}) as typeof process.stderr.write)(process.stderr.write);
|
||||
|
||||
// puts back to original
|
||||
return function unhook() {
|
||||
process.stdout.write = old_stdout_write;
|
||||
process.stderr.write = old_stderr_write;
|
||||
};
|
||||
}
|
||||
|
||||
function interceptor(text: string, fn: (text: string) => string) {
|
||||
return fn(text).replace(/\n$/, "") + (fn(text) && text.endsWith("\n") ? "\n" : "");
|
||||
}
|
||||
190
packages/plugin-runtime/src/metadata.ts
Normal file
190
packages/plugin-runtime/src/metadata.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
import fs from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import path from "node:path";
|
||||
import type { PluginDefinition } from "@yaakapp/api";
|
||||
|
||||
type PluginFeatureKey = Exclude<
|
||||
Extract<keyof PluginDefinition, string>,
|
||||
"init" | "dispose"
|
||||
>;
|
||||
type PluginAPIKey = PluginFeatureKey | "lifecycle";
|
||||
|
||||
type MetadataDefinition = {
|
||||
key: PluginFeatureKey;
|
||||
label: string;
|
||||
array: boolean;
|
||||
};
|
||||
|
||||
type MetadataItem =
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| null
|
||||
| MetadataItem[]
|
||||
| { [key: string]: MetadataItem };
|
||||
|
||||
type APITypeMetadata = {
|
||||
label: string;
|
||||
source: string;
|
||||
count: number;
|
||||
items: MetadataItem[];
|
||||
};
|
||||
|
||||
type PluginMetadata = {
|
||||
schemaVersion: 1;
|
||||
apiTypes: PluginAPIKey[];
|
||||
apis: Partial<Record<PluginAPIKey, APITypeMetadata>>;
|
||||
};
|
||||
|
||||
const definitions: MetadataDefinition[] = [
|
||||
{
|
||||
key: "authentication",
|
||||
label: "Authentication",
|
||||
array: false,
|
||||
},
|
||||
{ key: "filter", label: "Filter", array: false },
|
||||
{
|
||||
key: "folderActions",
|
||||
label: "Folder Action",
|
||||
array: true,
|
||||
},
|
||||
{
|
||||
key: "grpcRequestActions",
|
||||
label: "gRPC Request Action",
|
||||
array: true,
|
||||
},
|
||||
{
|
||||
key: "httpRequestActions",
|
||||
label: "HTTP Request Action",
|
||||
array: true,
|
||||
},
|
||||
{ key: "importer", label: "Importer", array: false },
|
||||
{
|
||||
key: "templateFunctions",
|
||||
label: "Template Tag",
|
||||
array: true,
|
||||
},
|
||||
{ key: "themes", label: "Theme", array: true },
|
||||
{
|
||||
key: "websocketRequestActions",
|
||||
label: "WebSocket Request Action",
|
||||
array: true,
|
||||
},
|
||||
{
|
||||
key: "workspaceActions",
|
||||
label: "Workspace Action",
|
||||
array: true,
|
||||
},
|
||||
];
|
||||
|
||||
export function generatePluginMetadata(
|
||||
plugin: PluginDefinition,
|
||||
): PluginMetadata {
|
||||
const metadata: PluginMetadata = {
|
||||
schemaVersion: 1,
|
||||
apiTypes: [],
|
||||
apis: {},
|
||||
};
|
||||
|
||||
for (const definition of definitions) {
|
||||
const value = plugin[definition.key];
|
||||
const items = definition.array ? value : value ? [value] : [];
|
||||
|
||||
if (!Array.isArray(items) || items.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
metadata.apiTypes.push(definition.key);
|
||||
metadata.apis[definition.key] = {
|
||||
label: definition.label,
|
||||
source: definition.key,
|
||||
count: items.length,
|
||||
items: sanitize(items) as MetadataItem[],
|
||||
};
|
||||
}
|
||||
|
||||
const lifecycleHooks = ["init", "dispose"].filter(
|
||||
(key) =>
|
||||
typeof plugin[key as keyof Pick<PluginDefinition, "init" | "dispose">] ===
|
||||
"function",
|
||||
);
|
||||
if (lifecycleHooks.length > 0) {
|
||||
metadata.apiTypes.push("lifecycle");
|
||||
metadata.apis.lifecycle = {
|
||||
label: "Lifecycle Hook",
|
||||
source: lifecycleHooks.join(","),
|
||||
count: lifecycleHooks.length,
|
||||
items: lifecycleHooks.map((name) => ({ name })),
|
||||
};
|
||||
}
|
||||
|
||||
return metadata;
|
||||
}
|
||||
|
||||
const entryPath = process.argv[1];
|
||||
const outputPath = process.argv[2];
|
||||
|
||||
if (!entryPath) {
|
||||
throw new Error("Missing plugin entrypoint path");
|
||||
}
|
||||
if (!outputPath) {
|
||||
throw new Error("Missing plugin metadata output path");
|
||||
}
|
||||
|
||||
const require = createRequire(path.join(process.cwd(), "plugin-metadata.js"));
|
||||
const moduleExports = require(path.resolve(entryPath)) as PluginDefinition & {
|
||||
plugin?: PluginDefinition;
|
||||
default?: PluginDefinition;
|
||||
};
|
||||
const plugin = moduleExports.plugin ?? moduleExports.default ?? moduleExports;
|
||||
|
||||
if (!plugin || typeof plugin !== "object") {
|
||||
throw new Error("Plugin entrypoint must export a plugin object");
|
||||
}
|
||||
|
||||
const metadata = generatePluginMetadata(plugin);
|
||||
fs.writeFileSync(outputPath, `${JSON.stringify(metadata, null, 2)}\n`);
|
||||
|
||||
function sanitize(
|
||||
value: unknown,
|
||||
seen = new WeakSet<object>(),
|
||||
): MetadataItem | undefined {
|
||||
if (value === null) return null;
|
||||
|
||||
switch (typeof value) {
|
||||
case "boolean":
|
||||
case "number":
|
||||
case "string":
|
||||
return value;
|
||||
case "bigint":
|
||||
return value.toString();
|
||||
case "function":
|
||||
case "symbol":
|
||||
case "undefined":
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const objectValue = value as object;
|
||||
if (seen.has(objectValue)) {
|
||||
return "[Circular]";
|
||||
}
|
||||
|
||||
seen.add(objectValue);
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
const output = value.map((item) => sanitize(item, seen) ?? null);
|
||||
seen.delete(objectValue);
|
||||
return output;
|
||||
}
|
||||
|
||||
const output: Record<string, MetadataItem> = {};
|
||||
for (const [key, item] of Object.entries(objectValue)) {
|
||||
const sanitized = sanitize(item, seen);
|
||||
if (sanitized !== undefined) {
|
||||
output[key] = sanitized;
|
||||
}
|
||||
}
|
||||
|
||||
seen.delete(objectValue);
|
||||
return output;
|
||||
}
|
||||
22
packages/plugin-runtime/src/migrations.ts
Normal file
22
packages/plugin-runtime/src/migrations.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import type { TemplateFunctionPlugin } from "@yaakapp/api";
|
||||
|
||||
export function migrateTemplateFunctionSelectOptions(
|
||||
f: TemplateFunctionPlugin,
|
||||
): TemplateFunctionPlugin {
|
||||
const migratedArgs = f.args.map((a) => {
|
||||
if (a.type === "select") {
|
||||
// Migrate old options that had 'name' instead of 'label'
|
||||
type LegacyOption = { label?: string; value: string; name?: string };
|
||||
a.options = a.options.map((o) => {
|
||||
const legacy = o as LegacyOption;
|
||||
return {
|
||||
label: legacy.label ?? legacy.name ?? "",
|
||||
value: legacy.value,
|
||||
};
|
||||
});
|
||||
}
|
||||
return a;
|
||||
});
|
||||
|
||||
return { ...f, args: migratedArgs };
|
||||
}
|
||||
194
packages/plugin-runtime/src/responseBody.ts
Normal file
194
packages/plugin-runtime/src/responseBody.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
import type { HttpResponseBody, ReadHttpResponseBodyOptions } from "@yaakapp/api";
|
||||
|
||||
/** Bytes pulled from the host per round trip, when the caller doesn't say. */
|
||||
const DEFAULT_CHUNK_SIZE = 1024 * 1024;
|
||||
|
||||
/**
|
||||
* The most a plugin buffers by default.
|
||||
*
|
||||
* Reading a body used to be unbounded, so any ceiling is an improvement; this
|
||||
* one is set well above what an API returns and well below what makes the
|
||||
* plugin runtime fall over. `chunks()` has no ceiling, and any caller that
|
||||
* really wants the whole thing can raise `maxBytes`.
|
||||
*/
|
||||
const DEFAULT_MAX_BYTES = 32 * 1024 * 1024;
|
||||
|
||||
/** How long to wait, having caught up with a body still arriving, before looking again. */
|
||||
const DEFAULT_POLL_INTERVAL_MS = 100;
|
||||
|
||||
/** Fetch one window of body bytes from the host. */
|
||||
export type ReadResponseBodyChunk = (offset: number, length: number) => Promise<Uint8Array>;
|
||||
|
||||
export interface ResponseBodyInfo {
|
||||
responseId: string;
|
||||
contentLength: number;
|
||||
contentType: string | null;
|
||||
/** Whether the response has finished arriving, so `contentLength` is final. */
|
||||
complete: boolean;
|
||||
}
|
||||
|
||||
/** What can change while a body is still arriving. */
|
||||
export type ResponseBodyProgress = Pick<ResponseBodyInfo, "contentLength" | "complete">;
|
||||
|
||||
export interface CreateResponseBodyOptions {
|
||||
/**
|
||||
* Ask the host where the body has got to. Needed only for a body that was
|
||||
* not complete when opened; a reader that has caught up calls this to learn
|
||||
* whether to wait for more or stop.
|
||||
*/
|
||||
refresh?: () => Promise<ResponseBodyProgress>;
|
||||
pollIntervalMs?: number;
|
||||
}
|
||||
|
||||
export function createResponseBody(
|
||||
info: ResponseBodyInfo,
|
||||
readChunk: ReadResponseBodyChunk,
|
||||
{ refresh, pollIntervalMs = DEFAULT_POLL_INTERVAL_MS }: CreateResponseBodyOptions = {},
|
||||
): HttpResponseBody {
|
||||
const { responseId, contentLength, contentType, complete } = info;
|
||||
|
||||
/**
|
||||
* Yield the body from the start until it has all arrived.
|
||||
*
|
||||
* A complete body is read up to its known length and no further. One still
|
||||
* arriving is followed: on catching up, ask the host whether it has finished,
|
||||
* and if not, wait and look again. So this ends when the response does —
|
||||
* which for a stream that never closes means it doesn't, exactly as
|
||||
* iterating `fetch`'s body would not.
|
||||
*/
|
||||
async function* chunks(
|
||||
options?: Pick<ReadHttpResponseBodyOptions, "chunkSize">,
|
||||
): AsyncIterable<Uint8Array> {
|
||||
const chunkSize = Math.max(1, Math.floor(options?.chunkSize ?? DEFAULT_CHUNK_SIZE));
|
||||
let known = contentLength;
|
||||
let done = complete;
|
||||
let offset = 0;
|
||||
|
||||
while (true) {
|
||||
if (done && offset >= known) return;
|
||||
|
||||
const want = done ? Math.min(chunkSize, known - offset) : chunkSize;
|
||||
const chunk = await readChunk(offset, want);
|
||||
if (chunk.byteLength > 0) {
|
||||
yield chunk;
|
||||
offset += chunk.byteLength;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Caught up. A complete body that came up short simply ended sooner than
|
||||
// the host said; one still arriving needs asking about.
|
||||
if (done || refresh == null) return;
|
||||
({ contentLength: known, complete: done } = await refresh());
|
||||
if (offset < known) continue;
|
||||
if (done) return;
|
||||
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
|
||||
}
|
||||
}
|
||||
|
||||
async function readAll(accessor: string, options?: ReadHttpResponseBodyOptions) {
|
||||
const maxBytes = options?.maxBytes ?? DEFAULT_MAX_BYTES;
|
||||
refuseIfTooBig(accessor, contentLength, maxBytes);
|
||||
|
||||
const parts: Uint8Array[] = [];
|
||||
let total = 0;
|
||||
for await (const chunk of chunks(options)) {
|
||||
total += chunk.byteLength;
|
||||
// The size the host reported is a claim about a moment ago, so check the
|
||||
// bytes actually arriving too.
|
||||
refuseIfTooBig(accessor, total, maxBytes);
|
||||
parts.push(chunk);
|
||||
}
|
||||
|
||||
const bytes = new Uint8Array(total);
|
||||
let offset = 0;
|
||||
for (const part of parts) {
|
||||
bytes.set(part, offset);
|
||||
offset += part.byteLength;
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
return {
|
||||
responseId,
|
||||
contentLength,
|
||||
contentType,
|
||||
complete,
|
||||
chunks,
|
||||
async arrayBuffer(options) {
|
||||
const bytes = await readAll("arrayBuffer", options);
|
||||
return bytes.buffer as ArrayBuffer;
|
||||
},
|
||||
async text(options) {
|
||||
return decodeBody(await readAll("text", options), contentType);
|
||||
},
|
||||
async json<T>(options?: ReadHttpResponseBodyOptions) {
|
||||
return JSON.parse(decodeBody(await readAll("json", options), contentType)) as T;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function refuseIfTooBig(accessor: string, bytes: number, maxBytes: number) {
|
||||
if (bytes <= maxBytes) return;
|
||||
throw new Error(
|
||||
`Response body is ${formatBytes(bytes)}, over the ${formatBytes(maxBytes)} limit for ` +
|
||||
`${accessor}(). Read it with chunks() instead, or pass a larger maxBytes.`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode using the charset the response declared.
|
||||
*
|
||||
* Assuming UTF-8 mangles every response that isn't, and the header is right
|
||||
* there. An unknown label is the one case worth guessing on, since the
|
||||
* alternative is refusing to read a body we can very likely still read.
|
||||
*/
|
||||
function decodeBody(bytes: Uint8Array, contentType: string | null): string {
|
||||
const charset = parseCharset(contentType);
|
||||
if (charset != null) {
|
||||
try {
|
||||
return new TextDecoder(charset).decode(bytes);
|
||||
} catch {
|
||||
// Not a label this runtime knows.
|
||||
}
|
||||
}
|
||||
// TextDecoder drops a leading BOM on its own.
|
||||
return new TextDecoder("utf-8").decode(bytes);
|
||||
}
|
||||
|
||||
function parseCharset(contentType: string | null): string | null {
|
||||
const match = contentType?.match(/;\s*charset\s*=\s*"?([^";]+)"?/i);
|
||||
return match?.[1]?.trim() || null;
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes === Infinity) return "unlimited";
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
const units = ["KB", "MB", "GB"];
|
||||
let value = bytes / 1024;
|
||||
let unit = 0;
|
||||
while (value >= 1024 && unit < units.length - 1) {
|
||||
value /= 1024;
|
||||
unit++;
|
||||
}
|
||||
return `${value.toFixed(1)} ${units[unit]}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a chunk that arrived as base64.
|
||||
*
|
||||
* The desktop transport is a WebSocket carrying JSON text frames, so bytes
|
||||
* have to be spelled out. A host that can pass an ArrayBuffer along skips this.
|
||||
*/
|
||||
export function decodeBase64Chunk(data: string): Uint8Array {
|
||||
if (typeof Buffer !== "undefined") {
|
||||
const buf = Buffer.from(data, "base64");
|
||||
return new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength);
|
||||
}
|
||||
|
||||
const binary = atob(data);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) {
|
||||
bytes[i] = binary.charCodeAt(i);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
151
packages/plugin-runtime/tests/common.test.ts
Normal file
151
packages/plugin-runtime/tests/common.test.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import { applyFormInputDefaults } from "@yaakapp-internal/lib/templateFunction";
|
||||
import type { CallTemplateFunctionArgs } from "@yaakapp-internal/plugins";
|
||||
import type { Context, DynamicTemplateFunctionArg } from "@yaakapp/api";
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import { applyDynamicFormInput } from "../src/common";
|
||||
|
||||
describe("applyFormInputDefaults", () => {
|
||||
test("Works with top-level select", () => {
|
||||
const args: DynamicTemplateFunctionArg[] = [
|
||||
{
|
||||
type: "select",
|
||||
name: "test",
|
||||
options: [{ label: "Option 1", value: "one" }],
|
||||
defaultValue: "one",
|
||||
},
|
||||
];
|
||||
expect(applyFormInputDefaults(args, {})).toEqual({
|
||||
test: "one",
|
||||
});
|
||||
});
|
||||
|
||||
test("Works with existing value", () => {
|
||||
const args: DynamicTemplateFunctionArg[] = [
|
||||
{
|
||||
type: "select",
|
||||
name: "test",
|
||||
options: [{ label: "Option 1", value: "one" }],
|
||||
defaultValue: "one",
|
||||
},
|
||||
];
|
||||
expect(applyFormInputDefaults(args, { test: "explicit" })).toEqual({
|
||||
test: "explicit",
|
||||
});
|
||||
});
|
||||
|
||||
test("Works with recursive select", () => {
|
||||
const args: DynamicTemplateFunctionArg[] = [
|
||||
{ type: "text", name: "dummy", defaultValue: "top" },
|
||||
{
|
||||
type: "accordion",
|
||||
label: "Test",
|
||||
inputs: [
|
||||
{ type: "text", name: "name", defaultValue: "hello" },
|
||||
{
|
||||
type: "select",
|
||||
name: "test",
|
||||
options: [{ label: "Option 1", value: "one" }],
|
||||
defaultValue: "one",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
expect(applyFormInputDefaults(args, {})).toEqual({
|
||||
dummy: "top",
|
||||
test: "one",
|
||||
name: "hello",
|
||||
});
|
||||
});
|
||||
|
||||
test("Works with dynamic options", () => {
|
||||
const args: DynamicTemplateFunctionArg[] = [
|
||||
{
|
||||
type: "select",
|
||||
name: "test",
|
||||
defaultValue: "one",
|
||||
options: [],
|
||||
dynamic() {
|
||||
return { options: [{ label: "Option 1", value: "one" }] };
|
||||
},
|
||||
},
|
||||
];
|
||||
expect(applyFormInputDefaults(args, {})).toEqual({
|
||||
test: "one",
|
||||
});
|
||||
expect(applyFormInputDefaults(args, {})).toEqual({
|
||||
test: "one",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyDynamicFormInput", () => {
|
||||
test("Works with plain input", async () => {
|
||||
const ctx = {} as Context;
|
||||
const args: DynamicTemplateFunctionArg[] = [
|
||||
{ type: "text", name: "name" },
|
||||
{ type: "checkbox", name: "checked" },
|
||||
];
|
||||
const callArgs: CallTemplateFunctionArgs = {
|
||||
values: {},
|
||||
purpose: "preview",
|
||||
};
|
||||
expect(await applyDynamicFormInput(ctx, args, callArgs)).toEqual([
|
||||
{ type: "text", name: "name" },
|
||||
{ type: "checkbox", name: "checked" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("Works with dynamic input", async () => {
|
||||
const ctx = {} as Context;
|
||||
const args: DynamicTemplateFunctionArg[] = [
|
||||
{
|
||||
type: "text",
|
||||
name: "name",
|
||||
async dynamic(_ctx, _args) {
|
||||
return { hidden: true };
|
||||
},
|
||||
},
|
||||
];
|
||||
const callArgs: CallTemplateFunctionArgs = {
|
||||
values: {},
|
||||
purpose: "preview",
|
||||
};
|
||||
expect(await applyDynamicFormInput(ctx, args, callArgs)).toEqual([
|
||||
{ type: "text", name: "name", hidden: true },
|
||||
]);
|
||||
});
|
||||
|
||||
test("Works with recursive dynamic input", async () => {
|
||||
const ctx = {} as Context;
|
||||
const callArgs: CallTemplateFunctionArgs = {
|
||||
values: { hello: "world" },
|
||||
purpose: "preview",
|
||||
};
|
||||
const args: DynamicTemplateFunctionArg[] = [
|
||||
{
|
||||
type: "banner",
|
||||
inputs: [
|
||||
{
|
||||
type: "text",
|
||||
name: "name",
|
||||
async dynamic(_ctx, args) {
|
||||
return { hidden: args.values.hello === "world" };
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
expect(await applyDynamicFormInput(ctx, args, callArgs)).toEqual([
|
||||
{
|
||||
type: "banner",
|
||||
inputs: [
|
||||
{
|
||||
type: "text",
|
||||
name: "name",
|
||||
hidden: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
203
packages/plugin-runtime/tests/responseBody.test.ts
Normal file
203
packages/plugin-runtime/tests/responseBody.test.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import { createResponseBody, decodeBase64Chunk } from "../src/responseBody";
|
||||
|
||||
/** A finished body, in a store that records every window it was asked for. */
|
||||
function fakeBody(bytes: Uint8Array, contentType: string | null) {
|
||||
const reads: Array<[number, number]> = [];
|
||||
const body = createResponseBody(
|
||||
{ responseId: "rs_test", contentLength: bytes.byteLength, contentType, complete: true },
|
||||
async (offset, length) => {
|
||||
reads.push([offset, length]);
|
||||
return bytes.slice(offset, offset + length);
|
||||
},
|
||||
);
|
||||
return { body, reads };
|
||||
}
|
||||
|
||||
/**
|
||||
* A body still arriving: it grows by one script step every time the reader
|
||||
* asks the host where it has got to, and completes on the last step.
|
||||
*/
|
||||
function streamingBody(steps: string[], contentType = "text/plain") {
|
||||
let stored = new Uint8Array();
|
||||
let step = 0;
|
||||
let refreshes = 0;
|
||||
const advance = () => {
|
||||
if (step < steps.length) {
|
||||
const next = utf8(steps[step]!);
|
||||
const grown = new Uint8Array(stored.byteLength + next.byteLength);
|
||||
grown.set(stored);
|
||||
grown.set(next, stored.byteLength);
|
||||
stored = grown;
|
||||
step++;
|
||||
}
|
||||
return { contentLength: stored.byteLength, complete: step >= steps.length };
|
||||
};
|
||||
const body = createResponseBody(
|
||||
{ responseId: "rs_live", contentLength: 0, contentType, complete: false },
|
||||
async (offset, length) => stored.slice(offset, offset + length),
|
||||
{
|
||||
refresh: async () => {
|
||||
refreshes++;
|
||||
return advance();
|
||||
},
|
||||
pollIntervalMs: 1,
|
||||
},
|
||||
);
|
||||
return { body, refreshCount: () => refreshes };
|
||||
}
|
||||
|
||||
function utf8(text: string) {
|
||||
return new TextEncoder().encode(text);
|
||||
}
|
||||
|
||||
describe("response body", () => {
|
||||
test("pulls a body in chunks and reassembles it", async () => {
|
||||
const { body, reads } = fakeBody(utf8("abcdefghij"), "text/plain");
|
||||
|
||||
expect(await body.text({ chunkSize: 4 })).toEqual("abcdefghij");
|
||||
expect(reads).toEqual([
|
||||
[0, 4],
|
||||
[4, 4],
|
||||
[8, 2],
|
||||
]);
|
||||
});
|
||||
|
||||
test("can be read more than once, unlike fetch", async () => {
|
||||
const { body } = fakeBody(utf8('{"a":1}'), "application/json");
|
||||
|
||||
expect(await body.text()).toEqual('{"a":1}');
|
||||
expect(await body.json()).toEqual({ a: 1 });
|
||||
expect(new Uint8Array(await body.arrayBuffer())).toEqual(utf8('{"a":1}'));
|
||||
});
|
||||
|
||||
test("decodes using the charset the response declared", async () => {
|
||||
// "café naïve" as Latin-1, which is mojibake if read as UTF-8.
|
||||
const latin1 = new Uint8Array([0x63, 0x61, 0x66, 0xe9, 0x20, 0x6e, 0x61, 0xef, 0x76, 0x65]);
|
||||
|
||||
const declared = fakeBody(latin1, "text/plain; charset=iso-8859-1");
|
||||
expect(await declared.body.text()).toEqual("café naïve");
|
||||
|
||||
const undeclared = fakeBody(latin1, "text/plain");
|
||||
expect(await undeclared.body.text()).not.toEqual("café naïve");
|
||||
});
|
||||
|
||||
test("falls back to UTF-8 for a charset the runtime doesn't know", async () => {
|
||||
const { body } = fakeBody(utf8("hello"), "text/plain; charset=not-a-real-charset");
|
||||
expect(await body.text()).toEqual("hello");
|
||||
});
|
||||
|
||||
test("drops a UTF-8 BOM", async () => {
|
||||
const withBom = new Uint8Array([0xef, 0xbb, 0xbf, ...utf8('{"a":1}')]);
|
||||
const { body } = fakeBody(withBom, "application/json");
|
||||
|
||||
expect(await body.text()).toEqual('{"a":1}');
|
||||
expect(await body.json()).toEqual({ a: 1 });
|
||||
});
|
||||
|
||||
test("refuses to buffer past maxBytes, and says what to do instead", async () => {
|
||||
const { body } = fakeBody(utf8("x".repeat(100)), "text/plain");
|
||||
|
||||
await expect(body.text({ maxBytes: 50 })).rejects.toThrow(/chunks\(\)/);
|
||||
await expect(body.json({ maxBytes: 50 })).rejects.toThrow(/over the/);
|
||||
await expect(body.arrayBuffer({ maxBytes: 50 })).rejects.toThrow(/arrayBuffer\(\)/);
|
||||
|
||||
// The ceiling is the caller's to raise.
|
||||
expect(await body.text({ maxBytes: 100 })).toHaveLength(100);
|
||||
});
|
||||
|
||||
test("streams past maxBytes through chunks()", async () => {
|
||||
const { body } = fakeBody(utf8("x".repeat(100)), "text/plain");
|
||||
|
||||
let total = 0;
|
||||
for await (const chunk of body.chunks({ chunkSize: 10 })) {
|
||||
total += chunk.byteLength;
|
||||
}
|
||||
expect(total).toEqual(100);
|
||||
});
|
||||
|
||||
test("stops early when the host runs out of bytes sooner than it claimed", async () => {
|
||||
// contentLength says 100; the store only ever hands back 10.
|
||||
const body = createResponseBody(
|
||||
{ responseId: "rs_test", contentLength: 100, contentType: "text/plain", complete: true },
|
||||
async (offset) => (offset === 0 ? utf8("0123456789") : new Uint8Array()),
|
||||
);
|
||||
|
||||
expect(await body.text()).toEqual("0123456789");
|
||||
});
|
||||
|
||||
test("a response with no body reads as empty", async () => {
|
||||
const { body, reads } = fakeBody(new Uint8Array(), "application/json");
|
||||
|
||||
expect(body.contentLength).toEqual(0);
|
||||
expect(await body.text()).toEqual("");
|
||||
expect(reads).toEqual([]);
|
||||
});
|
||||
|
||||
test("keeps binary bytes intact", async () => {
|
||||
const png = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0xff]);
|
||||
const { body } = fakeBody(png, "image/png");
|
||||
|
||||
expect(new Uint8Array(await body.arrayBuffer({ chunkSize: 3 }))).toEqual(png);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a response still arriving", () => {
|
||||
test("chunks() follows it until it finishes", async () => {
|
||||
const { body, refreshCount } = streamingBody(["data: 1\n", "data: 2\n", "data: 3\n"]);
|
||||
expect(body.complete).toBe(false);
|
||||
|
||||
const seen: string[] = [];
|
||||
for await (const chunk of body.chunks()) {
|
||||
seen.push(new TextDecoder().decode(chunk));
|
||||
}
|
||||
|
||||
expect(seen.join("")).toEqual("data: 1\ndata: 2\ndata: 3\n");
|
||||
// Asked once per catch-up, and stopped as soon as the host said it was done.
|
||||
expect(refreshCount()).toEqual(3);
|
||||
});
|
||||
|
||||
test("text() waits for the rest rather than returning a prefix", async () => {
|
||||
const { body } = streamingBody(['{"token":', '"abc"}']);
|
||||
expect(await body.json()).toEqual({ token: "abc" });
|
||||
});
|
||||
|
||||
test("keeps waiting through a stretch with nothing new", async () => {
|
||||
// Two refreshes report no growth before the body finally moves.
|
||||
const { body } = streamingBody(["", "", "late"]);
|
||||
expect(await body.text()).toEqual("late");
|
||||
});
|
||||
|
||||
test("still refuses to buffer past maxBytes as it streams", async () => {
|
||||
const { body } = streamingBody(["x".repeat(40), "x".repeat(40), "x".repeat(40)]);
|
||||
await expect(body.text({ maxBytes: 100 })).rejects.toThrow(/chunks\(\)/);
|
||||
});
|
||||
|
||||
test("a finished body never asks the host again", async () => {
|
||||
let refreshes = 0;
|
||||
const body = createResponseBody(
|
||||
{ responseId: "rs_done", contentLength: 5, contentType: null, complete: true },
|
||||
async (offset, length) => utf8("hello").slice(offset, offset + length),
|
||||
{
|
||||
refresh: async () => {
|
||||
refreshes++;
|
||||
return { contentLength: 5, complete: true };
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(await body.text()).toEqual("hello");
|
||||
expect(refreshes).toEqual(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("decodeBase64Chunk", () => {
|
||||
test("round-trips arbitrary bytes", () => {
|
||||
const bytes = new Uint8Array([0, 1, 127, 128, 254, 255]);
|
||||
const base64 = Buffer.from(bytes).toString("base64");
|
||||
expect(decodeBase64Chunk(base64)).toEqual(bytes);
|
||||
});
|
||||
|
||||
test("decodes an empty chunk", () => {
|
||||
expect(decodeBase64Chunk("")).toEqual(new Uint8Array());
|
||||
});
|
||||
});
|
||||
65
packages/plugin-runtime/tests/sendHttpRequest.test.ts
Normal file
65
packages/plugin-runtime/tests/sendHttpRequest.test.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import type { InternalEvent } from "@yaakapp/api";
|
||||
import { expect, test } from "vite-plus/test";
|
||||
import { EventChannel } from "../src/EventChannel";
|
||||
import { PluginInstance } from "../src/PluginInstance";
|
||||
|
||||
test("the runtime forwards per-send environments without changing plugin context", async () => {
|
||||
const dir = await mkdtemp(path.join(tmpdir(), "yaak-send-environment-"));
|
||||
await mkdir(path.join(dir, "build"));
|
||||
await writeFile(
|
||||
path.join(dir, "build/index.js"),
|
||||
`exports.plugin = { async init(ctx) {
|
||||
await Promise.all(["ev_a", "ev_b", undefined].map(environmentId =>
|
||||
ctx.httpRequest.send({ httpRequest: { id: "rq_test" }, environmentId })
|
||||
));
|
||||
} };`,
|
||||
);
|
||||
const channel = new EventChannel();
|
||||
const context = { workspaceId: "wk_test", label: "test-window" };
|
||||
const bootRequest = { dir, watch: false };
|
||||
const instance = new PluginInstance({ bootRequest, pluginRefId: "test", context }, channel);
|
||||
const sends: InternalEvent[] = [];
|
||||
const booted = new Promise<void>((resolve, reject) => {
|
||||
channel.listen((event) => {
|
||||
if (event.payload.type === "send_http_request_request") {
|
||||
// Match the JSON transport used by the real plugin host.
|
||||
sends.push(JSON.parse(JSON.stringify(event)) as InternalEvent);
|
||||
instance.postMessage({
|
||||
...event,
|
||||
id: `reply-${event.id}`,
|
||||
replyId: event.id,
|
||||
payload: { type: "error_response", error: "test host received send" },
|
||||
});
|
||||
} else if (event.payload.type === "error_response") {
|
||||
// The stub host rejects the sends after capturing them.
|
||||
if (event.payload.error === "test host received send") resolve();
|
||||
else reject(new Error(event.payload.error));
|
||||
} else if (event.payload.type === "boot_response") {
|
||||
reject(new Error("Expected the test host's send error"));
|
||||
}
|
||||
});
|
||||
});
|
||||
try {
|
||||
instance.postMessage({
|
||||
id: "boot",
|
||||
replyId: null,
|
||||
pluginRefId: "test",
|
||||
pluginName: "test",
|
||||
context,
|
||||
payload: { type: "boot_request", ...bootRequest },
|
||||
});
|
||||
await booted;
|
||||
expect(sends.map((event) => event.payload)).toEqual([
|
||||
{ type: "send_http_request_request", httpRequest: { id: "rq_test" }, environmentId: "ev_a" },
|
||||
{ type: "send_http_request_request", httpRequest: { id: "rq_test" }, environmentId: "ev_b" },
|
||||
{ type: "send_http_request_request", httpRequest: { id: "rq_test" } },
|
||||
]);
|
||||
expect(sends.map((event) => event.context)).toEqual([context, context, context]);
|
||||
} finally {
|
||||
await instance.terminate();
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
17
packages/plugin-runtime/tsconfig.json
Normal file
17
packages/plugin-runtime/tsconfig.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "node16",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"target": "es2021",
|
||||
"lib": ["es2021"],
|
||||
"noImplicitAny": false,
|
||||
"moduleResolution": "node16",
|
||||
"resolveJsonModule": true,
|
||||
"sourceMap": true,
|
||||
"rootDir": "src",
|
||||
"outDir": "build"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
124
packages/tailwind-config/index.css
Normal file
124
packages/tailwind-config/index.css
Normal file
@@ -0,0 +1,124 @@
|
||||
@custom-variant dark (&:is([data-resolved-appearance="dark"] *));
|
||||
@custom-variant hocus (&:hover, &:focus-visible, &.focus:focus);
|
||||
@custom-variant focus-visible-or-class (&:focus-visible, &.focus:focus);
|
||||
|
||||
@theme inline {
|
||||
--font-mono:
|
||||
var(--font-family-editor), JetBrains Mono, ui-monospace, SFMono-Regular,
|
||||
Menlo, Monaco, Fira Code, Ubuntu Mono, Consolas, Liberation Mono,
|
||||
Courier New, DejaVu Sans Mono, Hack, monospace;
|
||||
--font-sans:
|
||||
var(--font-family-interface), Inter UI, -apple-system, BlinkMacSystemFont,
|
||||
Segoe UI, Roboto, Oxygen-Sans, Ubuntu, Cantarell, Helvetica Neue,
|
||||
sans-serif, Apple Color Emoji, Segoe UI Emoji, Segoe UI Symbol;
|
||||
--font-serif: initial;
|
||||
|
||||
--text-*: initial;
|
||||
--text-4xs: 0.6rem;
|
||||
--text-3xs: 0.675rem;
|
||||
--text-2xs: 0.75rem;
|
||||
--text-xs: 0.8rem;
|
||||
--text-sm: 0.9rem;
|
||||
--text-base: 1rem;
|
||||
--text-lg: 1.12rem;
|
||||
--text-xl: 1.25rem;
|
||||
--text-2xl: 1.5rem;
|
||||
--text-3xl: 2rem;
|
||||
--text-4xl: 2.5rem;
|
||||
--text-5xl: 3rem;
|
||||
--text-editor: var(--editor-font-size);
|
||||
--text-shrink: 0.8em;
|
||||
|
||||
--shadow-*: initial;
|
||||
--shadow: 0 1px 3px 0 var(--shadow);
|
||||
--shadow-lg: 0 10px 15px -3px var(--shadow);
|
||||
|
||||
--color-*: initial;
|
||||
--color-transparent: transparent;
|
||||
--color-placeholder: var(--textSubtlest);
|
||||
--color-shadow: var(--shadow);
|
||||
--color-backdrop: var(--backdrop);
|
||||
--color-selection: var(--selection);
|
||||
--color-surface: var(--surface);
|
||||
--color-surface-highlight: var(--surfaceHighlight);
|
||||
--color-surface-active: var(--surfaceActive);
|
||||
--color-text: var(--text);
|
||||
--color-text-subtle: var(--textSubtle);
|
||||
--color-text-subtlest: var(--textSubtlest);
|
||||
--color-border: var(--border);
|
||||
--color-border-subtle: var(--borderSubtle);
|
||||
--color-border-focus: var(--borderFocus);
|
||||
--color-primary: var(--primary);
|
||||
--color-danger: var(--danger);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-success: var(--success);
|
||||
--color-info: var(--info);
|
||||
--color-notice: var(--notice);
|
||||
--color-warning: var(--warning);
|
||||
|
||||
--animate-blinkRing: blinkRing 150ms step-start 400ms infinite;
|
||||
|
||||
--opacity-disabled: 30%;
|
||||
|
||||
--height-2xs: 1.4rem;
|
||||
--height-xs: 1.8rem;
|
||||
--height-sm: 2rem;
|
||||
--height-md: 2.3rem;
|
||||
--height-lg: 2.6rem;
|
||||
|
||||
--width-2xs: 1.4rem;
|
||||
--width-xs: 1.8rem;
|
||||
--width-sm: 2rem;
|
||||
--width-md: 2.3rem;
|
||||
--width-lg: 2.6rem;
|
||||
|
||||
--min-height-2xs: 1.4rem;
|
||||
--min-height-xs: 1.8rem;
|
||||
--min-height-sm: 2rem;
|
||||
--min-height-md: 2.3rem;
|
||||
--min-height-lg: 2.6rem;
|
||||
|
||||
--min-width-2xs: 1.4rem;
|
||||
--min-width-xs: 1.8rem;
|
||||
--min-width-sm: 2rem;
|
||||
--min-width-md: 2.3rem;
|
||||
--min-width-lg: 2.6rem;
|
||||
|
||||
--leading-xs: calc(1.75rem - 2px);
|
||||
--leading-sm: calc(2rem - 2px);
|
||||
--leading-md: calc(2.5rem - 2px);
|
||||
|
||||
--transition-property-grid: grid;
|
||||
|
||||
@keyframes blinkRing {
|
||||
0%,
|
||||
49% {
|
||||
--tw-ring-color: var(--primary);
|
||||
}
|
||||
50%,
|
||||
99% {
|
||||
--tw-ring-color: transparent;
|
||||
}
|
||||
100% {
|
||||
--tw-ring-color: var(--primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
The default border color has changed to `currentcolor` in Tailwind CSS v4,
|
||||
so we've added these compatibility styles to make sure everything still
|
||||
looks the same as it did with Tailwind CSS v3.
|
||||
|
||||
If we ever want to remove these styles, we need to add an explicit border
|
||||
color utility to any element that depends on these defaults.
|
||||
*/
|
||||
@layer base {
|
||||
*,
|
||||
::after,
|
||||
::before,
|
||||
::backdrop,
|
||||
::file-selector-button {
|
||||
border-color: var(--color-gray-200, currentcolor);
|
||||
}
|
||||
}
|
||||
6
packages/tailwind-config/package.json
Normal file
6
packages/tailwind-config/package.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "@yaakapp-internal/tailwind-config",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"style": "index.css"
|
||||
}
|
||||
13
packages/theme/package.json
Normal file
13
packages/theme/package.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "@yaakapp-internal/theme",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.11.0",
|
||||
"@yaakapp-internal/plugins": "^1.0.0",
|
||||
"parse-color": "^1.0.0"
|
||||
}
|
||||
}
|
||||
85
packages/theme/src/appearance.ts
Normal file
85
packages/theme/src/appearance.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { platform } from "@yaakapp-internal/platform";
|
||||
|
||||
export type Appearance = "light" | "dark";
|
||||
|
||||
const SYSTEM_APPEARANCE_CHANGE_EVENT = "system_appearance_change";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__YAAK_INITIAL_APPEARANCE__?: Appearance;
|
||||
}
|
||||
}
|
||||
|
||||
// NOTE: On macOS and Linux the backend detects the appearance the OS prefers and injects
|
||||
// it here, later emitting change events. This is required because applying a theme forces
|
||||
// the window appearance, which poisons what the webview reports (CSS media queries and
|
||||
// window theme events follow the override).
|
||||
export function getSystemAppearance(): Appearance | null {
|
||||
const appearance = window.__YAAK_INITIAL_APPEARANCE__;
|
||||
return appearance === "dark" || appearance === "light" ? appearance : null;
|
||||
}
|
||||
|
||||
export function getCSSAppearance(): Appearance {
|
||||
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
||||
}
|
||||
|
||||
export async function getWindowAppearance(): Promise<Appearance> {
|
||||
const appearance = await platform.window.theme();
|
||||
return appearance ?? getCSSAppearance();
|
||||
}
|
||||
|
||||
export function subscribeToCSSAppearanceChange(cb: (appearance: Appearance) => void): () => void {
|
||||
const media = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
const listener = () => cb(media.matches ? "dark" : "light");
|
||||
media.addEventListener("change", listener);
|
||||
return () => media.removeEventListener("change", listener);
|
||||
}
|
||||
|
||||
export function subscribeToWindowAppearanceChange(
|
||||
cb: (appearance: Appearance) => void,
|
||||
): () => void {
|
||||
return platform.window.onThemeChanged(cb);
|
||||
}
|
||||
|
||||
export function subscribeToSystemAppearanceChange(
|
||||
cb: (appearance: Appearance) => void,
|
||||
): () => void {
|
||||
return platform.listen<Appearance>(SYSTEM_APPEARANCE_CHANGE_EVENT, cb);
|
||||
}
|
||||
|
||||
export function resolveAppearance(
|
||||
preferredAppearance: Appearance,
|
||||
appearanceSetting: string,
|
||||
): Appearance {
|
||||
const appearance = appearanceSetting === "system" ? preferredAppearance : appearanceSetting;
|
||||
return appearance === "dark" ? "dark" : "light";
|
||||
}
|
||||
|
||||
export function subscribeToPreferredAppearance(cb: (appearance: Appearance) => void) {
|
||||
const systemAppearance = getSystemAppearance();
|
||||
if (systemAppearance != null) {
|
||||
cb(systemAppearance);
|
||||
return subscribeToSystemAppearanceChange(cb);
|
||||
}
|
||||
|
||||
cb(getCSSAppearance());
|
||||
void getWindowAppearance().then(cb);
|
||||
return subscribeToPreferredAppearanceChange(cb);
|
||||
}
|
||||
|
||||
export function subscribeToPreferredAppearanceChange(cb: (appearance: Appearance) => void) {
|
||||
if (getSystemAppearance() != null) {
|
||||
// The CSS and window sources are poisoned by forced window appearances, so only
|
||||
// listen to the backend's system appearance detection when it's available
|
||||
return subscribeToSystemAppearanceChange(cb);
|
||||
}
|
||||
|
||||
const unsubscribeCSS = subscribeToCSSAppearanceChange(cb);
|
||||
const unsubscribeWindow = subscribeToWindowAppearanceChange(cb);
|
||||
const unsubscribeSystem = subscribeToSystemAppearanceChange(cb);
|
||||
return () => {
|
||||
unsubscribeCSS();
|
||||
unsubscribeWindow();
|
||||
unsubscribeSystem();
|
||||
};
|
||||
}
|
||||
76
packages/theme/src/defaultThemes.ts
Normal file
76
packages/theme/src/defaultThemes.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import type { Theme } from "@yaakapp-internal/plugins";
|
||||
|
||||
export const defaultDarkTheme: Theme = {
|
||||
id: "yaak-dark",
|
||||
label: "Yaak",
|
||||
dark: true,
|
||||
base: {
|
||||
surface: "hsl(244,23%,14%)",
|
||||
surfaceHighlight: "hsl(244,23%,20%)",
|
||||
text: "hsl(245,23%,85%)",
|
||||
textSubtle: "hsl(245,18%,58%)",
|
||||
textSubtlest: "hsl(245,18%,45%)",
|
||||
border: "hsl(244,23%,25%)",
|
||||
primary: "hsl(266,100%,79%)",
|
||||
secondary: "hsl(245,23%,60%)",
|
||||
info: "hsl(206,100%,63%)",
|
||||
success: "hsl(150,99%,44%)",
|
||||
notice: "hsl(48,80%,63%)",
|
||||
warning: "hsl(28,100%,61%)",
|
||||
danger: "hsl(342,90%,68%)",
|
||||
},
|
||||
components: {
|
||||
button: {
|
||||
primary: "hsl(266,100%,71.1%)",
|
||||
secondary: "hsl(244,23%,54%)",
|
||||
info: "hsl(206,100%,56.7%)",
|
||||
success: "hsl(150,99%,37.4%)",
|
||||
notice: "hsl(48,80%,50.4%)",
|
||||
warning: "hsl(28,100%,54.9%)",
|
||||
danger: "hsl(342,90%,61.2%)",
|
||||
},
|
||||
dialog: {
|
||||
border: "hsl(244,23%,24%)",
|
||||
},
|
||||
sidebar: {
|
||||
surface: "hsl(243,23%,16%)",
|
||||
border: "hsl(244,23%,22%)",
|
||||
},
|
||||
responsePane: {
|
||||
surface: "hsl(243,23%,16%)",
|
||||
border: "hsl(246,23%,23%)",
|
||||
},
|
||||
appHeader: {
|
||||
surface: "hsl(244,23%,12%)",
|
||||
border: "hsl(244,23%,21%)",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const defaultLightTheme: Theme = {
|
||||
id: "yaak-light",
|
||||
label: "Yaak",
|
||||
dark: false,
|
||||
base: {
|
||||
surface: "hsl(0,0%,100%)",
|
||||
surfaceHighlight: "hsl(218,24%,92%)",
|
||||
text: "hsl(217,24%,10%)",
|
||||
textSubtle: "hsl(217,24%,40%)",
|
||||
textSubtlest: "hsl(217,24%,58%)",
|
||||
border: "hsl(217,22%,90%)",
|
||||
primary: "hsl(266,100%,60%)",
|
||||
secondary: "hsl(220,24%,50%)",
|
||||
info: "hsl(206,100%,40%)",
|
||||
success: "hsl(139,66%,34%)",
|
||||
notice: "hsl(45,100%,34%)",
|
||||
warning: "hsl(30,100%,36%)",
|
||||
danger: "hsl(335,75%,48%)",
|
||||
},
|
||||
components: {
|
||||
sidebar: {
|
||||
surface: "hsl(220,20%,98%)",
|
||||
border: "hsl(217,22%,88%)",
|
||||
surfaceHighlight: "hsl(217,25%,94%)",
|
||||
},
|
||||
},
|
||||
};
|
||||
28
packages/theme/src/index.ts
Normal file
28
packages/theme/src/index.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
export type { Appearance } from "./appearance";
|
||||
export {
|
||||
subscribeToCSSAppearanceChange,
|
||||
getCSSAppearance,
|
||||
getSystemAppearance,
|
||||
getWindowAppearance,
|
||||
resolveAppearance,
|
||||
subscribeToPreferredAppearance,
|
||||
subscribeToPreferredAppearanceChange,
|
||||
subscribeToSystemAppearanceChange,
|
||||
subscribeToWindowAppearanceChange,
|
||||
} from "./appearance";
|
||||
export { defaultDarkTheme, defaultLightTheme } from "./defaultThemes";
|
||||
export { YaakColor } from "./yaakColor";
|
||||
export type { DocumentPlatform, YaakColorKey, YaakColors, YaakTheme } from "./window";
|
||||
export {
|
||||
addThemeStylesToDocument,
|
||||
applyThemeToDocument,
|
||||
completeColorVariables,
|
||||
completeFullColorVariables,
|
||||
completePartialColorVariables,
|
||||
completeTheme,
|
||||
getThemeCSS,
|
||||
indent,
|
||||
platformFromUserAgent,
|
||||
setThemeOnDocument,
|
||||
setPlatformOnDocument,
|
||||
} from "./window";
|
||||
413
packages/theme/src/window.ts
Normal file
413
packages/theme/src/window.ts
Normal file
@@ -0,0 +1,413 @@
|
||||
import type { Theme, ThemeComponentColors } from "@yaakapp-internal/plugins";
|
||||
import { defaultDarkTheme, defaultLightTheme } from "./defaultThemes";
|
||||
import { YaakColor } from "./yaakColor";
|
||||
|
||||
export type YaakColors = {
|
||||
surface: YaakColor;
|
||||
surfaceHighlight?: YaakColor;
|
||||
surfaceActive?: YaakColor;
|
||||
text: YaakColor;
|
||||
textSubtle?: YaakColor;
|
||||
textSubtlest?: YaakColor;
|
||||
border?: YaakColor;
|
||||
borderSubtle?: YaakColor;
|
||||
borderFocus?: YaakColor;
|
||||
shadow?: YaakColor;
|
||||
backdrop?: YaakColor;
|
||||
selection?: YaakColor;
|
||||
primary?: YaakColor;
|
||||
secondary?: YaakColor;
|
||||
info?: YaakColor;
|
||||
success?: YaakColor;
|
||||
notice?: YaakColor;
|
||||
warning?: YaakColor;
|
||||
danger?: YaakColor;
|
||||
};
|
||||
|
||||
export type YaakTheme = {
|
||||
id: string;
|
||||
name: string;
|
||||
base: YaakColors;
|
||||
components?: Partial<{
|
||||
dialog: Partial<YaakColors>;
|
||||
menu: Partial<YaakColors>;
|
||||
toast: Partial<YaakColors>;
|
||||
sidebar: Partial<YaakColors>;
|
||||
responsePane: Partial<YaakColors>;
|
||||
appHeader: Partial<YaakColors>;
|
||||
button: Partial<YaakColors>;
|
||||
banner: Partial<YaakColors>;
|
||||
templateTag: Partial<YaakColors>;
|
||||
urlBar: Partial<YaakColors>;
|
||||
editor: Partial<YaakColors>;
|
||||
input: Partial<YaakColors>;
|
||||
}>;
|
||||
};
|
||||
|
||||
export type YaakColorKey = keyof ThemeComponentColors;
|
||||
export type DocumentPlatform = "linux" | "macos" | "windows" | "unknown";
|
||||
|
||||
type ComponentName = keyof NonNullable<Theme["components"]>;
|
||||
type CSSVariables = Record<YaakColorKey, string | undefined>;
|
||||
|
||||
export function completeFullColorVariables(theme: Theme, cmp: Partial<CSSVariables>): CSSVariables {
|
||||
const color = (value: string | undefined) => yc(theme, value);
|
||||
const vars: CSSVariables = {
|
||||
surface: cmp.surface,
|
||||
surfaceHighlight: cmp.surfaceHighlight ?? color(cmp.surface)?.lift(0.06).css(),
|
||||
surfaceActive: cmp.surfaceActive ?? color(cmp.primary)?.lower(0.2).translucify(0.8).css(),
|
||||
backdrop: cmp.backdrop ?? color(cmp.surface)?.lower(0.2).translucify(0.2).css(),
|
||||
selection: cmp.selection ?? color(cmp.primary)?.lower(0.1).translucify(0.7).css(),
|
||||
border: cmp.border,
|
||||
borderSubtle: cmp.borderSubtle,
|
||||
borderFocus: cmp.borderFocus ?? color(cmp.info)?.translucify(0.5)?.css(),
|
||||
text: cmp.text,
|
||||
textSubtle: cmp.textSubtle,
|
||||
textSubtlest: cmp.textSubtlest,
|
||||
shadow:
|
||||
cmp.shadow ??
|
||||
YaakColor.black()
|
||||
.translucify(theme.dark ? 0.7 : 0.93)
|
||||
.css(),
|
||||
primary: cmp.primary,
|
||||
secondary: cmp.secondary,
|
||||
info: cmp.info,
|
||||
success: cmp.success,
|
||||
notice: cmp.notice,
|
||||
warning: cmp.warning,
|
||||
danger: cmp.danger,
|
||||
};
|
||||
|
||||
const themeColor = (value: string) => new YaakColor(value, theme.dark ? "dark" : "light");
|
||||
const themeSurface = themeColor(theme.dark ? "oklch(23% 0 0)" : "oklch(100% 0 0)");
|
||||
const surface = themeColor(vars.surface ?? themeSurface.css());
|
||||
const reference = surface.compositeOver(themeSurface);
|
||||
const seed = themeColor(vars.surface ?? vars.surfaceHighlight ?? vars.border ?? surface.css());
|
||||
const textBase = seed.desaturate(0.6).opacify(1);
|
||||
const borderBase = seed.opacify(1);
|
||||
const text = vars.text ?? textBase.withContrast(reference, 11).css();
|
||||
const textColor = themeColor(text);
|
||||
|
||||
return normalizeColorVariables(theme, {
|
||||
...vars,
|
||||
text,
|
||||
textSubtle: vars.textSubtle ?? textColor.lower(0.2).css(),
|
||||
textSubtlest: vars.textSubtlest ?? textColor.lower(0.4).css(),
|
||||
border: vars.border ?? borderBase.desaturate(0.2).withContrast(reference, 3).css(),
|
||||
borderSubtle:
|
||||
vars.borderSubtle ?? borderBase.desaturate(0.2).withContrast(reference, 1.2).css(),
|
||||
});
|
||||
}
|
||||
|
||||
export function completePartialColorVariables(
|
||||
theme: Theme,
|
||||
cmp: Partial<CSSVariables>,
|
||||
): CSSVariables {
|
||||
const color = (value: string | undefined) => yc(theme, value);
|
||||
const text = color(cmp.text);
|
||||
|
||||
return normalizeColorVariables(theme, {
|
||||
surface: cmp.surface,
|
||||
surfaceHighlight: cmp.surfaceHighlight ?? color(cmp.surface)?.lift(0.06).css(),
|
||||
surfaceActive: cmp.surfaceActive ?? color(cmp.primary)?.lower(0.2).translucify(0.8).css(),
|
||||
backdrop: cmp.backdrop ?? color(cmp.surface)?.lower(0.2).translucify(0.2).css(),
|
||||
selection: cmp.selection ?? color(cmp.primary)?.lower(0.1).translucify(0.7).css(),
|
||||
border: cmp.border ?? color(cmp.surface)?.lift(0.11).css(),
|
||||
borderSubtle: cmp.borderSubtle ?? color(cmp.border)?.lower(0.06).css(),
|
||||
borderFocus: cmp.borderFocus ?? color(cmp.info)?.translucify(0.5).css(),
|
||||
text: cmp.text,
|
||||
textSubtle: cmp.textSubtle ?? text?.lower(0.3).css(),
|
||||
textSubtlest: cmp.textSubtlest ?? text?.lower(0.5).css(),
|
||||
shadow:
|
||||
cmp.shadow ??
|
||||
YaakColor.black()
|
||||
.translucify(theme.dark ? 0.7 : 0.93)
|
||||
.css(),
|
||||
primary: cmp.primary,
|
||||
secondary: cmp.secondary,
|
||||
info: cmp.info,
|
||||
success: cmp.success,
|
||||
notice: cmp.notice,
|
||||
warning: cmp.warning,
|
||||
danger: cmp.danger,
|
||||
});
|
||||
}
|
||||
|
||||
export const completeColorVariables = completeFullColorVariables;
|
||||
|
||||
function normalizeColorVariables(theme: Theme, vars: CSSVariables): CSSVariables {
|
||||
const normalized: CSSVariables = {} as CSSVariables;
|
||||
|
||||
for (const [key, value] of Object.entries(vars)) {
|
||||
normalized[key as YaakColorKey] = value == null ? undefined : yc(theme, value).css();
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function templateTagColorVariables(theme: Theme, color: YaakColor): CSSVariables {
|
||||
return completeFullColorVariables(theme, {
|
||||
// Lift toward the foreground, but not all the way. liftMax() pushes lightness to an
|
||||
// extreme, and the two extremes don't behave alike: lightening past the sRGB gamut clips
|
||||
// to a still-tinted color, while darkening to zero lightness collapses every hue to black.
|
||||
// That's why tags kept their color in dark mode and turned black in light. Stopping short
|
||||
// keeps the hue in both, and still clears 6:1 against the tag's own surface.
|
||||
text: color.lift(0.6).css(),
|
||||
textSubtle: color.lift(0.5).css(),
|
||||
textSubtlest: color.css(),
|
||||
surface: color.lower(0.2).translucify(0.8).css(),
|
||||
border: color.translucify(0.6).css(),
|
||||
borderSubtle: color.translucify(0.8).css(),
|
||||
surfaceHighlight: color.lower(0.1).translucify(0.7).css(),
|
||||
});
|
||||
}
|
||||
|
||||
function toastColorVariables(theme: Theme, color: YaakColor): CSSVariables {
|
||||
return completeFullColorVariables(theme, {
|
||||
surface: color.translucify(0.9).css(),
|
||||
surfaceHighlight: color.translucify(0.8).css(),
|
||||
});
|
||||
}
|
||||
|
||||
function bannerColorVariables(theme: Theme, color: YaakColor): CSSVariables {
|
||||
return completeFullColorVariables(theme, {
|
||||
text: color.desaturate(0.5).lift(0.12).css(),
|
||||
textSubtle: color.desaturate(0.58).lift(0.04).translucify(0.04).css(),
|
||||
textSubtlest: color.desaturate(0.65).translucify(0.18).css(),
|
||||
surface: color.translucify(0.95).css(),
|
||||
surfaceHighlight: color.translucify(0.85).css(),
|
||||
border: color.lift(0.3).translucify(0.8).css(),
|
||||
});
|
||||
}
|
||||
|
||||
function buttonSolidColorVariables(
|
||||
theme: Theme,
|
||||
color: YaakColor,
|
||||
isDefault = false,
|
||||
): CSSVariables {
|
||||
const vars: Partial<CSSVariables> = {
|
||||
surface: color.lower(0.3).css(),
|
||||
surfaceHighlight: color.lower(0.1).css(),
|
||||
};
|
||||
|
||||
if (isDefault) {
|
||||
vars.surface = undefined;
|
||||
vars.surfaceHighlight = color.lift(0.08).css();
|
||||
}
|
||||
|
||||
return completeFullColorVariables(theme, vars);
|
||||
}
|
||||
|
||||
function buttonBorderColorVariables(
|
||||
theme: Theme,
|
||||
color: YaakColor,
|
||||
isDefault = false,
|
||||
): CSSVariables {
|
||||
const vars: Partial<CSSVariables> = {
|
||||
text: color.desaturate(0.4).lift(1).css(),
|
||||
textSubtle: color.desaturate(0.4).lift(0.55).css(),
|
||||
surfaceHighlight: color.translucify(0.8).css(),
|
||||
borderSubtle: color.translucify(0.5).css(),
|
||||
border: color.translucify(0.3).css(),
|
||||
};
|
||||
|
||||
if (isDefault) {
|
||||
vars.borderSubtle = color.lift(0.28).css();
|
||||
vars.border = color.lift(0.5).css();
|
||||
}
|
||||
|
||||
return completeFullColorVariables(theme, vars);
|
||||
}
|
||||
|
||||
function variablesToCSS(
|
||||
selector: string | null,
|
||||
vars: Partial<CSSVariables> | null,
|
||||
): string | null {
|
||||
if (vars == null) return null;
|
||||
|
||||
const css = Object.entries(vars)
|
||||
.filter(([, value]) => value)
|
||||
.map(([name, value]) => `--${name}: ${value};`)
|
||||
.join("\n");
|
||||
|
||||
return selector == null ? css : `${selector} {\n${indent(css)}\n}`;
|
||||
}
|
||||
|
||||
function componentCSS(component: ComponentName, vars: CSSVariables): string | null {
|
||||
return variablesToCSS(`.x-theme-${component}`, vars);
|
||||
}
|
||||
|
||||
function buttonCSS(
|
||||
theme: Theme,
|
||||
colorKey: YaakColorKey,
|
||||
colors?: ThemeComponentColors,
|
||||
): string | null {
|
||||
const color = yc(theme, colors?.[colorKey]);
|
||||
if (color == null) return null;
|
||||
|
||||
return [
|
||||
variablesToCSS(`.x-theme-button--solid--${colorKey}`, buttonSolidColorVariables(theme, color)),
|
||||
variablesToCSS(
|
||||
`.x-theme-button--border--${colorKey}`,
|
||||
buttonBorderColorVariables(theme, color),
|
||||
),
|
||||
].join("\n\n");
|
||||
}
|
||||
|
||||
function bannerCSS(
|
||||
theme: Theme,
|
||||
colorKey: YaakColorKey,
|
||||
colors?: ThemeComponentColors,
|
||||
): string | null {
|
||||
const color = yc(theme, colors?.[colorKey]);
|
||||
if (color == null) return null;
|
||||
|
||||
return variablesToCSS(`.x-theme-banner--${colorKey}`, bannerColorVariables(theme, color));
|
||||
}
|
||||
|
||||
function toastCSS(
|
||||
theme: Theme,
|
||||
colorKey: YaakColorKey,
|
||||
colors?: ThemeComponentColors,
|
||||
): string | null {
|
||||
const color = yc(theme, colors?.[colorKey]);
|
||||
if (color == null) return null;
|
||||
|
||||
return variablesToCSS(`.x-theme-toast--${colorKey}`, toastColorVariables(theme, color));
|
||||
}
|
||||
|
||||
function templateTagCSS(
|
||||
theme: Theme,
|
||||
colorKey: YaakColorKey,
|
||||
colors?: ThemeComponentColors,
|
||||
): string | null {
|
||||
const color = yc(theme, colors?.[colorKey]);
|
||||
if (color == null) return null;
|
||||
|
||||
return variablesToCSS(
|
||||
`.x-theme-templateTag--${colorKey}`,
|
||||
templateTagColorVariables(theme, color),
|
||||
);
|
||||
}
|
||||
|
||||
export function getThemeCSS(theme: Theme): string {
|
||||
theme.components = theme.components ?? {};
|
||||
theme.components.toast = theme.components.toast ?? theme.components.menu ?? {};
|
||||
const { components, id, label } = theme;
|
||||
const colors = Object.keys(theme.base).reduce((prev, key) => {
|
||||
return { ...prev, [key]: theme.base[key as YaakColorKey] };
|
||||
}, {} as ThemeComponentColors);
|
||||
|
||||
let themeCSS = "";
|
||||
try {
|
||||
const baseCss = variablesToCSS(null, completeFullColorVariables(theme, theme.base));
|
||||
const baseSurface = yc(theme, theme.base.surface);
|
||||
|
||||
themeCSS = [
|
||||
baseCss,
|
||||
...Object.entries(components).map(([key, value]) =>
|
||||
componentCSS(key as ComponentName, completePartialColorVariables(theme, value ?? {})),
|
||||
),
|
||||
baseSurface == null
|
||||
? null
|
||||
: variablesToCSS(
|
||||
".x-theme-button--solid--default",
|
||||
buttonSolidColorVariables(theme, baseSurface, true),
|
||||
),
|
||||
baseSurface == null
|
||||
? null
|
||||
: variablesToCSS(
|
||||
".x-theme-button--border--default",
|
||||
buttonBorderColorVariables(theme, baseSurface, true),
|
||||
),
|
||||
...Object.keys(colors).map((key) =>
|
||||
buttonCSS(theme, key as YaakColorKey, theme.components?.button ?? colors),
|
||||
),
|
||||
...Object.keys(colors).map((key) =>
|
||||
bannerCSS(theme, key as YaakColorKey, theme.components?.banner ?? colors),
|
||||
),
|
||||
...Object.keys(colors).map((key) =>
|
||||
toastCSS(theme, key as YaakColorKey, theme.components?.banner ?? colors),
|
||||
),
|
||||
...Object.keys(colors).map((key) =>
|
||||
templateTagCSS(theme, key as YaakColorKey, theme.components?.templateTag ?? colors),
|
||||
),
|
||||
].join("\n\n");
|
||||
} catch (err) {
|
||||
console.error("Failed to generate CSS", err);
|
||||
}
|
||||
|
||||
return [`/* ${label} */`, `[data-theme="${id}"] {`, indent(themeCSS), "}"].join("\n");
|
||||
}
|
||||
|
||||
export function addThemeStylesToDocument(rawTheme: Theme | null) {
|
||||
if (rawTheme == null) {
|
||||
console.error("Failed to add theme styles: theme is null");
|
||||
return;
|
||||
}
|
||||
|
||||
const theme = completeTheme(rawTheme);
|
||||
let styleEl = document.head.querySelector("style[data-theme]");
|
||||
if (!styleEl) {
|
||||
styleEl = document.createElement("style");
|
||||
document.head.appendChild(styleEl);
|
||||
}
|
||||
|
||||
styleEl.setAttribute("data-theme", theme.id);
|
||||
styleEl.setAttribute("data-updated-at", new Date().toISOString());
|
||||
styleEl.textContent = getThemeCSS(theme);
|
||||
}
|
||||
|
||||
export function setThemeOnDocument(theme: Theme | null) {
|
||||
if (theme == null) {
|
||||
console.error("Failed to set theme: theme is null");
|
||||
return;
|
||||
}
|
||||
|
||||
document.documentElement.setAttribute("data-theme", theme.id);
|
||||
}
|
||||
|
||||
export function applyThemeToDocument(theme: Theme | null) {
|
||||
addThemeStylesToDocument(theme);
|
||||
setThemeOnDocument(theme);
|
||||
}
|
||||
|
||||
export function platformFromUserAgent(userAgent: string): DocumentPlatform {
|
||||
const normalized = userAgent.toLowerCase();
|
||||
|
||||
if (normalized.includes("linux")) return "linux";
|
||||
if (normalized.includes("mac os") || normalized.includes("macintosh")) return "macos";
|
||||
if (normalized.includes("win")) return "windows";
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
export function setPlatformOnDocument(platform: string | null | undefined) {
|
||||
const normalized =
|
||||
platform === "linux" || platform === "macos" || platform === "windows" ? platform : "unknown";
|
||||
document.documentElement.setAttribute("data-platform", normalized);
|
||||
}
|
||||
|
||||
export function indent(text: string, space = " "): string {
|
||||
return text
|
||||
.split("\n")
|
||||
.map((line) => space + line)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function yc<T extends string | null | undefined>(
|
||||
theme: Theme,
|
||||
value: T,
|
||||
): T extends string ? YaakColor : null {
|
||||
if (value == null) return null as never;
|
||||
return new YaakColor(value, theme.dark ? "dark" : "light") as never;
|
||||
}
|
||||
|
||||
export function completeTheme(theme: Theme): Theme {
|
||||
const fallback = theme.dark ? defaultDarkTheme.base : defaultLightTheme.base;
|
||||
|
||||
for (const [key, value] of Object.entries(fallback)) {
|
||||
theme.base[key as YaakColorKey] ??= value;
|
||||
}
|
||||
|
||||
return theme;
|
||||
}
|
||||
386
packages/theme/src/yaakColor.ts
Normal file
386
packages/theme/src/yaakColor.ts
Normal file
@@ -0,0 +1,386 @@
|
||||
import parseColor from "parse-color";
|
||||
|
||||
export class YaakColor {
|
||||
private readonly appearance: "dark" | "light" = "light";
|
||||
|
||||
private lightness = 0;
|
||||
private chroma = 0;
|
||||
private hue = 0;
|
||||
private alpha = 1;
|
||||
|
||||
constructor(cssColor: string, appearance: "dark" | "light" = "light") {
|
||||
try {
|
||||
this.set(cssColor);
|
||||
this.appearance = appearance;
|
||||
} catch (err) {
|
||||
console.log("Failed to parse CSS color", cssColor, err);
|
||||
}
|
||||
}
|
||||
|
||||
static transparent(): YaakColor {
|
||||
return new YaakColor("rgb(0,0,0)", "light").translucify(1);
|
||||
}
|
||||
|
||||
static white(): YaakColor {
|
||||
return new YaakColor("rgb(0,0,0)", "light").lower(999);
|
||||
}
|
||||
|
||||
static black(): YaakColor {
|
||||
return new YaakColor("rgb(0,0,0)", "light").lift(999);
|
||||
}
|
||||
|
||||
set(cssColor: string): YaakColor {
|
||||
let fixedCssColor = cssColor;
|
||||
if (cssColor.startsWith("#") && cssColor.length === 9) {
|
||||
const [r, g, b, a] = hexToRgba(cssColor);
|
||||
fixedCssColor = `rgba(${r},${g},${b},${a})`;
|
||||
}
|
||||
|
||||
const oklch = parseOklch(fixedCssColor);
|
||||
if (oklch != null) {
|
||||
this.lightness = oklch.lightness;
|
||||
this.chroma = oklch.chroma;
|
||||
this.hue = oklch.hue;
|
||||
this.alpha = oklch.alpha;
|
||||
return this;
|
||||
}
|
||||
|
||||
const { rgba } = parseColor(fixedCssColor);
|
||||
const [lightness, chroma, hue] = rgbToOklch(rgba[0], rgba[1], rgba[2]);
|
||||
this.lightness = lightness;
|
||||
this.chroma = chroma;
|
||||
this.hue = hue;
|
||||
this.alpha = rgba[3] ?? 1;
|
||||
return this;
|
||||
}
|
||||
|
||||
clone(): YaakColor {
|
||||
return new YaakColor(this.css(), this.appearance);
|
||||
}
|
||||
|
||||
themeColor(cssColor: string): YaakColor {
|
||||
return new YaakColor(cssColor, this.appearance);
|
||||
}
|
||||
|
||||
lower(mod: number): YaakColor {
|
||||
return this.appearance === "dark" ? this._darken(mod) : this._lighten(mod);
|
||||
}
|
||||
|
||||
lift(mod: number): YaakColor {
|
||||
return this.appearance === "dark" ? this._lighten(mod) : this._darken(mod);
|
||||
}
|
||||
|
||||
liftMax(): YaakColor {
|
||||
return this.lift(999);
|
||||
}
|
||||
|
||||
lowerMax(): YaakColor {
|
||||
return this.lower(999);
|
||||
}
|
||||
|
||||
themeSurface(): YaakColor {
|
||||
return new YaakColor(
|
||||
this.appearance === "dark" ? "oklch(23% 0 0)" : "oklch(100% 0 0)",
|
||||
this.appearance,
|
||||
);
|
||||
}
|
||||
|
||||
minLightness(n: number): YaakColor {
|
||||
const color = this.clone();
|
||||
if (color.lightness < n) {
|
||||
color.lightness = n;
|
||||
}
|
||||
return color;
|
||||
}
|
||||
|
||||
isDark(): boolean {
|
||||
return this.lightness < 50;
|
||||
}
|
||||
|
||||
translucify(mod: number): YaakColor {
|
||||
const color = this.clone();
|
||||
color.alpha = clamp(color.alpha - color.alpha * mod, 0, 1);
|
||||
return color;
|
||||
}
|
||||
|
||||
opacify(mod: number): YaakColor {
|
||||
const color = this.clone();
|
||||
color.alpha = clamp(this.alpha + (1 - this.alpha) * mod, 0, 1);
|
||||
return color;
|
||||
}
|
||||
|
||||
desaturate(mod: number): YaakColor {
|
||||
const color = this.clone();
|
||||
color.chroma = color.chroma - color.chroma * mod;
|
||||
return color;
|
||||
}
|
||||
|
||||
saturate(mod: number): YaakColor {
|
||||
const color = this.clone();
|
||||
color.chroma = this.chroma + this.chroma * mod;
|
||||
return color;
|
||||
}
|
||||
|
||||
lighterThan(color: YaakColor): boolean {
|
||||
return this.lightness > color.lightness;
|
||||
}
|
||||
|
||||
contrastRatio(background: YaakColor): number {
|
||||
const foreground = this.alpha < 1 ? this.compositeOver(background) : this;
|
||||
const foregroundLuminance = foreground.relativeLuminance();
|
||||
const backgroundLuminance = background.relativeLuminance();
|
||||
const lighter = Math.max(foregroundLuminance, backgroundLuminance);
|
||||
const darker = Math.min(foregroundLuminance, backgroundLuminance);
|
||||
return (lighter + 0.05) / (darker + 0.05);
|
||||
}
|
||||
|
||||
withContrast(background: YaakColor, minContrast: number): YaakColor {
|
||||
const darker = this.clone();
|
||||
darker.lightness = 0;
|
||||
darker.chroma = 0;
|
||||
darker.hue = 0;
|
||||
|
||||
const lighter = this.clone();
|
||||
lighter.lightness = 100;
|
||||
lighter.chroma = 0;
|
||||
lighter.hue = 0;
|
||||
|
||||
const darkerContrast = darker.contrastRatio(background);
|
||||
const lighterContrast = lighter.contrastRatio(background);
|
||||
let useLighterColor = lighterContrast >= darkerContrast;
|
||||
|
||||
// Saturated accent surfaces often read better with white text even when
|
||||
// black has the higher numeric contrast. Keep yellow-ish light accents dark
|
||||
// by requiring white to clear a modest contrast floor first.
|
||||
if (minContrast >= 3 && lighterContrast >= 2.5) {
|
||||
useLighterColor = true;
|
||||
}
|
||||
|
||||
const selectedContrast = useLighterColor ? lighterContrast : darkerContrast;
|
||||
if (selectedContrast < minContrast) {
|
||||
return useLighterColor ? lighter : darker;
|
||||
}
|
||||
|
||||
let minLightness = 0;
|
||||
let maxLightness = 100;
|
||||
const color = this.clone();
|
||||
|
||||
for (let i = 0; i < 24; i += 1) {
|
||||
color.lightness = (minLightness + maxLightness) / 2;
|
||||
const contrast = color.contrastRatio(background);
|
||||
|
||||
if (useLighterColor) {
|
||||
if (contrast >= minContrast) {
|
||||
maxLightness = color.lightness;
|
||||
} else {
|
||||
minLightness = color.lightness;
|
||||
}
|
||||
} else if (contrast >= minContrast) {
|
||||
minLightness = color.lightness;
|
||||
} else {
|
||||
maxLightness = color.lightness;
|
||||
}
|
||||
}
|
||||
|
||||
color.lightness = useLighterColor ? maxLightness : minLightness;
|
||||
return color;
|
||||
}
|
||||
|
||||
compositeOver(background: YaakColor): YaakColor {
|
||||
const [fgR, fgG, fgB] = this.rgb();
|
||||
const [bgR, bgG, bgB] = background.rgb();
|
||||
const alpha = this.alpha + background.alpha * (1 - this.alpha);
|
||||
|
||||
if (alpha <= 0) {
|
||||
return YaakColor.transparent();
|
||||
}
|
||||
|
||||
const r = (fgR * this.alpha + bgR * background.alpha * (1 - this.alpha)) / alpha;
|
||||
const g = (fgG * this.alpha + bgG * background.alpha * (1 - this.alpha)) / alpha;
|
||||
const b = (fgB * this.alpha + bgB * background.alpha * (1 - this.alpha)) / alpha;
|
||||
|
||||
return new YaakColor(`rgba(${r},${g},${b},${alpha})`, this.appearance);
|
||||
}
|
||||
|
||||
css(): string {
|
||||
const [r, g, b] = this.rgb();
|
||||
return rgbaToHex(r, g, b, this.alpha);
|
||||
}
|
||||
|
||||
hexNoAlpha(): string {
|
||||
const [r, g, b] = this.rgb();
|
||||
return rgbaToHexNoAlpha(r, g, b);
|
||||
}
|
||||
|
||||
private relativeLuminance(): number {
|
||||
const [r, g, b] = this.rgb();
|
||||
const red = srgbToLinear(r / 255);
|
||||
const green = srgbToLinear(g / 255);
|
||||
const blue = srgbToLinear(b / 255);
|
||||
return 0.2126 * red + 0.7152 * green + 0.0722 * blue;
|
||||
}
|
||||
|
||||
private rgb(): [number, number, number] {
|
||||
return oklchToRgb(this.lightness, this.chroma, this.hue);
|
||||
}
|
||||
|
||||
private _lighten(mod: number): YaakColor {
|
||||
const color = this.clone();
|
||||
color.lightness = clamp(this.lightness + (100 - this.lightness) * mod, 0, 100);
|
||||
return color;
|
||||
}
|
||||
|
||||
private _darken(mod: number): YaakColor {
|
||||
const color = this.clone();
|
||||
color.lightness = clamp(this.lightness - this.lightness * mod, 0, 100);
|
||||
return color;
|
||||
}
|
||||
}
|
||||
|
||||
function parseOklch(
|
||||
cssColor: string,
|
||||
): { lightness: number; chroma: number; hue: number; alpha: number } | null {
|
||||
const match = cssColor
|
||||
.trim()
|
||||
.match(
|
||||
/^oklch\(\s*([^\s,]+)(?:\s+|,\s*)([^\s,]+)(?:\s+|,\s*)([^\s,/]+)(?:\s*\/\s*([^)]+)|(?:\s*,\s*([^)]*))?)\s*\)$/i,
|
||||
);
|
||||
if (match == null) return null;
|
||||
|
||||
const [, lightnessValue, chromaValue, hueValue, slashAlpha, commaAlpha] = match;
|
||||
if (lightnessValue == null || chromaValue == null || hueValue == null) return null;
|
||||
|
||||
const lightness = parseOklchLightness(lightnessValue);
|
||||
const chroma = parseCssNumber(chromaValue, 1);
|
||||
const hue = normalizeHue(parseCssNumber(hueValue.replace(/deg$/i, ""), 1));
|
||||
const alpha = parseCssNumber(slashAlpha ?? commaAlpha ?? "1", 1);
|
||||
|
||||
if (
|
||||
!Number.isFinite(lightness) ||
|
||||
!Number.isFinite(chroma) ||
|
||||
!Number.isFinite(hue) ||
|
||||
!Number.isFinite(alpha)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
lightness: clamp(lightness, 0, 100),
|
||||
chroma: Math.max(0, chroma),
|
||||
hue,
|
||||
alpha: clamp(alpha, 0, 1),
|
||||
};
|
||||
}
|
||||
|
||||
function parseCssNumber(value: string, percentScale: number): number {
|
||||
const normalized = value.trim();
|
||||
if (normalized.endsWith("%")) {
|
||||
return (Number.parseFloat(normalized) / 100) * percentScale;
|
||||
}
|
||||
return Number.parseFloat(normalized);
|
||||
}
|
||||
|
||||
function parseOklchLightness(value: string): number {
|
||||
const parsed = parseCssNumber(value, 100);
|
||||
return value.trim().endsWith("%") || parsed > 1 ? parsed : parsed * 100;
|
||||
}
|
||||
|
||||
function rgbToOklch(r: number, g: number, b: number): [number, number, number] {
|
||||
const red = srgbToLinear(r / 255);
|
||||
const green = srgbToLinear(g / 255);
|
||||
const blue = srgbToLinear(b / 255);
|
||||
|
||||
const l = 0.4122214708 * red + 0.5363325363 * green + 0.0514459929 * blue;
|
||||
const m = 0.2119034982 * red + 0.6806995451 * green + 0.1073969566 * blue;
|
||||
const s = 0.0883024619 * red + 0.2817188376 * green + 0.6299787005 * blue;
|
||||
|
||||
const lRoot = Math.cbrt(l);
|
||||
const mRoot = Math.cbrt(m);
|
||||
const sRoot = Math.cbrt(s);
|
||||
|
||||
const lightness = 0.2104542553 * lRoot + 0.793617785 * mRoot - 0.0040720468 * sRoot;
|
||||
const a = 1.9779984951 * lRoot - 2.428592205 * mRoot + 0.4505937099 * sRoot;
|
||||
const okb = 0.0259040371 * lRoot + 0.7827717662 * mRoot - 0.808675766 * sRoot;
|
||||
|
||||
return [
|
||||
lightness * 100,
|
||||
Math.sqrt(a * a + okb * okb),
|
||||
normalizeHue(radToDeg(Math.atan2(okb, a))),
|
||||
];
|
||||
}
|
||||
|
||||
function oklchToRgb(lightness: number, chroma: number, hue: number): [number, number, number] {
|
||||
const l = clamp(lightness, 0, 100) / 100;
|
||||
const a = Math.cos(degToRad(hue)) * chroma;
|
||||
const b = Math.sin(degToRad(hue)) * chroma;
|
||||
|
||||
const lRoot = l + 0.3963377774 * a + 0.2158037573 * b;
|
||||
const mRoot = l - 0.1055613458 * a - 0.0638541728 * b;
|
||||
const sRoot = l - 0.0894841775 * a - 1.291485548 * b;
|
||||
|
||||
const lCube = lRoot * lRoot * lRoot;
|
||||
const mCube = mRoot * mRoot * mRoot;
|
||||
const sCube = sRoot * sRoot * sRoot;
|
||||
|
||||
const red = 4.0767416621 * lCube - 3.3077115913 * mCube + 0.2309699292 * sCube;
|
||||
const green = -1.2684380046 * lCube + 2.6097574011 * mCube - 0.3413193965 * sCube;
|
||||
const blue = -0.0041960863 * lCube - 0.7034186147 * mCube + 1.707614701 * sCube;
|
||||
|
||||
return [linearToSrgb(red) * 255, linearToSrgb(green) * 255, linearToSrgb(blue) * 255];
|
||||
}
|
||||
|
||||
function srgbToLinear(value: number): number {
|
||||
return value <= 0.04045 ? value / 12.92 : Math.pow((value + 0.055) / 1.055, 2.4);
|
||||
}
|
||||
|
||||
function linearToSrgb(value: number): number {
|
||||
const srgb = value <= 0.0031308 ? value * 12.92 : 1.055 * Math.pow(value, 1 / 2.4) - 0.055;
|
||||
return clamp(srgb, 0, 1);
|
||||
}
|
||||
|
||||
function normalizeHue(value: number): number {
|
||||
const hue = value % 360;
|
||||
return hue < 0 ? hue + 360 : hue;
|
||||
}
|
||||
|
||||
function degToRad(value: number): number {
|
||||
return (value * Math.PI) / 180;
|
||||
}
|
||||
|
||||
function radToDeg(value: number): number {
|
||||
return (value * 180) / Math.PI;
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
function rgbaToHex(r: number, g: number, b: number, a: number): string {
|
||||
const toHex = (n: number): string => {
|
||||
const hex = Number(Math.round(n)).toString(16);
|
||||
return hex.length === 1 ? `0${hex}` : hex;
|
||||
};
|
||||
return `#${[toHex(r), toHex(g), toHex(b), toHex(a * 255)].join("").toUpperCase()}`;
|
||||
}
|
||||
|
||||
function rgbaToHexNoAlpha(r: number, g: number, b: number): string {
|
||||
const toHex = (n: number): string => {
|
||||
const hex = Number(Math.round(n)).toString(16);
|
||||
return hex.length === 1 ? `0${hex}` : hex;
|
||||
};
|
||||
return `#${[toHex(r), toHex(g), toHex(b)].join("").toUpperCase()}`;
|
||||
}
|
||||
|
||||
function hexToRgba(hex: string): [number, number, number, number] {
|
||||
const fromHex = (value: string): number => {
|
||||
if (value === "") return 255;
|
||||
return Number(`0x${value}`);
|
||||
};
|
||||
|
||||
const r = fromHex(hex.slice(1, 3));
|
||||
const g = fromHex(hex.slice(3, 5));
|
||||
const b = fromHex(hex.slice(5, 7));
|
||||
const a = fromHex(hex.slice(7, 9));
|
||||
|
||||
return [r, g, b, a / 255];
|
||||
}
|
||||
12
packages/theme/tsconfig.json
Normal file
12
packages/theme/tsconfig.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es2021",
|
||||
"lib": ["DOM", "DOM.Iterable", "ESNext"],
|
||||
"strict": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"jsx": "react-jsx",
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
11
packages/ui/package.json
Normal file
11
packages/ui/package.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "@yaakapp-internal/ui",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"dependencies": {
|
||||
"jotai-family": "^1.0.1"
|
||||
}
|
||||
}
|
||||
28
packages/ui/src/components/Banner.tsx
Normal file
28
packages/ui/src/components/Banner.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import classNames from "classnames";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export interface BannerProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
color?: "primary" | "secondary" | "success" | "notice" | "warning" | "danger" | "info";
|
||||
}
|
||||
|
||||
export function Banner({ children, className, color }: BannerProps) {
|
||||
return (
|
||||
<div className="w-auto grid grid-rows-1 max-h-full flex-0">
|
||||
<div
|
||||
className={classNames(
|
||||
className,
|
||||
color && "bg-surface",
|
||||
`x-theme-banner--${color}`,
|
||||
"border border-border border-dashed",
|
||||
"px-4 py-2 rounded-lg select-auto cursor-auto",
|
||||
"overflow-auto text-text",
|
||||
"mb-auto", // Don't stretch all the way down if the parent is in grid or flexbox
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
127
packages/ui/src/components/Button.tsx
Normal file
127
packages/ui/src/components/Button.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
import type { Color } from "@yaakapp-internal/plugins";
|
||||
import classNames from "classnames";
|
||||
import type { HTMLAttributes, ReactNode } from "react";
|
||||
import { forwardRef } from "react";
|
||||
import { Icon } from "./Icon";
|
||||
import { LoadingIcon } from "./LoadingIcon";
|
||||
|
||||
type ButtonVariant = "border" | "solid" | "input";
|
||||
type ButtonSize = "2xs" | "xs" | "sm" | "md" | "auto";
|
||||
|
||||
export type ButtonProps = Omit<HTMLAttributes<HTMLButtonElement>, "color" | "onChange"> & {
|
||||
innerClassName?: string;
|
||||
color?: Color | "custom" | "default";
|
||||
tone?: Color | "default";
|
||||
variant?: ButtonVariant;
|
||||
isLoading?: boolean;
|
||||
size?: ButtonSize;
|
||||
justify?: "start" | "center";
|
||||
type?: "button" | "submit";
|
||||
forDropdown?: boolean;
|
||||
disabled?: boolean;
|
||||
title?: string;
|
||||
leftSlot?: ReactNode;
|
||||
rightSlot?: ReactNode;
|
||||
};
|
||||
|
||||
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
|
||||
{
|
||||
isLoading,
|
||||
className,
|
||||
innerClassName,
|
||||
children,
|
||||
color,
|
||||
tone,
|
||||
forDropdown,
|
||||
type = "button",
|
||||
justify = "center",
|
||||
size = "md",
|
||||
variant = "solid",
|
||||
leftSlot,
|
||||
rightSlot,
|
||||
disabled,
|
||||
title,
|
||||
onClick,
|
||||
...props
|
||||
}: ButtonProps,
|
||||
ref,
|
||||
) {
|
||||
const resolvedColor = color ?? tone ?? "default";
|
||||
const isDisabled = disabled || isLoading;
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
type={type}
|
||||
className={classNames(
|
||||
className,
|
||||
"x-theme-button",
|
||||
`x-theme-button--${variant}`,
|
||||
`x-theme-button--${variant}--${resolvedColor}`,
|
||||
"border",
|
||||
"max-w-full min-w-0",
|
||||
"hocus:opacity-100",
|
||||
"whitespace-nowrap outline-hidden",
|
||||
"shrink-0 flex items-center",
|
||||
"outline-0",
|
||||
isDisabled ? "pointer-events-none opacity-disabled" : "pointer-events-auto",
|
||||
justify === "start" && "justify-start",
|
||||
justify === "center" && "justify-center",
|
||||
size === "md" && "h-md px-3 rounded-md",
|
||||
size === "sm" && "h-sm px-2.5 rounded-md",
|
||||
size === "xs" && "h-xs px-2 text-sm rounded-md",
|
||||
size === "2xs" && "h-2xs px-2 text-xs rounded-sm",
|
||||
size === "auto" && "px-3 py-2 rounded-md",
|
||||
variant === "solid" && "border-transparent",
|
||||
variant === "solid" &&
|
||||
resolvedColor === "custom" &&
|
||||
"focus-visible:outline-2 outline-border-focus",
|
||||
variant === "solid" &&
|
||||
resolvedColor !== "custom" &&
|
||||
"text-text enabled:hocus:text-text enabled:hocus:bg-surface-highlight outline-border-subtle",
|
||||
variant === "solid" &&
|
||||
resolvedColor !== "custom" &&
|
||||
resolvedColor !== "default" &&
|
||||
"bg-surface",
|
||||
variant === "border" && "border",
|
||||
variant === "border" &&
|
||||
resolvedColor !== "custom" &&
|
||||
"border-border-subtle text-text-subtle enabled:hocus:border-border " +
|
||||
"enabled:hocus:bg-surface-highlight enabled:hocus:text-text outline-border-subtler",
|
||||
// Chrome of a form input rather than a button: no hover state, and colors resolve from the
|
||||
// surrounding x-theme-input rather than a button theme.
|
||||
variant === "input" && "border-border text-text",
|
||||
)}
|
||||
disabled={isDisabled}
|
||||
onClick={onClick}
|
||||
onDoubleClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
title={title}
|
||||
{...props}
|
||||
>
|
||||
{isLoading ? (
|
||||
<LoadingIcon size={size === "auto" ? "md" : size} className="mr-1" />
|
||||
) : leftSlot ? (
|
||||
<div className="mr-2">{leftSlot}</div>
|
||||
) : null}
|
||||
<div
|
||||
className={classNames(
|
||||
"truncate w-full",
|
||||
justify === "start" ? "text-left" : "text-center",
|
||||
innerClassName,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
{rightSlot && <div className="ml-1">{rightSlot}</div>}
|
||||
{forDropdown && (
|
||||
<Icon
|
||||
icon="chevron_down"
|
||||
size={size === "auto" ? "md" : size}
|
||||
className="ml-1 -mr-1 relative top-[0.1em]"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
});
|
||||
34
packages/ui/src/components/DropMarker.tsx
Normal file
34
packages/ui/src/components/DropMarker.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import classNames from "classnames";
|
||||
import type { CSSProperties } from "react";
|
||||
import { memo } from "react";
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
orientation?: "horizontal" | "vertical";
|
||||
}
|
||||
|
||||
export const DropMarker = memo(
|
||||
function DropMarker({ className, style, orientation = "horizontal" }: Props) {
|
||||
return (
|
||||
<div
|
||||
style={style}
|
||||
className={classNames(
|
||||
className,
|
||||
"absolute pointer-events-none z-50",
|
||||
orientation === "horizontal" && "w-full",
|
||||
orientation === "vertical" && "w-0 top-0 bottom-0",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={classNames(
|
||||
"absolute bg-primary rounded-full",
|
||||
orientation === "horizontal" && "left-2 right-2 bottom-[-0.1rem] h-[0.2rem]",
|
||||
orientation === "vertical" && "left-[-0.1rem] top-0 bottom-0 w-[0.2rem]",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
() => true,
|
||||
);
|
||||
23
packages/ui/src/components/FormattedError.tsx
Normal file
23
packages/ui/src/components/FormattedError.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import classNames from "classnames";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function FormattedError({ children, className }: Props) {
|
||||
return (
|
||||
<pre
|
||||
className={classNames(
|
||||
className,
|
||||
"cursor-text select-auto",
|
||||
"**:cursor-text **:select-auto",
|
||||
"font-mono text-sm w-full bg-surface-highlight p-3 rounded-sm",
|
||||
"whitespace-pre-wrap border border-danger border-dashed overflow-x-auto",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
100
packages/ui/src/components/HeaderSize.tsx
Normal file
100
packages/ui/src/components/HeaderSize.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import { useCapability } from "@yaakapp-internal/platform";
|
||||
import classNames from "classnames";
|
||||
import type { CSSProperties, HTMLAttributes, ReactNode } from "react";
|
||||
import { useMemo } from "react";
|
||||
import { useIsFullscreen } from "../hooks/useIsFullscreen";
|
||||
import { HEADER_SIZE_LG, HEADER_SIZE_MD, WINDOW_CONTROLS_WIDTH } from "../lib/constants";
|
||||
import { WindowControls } from "./WindowControls";
|
||||
|
||||
interface HeaderSizeProps extends HTMLAttributes<HTMLDivElement> {
|
||||
children?: ReactNode;
|
||||
size: "md" | "lg";
|
||||
ignoreControlsSpacing?: boolean;
|
||||
onlyXWindowControl?: boolean;
|
||||
hideControls?: boolean;
|
||||
osType: string;
|
||||
hideWindowControls: boolean;
|
||||
useNativeTitlebar: boolean;
|
||||
interfaceScale: number;
|
||||
}
|
||||
|
||||
export function HeaderSize({
|
||||
className,
|
||||
style,
|
||||
size,
|
||||
ignoreControlsSpacing,
|
||||
onlyXWindowControl,
|
||||
children,
|
||||
hideControls,
|
||||
osType,
|
||||
hideWindowControls,
|
||||
useNativeTitlebar,
|
||||
interfaceScale,
|
||||
}: HeaderSizeProps) {
|
||||
const isFullscreen = useIsFullscreen();
|
||||
// The header only doubles as the titlebar when the host hands the page the
|
||||
// window frame (Tauri) and the user hasn't opted for the native one. In a
|
||||
// browser tab the frame is the browser's: no controls, no traffic lights.
|
||||
const drawsWindowChrome = useCapability("windowChrome") && !useNativeTitlebar;
|
||||
const finalStyle = useMemo<CSSProperties>(() => {
|
||||
const s = { ...style };
|
||||
|
||||
// Set the height (use min-height because scaling font size may make it larger
|
||||
if (size === "md") s.minHeight = HEADER_SIZE_MD;
|
||||
if (size === "lg") s.minHeight = HEADER_SIZE_LG;
|
||||
|
||||
if (!drawsWindowChrome) {
|
||||
// No style updates when something else draws the titlebar
|
||||
} else if (osType === "macos") {
|
||||
if (!isFullscreen) {
|
||||
// Add large padding for window controls
|
||||
s.paddingLeft = 76 / interfaceScale;
|
||||
}
|
||||
} else if (!ignoreControlsSpacing && !hideWindowControls) {
|
||||
s.paddingRight = WINDOW_CONTROLS_WIDTH;
|
||||
}
|
||||
|
||||
return s;
|
||||
}, [
|
||||
ignoreControlsSpacing,
|
||||
isFullscreen,
|
||||
hideWindowControls,
|
||||
interfaceScale,
|
||||
size,
|
||||
style,
|
||||
drawsWindowChrome,
|
||||
osType,
|
||||
]);
|
||||
|
||||
return (
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
style={finalStyle}
|
||||
className={classNames(
|
||||
className,
|
||||
"pt-px", // Make up for bottom border
|
||||
"select-none relative flex items-center",
|
||||
"w-full border-b border-border-subtle min-w-0",
|
||||
)}
|
||||
>
|
||||
{/* NOTE: This needs display:grid or else the element shrinks (even though scrollable) */}
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
className={classNames(
|
||||
"pointer-events-none h-full w-full overflow-x-auto hide-scrollbars grid",
|
||||
"px-1", // Give it some space on either end for focus outlines
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
{!hideControls && drawsWindowChrome && (
|
||||
<WindowControls
|
||||
onlyX={onlyXWindowControl}
|
||||
osType={osType}
|
||||
hideWindowControls={hideWindowControls}
|
||||
useNativeTitlebar={useNativeTitlebar}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
22
packages/ui/src/components/Heading.tsx
Normal file
22
packages/ui/src/components/Heading.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import classNames from "classnames";
|
||||
import type { HTMLAttributes } from "react";
|
||||
|
||||
interface Props extends HTMLAttributes<HTMLHeadingElement> {
|
||||
level?: 1 | 2 | 3;
|
||||
}
|
||||
|
||||
export function Heading({ className, level = 1, ...props }: Props) {
|
||||
const Component = level === 1 ? "h1" : level === 2 ? "h2" : "h3";
|
||||
return (
|
||||
<Component
|
||||
className={classNames(
|
||||
className,
|
||||
"font-semibold text-text",
|
||||
level === 1 && "text-2xl",
|
||||
level === 2 && "text-xl",
|
||||
level === 3 && "text-lg",
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
340
packages/ui/src/components/Icon.tsx
Normal file
340
packages/ui/src/components/Icon.tsx
Normal file
@@ -0,0 +1,340 @@
|
||||
import type { Color } from "@yaakapp-internal/plugins";
|
||||
import classNames from "classnames";
|
||||
import {
|
||||
AlarmClockIcon,
|
||||
AlertTriangleIcon,
|
||||
ArchiveIcon,
|
||||
ArrowBigDownDashIcon,
|
||||
ArrowBigLeftDashIcon,
|
||||
ArrowBigRightDashIcon,
|
||||
ArrowBigRightIcon,
|
||||
ArrowBigUpDashIcon,
|
||||
ArrowDownIcon,
|
||||
ArrowDownToDotIcon,
|
||||
ArrowDownToLineIcon,
|
||||
ArrowLeftIcon,
|
||||
ArrowRightCircleIcon,
|
||||
ArrowRightIcon,
|
||||
ArrowUpDownIcon,
|
||||
ArrowUpFromDotIcon,
|
||||
ArrowUpFromLineIcon,
|
||||
ArrowUpIcon,
|
||||
BadgeCheckIcon,
|
||||
BookOpenText,
|
||||
BoxIcon,
|
||||
CakeIcon,
|
||||
CheckCircleIcon,
|
||||
CheckIcon,
|
||||
ChevronDownIcon,
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
ChevronsDownUpIcon,
|
||||
ChevronsUpDownIcon,
|
||||
CircleAlertIcon,
|
||||
CircleDashedIcon,
|
||||
CircleDollarSignIcon,
|
||||
CircleFadingArrowUpIcon,
|
||||
CircleHelpIcon,
|
||||
CircleOffIcon,
|
||||
CirclePauseIcon,
|
||||
CirclePlayIcon,
|
||||
CircleStopIcon,
|
||||
ClipboardPasteIcon,
|
||||
ClockIcon,
|
||||
CodeIcon,
|
||||
Columns2Icon,
|
||||
CommandIcon,
|
||||
CookieIcon,
|
||||
CopyCheck,
|
||||
CopyIcon,
|
||||
CornerRightDownIcon,
|
||||
CornerRightUpIcon,
|
||||
CreditCardIcon,
|
||||
CrosshairIcon,
|
||||
DotIcon,
|
||||
DownloadIcon,
|
||||
EllipsisIcon,
|
||||
EllipsisVerticalIcon,
|
||||
ExpandIcon,
|
||||
ExternalLinkIcon,
|
||||
EyeIcon,
|
||||
EyeOffIcon,
|
||||
FileCodeIcon,
|
||||
FileIcon,
|
||||
FileTextIcon,
|
||||
FilterIcon,
|
||||
FlameIcon,
|
||||
FlaskConicalIcon,
|
||||
FolderCodeIcon,
|
||||
FolderCogIcon,
|
||||
FolderDownIcon,
|
||||
FolderGitIcon,
|
||||
FolderIcon,
|
||||
FolderInputIcon,
|
||||
FolderOpenIcon,
|
||||
FolderOutputIcon,
|
||||
FolderSymlinkIcon,
|
||||
FolderSyncIcon,
|
||||
FolderUpIcon,
|
||||
GiftIcon,
|
||||
GitBranchIcon,
|
||||
GitBranchPlusIcon,
|
||||
GitCommitIcon,
|
||||
GitCommitVerticalIcon,
|
||||
GitForkIcon,
|
||||
GitPullRequestIcon,
|
||||
GlobeIcon,
|
||||
GripVerticalIcon,
|
||||
HandIcon,
|
||||
HardDriveDownloadIcon,
|
||||
HistoryIcon,
|
||||
HomeIcon,
|
||||
ImportIcon,
|
||||
InfoIcon,
|
||||
KeyRoundIcon,
|
||||
KeyboardIcon,
|
||||
ListXIcon,
|
||||
LockIcon,
|
||||
LockOpenIcon,
|
||||
MergeIcon,
|
||||
MessageSquare,
|
||||
MinusCircleIcon,
|
||||
MinusIcon,
|
||||
MoonIcon,
|
||||
MoreVerticalIcon,
|
||||
PaletteIcon,
|
||||
PanelLeftCloseIcon,
|
||||
PanelLeftOpenIcon,
|
||||
PencilIcon,
|
||||
PinIcon,
|
||||
PinOffIcon,
|
||||
Plug,
|
||||
PlusCircleIcon,
|
||||
PlusIcon,
|
||||
PuzzleIcon,
|
||||
RefreshCcwIcon,
|
||||
RefreshCwIcon,
|
||||
RocketIcon,
|
||||
RotateCcwIcon,
|
||||
Rows2Icon,
|
||||
SaveIcon,
|
||||
SearchIcon,
|
||||
SendHorizontalIcon,
|
||||
SettingsIcon,
|
||||
ShieldAlertIcon,
|
||||
ShieldCheckIcon,
|
||||
ShieldIcon,
|
||||
ShieldOffIcon,
|
||||
SparklesIcon,
|
||||
SquareCheckIcon,
|
||||
SquareIcon,
|
||||
SquareTerminalIcon,
|
||||
SunIcon,
|
||||
TableIcon,
|
||||
Trash2Icon,
|
||||
Undo2Icon,
|
||||
UploadIcon,
|
||||
VariableIcon,
|
||||
Wand2Icon,
|
||||
WifiIcon,
|
||||
WrenchIcon,
|
||||
XIcon,
|
||||
ZapIcon,
|
||||
ZapOffIcon,
|
||||
} from "lucide-react";
|
||||
import type { CSSProperties, HTMLAttributes } from "react";
|
||||
import { memo } from "react";
|
||||
|
||||
const icons = {
|
||||
alarm_clock: AlarmClockIcon,
|
||||
alert_triangle: AlertTriangleIcon,
|
||||
archive: ArchiveIcon,
|
||||
arrow_big_down_dash: ArrowBigDownDashIcon,
|
||||
arrow_big_left_dash: ArrowBigLeftDashIcon,
|
||||
arrow_big_right: ArrowBigRightIcon,
|
||||
arrow_big_right_dash: ArrowBigRightDashIcon,
|
||||
arrow_big_up_dash: ArrowBigUpDashIcon,
|
||||
arrow_down: ArrowDownIcon,
|
||||
arrow_down_to_dot: ArrowDownToDotIcon,
|
||||
arrow_down_to_line: ArrowDownToLineIcon,
|
||||
arrow_left: ArrowLeftIcon,
|
||||
arrow_right: ArrowRightIcon,
|
||||
arrow_right_circle: ArrowRightCircleIcon,
|
||||
arrow_up: ArrowUpIcon,
|
||||
arrow_up_down: ArrowUpDownIcon,
|
||||
arrow_up_from_dot: ArrowUpFromDotIcon,
|
||||
arrow_up_from_line: ArrowUpFromLineIcon,
|
||||
badge_check: BadgeCheckIcon,
|
||||
book_open_text: BookOpenText,
|
||||
box: BoxIcon,
|
||||
cake: CakeIcon,
|
||||
chat: MessageSquare,
|
||||
check: CheckIcon,
|
||||
check_circle: CheckCircleIcon,
|
||||
check_square_checked: SquareCheckIcon,
|
||||
check_square_unchecked: SquareIcon,
|
||||
chevron_down: ChevronDownIcon,
|
||||
chevron_left: ChevronLeftIcon,
|
||||
chevrons_up_down: ChevronsUpDownIcon,
|
||||
chevrons_down_up: ChevronsDownUpIcon,
|
||||
chevron_right: ChevronRightIcon,
|
||||
circle_alert: CircleAlertIcon,
|
||||
circle_dashed: CircleDashedIcon,
|
||||
circle_dollar_sign: CircleDollarSignIcon,
|
||||
circle_fading_arrow_up: CircleFadingArrowUpIcon,
|
||||
clock: ClockIcon,
|
||||
code: CodeIcon,
|
||||
columns_2: Columns2Icon,
|
||||
command: CommandIcon,
|
||||
cookie: CookieIcon,
|
||||
copy: CopyIcon,
|
||||
copy_check: CopyCheck,
|
||||
corner_right_down: CornerRightDownIcon,
|
||||
corner_right_up: CornerRightUpIcon,
|
||||
credit_card: CreditCardIcon,
|
||||
crosshair: CrosshairIcon,
|
||||
dot: DotIcon,
|
||||
download: DownloadIcon,
|
||||
ellipsis: EllipsisIcon,
|
||||
ellipsis_vertical: EllipsisVerticalIcon,
|
||||
expand: ExpandIcon,
|
||||
external_link: ExternalLinkIcon,
|
||||
eye: EyeIcon,
|
||||
eye_closed: EyeOffIcon,
|
||||
file: FileIcon,
|
||||
file_code: FileCodeIcon,
|
||||
file_text: FileTextIcon,
|
||||
filter: FilterIcon,
|
||||
flame: FlameIcon,
|
||||
flask: FlaskConicalIcon,
|
||||
folder: FolderIcon,
|
||||
folder_code: FolderCodeIcon,
|
||||
folder_cog: FolderCogIcon,
|
||||
folder_git: FolderGitIcon,
|
||||
folder_input: FolderInputIcon,
|
||||
folder_open: FolderOpenIcon,
|
||||
folder_output: FolderOutputIcon,
|
||||
folder_symlink: FolderSymlinkIcon,
|
||||
folder_sync: FolderSyncIcon,
|
||||
folder_down: FolderDownIcon,
|
||||
folder_up: FolderUpIcon,
|
||||
gift: GiftIcon,
|
||||
git_branch: GitBranchIcon,
|
||||
git_branch_plus: GitBranchPlusIcon,
|
||||
git_commit: GitCommitIcon,
|
||||
git_commit_vertical: GitCommitVerticalIcon,
|
||||
git_fork: GitForkIcon,
|
||||
git_pull_request: GitPullRequestIcon,
|
||||
globe: GlobeIcon,
|
||||
grip_vertical: GripVerticalIcon,
|
||||
circle_off: CircleOffIcon,
|
||||
circle_pause: CirclePauseIcon,
|
||||
circle_play: CirclePlayIcon,
|
||||
circle_stop: CircleStopIcon,
|
||||
hand: HandIcon,
|
||||
hard_drive_download: HardDriveDownloadIcon,
|
||||
help: CircleHelpIcon,
|
||||
history: HistoryIcon,
|
||||
house: HomeIcon,
|
||||
import: ImportIcon,
|
||||
info: InfoIcon,
|
||||
key_round: KeyRoundIcon,
|
||||
keyboard: KeyboardIcon,
|
||||
left_panel_hidden: PanelLeftOpenIcon,
|
||||
left_panel_visible: PanelLeftCloseIcon,
|
||||
list_x: ListXIcon,
|
||||
lock: LockIcon,
|
||||
lock_open: LockOpenIcon,
|
||||
magic_wand: Wand2Icon,
|
||||
merge: MergeIcon,
|
||||
minus: MinusIcon,
|
||||
minus_circle: MinusCircleIcon,
|
||||
moon: MoonIcon,
|
||||
more_vertical: MoreVerticalIcon,
|
||||
palette: PaletteIcon,
|
||||
paste: ClipboardPasteIcon,
|
||||
pencil: PencilIcon,
|
||||
pin: PinIcon,
|
||||
plug: Plug,
|
||||
plus: PlusIcon,
|
||||
plus_circle: PlusCircleIcon,
|
||||
puzzle: PuzzleIcon,
|
||||
refresh: RefreshCwIcon,
|
||||
rocket: RocketIcon,
|
||||
rotate_ccw: RotateCcwIcon,
|
||||
rows_2: Rows2Icon,
|
||||
save: SaveIcon,
|
||||
search: SearchIcon,
|
||||
send_horizontal: SendHorizontalIcon,
|
||||
settings: SettingsIcon,
|
||||
shield: ShieldIcon,
|
||||
shield_check: ShieldCheckIcon,
|
||||
shield_off: ShieldOffIcon,
|
||||
sparkles: SparklesIcon,
|
||||
square_terminal: SquareTerminalIcon,
|
||||
sun: SunIcon,
|
||||
table: TableIcon,
|
||||
text: FileTextIcon,
|
||||
trash: Trash2Icon,
|
||||
undo_2: Undo2Icon,
|
||||
unpin: PinOffIcon,
|
||||
update: RefreshCcwIcon,
|
||||
upload: UploadIcon,
|
||||
variable: VariableIcon,
|
||||
wifi: WifiIcon,
|
||||
wrench: WrenchIcon,
|
||||
x: XIcon,
|
||||
zap: ZapIcon,
|
||||
zap_off: ZapOffIcon,
|
||||
_unknown: ShieldAlertIcon,
|
||||
|
||||
empty: (props: HTMLAttributes<HTMLSpanElement>) => <div {...props} />,
|
||||
};
|
||||
|
||||
export interface IconProps {
|
||||
icon: keyof typeof icons;
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
size?: "2xs" | "xs" | "sm" | "md" | "lg" | "xl";
|
||||
spin?: boolean;
|
||||
title?: string;
|
||||
color?: Color | "custom" | "default";
|
||||
}
|
||||
|
||||
export const Icon = memo(function Icon({
|
||||
icon,
|
||||
color = "default",
|
||||
spin,
|
||||
size = "md",
|
||||
style,
|
||||
className,
|
||||
title,
|
||||
}: IconProps) {
|
||||
const Component = icons[icon] ?? icons._unknown;
|
||||
return (
|
||||
<Component
|
||||
style={style}
|
||||
title={title}
|
||||
className={classNames(
|
||||
className,
|
||||
!spin && "transform-gpu",
|
||||
spin && "animate-spin",
|
||||
"shrink-0",
|
||||
size === "xl" && "h-6 w-6",
|
||||
size === "lg" && "h-5 w-5",
|
||||
size === "md" && "h-4 w-4",
|
||||
size === "sm" && "h-3.5 w-3.5",
|
||||
size === "xs" && "h-3 w-3",
|
||||
size === "2xs" && "h-2.5 w-2.5",
|
||||
color === "default" && "inherit",
|
||||
color === "danger" && "text-danger",
|
||||
color === "warning" && "text-warning",
|
||||
color === "notice" && "text-notice",
|
||||
color === "info" && "text-info",
|
||||
color === "success" && "text-success",
|
||||
color === "primary" && "text-primary",
|
||||
color === "secondary" && "text-secondary",
|
||||
)}
|
||||
/>
|
||||
);
|
||||
});
|
||||
95
packages/ui/src/components/IconButton.tsx
Normal file
95
packages/ui/src/components/IconButton.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import classNames from "classnames";
|
||||
import type { MouseEvent } from "react";
|
||||
import { forwardRef, useCallback } from "react";
|
||||
import { useTimedBoolean } from "../hooks/useTimedBoolean";
|
||||
import type { ButtonProps } from "./Button";
|
||||
import { Button } from "./Button";
|
||||
import type { IconProps } from "./Icon";
|
||||
import { Icon } from "./Icon";
|
||||
import { LoadingIcon } from "./LoadingIcon";
|
||||
|
||||
export type IconButtonProps = IconProps &
|
||||
ButtonProps & {
|
||||
showConfirm?: boolean;
|
||||
iconClassName?: string;
|
||||
iconSize?: IconProps["size"];
|
||||
iconColor?: IconProps["color"];
|
||||
title: string;
|
||||
showBadge?: boolean;
|
||||
};
|
||||
|
||||
export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(function IconButton(
|
||||
{
|
||||
showConfirm,
|
||||
icon,
|
||||
color = "default",
|
||||
spin,
|
||||
onClick,
|
||||
className,
|
||||
iconClassName,
|
||||
tabIndex,
|
||||
size = "md",
|
||||
iconSize,
|
||||
showBadge,
|
||||
iconColor,
|
||||
isLoading,
|
||||
type = "button",
|
||||
...props
|
||||
}: IconButtonProps,
|
||||
ref,
|
||||
) {
|
||||
const [confirmed, setConfirmed] = useTimedBoolean();
|
||||
const handleClick = useCallback(
|
||||
(e: MouseEvent<HTMLButtonElement>) => {
|
||||
if (showConfirm) setConfirmed();
|
||||
onClick?.(e);
|
||||
},
|
||||
[onClick, setConfirmed, showConfirm],
|
||||
);
|
||||
|
||||
return (
|
||||
<Button
|
||||
ref={ref}
|
||||
aria-hidden={icon === "empty"}
|
||||
disabled={icon === "empty"}
|
||||
tabIndex={(tabIndex ?? icon === "empty") ? -1 : undefined}
|
||||
onClick={handleClick}
|
||||
innerClassName="flex items-center justify-center"
|
||||
size={size}
|
||||
color={color}
|
||||
type={type}
|
||||
className={classNames(
|
||||
className,
|
||||
"group/button relative shrink-0",
|
||||
"px-0!",
|
||||
size === "md" && "w-md",
|
||||
size === "sm" && "w-sm",
|
||||
size === "xs" && "w-xs",
|
||||
size === "2xs" && "w-5",
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{showBadge && (
|
||||
<div className="absolute top-0 right-0 w-1/2 h-1/2 flex items-center justify-center">
|
||||
<div className="w-2.5 h-2.5 bg-pink-500 rounded-full" />
|
||||
</div>
|
||||
)}
|
||||
{isLoading ? (
|
||||
<LoadingIcon size={iconSize} className={iconClassName} />
|
||||
) : (
|
||||
<Icon
|
||||
size={iconSize}
|
||||
icon={confirmed ? "check" : icon}
|
||||
spin={spin}
|
||||
color={iconColor}
|
||||
className={classNames(
|
||||
iconClassName,
|
||||
"group-hover/button:text-text",
|
||||
confirmed && "text-success!",
|
||||
props.disabled && "opacity-70",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
});
|
||||
15
packages/ui/src/components/InlineCode.tsx
Normal file
15
packages/ui/src/components/InlineCode.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import classNames from "classnames";
|
||||
import type { HTMLAttributes } from "react";
|
||||
|
||||
export function InlineCode({ className, ...props }: HTMLAttributes<HTMLSpanElement>) {
|
||||
return (
|
||||
<code
|
||||
className={classNames(
|
||||
className,
|
||||
"font-mono text-shrink bg-surface-highlight border border-border-subtle grow-0",
|
||||
"px-1.5 py-0.5 rounded-sm text shadow-inner wrap-break-word",
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
35
packages/ui/src/components/LoadingIcon.tsx
Normal file
35
packages/ui/src/components/LoadingIcon.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import classNames from "classnames";
|
||||
|
||||
interface Props {
|
||||
size?: "2xs" | "xs" | "sm" | "md" | "lg" | "xl";
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function LoadingIcon({ size = "md", className }: Props) {
|
||||
const classes = classNames(
|
||||
className,
|
||||
"text-inherit shrink-0",
|
||||
size === "xl" && "h-6 w-6",
|
||||
size === "lg" && "h-5 w-5",
|
||||
size === "md" && "h-4 w-4",
|
||||
size === "sm" && "h-3.5 w-3.5",
|
||||
size === "xs" && "h-3 w-3",
|
||||
size === "2xs" && "h-2.5 w-2.5",
|
||||
"animate-spin",
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
classes,
|
||||
"border-[currentColor] border-b-transparent rounded-full",
|
||||
size === "xl" && "border-[0.2rem]",
|
||||
size === "lg" && "border-[0.16rem]",
|
||||
size === "md" && "border-[0.13rem]",
|
||||
size === "sm" && "border-[0.1rem]",
|
||||
size === "xs" && "border-[0.08rem]",
|
||||
size === "2xs" && "border-[0.06rem]",
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
90
packages/ui/src/components/Overlay.tsx
Normal file
90
packages/ui/src/components/Overlay.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import classNames from "classnames";
|
||||
import { FocusTrap } from "focus-trap-react";
|
||||
import * as m from "motion/react-m";
|
||||
import type { ReactNode } from "react";
|
||||
import { useRef } from "react";
|
||||
import { Portal } from "./Portal";
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
portalName: string;
|
||||
open: boolean;
|
||||
onClose?: () => void;
|
||||
zIndex?: keyof typeof zIndexes;
|
||||
variant?: "default" | "transparent";
|
||||
noBackdrop?: boolean;
|
||||
}
|
||||
|
||||
const zIndexes: Record<number, string> = {
|
||||
10: "z-10",
|
||||
20: "z-20",
|
||||
30: "z-30",
|
||||
40: "z-40",
|
||||
50: "z-50",
|
||||
};
|
||||
|
||||
export function Overlay({
|
||||
variant = "default",
|
||||
zIndex = 30,
|
||||
open,
|
||||
onClose,
|
||||
portalName,
|
||||
noBackdrop,
|
||||
children,
|
||||
}: Props) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
if (noBackdrop) {
|
||||
return (
|
||||
<Portal name={portalName}>
|
||||
{open && (
|
||||
<FocusTrap focusTrapOptions={{ clickOutsideDeactivates: true }}>
|
||||
{/* NOTE: <div> wrapper is required for some reason, or FocusTrap complains */}
|
||||
<div>{children}</div>
|
||||
</FocusTrap>
|
||||
)}
|
||||
</Portal>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Portal name={portalName}>
|
||||
{open && (
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
// Allow outside click so we can click things like toasts
|
||||
allowOutsideClick: true,
|
||||
delayInitialFocus: true,
|
||||
checkCanFocusTrap: async () => {
|
||||
// Not sure why delayInitialFocus: true doesn't help, but having this no-op promise
|
||||
// seems to be required to make things work.
|
||||
},
|
||||
}}
|
||||
>
|
||||
<m.div
|
||||
ref={containerRef}
|
||||
className={classNames("fixed inset-0", zIndexes[zIndex])}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
>
|
||||
<div
|
||||
aria-hidden
|
||||
onClick={onClose}
|
||||
className={classNames(
|
||||
"absolute inset-0",
|
||||
variant === "default" && "bg-backdrop backdrop-blur-xs",
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Show the draggable region at the top */}
|
||||
{/* TODO: Figure out tauri drag region and also make clickable still */}
|
||||
{variant === "default" && (
|
||||
<div data-tauri-drag-region className="absolute top-0 left-0 h-md right-0" />
|
||||
)}
|
||||
{children}
|
||||
</m.div>
|
||||
</FocusTrap>
|
||||
)}
|
||||
</Portal>
|
||||
);
|
||||
}
|
||||
13
packages/ui/src/components/Portal.tsx
Normal file
13
packages/ui/src/components/Portal.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { usePortal } from "../hooks/usePortal";
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export function Portal({ children, name }: Props) {
|
||||
const portal = usePortal(name);
|
||||
return createPortal(children, portal);
|
||||
}
|
||||
112
packages/ui/src/components/ResizeHandle.tsx
Normal file
112
packages/ui/src/components/ResizeHandle.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import classNames from "classnames";
|
||||
import type { CSSProperties, MouseEvent as ReactMouseEvent } from "react";
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
|
||||
const START_DISTANCE = 7;
|
||||
|
||||
export interface ResizeHandleEvent {
|
||||
x: number;
|
||||
y: number;
|
||||
xStart: number;
|
||||
yStart: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
style?: CSSProperties;
|
||||
className?: string;
|
||||
onResizeStart?: () => void;
|
||||
onResizeEnd?: () => void;
|
||||
onResizeMove?: (e: ResizeHandleEvent) => void;
|
||||
onReset?: () => void;
|
||||
side: "left" | "right" | "top";
|
||||
justify: "center" | "end" | "start";
|
||||
}
|
||||
|
||||
export function ResizeHandle({
|
||||
style,
|
||||
justify,
|
||||
className,
|
||||
onResizeStart,
|
||||
onResizeEnd,
|
||||
onResizeMove,
|
||||
onReset,
|
||||
side,
|
||||
}: Props) {
|
||||
const vertical = side === "top";
|
||||
const [isResizing, setIsResizing] = useState<boolean>(false);
|
||||
const moveState = useRef<{
|
||||
move: (e: MouseEvent) => void;
|
||||
up: (e: MouseEvent) => void;
|
||||
calledStart: boolean;
|
||||
xStart: number;
|
||||
yStart: number;
|
||||
} | null>(null);
|
||||
|
||||
const handlePointerDown = useCallback(
|
||||
(e: ReactMouseEvent<HTMLDivElement>) => {
|
||||
function move(e: MouseEvent) {
|
||||
if (moveState.current == null) return;
|
||||
|
||||
const xDistance = moveState.current.xStart - e.clientX;
|
||||
const yDistance = moveState.current.yStart - e.clientY;
|
||||
const distance = Math.abs(vertical ? yDistance : xDistance);
|
||||
if (moveState.current.calledStart) {
|
||||
onResizeMove?.({
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
xStart: moveState.current.xStart,
|
||||
yStart: moveState.current.yStart,
|
||||
});
|
||||
} else if (distance > START_DISTANCE) {
|
||||
onResizeStart?.();
|
||||
moveState.current.calledStart = true;
|
||||
setIsResizing(true);
|
||||
}
|
||||
}
|
||||
|
||||
function up() {
|
||||
setIsResizing(false);
|
||||
moveState.current = null;
|
||||
document.documentElement.removeEventListener("mousemove", move);
|
||||
document.documentElement.removeEventListener("mouseup", up);
|
||||
onResizeEnd?.();
|
||||
}
|
||||
|
||||
moveState.current = { calledStart: false, xStart: e.clientX, yStart: e.clientY, move, up };
|
||||
|
||||
document.documentElement.addEventListener("mousemove", move);
|
||||
document.documentElement.addEventListener("mouseup", up);
|
||||
},
|
||||
[onResizeEnd, onResizeMove, onResizeStart, vertical],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-hidden
|
||||
style={style}
|
||||
onDoubleClick={onReset}
|
||||
onPointerDown={handlePointerDown}
|
||||
className={classNames(
|
||||
className,
|
||||
"group z-10 flex select-none transition-colors hover:bg-surface-active rounded-full",
|
||||
vertical ? "w-full h-1.5 cursor-row-resize" : "h-full w-1.5 cursor-col-resize",
|
||||
justify === "center" && "justify-center",
|
||||
justify === "end" && "justify-end",
|
||||
justify === "start" && "justify-start",
|
||||
side === "right" && "right-0",
|
||||
side === "left" && "left-0",
|
||||
side === "top" && "top-0",
|
||||
)}
|
||||
>
|
||||
{isResizing && (
|
||||
<div
|
||||
className={classNames(
|
||||
"fixed left-[-100vw] right-[-100vw] top-[-100vh] bottom-[-100vh]",
|
||||
vertical && "cursor-row-resize",
|
||||
!vertical && "cursor-col-resize",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
146
packages/ui/src/components/SidebarLayout.tsx
Normal file
146
packages/ui/src/components/SidebarLayout.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
import classNames from "classnames";
|
||||
import * as m from "motion/react-m";
|
||||
import type { CSSProperties, ReactNode } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useContainerSize } from "../hooks/useContainerSize";
|
||||
import { Overlay } from "./Overlay";
|
||||
import type { ResizeHandleEvent } from "./ResizeHandle";
|
||||
import { ResizeHandle } from "./ResizeHandle";
|
||||
|
||||
const FLOATING_BREAKPOINT = 600;
|
||||
|
||||
const side = { gridArea: "side", minWidth: 0 };
|
||||
const drag = { gridArea: "drag" };
|
||||
const body = { gridArea: "body", minWidth: 0 };
|
||||
|
||||
interface Props {
|
||||
width: number;
|
||||
onWidthChange: (width: number) => void;
|
||||
hidden?: boolean;
|
||||
onHiddenChange?: (hidden: boolean) => void;
|
||||
floatingHidden?: boolean;
|
||||
onFloatingHiddenChange?: (hidden: boolean) => void;
|
||||
onFloatingChange?: (floating: boolean) => void;
|
||||
floatingWidth?: number;
|
||||
defaultWidth?: number;
|
||||
minWidth?: number;
|
||||
className?: string;
|
||||
sidebar: ReactNode;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function SidebarLayout({
|
||||
width,
|
||||
onWidthChange,
|
||||
hidden = false,
|
||||
onHiddenChange,
|
||||
floatingHidden = true,
|
||||
onFloatingHiddenChange,
|
||||
onFloatingChange,
|
||||
floatingWidth = 320,
|
||||
defaultWidth = 250,
|
||||
minWidth = 50,
|
||||
className,
|
||||
sidebar,
|
||||
children,
|
||||
}: Props) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const containerSize = useContainerSize(containerRef);
|
||||
const floating = containerSize.width > 0 && containerSize.width <= FLOATING_BREAKPOINT;
|
||||
|
||||
useEffect(() => {
|
||||
onFloatingChange?.(floating);
|
||||
}, [floating]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
const startWidth = useRef<number | null>(null);
|
||||
|
||||
const sideWidth = hidden ? 0 : width;
|
||||
|
||||
const styles = useMemo<CSSProperties>(
|
||||
() => ({
|
||||
gridTemplate: `
|
||||
' ${side.gridArea} ${drag.gridArea} ${body.gridArea}' minmax(0,1fr)
|
||||
/ ${sideWidth}px 0 1fr`,
|
||||
}),
|
||||
[sideWidth],
|
||||
);
|
||||
|
||||
const handleResizeStart = useCallback(() => {
|
||||
startWidth.current = width;
|
||||
setIsResizing(true);
|
||||
}, [width]);
|
||||
|
||||
const handleResizeEnd = useCallback(() => {
|
||||
setIsResizing(false);
|
||||
startWidth.current = null;
|
||||
}, []);
|
||||
|
||||
const handleResizeMove = useCallback(
|
||||
({ x, xStart }: ResizeHandleEvent) => {
|
||||
if (startWidth.current == null) return;
|
||||
|
||||
const newWidth = startWidth.current + (x - xStart);
|
||||
if (newWidth < minWidth) {
|
||||
onHiddenChange?.(true);
|
||||
onWidthChange(defaultWidth);
|
||||
} else {
|
||||
if (hidden) onHiddenChange?.(false);
|
||||
onWidthChange(newWidth);
|
||||
}
|
||||
},
|
||||
[minWidth, hidden, onHiddenChange, onWidthChange, defaultWidth],
|
||||
);
|
||||
|
||||
const handleReset = useCallback(() => {
|
||||
onWidthChange(defaultWidth);
|
||||
}, [onWidthChange, defaultWidth]);
|
||||
|
||||
if (floating) {
|
||||
return (
|
||||
<div ref={containerRef} className={classNames(className, "w-full h-full min-h-0")}>
|
||||
<Overlay
|
||||
open={!floatingHidden}
|
||||
portalName="sidebar"
|
||||
onClose={() => onFloatingHiddenChange?.(true)}
|
||||
zIndex={20}
|
||||
>
|
||||
<m.div
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
style={{ width: floatingWidth }}
|
||||
className="absolute top-0 left-0 bottom-0"
|
||||
>
|
||||
{sidebar}
|
||||
</m.div>
|
||||
</Overlay>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
style={styles}
|
||||
className={classNames(className, "grid w-full h-full", !isResizing && "transition-grid")}
|
||||
>
|
||||
<div style={side} className="overflow-hidden">
|
||||
{sidebar}
|
||||
</div>
|
||||
<ResizeHandle
|
||||
style={drag}
|
||||
className="-translate-x-px"
|
||||
justify="end"
|
||||
side="right"
|
||||
onResizeStart={handleResizeStart}
|
||||
onResizeEnd={handleResizeEnd}
|
||||
onResizeMove={handleResizeMove}
|
||||
onReset={handleReset}
|
||||
/>
|
||||
<div style={body} className="min-w-0">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
168
packages/ui/src/components/SplitLayout.tsx
Normal file
168
packages/ui/src/components/SplitLayout.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
import classNames from "classnames";
|
||||
import type { CSSProperties, ReactNode } from "react";
|
||||
import { useCallback, useMemo, useRef } from "react";
|
||||
import { useLocalStorage } from "react-use";
|
||||
import { useContainerSize } from "../hooks/useContainerSize";
|
||||
import { clamp } from "../lib/clamp";
|
||||
import type { ResizeHandleEvent } from "./ResizeHandle";
|
||||
import { ResizeHandle } from "./ResizeHandle";
|
||||
|
||||
export type SplitLayoutLayout = "responsive" | "horizontal" | "vertical";
|
||||
|
||||
export interface SlotProps {
|
||||
orientation: "horizontal" | "vertical";
|
||||
style: CSSProperties;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
storageKey: string;
|
||||
firstSlot: (props: SlotProps) => ReactNode;
|
||||
secondSlot: null | ((props: SlotProps) => ReactNode);
|
||||
style?: CSSProperties;
|
||||
className?: string;
|
||||
defaultRatio?: number;
|
||||
minHeightPx?: number;
|
||||
minWidthPx?: number;
|
||||
layout?: SplitLayoutLayout;
|
||||
resizeHandleClassName?: string;
|
||||
}
|
||||
|
||||
const baseProperties = { minHeight: 0, minWidth: 0 };
|
||||
const areaL = { ...baseProperties, gridArea: "left" };
|
||||
const areaR = { ...baseProperties, gridArea: "right" };
|
||||
const areaD = { ...baseProperties, gridArea: "drag" };
|
||||
|
||||
const STACK_VERTICAL_WIDTH = 500;
|
||||
|
||||
export function SplitLayout({
|
||||
style,
|
||||
firstSlot,
|
||||
secondSlot,
|
||||
className,
|
||||
storageKey,
|
||||
layout = "responsive",
|
||||
resizeHandleClassName,
|
||||
defaultRatio = 0.5,
|
||||
minHeightPx = 10,
|
||||
minWidthPx = 10,
|
||||
}: Props) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [widthRaw, setWidth] = useLocalStorage<number>(`${storageKey}_width`);
|
||||
const [heightRaw, setHeight] = useLocalStorage<number>(`${storageKey}_height`);
|
||||
const width = widthRaw ?? defaultRatio;
|
||||
let height = heightRaw ?? defaultRatio;
|
||||
|
||||
if (!secondSlot) {
|
||||
height = 0;
|
||||
minHeightPx = 0;
|
||||
}
|
||||
|
||||
const size = useContainerSize(containerRef);
|
||||
const verticalBasedOnSize = size.width !== 0 && size.width < STACK_VERTICAL_WIDTH;
|
||||
const vertical = layout !== "horizontal" && (layout === "vertical" || verticalBasedOnSize);
|
||||
const renderedWidth = clampSplitRatio(width, minWidthPx, size.width);
|
||||
const renderedHeight = secondSlot ? clampSplitRatio(height, minHeightPx, size.height) : 0;
|
||||
|
||||
const styles = useMemo<CSSProperties>(() => {
|
||||
return {
|
||||
...style,
|
||||
gridTemplate: vertical
|
||||
? `
|
||||
' ${areaL.gridArea}' minmax(0,${1 - renderedHeight}fr)
|
||||
' ${areaD.gridArea}' 0
|
||||
' ${areaR.gridArea}' minmax(0,${renderedHeight}fr)
|
||||
/ 1fr
|
||||
`
|
||||
: `
|
||||
' ${areaL.gridArea} ${areaD.gridArea} ${areaR.gridArea}' minmax(0,1fr)
|
||||
/ ${1 - renderedWidth}fr 0 ${renderedWidth}fr
|
||||
`,
|
||||
};
|
||||
}, [style, vertical, renderedHeight, renderedWidth]);
|
||||
|
||||
const handleReset = useCallback(() => {
|
||||
if (vertical) setHeight(defaultRatio);
|
||||
else setWidth(defaultRatio);
|
||||
}, [vertical, setHeight, defaultRatio, setWidth]);
|
||||
|
||||
const handleResizeMove = useCallback(
|
||||
(e: ResizeHandleEvent) => {
|
||||
if (containerRef.current === null) return;
|
||||
|
||||
const { paddingLeft, paddingRight, paddingTop, paddingBottom } = getComputedStyle(
|
||||
containerRef.current,
|
||||
);
|
||||
const $c = containerRef.current;
|
||||
const containerWidth =
|
||||
$c.clientWidth - Number.parseFloat(paddingLeft) - Number.parseFloat(paddingRight);
|
||||
const containerHeight =
|
||||
$c.clientHeight - Number.parseFloat(paddingTop) - Number.parseFloat(paddingBottom);
|
||||
|
||||
if ((vertical && containerHeight <= 0) || (!vertical && containerWidth <= 0)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const mouseStartX = e.xStart;
|
||||
const mouseStartY = e.yStart;
|
||||
const startWidth = containerWidth * renderedWidth;
|
||||
const startHeight = containerHeight * renderedHeight;
|
||||
|
||||
if (vertical) {
|
||||
const minHeight = Math.min(minHeightPx, containerHeight);
|
||||
const maxHeightPx = Math.max(minHeight, containerHeight - minHeightPx);
|
||||
const newHeightPx = clamp(startHeight - (e.y - mouseStartY), minHeight, maxHeightPx);
|
||||
setHeight(newHeightPx / containerHeight);
|
||||
} else {
|
||||
const minWidth = Math.min(minWidthPx, containerWidth);
|
||||
const maxWidthPx = Math.max(minWidth, containerWidth - minWidthPx);
|
||||
const newWidthPx = clamp(startWidth - (e.x - mouseStartX), minWidth, maxWidthPx);
|
||||
setWidth(newWidthPx / containerWidth);
|
||||
}
|
||||
},
|
||||
[
|
||||
renderedWidth,
|
||||
renderedHeight,
|
||||
vertical,
|
||||
minHeightPx,
|
||||
setHeight,
|
||||
minWidthPx,
|
||||
setWidth,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
style={styles}
|
||||
className={classNames(className, "grid w-full h-full overflow-hidden")}
|
||||
>
|
||||
{firstSlot({ style: areaL, orientation: vertical ? "vertical" : "horizontal" })}
|
||||
{secondSlot && (
|
||||
<>
|
||||
<ResizeHandle
|
||||
style={areaD}
|
||||
className={classNames(
|
||||
resizeHandleClassName,
|
||||
vertical ? "-translate-y-1" : "-translate-x-1",
|
||||
)}
|
||||
onResizeMove={handleResizeMove}
|
||||
onReset={handleReset}
|
||||
side={vertical ? "top" : "left"}
|
||||
justify="center"
|
||||
/>
|
||||
{secondSlot({ style: areaR, orientation: vertical ? "vertical" : "horizontal" })}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function clampSplitRatio(ratio: number, minPx: number, containerPx: number): number {
|
||||
if (containerPx <= 0 || minPx <= 0) {
|
||||
return ratio;
|
||||
}
|
||||
|
||||
const minRatio = Math.min(1, minPx / containerPx);
|
||||
const maxRatio = minRatio >= 0.5 ? minRatio : 1 - minRatio;
|
||||
return clamp(ratio, minRatio, maxRatio);
|
||||
}
|
||||
93
packages/ui/src/components/Stacks.tsx
Normal file
93
packages/ui/src/components/Stacks.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import classNames from "classnames";
|
||||
import type { ComponentType, ForwardedRef, HTMLAttributes, ReactNode } from "react";
|
||||
import { forwardRef } from "react";
|
||||
|
||||
const gapClasses = {
|
||||
0: "gap-0",
|
||||
0.5: "gap-0.5",
|
||||
1: "gap-1",
|
||||
1.5: "gap-1.5",
|
||||
2: "gap-2",
|
||||
3: "gap-3",
|
||||
4: "gap-4",
|
||||
5: "gap-5",
|
||||
6: "gap-6",
|
||||
};
|
||||
|
||||
interface HStackProps extends BaseStackProps {
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
export const HStack = forwardRef(function HStack(
|
||||
{ className, space, children, alignItems = "center", ...props }: HStackProps,
|
||||
// oxlint-disable-next-line no-explicit-any
|
||||
ref: ForwardedRef<any>,
|
||||
) {
|
||||
return (
|
||||
<BaseStack
|
||||
ref={ref}
|
||||
className={classNames(className, "flex-row", space != null && gapClasses[space])}
|
||||
alignItems={alignItems}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</BaseStack>
|
||||
);
|
||||
});
|
||||
|
||||
export type VStackProps = BaseStackProps & {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export const VStack = forwardRef(function VStack(
|
||||
{ className, space, children, ...props }: VStackProps,
|
||||
// oxlint-disable-next-line no-explicit-any
|
||||
ref: ForwardedRef<any>,
|
||||
) {
|
||||
return (
|
||||
<BaseStack
|
||||
ref={ref}
|
||||
className={classNames(className, "flex-col", space != null && gapClasses[space])}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</BaseStack>
|
||||
);
|
||||
});
|
||||
|
||||
type BaseStackProps = HTMLAttributes<HTMLElement> & {
|
||||
as?: ComponentType | "ul" | "label" | "form" | "p";
|
||||
space?: keyof typeof gapClasses;
|
||||
alignItems?: "start" | "center" | "stretch" | "end";
|
||||
justifyContent?: "start" | "center" | "end" | "between";
|
||||
wrap?: boolean;
|
||||
};
|
||||
|
||||
const BaseStack = forwardRef(function BaseStack(
|
||||
{ className, alignItems, justifyContent, wrap, children, as, ...props }: BaseStackProps,
|
||||
// oxlint-disable-next-line no-explicit-any
|
||||
ref: ForwardedRef<any>,
|
||||
) {
|
||||
const Component = as ?? "div";
|
||||
return (
|
||||
<Component
|
||||
ref={ref}
|
||||
className={classNames(
|
||||
className,
|
||||
"flex",
|
||||
wrap && "flex-wrap",
|
||||
alignItems === "center" && "items-center",
|
||||
alignItems === "start" && "items-start",
|
||||
alignItems === "stretch" && "items-stretch",
|
||||
alignItems === "end" && "items-end",
|
||||
justifyContent === "start" && "justify-start",
|
||||
justifyContent === "center" && "justify-center",
|
||||
justifyContent === "end" && "justify-end",
|
||||
justifyContent === "between" && "justify-between",
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Component>
|
||||
);
|
||||
});
|
||||
128
packages/ui/src/components/Table.tsx
Normal file
128
packages/ui/src/components/Table.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
import classNames from "classnames";
|
||||
import type { HTMLAttributes } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export function Table({
|
||||
children,
|
||||
className,
|
||||
scrollable,
|
||||
style,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
scrollable?: boolean;
|
||||
style?: React.CSSProperties;
|
||||
}) {
|
||||
return (
|
||||
<div style={style} className={classNames("w-full", scrollable && "h-full overflow-y-auto")}>
|
||||
<table
|
||||
className={classNames(
|
||||
className,
|
||||
"w-full text-sm mb-auto min-w-full max-w-full",
|
||||
"border-separate border-spacing-0",
|
||||
scrollable && "[&_thead]:sticky [&_thead]:top-0 [&_thead]:z-10",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TableBody({ children, className }: { children: ReactNode; className?: string }) {
|
||||
return (
|
||||
<tbody
|
||||
className={classNames(
|
||||
className,
|
||||
"[&>tr:not(:last-child):not([data-table-spacer])>td]:border-b",
|
||||
"[&>tr:not(:last-child):not([data-table-spacer])>td]:border-b-surface-highlight",
|
||||
)}
|
||||
>
|
||||
<tr aria-hidden data-table-spacer className="h-0.5">
|
||||
<td className="p-0" colSpan={1000} />
|
||||
</tr>
|
||||
{children}
|
||||
</tbody>
|
||||
);
|
||||
}
|
||||
|
||||
export function TableHead({ children, className }: { children: ReactNode; className?: string }) {
|
||||
return (
|
||||
<thead
|
||||
className={classNames(
|
||||
className,
|
||||
"bg-surface [&_th]:border-b [&_th]:border-b-surface-highlight",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</thead>
|
||||
);
|
||||
}
|
||||
|
||||
export function TableRow({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
} & HTMLAttributes<HTMLTableRowElement>) {
|
||||
return (
|
||||
<tr className={className} {...props}>
|
||||
{children}
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
export function TableCell({
|
||||
children,
|
||||
className,
|
||||
align = "left",
|
||||
}: {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
align?: "left" | "center" | "right";
|
||||
}) {
|
||||
return (
|
||||
<td
|
||||
className={classNames(
|
||||
className,
|
||||
"py-2 not-first:pl-4 whitespace-nowrap",
|
||||
align === "left" ? "text-left" : align === "center" ? "text-center" : "text-right",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</td>
|
||||
);
|
||||
}
|
||||
|
||||
export function TruncatedWideTableCell({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<TableCell className={classNames(className, "truncate max-w-0 w-full")}>{children}</TableCell>
|
||||
);
|
||||
}
|
||||
|
||||
export function TableHeaderCell({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children?: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<th
|
||||
className={classNames(
|
||||
className,
|
||||
"whitespace-nowrap py-2 not-first:pl-4 text-left text-text-subtle",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</th>
|
||||
);
|
||||
}
|
||||
99
packages/ui/src/components/WindowControls.tsx
Normal file
99
packages/ui/src/components/WindowControls.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import { platform } from "@yaakapp-internal/platform";
|
||||
import classNames from "classnames";
|
||||
import { useState } from "react";
|
||||
import { WINDOW_CONTROLS_WIDTH } from "../lib/constants";
|
||||
import { Button } from "./Button";
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
onlyX?: boolean;
|
||||
osType: string;
|
||||
hideWindowControls: boolean;
|
||||
useNativeTitlebar: boolean;
|
||||
}
|
||||
|
||||
export function WindowControls({
|
||||
className,
|
||||
onlyX,
|
||||
osType,
|
||||
hideWindowControls,
|
||||
useNativeTitlebar,
|
||||
}: Props) {
|
||||
const [maximized, setMaximized] = useState<boolean>(false);
|
||||
|
||||
// Never show controls on macOS or if hideWindowControls is true
|
||||
if (osType === "macos" || hideWindowControls || useNativeTitlebar) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
className,
|
||||
"ml-4 absolute right-0 top-0 bottom-0 flex items-center justify-end",
|
||||
)}
|
||||
style={{ width: WINDOW_CONTROLS_WIDTH }}
|
||||
data-tauri-drag-region
|
||||
>
|
||||
{!onlyX && (
|
||||
<>
|
||||
<Button
|
||||
className="h-full! px-4 text-text-subtle hocus:text hocus:bg-surface-highlight rounded-none"
|
||||
color="custom"
|
||||
onClick={() => platform.window.minimize()}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||
<title>Minimize</title>
|
||||
<path fill="currentColor" d="M14 8v1H3V8z" />
|
||||
</svg>
|
||||
</Button>
|
||||
<Button
|
||||
className="h-full! px-4 text-text-subtle hocus:text hocus:bg-surface-highlight rounded-none"
|
||||
color="custom"
|
||||
onClick={async () => {
|
||||
const w = platform.window;
|
||||
const isMaximized = await w.isMaximized();
|
||||
if (isMaximized) {
|
||||
await w.unmaximize();
|
||||
setMaximized(false);
|
||||
} else {
|
||||
await w.maximize();
|
||||
setMaximized(true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{maximized ? (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||
<title>Unmaximize</title>
|
||||
<g fill="currentColor">
|
||||
<path d="M3 5v9h9V5zm8 8H4V6h7z" />
|
||||
<path fillRule="evenodd" d="M5 5h1V4h7v7h-1v1h2V3H5z" clipRule="evenodd" />
|
||||
</g>
|
||||
</svg>
|
||||
) : (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||
<title>Maximize</title>
|
||||
<path fill="currentColor" d="M3 3v10h10V3zm9 9H4V4h8z" />
|
||||
</svg>
|
||||
)}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
color="custom"
|
||||
className="h-full! px-4 text-text-subtle rounded-none hocus:bg-danger hocus:text-text"
|
||||
onClick={() => platform.window.close()}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||
<title>Close</title>
|
||||
<path
|
||||
fill="currentColor"
|
||||
fillRule="evenodd"
|
||||
d="m7.116 8l-4.558 4.558l.884.884L8 8.884l4.558 4.558l.884-.884L8.884 8l4.558-4.558l-.884-.884L8 7.116L3.442 2.558l-.884.884z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
835
packages/ui/src/components/tree/Tree.tsx
Normal file
835
packages/ui/src/components/tree/Tree.tsx
Normal file
@@ -0,0 +1,835 @@
|
||||
import type { DragEndEvent, DragMoveEvent, DragStartEvent } from "@dnd-kit/core";
|
||||
import type { Virtualizer } from "@tanstack/react-virtual";
|
||||
import {
|
||||
DndContext,
|
||||
MeasuringStrategy,
|
||||
PointerSensor,
|
||||
pointerWithin,
|
||||
useDroppable,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from "@dnd-kit/core";
|
||||
import { platform } from "@yaakapp-internal/platform";
|
||||
import classNames from "classnames";
|
||||
import type { ComponentType, MouseEvent, ReactElement, Ref, RefAttributes } from "react";
|
||||
import {
|
||||
forwardRef,
|
||||
memo,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useKey, useKeyPressEvent } from "react-use";
|
||||
import { computeSideForDragMove } from "../../lib/dnd";
|
||||
import { useAtomValue, useStore } from "jotai";
|
||||
import { draggingIdsFamily, focusIdsFamily, hoveredParentFamily, selectedIdsFamily } from "./atoms";
|
||||
import { type CollapsedAtom, CollapsedAtomContext } from "./context";
|
||||
import type { ContextMenuRenderer, TreeNode } from "./common";
|
||||
import { closestVisibleNode, equalSubtree, getSelectedItems, hasAncestor } from "./common";
|
||||
import { TreeDragOverlay } from "./TreeDragOverlay";
|
||||
import type { TreeItemClickEvent, TreeItemHandle, TreeItemProps } from "./TreeItem";
|
||||
import type { TreeItemListProps } from "./TreeItemList";
|
||||
import { TreeItemList } from "./TreeItemList";
|
||||
import { useSelectableItems } from "./useSelectableItems";
|
||||
|
||||
/** So we re-calculate after expanding a folder during drag */
|
||||
const measuring = { droppable: { strategy: MeasuringStrategy.Always } };
|
||||
|
||||
export interface TreeProps<T extends { id: string }> {
|
||||
root: TreeNode<T>;
|
||||
treeId: string;
|
||||
collapsedAtom: CollapsedAtom;
|
||||
getItemKey: (item: T) => string;
|
||||
getContextMenu?: (items: T[]) => unknown[] | Promise<unknown[]>;
|
||||
renderContextMenu?: ContextMenuRenderer;
|
||||
ItemInner: ComponentType<{ treeId: string; item: T }>;
|
||||
ItemLeftSlotInner?: ComponentType<{ treeId: string; item: T }>;
|
||||
ItemRightSlot?: ComponentType<{ treeId: string; item: T }>;
|
||||
className?: string;
|
||||
onActivate?: (item: T) => void;
|
||||
onDragEnd?: (opt: { items: T[]; parent: T; children: T[]; insertAt: number }) => void;
|
||||
getEditOptions?: (item: T) => {
|
||||
defaultValue: string;
|
||||
placeholder?: string;
|
||||
onChange: (item: T, text: string) => void;
|
||||
};
|
||||
}
|
||||
|
||||
export interface TreeHandle {
|
||||
treeId: string;
|
||||
focus: () => boolean;
|
||||
hasFocus: () => boolean;
|
||||
getSelectedItems: () => { id: string }[];
|
||||
selectItem: (id: string, focus?: boolean) => void;
|
||||
renameItem: (id: string) => void;
|
||||
showContextMenu: () => void;
|
||||
}
|
||||
|
||||
function TreeInner<T extends { id: string }>(
|
||||
{
|
||||
className,
|
||||
collapsedAtom,
|
||||
getContextMenu,
|
||||
getEditOptions,
|
||||
getItemKey,
|
||||
onActivate,
|
||||
onDragEnd,
|
||||
renderContextMenu,
|
||||
ItemInner,
|
||||
ItemLeftSlotInner,
|
||||
ItemRightSlot,
|
||||
root,
|
||||
treeId,
|
||||
}: TreeProps<T>,
|
||||
ref: Ref<TreeHandle>,
|
||||
) {
|
||||
const store = useStore();
|
||||
const treeRef = useRef<HTMLDivElement>(null);
|
||||
const virtualizerRef = useRef<Virtualizer<HTMLElement, Element> | null>(null);
|
||||
|
||||
// The scroll container is tracked in state as well as a ref, so the virtualizer re-resolves it
|
||||
// once it exists. React attaches refs and runs layout effects child-first, so TreeItemList's
|
||||
// effects run before this ancestor's ref is set: on a fresh mount a ref-only getScrollElement
|
||||
// returns null, and the virtualizer renders nothing until some later render happens to wake it
|
||||
// up. Usually one does, which is why this only showed when the tree remounted into settled data
|
||||
// (filtering down to no results and back).
|
||||
const [scrollEl, setScrollEl] = useState<HTMLDivElement | null>(null);
|
||||
const setTreeRef = useCallback((el: HTMLDivElement | null) => {
|
||||
treeRef.current = el;
|
||||
setScrollEl(el);
|
||||
}, []);
|
||||
const getScrollElement = useCallback(() => scrollEl, [scrollEl]);
|
||||
const handleVirtualizerReady = useCallback((v: Virtualizer<HTMLElement, Element>) => {
|
||||
virtualizerRef.current = v;
|
||||
}, []);
|
||||
const selectableItems = useSelectableItems(root);
|
||||
|
||||
// Only render nodes that are actually visible (not filtered out, and not
|
||||
// inside a collapsed folder). Mounting every node regardless of visibility
|
||||
// makes large workspaces unusable: thousands of hidden TreeItems each run
|
||||
// their dnd/context hooks on every tree commit just to return null.
|
||||
const collapsedMap = useAtomValue(collapsedAtom);
|
||||
const visibleItems = useMemo(() => {
|
||||
return selectableItems.filter((i) => {
|
||||
if (i.node.hidden) return false;
|
||||
for (let p = i.node.parent; p != null; p = p.parent) {
|
||||
if (collapsedMap[p.item.id]) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [selectableItems, collapsedMap]);
|
||||
const [showContextMenu, setShowContextMenu] = useState<{
|
||||
items: unknown[];
|
||||
x: number;
|
||||
y: number;
|
||||
} | null>(null);
|
||||
const treeItemRefs = useRef<Record<string, TreeItemHandle>>({});
|
||||
const handleAddTreeItemRef = useCallback((item: T, r: TreeItemHandle | null) => {
|
||||
if (r == null) {
|
||||
delete treeItemRefs.current[item.id];
|
||||
|
||||
// Keep keyboard focus inside the tree when the focused row is virtualized away.
|
||||
//
|
||||
// Scrolling the focused row out of the window destroys the button holding focus, and the
|
||||
// browser drops focus to the body rather than moving it anywhere. Everything keyboard-driven
|
||||
// is gated on the tree containing document.activeElement (arrow navigation here, rename,
|
||||
// delete, duplicate and the context menu in the consumer), so the entire keyboard interface
|
||||
// would go dead until the user clicked a row again. They all act on the selected id rather
|
||||
// than the focused element, so parking focus on the container is enough to keep them live.
|
||||
//
|
||||
// NOTE: This has to hang off the unmount rather than a render or a focusout. Scrolling
|
||||
// re-renders the list, not this component, and removing a focused element doesn't reliably
|
||||
// fire focusout.
|
||||
requestAnimationFrame(() => {
|
||||
const el = treeRef.current;
|
||||
if (el == null) return;
|
||||
const active = document.activeElement;
|
||||
const focusWasDropped = active == null || active === document.body || !active.isConnected;
|
||||
if (focusWasDropped) {
|
||||
el.focus({ preventScroll: true });
|
||||
}
|
||||
});
|
||||
} else {
|
||||
treeItemRefs.current[item.id] = r;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Select the first item on first render
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: Only used for initial render
|
||||
useEffect(() => {
|
||||
const ids = store.get(selectedIdsFamily(treeId));
|
||||
const fallback = selectableItems[0];
|
||||
if (ids.length === 0 && fallback != null) {
|
||||
store.set(selectedIdsFamily(treeId), [fallback.node.item.id]);
|
||||
store.set(focusIdsFamily(treeId), {
|
||||
anchorId: fallback.node.item.id,
|
||||
lastId: fallback.node.item.id,
|
||||
});
|
||||
}
|
||||
}, [treeId]);
|
||||
|
||||
const handleCloseContextMenu = useCallback(() => {
|
||||
setShowContextMenu(null);
|
||||
}, []);
|
||||
|
||||
const isTreeFocused = useCallback(() => {
|
||||
return treeRef.current?.contains(document.activeElement);
|
||||
}, []);
|
||||
|
||||
const tryFocus = useCallback(() => {
|
||||
const find = () =>
|
||||
treeRef.current?.querySelector<HTMLButtonElement>('.tree-item button[tabindex="0"]');
|
||||
const $el = find();
|
||||
if ($el != null) {
|
||||
// preventScroll so scrolling stays single-sourced (focus() implicitly
|
||||
// scrolls, which fights the virtualizer's scrollToIndex)
|
||||
$el.focus({ preventScroll: true });
|
||||
$el.scrollIntoView({ block: "nearest" });
|
||||
return true;
|
||||
}
|
||||
|
||||
// The focused row may be virtualized out of range. Scroll it into range,
|
||||
// then focus it once it has mounted.
|
||||
const lastFocusedId = store.get(focusIdsFamily(treeId)).lastId;
|
||||
const index = visibleItems.findIndex((i) => i.node.item.id === lastFocusedId);
|
||||
if (index < 0) {
|
||||
return false;
|
||||
}
|
||||
virtualizerRef.current?.scrollToIndex(index, { align: "auto" });
|
||||
requestAnimationFrame(() => find()?.focus({ preventScroll: true }));
|
||||
return true;
|
||||
}, [store, treeId, visibleItems]);
|
||||
|
||||
const ensureTabbableItem = useCallback(() => {
|
||||
const lastSelectedId = store.get(focusIdsFamily(treeId)).lastId;
|
||||
const lastSelectedItem = selectableItems.find(
|
||||
(i) => i.node.item.id === lastSelectedId && !i.node.hidden,
|
||||
);
|
||||
|
||||
// If no item found, default to selecting the first item (prefer leaf node);
|
||||
if (lastSelectedItem == null) {
|
||||
const firstLeafItem = selectableItems.find((i) => !i.node.hidden && i.node.children == null);
|
||||
const firstItem = firstLeafItem ?? selectableItems.find((i) => !i.node.hidden);
|
||||
if (firstItem != null) {
|
||||
const id = firstItem.node.item.id;
|
||||
store.set(selectedIdsFamily(treeId), [id]);
|
||||
store.set(focusIdsFamily(treeId), { anchorId: id, lastId: id });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const closest = closestVisibleNode(store, collapsedAtom, lastSelectedItem.node);
|
||||
if (closest != null) {
|
||||
const id = closest.item.id;
|
||||
store.set(selectedIdsFamily(treeId), [id]);
|
||||
store.set(focusIdsFamily(treeId), { anchorId: id, lastId: id });
|
||||
}
|
||||
}, [selectableItems, treeId]);
|
||||
|
||||
// Ensure there's always a tabbable item after collapsed state changes
|
||||
useEffect(() => {
|
||||
const unsub = store.sub(collapsedAtom, ensureTabbableItem);
|
||||
return unsub;
|
||||
}, [ensureTabbableItem, treeId]);
|
||||
|
||||
// Ensure there's always a tabbable item after render
|
||||
useEffect(() => {
|
||||
requestAnimationFrame(ensureTabbableItem);
|
||||
});
|
||||
|
||||
const hasFocus = useCallback(() => {
|
||||
return treeRef.current?.contains(document.activeElement) ?? false;
|
||||
}, []);
|
||||
|
||||
const setSelected = useCallback(
|
||||
(ids: string[], focus: boolean) => {
|
||||
store.set(selectedIdsFamily(treeId), ids);
|
||||
// TODO: Figure out a better way than timeout
|
||||
if (!focus) return;
|
||||
setTimeout(tryFocus, 50);
|
||||
},
|
||||
[treeId, tryFocus],
|
||||
);
|
||||
|
||||
/**
|
||||
* Run something against a row's handle, scrolling the row into view first when it isn't
|
||||
* mounted.
|
||||
*
|
||||
* Virtualized rows only have a handle while they're in the window, but the row these act on is
|
||||
* the selected one, which the user is free to scroll away from before hitting a hotkey. Without
|
||||
* this, renaming an off-screen row silently does nothing and the context menu has no rect to
|
||||
* open against.
|
||||
*/
|
||||
const withTreeItem = useCallback(
|
||||
(id: string, action: (handle: TreeItemHandle) => void) => {
|
||||
const mounted = treeItemRefs.current[id];
|
||||
if (mounted != null) {
|
||||
action(mounted);
|
||||
return;
|
||||
}
|
||||
|
||||
const index = visibleItems.findIndex((i) => i.node.item.id === id);
|
||||
if (index < 0) return;
|
||||
|
||||
virtualizerRef.current?.scrollToIndex(index, { align: "auto" });
|
||||
requestAnimationFrame(() => {
|
||||
const handle = treeItemRefs.current[id];
|
||||
if (handle != null) action(handle);
|
||||
});
|
||||
},
|
||||
[visibleItems],
|
||||
);
|
||||
|
||||
const treeHandle = useMemo<TreeHandle>(
|
||||
() => ({
|
||||
treeId,
|
||||
focus: tryFocus,
|
||||
hasFocus: hasFocus,
|
||||
getSelectedItems: () => getSelectedItems(store, treeId, selectableItems),
|
||||
renameItem: (id) => withTreeItem(id, (handle) => handle.rename()),
|
||||
selectItem: (id, focus) => {
|
||||
if (store.get(selectedIdsFamily(treeId)).includes(id)) {
|
||||
// Already selected
|
||||
return;
|
||||
}
|
||||
store.set(focusIdsFamily(treeId), { anchorId: id, lastId: id });
|
||||
setSelected([id], focus === true);
|
||||
},
|
||||
showContextMenu: async () => {
|
||||
if (getContextMenu == null) return;
|
||||
const items = getSelectedItems(store, treeId, selectableItems);
|
||||
const menuItems = await getContextMenu(items);
|
||||
const lastSelectedId = store.get(focusIdsFamily(treeId)).lastId;
|
||||
if (lastSelectedId == null) return;
|
||||
withTreeItem(lastSelectedId, (handle) => {
|
||||
const rect = handle.rect();
|
||||
setShowContextMenu({ items: menuItems, x: rect.x, y: rect.y });
|
||||
});
|
||||
},
|
||||
}),
|
||||
[getContextMenu, hasFocus, selectableItems, setSelected, treeId, tryFocus, withTreeItem],
|
||||
);
|
||||
|
||||
useImperativeHandle(ref, (): TreeHandle => treeHandle, [treeHandle]);
|
||||
|
||||
const handleGetContextMenu = useMemo(() => {
|
||||
if (getContextMenu == null) return;
|
||||
return (item: T) => {
|
||||
const items = getSelectedItems(store, treeId, selectableItems);
|
||||
const isSelected = items.find((i) => i.id === item.id);
|
||||
if (isSelected) {
|
||||
// If right-clicked an item that was in the multiple-selection, use the entire selection
|
||||
return getContextMenu(items);
|
||||
}
|
||||
// If right-clicked an item that was NOT in the multiple-selection, just use that one
|
||||
// Also update the selection with it
|
||||
setSelected([item.id], false);
|
||||
store.set(focusIdsFamily(treeId), (prev) => ({ ...prev, lastId: item.id }));
|
||||
return getContextMenu([item]);
|
||||
};
|
||||
}, [getContextMenu, selectableItems, setSelected, treeId]);
|
||||
|
||||
const handleSelect = useCallback<NonNullable<TreeItemProps<T>["onClick"]>>(
|
||||
(item, { shiftKey, metaKey, ctrlKey }) => {
|
||||
const anchorSelectedId = store.get(focusIdsFamily(treeId)).anchorId;
|
||||
const selectedIdsAtom = selectedIdsFamily(treeId);
|
||||
const selectedIds = store.get(selectedIdsAtom);
|
||||
|
||||
// Mark the item as the last one selected
|
||||
store.set(focusIdsFamily(treeId), (prev) => ({ ...prev, lastId: item.id }));
|
||||
|
||||
if (shiftKey) {
|
||||
const anchorIndex = visibleItems.findIndex((i) => i.node.item.id === anchorSelectedId);
|
||||
const currIndex = visibleItems.findIndex((v) => v.node.item.id === item.id);
|
||||
|
||||
// Nothing was selected yet, so just select this item
|
||||
if (selectedIds.length === 0 || anchorIndex === -1 || currIndex === -1) {
|
||||
setSelected([item.id], true);
|
||||
store.set(focusIdsFamily(treeId), (prev) => ({ ...prev, anchorId: item.id }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (currIndex > anchorIndex) {
|
||||
// Selecting down
|
||||
const itemsToSelect = visibleItems.slice(anchorIndex, currIndex + 1);
|
||||
setSelected(
|
||||
itemsToSelect.map((v) => v.node.item.id),
|
||||
true,
|
||||
);
|
||||
} else if (currIndex < anchorIndex) {
|
||||
// Selecting up
|
||||
const itemsToSelect = visibleItems.slice(currIndex, anchorIndex + 1);
|
||||
setSelected(
|
||||
itemsToSelect.map((v) => v.node.item.id),
|
||||
true,
|
||||
);
|
||||
} else {
|
||||
setSelected([item.id], true);
|
||||
}
|
||||
} else if (platform.osType() === "macos" ? metaKey : ctrlKey) {
|
||||
const withoutCurr = selectedIds.filter((id) => id !== item.id);
|
||||
if (withoutCurr.length === selectedIds.length) {
|
||||
// It wasn't in there, so add it
|
||||
setSelected([...selectedIds, item.id], true);
|
||||
} else {
|
||||
// It was in there, so remove it
|
||||
setSelected(withoutCurr, true);
|
||||
}
|
||||
} else {
|
||||
// Select single
|
||||
setSelected([item.id], true);
|
||||
store.set(focusIdsFamily(treeId), (prev) => ({ ...prev, anchorId: item.id }));
|
||||
}
|
||||
},
|
||||
[setSelected, treeId, visibleItems],
|
||||
);
|
||||
|
||||
const handleClick = useCallback<NonNullable<TreeItemProps<T>["onClick"]>>(
|
||||
(item, e) => {
|
||||
if (e.shiftKey || e.ctrlKey || e.metaKey) {
|
||||
handleSelect(item, e);
|
||||
} else {
|
||||
handleSelect(item, e);
|
||||
onActivate?.(item);
|
||||
}
|
||||
},
|
||||
[handleSelect, onActivate],
|
||||
);
|
||||
|
||||
const selectPrevItem = useCallback(
|
||||
(e: TreeItemClickEvent) => {
|
||||
const lastSelectedId = store.get(focusIdsFamily(treeId)).lastId;
|
||||
const index = visibleItems.findIndex((i) => i.node.item.id === lastSelectedId);
|
||||
const item = visibleItems[index - 1];
|
||||
if (item != null) {
|
||||
handleSelect(item.node.item, e);
|
||||
}
|
||||
},
|
||||
[handleSelect, treeId, visibleItems],
|
||||
);
|
||||
|
||||
const selectNextItem = useCallback(
|
||||
(e: TreeItemClickEvent) => {
|
||||
const lastSelectedId = store.get(focusIdsFamily(treeId)).lastId;
|
||||
const index = visibleItems.findIndex((i) => i.node.item.id === lastSelectedId);
|
||||
const item = visibleItems[index + 1];
|
||||
if (item != null) {
|
||||
handleSelect(item.node.item, e);
|
||||
}
|
||||
},
|
||||
[handleSelect, treeId, visibleItems],
|
||||
);
|
||||
|
||||
const selectParentItem = useCallback(
|
||||
(e: TreeItemClickEvent) => {
|
||||
const lastSelectedId = store.get(focusIdsFamily(treeId)).lastId;
|
||||
const lastSelectedItem =
|
||||
selectableItems.find((i) => i.node.item.id === lastSelectedId)?.node ?? null;
|
||||
if (lastSelectedItem?.parent != null) {
|
||||
handleSelect(lastSelectedItem.parent.item, e);
|
||||
}
|
||||
},
|
||||
[handleSelect, selectableItems, treeId],
|
||||
);
|
||||
|
||||
useKey(
|
||||
(e) => e.key === "ArrowUp" || e.key.toLowerCase() === "k",
|
||||
(e) => {
|
||||
if (!isTreeFocused()) return;
|
||||
e.preventDefault();
|
||||
selectPrevItem(e);
|
||||
},
|
||||
undefined,
|
||||
[selectableItems, handleSelect],
|
||||
);
|
||||
|
||||
useKey(
|
||||
(e) => e.key === "ArrowDown" || e.key.toLowerCase() === "j",
|
||||
(e) => {
|
||||
if (!isTreeFocused()) return;
|
||||
e.preventDefault();
|
||||
selectNextItem(e);
|
||||
},
|
||||
undefined,
|
||||
[selectableItems, handleSelect],
|
||||
);
|
||||
|
||||
// If the selected item is a collapsed folder, expand it. Otherwise, select next item
|
||||
useKey(
|
||||
(e) => e.key === "ArrowRight" || e.key === "l",
|
||||
(e) => {
|
||||
if (!isTreeFocused()) return;
|
||||
e.preventDefault();
|
||||
|
||||
const collapsed = store.get(collapsedAtom);
|
||||
const lastSelectedId = store.get(focusIdsFamily(treeId)).lastId;
|
||||
const lastSelectedItem = selectableItems.find((i) => i.node.item.id === lastSelectedId);
|
||||
|
||||
if (
|
||||
lastSelectedId &&
|
||||
lastSelectedItem?.node.children != null &&
|
||||
collapsed[lastSelectedItem.node.item.id] === true
|
||||
) {
|
||||
store.set(collapsedAtom, { ...collapsed, [lastSelectedId]: false });
|
||||
} else {
|
||||
selectNextItem(e);
|
||||
}
|
||||
},
|
||||
undefined,
|
||||
[selectableItems, handleSelect],
|
||||
);
|
||||
|
||||
// If the selected item is in a folder, select its parent.
|
||||
// If the selected item is an expanded folder, collapse it.
|
||||
useKey(
|
||||
(e) => e.key === "ArrowLeft" || e.key === "h",
|
||||
(e) => {
|
||||
if (!isTreeFocused()) return;
|
||||
e.preventDefault();
|
||||
|
||||
const collapsed = store.get(collapsedAtom);
|
||||
const lastSelectedId = store.get(focusIdsFamily(treeId)).lastId;
|
||||
const lastSelectedItem = selectableItems.find((i) => i.node.item.id === lastSelectedId);
|
||||
|
||||
if (
|
||||
lastSelectedId &&
|
||||
lastSelectedItem?.node.children != null &&
|
||||
collapsed[lastSelectedItem.node.item.id] !== true
|
||||
) {
|
||||
store.set(collapsedAtom, { ...collapsed, [lastSelectedId]: true });
|
||||
} else {
|
||||
selectParentItem(e);
|
||||
}
|
||||
},
|
||||
{ options: {} },
|
||||
[selectableItems, handleSelect],
|
||||
);
|
||||
|
||||
useKeyPressEvent("Escape", async () => {
|
||||
if (!treeRef.current?.contains(document.activeElement)) return;
|
||||
clearDragState();
|
||||
const lastSelectedId = store.get(focusIdsFamily(treeId)).lastId;
|
||||
if (lastSelectedId == null) return;
|
||||
setSelected([lastSelectedId], false);
|
||||
});
|
||||
|
||||
const handleDragMove = useCallback(
|
||||
function handleDragMove(e: DragMoveEvent) {
|
||||
const over = e.over;
|
||||
if (!over) {
|
||||
// Clear the drop indicator when hovering outside the tree
|
||||
store.set(hoveredParentFamily(treeId), {
|
||||
parentId: null,
|
||||
parentDepth: null,
|
||||
childIndex: null,
|
||||
index: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Not sure when or if this happens
|
||||
if (e.active.rect.current.initial == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Root is anything past the end of the list, so set it to the end
|
||||
const hoveringRoot = over.id === root.item.id;
|
||||
if (hoveringRoot) {
|
||||
store.set(hoveredParentFamily(treeId), {
|
||||
parentId: root.item.id,
|
||||
parentDepth: root.depth,
|
||||
index: visibleItems.length,
|
||||
childIndex: visibleItems.length,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const overSelectableItem = selectableItems.find((i) => i.node.item.id === over.id) ?? null;
|
||||
if (overSelectableItem == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const draggingItems = store.get(draggingIdsFamily(treeId));
|
||||
for (const id of draggingItems) {
|
||||
const item = selectableItems.find((i) => i.node.item.id === id)?.node ?? null;
|
||||
if (item == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isSameParent = item.parent?.item.id === overSelectableItem.node.parent?.item.id;
|
||||
if (item.localDrag && !isSameParent) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const node = overSelectableItem.node;
|
||||
const side = computeSideForDragMove(node.item.id, e);
|
||||
|
||||
const item = node.item;
|
||||
let hoveredParent = node.parent;
|
||||
const dragIndex = visibleItems.findIndex((n) => n.node.item.id === item.id) ?? -1;
|
||||
const hovered = visibleItems[dragIndex]?.node ?? null;
|
||||
const hoveredIndex = dragIndex + (side === "before" ? 0 : 1);
|
||||
let hoveredChildIndex = overSelectableItem.index + (side === "before" ? 0 : 1);
|
||||
|
||||
// Move into the folder if it's open and we're moving after it
|
||||
if (hovered?.children != null && side === "after") {
|
||||
hoveredParent = hovered;
|
||||
hoveredChildIndex = 0;
|
||||
}
|
||||
|
||||
const parentId = hoveredParent?.item.id ?? null;
|
||||
const parentDepth = hoveredParent?.depth ?? null;
|
||||
const index = hoveredIndex;
|
||||
const childIndex = hoveredChildIndex;
|
||||
const existing = store.get(hoveredParentFamily(treeId));
|
||||
if (
|
||||
!(
|
||||
parentId === existing.parentId &&
|
||||
parentDepth === existing.parentDepth &&
|
||||
index === existing.index &&
|
||||
childIndex === existing.childIndex
|
||||
)
|
||||
) {
|
||||
store.set(hoveredParentFamily(treeId), {
|
||||
parentId,
|
||||
parentDepth,
|
||||
index,
|
||||
childIndex,
|
||||
});
|
||||
}
|
||||
},
|
||||
[root.depth, root.item.id, selectableItems, treeId, visibleItems],
|
||||
);
|
||||
|
||||
const handleDragStart = useCallback(
|
||||
function handleDragStart(e: DragStartEvent) {
|
||||
const selectedItems = getSelectedItems(store, treeId, selectableItems);
|
||||
const isDraggingSelectedItem = selectedItems.find((i) => i.id === e.active.id);
|
||||
|
||||
// If we started dragging an already-selected item, we'll use that
|
||||
if (isDraggingSelectedItem) {
|
||||
store.set(
|
||||
draggingIdsFamily(treeId),
|
||||
selectedItems.map((i) => i.id),
|
||||
);
|
||||
} else {
|
||||
// If we started dragging a non-selected item, only drag that item
|
||||
const activeItem = selectableItems.find((i) => i.node.item.id === e.active.id)?.node.item;
|
||||
if (activeItem != null) {
|
||||
store.set(draggingIdsFamily(treeId), [activeItem.id]);
|
||||
// Also update selection to just be this one
|
||||
handleSelect(activeItem, {
|
||||
shiftKey: false,
|
||||
metaKey: false,
|
||||
ctrlKey: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
[handleSelect, selectableItems, treeId],
|
||||
);
|
||||
|
||||
const clearDragState = useCallback(() => {
|
||||
store.set(hoveredParentFamily(treeId), {
|
||||
parentId: null,
|
||||
parentDepth: null,
|
||||
index: null,
|
||||
childIndex: null,
|
||||
});
|
||||
store.set(draggingIdsFamily(treeId), []);
|
||||
}, [treeId]);
|
||||
|
||||
const handleDragEnd = useCallback(
|
||||
function handleDragEnd(e: DragEndEvent) {
|
||||
// Get this from the store so our callback doesn't change all the time
|
||||
const {
|
||||
index: hoveredIndex,
|
||||
parentId: hoveredParentId,
|
||||
childIndex: hoveredChildIndex,
|
||||
} = store.get(hoveredParentFamily(treeId));
|
||||
const draggingItems = store.get(draggingIdsFamily(treeId));
|
||||
clearDragState();
|
||||
|
||||
// Dropped outside the tree?
|
||||
if (e.over == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const hoveredParentS =
|
||||
hoveredParentId === root.item.id
|
||||
? { node: root, depth: 0, index: 0 }
|
||||
: (selectableItems.find((i) => i.node.item.id === hoveredParentId) ?? null);
|
||||
const hoveredParent = hoveredParentS?.node ?? null;
|
||||
|
||||
if (hoveredParent == null || hoveredIndex == null || !draggingItems?.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Resolve the actual tree nodes for each dragged item (keeps order of draggingItems)
|
||||
const draggedNodes: TreeNode<T>[] = draggingItems
|
||||
.map((id) => {
|
||||
return selectableItems.find((i) => i.node.item.id === id)?.node ?? null;
|
||||
})
|
||||
.filter((n) => n != null)
|
||||
// Filter out invalid drags (dragging into descendant)
|
||||
.filter(
|
||||
(n) => hoveredParent.item.id !== n.item.id && !hasAncestor(hoveredParent, n.item.id),
|
||||
);
|
||||
|
||||
// Work on a local copy of target children
|
||||
const nextChildren = [...(hoveredParent.children ?? [])];
|
||||
|
||||
// Remove any of the dragged nodes already in the target, adjusting hoveredIndex
|
||||
let insertAt = hoveredChildIndex ?? 0;
|
||||
for (const node of draggedNodes) {
|
||||
const i = nextChildren.findIndex((n) => n.item.id === node.item.id);
|
||||
if (i !== -1) {
|
||||
nextChildren.splice(i, 1);
|
||||
if (i < insertAt) insertAt -= 1; // account for removed-before
|
||||
}
|
||||
}
|
||||
|
||||
// Batch callback
|
||||
onDragEnd?.({
|
||||
items: draggedNodes.map((n) => n.item),
|
||||
parent: hoveredParent.item,
|
||||
children: nextChildren.map((c) => c.item),
|
||||
insertAt,
|
||||
});
|
||||
},
|
||||
[treeId, clearDragState, selectableItems, root, onDragEnd],
|
||||
);
|
||||
|
||||
const treeItemListProps: Omit<
|
||||
TreeItemListProps<T>,
|
||||
"nodes" | "treeId" | "activeIdAtom" | "hoveredParent" | "hoveredIndex"
|
||||
> = {
|
||||
getItemKey,
|
||||
getContextMenu: handleGetContextMenu,
|
||||
renderContextMenu,
|
||||
onClick: handleClick,
|
||||
getEditOptions,
|
||||
ItemInner,
|
||||
ItemLeftSlotInner,
|
||||
ItemRightSlot,
|
||||
};
|
||||
|
||||
const handleContextMenu = useCallback(
|
||||
async (e: MouseEvent<HTMLElement>) => {
|
||||
if (getContextMenu == null) return;
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const items = await getContextMenu([]);
|
||||
setShowContextMenu({ items, x: e.clientX, y: e.clientY });
|
||||
},
|
||||
[getContextMenu],
|
||||
);
|
||||
|
||||
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 6 } }));
|
||||
|
||||
return (
|
||||
<CollapsedAtomContext.Provider value={collapsedAtom}>
|
||||
{showContextMenu &&
|
||||
renderContextMenu?.({
|
||||
items: showContextMenu.items,
|
||||
position: showContextMenu,
|
||||
onClose: handleCloseContextMenu,
|
||||
})}
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={pointerWithin}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragCancel={clearDragState}
|
||||
onDragAbort={clearDragState}
|
||||
onDragMove={handleDragMove}
|
||||
measuring={measuring}
|
||||
autoScroll
|
||||
>
|
||||
<div
|
||||
ref={setTreeRef}
|
||||
// Focusable so the container can hold focus when the focused row unmounts. Not reachable
|
||||
// by tabbing, since the rows themselves are what's tabbable.
|
||||
tabIndex={-1}
|
||||
className={classNames(
|
||||
className,
|
||||
"outline-hidden h-full",
|
||||
"overflow-y-auto overflow-x-hidden",
|
||||
"grid grid-rows-[auto_1fr]",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={classNames(
|
||||
"[&_.tree-item.selected_.tree-item-inner]:text-text",
|
||||
"focus-within:[&_.tree-item.selected]:bg-surface-active",
|
||||
"not-focus-within:[&_.tree-item.selected:not([data-context-menu-open])]:bg-surface-highlight",
|
||||
"[&_.tree-item.selected[data-context-menu-open]]:bg-surface-active",
|
||||
// Round the items, but only if the ends of the selection.
|
||||
// Also account for the drop marker being in between items
|
||||
"[&_.tree-item]:rounded-md",
|
||||
"[&_.tree-item.selected+.tree-item.selected]:rounded-t-none",
|
||||
"[&_.tree-item.selected+.drop-marker+.tree-item.selected]:rounded-t-none",
|
||||
"[&_.tree-item.selected:has(+.tree-item.selected)]:rounded-b-none",
|
||||
"[&_.tree-item.selected:has(+.drop-marker+.tree-item.selected)]:rounded-b-none",
|
||||
// Virtualized rows are wrapped in .tree-row divs, so the sibling
|
||||
// relationships above need wrapper-aware equivalents
|
||||
"[&_.tree-row:has(.tree-item.selected)+.tree-row_.tree-item.selected]:rounded-t-none",
|
||||
"[&_.tree-row:has(.tree-item.selected):has(+.tree-row_.tree-item.selected)_.tree-item.selected]:rounded-b-none",
|
||||
)}
|
||||
>
|
||||
<TreeItemList
|
||||
addTreeItemRef={handleAddTreeItemRef}
|
||||
nodes={visibleItems}
|
||||
treeId={treeId}
|
||||
getScrollElement={getScrollElement}
|
||||
onVirtualizerReady={handleVirtualizerReady}
|
||||
{...treeItemListProps}
|
||||
/>
|
||||
</div>
|
||||
{/* Assign root ID so we can reuse our same move/end logic */}
|
||||
<DropRegionAfterList id={root.item.id} onContextMenu={handleContextMenu} />
|
||||
</div>
|
||||
<TreeDragOverlay
|
||||
treeId={treeId}
|
||||
selectableItems={selectableItems}
|
||||
ItemInner={ItemInner}
|
||||
getItemKey={getItemKey}
|
||||
/>
|
||||
</DndContext>
|
||||
</CollapsedAtomContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
// 1) Preserve generics through forwardRef:
|
||||
const Tree_ = forwardRef(TreeInner) as <T extends { id: string }>(
|
||||
props: TreeProps<T> & RefAttributes<TreeHandle>,
|
||||
) => ReactElement | null;
|
||||
|
||||
export const Tree = memo(
|
||||
Tree_,
|
||||
({ root: prevNode, ...prevProps }, { root: nextNode, ...nextProps }) => {
|
||||
for (const key of Object.keys(prevProps)) {
|
||||
if (prevProps[key as keyof typeof prevProps] !== nextProps[key as keyof typeof nextProps]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return equalSubtree(prevNode, nextNode, nextProps.getItemKey);
|
||||
},
|
||||
) as typeof Tree_;
|
||||
|
||||
function DropRegionAfterList({
|
||||
id,
|
||||
onContextMenu,
|
||||
}: {
|
||||
id: string;
|
||||
onContextMenu?: (e: MouseEvent<HTMLDivElement>) => void;
|
||||
}) {
|
||||
const { setNodeRef } = useDroppable({ id });
|
||||
// biome-ignore lint/a11y/noStaticElementInteractions: Meh
|
||||
return <div ref={setNodeRef} onContextMenu={onContextMenu} />;
|
||||
}
|
||||
31
packages/ui/src/components/tree/TreeDragOverlay.tsx
Normal file
31
packages/ui/src/components/tree/TreeDragOverlay.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { DragOverlay } from "@dnd-kit/core";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { draggingIdsFamily } from "./atoms";
|
||||
import type { SelectableTreeNode } from "./common";
|
||||
import type { TreeProps } from "./Tree";
|
||||
import { TreeItemList } from "./TreeItemList";
|
||||
|
||||
export function TreeDragOverlay<T extends { id: string }>({
|
||||
treeId,
|
||||
selectableItems,
|
||||
getItemKey,
|
||||
ItemInner,
|
||||
ItemLeftSlotInner,
|
||||
}: {
|
||||
treeId: string;
|
||||
selectableItems: SelectableTreeNode<T>[];
|
||||
} & Pick<TreeProps<T>, "getItemKey" | "ItemInner" | "ItemLeftSlotInner">) {
|
||||
const draggingItems = useAtomValue(draggingIdsFamily(treeId));
|
||||
return (
|
||||
<DragOverlay dropAnimation={null}>
|
||||
<TreeItemList
|
||||
treeId={`${treeId}.dragging`}
|
||||
nodes={selectableItems.filter((i) => draggingItems.includes(i.node.item.id))}
|
||||
getItemKey={getItemKey}
|
||||
ItemInner={ItemInner}
|
||||
ItemLeftSlotInner={ItemLeftSlotInner}
|
||||
forceDepth={0}
|
||||
/>
|
||||
</DragOverlay>
|
||||
);
|
||||
}
|
||||
36
packages/ui/src/components/tree/TreeDropMarker.tsx
Normal file
36
packages/ui/src/components/tree/TreeDropMarker.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import classNames from "classnames";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { memo } from "react";
|
||||
import { DropMarker } from "../DropMarker";
|
||||
import { hoveredParentDepthFamily, isIndexHoveredFamily } from "./atoms";
|
||||
import type { TreeNode } from "./common";
|
||||
import { useIsCollapsed } from "./context";
|
||||
|
||||
export const TreeDropMarker = memo(function TreeDropMarker<T extends { id: string }>({
|
||||
className,
|
||||
treeId,
|
||||
node,
|
||||
index,
|
||||
}: {
|
||||
treeId: string;
|
||||
index: number;
|
||||
node: TreeNode<T> | null;
|
||||
className?: string;
|
||||
}) {
|
||||
const isHovered = useAtomValue(isIndexHoveredFamily({ treeId, index }));
|
||||
const parentDepth = useAtomValue(hoveredParentDepthFamily(treeId));
|
||||
const collapsed = useIsCollapsed(node?.item.id);
|
||||
|
||||
// Only show if we're hovering over this index
|
||||
if (!isHovered) return null;
|
||||
|
||||
// Don't show if we're right under a collapsed folder, or empty folder. We have a separate
|
||||
// delayed expansion animation for that.
|
||||
if (collapsed || node?.children?.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="drop-marker relative" style={{ paddingLeft: `${parentDepth}rem` }}>
|
||||
<DropMarker className={classNames(className)} />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
32
packages/ui/src/components/tree/TreeIndentGuide.tsx
Normal file
32
packages/ui/src/components/tree/TreeIndentGuide.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import classNames from "classnames";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { memo } from "react";
|
||||
import { hoveredParentDepthFamily, isAncestorHoveredFamily } from "./atoms";
|
||||
|
||||
export const TreeIndentGuide = memo(function TreeIndentGuide({
|
||||
treeId,
|
||||
depth,
|
||||
ancestorIds,
|
||||
}: {
|
||||
treeId: string;
|
||||
depth: number;
|
||||
ancestorIds: string[];
|
||||
}) {
|
||||
const parentDepth = useAtomValue(hoveredParentDepthFamily(treeId));
|
||||
const isHovered = useAtomValue(isAncestorHoveredFamily({ treeId, ancestorIds }));
|
||||
|
||||
return (
|
||||
<div className="flex">
|
||||
{Array.from({ length: depth }).map((_, i) => (
|
||||
<div
|
||||
// oxlint-disable-next-line react/no-array-index-key
|
||||
key={i}
|
||||
className={classNames(
|
||||
"w-[calc(1rem+0.5px)] border-r border-r-text-subtlest",
|
||||
!(parentDepth === i + 1 && isHovered) && "opacity-30",
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user