File size: 7,046 Bytes
674d56a | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 | /**
* Plugin slot registry.
*
* Plugins can inject components into named locations in the app shell
* (header-left, sidebar, backdrop, etc.) by calling
* `window.__HERMES_PLUGINS__.registerSlot(pluginName, slotName, Component)`
* from their JS bundle. Multiple plugins can populate the same slot β they
* render stacked in registration order.
*
* The canonical slot names are documented in `KNOWN_SLOT_NAMES` below. The
* registry accepts any string so plugin ecosystems can define their own
* slots; the shell only renders `<PluginSlot name="..." />` for the slots
* it knows about.
*/
import React, { Fragment, useEffect, useState } from "react";
/** Slot locations the built-in shell renders. Plugins declaring any of
* these in their manifest's `slots` field get wired in automatically.
*
* Shell-wide slots:
* - `backdrop` β optional full-viewport background decoration;
* mounted behind shell chrome at z-0
* - `header-left` β injected before the Hermes brand in the top bar
* - `header-right` β injected before the theme/language switchers
* - `header-banner` β injected below the top nav bar, full-width
* - `sidebar` β the cockpit sidebar rail (only rendered when
* `layoutVariant === "cockpit"`)
* - `pre-main` β rendered above the route outlet (inside `<main>`)
* - `post-main` β rendered below the route outlet (inside `<main>`)
* - `footer-left` β replaces the left footer cell content
* - `footer-right` β replaces the right footer cell content
* - `overlay` β fixed-position layer above everything else;
* useful for chrome (scanlines, vignettes) the
* theme's customCSS can't achieve alone
*
* Page-scoped slots (rendered inside a specific built-in page β use these
* to inject widgets, cards, or toolbars into existing pages without
* overriding the whole route):
* - `sessions:top` β top of /sessions page (above session list)
* - `sessions:bottom` β bottom of /sessions page
* - `analytics:top` β top of /analytics page
* - `analytics:bottom` β bottom of /analytics page
* - `logs:top` β top of /logs page (above filter toolbar)
* - `logs:bottom` β bottom of /logs page (below log viewer)
* - `cron:top` β top of /cron page
* - `cron:bottom` β bottom of /cron page
* - `skills:top` β top of /skills page
* - `skills:bottom` β bottom of /skills page
* - `plugins:top` β top of /plugins page
* - `plugins:bottom` β bottom of /plugins page
* - `config:top` β top of /config page
* - `config:bottom` β bottom of /config page
* - `env:top` β top of /env (Keys) page
* - `env:bottom` β bottom of /env (Keys) page
* - `docs:top` β top of /docs page (above the docs iframe)
* - `docs:bottom` β bottom of /docs page
* - `chat:top` β top of /chat page (above the composer, when embedded chat is on)
* - `chat:bottom` β bottom of /chat page
*/
export const KNOWN_SLOT_NAMES = [
// Shell-wide
"backdrop",
"header-left",
"header-right",
"header-banner",
"sidebar",
"pre-main",
"post-main",
"footer-left",
"footer-right",
"overlay",
// Page-scoped
"sessions:top",
"sessions:bottom",
"analytics:top",
"analytics:bottom",
"logs:top",
"logs:bottom",
"cron:top",
"cron:bottom",
"skills:top",
"skills:bottom",
"plugins:top",
"plugins:bottom",
"config:top",
"config:bottom",
"env:top",
"env:bottom",
"docs:top",
"docs:bottom",
"chat:top",
"chat:bottom",
] as const;
export type KnownSlotName = (typeof KNOWN_SLOT_NAMES)[number];
type SlotListener = () => void;
interface SlotEntry {
plugin: string;
component: React.ComponentType;
}
/** Map<slotName, SlotEntry[]>. Entries are appended in registration order. */
const _slotRegistry: Map<string, SlotEntry[]> = new Map();
const _slotListeners: Set<SlotListener> = new Set();
function _notifySlots() {
for (const fn of _slotListeners) {
try {
fn();
} catch {
/* ignore */
}
}
}
/** Register a component for a slot. Called by plugin bundles via
* `window.__HERMES_PLUGINS__.registerSlot(...)`.
*
* If the same (plugin, slot) pair is registered twice, the later call
* replaces the earlier one β this matches how React HMR expects plugin
* re-mounts to behave. */
export function registerSlot(
plugin: string,
slot: string,
component: React.ComponentType,
): void {
const existing = _slotRegistry.get(slot) ?? [];
const filtered = existing.filter((e) => e.plugin !== plugin);
filtered.push({ plugin, component });
_slotRegistry.set(slot, filtered);
_notifySlots();
}
/** Read current entries for a slot. Returns a copy so callers can't mutate
* registry state. */
export function getSlotEntries(slot: string): SlotEntry[] {
return (_slotRegistry.get(slot) ?? []).slice();
}
/** Subscribe to registry changes. Returns an unsubscribe function. */
export function onSlotRegistered(fn: SlotListener): () => void {
_slotListeners.add(fn);
return () => {
_slotListeners.delete(fn);
};
}
/** Clear a specific plugin's slot registrations. Useful for HMR /
* plugin reload flows β not wired in by default. */
export function unregisterPluginSlots(plugin: string): void {
let changed = false;
for (const [slot, entries] of _slotRegistry.entries()) {
const kept = entries.filter((e) => e.plugin !== plugin);
if (kept.length !== entries.length) {
changed = true;
if (kept.length === 0) _slotRegistry.delete(slot);
else _slotRegistry.set(slot, kept);
}
}
if (changed) _notifySlots();
}
interface PluginSlotProps {
/** Slot identifier (e.g. `"sidebar"`, `"header-left"`). */
name: string;
/** Optional content rendered when no plugins have claimed the slot.
* Useful for built-in defaults the plugin would replace. */
fallback?: React.ReactNode;
}
/** Render all components registered for a given slot, stacked in order.
*
* Component re-renders when the slot registry changes so plugins that
* arrive after initial mount show up without a manual refresh. */
export function PluginSlot({ name, fallback }: PluginSlotProps) {
const [entries, setEntries] = useState<SlotEntry[]>(() => getSlotEntries(name));
useEffect(() => {
// Pick up anything registered between the initial `useState` call
// and the first effect tick, then subscribe for future changes.
setEntries(getSlotEntries(name));
const unsub = onSlotRegistered(() => setEntries(getSlotEntries(name)));
return unsub;
}, [name]);
if (entries.length === 0) {
return fallback ? React.createElement(Fragment, null, fallback) : null;
}
return React.createElement(
Fragment,
null,
...entries.map((entry) =>
React.createElement(entry.component, { key: entry.plugin }),
),
);
}
|