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.
292 lines
11 KiB
JavaScript
292 lines
11 KiB
JavaScript
/**
|
|
* 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
|
|
},
|
|
})
|