fix!: port the settings page and shell seam to dsh 0.1.7-rc.2
Release / release (push) Successful in 6s

0.1.7-rc.2 moved four things this bundle was built on, and every one of them
failed quietly rather than loudly, so the plugin looked installed and did
nothing:

  * `ctx.settings.installSection(...)` is gone. The call threw into a
    `try/catch` that only logged a warning, so the settings namespace was never
    registered and `configure` had nowhere to persist. An entry's exported
    Config IS the form now; the namespace is the Loader entry id the bundle's
    patch declares, and `ctx.settings.configure({ auto: false })` is what claims
    that entry's own page.

  * `settingsScope` and the `settings.plugin.item` slot are gone. The card
    registered into both, so it never rendered — the tab draws the intersection
    of served namespaces and registered cards, and that intersection was empty.
    It now registers into `plugins.bundle.config` keyed by the bundle's package
    name, over the `configForms` service, and uses the framework's own form
    chrome (`SettingsFormModel` / `SettingsForm` / `SettingsValueField` /
    `SettingsSecretField`) instead of the hand-rolled equivalent. A save is one
    revision-fenced write.

  * `ctx.shell.run(spec)` is gone. Every action that runs a command threw
    `shell.run is not a function`; the seam is now
    `await ctx.shell.execute(ctx.shell.resolve(request)).result()`.

  * A job's `owner` is a session id, not the agent object. The wrong owner
    registered a job no session could collect or cancel, and the producer's
    `done` resolved `output` where the registry reads `result`, so a finished
    job handed back nothing.

Two conformance gaps went with them: a Config field that is not marked
`volatile()` is invisible to every settings UI — the form projection keeps only
volatile fields, and an entry with no form is dropped from `settings.describe`
entirely — and `embeddingApiKey` was a plain field, so the key rode every
settings read back to the browser. It is `role('secret')` now: the browser is
told whether a key is stored and never receives the key.

Also declares the `@deepseek-ai/dsh-*` peerDependencies the compatibility gate
reads, so an incompatible runtime refuses this plugin at startup instead of
half-loading it.

Verified against a real 0.1.7-rc.2 boot: `settings.describe` serves the entry
with `autoGenerate: false`, the key redacted and reported only as `set: true`,
and no startup diagnostics. The host mock in the tests now has no `run` and no
`installSection`, so those seams are contract-tested rather than assumed, and
the browser tests read the installed harness's own slot catalog and package
exports — a rename fails a test instead of quietly emptying a page.
This commit is contained in:
dsh-slopo agent committed 2026-09-26 06:38:16 +01:00
1 parent ffefc42e01
commit 1b2ab3613f
8 files changed
+1549 -689

No files matched your search

+393 -317
View File
@@ -1,26 +1,37 @@
/**
* dsh-slopo — browser half: the card in Settings → Plugins → Plugin configuration.
* dsh-slopo — browser half: the bundle's configuration page on the Plugins page.
*
* The Host half registers a `dsh-slopo` settings namespace. This file registers
* a card under that exact namespace key. Both are required: the tab renders the
* INTERSECTION of the namespaces the Host serves and the cards the browser
* claims, so a served namespace with no card draws nothing at all, and a card
* whose namespace is not served is never dispatched.
* Where the card lives, and why it moved. Up to dsh 0.1.6 the settings surfaces
* were: a settings namespace a plugin registered for itself, plus a
* `settings.plugin.item` card the plugin's client half registered under that
* namespace key, rendered inside Settings → Plugins → Plugin configuration.
* 0.1.7-rc.2 removed both halves of that contract:
*
* The chrome follows the framework's `PluginCard`: the root is an `<li>` (the
* tab renders every card into a `<ul>`), the header is its own `<button>` that
* discloses the controls in place, the card starts collapsed, it marks itself
* when it holds unsaved edits, and it collapses again after a successful save.
* Nothing about that is imported — `PluginCard` lives in that package's client
* bundle and the client purity gate forbids cross-plugin value imports — so the
* behaviour is reproduced here rather than borrowed.
* * `ctx.settings.installSection(...)` no longer exists, and a namespace is no
* longer chosen by the plugin — it IS the Loader profile entry id, which the
* bundle's own patch declares (`dsh-slopo`).
* * the `settings.plugin.item` slot and the `settingsScope` service no longer
* exist either. Configuration now lives on the Plugins page, and a bundle's
* own settings register into `plugins.bundle.config` keyed by the bundle's
* PACKAGE NAME, which the page only dispatches for a package it lists.
*
* The service that replaced `settingsScope` is `configForms`, and the object it
* hands out for one entry (`configForms.get(id)`) is API-compatible with what
* this card used before: `getSnapshot()`, `subscribe()`, `mutate(ops, revision)`.
* What actually changed is the chrome. The framework now ships the form
* vocabulary this card hand-rolled — `SettingsFormModel` stages the edits and
* fences the save with the revision the drafts were read at, `SettingsForm`
* draws the frame and the single Save, and the field controls carry the
* overridden badge, the reset and the invalid-edit block. Reproducing that by
* hand is what the file used to do; using it is the point.
*
* Hand-written plain JavaScript inside the module-loader wrapper, with no build
* step and no bundler. `dsh plugin add git+…` fetches sources rather than build
* output, so a `prepare` script would make every user allowlist a build before
* the first install succeeds. The only import is `react`, which is in the
* platform seed table; anything outside that table would have to be bundled
* into this file or declared in `dsh.client.external`.
* the first install succeeds. The imports are `react` and
* `@deepseek-ai/dsh-client-ui-primitives`, both platform seed words; anything
* outside that table would have to be bundled into this file or declared in
* `dsh.client.external`.
*
* Styling is inline on purpose, for the same reason: there is no stylesheet this
* file may reach.
@@ -32,338 +43,403 @@ window.__ModuleLoader__.load({
var exports = module.exports
const react = require('react')
const primitives = require('@deepseek-ai/dsh-client-ui-primitives')
const NS = 'dsh-slopo'
const h = react.createElement
const TITLE = 'Slopo'
const DESCRIPTION = 'Embedding backend, index scope and the budget that stops a large embed.'
const { Checkbox, SettingsForm, SettingsFormModel, SettingsSecretField, SettingsValueField } = primitives
const { settingsNumberField, settingsTextField } = primitives
/**
* The settings this card edits.
* The bundle's package name.
*
* This list mirrors the Host schema in index.js. It is duplicated rather
* than fetched because the card renders its own controls — there is no
* generic schema-to-form renderer in the framework — and a field the Host
* does not know is rejected there anyway, so a drift shows up as a clear
* error rather than silent data loss.
* One string plays three roles and they must agree: it is the npm package
* the Plugins page lists, the Loader entry id inside that bundle's patch
* (and therefore the settings namespace `configForms` addresses), and the
* `plugins.bundle.config` key this card registers under.
*/
const BUNDLE = 'dsh-slopo'
/** Dictionary namespace owned by this plugin. */
const NS = 'dsh-slopo'
/** The one write-only field: a credential the wire never carries back. */
const API_KEY_FIELD = 'embeddingApiKey'
const DESCRIPTION =
'Embedding backend, index scope and the budget that stops a large embed.'
/** The card's own copy. */
const en = {
description: DESCRIPTION,
overridden: 'Overridden',
reset: 'Reset to default',
readOnly: 'This deployment stores settings read-only.',
unavailable: 'This plugin is not loaded, so it cannot be configured right now.',
save: 'Save',
saving: 'Saving…',
saveFailed: 'The deployment did not accept these values; they were left for you to correct.',
invalidNumber: 'Enter a number, or leave blank to use the default.',
apiKeySet: 'A key is stored.',
apiKeyUnset: 'No key is stored.',
listHint: 'Comma separated.',
'binary.label': 'slopo executable',
'binary.hint': 'Empty discovers it: managed install, then PATH, then ~/.local/bin.',
'autoInstall.label': 'Install slopo with uv when missing',
'autoInstall.hint': 'Installs into $DSH_HOME with uv, so the executable survives a container recreation.',
'embeddingModel.label': 'Embedding model',
'embeddingModel.hint':
'LiteLLM string, provider prefix included — e.g. ollama/unclemusclez/jina-embeddings-v2-base-code:latest',
'embeddingDimensions.label': 'Embedding dimensions',
'embeddingDimensions.hint': 'Must match what the model returns. Frozen into the index at first use.',
'embeddingApiBase.label': 'Embedding API base',
'embeddingApiBase.hint': 'e.g. http://127.0.0.1:11434',
'embeddingApiKey.label': 'Embedding API key',
'embeddingApiKey.hint':
'Passed to slopo in its environment and never written into the slopo config file. It is write-only here: the deployment reports whether one is stored, never the key.',
'embeddingParamsJson.label': 'Extra embedding parameters',
'embeddingParamsJson.hint': 'A JSON object, merged into embedding_params.',
'sourceDir.label': 'Source directory',
'sourceDir.hint': "Empty uses the session's working directory.",
'sourceDirExclude.label': 'Extra exclude patterns',
'sourceDirExclude.hint': 'Gitignore style, comma separated. Added to the defaults rather than replacing them.',
'useDefaultExcludes.label': 'Exclude node_modules and build output',
'useDefaultExcludes.hint':
'Off means slopo parses your dependencies too — on a repo with node_modules, the difference between ~1,500 and ~114,000 code units.',
'maxIndexUnits.label': 'Refuse to embed more than this many units',
'maxIndexUnits.hint': '0 removes the limit. Makes a large scope a question rather than an hours-long embed.',
'ignoreFile.label': 'Ignore file',
'ignoreFile.hint': 'Point this at the repository to version the dismissed clusters.',
'stateDir.label': 'State directory',
'stateDir.hint': 'Empty uses $DSH_HOME/dsh-slopo/workspaces/<workspace>.',
'embeddingRequestDelay.label': 'Delay between embedding requests (ms)',
'embeddingRequestDelay.hint': 'Rate limit for a provider that rejects bursts. 0 sends as fast as it can.',
'timeoutMs.label': 'Foreground timeout (ms)',
'timeoutMs.hint': 'How long one foreground slopo run may take before it is killed.',
}
/**
* The fields this card edits, in display order.
*
* This list mirrors the Host schema in index.js, including `kind`. It is
* duplicated rather than fetched because the card renders its own controls —
* there is no schema-to-form renderer in the framework — and a field the
* Host does not know is rejected there anyway, so a drift shows up as a
* clear error rather than silent data loss. Every key must be a `volatile()`
* field of that schema or the write is refused.
*/
const FIELDS = [
{
key: 'embeddingModel',
label: 'Embedding model',
hint: 'LiteLLM string, provider prefix included — e.g. ollama/unclemusclez/jina-embeddings-v2-base-code:latest',
},
{ key: 'embeddingDimensions', label: 'Embedding dimensions', kind: 'number', hint: 'Must match what the model returns. Frozen into the index at first use.' },
{ key: 'embeddingApiBase', label: 'Embedding API base', hint: 'e.g. http://127.0.0.1:11434' },
{
key: 'embeddingApiKey',
label: 'Embedding API key',
kind: 'password',
hint: 'Passed to slopo in its environment and never written into the slopo config file.',
},
{ key: 'embeddingParamsJson', label: 'Extra embedding parameters', hint: 'A JSON object, merged into embedding_params.' },
{ key: 'binary', label: 'slopo executable', hint: 'Empty discovers it: managed install, then PATH, then ~/.local/bin.' },
{ key: 'autoInstall', label: 'Install slopo with uv when missing', kind: 'boolean' },
{ key: 'sourceDir', label: 'Source directory', hint: 'Empty uses the session working directory.' },
{
key: 'sourceDirExclude',
label: 'Extra exclude patterns',
kind: 'list',
hint: 'Comma separated, gitignore style. Added to the defaults rather than replacing them.',
},
{
key: 'useDefaultExcludes',
label: 'Exclude node_modules and build output',
kind: 'boolean',
hint: 'Off means slopo parses your dependencies too — on a repo with node_modules, the difference between ~1,500 and ~114,000 code units.',
},
{
key: 'maxIndexUnits',
label: 'Refuse to embed more than this many units',
kind: 'number',
hint: 'Makes a large scope a question rather than an hours-long embed. 0 removes the limit.',
},
{ key: 'stateDir', label: 'State directory', hint: 'Empty uses $DSH_HOME/dsh-slopo/workspaces/<workspace>.' },
{ key: 'ignoreFile', label: 'Ignore file', hint: 'Point this at the repository to version the dismissed clusters.' },
{ key: 'embeddingRequestDelay', label: 'Delay between embedding requests (ms)', kind: 'number' },
{ key: 'timeoutMs', label: 'Foreground timeout (ms)', kind: 'number' },
{ key: 'embeddingModel', kind: 'text' },
{ key: 'embeddingDimensions', kind: 'number' },
{ key: 'embeddingApiBase', kind: 'text' },
{ key: 'embeddingApiKey', kind: 'secret' },
{ key: 'embeddingParamsJson', kind: 'text' },
{ key: 'binary', kind: 'text' },
{ key: 'autoInstall', kind: 'boolean' },
{ key: 'sourceDir', kind: 'text' },
{ key: 'sourceDirExclude', kind: 'list' },
{ key: 'useDefaultExcludes', kind: 'boolean' },
{ key: 'maxIndexUnits', kind: 'number' },
{ key: 'ignoreFile', kind: 'text' },
{ key: 'stateDir', kind: 'text' },
{ key: 'embeddingRequestDelay', kind: 'number' },
{ key: 'timeoutMs', kind: 'number' },
]
const BORDER = '1px solid var(--dsh-border, #3a3a3a)'
/** A label or hint from this card's dictionary. */
const copy = (t, key, part) => t(`${key}.${part}`)
const S = {
// The tab renders into a <ul>, so the root must be an <li> or the list
// styling never reaches it.
card: { listStyle: 'none', border: BORDER, borderRadius: '8px', margin: '8px 0', overflow: 'hidden' },
cardOpen: { background: 'var(--dsh-surface, rgba(255,255,255,0.02))' },
header: {
display: 'flex',
alignItems: 'center',
gap: '12px',
width: '100%',
padding: '12px 16px',
border: 'none',
background: 'transparent',
color: 'inherit',
textAlign: 'left',
cursor: 'pointer',
font: 'inherit',
/** Every field's label and hint, resolving against the active locale. */
const describeField = (t, field) => ({
key: field.key,
kind: field.kind,
label: copy(t, field.key, 'label'),
hint: copy(t, field.key, 'hint'),
id: `plugin-config-${BUNDLE}-${field.key}`,
})
/**
* A boolean field's draft conversion.
*
* The shared kit exports number and text specs only; a switch is a set of
* `true`/`false`, so the control has nothing to parse but the two literals
* it renders. Anything else is an invalid draft, which blocks the save
* rather than silently writing `false`.
*/
const settingsBooleanField = (field) => ({
field,
format: (value) => (value === true ? 'true' : 'false'),
parse: (text) => {
const trimmed = text.trim()
if (trimmed === 'true') return { kind: 'set', value: true }
if (trimmed === 'false') return { kind: 'set', value: false }
return undefined
},
headText: { display: 'flex', flexDirection: 'column', gap: '2px', flex: '1 1 auto', minWidth: 0 },
name: { fontSize: '14px', fontWeight: 600 },
description: { fontSize: '11px', opacity: 0.65, lineHeight: 1.45 },
pending: {
flex: '0 0 auto',
fontSize: '10px',
textTransform: 'uppercase',
letterSpacing: '0.04em',
padding: '2px 6px',
borderRadius: '4px',
border: BORDER,
opacity: 0.8,
},
chevron: { flex: '0 0 auto', fontSize: '10px', opacity: 0.7, transition: 'transform 120ms ease' },
chevronOpen: { transform: 'rotate(180deg)' },
body: { padding: '0 16px 16px 16px' },
readOnly: { fontSize: '11px', opacity: 0.75, marginTop: 0, marginBottom: '12px' },
hint: { fontSize: '11px', opacity: 0.65, marginTop: '2px', lineHeight: 1.45 },
row: { marginTop: '12px' },
label: { display: 'block', fontSize: '12px', fontWeight: 500, marginBottom: '4px' },
input: {
width: '100%',
boxSizing: 'border-box',
padding: '6px 8px',
fontSize: '12px',
borderRadius: '6px',
border: BORDER,
background: 'transparent',
color: 'inherit',
},
actions: { display: 'flex', gap: '8px', alignItems: 'center', marginTop: '16px', flexWrap: 'wrap' },
button: {
padding: '6px 12px',
fontSize: '12px',
borderRadius: '6px',
border: BORDER,
background: 'transparent',
color: 'inherit',
cursor: 'pointer',
},
primary: { fontWeight: 600 },
note: { fontSize: '11px', opacity: 0.75, marginLeft: 'auto' },
})
/**
* A string-array field's draft conversion: a comma-separated list.
*
* An empty draft is an empty list rather than a clear: the schema declares
* this field's default as `[]`, so "no extra patterns" and "fall back to
* the composition layer" are the same answer.
*/
const settingsListField = (field) => ({
field,
format: (value) => (Array.isArray(value) ? value.join(', ') : ''),
parse: (text) => ({
kind: 'set',
value: text
.split(/[,\n]/)
.map((part) => part.trim())
.filter((part) => part !== ''),
}),
})
/** The spec one field's drafts are converted with. */
function specFor(field) {
if (field.kind === 'number') return settingsNumberField(field.key)
if (field.kind === 'boolean') return settingsBooleanField(field.key)
if (field.kind === 'list') return settingsListField(field.key)
return settingsTextField(field.key)
}
/** The editable string form of one field's value. */
function toText(field, value) {
if (field.kind === 'list') return Array.isArray(value) ? value.join(', ') : ''
if (field.kind === 'boolean') return value === true ? 'true' : 'false'
if (value === undefined || value === null) return ''
return String(value)
}
/** The form frame's copy, read from this card's dictionary. */
const formLabels = (t) => ({
unavailable: t('unavailable'),
readOnly: t('readOnly'),
saveFailed: t('saveFailed'),
save: t('save'),
saving: t('saving'),
})
/** The value to write for one field, from its string form. */
function fromText(field, text) {
const trimmed = typeof text === 'string' ? text.trim() : ''
if (field.kind === 'number') return Number(trimmed)
if (field.kind === 'boolean') return trimmed === 'true'
if (field.kind === 'list') {
return trimmed === ''
? []
: trimmed
.split(/[,\n]/)
.map((part) => part.trim())
.filter((part) => part !== '')
}
return text
}
/** The field-control copy shared by every row. */
const fieldLabels = (t) => ({
overriddenLabel: t('overridden'),
resetLabel: t('reset'),
invalidLabel: t('invalidNumber'),
})
/** Build a draft of every field from a resolved section. */
function toDraft(value) {
const draft = {}
for (const field of FIELDS) draft[field.key] = toText(field, value?.[field.key])
return draft
}
function Field(props) {
const { field, value, overridden, disabled, onChange } = props
const common = {
style: S.input,
value,
/** One staged text row. */
function Row(props) {
const { t, state, disabled, edit, resetField, meta } = props
return h(SettingsValueField, {
id: meta.id,
label: meta.label,
hint: meta.hint,
disabled,
'aria-label': field.label,
onChange: (event) => onChange(event.target.value),
}
numeric: meta.kind === 'number',
...fieldLabels(t),
...state,
onEdit: (text) => edit(meta.key, text),
onReset: () => resetField(meta.key),
})
}
/**
* One staged switch row.
*
* `Checkbox` owns native keyboard and form semantics but no override
* affordance, so the badge and the reset sit beside it — the same pair a
* `SettingsValueField` draws for a text row.
*/
function SwitchRow(props) {
const { t, state, disabled, edit, resetField, meta } = props
const checked = state.text === 'true'
return h(
'div',
{ style: S.row },
{ style: { display: 'flex', flexDirection: 'column', gap: '4px' } },
h(
'label',
{ style: S.label },
field.label,
overridden ? h('span', { style: { opacity: 0.6, fontWeight: 400 } }, ' · overridden') : null,
'div',
{ style: { display: 'flex', alignItems: 'center', gap: '10px', flexWrap: 'wrap' } },
h(Checkbox, {
label: meta.label,
checked,
disabled,
onChange: (next) => edit(meta.key, next ? 'true' : 'false'),
}),
state.overridden
? h(
'button',
{
type: 'button',
disabled,
onClick: () => resetField(meta.key),
style: {
font: 'inherit',
fontSize: '11px',
cursor: 'pointer',
background: 'transparent',
color: 'inherit',
opacity: 0.75,
border: 'none',
padding: 0,
},
},
`${t('overridden')} · ${t('reset')}`,
)
: null,
),
field.kind === 'boolean'
? h('input', {
type: 'checkbox',
checked: value === 'true',
disabled,
'aria-label': field.label,
onChange: (event) => onChange(event.target.checked ? 'true' : 'false'),
})
: h('input', { ...common, type: field.kind === 'password' ? 'password' : 'text' }),
field.hint === undefined ? null : h('div', { style: S.hint }, field.hint),
)
}
function Card(props) {
const scope = props.scope
const [snapshot, setSnapshot] = react.useState(() => scope.getSnapshot())
const [draft, setDraft] = react.useState(null)
const [busy, setBusy] = react.useState(false)
const [note, setNote] = react.useState('')
// Collapsed on mount, like every other card in the tab.
const [open, setOpen] = react.useState(false)
react.useEffect(() => scope.subscribe(() => setSnapshot(scope.getSnapshot())), [scope])
const status = snapshot.status
const value = snapshot.value
const baseline = react.useMemo(() => toDraft(value), [value])
const current = draft ?? baseline
const user = snapshot.user
if (status === 'unavailable') {
// A deployment without the Host half, or a remote browser: show no trace.
return null
}
const dirtyKeys = FIELDS.filter((field) => current[field.key] !== baseline[field.key]).map((field) => field.key)
const overriddenKeys = user !== null && typeof user === 'object' ? Object.keys(user) : []
const writable = snapshot.writable === true
const save = async () => {
setBusy(true)
setNote('')
try {
for (const field of FIELDS) {
const next = current[field.key]
if (next === baseline[field.key]) continue
// Clearing a field is an unset, not a write of "", so the value
// re-inherits the composition layer instead of becoming empty.
if (String(next).trim() === '') await scope.unset(field.key)
else await scope.set(field.key, fromText(field, next))
}
setDraft(null)
setNote('Saved.')
// A landed save folds the card back up. A failed or blocked one stays
// open with the drafts, which is the whole point of staging them.
setOpen(false)
} catch (error) {
setNote(`Save failed: ${error?.message ?? error}`)
} finally {
setBusy(false)
}
}
const reset = async () => {
setBusy(true)
setNote('')
try {
for (const key of overriddenKeys) await scope.unset(key)
setDraft(null)
setNote('Reset to the deployment defaults.')
} catch (error) {
setNote(`Reset failed: ${error?.message ?? error}`)
} finally {
setBusy(false)
}
}
return h(
'li',
{ style: open ? { ...S.card, ...S.cardOpen } : S.card },
h(
'button',
{
type: 'button',
style: S.header,
'aria-expanded': open,
'aria-label': `${open ? 'Collapse' : 'Expand'}: ${TITLE}`,
onClick: () => setOpen(!open),
},
h(
'span',
{ style: S.headText },
h('span', { style: S.name }, TITLE),
h('span', { style: S.description }, DESCRIPTION),
),
// Marked on the header, so unsaved edits are visible while collapsed.
dirtyKeys.length > 0 ? h('span', { style: S.pending }, 'unsaved') : null,
h('span', { style: open ? { ...S.chevron, ...S.chevronOpen } : S.chevron }, '▾'),
),
open
? h(
'div',
{ style: S.body },
writable ? null : h('p', { style: S.readOnly, role: 'status' }, 'This namespace is read-only here.'),
...FIELDS.map((field) =>
h(Field, {
key: field.key,
field,
value: current[field.key],
overridden: overriddenKeys.includes(field.key),
disabled: busy || !writable,
onChange: (next) => setDraft({ ...current, [field.key]: next }),
}),
),
h(
'div',
{ style: S.actions },
h(
'button',
{ type: 'button', style: { ...S.button, ...S.primary }, disabled: busy || dirtyKeys.length === 0, onClick: save },
dirtyKeys.length === 0 ? 'Save' : `Save ${dirtyKeys.length} change${dirtyKeys.length === 1 ? '' : 's'}`,
),
h(
'button',
{ type: 'button', style: S.button, disabled: busy || overriddenKeys.length === 0, onClick: reset },
`Reset ${overriddenKeys.length} override${overriddenKeys.length === 1 ? '' : 's'}`,
),
note === '' ? null : h('span', { style: S.note }, note),
),
)
: null,
h('div', { style: { fontSize: '11px', opacity: 0.65, lineHeight: 1.45 } }, meta.hint),
)
}
/**
* Register the card, but only once a settings transport exists.
* Render the bundle's page: the form, or nothing while the deployment does
* not serve the entry.
*
* `ctx.inject(['settingsScope'], …)` rather than a top-level `inject`,
* because a required dependency that is unsatisfied means this plugin never
* applies at all — the card would vanish along with everything else instead
* of simply not appearing.
* @param props - the card's state snapshot, the form actions, and locale copy.
* @returns the settings form.
*/
function SlopoCard(props) {
const { t } = props
const state = props.useSlopoCard((snapshot) => snapshot)
// `view` is `page` for a bundle's configuration slot; the bundle's
// one-liner is the package's own description, so there is no summary view
// to answer here.
const disabled = !state.writable
const children = []
for (const field of FIELDS) {
const meta = describeField(t, field)
const shared = { key: field.key, t, state: state[field.key], disabled, edit: props.edit, resetField: props.resetField, meta }
children.push(field.kind === 'boolean' ? h(SwitchRow, shared) : h(Row, shared))
}
return h(
SettingsForm,
{ labels: formLabels(t), state, onSave: props.save, onDiscard: props.discard },
h(
'div',
{ style: { display: 'flex', flexDirection: 'column', gap: '14px' } },
// The key is write-only, so it is not one of the rows above.
h(SettingsSecretField, {
id: `plugin-config-${BUNDLE}-${API_KEY_FIELD}`,
label: copy(t, API_KEY_FIELD, 'label'),
hint: copy(t, API_KEY_FIELD, 'hint'),
disabled,
text: state.apiKey.text,
configured: state.apiKeyConfigured,
stateLabel: state.apiKeyConfigured ? t('apiKeySet') : t('apiKeyUnset'),
onEdit: (text) => props.edit(API_KEY_FIELD, text),
}),
...children,
),
)
}
/**
* The card's staged form over the `dsh-slopo` profile entry.
*
* `SettingsFormModel` is what makes Save the only write: a draft is staged
* against the revision it was read at, an invalid draft blocks the save
* instead of being dropped, and a refused save keeps its drafts so the user
* can correct them. The model subscribes to the scope itself and republishes
* through {@link SettingsFormModel.bind}, so this class only has to describe
* what one snapshot contains.
*/
class SlopoCardController {
/**
* @param ctx - the browser plugin context, whose `configForms` reports the entry's secret slots.
* @param scope - the shared configuration form for the `dsh-slopo` entry.
*/
constructor(ctx, scope) {
const specs = FIELDS.filter((field) => field.kind !== 'secret').map(specFor)
const secrets = [
{
field: API_KEY_FIELD,
// The credential is a settings field, not a separate credential
// reference, so its one-way write is the same fenced mutation the
// ordinary rows use — with the value never coming back.
write: (text) => scope.mutate([{ op: 'set', path: [API_KEY_FIELD], value: text }]),
},
]
this.ctx = ctx
this.form = new SettingsFormModel(scope, specs, secrets)
this.store = this.form.bind(() => this.projection())
}
/** Whether the deployment reports a stored key; the value itself never rides. */
readApiKeyConfigured() {
const view = this.ctx.configForms.describe().getSnapshot().view
const entry = view?.namespaces?.find((row) => row.ns === BUNDLE)
const slot = entry?.secrets?.find(
(secret) => Array.isArray(secret.path) && secret.path.length === 1 && secret.path[0] === API_KEY_FIELD,
)
return slot?.set === true
}
/** Build the snapshot the card renders from the form's current reads. */
projection() {
const state = {
...this.form.shell(),
apiKey: this.form.field(API_KEY_FIELD),
apiKeyConfigured: this.readApiKeyConfigured(),
}
for (const field of FIELDS) {
if (field.kind !== 'secret') state[field.key] = this.form.field(field.key)
}
return state
}
/** Build the face the slot registration injects: the card's state and its actions. */
inject() {
return { hooks: { slopoCard: this.store }, ...this.form.actions() }
}
/** Release the form's accepted-value subscription. */
dispose() {
this.form.dispose()
}
}
/**
* Register the card, but only once the settings transport exists.
*
* `ctx.inject(['configForms'], …)` rather than a top-level `inject`: a
* required dependency that is unsatisfied means this plugin never applies at
* all, and the card would simply not exist on a deployment that composes no
* settings surface — which is the correct outcome, not a load failure.
*
* `whileServed` is the second gate. The registration only stands while the
* deployment actually serves the `dsh-slopo` namespace, so an uninstalled or
* disabled bundle leaves no card behind, and the Plugins page's own
* `plugins.bundle.config` ledger stays in step with what the Host serves.
*/
function apply(ctx) {
ctx.inject(['settingsScope'], (scoped) => {
const binder = scoped.settingsScope
if (binder === undefined) return
const scope = binder.bind({ namespace: NS })
scoped.slots.inject('settings.plugin.item', () =>
scoped.slots.register(
{
name: 'settings.plugin.item',
key: NS,
inject: () => ({ scope }),
},
(props) => h(Card, props),
),
ctx.effect(
() =>
ctx.locale.register(NS, {
en,
}),
'dsh-slopo: card dictionary',
)
ctx.inject(['configForms'], (child) => {
const forms = child.configForms
if (forms === undefined) return
const controller = new SlopoCardController(child, forms.get(BUNDLE))
child.effect(
() => () => controller.dispose(),
'dsh-slopo: form subscription',
)
child.effect(
() =>
forms.whileServed([BUNDLE], () =>
child.slots.inject('plugins.bundle.config', () =>
child.slots.register(
{
name: 'plugins.bundle.config',
key: BUNDLE,
locale: NS,
inject: () => controller.inject(),
},
SlopoCard,
),
),
),
'dsh-slopo: Plugins page card',
)
})
}
module.exports = { name: NS, inject: ['slots'], apply }
module.exports = { name: BUNDLE, inject: ['slots', 'locale'], apply }
return module.exports
},
})