babavoss 0.0.1 → 0.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/NOTICE +7 -0
- package/bin/voss.ts +48 -0
- package/gui/babavoss-web.js +234 -0
- package/gui/chunk-5hpp1ypv.js +11710 -0
- package/gui/chunk-9rq662fd.js +8627 -0
- package/gui/chunk-k3cm8j6r.js +433 -0
- package/gui/chunk-pk09y98y.js +50 -0
- package/gui/chunk-smz02qa6.js +186 -0
- package/gui/chunk-wwqypxre.js +49 -0
- package/gui/gui.js +2015 -0
- package/gui/react-compiler-runtime.js +39 -0
- package/gui/react-dom-client.js +21 -0
- package/gui/react-dom.js +44 -0
- package/gui/react-jsx-runtime.js +19 -0
- package/gui/react.js +105 -0
- package/gui/theme.css +3053 -0
- package/index.ts +15 -0
- package/package.json +50 -4
- package/src/baba/check.ts +90 -0
- package/src/baba/config.ts +261 -0
- package/src/baba/find.ts +12 -0
- package/src/baba/init.ts +176 -0
- package/src/baba/node.ts +483 -0
- package/src/baba/project.ts +63 -0
- package/src/baba/registry.ts +35 -0
- package/src/baba/worker.ts +55 -0
- package/src/bench/index.ts +6 -0
- package/src/bench/measure.ts +132 -0
- package/src/bench/scenarios.ts +136 -0
- package/src/build/builder.ts +74 -0
- package/src/build/failure.ts +78 -0
- package/src/build/guard.ts +85 -0
- package/src/build/mdx-register.ts +3 -0
- package/src/build/mdx.ts +38 -0
- package/src/build/project.ts +38 -0
- package/src/build/views.ts +146 -0
- package/src/builder/main.ts +29 -0
- package/src/desktop/bob.ts +76 -0
- package/src/desktop/desktop.css +111 -0
- package/src/desktop/icons.ts +50 -0
- package/src/desktop/index.ts +323 -0
- package/src/desktop/routes.ts +98 -0
- package/src/desktop/view.tsx +673 -0
- package/src/door/core.ts +384 -0
- package/src/ecs/baba.ts +431 -0
- package/src/ecs/codec.ts +334 -0
- package/src/ecs/handles.ts +91 -0
- package/src/ecs/replica.ts +150 -0
- package/src/ecs/runtime.ts +603 -0
- package/src/ecs/scheduler.ts +75 -0
- package/src/ecs/snapshot.ts +102 -0
- package/src/ecs/state.ts +759 -0
- package/src/ecs/system.ts +256 -0
- package/src/ecs/table.ts +420 -0
- package/src/ecs/testbed.ts +97 -0
- package/src/exec/host.ts +177 -0
- package/src/exec/main.ts +98 -0
- package/src/exec/watch.ts +7 -0
- package/src/exec/wire.ts +29 -0
- package/src/generated/build.ts +4 -0
- package/src/gui/css.d.ts +1 -0
- package/src/gui/gui.tsx +245 -0
- package/src/gui/index.ts +51 -0
- package/src/gui/inspector.tsx +47 -0
- package/src/gui/levels.tsx +73 -0
- package/src/gui/promptware.tsx +68 -0
- package/src/gui/runner.tsx +118 -0
- package/src/gui/theme.css +498 -0
- package/src/gui/theme.ts +25 -0
- package/src/gui/wizard.tsx +227 -0
- package/src/guide/add-a-desktop.mdx +100 -0
- package/src/guide/compose-an-interface.mdx +84 -0
- package/src/guide/index.ts +13 -0
- package/src/guide/reach-outside.mdx +112 -0
- package/src/guide/spec-a-system.mdx +93 -0
- package/src/guide/systems-together.mdx +72 -0
- package/src/guide/write-a-system.mdx +183 -0
- package/src/guide/write-promptware.mdx +90 -0
- package/src/http/server.ts +310 -0
- package/src/kernel/build.ts +21 -0
- package/src/kernel/builder.ts +105 -0
- package/src/kernel/children.ts +117 -0
- package/src/kernel/context.ts +90 -0
- package/src/kernel/lock.ts +46 -0
- package/src/kernel/names.ts +14 -0
- package/src/kernel/schema.ts +130 -0
- package/src/kernel/where.ts +12 -0
- package/src/kit/index.ts +232 -0
- package/src/maker/system.ts +213 -0
- package/src/mcp/daemon.ts +61 -0
- package/src/mcp/main.ts +208 -0
- package/src/mcp/rpc.ts +64 -0
- package/src/mcp/tools.ts +125 -0
- package/src/prompt/evals.ts +42 -0
- package/src/prompt/index.ts +242 -0
- package/src/prompt/jsx-dev-runtime.ts +1 -0
- package/src/prompt/jsx-runtime.ts +49 -0
- package/src/prompt/mdx.d.ts +1 -0
- package/src/promptware/compile.ts +183 -0
- package/src/promptware/define.ts +16 -0
- package/src/promptware/disk.ts +72 -0
- package/src/promptware/markdown.d.ts +6 -0
- package/src/promptware/sync.ts +437 -0
- package/src/promptware/system.ts +215 -0
- package/src/runtime/bridge.ts +85 -0
- package/src/runtime/connect.ts +54 -0
- package/src/runtime/env.ts +35 -0
- package/src/runtime/harness.ts +80 -0
- package/src/runtime/main.ts +119 -0
- package/src/runtime/worker.ts +33 -0
- package/src/server/edge.ts +332 -0
- package/src/server/main.ts +45 -0
- package/src/server/messages.ts +97 -0
- package/src/server/protocol.ts +37 -0
- package/src/services/args.ts +45 -0
- package/src/services/exec.ts +69 -0
- package/src/services/fs.ts +139 -0
- package/src/services/http.ts +30 -0
- package/src/services/index.ts +113 -0
- package/src/services/secrets.ts +18 -0
- package/src/shell/address.ts +21 -0
- package/src/shell/args.ts +219 -0
- package/src/shell/client.ts +107 -0
- package/src/shell/codes.ts +26 -0
- package/src/shell/positional.ts +20 -0
- package/src/shell/run.ts +470 -0
- package/src/shell/service.ts +167 -0
- package/src/shell/state.ts +204 -0
- package/src/spec/adapters.ts +72 -0
- package/src/spec/diff.ts +26 -0
- package/src/spec/files.ts +17 -0
- package/src/spec/index.ts +155 -0
- package/src/spec/run.ts +97 -0
- package/src/spec/take.ts +54 -0
- package/src/test/index.ts +8 -0
- package/src/test/prove.ts +56 -0
- package/src/test/records.ts +23 -0
- package/src/test/specs.ts +56 -0
- package/src/test/steps.ts +100 -0
- package/src/test/voss-dir.ts +17 -0
- package/src/transport/messages.ts +110 -0
- package/src/transport/transport.ts +62 -0
- package/src/wall/probe.ts +67 -0
- package/src/wall/profile.ts +103 -0
- package/src/wall/spawn.ts +59 -0
- package/src/web/app.tsx +53 -0
- package/src/web/core.tsx +140 -0
- package/src/web/form.ts +155 -0
- package/src/web/hooks.ts +135 -0
- package/src/web/index.tsx +17 -0
- package/src/web/list.ts +19 -0
- package/src/web/maker.tsx +766 -0
- package/src/web/objects.tsx +213 -0
- package/src/web/socket.ts +84 -0
- package/src/web/state.tsx +69 -0
- package/src/web/store.ts +221 -0
- package/src/web/ui.tsx +135 -0
- package/README.md +0 -5
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
// An optional shell, composed into the same state as the project's systems.
|
|
2
|
+
// Configuration is code; all changing state is owned by this system.
|
|
3
|
+
//
|
|
4
|
+
// The model is small and the same at every door: an app is a kind the baba
|
|
5
|
+
// registers; a window is one instance of it in a session, at a path; the
|
|
6
|
+
// session shows one window, or nothing, and then the page shows its launcher.
|
|
7
|
+
// Closing the window in front brings its neighbour forward, the one to its left
|
|
8
|
+
// on the bar, else the one to its right, as a browser does. Opening is one verb,
|
|
9
|
+
// `desktop-open`: it brings the window at that path to the front, else the
|
|
10
|
+
// app's current window, else makes one; asked for a `fresh` window, an app
|
|
11
|
+
// declared `many` gets another, any other app gives the one it has. The
|
|
12
|
+
// launcher, a link in an app, the page's address, the CLI and another system's
|
|
13
|
+
// ask all go through it, which is how workflows compose from one rule. A
|
|
14
|
+
// window also remembers what its app asks it to, so an app keeps its state
|
|
15
|
+
// per window in the state. A browser tab owns a session,
|
|
16
|
+
// named by the system when the tab enters with no name, and the tab keeps the
|
|
17
|
+
// key in its own storage, so a reload lands back in it; a link with
|
|
18
|
+
// `?session=KEY` joins it. A call that names no session reaches the current one, the last
|
|
19
|
+
// touched, so the CLI's `desktop-open` lands in the tab last used; a session
|
|
20
|
+
// left empty for a day goes on its own. The theme is the baba's, one resource,
|
|
21
|
+
// not a session's. The launcher is the page's: it shows while its search has
|
|
22
|
+
// focus, or while nothing is in front. An app's icon is a Lucide name; the
|
|
23
|
+
// system reads its drawing from the baba's Lucide package once and keeps it on
|
|
24
|
+
// the app, so every door draws the same icon. An app may declare its routes,
|
|
25
|
+
// the paths its windows may be at; a path none of them takes is refused.
|
|
26
|
+
import { accept, effect, resource } from "../ecs/baba.ts";
|
|
27
|
+
import { system, type ReaderOf, type StateOf } from "../ecs/system.ts";
|
|
28
|
+
import { iconName, iconTags, readIcon } from "./icons.ts";
|
|
29
|
+
import { canonicalPath, matchRoute } from "./routes.ts";
|
|
30
|
+
import { s } from "../kernel/schema.ts";
|
|
31
|
+
import type { Value } from "../ecs/state.ts";
|
|
32
|
+
|
|
33
|
+
export const themes = ["system", "light", "dark"] as const;
|
|
34
|
+
export type Theme = typeof themes[number];
|
|
35
|
+
/** How long a session with nothing open is kept untouched: a day. */
|
|
36
|
+
export const SESSION_IDLE = 24 * 60 * 60 * 1000;
|
|
37
|
+
/** Sessions are named after trees, in this order, the first not taken; a name a person can read in a list and say. */
|
|
38
|
+
export const sessionNames = [
|
|
39
|
+
"oak", "ash", "elm", "fir", "yew", "pine", "birch", "cedar", "maple", "willow", "alder", "aspen", "beech", "hazel", "holly", "larch",
|
|
40
|
+
"linden", "rowan", "spruce", "poplar", "olive", "palm", "plum", "pear", "fig", "lime", "teak", "ebony", "walnut", "chestnut", "cypress", "juniper",
|
|
41
|
+
"laurel", "magnolia", "myrtle", "sequoia", "sycamore", "acacia", "baobab", "ginkgo",
|
|
42
|
+
] as const;
|
|
43
|
+
export type { AppSpec } from "../ecs/baba.ts";
|
|
44
|
+
export { route, matchRoute, canonicalPath, type RouteParams, type RouteMatch } from "./routes.ts";
|
|
45
|
+
import type { AppSpec } from "../ecs/baba.ts";
|
|
46
|
+
/** The old name. */
|
|
47
|
+
export type Surface = AppSpec;
|
|
48
|
+
export interface DesktopOptions {
|
|
49
|
+
apps?: readonly AppSpec[];
|
|
50
|
+
/** The old name of `apps`. */
|
|
51
|
+
surfaces?: readonly AppSpec[];
|
|
52
|
+
/** No longer read: there is no dock. Accepted for one release. */
|
|
53
|
+
dock?: { edge?: string; size?: number };
|
|
54
|
+
}
|
|
55
|
+
export function desktop(options: DesktopOptions) {
|
|
56
|
+
const catalog = (options.apps ?? options.surfaces ?? []).map((v, order) => ({ key: v.key, title: v.title, group: v.group ?? "", icon: v.icon ?? "", many: v.many ?? false, order, routes: [...(v.routes ?? [])] }));
|
|
57
|
+
if (new Set(catalog.map((v) => v.key)).size !== catalog.length || catalog.some((v) => !/^[a-z][a-z0-9-]*$/.test(v.key))) throw new Error("desktop app keys must be unique lowercase names");
|
|
58
|
+
const sessionArg = { session: s.optional(s.string()) };
|
|
59
|
+
const routeArg = { ...sessionArg, app: s.string(), path: s.optional(s.string()), fresh: s.optional(s.boolean()) };
|
|
60
|
+
const windowArg = { ...sessionArg, window: s.optional(s.entity("desktopWindow")) };
|
|
61
|
+
const appShape = { key: s.key(s.string()), title: s.string(), group: s.string(), icon: s.string(), many: s.boolean(), order: s.integer(), routes: s.optional(s.array(s.string())) };
|
|
62
|
+
/** A session: the window in front and when it was last touched. `theme` is no longer read, it is the baba's resource now; kept for one release so an older state fits. */
|
|
63
|
+
const sessionShape = {
|
|
64
|
+
key: s.key(s.string()), selected: s.nullable(s.entity()), theme: s.enum(...themes), touched: s.optional(s.integer()),
|
|
65
|
+
};
|
|
66
|
+
const settingsShape = { theme: s.enum(...themes) };
|
|
67
|
+
/** One instance of an app in a session: where it is, the title its app gave it, what its app asked it to remember. Windows are in the order they were opened. */
|
|
68
|
+
const windowShape = { session: s.entity(), app: s.entity(), path: s.string(), title: s.string(), memory: s.object({}, { open: true }) };
|
|
69
|
+
return system({
|
|
70
|
+
name: "desktop",
|
|
71
|
+
model: {
|
|
72
|
+
desktopApp: appShape,
|
|
73
|
+
desktopSession: sessionShape,
|
|
74
|
+
desktopWindow: windowShape,
|
|
75
|
+
desktopAsk: accept(routeArg),
|
|
76
|
+
desktopAnswer: { session: s.nullable(s.entity()), error: s.nullable(s.string()) },
|
|
77
|
+
/** An app's icon, drawn: the shapes of the Lucide icon its `icon` names, none when the baba has no such icon. */
|
|
78
|
+
desktopIcon: { name: s.string(), shapes: s.array(s.object({ tag: s.enum(...iconTags), attrs: s.object({}, { open: true }) })) },
|
|
79
|
+
/** The baba's settings, the same on every page: its theme. */
|
|
80
|
+
desktopSettings: resource(settingsShape, { theme: "system" }),
|
|
81
|
+
},
|
|
82
|
+
effects: {
|
|
83
|
+
desktopIconRead: effect({
|
|
84
|
+
summary: "a Lucide icon's shapes, read from the baba's lucide-static or lucide-react; none when neither has it",
|
|
85
|
+
args: s.object({ name: s.string() }),
|
|
86
|
+
retry: 30_000,
|
|
87
|
+
result: s.object({ name: s.string(), shapes: s.array(s.object({ tag: s.enum(...iconTags), attrs: s.object({}, { open: true }) })) }),
|
|
88
|
+
run: async ({ name }, ctx) => ({ name, shapes: await readIcon(name, ctx.fs) }),
|
|
89
|
+
}),
|
|
90
|
+
},
|
|
91
|
+
}, (desktop, p) => {
|
|
92
|
+
type W = StateOf<typeof desktop>;
|
|
93
|
+
/** The state as the state query reads it: helpers it shares take this. */
|
|
94
|
+
type R = ReaderOf<typeof desktop>;
|
|
95
|
+
const { desktopApp: app, desktopSession: session, desktopWindow: window, desktopAsk: ask, desktopAnswer: answer, desktopIcon: icon, desktopSettings: settings, desktopIconRead: iconReadEffect } = desktop;
|
|
96
|
+
type Win = { entity: number; desktopWindow: Value<typeof window> };
|
|
97
|
+
const touchedAt = (w: R, e: number) => w.get(e, session)?.touched ?? 0;
|
|
98
|
+
/** The current session: the one last touched, the latest made among equals; none before any tab has entered. */
|
|
99
|
+
function current(w: R): number | undefined {
|
|
100
|
+
let best: number | undefined;
|
|
101
|
+
for (const r of w.query(session)) if (best === undefined || touchedAt(w, r.entity) >= touchedAt(w, best)) best = r.entity;
|
|
102
|
+
return best;
|
|
103
|
+
}
|
|
104
|
+
/** A new session, named by the first tree not taken; past the trees, a tree with a number. */
|
|
105
|
+
function mint(w: W): number {
|
|
106
|
+
const taken = new Set(w.query(session).map((r) => r.desktopSession.key));
|
|
107
|
+
let key: string | undefined = sessionNames.find((n) => !taken.has(n));
|
|
108
|
+
for (let i = 2; key === undefined; i++) key = sessionNames.map((n) => `${n}-${i}`).find((n) => !taken.has(n));
|
|
109
|
+
return w.spawn(session, { key, selected: null, theme: "system", touched: w.now });
|
|
110
|
+
}
|
|
111
|
+
const touch = (w: W, e: number) => { w.add(e, session, { ...w.get(e, session)!, touched: w.now }); return e; };
|
|
112
|
+
/** The session a call means: the one named, made if missing; unnamed, the current one, or a first one. Only presence touches it: entering, opening, selecting, closing; an app naming or remembering does not. */
|
|
113
|
+
function ensure(w: W, key?: string) {
|
|
114
|
+
if (key === undefined) return current(w) ?? mint(w);
|
|
115
|
+
if (!key.trim()) throw new Error("session must have a name");
|
|
116
|
+
return w.find(session, key) ?? w.spawn(session, { key, selected: null, theme: "system", touched: w.now });
|
|
117
|
+
}
|
|
118
|
+
const windowsOf = (w: R, ws: number): Win[] => w.query(window).filter((r) => r.desktopWindow.session === ws);
|
|
119
|
+
function sync(w: W) {
|
|
120
|
+
for (const item of catalog) {
|
|
121
|
+
const e = w.find(app, item.key);
|
|
122
|
+
if (e === undefined) w.spawn(app, item);
|
|
123
|
+
else if (JSON.stringify(w.get(e, app)) !== JSON.stringify(item)) w.add(e, app, item);
|
|
124
|
+
}
|
|
125
|
+
for (const r of w.query(app)) if (!catalog.some((v) => v.key === r.desktopApp.key)) w.despawn(r.entity);
|
|
126
|
+
// An app declared `many` no longer keeps its earlier windows: the latest stays.
|
|
127
|
+
const seen = new Set<string>();
|
|
128
|
+
for (const r of [...w.query(window)].reverse()) {
|
|
129
|
+
const at = `${r.desktopWindow.session}:${r.desktopWindow.app}`;
|
|
130
|
+
const many = w.get(r.desktopWindow.app, app)?.many ?? false;
|
|
131
|
+
if (!w.has(r.desktopWindow.app, app) || !w.has(r.desktopWindow.session, session) || (!many && seen.has(at))) { w.despawn(r.entity); continue; }
|
|
132
|
+
seen.add(at);
|
|
133
|
+
// A window at a path its app's routes no longer take goes home: the state is fitted to the code.
|
|
134
|
+
const routes = w.get(r.desktopWindow.app, app)?.routes ?? [];
|
|
135
|
+
if (routes.length && !matchRoute(routes, r.desktopWindow.path)) w.add(r.entity, window, { ...r.desktopWindow, path: "/" });
|
|
136
|
+
}
|
|
137
|
+
const now = current(w);
|
|
138
|
+
for (const r of w.query(session)) {
|
|
139
|
+
const v = r.desktopSession;
|
|
140
|
+
// A theme chosen on a session before the theme was the baba's becomes the baba's, once.
|
|
141
|
+
if (v.theme !== "system") { if (w.get(settings).theme === "system") w.set(settings, { theme: v.theme }); w.add(r.entity, session, { ...v, theme: "system" }); }
|
|
142
|
+
if (v.selected !== null && !w.has(v.selected, window)) w.add(r.entity, session, { ...w.get(r.entity, session)!, selected: null });
|
|
143
|
+
// A session with nothing open that no tab has touched for a day goes; the current one stays, so a tab always has its session.
|
|
144
|
+
else if (v.selected === null && r.entity !== now && w.now - (v.touched ?? 0) > SESSION_IDLE && !windowsOf(w, r.entity).length) w.despawn(r.entity);
|
|
145
|
+
}
|
|
146
|
+
// No session is made here: every baba runs the shell, and one no page has entered keeps its state as its systems left it.
|
|
147
|
+
}
|
|
148
|
+
function show(w: W, e: number, win: number) {
|
|
149
|
+
w.add(e, session, { ...w.get(e, session)!, selected: win, touched: w.now });
|
|
150
|
+
}
|
|
151
|
+
/** The one way in: the window at that path, else the app's current one, else a new one; `fresh` makes another where the app allows many. */
|
|
152
|
+
function open(w: W, asked: { session?: string; app: string; path?: string; fresh?: boolean }) {
|
|
153
|
+
// Validate before making a session, including for asks (which are not actions).
|
|
154
|
+
const item = catalog.find((v) => v.key === asked.app);
|
|
155
|
+
if (!item) throw new Error(`unknown desktop app ${asked.app}`);
|
|
156
|
+
if (asked.path !== undefined && (!asked.path.startsWith("/") || asked.path.startsWith("//"))) throw new Error("an app path must start with one slash");
|
|
157
|
+
// One spelling of a path, the address's: a window is found by it whichever way the path was written.
|
|
158
|
+
const a = asked.path === undefined ? asked : { ...asked, path: canonicalPath(asked.path) };
|
|
159
|
+
if (a.path !== undefined && item.routes.length && !matchRoute(item.routes, a.path)) throw new Error(`${a.path} is not a path of ${a.app}; its routes: ${item.routes.join(", ")}`);
|
|
160
|
+
sync(w);
|
|
161
|
+
const e = ensure(w, a.session), target = w.find(app, item.key)!;
|
|
162
|
+
const ws = w.get(e, session)!;
|
|
163
|
+
const wins = windowsOf(w, e).filter((r) => r.desktopWindow.app === target);
|
|
164
|
+
let win = a.path !== undefined ? wins.find((r) => r.desktopWindow.path === a.path) : undefined;
|
|
165
|
+
if (!win && !(a.fresh && item.many)) win = wins.find((r) => r.entity === ws.selected) ?? wins.at(-1);
|
|
166
|
+
if (win) {
|
|
167
|
+
if (a.path !== undefined && win.desktopWindow.path !== a.path) w.add(win.entity, window, { ...win.desktopWindow, path: a.path });
|
|
168
|
+
show(w, e, win.entity);
|
|
169
|
+
} else {
|
|
170
|
+
show(w, e, w.spawn(window, { session: e, app: target, path: a.path ?? "/", title: "", memory: {} }));
|
|
171
|
+
}
|
|
172
|
+
return e;
|
|
173
|
+
}
|
|
174
|
+
/** A window of the session, the selected one unless named. */
|
|
175
|
+
function windowOf(w: W, e: number, id: number | undefined): Win {
|
|
176
|
+
const ws = w.get(e, session)!;
|
|
177
|
+
const target = id ?? ws.selected;
|
|
178
|
+
if (target === null || target === undefined) throw new Error(`no window is selected in ${ws.key}`);
|
|
179
|
+
const v = w.get(target, window);
|
|
180
|
+
if (!v || v.session !== e) throw new Error(`${target} is not a window of ${ws.key}`);
|
|
181
|
+
return { entity: target, desktopWindow: v };
|
|
182
|
+
}
|
|
183
|
+
return [
|
|
184
|
+
// An app whose icon is not drawn yet, or names another icon now, has it read; a name the baba has no icon for comes back with no shapes, and the app draws a dot.
|
|
185
|
+
p.on(iconReadEffect, {
|
|
186
|
+
for: (w) => w.query(app).filter((r) => iconName.test(r.desktopApp.icon) && w.get(r.entity, icon)?.name !== r.desktopApp.icon).map((r) => [r.entity, { name: r.desktopApp.icon }] as const),
|
|
187
|
+
// Filed under the name it was read for: a name changed meanwhile is read again.
|
|
188
|
+
done: (w, e, r) => { if (e !== null && w.has(e, app)) w.add(e, icon, { name: r.name, shapes: r.shapes }); },
|
|
189
|
+
}),
|
|
190
|
+
|
|
191
|
+
// Only a shell something has entered is kept in step each round: every baba runs it, and one never entered adds nothing to its state.
|
|
192
|
+
p.step("reconcile", (w) => { if (w.query(session).length) sync(w); }),
|
|
193
|
+
|
|
194
|
+
p.step("requests", (w) => {
|
|
195
|
+
for (const r of w.query(ask)) {
|
|
196
|
+
try { w.add(r.entity, answer, { session: open(w, r.desktopAsk), error: null }); }
|
|
197
|
+
catch (error) { w.add(r.entity, answer, { session: null, error: String(error) }); }
|
|
198
|
+
w.remove(r.entity, ask);
|
|
199
|
+
}
|
|
200
|
+
}),
|
|
201
|
+
|
|
202
|
+
p.action("desktop-open", {
|
|
203
|
+
summary: "open an app at a path: the window already there, else the app's current window, else a new one; `fresh` opens another where the app allows many", args: routeArg, result: s.entity(),
|
|
204
|
+
run: (w, a) => open(w, a),
|
|
205
|
+
}),
|
|
206
|
+
|
|
207
|
+
p.action("desktop-select", {
|
|
208
|
+
summary: "bring a window to the front", args: { ...sessionArg, window: s.entity("desktopWindow") }, result: s.entity(),
|
|
209
|
+
run: (w, a) => {
|
|
210
|
+
sync(w); const e = ensure(w, a.session); const win = windowOf(w, e, a.window); show(w, e, win.entity); return e;
|
|
211
|
+
},
|
|
212
|
+
}),
|
|
213
|
+
|
|
214
|
+
p.action("desktop-close", {
|
|
215
|
+
summary: "close a window, the selected one unless named; closing the one in front brings the window to its left forward, else the one to its right, else nothing, and the page shows its launcher", args: windowArg, result: s.entity(),
|
|
216
|
+
run: (w, a) => {
|
|
217
|
+
sync(w); const e = ensure(w, a.session); const ws = w.get(e, session)!;
|
|
218
|
+
const win = windowOf(w, e, a.window);
|
|
219
|
+
const order = windowsOf(w, e), at = order.findIndex((r) => r.entity === win.entity);
|
|
220
|
+
const next = order[at - 1] ?? order[at + 1];
|
|
221
|
+
w.despawn(win.entity);
|
|
222
|
+
w.add(e, session, { ...ws, selected: ws.selected === win.entity ? next?.entity ?? null : ws.selected, touched: w.now });
|
|
223
|
+
return e;
|
|
224
|
+
},
|
|
225
|
+
}),
|
|
226
|
+
|
|
227
|
+
p.action("desktop-title", {
|
|
228
|
+
summary: "name a window after what its app shows, the selected one unless named", args: { ...windowArg, title: s.string() }, result: s.entity(),
|
|
229
|
+
run: (w, a) => {
|
|
230
|
+
sync(w); const e = ensure(w, a.session); const win = windowOf(w, e, a.window);
|
|
231
|
+
if (win.desktopWindow.title !== a.title) w.add(win.entity, window, { ...win.desktopWindow, title: a.title });
|
|
232
|
+
return win.entity;
|
|
233
|
+
},
|
|
234
|
+
}),
|
|
235
|
+
|
|
236
|
+
p.action("desktop-remember", {
|
|
237
|
+
summary: "keep a value on a window for its app, the selected window unless named; null forgets the key", args: { ...windowArg, key: s.string(), value: s.unknown() }, result: s.entity(),
|
|
238
|
+
run: (w, a) => {
|
|
239
|
+
sync(w); const e = ensure(w, a.session); const win = windowOf(w, e, a.window);
|
|
240
|
+
const memory = { ...(win.desktopWindow.memory as Record<string, unknown>) };
|
|
241
|
+
if (a.value === null || a.value === undefined) delete memory[a.key]; else memory[a.key] = a.value;
|
|
242
|
+
w.add(win.entity, window, { ...win.desktopWindow, memory });
|
|
243
|
+
return win.entity;
|
|
244
|
+
},
|
|
245
|
+
}),
|
|
246
|
+
|
|
247
|
+
p.action("desktop-enter", {
|
|
248
|
+
summary: "enter a session: the session named, made if it is missing, else a new one with a name of its own; a tab enters on load and on focus, so the session it is on is the current one", args: sessionArg, result: s.entity(),
|
|
249
|
+
run: (w, a) => {
|
|
250
|
+
sync(w); return touch(w, a.session === undefined ? mint(w) : ensure(w, a.session));
|
|
251
|
+
},
|
|
252
|
+
}),
|
|
253
|
+
|
|
254
|
+
p.action("desktop-leave", {
|
|
255
|
+
summary: "end a session: its windows close and the session goes; the current one unless named", args: sessionArg, result: s.entity(),
|
|
256
|
+
run: (w, a) => {
|
|
257
|
+
sync(w); const e = ensure(w, a.session);
|
|
258
|
+
for (const r of windowsOf(w, e)) w.despawn(r.entity);
|
|
259
|
+
w.despawn(e);
|
|
260
|
+
return e;
|
|
261
|
+
},
|
|
262
|
+
}),
|
|
263
|
+
|
|
264
|
+
p.action("desktop-settings", {
|
|
265
|
+
summary: "set the baba's theme, the same on every page", args: { ...sessionArg, theme: s.optional(s.enum(...themes)) },
|
|
266
|
+
run: (w, a) => {
|
|
267
|
+
sync(w); if (a.theme) w.set(settings, { theme: a.theme });
|
|
268
|
+
},
|
|
269
|
+
}),
|
|
270
|
+
|
|
271
|
+
p.query("desktop-state", {
|
|
272
|
+
summary: "a session, the current one unless named: its record, apps and windows; every session beside it, last touched first; the theme", args: sessionArg,
|
|
273
|
+
result: s.object({
|
|
274
|
+
entity: s.nullable(s.entity()), session: s.nullable(s.object(sessionShape)), apps: s.array(s.object({ entity: s.entity(), ...appShape })), windows: s.array(s.object({ entity: s.entity(), ...windowShape })),
|
|
275
|
+
sessions: s.array(s.object({ entity: s.entity(), key: s.string(), touched: s.integer(), windows: s.integer(), front: s.nullable(s.string()) })), theme: s.enum(...themes),
|
|
276
|
+
}),
|
|
277
|
+
read: (w, a) => {
|
|
278
|
+
const e = a.session === undefined ? current(w) : w.find(session, a.session);
|
|
279
|
+
const sessions = w.query(session).map((r) => {
|
|
280
|
+
const wins = windowsOf(w, r.entity), front = wins.find((o) => o.entity === r.desktopSession.selected);
|
|
281
|
+
const label = front ? front.desktopWindow.title || (front.desktopWindow.path === "/" ? w.get(front.desktopWindow.app, app)?.title ?? null : front.desktopWindow.path) : null;
|
|
282
|
+
return { entity: r.entity, key: r.desktopSession.key, touched: r.desktopSession.touched ?? 0, windows: wins.length, front: label };
|
|
283
|
+
}).sort((x, y) => y.touched - x.touched || y.entity - x.entity);
|
|
284
|
+
return { entity: e ?? null, session: e === undefined ? null : w.get(e, session)!, apps: w.query(app).map((r) => ({ entity: r.entity, ...r.desktopApp })), windows: e === undefined ? [] : windowsOf(w, e).map((r) => ({ entity: r.entity, ...r.desktopWindow })), sessions, theme: w.get(settings).theme };
|
|
285
|
+
},
|
|
286
|
+
}),
|
|
287
|
+
];
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
export type DesktopSystem = ReturnType<typeof desktop>;
|
|
291
|
+
/** voss's own apps, after every baba's: the Maker, the state, the contract, the know-how, the settings. An interface may give one its own component; a baba may not declare one. */
|
|
292
|
+
export const vossApps = [
|
|
293
|
+
{ key: "maker", title: "Maker", icon: "hammer", group: "Voss" },
|
|
294
|
+
{ key: "state", title: "State", icon: "database", group: "Voss" },
|
|
295
|
+
{ key: "contract", title: "Contract", icon: "square-terminal", group: "Voss" },
|
|
296
|
+
{ key: "promptware", title: "Promptware", icon: "book-open-text", group: "Voss" },
|
|
297
|
+
{ key: "settings", title: "Settings", icon: "settings", group: "Voss" },
|
|
298
|
+
] as const satisfies readonly AppSpec[];
|
|
299
|
+
export type VossApp = typeof vossApps[number]["key"];
|
|
300
|
+
/** The app a baba that declares none has: its interface, whatever it is, in one window. */
|
|
301
|
+
export const viewApp = { key: "view", title: "View", icon: "app-window" } as const satisfies AppSpec;
|
|
302
|
+
/** The catalog the shell runs: the baba's apps, or its one view, then voss's. */
|
|
303
|
+
export function catalogOf(apps: readonly AppSpec[]): AppSpec[] {
|
|
304
|
+
for (const v of apps) if (vossApps.some((b) => b.key === v.key)) throw new Error(`the app ${v.key} is voss's; to draw it otherwise, give it a component in compose, without declaring it`);
|
|
305
|
+
return [...(apps.length ? apps : [viewApp]), ...vossApps];
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** The shell's references, for a system that asks it to open an app (`w.add(e, shell.desktopAsk, …)`) and for an app's tests: the same names as the system voss runs. */
|
|
309
|
+
export const shell = desktop({});
|
|
310
|
+
export type Session = Value<DesktopSystem["desktopSession"]>;
|
|
311
|
+
export type DesktopApp = Value<DesktopSystem["desktopApp"]>;
|
|
312
|
+
/** The old name. */
|
|
313
|
+
export type DesktopSurface = DesktopApp;
|
|
314
|
+
export type DesktopWindow = Value<DesktopSystem["desktopWindow"]>;
|
|
315
|
+
|
|
316
|
+
/** The apps whose title, key or group contain every word, in catalog order. */
|
|
317
|
+
export function searchApps<T extends DesktopApp>(items: readonly T[], text: string): T[] {
|
|
318
|
+
const words = text.toLowerCase().trim().split(/\s+/).filter(Boolean);
|
|
319
|
+
return items.filter((s) => words.every((word) => `${s.title} ${s.key} ${s.group}`.toLowerCase().includes(word))).sort((a, b) => a.order - b.order);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** The old name. */
|
|
323
|
+
export const searchSurfaces = searchApps;
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// An app's routes: the paths its windows may be at, declared as data beside the app, so the desktop system
|
|
2
|
+
// refuses a path no route takes, a system builds a path without importing the interface, and compose checks
|
|
3
|
+
// a component for each. A pattern is segments after one slash: a literal, `:name` for one segment, and last
|
|
4
|
+
// `*` for the rest, none or more. `/` is the app's home. Matching prefers literals to names, names to the rest.
|
|
5
|
+
|
|
6
|
+
/** The parameters a pattern names, as `route` takes them. */
|
|
7
|
+
export type RouteParams<P extends string> = Flat<Split<P extends `/${infer R}` ? R : P>>;
|
|
8
|
+
type Seg<S extends string> = S extends `:${infer N}` ? { [K in N]: string | number } : S extends "*" ? { "*"?: string } : {};
|
|
9
|
+
type Split<P extends string> = P extends `${infer A}/${infer B}` ? Seg<A> & Split<B> : Seg<P>;
|
|
10
|
+
type Flat<T> = { [K in keyof T]: T[K] };
|
|
11
|
+
|
|
12
|
+
/** A path matched: the pattern that took it, and its parameters, decoded. */
|
|
13
|
+
export interface RouteMatch { route: string; params: Record<string, string> }
|
|
14
|
+
|
|
15
|
+
const LITERAL = /^[A-Za-z0-9._~-]+$/, NAME = /^:[A-Za-z_][A-Za-z0-9_]*$/;
|
|
16
|
+
const segments = (path: string) => path === "/" ? [] : path.slice(1).split("/");
|
|
17
|
+
|
|
18
|
+
/** Why a pattern is not one, or null. */
|
|
19
|
+
export function routeError(pattern: string): string | null {
|
|
20
|
+
if (!pattern.startsWith("/")) return `the route ${JSON.stringify(pattern)} must start with /`;
|
|
21
|
+
if (pattern !== "/" && pattern.endsWith("/")) return `the route ${pattern} must not end with /`;
|
|
22
|
+
const segs = segments(pattern), seen = new Set<string>();
|
|
23
|
+
for (const [i, s] of segs.entries()) {
|
|
24
|
+
if (s === "*") { if (i !== segs.length - 1) return `the route ${pattern}: * is only the last segment`; continue; }
|
|
25
|
+
if (NAME.test(s)) { if (seen.has(s)) return `the route ${pattern} names ${s} twice`; seen.add(s); continue; }
|
|
26
|
+
if (!LITERAL.test(s)) return `the route ${pattern}: ${JSON.stringify(s)} is neither a word, a :name nor *`;
|
|
27
|
+
}
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Why a list of routes is not an app's, or null: each a pattern, none twice, and one of them takes `/`, the home. */
|
|
32
|
+
export function routesError(routes: readonly string[]): string | null {
|
|
33
|
+
for (const r of routes) { const e = routeError(r); if (e) return e; }
|
|
34
|
+
if (new Set(routes).size !== routes.length) return "a route is declared twice";
|
|
35
|
+
if (routes.length && !matchRoute(routes, "/")) return "no route takes /, the app's home";
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** How specific a pattern is, segment by segment: a literal 2, a name 1, the rest -1; compared left to right, a pattern that has ended (0) before the rest, so `/files` takes `/files` from `/files/*`. */
|
|
40
|
+
const rank = (pattern: string) => segments(pattern).map((s) => s === "*" ? -1 : s.startsWith(":") ? 1 : 2);
|
|
41
|
+
function compare(a: number[], b: number[]) {
|
|
42
|
+
for (let i = 0; i < Math.max(a.length, b.length); i++) { const x = a[i] ?? 0, y = b[i] ?? 0; if (x !== y) return y - x; }
|
|
43
|
+
return 0;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* A path in the one form the desktop keeps and the address shows: each segment decoded, then encoded, so `/task/a b`,
|
|
48
|
+
* `/task/a%20b` and the browser's spelling of either are one path. `.` and `..`, which a browser resolves away, are refused.
|
|
49
|
+
*/
|
|
50
|
+
export function canonicalPath(path: string): string {
|
|
51
|
+
if (path === "/") return path;
|
|
52
|
+
return "/" + path.slice(1).split("/").map((s) => {
|
|
53
|
+
let v = s;
|
|
54
|
+
try { v = decodeURIComponent(s); } catch { /* a malformed escape is kept as written, encoded */ }
|
|
55
|
+
if (v === "." || v === "..") throw new Error(`an app path has no . or .. segment: ${path}`);
|
|
56
|
+
return encodeURIComponent(v);
|
|
57
|
+
}).join("/");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The route that takes a path, the most specific; null when none does. A trailing slash is ignored. */
|
|
61
|
+
export function matchRoute(routes: readonly string[], path: string): RouteMatch | null {
|
|
62
|
+
if (!path.startsWith("/")) return null;
|
|
63
|
+
const at = segments(path.length > 1 && path.endsWith("/") ? path.slice(0, -1) : path);
|
|
64
|
+
let best: { m: RouteMatch; rank: number[] } | null = null;
|
|
65
|
+
for (const route of routes) {
|
|
66
|
+
const pat = segments(route), params: Record<string, string> = {};
|
|
67
|
+
let ok = true;
|
|
68
|
+
try {
|
|
69
|
+
for (let i = 0; ok && i < pat.length; i++) {
|
|
70
|
+
const p = pat[i]!;
|
|
71
|
+
if (p === "*") { params["*"] = at.slice(i).map(decodeURIComponent).join("/"); break; }
|
|
72
|
+
if (i >= at.length) ok = false;
|
|
73
|
+
else if (p.startsWith(":")) { if (at[i] === "") ok = false; else params[p.slice(1)] = decodeURIComponent(at[i]!); }
|
|
74
|
+
else if (p !== at[i]) ok = false;
|
|
75
|
+
}
|
|
76
|
+
} catch { ok = false; } // a malformed escape takes no route
|
|
77
|
+
if (ok && pat.at(-1) !== "*" && pat.length !== at.length) ok = false;
|
|
78
|
+
if (!ok) continue;
|
|
79
|
+
const r = rank(route);
|
|
80
|
+
if (!best || compare(r, best.rank) < 0) best = { m: { route, params }, rank: r };
|
|
81
|
+
}
|
|
82
|
+
return best?.m ?? null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** The path of a route with its parameters, each encoded: `route("/pr/:n", { n: 86 })` is `/pr/86`. */
|
|
86
|
+
export function route<P extends string>(pattern: P, ...params: {} extends RouteParams<P> ? [RouteParams<P>?] : [RouteParams<P>]): string {
|
|
87
|
+
const e = routeError(pattern);
|
|
88
|
+
if (e) throw new Error(e);
|
|
89
|
+
const values = (params[0] ?? {}) as Record<string, string | number>;
|
|
90
|
+
const out = segments(pattern).flatMap((s) => {
|
|
91
|
+
if (s === "*") return String(values["*"] ?? "").split("/").filter(Boolean).map(encodeURIComponent);
|
|
92
|
+
if (!s.startsWith(":")) return [s];
|
|
93
|
+
const v = values[s.slice(1)];
|
|
94
|
+
if (v === undefined || v === "") throw new Error(`the route ${pattern} needs ${s}`);
|
|
95
|
+
return [encodeURIComponent(String(v))];
|
|
96
|
+
});
|
|
97
|
+
return canonicalPath("/" + out.join("/"));
|
|
98
|
+
}
|