gentle-shell-bridge 0.0.0-stage → 0.3.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/.codegraph/.gitignore +5 -0
- package/PRD.md +144 -0
- package/README.md +745 -2
- package/docs/architecture.md +981 -0
- package/docs/contract.md +1266 -0
- package/docs/state.md +251 -0
- package/examples/bash-card.ts +1694 -0
- package/gentle-shell-bridge-0.3.0.tgz +0 -0
- package/odd/tasks/palette-sections.md +30 -0
- package/odd/tasks/plugin-keymap.md +32 -0
- package/odd/tasks/plugin-surface-contract.md +55 -0
- package/package.json +35 -4
- package/src/api.ts +492 -0
- package/src/arm.ts +83 -0
- package/src/bus.ts +558 -0
- package/src/capture.ts +886 -0
- package/src/card-style-line.ts +112 -0
- package/src/card-style-rows.ts +390 -0
- package/src/card-style-transform.ts +862 -0
- package/src/card-style-wiring.ts +472 -0
- package/src/card-styles.ts +159 -0
- package/src/config.ts +71 -0
- package/src/extension.ts +271 -0
- package/src/hook.ts +310 -0
- package/src/index.ts +71 -0
- package/src/notices.ts +137 -0
- package/src/plugin-lines.ts +865 -0
- package/src/plugin-surface.ts +218 -0
- package/src/presence.ts +259 -0
- package/src/repaint.ts +351 -0
- package/src/row-labels.ts +146 -0
- package/src/row-names.ts +273 -0
- package/src/row-origin.ts +144 -0
- package/src/rows.ts +248 -0
- package/src/section-panel.ts +378 -0
- package/src/section-pointer.ts +164 -0
- package/src/sections-rows.ts +128 -0
- package/src/sidebar-render.ts +535 -0
- package/src/sidebar.ts +919 -0
- package/src/status.ts +1161 -0
- package/src/store.ts +279 -0
- package/src/tool-design-adoption.ts +263 -0
- package/src/tool-design-rows.ts +246 -0
- package/src/tool-design-wiring.ts +428 -0
- package/src/tool-designs.ts +402 -0
- package/src/transform-pipeline.ts +351 -0
- package/src/types.ts +442 -0
- package/src/visibility.ts +154 -0
- package/test/api.test.ts +395 -0
- package/test/apply-on-move.test.ts +477 -0
- package/test/arm.test.ts +220 -0
- package/test/bridge-ready.test.ts +309 -0
- package/test/bus.test.ts +165 -0
- package/test/button-rows.test.ts +409 -0
- package/test/capture.test.ts +356 -0
- package/test/card-style-rows.test.ts +510 -0
- package/test/card-style-transform.test.ts +379 -0
- package/test/card-style-wiring.test.ts +258 -0
- package/test/card-styles-e2e.test.ts +491 -0
- package/test/card-styles.test.ts +324 -0
- package/test/card-transcript-path.test.ts +178 -0
- package/test/config.test.ts +93 -0
- package/test/e2e.test.ts +443 -0
- package/test/example-bash-identity.test.ts +372 -0
- package/test/example-bash-matrix.test.ts +856 -0
- package/test/example-designs.test.ts +734 -0
- package/test/example-neon-transform.test.ts +283 -0
- package/test/example-plain-style.test.ts +1268 -0
- package/test/extension.test.ts +160 -0
- package/test/fake-events.ts +58 -0
- package/test/fake-host.ts +211 -0
- package/test/fake-sidebar.ts +516 -0
- package/test/fake-transcript.ts +486 -0
- package/test/frame-telemetry.test.ts +417 -0
- package/test/hook.test.ts +196 -0
- package/test/manifest.test.ts +105 -0
- package/test/new-category.test.ts +979 -0
- package/test/notices.test.ts +328 -0
- package/test/plugin-integration-lines.test.ts +979 -0
- package/test/plugin-surface.test.ts +152 -0
- package/test/presence.test.ts +459 -0
- package/test/rail-surfaces.test.ts +457 -0
- package/test/rail-width.test.ts +423 -0
- package/test/regression.test.ts +483 -0
- package/test/repaint-api.test.ts +200 -0
- package/test/reserved-categories.test.ts +361 -0
- package/test/row-labels.test.ts +284 -0
- package/test/row-names.test.ts +473 -0
- package/test/row-origin.test.ts +395 -0
- package/test/section-interaction.test.ts +364 -0
- package/test/section-panel.test.ts +403 -0
- package/test/sections-rows.test.ts +291 -0
- package/test/sidebar-backend.test.ts +471 -0
- package/test/sidebar-e2e.test.ts +331 -0
- package/test/sidebar-hover.test.ts +276 -0
- package/test/sidebar-independent.test.ts +349 -0
- package/test/sidebar-render.test.ts +281 -0
- package/test/sidebar-sections.test.ts +245 -0
- package/test/status-card-styles.test.ts +201 -0
- package/test/status-section-frame.test.ts +181 -0
- package/test/status-sections.test.ts +243 -0
- package/test/status.test.ts +209 -0
- package/test/store.test.ts +192 -0
- package/test/surface-repaint-cost.test.ts +520 -0
- package/test/tool-design-chain.test.ts +483 -0
- package/test/tool-design-cost.test.ts +473 -0
- package/test/tool-design-rows.test.ts +359 -0
- package/test/tool-designs.test.ts +745 -0
- package/test/transform-pipeline.test.ts +465 -0
- package/test/transforms-bus.test.ts +233 -0
- package/test/verifier-width-regressions.test.ts +164 -0
- package/tsconfig.json +18 -0
|
Binary file
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# ODD — plugin sections in gentle's command palette
|
|
2
|
+
|
|
3
|
+
Two repos: `gentle-shell-bridge` (the API plugins call) and `gentle-shell`/gentle-pi (owner of `/gentle:commands`). Size: large (cross-repo). The user calls the palette "panel de gentle shell commands".
|
|
4
|
+
|
|
5
|
+
## Specs
|
|
6
|
+
|
|
7
|
+
- **S1** — A plugin adds its entries to the palette in either of two shapes: into an existing section, or as a new section of its own. User: "agregar seccioones a panel de gentle shell comands, ya sea a una seccion exxistente o con categoria nueva en commands".
|
|
8
|
+
- **S2** — A contributed entry behaves like a native one: the palette lists it only while its command is registered, and selecting it runs that command.
|
|
9
|
+
- **S3** — Disposing the registration removes the entry, or the section when it was the plugin's own and is left empty.
|
|
10
|
+
- **S4** — Without host support the registration reports an unavailable reason through `/bridge-status`, never a silent no-op.
|
|
11
|
+
- **S5** — Order and grouping stay the host's: a contributed section appears with the host's sections and does not reorder them.
|
|
12
|
+
|
|
13
|
+
## Tasks
|
|
14
|
+
|
|
15
|
+
- **T1** (S1, S5) — Host: catalog accepts contributed sections/items at build time, in catalog order. Route: inline.
|
|
16
|
+
- **T2** (S1) — Bridge: `registerCommands` (existing section) and a section of its own, with disposer. Route: inline.
|
|
17
|
+
- **T3** (S2) — Selecting a contributed entry runs the plugin's command, including when the command disappears mid-session.
|
|
18
|
+
- **T4** (S3) — Dispose removes entry/section at the next palette open.
|
|
19
|
+
- **T5** (S4) — Unavailable path reported through `/bridge-status`.
|
|
20
|
+
- **T6** — Docs: contract.md, README, host docs.
|
|
21
|
+
|
|
22
|
+
## Log
|
|
23
|
+
|
|
24
|
+
- **L1** — "ahora veremos si podemos agregar 2 feactures para bridge, una es para agregar algun aparatado a panel commands [Image#1] ya sea en una seccion existe o propia, que seria ahi acciones rapidas como abrir panel etc, las que tiene."
|
|
25
|
+
- **L2** — "que preguntas, sabes siquiera de que hablamos? que es este plugin bridge, ya tenemos arto integrado, ni revisaste el contexto del proyecto"
|
|
26
|
+
- **L3** — "otro para agregar seccioones a panel de gentle shell comands, ya sea a una seccion exxistente o con categoria nueva en commands"
|
|
27
|
+
- **L4** (evidence, explorer mwgxgx3x-1-onuz) — The palette is a curated static catalog, "not a raw listing of every registered extension command" (`gentle-shell/lib/command-palette-catalog.ts:2-4`); `buildCommandPaletteGroups` keeps only catalog entries that are registered (`:33-49`). No extension point exists for a third-party group or item.
|
|
28
|
+
- **L5** (commits) — Host: `33bbe4e84` (merge of contributions in `lib/command-palette-catalog.ts`, `CONTRIBUTED_SECTION` = "Plugins" for an item that names no section). Bridge: `85f0f2b` (`registerCommands`, publishing on the same surface as keymaps).
|
|
29
|
+
- **L6** (evidence) — Covered by `tests/plugin-surface.test.ts`: joins a named section, opens an own section after the catalog, and an unregistered command is not listed (also for an own section that would be left empty).
|
|
30
|
+
- **L8** (delivery) — Same two PRs as plugin-keymap (bridge #6, host #80), both merged. CI never started on the host fork (0 runs for the head SHA).
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# ODD — plugin keymaps in gentle's Customize Keymap page
|
|
2
|
+
|
|
3
|
+
Two repos: `gentle-shell-bridge` (the API plugins call) and `gentle-shell`/gentle-pi (the owner of the Keymaps page, the bindings file and the collision rules). Size: large (multi-session, cross-repo, contract-level).
|
|
4
|
+
|
|
5
|
+
## Specs
|
|
6
|
+
|
|
7
|
+
- **S1** — A plugin registers one keymapped action through the bridge and the host lists it on the `Keymaps` page of `/gentle:customize`. User: "registrar keymap en customize keymap, para que aparezca ahi".
|
|
8
|
+
- **S2** — That row is edited with the host's own gestures on that page (Enter assigns, ⌫ disables, r restores the default), persists where the host persists its keymaps and applies after `/reload`. User: "se pueda modificar desde ahi el keymap del plugin" and "sumo compativlidad para modificar mendiante customize keymap de gentle".
|
|
9
|
+
- **S3** — The binding is real, not advertised: the plugin can resolve its own effective key and register the shortcut itself, so the row never claims a key that does nothing.
|
|
10
|
+
- **S4** — The host stays the single registry: environment overrides, reserved Pi keys and duplicate-key checks keep working for plugin ids exactly as for native ones. User's "compatibilidad" means the native rules, not a bypass.
|
|
11
|
+
- **S5** — Disposing the registration removes the plugin's row; a stale row or a stale key must not survive it.
|
|
12
|
+
- **S6** — Without host support, `registerKeymap` reports an unavailable reason through `/bridge-status`; it never fails silently.
|
|
13
|
+
|
|
14
|
+
## Tasks
|
|
15
|
+
|
|
16
|
+
- **T1** (S1, S6) — Host: extension-facing registry for keymap actions, and the bridge reads it. Route: inline.
|
|
17
|
+
- **T2** (S1, S2) — Host: `buildKeymapRows` and the collision helpers accept registered ids. Route: inline.
|
|
18
|
+
- **T3** (S1, S3) — Bridge: `registerKeymap` in the published API, disposer, and effective-key resolution for the plugin. Route: inline.
|
|
19
|
+
- **T4** (S4) — Collision + env-lock parity pinned by tests on both sides. Route: inline.
|
|
20
|
+
- **T5** (S2, S5) — End-to-end: register, reassign from the page, `/reload`, key works; dispose, key gone.
|
|
21
|
+
- **T6** — Docs: contract.md, README, and the host's docs for the new surface.
|
|
22
|
+
|
|
23
|
+
## Log
|
|
24
|
+
|
|
25
|
+
- **L1** — "ahora veremos si podemos agregar 2 feactures para bridge, una es para agregar algun aparatado a panel commands [Image#1] ya sea en una seccion existe o propia, que seria ahi acciones rapidas como abrir panel etc, las que tiene. Y otra para registrar keymap en customize keymap, para que aparezca ahi y se pueda modificar desde ahi el keymap del plugin integra y sumo compativlidad para modificar mendiante customize keymap de gentle."
|
|
26
|
+
- **L2** — "que preguntas, sabes siquiera de que hablamos? que es este plugin bridge, ya tenemos arto integrado, ni revisaste el contexto del proyecto"
|
|
27
|
+
- **L3** — "Y ahora ve como integrar lo que pedi, el bridge para registrar keymap de un plugin en gentle customize keymap. y otro para agregar seccioones a panel de gentle shell comands, ya sea a una seccion exxistente o con categoria nueva en commands"
|
|
28
|
+
- **L4** (evidence, explorer mwgxgx3x-1-onuz) — Host today: rows only from the static `KEYMAP_ACTIONS` (`gentle-shell/lib/keymaps.ts:34`) plus curated-catalog commands (`lib/keymaps-customize.ts:64-67`); no third-party registry of any kind; `keymaps.json` persists unknown ids (`parseKeymapFile`) but `keymapOwner`/`checkKeymap` ignore them, so an unknown id gets no row, no binding and no collision check. Bridge refuses the category at `src/row-names.ts:119-120` and `src/bus.ts:121-138`.
|
|
29
|
+
- **L5** (commits) — Host half: `33bbe4e84` on `feat/plugin-surfaces` in the gentle-shell worktree (5 files, 320 lines): `lib/plugin-surface.ts` reader, contributed actions in `lib/keymaps.ts` (lookup, effective key, source, collisions), the published keymap resolver, and `lib/keymaps-customize.ts` listing them. Bridge half: `85f0f2b` on `feat/plugin-surfaces` (6 files, 398 lines): `src/plugin-surface.ts`, the three optional API members, and the export/API contract lists.
|
|
30
|
+
- **L6** (evidence) — Host tests: `tests/plugin-surface.test.ts` 12/12; with `keymaps` and `keymaps-customize`, 25/26 — the one failure is `tests/command-palette.test.ts` unable to resolve `@earendil-works/pi-coding-agent` because neither checkout has `node_modules` installed (pre-existing, environment). Bridge tests: 691/691.
|
|
31
|
+
- **L7** (delivery defect + fix) — The first delivery was NOT visible: Pi loads the host from the installed git checkout (`/home/jona/.pi/agent/git/github.com/Gentleman-Programming/gentle-shell`, at 3dc902975) and the bridge from `gentle-shell-bridge-worktrees/bash-card-identity`, neither of which had this work. Restarting Pi could not help. Fix, applied to the running copies: `git apply` of the host patch onto the installed checkout (its `lib/keymaps.ts`, `lib/keymaps-customize.ts`, `lib/command-palette-catalog.ts` are byte-identical to the base commit, so it applies cleanly), and the bridge package entry in `~/.pi/agent/settings.json` repointed to `../../dev/projects/gentle-shell-bridge` (backup `settings.json.bak-plugin-surface`). Verified by importing the INSTALLED `lib/keymaps.ts`/`lib/keymaps-customize.ts`/`lib/command-palette-catalog.ts` with a published surface: action listed, row `Bash card: report state: alt+b`, palette group `Panel`, and the resolver answering `alt+b`. The installed checkout is now a dirty working tree: a package update replaces it and the patch must be reapplied (or the branch merged upstream).
|
|
32
|
+
- **L9** (delivery) — Bridge PR jonasotoaguilar/gentle-shell-bridge#6 merged as `90704ca`; host PR jonasotoaguilar/gentle-shell#80 merged as `b61559cf6`. Both merge commits, no squash. CI on the host fork never ran: `actions/runs?head_sha=33bbe4e8…` returned `total_count: 0` while the workflow is active and Actions is enabled — a GitHub-side condition, not a code failure; the proof for this change is the local suites plus the live TUI confirmation (L8). The installed upstream checkout still carries the hand-applied patch, so it is unaffected by the fork merge.
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# Wire contract — the bridge publishes, the host reads
|
|
2
|
+
|
|
3
|
+
Two repos, one handshake. The bridge already publishes its API at module load on
|
|
4
|
+
`Symbol.for("gentle-shell-bridge/api/v1")`; this contract adds a second, narrower global that
|
|
5
|
+
holds the plugin contributions the host must render. Direction matters: **the bridge publishes
|
|
6
|
+
and the host reads lazily**, so no load order, no session_start race, and no lifecycle in the host.
|
|
7
|
+
|
|
8
|
+
## The global
|
|
9
|
+
|
|
10
|
+
```ts
|
|
11
|
+
Symbol.for("gentle-shell-bridge/plugin-surface/v1")
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
```ts
|
|
15
|
+
interface PluginSurface {
|
|
16
|
+
schema: 1;
|
|
17
|
+
/** Live: the host calls this per menu/palette build, never once at startup. */
|
|
18
|
+
keymaps(): readonly PluginKeymapAction[];
|
|
19
|
+
commands(): readonly PluginCommandContribution[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface PluginKeymapAction {
|
|
23
|
+
/** Plugin-owned, stable, never a built-in id and never `command:<name>`. */
|
|
24
|
+
id: string;
|
|
25
|
+
label: string;
|
|
26
|
+
/** Optional environment variable that overrides the key, same semantics as a built-in action. */
|
|
27
|
+
env?: string;
|
|
28
|
+
defaultKey?: string;
|
|
29
|
+
/** The slash command this action invokes, when it has one. */
|
|
30
|
+
command?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface PluginCommandContribution {
|
|
34
|
+
id: string;
|
|
35
|
+
/** An existing palette section title, or a new one the plugin owns. */
|
|
36
|
+
section?: string;
|
|
37
|
+
label: string;
|
|
38
|
+
/** The slash command the palette runs. Listed only while this command is registered. */
|
|
39
|
+
command: string;
|
|
40
|
+
}
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Rules both sides obey
|
|
44
|
+
|
|
45
|
+
1. **Absent or malformed is empty.** A host that finds no global, a wrong `schema`, a throwing
|
|
46
|
+
getter or a bad entry renders nothing from it and never throws. Fail closed, entry by entry.
|
|
47
|
+
2. **Live reads.** The host calls `keymaps()`/`commands()` at build time. A registration made
|
|
48
|
+
during `session_start` is visible at the next menu or palette open; nothing is cached at startup.
|
|
49
|
+
3. **The host keeps ownership of its own rules.** A contributed key obeys the same reserved Pi
|
|
50
|
+
keys, environment lock and duplicate-key checks as a native one; a contributed id takes part in
|
|
51
|
+
collision detection exactly like a built-in id. No bypass.
|
|
52
|
+
4. **The plugin binds its own key.** `pi.registerShortcut` is called by the plugin (or the bridge
|
|
53
|
+
on its behalf), never invented by the host; the host only renders the row and checks the key.
|
|
54
|
+
5. **No new host-side lifecycle.** The host never disposes a contribution. Removal is the bridge's
|
|
55
|
+
disposer, and the next build simply stops seeing it.
|
package/package.json
CHANGED
|
@@ -1,6 +1,37 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gentle-shell-bridge",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "Shared provider bus for gentle-shell's /gentle:customize settings rows: plugins register rows, the bridge owns the single interception layer.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"pi-package",
|
|
8
|
+
"pi",
|
|
9
|
+
"gentle-shell",
|
|
10
|
+
"gentle-ai",
|
|
11
|
+
"extension"
|
|
12
|
+
],
|
|
13
|
+
"pi": {
|
|
14
|
+
"extensions": [
|
|
15
|
+
"./src/extension.ts"
|
|
16
|
+
]
|
|
17
|
+
},
|
|
18
|
+
"engines": {
|
|
19
|
+
"node": ">=22.18.0"
|
|
20
|
+
},
|
|
21
|
+
"types": "./src/index.ts",
|
|
22
|
+
"exports": {
|
|
23
|
+
".": {
|
|
24
|
+
"types": "./src/index.ts",
|
|
25
|
+
"default": "./src/index.ts"
|
|
26
|
+
},
|
|
27
|
+
"./package.json": "./package.json"
|
|
28
|
+
},
|
|
29
|
+
"scripts": {
|
|
30
|
+
"test": "node --test",
|
|
31
|
+
"typecheck": "tsc --noEmit"
|
|
32
|
+
},
|
|
33
|
+
"devDependencies": {
|
|
34
|
+
"@types/node": "^24.19.2",
|
|
35
|
+
"typescript": "^5.9.3"
|
|
36
|
+
}
|
|
37
|
+
}
|
package/src/api.ts
ADDED
|
@@ -0,0 +1,492 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The runtime handshake: how a plugin reaches this bridge without a path, an environment variable
|
|
3
|
+
* or an import.
|
|
4
|
+
*
|
|
5
|
+
* Pi gives two independent projects exactly one sanctioned channel that needs no install detail:
|
|
6
|
+
* `globalThis` (`docs/extensions.md:1965-1972`). So the package publishes a frozen, versioned
|
|
7
|
+
* service object at a stable symbol, at module load:
|
|
8
|
+
*
|
|
9
|
+
* ```ts
|
|
10
|
+
* Symbol.for("gentle-shell-bridge/api/v1") → { schema, version, VERSION, registerRows,
|
|
11
|
+
* registerSection, registerCardStyle,
|
|
12
|
+
* registerTransform, registerToolDesign,
|
|
13
|
+
* createStore, requestRepaint }
|
|
14
|
+
* ```
|
|
15
|
+
*
|
|
16
|
+
* A plugin registers from its own `session_start` handler. But Pi does NOT guarantee that this
|
|
17
|
+
* package loaded first: the real TUI showed global extensions (`~/.pi/agent/extensions`) loading —
|
|
18
|
+
* and receiving `session_start` — before a configured package's module ever published. So the
|
|
19
|
+
* symbol's presence is the complete answer only when it is present. When it is absent, the plugin
|
|
20
|
+
* still needs a way to hear about a bridge that publishes later, and it hears it on Pi's own
|
|
21
|
+
* cross-extension bus (`docs/extensions.md:1965-1972`):
|
|
22
|
+
*
|
|
23
|
+
* ```ts
|
|
24
|
+
* pi.events.on("gentle-shell-bridge:ready", ({ schema, version, VERSION }) => …)
|
|
25
|
+
* ```
|
|
26
|
+
*
|
|
27
|
+
* The bridge emits that event once per process, after a successful publish and never for an
|
|
28
|
+
* incompatible slot (`announceBridgeReady`). A plugin that imports this package calls
|
|
29
|
+
* `onBridgeApi(handler, pi.events)`, which serves it immediately when the object is already there
|
|
30
|
+
* and subscribes when it is not. The symbol stays the authority: the announcement only says "look
|
|
31
|
+
* again", so a copy that publishes an incompatible slot announces nothing and a listener that
|
|
32
|
+
* hears the event still gates on the object itself.
|
|
33
|
+
*
|
|
34
|
+
* Why a symbol instead of a path or `GENTLE_SHELL_BRIDGE_MODULE`: a path is not a contract between
|
|
35
|
+
* two projects — it names one checkout on one machine, and it breaks the day either project moves.
|
|
36
|
+
* The symbol key is the contract, and `v1` in the key is the major version: a future major takes a
|
|
37
|
+
* NEW key rather than mutating this one. `pi-lens` publishes its IO bridge the same way
|
|
38
|
+
* (`clients/io-bridge-contract.ts:26-31`, `clients/process-bridge.ts:31-66`): stable symbol, a
|
|
39
|
+
* version constant, a frozen non-writable slot, and "version mismatch means unsupported".
|
|
40
|
+
*
|
|
41
|
+
* ## First wins, and nothing is ever reset
|
|
42
|
+
*
|
|
43
|
+
* Pi loads every extension through its own jiti loader with `moduleCache: false`, so this module
|
|
44
|
+
* exists once per copy. Every copy's functions operate on the same process-global bus
|
|
45
|
+
* (`./bus.ts`, `./store.ts`), which makes them interchangeable: whoever publishes first owns the
|
|
46
|
+
* slot, and later copies hand the same object back. A slot that holds something incompatible is
|
|
47
|
+
* treated as ABSENT — never overwritten, never deleted.
|
|
48
|
+
*
|
|
49
|
+
* ## Version skew
|
|
50
|
+
*
|
|
51
|
+
* Consumers gate on the schema string, `version === 1` and the three callable members
|
|
52
|
+
* (`isBridgeApi`). A mismatch is not an error and never a fallback to a half-working object: the
|
|
53
|
+
* caller sees `undefined`, and one notice is recorded per process (the TUI shows it once and
|
|
54
|
+
* `/bridge-status` keeps reporting it).
|
|
55
|
+
*
|
|
56
|
+
* ## Why the gate is also documented inline
|
|
57
|
+
*
|
|
58
|
+
* A plugin that does not depend on this package must be able to do the same thing in about six
|
|
59
|
+
* lines, so README.md carries the inline copy of the gate and the symbol. Keep the two in sync.
|
|
60
|
+
*/
|
|
61
|
+
|
|
62
|
+
import { registerCardStyle, registerRows, registerSection, registerTransform } from "./bus.ts";
|
|
63
|
+
import { recordNotice } from "./notices.ts";
|
|
64
|
+
import { registerCommands, registerKeymap, resolveKeymap } from "./plugin-surface.ts";
|
|
65
|
+
import { requestRepaint } from "./repaint.ts";
|
|
66
|
+
import { createStore } from "./store.ts";
|
|
67
|
+
import { registerToolDesign } from "./tool-designs.ts";
|
|
68
|
+
|
|
69
|
+
/** The release that published the object; informational, never the compatibility check. */
|
|
70
|
+
export const VERSION = "0.3.0";
|
|
71
|
+
|
|
72
|
+
/** Identity of the object, so a foreign payload at the symbol is never mistaken for ours. */
|
|
73
|
+
export const API_SCHEMA = "gentle-shell-bridge/api";
|
|
74
|
+
|
|
75
|
+
/** Major version of the published shape. A breaking change means a new symbol key. */
|
|
76
|
+
export const API_VERSION = 1;
|
|
77
|
+
|
|
78
|
+
/** The published service object's stable, process-global key. */
|
|
79
|
+
export const BRIDGE_API_KEY: symbol = Symbol.for("gentle-shell-bridge/api/v1");
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The channel this package announces itself on, on Pi's cross-extension bus.
|
|
83
|
+
*
|
|
84
|
+
* A plugin loaded before this package has no object to read yet, so it subscribes here. The
|
|
85
|
+
* announcement is a notification, never a registry: it says "the object is published now", and the
|
|
86
|
+
* listener reads the symbol. That keeps one authority (the symbol and its shape gate) and one
|
|
87
|
+
* channel for the timing.
|
|
88
|
+
*/
|
|
89
|
+
export const BRIDGE_READY_EVENT = "gentle-shell-bridge:ready";
|
|
90
|
+
|
|
91
|
+
/** What the announcement carries: identity, major version and the publishing release. */
|
|
92
|
+
export interface BridgeReadyPayload {
|
|
93
|
+
readonly schema: typeof API_SCHEMA;
|
|
94
|
+
readonly version: typeof API_VERSION;
|
|
95
|
+
readonly VERSION: string;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The two members of `pi.events` this package uses (`docs/extensions.md:1965-1972`). Both are
|
|
100
|
+
* optional so a host without a bus is a normal case, not a type error: no bus means the symbol is
|
|
101
|
+
* the only channel, which is exactly how this worked before.
|
|
102
|
+
*/
|
|
103
|
+
export type BridgeEventUnsubscribe = () => void;
|
|
104
|
+
|
|
105
|
+
export interface BridgeEventBus {
|
|
106
|
+
emit?(channel: string, data: unknown): void;
|
|
107
|
+
on?(channel: string, handler: (data: unknown) => void): BridgeEventUnsubscribe | void;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** The one payload every announcement carries; frozen like the published object. */
|
|
111
|
+
const READY_PAYLOAD: BridgeReadyPayload = Object.freeze({
|
|
112
|
+
schema: API_SCHEMA,
|
|
113
|
+
version: API_VERSION,
|
|
114
|
+
VERSION,
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Bookkeeping for "one announcement per process", keyed by a symbol so two copies of this module
|
|
119
|
+
* share it, exactly like the slot and the skew notice.
|
|
120
|
+
*/
|
|
121
|
+
const READY_KEY: symbol = Symbol.for("gentle-shell-bridge/api/v1.ready");
|
|
122
|
+
|
|
123
|
+
/** Bookkeeping for "one skew notice per process", kept off module scope for the same reason as the bus. */
|
|
124
|
+
const WARNED_KEY: symbol = Symbol.for("gentle-shell-bridge/api/v1.warned");
|
|
125
|
+
|
|
126
|
+
/** The object published at `BRIDGE_API_KEY`. */
|
|
127
|
+
export interface BridgeApi {
|
|
128
|
+
readonly schema: typeof API_SCHEMA;
|
|
129
|
+
readonly version: typeof API_VERSION;
|
|
130
|
+
/** The publishing release, e.g. `"0.2.0"`. Informational. */
|
|
131
|
+
readonly VERSION: string;
|
|
132
|
+
readonly registerRows: typeof registerRows;
|
|
133
|
+
readonly registerSection: typeof registerSection;
|
|
134
|
+
/**
|
|
135
|
+
* Card styles (S1). Absent only on an object published by a copy that predates this surface: the
|
|
136
|
+
* slot is first-wins, so a mixed-copy process can hand back the older object. The shape gate does
|
|
137
|
+
* NOT demand it, deliberately — an older object is still a usable bridge for rows, sections and
|
|
138
|
+
* the store, and a plugin that needs card styles checks `typeof api.registerCardStyle ===
|
|
139
|
+
* "function"` before using it. Demanding it here would turn a working bridge into "absent" for
|
|
140
|
+
* every row-only plugin.
|
|
141
|
+
*/
|
|
142
|
+
readonly registerCardStyle?: typeof registerCardStyle;
|
|
143
|
+
/**
|
|
144
|
+
* Transcript transforms (S1). Optional for the same reason as `registerCardStyle`: the slot is
|
|
145
|
+
* first-wins, so a mixed-copy process can hand back an object published before this surface. A
|
|
146
|
+
* plugin that needs the pipeline checks `typeof api.registerTransform === "function"`; demanding
|
|
147
|
+
* it in the gate would turn a working bridge into "absent" for every row-only plugin.
|
|
148
|
+
*/
|
|
149
|
+
readonly registerTransform?: typeof registerTransform;
|
|
150
|
+
/**
|
|
151
|
+
* A repaint of what the host draws (T4, S13): one DIFFERENTIAL frame, `requestRender()` with no
|
|
152
|
+
* argument, after bumping the host's sidebar revision. It does NOT invalidate the component tree and
|
|
153
|
+
* does NOT force a full redraw: measured on a 600-child transcript with real pi components, that cost
|
|
154
|
+
* 753 ms and 6.9 MB per switch against 20 ms for the differential frame, because invalidating clears
|
|
155
|
+
* every Markdown cache. A design change still reaches the cards ALREADY on screen because the design
|
|
156
|
+
* wrapper re-applies the live selection to the host's rows on every render pass.
|
|
157
|
+
*
|
|
158
|
+
* Returns what `/bridge-status` reports as the last repaint, and never throws — with no host
|
|
159
|
+
* captured yet the request is reported as `unavailable` with its reason.
|
|
160
|
+
*
|
|
161
|
+
* Optional for the same reason as `registerCardStyle`: the slot is first-wins, so a mixed-copy
|
|
162
|
+
* process can hand back an object published before this member existed. A plugin that needs it
|
|
163
|
+
* checks `typeof api.requestRepaint === "function"`; demanding it in the gate would turn a working
|
|
164
|
+
* bridge into "absent" for every plugin that does not.
|
|
165
|
+
*/
|
|
166
|
+
readonly requestRepaint?: typeof requestRepaint;
|
|
167
|
+
/**
|
|
168
|
+
* A card DESIGN for one tool name (S4, S5, S7): the bridge owns the single
|
|
169
|
+
* `pi.registerToolRenderer` resolver and fans it out to every design a plugin registers here. A
|
|
170
|
+
* design receives the rows the host's own card produced and returns the rows to draw, so the frame
|
|
171
|
+
* stays the host's.
|
|
172
|
+
*
|
|
173
|
+
* Optional for the same reason as `registerCardStyle`: the slot is first-wins, so a mixed-copy
|
|
174
|
+
* process can hand back an object published before this member existed. A plugin that needs it
|
|
175
|
+
* checks `typeof api.registerToolDesign === "function"`; demanding it in the gate would turn a
|
|
176
|
+
* working bridge into "absent" for every plugin that does not register a design.
|
|
177
|
+
*/
|
|
178
|
+
readonly registerToolDesign?: typeof registerToolDesign;
|
|
179
|
+
/**
|
|
180
|
+
* A keymapped action for the host's Keymaps page (S1): the plugin's own action, editable with the
|
|
181
|
+
* same gestures as a native one (Enter assigns, ⌫ disables, r restores the default) and
|
|
182
|
+
* participating in the host's reserved-key and duplicate-key checks.
|
|
183
|
+
*
|
|
184
|
+
* The bridge publishes the entry; the host reads it while it builds the page. The plugin binds
|
|
185
|
+
* the key itself — Pi reads extension shortcuts at startup and on `/reload` — and asks
|
|
186
|
+
* `resolveKeymap` which key to bind, so the answer comes from the host's own precedence.
|
|
187
|
+
*
|
|
188
|
+
* Optional for the same reason as `registerCardStyle`: the slot is first-wins, so a mixed-copy
|
|
189
|
+
* process can hand back an object published before this member existed.
|
|
190
|
+
*/
|
|
191
|
+
readonly registerKeymap?: typeof registerKeymap;
|
|
192
|
+
/**
|
|
193
|
+
* Items for the host's command palette (S1): one entry, or several, into a section the host
|
|
194
|
+
* already has or a section of its own. An item whose command is not registered is not listed.
|
|
195
|
+
*
|
|
196
|
+
* Optional for the same reason as `registerKeymap`.
|
|
197
|
+
*/
|
|
198
|
+
readonly registerCommands?: typeof registerCommands;
|
|
199
|
+
/**
|
|
200
|
+
* The key an id currently uses, as the shell resolves it: environment variable, then saved
|
|
201
|
+
* binding, then default. Never throws; `unavailable` carries the reason when no shell answers.
|
|
202
|
+
*
|
|
203
|
+
* Optional for the same reason as `registerKeymap`.
|
|
204
|
+
*/
|
|
205
|
+
readonly resolveKeymap?: typeof resolveKeymap;
|
|
206
|
+
readonly createStore: typeof createStore;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function apiHost(): Record<symbol, unknown> {
|
|
210
|
+
// SAFETY: `globalThis` accepts symbol keys, and every write to these slots happens in this module
|
|
211
|
+
// behind the shape checks below.
|
|
212
|
+
return globalThis as unknown as Record<symbol, unknown>;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
interface WarnedState {
|
|
216
|
+
skew: boolean;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** One announcement per process, no matter how many copies publish or how often they ask. */
|
|
220
|
+
interface ReadyState {
|
|
221
|
+
announced: boolean;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* The process-global state for a symbol-keyed flag, created on first use. A foreign object at the
|
|
226
|
+
* key is replaced by a fresh one: this state belongs to this package alone, and it carries nothing
|
|
227
|
+
* a consumer reads.
|
|
228
|
+
*/
|
|
229
|
+
function flagState<T extends object>(key: symbol, shape: (candidate: Partial<T>) => boolean, created: T): T {
|
|
230
|
+
const host = apiHost();
|
|
231
|
+
const existing = host[key];
|
|
232
|
+
if (typeof existing === "object" && existing !== null) {
|
|
233
|
+
const candidate = existing as Partial<T>;
|
|
234
|
+
if (shape(candidate)) return candidate as T;
|
|
235
|
+
}
|
|
236
|
+
host[key] = created;
|
|
237
|
+
return created;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function warnedState(): WarnedState {
|
|
241
|
+
return flagState<WarnedState>(
|
|
242
|
+
WARNED_KEY,
|
|
243
|
+
(candidate) => typeof candidate.skew === "boolean",
|
|
244
|
+
{ skew: false },
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function readyState(): ReadyState {
|
|
249
|
+
return flagState<ReadyState>(
|
|
250
|
+
READY_KEY,
|
|
251
|
+
(candidate) => typeof candidate.announced === "boolean",
|
|
252
|
+
{ announced: false },
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** One clear notice per process, no matter how many copies ask. */
|
|
257
|
+
function warnSkewOnce(): void {
|
|
258
|
+
const state = warnedState();
|
|
259
|
+
if (state.skew) return;
|
|
260
|
+
state.skew = true;
|
|
261
|
+
recordNotice(
|
|
262
|
+
`gentle-shell-bridge: the slot ${String(BRIDGE_API_KEY)} holds something that is not the ` +
|
|
263
|
+
`"${API_SCHEMA}" API version ${String(API_VERSION)} this process expects (a different major ` +
|
|
264
|
+
"version, another library's payload, or a partial object). It is treated as ABSENT: " +
|
|
265
|
+
"bridgeApi() returns undefined and plugins register nothing through this slot. The slot is " +
|
|
266
|
+
"left exactly as it is — it is never overwritten and never reset. This notice is recorded " +
|
|
267
|
+
"once per process and appears in the TUI and in /bridge-status.",
|
|
268
|
+
"warning",
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* The shape gate: the schema string, `version === 1`, and the three members a consumer calls.
|
|
274
|
+
* Anything else — another library's object, a future major version, a partial object — is absent,
|
|
275
|
+
* never an error.
|
|
276
|
+
*/
|
|
277
|
+
export function isBridgeApi(value: unknown): value is BridgeApi {
|
|
278
|
+
if (typeof value !== "object" || value === null) return false;
|
|
279
|
+
const candidate = value as Record<string, unknown>;
|
|
280
|
+
return (
|
|
281
|
+
candidate.schema === API_SCHEMA &&
|
|
282
|
+
candidate.version === API_VERSION &&
|
|
283
|
+
typeof candidate.registerRows === "function" &&
|
|
284
|
+
typeof candidate.registerSection === "function" &&
|
|
285
|
+
typeof candidate.createStore === "function"
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** The object this copy publishes when it owns the slot (or when the slot is unusable). */
|
|
290
|
+
const LOCAL_API: BridgeApi = Object.freeze({
|
|
291
|
+
schema: API_SCHEMA,
|
|
292
|
+
version: API_VERSION,
|
|
293
|
+
VERSION,
|
|
294
|
+
registerRows,
|
|
295
|
+
registerSection,
|
|
296
|
+
registerCardStyle,
|
|
297
|
+
registerTransform,
|
|
298
|
+
requestRepaint,
|
|
299
|
+
registerToolDesign,
|
|
300
|
+
registerKeymap,
|
|
301
|
+
registerCommands,
|
|
302
|
+
resolveKeymap,
|
|
303
|
+
createStore,
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
/** The slot's object when it passes the gate; `undefined` when it is absent or incompatible. */
|
|
307
|
+
function compatibleApi(): BridgeApi | undefined {
|
|
308
|
+
const existing = apiHost()[BRIDGE_API_KEY];
|
|
309
|
+
if (existing === undefined) return undefined;
|
|
310
|
+
if (isBridgeApi(existing)) return existing;
|
|
311
|
+
warnSkewOnce();
|
|
312
|
+
return undefined;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* The validated published object as the slot holds it RIGHT NOW, or `undefined`.
|
|
317
|
+
*
|
|
318
|
+
* This is the whole handshake for a plugin that imports the package and is loaded after it. Call it
|
|
319
|
+
* from a `session_start` handler: an object means register now. `undefined` means nothing is
|
|
320
|
+
* published yet — which is "not yet", not necessarily "not installed", because Pi may load your
|
|
321
|
+
* plugin before this package. Use `onBridgeApi(handler, pi.events)` when both orders have to work;
|
|
322
|
+
* `bridgeApi()` itself never waits, defers or retries.
|
|
323
|
+
*/
|
|
324
|
+
export function bridgeApi(): BridgeApi | undefined {
|
|
325
|
+
return compatibleApi();
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Publish this copy's API object.
|
|
330
|
+
*
|
|
331
|
+
* Runs at module load, so loading the package — through `./extension.ts` or `./index.ts` — makes
|
|
332
|
+
* the API available before any `session_start`. First wins: a valid object already in the slot is
|
|
333
|
+
* kept and returned untouched. An incompatible object is kept too (never reset), and this copy's
|
|
334
|
+
* own object is returned so its own extension can still register through the same bus.
|
|
335
|
+
*/
|
|
336
|
+
export function publishBridgeApi(): BridgeApi {
|
|
337
|
+
const host = apiHost();
|
|
338
|
+
const existing = host[BRIDGE_API_KEY];
|
|
339
|
+
if (existing === undefined) {
|
|
340
|
+
host[BRIDGE_API_KEY] = LOCAL_API;
|
|
341
|
+
return LOCAL_API;
|
|
342
|
+
}
|
|
343
|
+
if (isBridgeApi(existing)) return existing;
|
|
344
|
+
warnSkewOnce();
|
|
345
|
+
return LOCAL_API;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Test-only seam: forget the published object, the once-per-process notice flag and the
|
|
350
|
+
* announcement flag. Clearing the published object is what lets a test model "the bridge is not
|
|
351
|
+
* installed"; clearing the announcement flag is what lets one process prove "once" twice. The
|
|
352
|
+
* extension never calls this. Deliberately not re-exported from `src/index.ts`.
|
|
353
|
+
*/
|
|
354
|
+
export function resetBridgeApiForTests(): void {
|
|
355
|
+
const host = apiHost();
|
|
356
|
+
delete host[BRIDGE_API_KEY];
|
|
357
|
+
delete host[WARNED_KEY];
|
|
358
|
+
delete host[READY_KEY];
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// ---------------------------------------------------------------------------------------------
|
|
362
|
+
// The announcement: how a plugin loaded BEFORE the package learns about it
|
|
363
|
+
// ---------------------------------------------------------------------------------------------
|
|
364
|
+
|
|
365
|
+
/** The `emit` member of a bus-shaped object, bound to it, or `undefined` when unusable. */
|
|
366
|
+
function busEmitter(events: unknown): ((channel: string, data: unknown) => void) | undefined {
|
|
367
|
+
if (typeof events !== "object" || events === null) return undefined;
|
|
368
|
+
const emit = (events as BridgeEventBus).emit;
|
|
369
|
+
if (typeof emit !== "function") return undefined;
|
|
370
|
+
return (channel, data) => {
|
|
371
|
+
emit.call(events, channel, data);
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/** The `on` member of a bus-shaped object, bound to it, or `undefined` when unusable. */
|
|
376
|
+
function busListener(
|
|
377
|
+
events: unknown,
|
|
378
|
+
): ((channel: string, handler: (data: unknown) => void) => BridgeEventUnsubscribe | void) | undefined {
|
|
379
|
+
if (typeof events !== "object" || events === null) return undefined;
|
|
380
|
+
const on = (events as BridgeEventBus).on;
|
|
381
|
+
if (typeof on !== "function") return undefined;
|
|
382
|
+
return (channel, handler) => on.call(events, channel, handler);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Announce this process's bridge, on Pi's cross-extension bus, at most once.
|
|
387
|
+
*
|
|
388
|
+
* Called from the extension entry — the first moment `pi.events` exists — right after the module
|
|
389
|
+
* load that published the object. Rules, all of them observable in the tests:
|
|
390
|
+
*
|
|
391
|
+
* - **once per process**, across package copies: the flag lives on `globalThis`, like the slot;
|
|
392
|
+
* - **never for an incompatible slot**: a process whose slot holds another library's payload (or
|
|
393
|
+
* another major) has no bridge to announce, and telling plugins to look again would only send
|
|
394
|
+
* them to an object that is absent;
|
|
395
|
+
* - **silent without a bus**: Pi may have no `pi.events` in a bare harness; the symbol still works
|
|
396
|
+
* for any plugin that reads it, so nothing is reported and nothing throws.
|
|
397
|
+
*
|
|
398
|
+
* Returns whether this call emitted.
|
|
399
|
+
*/
|
|
400
|
+
export function announceBridgeReady(events: unknown): boolean {
|
|
401
|
+
const emit = busEmitter(events);
|
|
402
|
+
if (emit === undefined) return false;
|
|
403
|
+
const state = readyState();
|
|
404
|
+
if (state.announced) return false;
|
|
405
|
+
// The announcement says "the slot is usable now": an incompatible slot is never announced.
|
|
406
|
+
if (compatibleApi() === undefined) return false;
|
|
407
|
+
state.announced = true;
|
|
408
|
+
try {
|
|
409
|
+
emit(BRIDGE_READY_EVENT, READY_PAYLOAD);
|
|
410
|
+
} catch {
|
|
411
|
+
// A bus that throws cannot un-announce the process: the flag stays set, so `once` holds even
|
|
412
|
+
// when the delivery failed. Nothing is written to the terminal.
|
|
413
|
+
}
|
|
414
|
+
return true;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/** What a consumer does with the published object. */
|
|
418
|
+
export type BridgeApiHandler = (api: BridgeApi) => void;
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Serve a plugin the published API, now or when it is announced.
|
|
422
|
+
*
|
|
423
|
+
* This is the helper form of the corrected timing rule:
|
|
424
|
+
*
|
|
425
|
+
* - **the object is already published** (this package loaded first): the handler runs immediately, in
|
|
426
|
+
* the same turn, and nothing is subscribed — calling it twice cannot leak a listener;
|
|
427
|
+
* - **it is not published yet** (the plugin loaded first, the real defect): the handler runs when the
|
|
428
|
+
* bridge announces itself, at most once per subscription, and the listener detaches itself.
|
|
429
|
+
*
|
|
430
|
+
* `events` is `pi.events`. Without a bus the helper cannot hear a later publication: it returns a
|
|
431
|
+
* no-op disposer and never calls the handler, which is the honest answer for a host with no bus.
|
|
432
|
+
* A handler that throws is contained: one plugin's failure must not break the announcement for the
|
|
433
|
+
* next listener.
|
|
434
|
+
*/
|
|
435
|
+
export function onBridgeApi(handler: BridgeApiHandler, events?: unknown): () => void {
|
|
436
|
+
/** One plugin's throw is its own: the bus must survive to notify the next listener. */
|
|
437
|
+
const deliver = (api: BridgeApi): void => {
|
|
438
|
+
try {
|
|
439
|
+
handler(api);
|
|
440
|
+
} catch {
|
|
441
|
+
// Contained on purpose (see the contract: nothing a plugin does may throw into Pi's render).
|
|
442
|
+
}
|
|
443
|
+
};
|
|
444
|
+
|
|
445
|
+
const existing = bridgeApi();
|
|
446
|
+
if (existing !== undefined) {
|
|
447
|
+
deliver(existing);
|
|
448
|
+
return () => {};
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
const listen = busListener(events);
|
|
452
|
+
if (listen === undefined) return () => {};
|
|
453
|
+
|
|
454
|
+
let active = true;
|
|
455
|
+
let unsubscribe: BridgeEventUnsubscribe | void;
|
|
456
|
+
|
|
457
|
+
const detach = (): void => {
|
|
458
|
+
const off = unsubscribe;
|
|
459
|
+
unsubscribe = undefined;
|
|
460
|
+
if (typeof off !== "function") return;
|
|
461
|
+
try {
|
|
462
|
+
off();
|
|
463
|
+
} catch {
|
|
464
|
+
// A disposer that throws cannot resurrect the subscription.
|
|
465
|
+
}
|
|
466
|
+
};
|
|
467
|
+
|
|
468
|
+
const onReady = (): void => {
|
|
469
|
+
if (!active) return;
|
|
470
|
+
const api = bridgeApi();
|
|
471
|
+
if (api === undefined) return; // an announcement not backed by a usable slot
|
|
472
|
+
active = false;
|
|
473
|
+
detach();
|
|
474
|
+
deliver(api);
|
|
475
|
+
};
|
|
476
|
+
|
|
477
|
+
try {
|
|
478
|
+
unsubscribe = listen(BRIDGE_READY_EVENT, onReady);
|
|
479
|
+
} catch {
|
|
480
|
+
return () => {};
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
return () => {
|
|
484
|
+
active = false;
|
|
485
|
+
detach();
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// The publishing point: loading this module publishes the API. `./extension.ts` imports it, so the
|
|
490
|
+
// object exists as soon as the package has loaded at all — and, when the package loads after a
|
|
491
|
+
// plugin, `announceBridgeReady` from the same entry is how that plugin hears about it.
|
|
492
|
+
publishBridgeApi();
|