Release / release (push) Successful in 6s
The extras control accepts a file — the field and its label said so — and the listing under it could not show one, because the Host primitive behind the picker browses folders and nothing else. So a grant for a single file meant typing its name exactly, which is the one thing a listing exists to avoid. The Host has a second lister and it does return files: `remote.workspaceFiles.list`, the same listing behind the sidebar's own file tree, confined to the session's workspace. The extras picker now takes that one while the level is inside the session folder — which is where an extra usually is — and falls back to the folder lister above it, saying so in the dialog rather than letting file rows vanish. The focus picker never offers files: a focus is a folder, so a file row there would offer something that cannot be chosen. The two listings normalise into one level shape, so the picker does not care which answered, and a row now carries its type: a folder is a way down, a file is a thing to take. Clicking a file fills the path field and marks the row, and the footer adds it in declared form. Pressing Enter on a name the level already lists is no longer reported as a failure — the path is exactly what the dialog is for. Everything stays additive: with no workspace file service, or above the session folder, the picker lists folders exactly as before.
1514 lines
63 KiB
JavaScript
1514 lines
63 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 {
|
|
Modal,
|
|
SegmentedControl,
|
|
SettingsForm,
|
|
SettingsFormModel,
|
|
SettingsValueField,
|
|
IconFolderCloseRegular,
|
|
FileTypeIcon,
|
|
} = 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),
|
|
},
|
|
)
|
|
}
|
|
|
|
/**
|
|
* The preset id whose sessions get a context button.
|
|
*
|
|
* A session records its preset as a projection value, so the button can
|
|
* appear exactly where the facet is in force and stay out of the way
|
|
* everywhere else. Rendering it unconditionally would put a control for a
|
|
* plugin that is not running on every conversation in the deployment.
|
|
*/
|
|
const FACET_PRESET = 'facet'
|
|
|
|
/** The composer tool row's entry id, and the button's accessible name. */
|
|
const CONTEXT_ENTRY_ID = 'facet-context'
|
|
const CONTEXT_LABEL = 'Facet context'
|
|
|
|
/** The scope one session has declared, normalised, or an empty declaration. */
|
|
function declaredFor(snapshot, sessionId) {
|
|
const all = snapshot?.value?.scopes
|
|
const mine = all !== null && typeof all === 'object' ? all[sessionId] : undefined
|
|
const entry = mine !== null && typeof mine === 'object' ? mine : {}
|
|
return {
|
|
focus: typeof entry.focus === 'string' ? entry.focus : '',
|
|
extras: Array.isArray(entry.extras) ? entry.extras.filter((path) => typeof path === 'string') : [],
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Write one session's whole declaration.
|
|
*
|
|
* One path, one write: the panel always supplies the complete declaration it
|
|
* wants, so a queued edit can never half-apply and leave a session scoped to
|
|
* something nobody chose. An empty declaration is removed rather than stored,
|
|
* so a session the user has finished with leaves no residue in the profile
|
|
* patch — and so clearing both fields means "the session directory", which is
|
|
* the schema's own default rather than a stored empty override.
|
|
*
|
|
* @returns {Promise<boolean>} whether the Host accepted the write.
|
|
*/
|
|
function writeDeclaration(form, sessionId, next) {
|
|
const focus = typeof next.focus === 'string' ? next.focus.trim() : ''
|
|
const extras = (next.extras ?? []).map((path) => String(path).trim()).filter((path) => path !== '')
|
|
const value = {
|
|
...(focus === '' ? {} : { focus }),
|
|
...(extras.length === 0 ? {} : { extras }),
|
|
}
|
|
const op =
|
|
Object.keys(value).length === 0
|
|
? { op: 'unset', path: ['scopes', sessionId] }
|
|
: { op: 'set', path: ['scopes', sessionId], value }
|
|
return Promise.resolve(form.mutate([op]))
|
|
}
|
|
|
|
/** Shared control styling, so the panel reads as part of the product. */
|
|
const controlStyle = {
|
|
boxSizing: 'border-box',
|
|
height: '32px',
|
|
padding: '0 10px',
|
|
fontSize: '13px',
|
|
fontFamily: 'inherit',
|
|
color: 'var(--dsw-alias-label-primary)',
|
|
background: 'var(--dsw-alias-bg-layer-3)',
|
|
border: '0.5px solid var(--dsw-alias-border-l4)',
|
|
borderRadius: 'var(--dsw-radius-md)',
|
|
}
|
|
const quietButtonStyle = {
|
|
...controlStyle,
|
|
cursor: 'pointer',
|
|
color: 'var(--dsw-alias-label-secondary)',
|
|
background: 'transparent',
|
|
}
|
|
const sectionTitleStyle = {
|
|
margin: '0 0 6px',
|
|
fontSize: '13px',
|
|
fontWeight: 600,
|
|
color: 'var(--dsw-alias-label-primary)',
|
|
}
|
|
const bodyStyle = { margin: '0 0 14px', fontSize: '12.5px', lineHeight: '19px', color: 'var(--dsw-alias-label-secondary)' }
|
|
const listedStyle = { ...bodyStyle, margin: '4px 0 0', fontFamily: 'var(--dsw-font-mono, ui-monospace, monospace)' }
|
|
|
|
// ── the directory listing ────────────────────────────────────────────────
|
|
|
|
/** The classes that widen the two dialogs past the primitive's 380px card. */
|
|
const PANEL_CLASS = 'dsh-facet-panel'
|
|
const PICKER_CLASS = 'dsh-facet-picker'
|
|
const ROW_CLASS = 'dsh-facet-row'
|
|
|
|
/**
|
|
* The stylesheet this bundle installs, for the two things an inline style
|
|
* cannot reach.
|
|
*
|
|
* `Modal` sizes its own card and takes only a class name, and a `:hover`
|
|
* state has no inline spelling, so both live here. Every selector names a
|
|
* class of ours and doubles it: the primitive's card rule is a single hashed
|
|
* class, and a doubled unhashed one outranks it whatever order the two
|
|
* stylesheets land in. That is the whole reason for the doubling — no
|
|
* `!important`, and nothing reaching into a class name this bundle does not
|
|
* own.
|
|
*
|
|
* The widths are the fix for the card the panel used to be: 380px is
|
|
* comfortable for a sentence and cramped for a path listing.
|
|
*/
|
|
const STYLE_ID = 'dsh-facet'
|
|
const STYLE_CSS =
|
|
'.dsh-facet-panel.dsh-facet-panel{width:min(760px,100%)}' +
|
|
'.dsh-facet-picker.dsh-facet-picker{width:min(560px,100%)}' +
|
|
'.dsh-facet-row:enabled:hover{background:var(--dsw-alias-interactive-bg-hover)}' +
|
|
'.dsh-facet-row:focus-visible{outline:var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary));outline-offset:-2px}'
|
|
|
|
/**
|
|
* Install {@link STYLE_CSS} once per document.
|
|
*
|
|
* Guarded on `document` because the Node test harness loads this module with
|
|
* only `window` and `require` faked, and there is no DOM there to style.
|
|
*/
|
|
function installStyles() {
|
|
if (typeof document === 'undefined') return
|
|
if (document.querySelector(`style[data-plugin-css="${STYLE_ID}"]`) !== null) return
|
|
const tag = document.createElement('style')
|
|
tag.dataset.plugin = 'dsh-facet'
|
|
tag.dataset.pluginCss = STYLE_ID
|
|
tag.textContent = STYLE_CSS
|
|
document.head.appendChild(tag)
|
|
}
|
|
|
|
installStyles()
|
|
|
|
/**
|
|
* Whether a declared path stands on its own instead of resolving against
|
|
* the session folder.
|
|
*
|
|
* Both separators, because a profile patch is written on one platform and
|
|
* read on whichever machine runs it.
|
|
*/
|
|
function isAbsolutePath(path) {
|
|
return typeof path === 'string' && (/^[\\/]/.test(path) || /^[A-Za-z]:[\\/]/.test(path))
|
|
}
|
|
|
|
/** One host path's segments, on either separator. */
|
|
function pathSegments(path) {
|
|
return String(path)
|
|
.split(/[\\/]+/)
|
|
.filter((segment) => segment !== '')
|
|
}
|
|
|
|
/**
|
|
* Say an absolute host path the way this panel declares paths: relative to
|
|
* the session folder where that still names it, absolute where it does not.
|
|
*
|
|
* Relative is not cosmetic. A declaration is resolved against the session
|
|
* directory at the point of use, so `../shared` keeps its meaning when the
|
|
* workspace moves and `/srv/old-workspace/shared` does not.
|
|
*
|
|
* @param {string} root - the session folder, absolute, or '' when unknown.
|
|
* @param {string} target - the path to express.
|
|
* @returns {string} the declared form; '' is the session folder itself.
|
|
*/
|
|
function relativeTo(root, target) {
|
|
const path = String(target ?? '')
|
|
if (typeof root !== 'string' || root === '' || path === '') return path
|
|
const base = pathSegments(root)
|
|
const full = pathSegments(path)
|
|
let common = 0
|
|
while (common < base.length && common < full.length && base[common] === full[common]) common += 1
|
|
// Nothing shared — another drive, or a path that is relative already.
|
|
// Absolute is the only honest spelling left.
|
|
if (common === 0) return path
|
|
const up = base.slice(common).map(() => '..')
|
|
return [...up, ...full.slice(common)].join('/')
|
|
}
|
|
|
|
/**
|
|
* Resolve a declared path against the session folder, for a listing call.
|
|
*
|
|
* The host's browse primitive lists absolute paths only, so a stored
|
|
* `focus` has to be joined before it can be opened. Joining is a string
|
|
* operation rather than a `path.resolve` because there is no `node:path` on
|
|
* this side of the wire and the host accepts either separator.
|
|
*
|
|
* @param {string} root - the session folder, absolute, or '' when unknown.
|
|
* @param {string} declared - a stored focus or extra.
|
|
* @returns {string} an absolute path where one can be built, else the input.
|
|
*/
|
|
function absoluteFrom(root, declared) {
|
|
const path = String(declared ?? '').trim()
|
|
if (path === '' || isAbsolutePath(path)) return path
|
|
if (typeof root !== 'string' || root === '') return path
|
|
return `${root.replace(/[\\/]+$/, '')}/${path.replace(/^[\\/]+/, '')}`
|
|
}
|
|
|
|
/**
|
|
* The operator-facing text for a failed browse call.
|
|
*
|
|
* The Host's own business message is preferred to the transport error,
|
|
* because a browse refusal is an answer — the folder is unreadable, the path
|
|
* is not absolute — and that sentence is the one worth reading. Both listers
|
|
* reach here: the folder primitive rejects with its own error and the remote
|
|
* one folds its failure into a `RemoteError`, which is an `Error` carrying
|
|
* the same message field.
|
|
*/
|
|
function browseFailure(error) {
|
|
const rpc = error !== null && typeof error === 'object' ? error.rpcError : undefined
|
|
if (rpc !== null && typeof rpc === 'object' && typeof rpc.message === 'string') return rpc.message
|
|
return error instanceof Error ? error.message : String(error)
|
|
}
|
|
|
|
// ── one level, in the picker's own vocabulary ────────────────────────────
|
|
//
|
|
// The Host has two listers and they answer different questions, so the
|
|
// picker reads them through one shape and does not care which answered:
|
|
//
|
|
// * `uiWorkspace.listDirectory` browses anywhere and returns folders only;
|
|
// * `remote.workspaceFiles.list` returns files as well, and confines itself
|
|
// to the session's workspace — the same listing the sidebar's file tree
|
|
// drives, and the only way this deployment can show a file at all.
|
|
//
|
|
// `{base, crumbs, entries, truncated, files}` is that shape. `files` says
|
|
// whether this level could carry file rows, which is what the copy under the
|
|
// path field tells the user.
|
|
|
|
/** Join one name onto a directory, with the separator the Host resolves. */
|
|
function joinUnder(base, name) {
|
|
return `${String(base).replace(/[\\/]+$/, '')}/${name}`
|
|
}
|
|
|
|
/** Whether a path is a directory or sits inside one, either separator. */
|
|
function withinPath(inner, outer) {
|
|
const path = String(inner).replace(/\\/g, '/').replace(/\/+$/, '')
|
|
const base = String(outer).replace(/\\/g, '/').replace(/\/+$/, '')
|
|
return base !== '' && (path === base || path.startsWith(`${base}/`))
|
|
}
|
|
|
|
/** The last segment of a path, for a trail's first crumb. */
|
|
function basenameOf(path) {
|
|
const segments = pathSegments(path)
|
|
return segments.length === 0 ? String(path) : segments[segments.length - 1]
|
|
}
|
|
|
|
/**
|
|
* The ancestry of one path inside the workspace, workspace root first.
|
|
*
|
|
* The folder primitive reports its own trail — from the filesystem root, so
|
|
* the user can go above the session — and the file listing reports none,
|
|
* because the workspace root is as far up as it goes. This builds the one it
|
|
* does not send.
|
|
*/
|
|
function trailTo(workspace, path) {
|
|
const trail = [{ name: basenameOf(workspace), path: workspace }]
|
|
const rest = relativeTo(workspace, path)
|
|
// A path outside the workspace cannot be walked to from it, and `relativeTo`
|
|
// says so by answering in `..` segments or absolutely.
|
|
if (rest === '' || rest.startsWith('..') || isAbsolutePath(rest)) return trail
|
|
let walked = workspace
|
|
for (const segment of rest.split('/')) {
|
|
if (segment === '') continue
|
|
walked = joinUnder(walked, segment)
|
|
trail.push({ name: segment, path: walked })
|
|
}
|
|
return trail
|
|
}
|
|
|
|
/**
|
|
* One folder listing, as the picker reads it.
|
|
*
|
|
* @param {object} listing - a `DirectoryListing` from the browse primitive.
|
|
* @param {string} [fallback] - the path asked for, when the answer carries none.
|
|
* @returns {object} the picker's level.
|
|
*/
|
|
function folderListing(listing, fallback) {
|
|
const base = typeof listing?.path === 'string' && listing.path !== '' ? listing.path : String(fallback ?? '')
|
|
const home = typeof listing?.home === 'string' ? listing.home : ''
|
|
return {
|
|
base,
|
|
crumbs: (Array.isArray(listing?.crumbs) ? listing.crumbs : []).map((crumb) => ({
|
|
// A root crumb carries its whole path as its name; "Home" is the label
|
|
// the host's own browser gives it.
|
|
name: crumb.path === home ? 'Home' : crumb.name,
|
|
path: crumb.path,
|
|
})),
|
|
entries: (Array.isArray(listing?.entries) ? listing.entries : []).map((entry) => ({
|
|
name: entry.name,
|
|
path: entry.path,
|
|
directory: true,
|
|
hidden: entry.hidden === true,
|
|
})),
|
|
truncated: listing?.truncated === true,
|
|
files: false,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* One workspace listing, as the picker reads it.
|
|
*
|
|
* Paths arrive relative to the workspace root and the picker works in
|
|
* absolute paths, so they are joined here — with `/`, whatever the level's
|
|
* own separators, because the Host resolves mixed separators and this is only
|
|
* a key and something to display.
|
|
*
|
|
* @param {object} listing - a `WorkspaceDirectoryListing`.
|
|
* @param {string} workspace - the session folder, absolute.
|
|
* @returns {object} the picker's level.
|
|
*/
|
|
function workspaceListing(listing, workspace) {
|
|
const relative = typeof listing?.path === 'string' ? listing.path : ''
|
|
const base = relative === '' ? workspace : joinUnder(workspace, relative)
|
|
return {
|
|
base,
|
|
crumbs: trailTo(workspace, base),
|
|
entries: (Array.isArray(listing?.entries) ? listing.entries : []).map((entry) => ({
|
|
name: entry.name,
|
|
path: joinUnder(base, entry.name),
|
|
directory: entry.type === 'directory',
|
|
// The workspace listing has no hidden flag; the platform convention is
|
|
// the dot, which is what the folder primitive reports for the same name.
|
|
hidden: String(entry.name).startsWith('.'),
|
|
})),
|
|
truncated: listing?.truncated === true,
|
|
files: true,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The state behind the picker: the level on screen, the path being edited,
|
|
* and the last failure.
|
|
*
|
|
* A model rather than component state because a browse is asynchronous and
|
|
* supersedable: clicking down three folders fires three listings, and the
|
|
* first one landing last must not overwrite the third. Each request carries
|
|
* a sequence number and only the newest may commit. It also keeps the whole
|
|
* interaction testable without a DOM, which is the same reason the settings
|
|
* card has a controller.
|
|
*
|
|
* Two listers in, one level out: see the note above {@link folderListing}
|
|
* for why there are two and what the picker does with either.
|
|
*/
|
|
var DirectoryPickerModel = class {
|
|
/**
|
|
* @param {object} options
|
|
* @param {(path?: string, signal?: AbortSignal) => Promise<object>} options.folders - the folder listing; browses anywhere.
|
|
* @param {(sessionId: string, path: string, signal?: AbortSignal) => Promise<object>} [options.files] - the file listing; files and folders, confined to the workspace.
|
|
* @param {string} [options.sessionId] - the session whose workspace the file listing is confined to.
|
|
* @param {string} options.root - the session folder, absolute, or ''.
|
|
* @param {string} [options.start] - the directory to open first; the root by default.
|
|
*/
|
|
constructor(options) {
|
|
const root = typeof options.root === 'string' ? options.root : ''
|
|
this.folders = options.folders
|
|
this.files = options.files
|
|
this.sessionId = options.sessionId
|
|
this.root = root
|
|
this.listeners = new Set()
|
|
this.request = 0
|
|
this.state = {
|
|
listing: null,
|
|
loading: false,
|
|
error: '',
|
|
showHidden: false,
|
|
draft: typeof options.start === 'string' && options.start !== '' ? options.start : root,
|
|
}
|
|
this.open(this.state.draft)
|
|
}
|
|
|
|
/**
|
|
* @param {() => void} listener - woken on every commit.
|
|
* @returns {() => void} the unsubscribe.
|
|
*/
|
|
subscribe(listener) {
|
|
this.listeners.add(listener)
|
|
return () => this.listeners.delete(listener)
|
|
}
|
|
|
|
/** @returns {object} the current state, for `useSyncExternalStore`. */
|
|
getSnapshot() {
|
|
return this.state
|
|
}
|
|
|
|
/** @param {object} patch @returns {void} */
|
|
update(patch) {
|
|
this.state = { ...this.state, ...patch }
|
|
for (const listener of [...this.listeners]) listener()
|
|
}
|
|
|
|
/**
|
|
* List one directory, discarding any listing already in flight.
|
|
*
|
|
* An empty path asks the Host for its home directory, which is the right
|
|
* answer when the session folder is not known yet. A failure keeps the
|
|
* level that was on screen and the path as typed, because a listing can
|
|
* fail on a path this panel may still legitimately declare — a file rather
|
|
* than a folder is the ordinary case, not an error.
|
|
*
|
|
* @param {string} [path] - the absolute directory to list.
|
|
*/
|
|
open(path) {
|
|
const target = String(path ?? '').trim()
|
|
const request = (this.request += 1)
|
|
this.update({ loading: true, error: '', draft: target })
|
|
this.read(target, request)
|
|
}
|
|
|
|
/**
|
|
* One level, from whichever lister can answer for it.
|
|
*
|
|
* The file listing is tried first while the level is inside the session's
|
|
* workspace, because that is the listing the extras need: it carries files
|
|
* as well as folders. The Host confines it to the workspace, so a level
|
|
* above the workspace root — or a deployment with no file service — falls
|
|
* back to the folder listing, which browses anywhere and carries folders
|
|
* only. The fallback is also the answer to a file listing that failed for
|
|
* any other reason, so a level the file service cannot read is still
|
|
* browsable as folders rather than reported as unreadable.
|
|
*
|
|
* @param {string} target - the absolute directory, or '' for the Host's home.
|
|
* @param {number} request - the sequence number this read belongs to.
|
|
*/
|
|
read(target, request) {
|
|
const files = this.files
|
|
const inside = target === '' ? this.root !== '' : withinPath(target, this.root)
|
|
const asFolders = () => this.folders(target === '' ? undefined : target).then((listing) => folderListing(listing, target))
|
|
const listing =
|
|
files === undefined || !inside
|
|
? asFolders()
|
|
: files(this.sessionId, target === '' ? this.root : target)
|
|
.then((answer) => workspaceListing(answer, this.root))
|
|
.catch(asFolders)
|
|
Promise.resolve(listing).then(
|
|
(level) => {
|
|
if (request !== this.request) return
|
|
this.update({ listing: level, loading: false, error: '', draft: level.base })
|
|
},
|
|
(reason) => {
|
|
if (request !== this.request) return
|
|
// A path that names a file in the level already on screen is not a
|
|
// failure, it is the thing this dialog is for: the list asked for is
|
|
// not a list, and the footer can still add what it names. That
|
|
// matters now that a file row is one keystroke away from the field.
|
|
const wanted = absoluteFrom(this.root, target)
|
|
const named = (this.state.listing?.entries ?? []).some(
|
|
(entry) => entry.directory !== true && entry.path === wanted,
|
|
)
|
|
if (named) {
|
|
this.update({ loading: false, error: '' })
|
|
return
|
|
}
|
|
this.update({ loading: false, error: browseFailure(reason) })
|
|
},
|
|
)
|
|
}
|
|
|
|
/** @param {string} text - the path field's own value. */
|
|
setDraft(text) {
|
|
this.update({ draft: String(text) })
|
|
}
|
|
|
|
/** @param {boolean} showHidden - whether host-flagged hidden folders are listed. */
|
|
setShowHidden(showHidden) {
|
|
this.update({ showHidden: showHidden === true })
|
|
}
|
|
}
|
|
|
|
/** Picker styling: the primitive's own control shapes, one size down. */
|
|
const pickerBodyStyle = { maxHeight: 'min(52vh, 360px)', overflowY: 'auto', paddingRight: '4px' }
|
|
const pickerLevelStyle = { display: 'flex', flexDirection: 'column', gap: '2px', marginTop: '4px' }
|
|
const pickerRowStyle = {
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: '8px',
|
|
boxSizing: 'border-box',
|
|
width: '100%',
|
|
height: '30px',
|
|
padding: '0 8px',
|
|
border: 0,
|
|
borderRadius: 'var(--dsw-radius-sm)',
|
|
background: 'transparent',
|
|
color: 'var(--dsw-alias-label-primary)',
|
|
fontFamily: 'inherit',
|
|
fontSize: '13px',
|
|
textAlign: 'left',
|
|
cursor: 'pointer',
|
|
}
|
|
const pickerNameStyle = { flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }
|
|
const pickerGlyphStyle = { display: 'inline-flex', flex: 'none', color: 'var(--dsw-alias-label-secondary)' }
|
|
/** A file row the path field already names, so a click is visibly a choice. */
|
|
const pickerChosenRowStyle = {
|
|
...pickerRowStyle,
|
|
// The token the Host's own directory browser uses for a chosen row, fallback
|
|
// and all, so a pick looks the same here as it does one panel over.
|
|
background: 'var(--dsw-alias-interactive-bg-active,var(--dsw-alias-interactive-bg-hover))',
|
|
}
|
|
const crumbStyle = {
|
|
flex: 'none',
|
|
border: 0,
|
|
padding: '0 2px',
|
|
background: 'transparent',
|
|
color: 'var(--dsw-alias-label-tertiary)',
|
|
fontFamily: 'inherit',
|
|
fontSize: '12.5px',
|
|
cursor: 'pointer',
|
|
}
|
|
const pickerStatusStyle = {
|
|
margin: '6px 0 0',
|
|
fontSize: '12px',
|
|
lineHeight: '18px',
|
|
color: 'var(--dsw-alias-label-tertiary)',
|
|
}
|
|
const pickerErrorStyle = { ...pickerStatusStyle, color: 'var(--dsw-alias-state-error-primary)' }
|
|
const toggleStyle = {
|
|
display: 'inline-flex',
|
|
alignItems: 'center',
|
|
gap: '6px',
|
|
fontSize: '12.5px',
|
|
color: 'var(--dsw-alias-label-secondary)',
|
|
cursor: 'pointer',
|
|
}
|
|
|
|
/**
|
|
* The directory picker: one editable path, the ancestry above it, and one
|
|
* level of children — folders always, and files wherever the Host will list
|
|
* them.
|
|
*
|
|
* A folder row is a way down; a file row is a thing to take, so it fills the
|
|
* path field rather than navigating, and the primary action adds what the
|
|
* field holds. That is the one interaction the two row kinds do not share,
|
|
* and it is why a file row is marked selected once the field names it.
|
|
*
|
|
* It drives no host call of its own — {@link DirectoryPickerModel} holds the
|
|
* listing and the path, and the panel builds one per opening so each visit
|
|
* starts from what the Host holds rather than from the last one's draft.
|
|
*
|
|
* @param {object} props - the model, the session folder, what is being picked, and the outcome calls.
|
|
*/
|
|
function DirectoryPicker(props) {
|
|
const { model, root, target, disabled, onClose, onPick } = props
|
|
const state = react.useSyncExternalStore(
|
|
(listener) => model.subscribe(listener),
|
|
() => model.getSnapshot(),
|
|
)
|
|
|
|
const listing = state.listing
|
|
const blocked = disabled === true
|
|
const typed = state.draft
|
|
const entries = listing === null ? [] : listing.entries.filter((entry) => state.showHidden || entry.hidden !== true)
|
|
// What the field currently means as a declaration. '' is the session
|
|
// folder: the focus's own default, and for an extra — already the root —
|
|
// nothing worth adding.
|
|
const declared = relativeTo(root, typed)
|
|
// The field's own value as an absolute path, so a file row can tell whether
|
|
// it is the one the field names.
|
|
const chosen = absoluteFrom(root, typed)
|
|
|
|
const rows = entries.map((entry) =>
|
|
h(
|
|
'button',
|
|
{
|
|
key: entry.path,
|
|
type: 'button',
|
|
className: ROW_CLASS,
|
|
style: entry.directory || entry.path !== chosen ? pickerRowStyle : pickerChosenRowStyle,
|
|
title: entry.path,
|
|
disabled: blocked || state.loading,
|
|
// A folder is chosen by standing in it; a file is chosen by naming
|
|
// it, so the click puts it in the field for the footer to add. The
|
|
// field holds an absolute path everywhere else in this dialog, and
|
|
// the declared form is what the panel's write turns it into.
|
|
onClick: () => (entry.directory || target === 'focus' ? model.open(entry.path) : model.setDraft(entry.path)),
|
|
},
|
|
h(
|
|
'span',
|
|
{ style: pickerGlyphStyle },
|
|
entry.directory
|
|
? h(IconFolderCloseRegular, { size: 14 })
|
|
: h(FileTypeIcon, { path: entry.name, size: 16 }),
|
|
),
|
|
h('span', { style: pickerNameStyle }, entry.name),
|
|
),
|
|
)
|
|
|
|
const trail = []
|
|
for (const [index, crumb] of (listing === null ? [] : listing.crumbs).entries()) {
|
|
if (index > 0) trail.push(h('span', { key: `sep-${index}`, style: { color: 'var(--dsw-alias-label-caption)' } }, '/'))
|
|
trail.push(
|
|
h(
|
|
'button',
|
|
{
|
|
key: crumb.path,
|
|
type: 'button',
|
|
style: crumbStyle,
|
|
title: crumb.path,
|
|
disabled: blocked || state.loading,
|
|
onClick: () => model.open(crumb.path),
|
|
},
|
|
crumb.name,
|
|
),
|
|
)
|
|
}
|
|
|
|
const status =
|
|
state.error !== ''
|
|
? h('p', { key: 'status', style: pickerErrorStyle }, state.error)
|
|
: state.loading
|
|
? h('p', { key: 'status', style: pickerStatusStyle }, 'Listing\u2026')
|
|
: listing === null
|
|
? h('p', { key: 'status', style: pickerStatusStyle }, 'Type a folder to start from.')
|
|
: entries.length === 0
|
|
? h('p', { key: 'status', style: pickerStatusStyle }, 'Nothing in here.')
|
|
: listing.truncated === true
|
|
? h('p', { key: 'status', style: pickerStatusStyle }, 'Too many entries to list; only the beginning is shown.')
|
|
: // Above the session folder the Host's file listing stops — it is
|
|
// confined to the workspace — so the rows become folders only.
|
|
// Saying so is better than letting file rows silently vanish.
|
|
target === 'extra' && listing.files !== true
|
|
? h(
|
|
'p',
|
|
{ key: 'status', style: pickerStatusStyle },
|
|
'Files are listed inside the session folder only; above it the list is folders.',
|
|
)
|
|
: null
|
|
|
|
return h(Modal, {
|
|
open: true,
|
|
onClose,
|
|
title: target === 'focus' ? 'Choose the focus folder' : 'Add a path or a file',
|
|
closeLabel: 'Close',
|
|
className: PICKER_CLASS,
|
|
// One scroll region around everything but the footer, so the card is
|
|
// bounded on a short viewport and the action stays reachable. The
|
|
// primitive's card is content-sized with `overflow: hidden`, so a fixed
|
|
// footer is the only way to keep it in view without reaching into the
|
|
// primitive's own class names.
|
|
children: h(
|
|
'div',
|
|
{ style: pickerBodyStyle },
|
|
h(
|
|
'div',
|
|
{ key: 'path' },
|
|
h('label', { style: labelStyle, htmlFor: 'facet-picker-path' }, target === 'focus' ? 'Folder' : 'Path or file'),
|
|
h('input', {
|
|
id: 'facet-picker-path',
|
|
type: 'text',
|
|
style: { ...controlStyle, width: '100%' },
|
|
value: typed,
|
|
disabled: blocked,
|
|
spellCheck: false,
|
|
// The listing carries the files it can reach; the box is still how a
|
|
// path outside the session's workspace — or a folder the session
|
|
// will create — is named at all.
|
|
'data-modal-autofocus': true,
|
|
placeholder: root === '' ? absoluteFrom(root, 'src/shared') : 'relative/to/the/session/folder',
|
|
onChange: (event) => model.setDraft(event.target.value),
|
|
onKeyDown: (event) => {
|
|
if (event.key !== 'Enter') return
|
|
event.preventDefault()
|
|
model.open(typed)
|
|
},
|
|
}),
|
|
h(
|
|
'p',
|
|
{ style: hintStyle },
|
|
target === 'focus'
|
|
? 'Relative to the session folder, or absolute. Enter lists whatever is there.'
|
|
: // Files where the Host lists them, folders everywhere. The box is
|
|
// still where a path the listing cannot reach gets named, so both
|
|
// halves of that are said rather than left to be discovered.
|
|
'Relative to the session folder, or absolute. Folders open; a file is taken as the path to add.',
|
|
),
|
|
),
|
|
h(
|
|
'div',
|
|
{
|
|
key: 'trail',
|
|
style: {
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: '2px',
|
|
minHeight: '22px',
|
|
marginTop: '12px',
|
|
overflowX: 'auto',
|
|
},
|
|
},
|
|
trail.length === 0 ? null : trail,
|
|
),
|
|
h('div', { key: 'level', style: pickerLevelStyle }, rows),
|
|
status,
|
|
),
|
|
footer: [
|
|
// Kept beside the action rather than under the list, the way the
|
|
// host's own browser does it: one control, always in view.
|
|
h(
|
|
'label',
|
|
{ key: 'hidden', style: toggleStyle },
|
|
h('input', {
|
|
type: 'checkbox',
|
|
checked: state.showHidden,
|
|
disabled: blocked,
|
|
onChange: (event) => model.setShowHidden(event.target.checked),
|
|
}),
|
|
'Show hidden folders',
|
|
),
|
|
h('span', { key: 'gap', style: { flex: 1 } }),
|
|
h('button', { key: 'cancel', type: 'button', style: quietButtonStyle, onClick: onClose }, 'Cancel'),
|
|
h(
|
|
'button',
|
|
{
|
|
key: 'pick',
|
|
type: 'button',
|
|
style: { ...quietButtonStyle, color: 'var(--dsw-alias-label-primary)', borderColor: 'var(--dsw-alias-border-l2)' },
|
|
// An extra that resolves to the session folder would add the root
|
|
// to a list of things beyond it; the focus may say exactly that,
|
|
// which is how it spells "the session folder itself".
|
|
disabled: blocked || typed.trim() === '' || (target === 'extra' && declared === ''),
|
|
// The declared form, not the absolute one: the session folder is
|
|
// where this conversation lives, and a path that says so keeps its
|
|
// meaning if the workspace moves.
|
|
onClick: () => onPick(declared),
|
|
},
|
|
target === 'focus' ? 'Set focus' : 'Add',
|
|
),
|
|
],
|
|
})
|
|
}
|
|
|
|
/** The crosshair that marks the button: a scope, not a setting. */
|
|
const CONTEXT_GLYPH = h(
|
|
'svg',
|
|
{ width: 15, height: 15, viewBox: '0 0 16 16', fill: 'none', 'aria-hidden': 'true' },
|
|
h('circle', { cx: 8, cy: 8, r: 3.2, stroke: 'currentColor', strokeWidth: 1.3 }),
|
|
h('path', {
|
|
d: 'M8 1.2v2.6M8 12.2v2.6M1.2 8h2.6M12.2 8h2.6',
|
|
stroke: 'currentColor',
|
|
strokeWidth: 1.3,
|
|
strokeLinecap: 'round',
|
|
}),
|
|
)
|
|
|
|
/** The panel's own scroll region: the card is wide, not tall. */
|
|
const panelBodyStyle = { maxHeight: 'min(62vh, 540px)', overflowY: 'auto', paddingRight: '4px' }
|
|
/**
|
|
* Two columns on a wide card, one on a narrow one. `auto-fit` is the whole
|
|
* responsive story: at 380px — the width this card used to be — the second
|
|
* track does not fit and every cell takes the full row.
|
|
*/
|
|
const panelGridStyle = {
|
|
display: 'grid',
|
|
gridTemplateColumns: 'repeat(auto-fit, minmax(300px, 1fr))',
|
|
gap: '28px',
|
|
alignItems: 'start',
|
|
}
|
|
|
|
/** The focus, shown as the declared path rather than as an editable box. */
|
|
const focusPathStyle = {
|
|
flex: 1,
|
|
minWidth: 0,
|
|
boxSizing: 'border-box',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
height: '32px',
|
|
padding: '0 10px',
|
|
border: '0.5px solid var(--dsw-alias-border-l4)',
|
|
borderRadius: 'var(--dsw-radius-md)',
|
|
background: 'var(--dsw-alias-bg-layer-3)',
|
|
fontFamily: 'var(--dsw-font-mono, ui-monospace, monospace)',
|
|
fontSize: '12.5px',
|
|
color: 'var(--dsw-alias-label-primary)',
|
|
overflow: 'hidden',
|
|
textOverflow: 'ellipsis',
|
|
whiteSpace: 'nowrap',
|
|
}
|
|
const focusEmptyPathStyle = { ...focusPathStyle, fontFamily: 'inherit', color: 'var(--dsw-alias-label-tertiary)' }
|
|
|
|
/**
|
|
* One folded-away reference section.
|
|
*
|
|
* The guidance used to sit under the controls at full length, which made a
|
|
* panel that is mostly prose and filled the viewport. Folded, it is still
|
|
* there for the session that needs it and costs two lines otherwise.
|
|
*/
|
|
function disclosure(summary, content) {
|
|
return h(
|
|
'details',
|
|
{ style: { marginTop: '18px' } },
|
|
h('summary', { style: { ...sectionTitleStyle, cursor: 'pointer', margin: 0 } }, summary),
|
|
h('div', { style: { marginTop: '8px' } }, content),
|
|
)
|
|
}
|
|
|
|
/**
|
|
* The file lister an opening should offer, if any.
|
|
*
|
|
* Files are what an extra may name, so that is the only control which offers
|
|
* them: a focus is a folder, and a listing that could hand the picker a file
|
|
* would only offer something that cannot be chosen. A deployment without the
|
|
* file service gets the folder listing as before, which is why this asks
|
|
* rather than assumes.
|
|
*
|
|
* @param {object} browse - the browsing face from {@link directoryBrowse}.
|
|
* @param {string} target - 'focus' or 'extra'.
|
|
* @returns {Function|undefined} the lister, or nothing to offer.
|
|
*/
|
|
function fileLister(browse, target) {
|
|
if (target !== 'extra') return undefined
|
|
return browse?.files?.available?.() === true ? browse.files.list : undefined
|
|
}
|
|
|
|
/**
|
|
* The facet context panel: what this conversation may see, and who decides.
|
|
*
|
|
* This is the only surface that writes per-session scope. The agent has no
|
|
* write path to it — not a narrow one, none — which is what makes "the user
|
|
* decides what is in scope" a property of the system rather than of the
|
|
* agent's instructions.
|
|
*
|
|
* Both scope controls are the same directory listing rather than two text
|
|
* boxes. The panel's vocabulary is paths relative to the session folder, and
|
|
* "which folder is `../shared`" is exactly the question a free-text box
|
|
* cannot answer; the listing answers it from the Host's own directory rather
|
|
* than from a guess about what the path was meant to name. The path field is
|
|
* still editable inside the picker, for a path no listing reaches: a folder
|
|
* the session will create, or a file above the session's workspace.
|
|
*
|
|
* @param {object} props - the bound form, the session, its folder, the browse face, and the close command.
|
|
*/
|
|
function FacetContextPanel(props) {
|
|
const { form, sessionId, mode, onClose, browse } = props
|
|
|
|
// `form` is the settings document for `facet-policy`, already a store: the
|
|
// panel renders whatever the Host last accepted, including a write from
|
|
// another tab.
|
|
const snapshot = react.useSyncExternalStore(
|
|
(listener) => form.subscribe(listener),
|
|
() => form.getSnapshot(),
|
|
)
|
|
const declared = declaredFor(snapshot, sessionId)
|
|
const [picker, setPicker] = react.useState(null)
|
|
const [failure, setFailure] = react.useState('')
|
|
const disabled = snapshot.writable !== true
|
|
|
|
// The session's own folder: what every relative path in this panel is
|
|
// relative to, and where a listing starts. Absent on a session whose
|
|
// summary has not arrived, in which case the Host's home directory stands
|
|
// in and every path is declared absolute.
|
|
const root = typeof props.cwd === 'string' ? props.cwd : ''
|
|
// Asked during render rather than once at registration: the service may
|
|
// activate after this bundle does, and a frozen `false` would hide the
|
|
// listing for the rest of the session.
|
|
const canBrowse = browse?.available?.() === true && !disabled
|
|
|
|
// The mode is a fact about the settings document, so it is read from the
|
|
// document rather than looked for among the slot's own props — which
|
|
// never carried it, leaving this line saying "ask" in every deployment.
|
|
const documentMode = snapshot.value?.mode
|
|
const shownMode = typeof mode === 'string' ? mode : typeof documentMode === 'string' ? documentMode : 'ask'
|
|
|
|
const write = (next) => {
|
|
writeDeclaration(form, sessionId, next).then(
|
|
(ok) => setFailure(ok ? '' : 'The deployment did not accept that change.'),
|
|
() => setFailure('The deployment did not accept that change.'),
|
|
)
|
|
}
|
|
|
|
// One model per opening, built at the click rather than at render, so
|
|
// closing and reopening the picker starts from what the Host holds.
|
|
const openPicker = (target) => {
|
|
if (!canBrowse) return
|
|
// The listing opens on the stored focus for the focus control and on the
|
|
// session folder otherwise. A path with nothing to resolve against — a
|
|
// relative declaration and an unknown session folder — is not a starting
|
|
// point at all, so an empty one asks the Host for its home instead.
|
|
const stored = target === 'focus' ? absoluteFrom(root, declared.focus) : root
|
|
setPicker({
|
|
target,
|
|
model: new DirectoryPickerModel({
|
|
folders: browse.list,
|
|
files: fileLister(browse, target),
|
|
sessionId,
|
|
root,
|
|
start: isAbsolutePath(stored) ? stored : '',
|
|
}),
|
|
})
|
|
}
|
|
|
|
const rows = declared.extras.map((path) =>
|
|
h(
|
|
'li',
|
|
{ key: path, style: { display: 'flex', alignItems: 'center', gap: '8px', marginTop: '6px' } },
|
|
h(
|
|
'code',
|
|
// The declared form is what is stored; the title spells out the
|
|
// absolute path it resolves to, which is what the agent will see.
|
|
{ style: { ...listedStyle, margin: 0, flex: 1, wordBreak: 'break-all' }, title: absoluteFrom(root, path) },
|
|
path,
|
|
),
|
|
h(
|
|
'button',
|
|
{
|
|
type: 'button',
|
|
style: { ...quietButtonStyle, height: '26px' },
|
|
disabled,
|
|
onClick: () => write({ ...declared, extras: declared.extras.filter((other) => other !== path) }),
|
|
},
|
|
'Remove',
|
|
),
|
|
),
|
|
)
|
|
|
|
const overlay =
|
|
picker === null
|
|
? null
|
|
: h(DirectoryPicker, {
|
|
model: picker.model,
|
|
root,
|
|
target: picker.target,
|
|
disabled,
|
|
onClose: () => setPicker(null),
|
|
onPick: (path) => {
|
|
setPicker(null)
|
|
// The picker already answers in declared form: an empty string
|
|
// is the session folder itself, which is `focus`'s own default
|
|
// rather than a stored override naming the same place.
|
|
if (picker.target === 'focus') write({ ...declared, focus: path })
|
|
else write({ ...declared, extras: [...declared.extras, path] })
|
|
},
|
|
})
|
|
|
|
return h(
|
|
Modal,
|
|
{
|
|
open: true,
|
|
onClose,
|
|
title: CONTEXT_LABEL,
|
|
closeLabel: 'Close',
|
|
description: `Session ${sessionId}`,
|
|
className: PANEL_CLASS,
|
|
},
|
|
h(
|
|
'div',
|
|
{ style: panelBodyStyle },
|
|
// Two columns, because the panel is now wide: the controls on the
|
|
// left where the eye starts, the reference prose on the right where it
|
|
// reads at a sane line length. `auto-fit` collapses it back to one
|
|
// column on a narrow viewport, so nothing has to know the width.
|
|
h(
|
|
'div',
|
|
{ style: panelGridStyle },
|
|
h(
|
|
'div',
|
|
null,
|
|
h('h3', { style: sectionTitleStyle }, 'Focus'),
|
|
h(
|
|
'div',
|
|
{ style: { display: 'flex', gap: '8px', alignItems: 'center' } },
|
|
h(
|
|
'span',
|
|
{ style: declared.focus === '' ? focusEmptyPathStyle : focusPathStyle, title: absoluteFrom(root, declared.focus) },
|
|
declared.focus === '' ? 'the session folder' : declared.focus,
|
|
),
|
|
h(
|
|
'button',
|
|
{ type: 'button', style: quietButtonStyle, disabled: !canBrowse, onClick: () => openPicker('focus') },
|
|
'Browse\u2026',
|
|
),
|
|
declared.focus === ''
|
|
? null
|
|
: h(
|
|
'button',
|
|
{ type: 'button', style: quietButtonStyle, disabled, onClick: () => write({ ...declared, focus: '' }) },
|
|
'Clear',
|
|
),
|
|
),
|
|
h(
|
|
'p',
|
|
{ style: hintStyle },
|
|
root === ''
|
|
? 'Relative to the session folder, or absolute. Empty means the session folder itself.'
|
|
: `Relative to the session folder (${root}), or absolute. Empty means the session folder itself.`,
|
|
),
|
|
|
|
h('h3', { style: { ...sectionTitleStyle, marginTop: '18px' } }, 'Also allowed'),
|
|
declared.extras.length === 0
|
|
? h('p', { style: bodyStyle }, 'Nothing yet. Shared types, project docs, or a single file go here.')
|
|
: h('ul', { style: { listStyle: 'none', margin: 0, padding: 0 } }, rows),
|
|
h(
|
|
'div',
|
|
{ style: { marginTop: '10px' } },
|
|
h(
|
|
'button',
|
|
{ type: 'button', style: quietButtonStyle, disabled: !canBrowse, onClick: () => openPicker('extra') },
|
|
'+ Add path or file\u2026',
|
|
),
|
|
),
|
|
h(
|
|
'p',
|
|
{ style: bodyStyle },
|
|
'Relative to the session folder, or absolute — anything the session needs beyond the focus.',
|
|
),
|
|
|
|
canBrowse
|
|
? null
|
|
: h(
|
|
'p',
|
|
{ style: bodyStyle },
|
|
'This deployment has no directory browser, so paths cannot be picked here. Standing grants can still be set on the plugin card; a per-session declaration needs a browser.',
|
|
),
|
|
failure === ''
|
|
? null
|
|
: h('p', { style: { ...bodyStyle, color: 'var(--dsw-alias-state-error-primary)' } }, failure),
|
|
h(
|
|
'p',
|
|
{ style: { ...bodyStyle, marginTop: '18px' } },
|
|
`Changes take effect on the next tool call. Deployment mode: ${shownMode}.`,
|
|
),
|
|
),
|
|
|
|
h(
|
|
'div',
|
|
null,
|
|
h('h3', { style: sectionTitleStyle }, 'What this conversation may see'),
|
|
h(
|
|
'p',
|
|
{ style: bodyStyle },
|
|
'The agent works inside the focus folder. Anything it needs beyond that is listed here, and the agent can narrow the focus on its own but can never widen it — only you can, here.',
|
|
),
|
|
disclosure(
|
|
'Mode details',
|
|
h(
|
|
'p',
|
|
{ style: bodyStyle },
|
|
'The mode is set on the plugin card under Plugins, and applies to every session: ' +
|
|
'"ask" puts a call outside the facet to you as a question; "deny" refuses it outright; ' +
|
|
'"off" disables enforcement while leaving the preset mounted. Where the deployment has no approval ' +
|
|
'channel — as here, under the danger-full-access permission preset — "ask" cannot actually reach you, ' +
|
|
'so an out-of-facet call is refused and this panel is the way to widen the scope.',
|
|
),
|
|
),
|
|
disclosure(
|
|
'How to use',
|
|
h(
|
|
'ol',
|
|
{ style: { ...bodyStyle, margin: 0, paddingLeft: '18px' } },
|
|
h('li', { style: { marginBottom: '6px' } }, 'Start the chat in the repository you want to work in.'),
|
|
h('li', { style: { marginBottom: '6px' } }, 'Set Focus to the one folder this conversation is about.'),
|
|
h(
|
|
'li',
|
|
{ style: { marginBottom: '6px' } },
|
|
'Add what it needs beyond that — shared types, project docs, a folder it will create a sibling in.',
|
|
),
|
|
h('li', null, 'Let the agent narrow as the work settles; it will ask you before it needs anything more.'),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
// Portalled by `Modal` itself, so a nested dialog is an ordinary child
|
|
// here: the last dialog in the document owns Escape, which makes the
|
|
// picker close before the panel does.
|
|
overlay,
|
|
)
|
|
}
|
|
|
|
/**
|
|
* The composer tool row's button, and the panel it opens.
|
|
*
|
|
* Present only for a session running the facet preset, which is what "shows
|
|
* up when in facet mode" means mechanically: the session's own projection
|
|
* says which preset it runs, so nothing has to be inferred from the URL or
|
|
* from the plugin being installed.
|
|
*
|
|
* @param {object} props - slot props: the session, `useSessions`, and the form.
|
|
*/
|
|
function FacetContextButton(props) {
|
|
const { sessionId, useSessions, form } = props
|
|
|
|
// Called before the early return, so the hook order is stable across the
|
|
// sessions that do and do not run the facet.
|
|
const preset = useSessions((state) => state.byId[sessionId]?.projectionValues?.agentPreset)
|
|
// The session's own folder, which is what "relative" means in the panel
|
|
// and where its listing starts. Read from the session list rather than
|
|
// looked up from the host, because the list already carries it and a
|
|
// second read could disagree with the row the user picked.
|
|
const cwd = useSessions((state) => state.byId[sessionId]?.cwd)
|
|
const [open, setOpen] = react.useState(false)
|
|
|
|
if (preset !== FACET_PRESET) return null
|
|
|
|
return h(
|
|
react.Fragment,
|
|
null,
|
|
h(
|
|
'button',
|
|
{
|
|
type: 'button',
|
|
'aria-label': CONTEXT_LABEL,
|
|
'aria-expanded': open,
|
|
title: `${CONTEXT_LABEL} — what this conversation may see`,
|
|
onClick: () => setOpen((value) => !value),
|
|
style: {
|
|
display: 'inline-flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
width: '28px',
|
|
height: '28px',
|
|
padding: 0,
|
|
cursor: 'pointer',
|
|
color: open ? 'var(--dsw-alias-label-primary)' : 'var(--dsw-alias-label-secondary)',
|
|
background: open ? 'var(--dsw-alias-interactive-bg-hover)' : 'transparent',
|
|
border: 0,
|
|
borderRadius: 'var(--dsw-radius-sm)',
|
|
},
|
|
},
|
|
CONTEXT_GLYPH,
|
|
),
|
|
// Mounted only while open, so each visit starts from what the Host holds
|
|
// rather than from a draft left over from the last one.
|
|
open ? h(FacetContextPanel, { ...props, form, sessionId, cwd, onClose: () => setOpen(false) }) : null,
|
|
)
|
|
}
|
|
|
|
/**
|
|
* The browsing face the picker drives: two listers, because the Host has two.
|
|
*
|
|
* `uiWorkspace` is read lazily rather than injected. Injecting it would make
|
|
* the whole context button conditional on it, and the panel is still the
|
|
* only way to declare scope in a deployment with no browser — it would just
|
|
* have no listing to offer. `ctx.get` answers `undefined` instead of
|
|
* throwing, which is what makes that a decision rather than a crash.
|
|
*
|
|
* The availability probe is a function rather than a value because the
|
|
* renderer memoizes the registration's inject result: a boolean read there
|
|
* would freeze at whatever the first render saw, and the service may well
|
|
* activate after this bundle does.
|
|
*
|
|
* @param {object} ctx - the client context to resolve the services from.
|
|
* @returns {object} `available()`/`list()` for folders, and the same pair again under `files`.
|
|
*/
|
|
function directoryBrowse(ctx) {
|
|
const workspace = () => ctx.get('uiWorkspace')
|
|
const usable = () => typeof workspace()?.listDirectory === 'function'
|
|
return {
|
|
available: usable,
|
|
list: (path, signal) => {
|
|
const service = workspace()
|
|
if (service === undefined || typeof service.listDirectory !== 'function') {
|
|
return Promise.reject(new Error('This deployment has no directory browser to list with.'))
|
|
}
|
|
return service.listDirectory(path, signal)
|
|
},
|
|
// Folders only, anywhere on the machine. The file lister below is what
|
|
// makes an extra selectable by name; this one is what makes it
|
|
// reachable at all, and the two are needed together.
|
|
files: {
|
|
available: () => typeof ctx.get('remote.workspaceFiles')?.list === 'function',
|
|
list: (sessionId, path, signal) => {
|
|
const server = ctx.get('remote.workspaceFiles')
|
|
if (server === undefined || typeof server.list !== 'function') {
|
|
return Promise.reject(new Error('This deployment cannot list files.'))
|
|
}
|
|
// The Host confines this to the Session's workspace, which is why the
|
|
// model falls back to the folder listing above the session folder.
|
|
return server.list(sessionId, path, signal).then((result) => {
|
|
if (result?.ok !== true) throw result?.error ?? new Error('The Host refused that listing.')
|
|
return result.value
|
|
})
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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 form = scoped.configForms.get(SETTINGS_ENTRY)
|
|
const card = new FacetCardController(form)
|
|
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',
|
|
)
|
|
|
|
// The context button rides the same form, so the panel and the plugin
|
|
// card can never disagree about what the document holds.
|
|
scoped.effect(
|
|
() =>
|
|
scoped.configForms.whileServed([SETTINGS_ENTRY], () =>
|
|
scoped.slots.inject('conversation.input.left', () =>
|
|
scoped.slots.register(
|
|
{
|
|
name: 'conversation.input.left',
|
|
id: CONTEXT_ENTRY_ID,
|
|
order: 20,
|
|
inject: () => ({ form, browse: directoryBrowse(ctx) }),
|
|
},
|
|
FacetContextButton,
|
|
),
|
|
),
|
|
),
|
|
'dsh-facet: context button',
|
|
)
|
|
})
|
|
}
|
|
|
|
module.exports = {
|
|
name: 'dsh-facet',
|
|
inject: ['slots'],
|
|
apply,
|
|
SETTINGS_ENTRY,
|
|
BUNDLE_CONFIG_KEY,
|
|
FACET_PRESET,
|
|
CONTEXT_ENTRY_ID,
|
|
PANEL_CLASS,
|
|
PICKER_CLASS,
|
|
ROW_CLASS,
|
|
FacetCard,
|
|
FacetCardController,
|
|
FacetContextButton,
|
|
FacetContextPanel,
|
|
DirectoryPicker,
|
|
DirectoryPickerModel,
|
|
declaredFor,
|
|
writeDeclaration,
|
|
absoluteFrom,
|
|
relativeTo,
|
|
isAbsolutePath,
|
|
withinPath,
|
|
joinUnder,
|
|
trailTo,
|
|
folderListing,
|
|
workspaceListing,
|
|
directoryBrowse,
|
|
fileLister,
|
|
}
|
|
return module.exports
|
|
},
|
|
})
|