/** * 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} 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} options.folders - the folder listing; browses anywhere. * @param {(sessionId: string, path: string, signal?: AbortSignal) => Promise} [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 }, })