@abide/abide 0.102.0 → 0.104.0
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/CHANGELOG.md +23 -0
- package/bin/abide.ts +31 -28
- package/docs/REGISTRY.md +40 -36
- package/docs/RULEBOOK.md +113 -92
- package/docs/abide.md +788 -554
- package/package.json +2 -1
- package/scaffold/AGENTS.md +13 -6
- package/src/compiler/TypeSession.ts +108 -35
- package/src/compiler/appMap.ts +91 -0
- package/src/compiler/argsSchemaOf.ts +1 -1
- package/src/compiler/checkApp.ts +35 -18
- package/src/compiler/checkedShadow.ts +40 -7
- package/src/compiler/compileApp.ts +271 -76
- package/src/compiler/compiledEntry.ts +10 -6
- package/src/compiler/componentGraph.ts +6 -7
- package/src/compiler/declaredExports.ts +3 -3
- package/src/compiler/emitClient.ts +3 -3
- package/src/compiler/emitComponent.ts +9 -9
- package/src/compiler/emitSource.ts +5 -9
- package/src/compiler/handlersUnder.ts +114 -73
- package/src/compiler/hooksCalled.ts +35 -0
- package/src/compiler/mountsOf.ts +30 -18
- package/src/compiler/parseAbide.ts +12 -12
- package/src/compiler/projectShadow.ts +71 -46
- package/src/compiler/readsIn.ts +2 -2
- package/src/compiler/scaffold.ts +24 -21
- package/src/compiler/serveLanguage.ts +0 -24
- package/src/compiler/sharedModuleWrites.ts +2 -2
- package/src/compiler/splitImports.ts +4 -4
- package/src/compiler/writeDefaultProject.ts +28 -1
- package/src/server/MODULE_QUERIES.ts +12 -3
- package/src/server/abideModules.ts +156 -53
- package/src/server/answerRequest.ts +9 -4
- package/src/server/answerRpc.ts +18 -16
- package/src/server/appTree.ts +92 -25
- package/src/server/commands.ts +5 -4
- package/src/server/componentModules.d.ts +1 -2
- package/src/server/createApp.ts +23 -11
- package/src/server/devWorker.ts +5 -1
- package/src/server/fenceServer.ts +36 -13
- package/src/server/generatedSurfaces.ts +12 -53
- package/src/server/hooks.ts +17 -4
- package/src/server/index.ts +1 -0
- package/src/server/installSourceMaps.ts +6 -1
- package/src/server/launcherOptions.ts +12 -13
- package/src/server/launcherPage.ts +2 -2
- package/src/server/nativeMenu.ts +3 -3
- package/src/server/preload.ts +13 -0
- package/src/server/runDesktop.ts +11 -16
- package/src/server/serveDev.ts +8 -2
- package/src/server/surfaceOf.ts +2 -0
- package/src/server/versionOf.ts +1 -2
- package/src/server/withAbideSources.ts +1 -1
- package/src/shared/ADDRESSES.ts +3 -0
- package/src/shared/LAUNCHER_FILES.ts +9 -0
- package/src/shared/RESERVED_DIRECTORIES.ts +19 -4
- package/src/shared/index.ts +2 -1
- package/src/{ui → shared}/menuBridge.ts +1 -1
- package/src/shared/menuItem.ts +2 -2
- package/src/shared/onMenu.ts +58 -0
- package/src/shared/reactive/Cursor.ts +66 -123
- package/src/shared/reactive/Reactive.ts +74 -28
- package/src/shared/reactive/callLoad.ts +4 -18
- package/src/shared/reactive/chunksOfStream.ts +10 -34
- package/src/{ui → shared}/reportMenu.ts +7 -9
- package/src/shared/routePattern.ts +74 -23
- package/src/shared/rpc.ts +1 -1
- package/src/ui/Router.ts +0 -6
- package/src/compiler/syncTypes.ts +0 -32
- package/src/compiler/writeTypes.ts +0 -82
- package/src/server/LAUNCHER_FILES.ts +0 -9
- /package/scaffold/src/{page.abide → index.abide} +0 -0
- /package/src/{compiler → shared}/SPECIFIERS.ts +0 -0
|
@@ -1,12 +1,11 @@
|
|
|
1
|
-
// 38.80 — THE `
|
|
2
|
-
//
|
|
3
|
-
// never by evaluating it during the bundle.
|
|
1
|
+
// 38.80 — WHAT THE `onLauncher` A `src/launcher.abide` CALLS ANSWERS: the window the desktop app opens
|
|
2
|
+
// and the forms its launcher offers, read as the app launches.
|
|
4
3
|
|
|
5
4
|
import { isRecord } from '#shared/isRecord.ts'
|
|
6
5
|
|
|
7
6
|
/**
|
|
8
|
-
* What
|
|
9
|
-
*
|
|
7
|
+
* What `onLauncher` may answer. 38.81 — turning off both forms is not one of them, so the checker
|
|
8
|
+
* refuses it where the answer is typed.
|
|
10
9
|
*/
|
|
11
10
|
export type LauncherOptions = {
|
|
12
11
|
width?: number
|
|
@@ -29,10 +28,10 @@ const SIZES = ['width', 'height', 'minWidth', 'minHeight'] as const
|
|
|
29
28
|
const FORMS = ['start', 'connect'] as const
|
|
30
29
|
|
|
31
30
|
/**
|
|
32
|
-
* The launcher `
|
|
33
|
-
* run time, where an untyped
|
|
31
|
+
* The launcher what `onLauncher` answered sets, or the refusal naming what is wrong with it — read
|
|
32
|
+
* at run time, where an untyped answer can still name a key it does not take.
|
|
34
33
|
*/
|
|
35
|
-
export function launcherOf(
|
|
34
|
+
export function launcherOf(answered: unknown): Launcher | string {
|
|
36
35
|
const launcher: Launcher = {
|
|
37
36
|
width: 1100,
|
|
38
37
|
height: 760,
|
|
@@ -41,9 +40,9 @@ export function launcherOf(exported: unknown): Launcher | string {
|
|
|
41
40
|
start: true,
|
|
42
41
|
connect: true,
|
|
43
42
|
}
|
|
44
|
-
if (
|
|
45
|
-
if (!isRecord(
|
|
46
|
-
for (const [key, value] of Object.entries(
|
|
43
|
+
if (answered === undefined) return launcher
|
|
44
|
+
if (!isRecord(answered)) return '`onLauncher` answers an object'
|
|
45
|
+
for (const [key, value] of Object.entries(answered)) {
|
|
47
46
|
if (
|
|
48
47
|
(SIZES as readonly string[]).includes(key) &&
|
|
49
48
|
typeof value === 'number' &&
|
|
@@ -56,10 +55,10 @@ export function launcherOf(exported: unknown): Launcher | string {
|
|
|
56
55
|
)
|
|
57
56
|
launcher[key as (typeof FORMS)[number]] = value
|
|
58
57
|
else
|
|
59
|
-
return
|
|
58
|
+
return `\`${key}\` is not a launcher option, or ${JSON.stringify(value)} is not a value it takes`
|
|
60
59
|
}
|
|
61
60
|
// 38.81.
|
|
62
61
|
if (!launcher.start && !launcher.connect)
|
|
63
|
-
return '`
|
|
62
|
+
return '`onLauncher` turns off both start and connect, which leaves the launcher nothing to do'
|
|
64
63
|
return launcher
|
|
65
64
|
}
|
|
@@ -21,9 +21,9 @@ const SYSTEM_ACCENT = '#0a84ff'
|
|
|
21
21
|
export type Launch = {
|
|
22
22
|
/** The app's name, as its package gives it. */
|
|
23
23
|
name: string
|
|
24
|
-
/** Where the start form posts, or `undefined` where
|
|
24
|
+
/** Where the start form posts, or `undefined` where what `onLauncher` answers turns starting off. */
|
|
25
25
|
start: string | undefined
|
|
26
|
-
/** Where the connect form posts, or `undefined` where
|
|
26
|
+
/** Where the connect form posts, or `undefined` where what `onLauncher` answers turns connecting off. */
|
|
27
27
|
connect: string | undefined
|
|
28
28
|
fields: ConfigField[]
|
|
29
29
|
/** The URL the last connect was given. */
|
package/src/server/nativeMenu.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
// THE DESKTOP APP'S MENU BAR (38.88): the default, with
|
|
2
|
-
// built as an `NSMenu`.
|
|
1
|
+
// THE DESKTOP APP'S MENU BAR (38.88): the default, with the menus the standing layouts and page file
|
|
2
|
+
// with `onMenu`, built as an `NSMenu`.
|
|
3
3
|
|
|
4
4
|
import { FFIType } from 'bun:ffi'
|
|
5
5
|
import type { MenuRole } from '#shared/menuItem.ts'
|
|
@@ -77,7 +77,7 @@ const NAMED_KEYS: Record<string, string> = {
|
|
|
77
77
|
* The menu bar the window shows: the application, File, Edit, View and Window menus, and `added`.
|
|
78
78
|
*
|
|
79
79
|
* 38.88 — AN ADDED MENU NAMED AS A DEFAULT ONE GROWS IT, and any other stands before Window, where
|
|
80
|
-
* the platform puts an app's own. 38.83 — no Connect… where
|
|
80
|
+
* the platform puts an app's own. 38.83 — no Connect… where what `onLauncher` answers turns connecting off.
|
|
81
81
|
*/
|
|
82
82
|
export function menuBar(
|
|
83
83
|
appName: string,
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// 38.109 — `@abide/abide/test`, PRELOADED BY `bun test`: the app's compilation served before any test
|
|
2
|
+
// file is loaded, so a test imports a `.abide` as any module — beside the code it tests, at the top
|
|
3
|
+
// of the file, with no `createApp` run first. Bun loads every import of a file before it evaluates
|
|
4
|
+
// any, so registering the compilation from inside a test is always too late; a preload is the
|
|
5
|
+
// one place that runs first. The build is `createApp`'s own under `bun test` (42.16), so a test that
|
|
6
|
+
// boots the app afterwards reuses it rather than building twice.
|
|
7
|
+
|
|
8
|
+
import { appRoot, servedBuild } from './createApp.ts'
|
|
9
|
+
|
|
10
|
+
const root = appRoot(undefined)
|
|
11
|
+
// A run from a directory that holds no app — a workspace's root — preloads nothing.
|
|
12
|
+
// Fresh whatever `NODE_ENV` says: `bun test` sets it only where nothing else has.
|
|
13
|
+
if (root !== undefined) await servedBuild(root, true)
|
package/src/server/runDesktop.ts
CHANGED
|
@@ -26,7 +26,7 @@ import { APP_PACKAGE, configuredMount, dataDirectory } from './config.ts'
|
|
|
26
26
|
import { configFields } from './configFields.ts'
|
|
27
27
|
import { type App, type AppOrigin, bootApp } from './createApp.ts'
|
|
28
28
|
import { executableName } from './executableName.ts'
|
|
29
|
-
import { reportUnexpected } from './hooks.ts'
|
|
29
|
+
import { HOOKS, inOrder, reportUnexpected } from './hooks.ts'
|
|
30
30
|
import { installServer } from './installServer.ts'
|
|
31
31
|
import { launcherOf } from './launcherOptions.ts'
|
|
32
32
|
import { type Launch, launcherPage } from './launcherPage.ts'
|
|
@@ -53,15 +53,10 @@ type Failure = {
|
|
|
53
53
|
|
|
54
54
|
/** What `abide bundle` read of the launcher's files, carried in the executable. */
|
|
55
55
|
export type Desktop = {
|
|
56
|
-
/** 38.
|
|
56
|
+
/** 38.82 — `src/launcher.abide`'s page, where the app has one that paints. */
|
|
57
57
|
launch:
|
|
58
|
-
| (() => Promise<{
|
|
59
|
-
default: (props: Launch) => Component
|
|
60
|
-
launcher?: unknown
|
|
61
|
-
}>)
|
|
58
|
+
| (() => Promise<{ default: (props: Launch) => Component }>)
|
|
62
59
|
| undefined
|
|
63
|
-
/** 38.82 — whether that file has markup of its own, which then stands in for the built-in page. */
|
|
64
|
-
custom: boolean
|
|
65
60
|
/** The document the launch page renders into, its stylesheet already in its head (38.84). */
|
|
66
61
|
document: string
|
|
67
62
|
/** 38.101 — the app's icon as a data URL, and the accent taken from it. */
|
|
@@ -87,23 +82,23 @@ export async function runDesktop(
|
|
|
87
82
|
async function launch(
|
|
88
83
|
origin: AppOrigin,
|
|
89
84
|
load: () => ReturnType<typeof builtSources>,
|
|
90
|
-
{ launch: launchModule,
|
|
85
|
+
{ launch: launchModule, document, icon, accent }: Desktop,
|
|
91
86
|
): Promise<void> {
|
|
92
87
|
installServer()
|
|
93
88
|
APP_PACKAGE.current = origin.package
|
|
94
|
-
// 37.1 — imported for what it files, so the start form reads `onConfig
|
|
89
|
+
// 37.1 — imported for what it files, so the start form reads `onConfig` and the launcher's
|
|
90
|
+
// `onLauncher` is filed.
|
|
95
91
|
await origin.hooks?.()
|
|
96
|
-
//
|
|
97
|
-
|
|
98
|
-
const launcherModule = await launchModule?.().catch((thrown: unknown) => {
|
|
92
|
+
// 38.85 — a launcher page that fails to load leaves the built-in page, with its defaults.
|
|
93
|
+
const launchPage = await launchModule?.().catch((thrown: unknown) => {
|
|
99
94
|
reportUnexpected(thrown)
|
|
100
95
|
return undefined
|
|
101
96
|
})
|
|
102
|
-
|
|
103
|
-
|
|
97
|
+
// 38.80 — what its `onLauncher` answers.
|
|
98
|
+
const options = launcherOf(inOrder(HOOKS.launcher).at(-1)?.())
|
|
99
|
+
// 38.81 — an `onLauncher` answer the checker would have refused, written where nothing checked it.
|
|
104
100
|
if (typeof options === 'string')
|
|
105
101
|
throw new Error(`abide: ${options}. RULEBOOK 38.81.`)
|
|
106
|
-
const launchPage = custom ? launcherModule : undefined
|
|
107
102
|
const appName = executableName(origin.package.name)
|
|
108
103
|
const directory = dataDirectory(origin.package.name ?? 'app')
|
|
109
104
|
const stateFile = `${directory}/desktop.json`
|
package/src/server/serveDev.ts
CHANGED
|
@@ -22,6 +22,7 @@ import { watch } from 'node:fs' // bun exposes no file watcher of its own
|
|
|
22
22
|
import { dirname, resolve } from 'node:path' // bun exposes no path helpers of its own
|
|
23
23
|
import {
|
|
24
24
|
type AppChecker,
|
|
25
|
+
compilationOf,
|
|
25
26
|
compileApp,
|
|
26
27
|
openChecker,
|
|
27
28
|
} from '#compiler/compileApp.ts'
|
|
@@ -144,7 +145,7 @@ export async function serveDev(
|
|
|
144
145
|
console.warn(formatDiagnosis(warning, process.cwd()))
|
|
145
146
|
watchDependencies(compiled.files)
|
|
146
147
|
return {
|
|
147
|
-
compilation:
|
|
148
|
+
compilation: compilationOf(compiled),
|
|
148
149
|
published: compiled.published,
|
|
149
150
|
failure: '',
|
|
150
151
|
}
|
|
@@ -154,7 +155,12 @@ export async function serveDev(
|
|
|
154
155
|
thrown instanceof Error ? thrown.message : String(thrown)
|
|
155
156
|
console.error(`abide dev: ${failure}`)
|
|
156
157
|
return {
|
|
157
|
-
compilation: {
|
|
158
|
+
compilation: {
|
|
159
|
+
files: {},
|
|
160
|
+
handlers: {},
|
|
161
|
+
tables: {},
|
|
162
|
+
mounted: {},
|
|
163
|
+
},
|
|
158
164
|
published: new Map(),
|
|
159
165
|
failure,
|
|
160
166
|
}
|
package/src/server/surfaceOf.ts
CHANGED
|
@@ -31,6 +31,8 @@ export function surfaceOf(
|
|
|
31
31
|
mount: Mount | Mounted,
|
|
32
32
|
surface: Surface,
|
|
33
33
|
): Listed | undefined {
|
|
34
|
+
// 16.71 — a private handler is its file's alone.
|
|
35
|
+
if (mount.private) return undefined
|
|
34
36
|
const live = 'handler' in mount ? mount : undefined
|
|
35
37
|
// 16.37 — what its `clients` says, and the build's default where it says nothing.
|
|
36
38
|
const carried =
|
package/src/server/versionOf.ts
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
// 18.7 — THE VERSION A VALUE'S STANDING PRODUCTION IS NAMED BY OVER THE WIRE. Stamped where it is
|
|
2
2
|
// asked for rather than where a production is minted, so a value no browser follows pays nothing on
|
|
3
3
|
// its production path. A production is told apart from the one before by the ordinal, what is held
|
|
4
|
-
//
|
|
5
|
-
// for a patch).
|
|
4
|
+
// and the patch epoch of what is held (5.7 moves no ordinal for a patch).
|
|
6
5
|
//
|
|
7
6
|
// ONE COUNTER FOR THE PROCESS, started at the boot time in microseconds, so no two productions share
|
|
8
7
|
// a number: not two values, not an entry evicted and built again, and not this process and the one
|
|
@@ -96,7 +96,7 @@ export async function withAbideSources(
|
|
|
96
96
|
}
|
|
97
97
|
|
|
98
98
|
/**
|
|
99
|
-
* 16.28, 34.
|
|
99
|
+
* 16.28, 34.19 — the `.abide`'s text with every `<script server>` body blanked: the map is served
|
|
100
100
|
* to a browser, which reads none of one. Each line is kept, so every line the map places is still
|
|
101
101
|
* the line it names.
|
|
102
102
|
*/
|
package/src/shared/ADDRESSES.ts
CHANGED
|
@@ -24,6 +24,9 @@ export const ABIDE_ADDRESS = '/__abide'
|
|
|
24
24
|
*/
|
|
25
25
|
export const CLIENT_ADDRESS = `${ABIDE_ADDRESS}/client`
|
|
26
26
|
|
|
27
|
+
/** Where a private handler is answered, below its file's path and then its name (16.70). */
|
|
28
|
+
export const PRIVATE_HANDLERS_ADDRESS = `${ABIDE_ADDRESS}/handlers`
|
|
29
|
+
|
|
27
30
|
// 20.5, 20.9, 36.7 — the generated surfaces, which the CLI reaches as well as the server answers.
|
|
28
31
|
|
|
29
32
|
/** The generated OpenAPI document. */
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// THE DESKTOP LAUNCHER'S FILES. Its page, which calls `onLauncher`, sits at the app's root beside the
|
|
2
|
+
// root layout, a name the framework finds rather than a page (23.21); the icon is a public file,
|
|
3
|
+
// so it is wherever the address it answers at says (36.11).
|
|
4
|
+
|
|
5
|
+
/** The `.abide` the desktop launcher renders, at the root alone. 38.80, 38.82. */
|
|
6
|
+
export const LAUNCHER_PAGE = 'launcher.abide'
|
|
7
|
+
|
|
8
|
+
/** The addresses of the desktop app's icon, the `.icns` first. 38.79. */
|
|
9
|
+
export const ICON_ADDRESSES = { icns: '/icon.icns', png: '/icon.png' } as const
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
// THE
|
|
2
|
-
// `public` is answered as authored (36.11)
|
|
3
|
-
// address of the
|
|
1
|
+
// THE FENCES: the four directory names that are never an address (23.15). `server` fences a graph
|
|
2
|
+
// (16.29), `public` is answered as authored (36.11), and `lib` and `tests` hold what is neither an
|
|
3
|
+
// address nor either of those. Every other directory under the root is a segment of the routes of
|
|
4
|
+
// the files directly in it (23.14), and holds nothing else (23.22).
|
|
4
5
|
|
|
5
6
|
/** A module under one only the server loads. */
|
|
6
7
|
export const SERVER_DIRECTORY = 'server'
|
|
@@ -8,8 +9,22 @@ export const SERVER_DIRECTORY = 'server'
|
|
|
8
9
|
/** A file under one is answered as authored, at the path above it. */
|
|
9
10
|
export const PUBLIC_DIRECTORY = 'public'
|
|
10
11
|
|
|
11
|
-
/**
|
|
12
|
+
/** The app's own code that is no address: components, modules, both sides. */
|
|
13
|
+
export const LIB_DIRECTORY = 'lib'
|
|
14
|
+
|
|
15
|
+
/** The app's tests. */
|
|
16
|
+
export const TESTS_DIRECTORY = 'tests'
|
|
17
|
+
|
|
18
|
+
/** All four, as the walk of the app tests a directory against them. */
|
|
12
19
|
export const RESERVED_DIRECTORIES: ReadonlySet<string> = new Set([
|
|
13
20
|
SERVER_DIRECTORY,
|
|
14
21
|
PUBLIC_DIRECTORY,
|
|
22
|
+
LIB_DIRECTORY,
|
|
23
|
+
TESTS_DIRECTORY,
|
|
24
|
+
])
|
|
25
|
+
|
|
26
|
+
/** 23.16 — the fences a `server` directory nests in: it fences a graph rather than an address. */
|
|
27
|
+
export const HOLDS_SERVER: ReadonlySet<string> = new Set([
|
|
28
|
+
LIB_DIRECTORY,
|
|
29
|
+
TESTS_DIRECTORY,
|
|
15
30
|
])
|
package/src/shared/index.ts
CHANGED
|
@@ -36,13 +36,14 @@ export { listOf } from './listOf.ts'
|
|
|
36
36
|
export { type Logger, type LogRecord, log } from './log.ts'
|
|
37
37
|
export type { Middleware } from './Middleware.ts'
|
|
38
38
|
export type { Params } from './matchRoute.ts'
|
|
39
|
-
// 38.88 — a desktop app's menu items,
|
|
39
|
+
// 38.88 — a desktop app's menu items, and the hook a route's layouts and page file them with.
|
|
40
40
|
export {
|
|
41
41
|
type MenuEntry,
|
|
42
42
|
type MenuItem,
|
|
43
43
|
type MenuRole,
|
|
44
44
|
menuItem,
|
|
45
45
|
} from './menuItem.ts'
|
|
46
|
+
export { onMenu } from './onMenu.ts'
|
|
46
47
|
// `navigate` ALONE, of everything routing needs. This barrel is what an app resolves through the
|
|
47
48
|
// bare `@abide/abide` specifier, and the matcher, the pattern grammar and `routeOf` are reached by path
|
|
48
49
|
// from the two places that have them — the build and the client router. Putting them here would
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The channel a desktop app's window opens to the page for its menu, or `undefined` in a browser.
|
|
3
3
|
*
|
|
4
|
-
* ITS OWN MODULE, so
|
|
4
|
+
* ITS OWN MODULE, so `onMenu` asks without importing the reporter: `reportMenu` is a chunk only a
|
|
5
5
|
* page inside a desktop app loads. 38.88.
|
|
6
6
|
*/
|
|
7
7
|
export function menuBridge():
|
package/src/shared/menuItem.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
// 38.88 — THE DESKTOP APP'S MENU BAR IS THE ROUTE'S: a layout or a page
|
|
2
|
-
// `<script
|
|
1
|
+
// 38.88 — THE DESKTOP APP'S MENU BAR IS THE ROUTE'S: a layout or a page calls `onMenu` in its
|
|
2
|
+
// `<script>`, and the window shows the menus filed while it stands. An item made by
|
|
3
3
|
// `menuItem` is one a page answers; the rest are an address or a role, which need no page.
|
|
4
4
|
|
|
5
5
|
/** What a menu item does natively, where an address or a page's own answer could not. 38.89. */
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// 38.88, 38.92 — THE MENUS A LAYOUT OR A PAGE ADDS TO A DESKTOP APP'S MENU BAR, filed while it
|
|
2
|
+
// stands. Called in its `<script>`, so the menus are a function of its state: what `read` reads moving
|
|
3
|
+
// tells the window again, and the component going takes them with it. Outside a desktop app's window
|
|
4
|
+
// there is no menu bar, and nothing is filed.
|
|
5
|
+
|
|
6
|
+
import { menuBridge } from './menuBridge.ts'
|
|
7
|
+
import type { MenuItem } from './menuItem.ts'
|
|
8
|
+
import { watch } from './reactive/index.ts'
|
|
9
|
+
|
|
10
|
+
/** One `onMenu`'s menus, as it last answered, and whether its component has gone. */
|
|
11
|
+
type Filed = { menus: MenuItem[]; gone: boolean }
|
|
12
|
+
|
|
13
|
+
// IN THE ORDER FILED, which is outermost first: a layout's setup runs before the page inside it.
|
|
14
|
+
let filed: Filed[] = []
|
|
15
|
+
let telling = false
|
|
16
|
+
|
|
17
|
+
/** Files the menus `read` answers for as long as the calling component stands; returns what unfiles them. */
|
|
18
|
+
export function onMenu(read: () => readonly MenuItem[]): () => void {
|
|
19
|
+
if (menuBridge() === undefined) return () => {}
|
|
20
|
+
const entry: Filed = { menus: [], gone: false }
|
|
21
|
+
filed.push(entry)
|
|
22
|
+
const stop = watch(() => {
|
|
23
|
+
entry.gone = false
|
|
24
|
+
entry.menus = [...read()]
|
|
25
|
+
tell()
|
|
26
|
+
// A rerun files again at once; a component that went stays gone.
|
|
27
|
+
return () => {
|
|
28
|
+
entry.gone = true
|
|
29
|
+
tell()
|
|
30
|
+
}
|
|
31
|
+
})
|
|
32
|
+
// 12.8 — a `read` that threw stopped the watch with no cleanup to run, so the unfiling is
|
|
33
|
+
// done here too.
|
|
34
|
+
return () => {
|
|
35
|
+
stop()
|
|
36
|
+
entry.gone = true
|
|
37
|
+
tell()
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ONCE PER TURN, after every rerun and every component a navigation took has settled.
|
|
42
|
+
function tell(): void {
|
|
43
|
+
if (telling) return
|
|
44
|
+
telling = true
|
|
45
|
+
queueMicrotask(async () => {
|
|
46
|
+
telling = false
|
|
47
|
+
const standing: Filed[] = []
|
|
48
|
+
const menus: MenuItem[] = []
|
|
49
|
+
for (const entry of filed) {
|
|
50
|
+
if (entry.gone) continue
|
|
51
|
+
standing.push(entry)
|
|
52
|
+
menus.push(...entry.menus)
|
|
53
|
+
}
|
|
54
|
+
filed = standing
|
|
55
|
+
const { reportMenu } = await import('./reportMenu.ts')
|
|
56
|
+
reportMenu(menus)
|
|
57
|
+
})
|
|
58
|
+
}
|
|
@@ -1,14 +1,12 @@
|
|
|
1
|
-
// THE CURSOR. RULEBOOK 2.
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
// held. An app that wants every message keeps them — a channel is an array.
|
|
1
|
+
// THE CURSOR. RULEBOOK 2.6: a streaming producer's chunks, read from the chunk buffer by ordinal,
|
|
2
|
+
// so two chunks between two `await`s are two turns of the loop, and a patch or a write over the
|
|
3
|
+
// stream moves no place (5.7, 9.12). Where the run ENDS is the caller's settling, handed to
|
|
4
|
+
// `closeOn`: `for await` over a `Reactive` is `Reactive.ts`'s `producedRun`.
|
|
6
5
|
//
|
|
7
6
|
// A NARROW FACE rather than the `Cell`, the same way `tracking.ts` takes a `Tracked`: what a
|
|
8
7
|
// cursor reads is a handful of fields, and naming them keeps the cell's internals out of a
|
|
9
8
|
// second file.
|
|
10
9
|
|
|
11
|
-
import { PATCHES } from './PATCHES.ts'
|
|
12
10
|
import {
|
|
13
11
|
dropReader,
|
|
14
12
|
newSubscriber,
|
|
@@ -23,21 +21,14 @@ import {
|
|
|
23
21
|
* place in a stream's chunk buffer without the buffer having to hold one.
|
|
24
22
|
*/
|
|
25
23
|
export type Retained = Tracked & {
|
|
26
|
-
/** The window a `throttle` or a `debounce` holds open, `holding` while a production waits on it. */
|
|
27
|
-
cap: { holding: boolean } | null
|
|
28
24
|
ordinal: number
|
|
29
25
|
ring: unknown[]
|
|
30
26
|
ringBase: number
|
|
31
|
-
servable: boolean
|
|
32
|
-
state: number
|
|
33
|
-
/**
|
|
34
|
-
* Non-null on a streaming producer, whose ring is the accumulation of the run being served
|
|
35
|
-
* (9.1) and is retained whole — the chunks ARE the value.
|
|
36
|
-
*/
|
|
37
|
-
stream: object | null
|
|
38
|
-
value: unknown
|
|
39
27
|
}
|
|
40
28
|
|
|
29
|
+
/** What `take` answers when the cursor has caught up — never a chunk, which may be `undefined`. */
|
|
30
|
+
export const CAUGHT_UP: unique symbol = Symbol('caught up')
|
|
31
|
+
|
|
41
32
|
// The cursors standing over each source, for a patch to keep the place of the ones behind (5.7).
|
|
42
33
|
const CURSORS = new WeakMap<Retained, Set<Cursor>>()
|
|
43
34
|
|
|
@@ -70,61 +61,36 @@ export function hasCursorBehind(source: Retained): boolean {
|
|
|
70
61
|
}
|
|
71
62
|
|
|
72
63
|
/**
|
|
73
|
-
* A
|
|
64
|
+
* A reader of a stream's chunks, in order, waking whoever waits on it when one lands.
|
|
74
65
|
*
|
|
75
|
-
* A RECORD, NOT A SCOPE.
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
* function object hanging off it is live that whole time. Fields on a prototype are the object
|
|
79
|
-
* alone, and `at` — read and written on every turn — becomes a property load on a fixed structure
|
|
80
|
-
* rather than a scope load.
|
|
66
|
+
* A RECORD, NOT A SCOPE. A cursor outlives the call that made it — a `for await` holds one for the
|
|
67
|
+
* life of the loop — so fields on a prototype are the object alone, and `at`, read and written on
|
|
68
|
+
* every turn, is a property load on a fixed structure rather than a scope load.
|
|
81
69
|
*/
|
|
82
|
-
export class Cursor
|
|
70
|
+
export class Cursor {
|
|
83
71
|
source: Retained
|
|
84
|
-
/** The ordinal of the last
|
|
72
|
+
/** The ordinal of the last chunk this cursor handed over. */
|
|
85
73
|
at: number
|
|
86
|
-
/**
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
patched: number
|
|
93
|
-
/**
|
|
94
|
-
* The turns asked for and not yet answered.
|
|
95
|
-
*
|
|
96
|
-
* A QUEUE rather than one slot because a consumer may ask twice before either answer arrives
|
|
97
|
-
* — `Promise.all([ it.next(), it.next() ])` is the shape, and with one slot the second `next`
|
|
98
|
-
* overwrote the first's resolver and that promise never settled at all. `for await` asks one
|
|
99
|
-
* at a time, which is why nothing here said so.
|
|
100
|
-
*/
|
|
101
|
-
waiting: ((result: IteratorResult<unknown>) => void)[]
|
|
74
|
+
/** Called once at the next wake, then cleared. */
|
|
75
|
+
wake: (() => void) | null
|
|
76
|
+
/** Whether the run `closeOn` was handed has settled. */
|
|
77
|
+
closed: boolean
|
|
78
|
+
/** What that run rejected with. */
|
|
79
|
+
failure: unknown
|
|
102
80
|
reader: Subscriber
|
|
103
81
|
|
|
104
82
|
constructor(source: Retained, from: number) {
|
|
105
83
|
this.source = source
|
|
106
84
|
this.at = from
|
|
107
|
-
this.
|
|
108
|
-
this.
|
|
109
|
-
|
|
110
|
-
//
|
|
111
|
-
//
|
|
112
|
-
//
|
|
113
|
-
//
|
|
114
|
-
// rather than discovered — see the same shape in `Reactive`'s `settled`.
|
|
85
|
+
this.wake = null
|
|
86
|
+
this.closed = false
|
|
87
|
+
this.failure = undefined
|
|
88
|
+
// The one closure left, and it captures this cursor and nothing else. `notify` is called as a
|
|
89
|
+
// method on the SUBSCRIBER, so it cannot be the cursor's own method. Registered outside the
|
|
90
|
+
// tracking protocol, so the source it waits on is declared rather than discovered — see the
|
|
91
|
+
// same shape in `Reactive`'s `settled`.
|
|
115
92
|
this.reader = newSubscriber([source], null)
|
|
116
|
-
|
|
117
|
-
// lands three productions on a consumer holding two turns answers both.
|
|
118
|
-
this.reader.notify = (): void => {
|
|
119
|
-
const waiting = this.waiting
|
|
120
|
-
while (waiting.length !== 0) {
|
|
121
|
-
const next = this.take()
|
|
122
|
-
if (next === null) return
|
|
123
|
-
;(waiting.shift() as (result: IteratorResult<unknown>) => void)(
|
|
124
|
-
next,
|
|
125
|
-
)
|
|
126
|
-
}
|
|
127
|
-
}
|
|
93
|
+
this.reader.notify = (): void => this.woken()
|
|
128
94
|
source.readers.push(this.reader)
|
|
129
95
|
let cursors = CURSORS.get(source)
|
|
130
96
|
if (cursors === undefined) {
|
|
@@ -135,83 +101,60 @@ export class Cursor implements AsyncIterator<unknown> {
|
|
|
135
101
|
}
|
|
136
102
|
|
|
137
103
|
/**
|
|
138
|
-
*
|
|
104
|
+
* The next chunk, or `CAUGHT_UP`.
|
|
139
105
|
*
|
|
140
|
-
* Reading
|
|
106
|
+
* Reading by ordinal is what keeps a burst from being a gap or a repeat.
|
|
141
107
|
*/
|
|
142
|
-
take():
|
|
108
|
+
take(): unknown {
|
|
143
109
|
const source = this.source
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
if (value === undefined) return null
|
|
149
|
-
const patched =
|
|
150
|
-
value !== null && typeof value === 'object'
|
|
151
|
-
? (PATCHES.written.get(value) ?? 0)
|
|
152
|
-
: 0
|
|
153
|
-
if (at >= source.ordinal && patched === this.patched) return null
|
|
154
|
-
// 5.9 — A PRODUCTION A WINDOW HOLDS has minted its ordinal and is not yet the value: the
|
|
155
|
-
// turn is the release's, which wakes this cursor.
|
|
156
|
-
if (source.cap?.holding === true) return null
|
|
157
|
-
// The standing production is the only one held, so everything minted or patched
|
|
158
|
-
// since the last turn collapses into it.
|
|
159
|
-
this.at = source.ordinal
|
|
160
|
-
this.patched = patched
|
|
161
|
-
return { value, done: false }
|
|
162
|
-
}
|
|
163
|
-
if (at >= source.ordinal) return null
|
|
164
|
-
// A run that restarted moved the buffer past this cursor: answer from its start.
|
|
165
|
-
if (at < source.ringBase) at = source.ringBase
|
|
166
|
-
const value = source.ring[at - source.ringBase]
|
|
167
|
-
this.at = at + 1
|
|
168
|
-
return { value, done: false }
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
next(): Promise<IteratorResult<unknown>> {
|
|
172
|
-
if (!this.reader.alive)
|
|
173
|
-
return Promise.resolve({ value: undefined, done: true })
|
|
174
|
-
// A TURN ALREADY ASKED FOR AND NOT ANSWERED KEEPS THE ORDER: taking from the
|
|
175
|
-
// ring here while one is queued would hand the later caller the earlier
|
|
176
|
-
// production.
|
|
177
|
-
if (this.waiting.length === 0) {
|
|
178
|
-
const ready = this.take()
|
|
179
|
-
// NOT `async`, and no `await` on the caught-up arm. A cursor behind a
|
|
180
|
-
// stream's chunks is the common shape while a burst drains, and an
|
|
181
|
-
// `async` here would put a microtask tick between every one of those
|
|
182
|
-
// turns.
|
|
183
|
-
if (ready !== null) return Promise.resolve(ready)
|
|
184
|
-
}
|
|
185
|
-
const waiting = this.waiting
|
|
186
|
-
return new Promise((resolve) => {
|
|
187
|
-
waiting.push(resolve)
|
|
188
|
-
})
|
|
110
|
+
if (this.at >= source.ordinal) return CAUGHT_UP
|
|
111
|
+
const value = source.ring[this.at - source.ringBase]
|
|
112
|
+
this.at += 1
|
|
113
|
+
return value
|
|
189
114
|
}
|
|
190
115
|
|
|
191
116
|
/**
|
|
192
|
-
* Ends the cursor
|
|
117
|
+
* Ends the cursor's run where `run` settles: `closed` is set, `failure` holds a rejection, and
|
|
118
|
+
* the current `turn` wakes.
|
|
193
119
|
*
|
|
194
|
-
*
|
|
120
|
+
* ONE REACTION ON THE CLOSE FOR THE WHOLE RUN, never one per chunk held until the run ends. A
|
|
121
|
+
* chunk landing in the same turn as the close is still unread when the close wakes the caller,
|
|
122
|
+
* so a drain before reading `closed` hands it over rather than losing it to whichever settled
|
|
123
|
+
* first.
|
|
195
124
|
*/
|
|
196
|
-
|
|
197
|
-
|
|
125
|
+
closeOn(run: PromiseLike<unknown>): void {
|
|
126
|
+
run.then(
|
|
127
|
+
() => {
|
|
128
|
+
this.closed = true
|
|
129
|
+
this.woken()
|
|
130
|
+
},
|
|
131
|
+
(error: unknown) => {
|
|
132
|
+
this.closed = true
|
|
133
|
+
this.failure = error
|
|
134
|
+
this.woken()
|
|
135
|
+
},
|
|
136
|
+
)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Settles at the next chunk or the close, whichever lands first. */
|
|
140
|
+
turn(): Promise<void> {
|
|
141
|
+
return new Promise((resolve) => {
|
|
142
|
+
this.wake = resolve
|
|
143
|
+
})
|
|
198
144
|
}
|
|
199
145
|
|
|
200
|
-
|
|
201
|
-
|
|
146
|
+
private woken(): void {
|
|
147
|
+
const wake = this.wake
|
|
148
|
+
this.wake = null
|
|
149
|
+
wake?.()
|
|
202
150
|
}
|
|
203
151
|
|
|
204
|
-
|
|
152
|
+
/** Drops the subscriber; nothing else does. */
|
|
153
|
+
stop(): void {
|
|
205
154
|
const reader = this.reader
|
|
206
155
|
reader.alive = false
|
|
207
156
|
dropReader(this.source, reader)
|
|
208
157
|
CURSORS.get(this.source)?.delete(this)
|
|
209
|
-
|
|
210
|
-
while (waiting.length !== 0)
|
|
211
|
-
(waiting.shift() as (result: IteratorResult<unknown>) => void)({
|
|
212
|
-
value: undefined,
|
|
213
|
-
done: true,
|
|
214
|
-
})
|
|
215
|
-
return Promise.resolve({ value: undefined, done: true })
|
|
158
|
+
this.wake = null
|
|
216
159
|
}
|
|
217
160
|
}
|