snapshot of https://github.com/mountain-loop/yaak.git (main, 2026-09-23; blobs > 131072B omitted)
Some checks failed
Lint and Test / Lint/Test (push) Has been cancelled
Generate Sponsors README / deploy (push) Has been skipped

This commit is contained in:
upstream-mirror
2026-09-23 17:39:41 +05:00
commit 906fb231cc
1544 changed files with 164131 additions and 0 deletions

View 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");
});
});

View 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;
}

View 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);
};
}

View 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}`;
}

View File

@@ -0,0 +1,4 @@
export * from "./debounce";
export * from "./eagerDebounceAsync";
export * from "./formatSize";
export * from "./templateFunction";

View 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;
}

View File

@@ -0,0 +1,6 @@
{
"name": "@yaakapp-internal/lib",
"version": "1.0.0",
"private": true,
"main": "index.ts"
}

View 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;
}

View 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"
}
}

View 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;
}

View 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"
}
}

View 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];
}

View 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";

View 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";

View 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(),
};

View 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(),
};
}

View 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;

View 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.

View 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`);
}

View 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);
}

View 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);
}

View 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("");
}

View 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",
};
}

View 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";

View 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;
}

View 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();
}
}

View 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;
}
}

View 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]!);
};

View 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"]
}

View File

@@ -0,0 +1,2 @@
lib
node_modules

View 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
```

View 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"
}
}

View 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>, };

File diff suppressed because one or more lines are too long

View 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;
};

View 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, };

View File

@@ -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;

View 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;

View 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";

View File

@@ -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;
})[];
};

View 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;
};
}

View 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;
};

View File

@@ -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;
};

View File

@@ -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;
};

View File

@@ -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;
};

View 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>;
};

View File

@@ -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>;
};

View File

@@ -0,0 +1,3 @@
import type { Theme } from "../bindings/gen_events";
export type ThemePlugin = Theme;

View File

@@ -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;
};

View File

@@ -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;
};

View 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[];
};

View 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>;
}>;
};

View 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"]
}

View File

@@ -0,0 +1 @@
24.11.1

View 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"
}
}

View 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);
}
}

View 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();
}
}

File diff suppressed because it is too large Load Diff

View 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;
}

View 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);
});

View 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" : "");
}

View 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;
}

View 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 };
}

View 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;
}

View 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,
},
],
},
]);
});
});

View 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());
});
});

View 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 });
}
});

View 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"]
}

View 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);
}
}

View File

@@ -0,0 +1,6 @@
{
"name": "@yaakapp-internal/tailwind-config",
"version": "1.0.0",
"private": true,
"style": "index.css"
}

View 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"
}
}

View 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();
};
}

View 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%)",
},
},
};

View 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";

View 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;
}

View 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];
}

View 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
View 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"
}
}

View 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>
);
}

View 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>
);
});

View 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,
);

View 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>
);
}

View 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>
);
}

View 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}
/>
);
}

View 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",
)}
/>
);
});

View 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>
);
});

View 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}
/>
);
}

View 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]",
)}
/>
);
}

View 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>
);
}

View 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);
}

View 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>
);
}

View 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>
);
}

View 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);
}

View 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>
);
});

View 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>
);
}

View 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>
);
}

View 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} />;
}

View 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>
);
}

View 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>
);
});

View 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