fix: put the settings card on the Plugins page with a browser half
Release / release (push) Successful in 6s
Release / release (push) Successful in 6s
The `volatile()` markers made the Host serve the namespace, and that was as far as it went: the Plugins page does not build a card from a `Config` schema. It draws only what browser halves register into the slots it declares, which is why dsh-slopo, dsh-recall and dsh-context all ship a `client/` — and why this plugin, with two served fields, still showed nothing. The card registers into `plugins.bundle.config` while `configForms.whileServed` reports the namespace served. The two keys are deliberately different strings and that is the trap in the file: the settings document is filed under the profile entry id (`facet-policy`) while the card is keyed by the package name (`dsh-facet`). Every sibling plugin happens to name them identically. Binding the form to the wrong one yields a card that renders, saves, and writes nowhere, so `test/client.test.js` pins both, holds the card's field list to the schema's volatile fields, and refuses to register at all when the namespace is not served.
This commit is contained in:
1 parent
e860ac2989
commit
1be3a67b97
4 files changed
+563
-7
No files matched your search
@@ -0,0 +1,291 @@
|
||||
/**
|
||||
* dsh-facet — the browser half.
|
||||
*
|
||||
* **Why this file exists when the host half already declares the schema.** The
|
||||
* Plugins page does not generate a card from a `Config` schema. It draws whatever
|
||||
* browser halves register into the slots it declares, and `plugins.bundle.config`
|
||||
* is the seat for one package's own configuration. Marking a field `volatile()`
|
||||
* makes the Host *serve* that namespace — `settings.describe()` drops an entry
|
||||
* with no volatile field, and `configForms.whileServed()` below refuses to
|
||||
* register a page the Host does not serve — but serving it is not drawing it. No
|
||||
* browser half means no card, however the schema is marked.
|
||||
*
|
||||
* **The two keys below are not the same string, and that is the trap.** Every
|
||||
* other plugin in this installation happens to name them identically, so the
|
||||
* distinction never shows up until it does:
|
||||
*
|
||||
* * the settings DOCUMENT is filed under the plugin's profile entry id —
|
||||
* `facet-policy`, the `id` of this bundle's row in `cordis.patch.yml`.
|
||||
* Exporting `Config` from `index.js` is the registration; this is the key
|
||||
* `configForms.get()` addresses.
|
||||
* * the CARD is filed under the bundle's PACKAGE NAME — `dsh-facet`. That is
|
||||
* what the Plugins page dispatches `plugins.bundle.config` by, and it goes on
|
||||
* the bundle's own page between its description and its rows.
|
||||
*
|
||||
* Bind the form to the wrong one and the card renders, saves, and writes nowhere.
|
||||
*
|
||||
* **Loadable without a bundler.** `react` and the primitives package are both in
|
||||
* the boot seed table; the settings document and the slot ledgers arrive as
|
||||
* Cordis services or slot props. Nothing here reaches into another plugin's
|
||||
* bundle.
|
||||
*/
|
||||
|
||||
window.__ModuleLoader__.load({
|
||||
id: 'dsh-facet',
|
||||
factory: (require) => {
|
||||
var module = { exports: {} }
|
||||
var exports = module.exports
|
||||
|
||||
const react = require('react')
|
||||
const primitives = require('@deepseek-ai/dsh-client-ui-primitives')
|
||||
|
||||
const { SegmentedControl, SettingsForm, SettingsFormModel, SettingsValueField } = primitives
|
||||
|
||||
const h = react.createElement
|
||||
|
||||
/** The settings document's key: this bundle's row id, not its package name. */
|
||||
const SETTINGS_ENTRY = 'facet-policy'
|
||||
|
||||
/** The card's key: the bundle's package name, not its row id. */
|
||||
const BUNDLE_CONFIG_KEY = 'dsh-facet'
|
||||
|
||||
/** The modes the host schema accepts, in the order the control offers them. */
|
||||
const MODES = ['ask', 'deny', 'off']
|
||||
|
||||
const TITLE = 'Facet'
|
||||
const DESCRIPTION = 'How a call outside the session\u2019s own folder is handled, and which paths are always in scope.'
|
||||
|
||||
const OVERRIDDEN_LABEL = 'Overridden'
|
||||
const RESET_LABEL = 'Reset to default'
|
||||
|
||||
const MODE_LABELS = { ask: 'Ask', deny: 'Deny', off: 'Off' }
|
||||
const MODE_OPTIONS = MODES.map((value) => ({ value, label: MODE_LABELS[value] }))
|
||||
|
||||
const labelStyle = {
|
||||
display: 'block',
|
||||
marginBottom: '6px',
|
||||
fontSize: '13px',
|
||||
fontWeight: 500,
|
||||
color: 'var(--dsw-alias-label-primary)',
|
||||
}
|
||||
const hintStyle = {
|
||||
margin: '6px 0 0',
|
||||
fontSize: '12px',
|
||||
lineHeight: '18px',
|
||||
color: 'var(--dsw-alias-label-tertiary)',
|
||||
}
|
||||
const badgeStyle = {
|
||||
marginLeft: '8px',
|
||||
fontSize: '11px',
|
||||
fontWeight: 400,
|
||||
color: 'var(--dsw-alias-label-tertiary)',
|
||||
}
|
||||
|
||||
/**
|
||||
* A finite-choice field, staged as the chosen value.
|
||||
*
|
||||
* The primitives ship `settingsNumberField` and `settingsTextField` and
|
||||
* deliberately stop there: only the owning plugin knows what its own
|
||||
* vocabulary is. An unrecognised draft returns `undefined`, which marks the
|
||||
* field invalid and blocks the save rather than quietly coercing a typo into
|
||||
* a mode the schema would reject at the Host anyway.
|
||||
*
|
||||
* @param {string} field - field name inside the namespace section.
|
||||
* @param {readonly string[]} choices - the values the host schema accepts.
|
||||
*/
|
||||
function settingsChoiceField(field, choices) {
|
||||
return {
|
||||
field,
|
||||
format: (value) => (typeof value === 'string' && choices.includes(value) ? value : ''),
|
||||
parse: (text) => {
|
||||
const normalised = String(text).trim().toLowerCase()
|
||||
if (normalised === '') return { kind: 'clear' }
|
||||
if (choices.includes(normalised)) return { kind: 'set', value: normalised }
|
||||
return undefined
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A whole-list field: comma or newline separated in the control, an array of
|
||||
* non-empty strings in the document.
|
||||
*
|
||||
* Empty stages a clear rather than an empty array. The schema's own default
|
||||
* is `[]`, so writing one would shadow a deployment's composed default with
|
||||
* nothing and leave the field marked overridden when the user meant to
|
||||
* remove their override.
|
||||
*
|
||||
* @param {string} field - field name inside the namespace section.
|
||||
*/
|
||||
function settingsListField(field) {
|
||||
return {
|
||||
field,
|
||||
format: (value) => (Array.isArray(value) ? value.join(', ') : ''),
|
||||
parse: (text) => {
|
||||
const parts = String(text)
|
||||
.split(/[,\n]/)
|
||||
.map((part) => part.trim())
|
||||
.filter((part) => part !== '')
|
||||
return parts.length === 0 ? { kind: 'clear' } : { kind: 'set', value: parts }
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** The facet page's staged form over the composed `facet-policy` entry. */
|
||||
var FacetCardController = class {
|
||||
/**
|
||||
* @param {object} scope - the shared configuration form for `facet-policy`.
|
||||
*/
|
||||
constructor(scope) {
|
||||
this.form = new SettingsFormModel(scope, [
|
||||
settingsChoiceField('mode', MODES),
|
||||
settingsListField('extraRoots'),
|
||||
])
|
||||
this.store = this.form.bind(() => this.projection())
|
||||
}
|
||||
|
||||
/** @returns {object} the shell's shared state plus this card's two controls. */
|
||||
projection() {
|
||||
return {
|
||||
...this.form.shell(),
|
||||
mode: this.form.field('mode'),
|
||||
extraRoots: this.form.field('extraRoots'),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the face the slot registration injects.
|
||||
*
|
||||
* The `hooks` key becomes the component's `useFacetCard` prop — the slot
|
||||
* runtime turns each entry into a bound hook — and the actions supply
|
||||
* `edit`, `resetField`, `save` and `discard` beside it.
|
||||
*
|
||||
* @returns {object} the snapshot hook and the form actions.
|
||||
*/
|
||||
inject() {
|
||||
return { hooks: { facetCard: this.store }, ...this.form.actions() }
|
||||
}
|
||||
|
||||
/** Release the form subscription. */
|
||||
dispose() {
|
||||
this.form.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the facet card's one-liner or its settings form, as the page asks.
|
||||
*
|
||||
* @param {object} props - the view asked for, the bound snapshot, and the form actions.
|
||||
*/
|
||||
function FacetCard(props) {
|
||||
if (props.view === 'summary') return DESCRIPTION
|
||||
|
||||
const state = props.useFacetCard((snapshot) => snapshot)
|
||||
const disabled = !state.writable || state.saving
|
||||
|
||||
// The mode is editable as a segmented control rather than through
|
||||
// `SettingsValueField`, because a three-value vocabulary in a free-text box
|
||||
// invites a typo the field can only answer with "invalid". It is still the
|
||||
// same staged draft underneath — the control calls the form's own `edit`,
|
||||
// so nothing is written until Save.
|
||||
const mode = h(
|
||||
'div',
|
||||
{ key: 'mode' },
|
||||
h(
|
||||
'label',
|
||||
{ style: labelStyle, htmlFor: 'facet-mode' },
|
||||
'Enforcement mode',
|
||||
state.mode.overridden ? h('span', { style: badgeStyle }, OVERRIDDEN_LABEL) : null,
|
||||
),
|
||||
h(SegmentedControl, {
|
||||
id: 'facet-mode',
|
||||
value: state.mode.text === '' ? 'ask' : state.mode.text,
|
||||
options: MODE_OPTIONS,
|
||||
onChange: (next) => props.edit('mode', next),
|
||||
label: 'Enforcement mode',
|
||||
disabled,
|
||||
}),
|
||||
h(
|
||||
'p',
|
||||
{ style: hintStyle },
|
||||
'Ask puts each call outside the facet to you as a question. Deny refuses it. Off disables enforcement while leaving the preset in place — useful for telling a scoping problem from an unrelated one.',
|
||||
),
|
||||
)
|
||||
|
||||
const extraRoots = h(SettingsValueField, {
|
||||
key: 'extraRoots',
|
||||
id: 'facet-extra-roots',
|
||||
label: 'Standing grants',
|
||||
hint: 'Paths always in scope, in every session — shared types, a common test harness. Separate them with commas. Relative paths resolve against each session\u2019s own directory, so the same list is meaningful in every workspace.',
|
||||
text: state.extraRoots.text,
|
||||
overridden: state.extraRoots.overridden,
|
||||
invalid: state.extraRoots.invalid,
|
||||
overriddenLabel: OVERRIDDEN_LABEL,
|
||||
resetLabel: RESET_LABEL,
|
||||
invalidLabel: 'Enter one or more paths, separated by commas.',
|
||||
disabled,
|
||||
onEdit: (text) => props.edit('extraRoots', text),
|
||||
onReset: () => props.resetField('extraRoots'),
|
||||
})
|
||||
|
||||
return h(
|
||||
SettingsForm,
|
||||
{
|
||||
labels: {
|
||||
unavailable: 'This plugin is not loaded, so it cannot be configured right now.',
|
||||
readOnly: 'This deployment stores settings read-only.',
|
||||
saveFailed: 'The deployment did not accept these values; they were left for you to correct.',
|
||||
save: 'Save',
|
||||
saving: 'Saving\u2026',
|
||||
},
|
||||
state,
|
||||
onSave: props.save,
|
||||
onDiscard: props.discard,
|
||||
// Column rather than a bare array, so the two controls keep the form's
|
||||
// own spacing instead of sitting flush against each other.
|
||||
children: h('div', { style: { display: 'flex', flexDirection: 'column', gap: '18px' } }, mode, extraRoots),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Mount the facet settings card while the Host serves its namespace.
|
||||
*
|
||||
* `whileServed` is the gate that makes the `volatile()` markers load-bearing:
|
||||
* the card is registered only while `facet-policy` appears in the Host's
|
||||
* served namespaces. An unserved namespace would be a control that silently
|
||||
* writes nowhere, so it registers nothing at all.
|
||||
*
|
||||
* @param {object} ctx - the browser plugin context.
|
||||
*/
|
||||
function apply(ctx) {
|
||||
// `inject` rather than a required dependency, so a deployment that composed
|
||||
// no settings provider loses the card and keeps everything else this bundle
|
||||
// contributes.
|
||||
ctx.inject(['configForms'], (scoped) => {
|
||||
const card = new FacetCardController(scoped.configForms.get(SETTINGS_ENTRY))
|
||||
scoped.effect(() => () => card.dispose(), 'dsh-facet: settings form')
|
||||
|
||||
scoped.effect(
|
||||
() =>
|
||||
scoped.configForms.whileServed([SETTINGS_ENTRY], () =>
|
||||
scoped.slots.inject('plugins.bundle.config', () =>
|
||||
scoped.slots.register(
|
||||
{
|
||||
name: 'plugins.bundle.config',
|
||||
key: BUNDLE_CONFIG_KEY,
|
||||
inject: () => card.inject(),
|
||||
},
|
||||
FacetCard,
|
||||
),
|
||||
),
|
||||
),
|
||||
'dsh-facet: settings page',
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
module.exports = { name: 'dsh-facet', inject: ['slots'], apply, SETTINGS_ENTRY, BUNDLE_CONFIG_KEY, FacetCard, FacetCardController }
|
||||
return module.exports
|
||||
},
|
||||
})
|
||||
Reference in new issue
Block a user