diff --git a/README.md b/README.md index c0a8f90..0099704 100644 --- a/README.md +++ b/README.md @@ -152,6 +152,17 @@ notes that the preset picker's guide dialog cannot show for a preset no core package ships. Writes go to the profile patch, so reopening a chat gives back the scope it had. +Both controls are a **directory listing**, not a text box: the panel's vocabulary +is paths relative to the session folder, and "which folder is `../shared`" is the +one question free text cannot answer. Pressing it opens a second dialog over the +panel, shaped like the host's own workspace browser — one level of child folders, +a breadcrumb trail, a hidden-entries toggle — driven through +`uiWorkspace.listDirectory`, the same Host primitive that browser uses. A pick is +stored in the form that keeps its meaning: `/work/root/src/shared` becomes +`src/shared` when the session started in `/work/root`. The picker's own path field +stays editable, because the Host lists folders and an extra may legitimately name +a file. + Because the panel owns widening, `facet action=add` is the *other* path and the narrower one: it is gated on an approval channel, and a deployment with none — the `danger-full-access` permission preset sets `approval: never` — refuses it before @@ -297,7 +308,7 @@ lib/scope.js the pure half — argument extraction, text, reason lib/guard.js preset row: rule, binding, tool, pre-execute guard presets/facet.patch.yml the `facet` agent preset presets/standard-compaction.patch.yml optional: the same budget for `standard` -test/ 73 tests +test/ 78 tests ``` `client/client.js` is a browser half rather than a module the host imports, and @@ -322,6 +333,26 @@ refuses a path that is not under a volatile node, and refusing is exactly right the panel is the only writer, and a plugin that could not serve the namespace would be a control that silently stored nothing. +The listing reaches the panel through `ctx.get('uiWorkspace')` rather than through +an injection, and the injection's `available()` is asked on every render. Both are +deliberate: `ctx.inject` would make the whole context button conditional on a +service the plugin does not own, and the panel is still the only way to declare +scope in a deployment with no directory browser — it would simply have no listing +to offer, which is what it says instead. The service is resolved at the call +because the renderer memoizes a registration's inject result, so a boolean read +there would freeze at whatever the first render saw. + +The two dialogs are wider than the primitive's 380px card, which is fixed in the +primitives' own stylesheet. `client/client.js` installs one small stylesheet of +its own — the widths and a row hover — and passes each card a class through +`Modal`'s `className`. Every selector in it doubles the class it names, so it +outranks the single hashed class the primitive applies whatever order the two +stylesheets land in; there is no `!important` and nothing reaching into a class +name this bundle does not own. The panel's body is a two-column grid that +`auto-fit` collapses to one on a narrow card, and each dialog scrolls a bounded +body rather than growing past the viewport — the panel used to be a tall single +column of prose on a 380px card, which is what it looked like. + ## Also in this repository - [`facet-preset.md`](../facet-preset.md) — the design note this was built from, diff --git a/client/client.js b/client/client.js index 812ef4c..7171f0c 100644 --- a/client/client.js +++ b/client/client.js @@ -39,7 +39,14 @@ window.__ModuleLoader__.load({ const react = require('react') const primitives = require('@deepseek-ai/dsh-client-ui-primitives') - const { Modal, SegmentedControl, SettingsForm, SettingsFormModel, SettingsValueField } = primitives + const { + Modal, + SegmentedControl, + SettingsForm, + SettingsFormModel, + SettingsValueField, + IconFolderCloseRegular, + } = primitives const h = react.createElement @@ -326,6 +333,443 @@ window.__ModuleLoader__.load({ 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. + */ + 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) + } + + /** + * 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. + */ + var DirectoryPickerModel = class { + /** + * @param {object} options + * @param {(path?: string, signal?: AbortSignal) => Promise} options.list - the host listing call. + * @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.list = options.list + 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 }) + Promise.resolve() + .then(() => this.list(target === '' ? undefined : target)) + .then( + (listing) => { + if (request !== this.request) return + this.update({ listing, loading: false, error: '', draft: String(listing?.path ?? target) }) + }, + (reason) => { + if (request !== this.request) 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)' } + 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 child folders. + * + * 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) + + const rows = entries.map((entry) => + h( + 'button', + { + key: entry.path, + type: 'button', + className: ROW_CLASS, + style: pickerRowStyle, + title: entry.path, + disabled: blocked || state.loading, + // A click walks down rather than picking: the primary action is the + // path field's own value, so a folder is chosen by standing in it. + onClick: () => model.open(entry.path), + }, + h('span', { style: pickerGlyphStyle }, h(IconFolderCloseRegular, { size: 14 })), + 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), + }, + // A root crumb carries its whole path as its name; "Home" is the + // shorter label the host's own browser gives it, and the title + // attribute still names where it is. + crumb.path === listing.home ? 'Home' : 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 }, 'No folders in here.') + : listing.truncated === true + ? h('p', { key: 'status', style: pickerStatusStyle }, 'Too many folders to list; only the beginning is shown.') + : null + + return h(Modal, { + open: true, + onClose, + title: target === 'focus' ? 'Choose the focus folder' : 'Add a folder or a path', + 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'), + h('input', { + id: 'facet-picker-path', + type: 'text', + style: { ...controlStyle, width: '100%' }, + value: typed, + disabled: blocked, + spellCheck: false, + // The Host lists folders, so this box is also how a file — 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 }, 'Relative to the session folder, or absolute. Enter lists whatever is there.'), + ), + 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', @@ -339,6 +783,57 @@ window.__ModuleLoader__.load({ }), ) + /** 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 facet context panel: what this conversation may see, and who decides. * @@ -347,10 +842,18 @@ window.__ModuleLoader__.load({ * decides what is in scope" a property of the system rather than of the * agent's instructions. * - * @param {object} props - the bound form, the session, and the close command. + * 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, because the Host lists folders and the + * extras may name a file. + * + * @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 } = 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 @@ -360,11 +863,26 @@ window.__ModuleLoader__.load({ () => form.getSnapshot(), ) const declared = declaredFor(snapshot, sessionId) - const [focusDraft, setFocusDraft] = react.useState(declared.focus) - const [extraDraft, setExtraDraft] = react.useState('') + 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.'), @@ -372,11 +890,36 @@ window.__ModuleLoader__.load({ ) } + // 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({ + list: browse.list, + 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', { style: { ...listedStyle, margin: 0, flex: 1, wordBreak: 'break-all' } }, path), + 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', { @@ -390,130 +933,157 @@ window.__ModuleLoader__.load({ ), ) + 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}` }, + { + open: true, + onClose, + title: CONTEXT_LABEL, + closeLabel: 'Close', + description: `Session ${sessionId}`, + className: PANEL_CLASS, + }, 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 below, and the agent can narrow the focus on its own but can never widen it — only you can, here.', - ), - - h('label', { style: sectionTitleStyle, htmlFor: 'facet-focus' }, 'Focus'), + { 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: { display: 'flex', gap: '8px' } }, - h('input', { - id: 'facet-focus', - type: 'text', - style: { ...controlStyle, flex: 1 }, - placeholder: 'the folder the session started in', - value: focusDraft, - disabled, - 'data-modal-autofocus': true, - onChange: (event) => setFocusDraft(event.target.value), - onKeyDown: (event) => { - if (event.key !== 'Enter') return - event.preventDefault() - write({ ...declared, focus: focusDraft }) - }, - }), + { style: panelGridStyle }, h( - 'button', - { type: 'button', style: quietButtonStyle, disabled, onClick: () => write({ ...declared, focus: focusDraft }) }, - 'Set', - ), - h( - 'button', - { - type: 'button', - style: quietButtonStyle, - disabled, - onClick: () => { - setFocusDraft('') - write({ ...declared, focus: '' }) - }, - }, - 'Clear', - ), - ), - h('p', { style: bodyStyle }, 'Relative to the session folder, or absolute. Empty means the session folder itself.'), + '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 sibling folder go here.') - : h('ul', { style: { listStyle: 'none', margin: 0, padding: 0 } }, rows), - h( - 'div', - { style: { display: 'flex', gap: '8px', marginTop: '10px' } }, - h('input', { - id: 'facet-extra', - type: 'text', - style: { ...controlStyle, flex: 1 }, - placeholder: 'src/shared, ../../CONTROLLER.md', - value: extraDraft, - disabled, - onChange: (event) => setExtraDraft(event.target.value), - onKeyDown: (event) => { - if (event.key !== 'Enter') return - event.preventDefault() - if (extraDraft.trim() === '') return - write({ ...declared, extras: [...declared.extras, extraDraft] }) - setExtraDraft('') - }, - }), - h( - 'button', - { - type: 'button', - style: quietButtonStyle, - disabled, - onClick: () => { - if (extraDraft.trim() === '') return - write({ ...declared, extras: [...declared.extras, extraDraft] }) - setExtraDraft('') - }, - }, - 'Add', - ), - ), - h('p', { style: bodyStyle }, 'Relative to the session folder, or absolute — anything the session needs beyond the focus.'), + h('h3', { style: { ...sectionTitleStyle, marginTop: '18px' } }, 'Also allowed'), + declared.extras.length === 0 + ? h('p', { style: bodyStyle }, 'Nothing yet. Shared types, project docs, or a sibling folder 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 folder or path\u2026', + ), + ), + h( + 'p', + { style: bodyStyle }, + 'Relative to the session folder, or absolute — anything the session needs beyond the focus.', + ), - 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: ${mode ?? 'ask'}.`, - ), + 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('h3', { style: { ...sectionTitleStyle, marginTop: '18px' } }, '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.', - ), - h('h3', { style: sectionTitleStyle }, 'How to use'), - h( - 'ol', - { style: { ...bodyStyle, 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.', + '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.'), + ), + ), ), - 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, ) } @@ -533,6 +1103,11 @@ window.__ModuleLoader__.load({ // 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 @@ -566,10 +1141,42 @@ window.__ModuleLoader__.load({ ), // 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, onClose: () => setOpen(false) }) : null, + open ? h(FacetContextPanel, { ...props, form, sessionId, cwd, onClose: () => setOpen(false) }) : null, ) } + /** + * The directory-browsing face the picker drives. + * + * `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 service from. + * @returns {object} `available()` and `list()`, the two calls the panel makes. + */ + 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) + }, + } + } + /** * Mount the facet settings card while the Host serves its namespace. * @@ -617,7 +1224,7 @@ window.__ModuleLoader__.load({ name: 'conversation.input.left', id: CONTEXT_ENTRY_ID, order: 20, - inject: () => ({ form }), + inject: () => ({ form, browse: directoryBrowse(ctx) }), }, FacetContextButton, ), @@ -636,12 +1243,21 @@ window.__ModuleLoader__.load({ 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, + directoryBrowse, } return module.exports }, diff --git a/package.json b/package.json index f613b13..76d0d34 100644 --- a/package.json +++ b/package.json @@ -50,7 +50,8 @@ "inject": [ "@deepseek-ai/dsh-client-ui-settings", "@deepseek-ai/dsh-client-ui-plugin-manager", - "@deepseek-ai/dsh-client-ui-conversation" + "@deepseek-ai/dsh-client-ui-conversation", + "@deepseek-ai/dsh-client-ui-workspace" ] } }, diff --git a/test/client.test.js b/test/client.test.js index 6166710..4827694 100644 --- a/test/client.test.js +++ b/test/client.test.js @@ -35,6 +35,9 @@ async function loadClient() { SettingsForm: 'SettingsForm', SettingsValueField: 'SettingsValueField', SegmentedControl: 'SegmentedControl', + // The listing's row glyph; a client half may only use what the primitives + // actually export, so the stand-in is named the same way. + IconFolderCloseRegular: 'IconFolderCloseRegular', /** Stands in for the real model: records the specs and answers with the value. */ SettingsFormModel: class { constructor(scope, specs) { @@ -84,14 +87,21 @@ async function loadClient() { } /** Drive `apply` against a fake slot/configForms surface and report what it did. */ -function drive(mod, { served = true, value = {} } = {}) { +function drive(mod, { served = true, value = {}, workspace } = {}) { const seen = { formNs: undefined, watched: undefined, registrations: [] } const disposers = [] let currentSlot + // The optional service the context panel browses through. `get` is the + // Cordis read that answers `undefined` rather than throwing, which is what + // the plugin relies on to keep the panel in a deployment with no browser. + const get = (name) => (name === 'uiWorkspace' ? workspace : undefined) + const ctx = { + get, inject: (names, scope) => { const scoped = { + get, configForms: { get: (ns) => { seen.formNs = ns @@ -170,8 +180,10 @@ test('the context button rides the composer tool row, gated on the same namespac assert.equal(button.options.id, 'facet-context') assert.equal(typeof button.options.inject, 'function') // It has to receive the settings form, because the panel writes through the - // very same document the plugin card does. + // very same document the plugin card does — and the browse face, because the + // panel's two controls are directory listings. assert.equal(typeof button.options.inject().form, 'object') + assert.equal(typeof button.options.inject().browse.list, 'function') }) test('the host row the form binds to is the one cordis.patch.yml actually declares', async () => { @@ -291,6 +303,10 @@ test('the client half loads the packages whose slots it registers into', async ( assert.ok(inject.includes('@deepseek-ai/dsh-client-ui-plugin-manager'), 'registers into plugins.bundle.config') assert.ok(inject.includes('@deepseek-ai/dsh-client-ui-conversation'), 'registers into conversation.input.left') assert.ok(inject.includes('@deepseek-ai/dsh-client-ui-settings'), 'reads configForms') + // Not a slot this half registers into: the panel's directory listing drives + // `uiWorkspace.listDirectory`, so the package that provides it has to be in + // the boot graph before this half asks for it. + assert.ok(inject.includes('@deepseek-ai/dsh-client-ui-workspace'), 'browses through uiWorkspace') }) // ── the context button and panel ───────────────────────────────────────────── @@ -364,31 +380,32 @@ test('the panel renders the scope, the guidance, and a way to add and remove', a mutate: (ops) => (written.push(ops), true), } - const tree = mod.FacetContextPanel({ form, sessionId: 'session-1', mode: 'deny', onClose: () => {} }) + const tree = mod.FacetContextPanel({ + form, + sessionId: 'session-1', + cwd: '/work/root', + browse: { available: () => true, list: () => Promise.resolve() }, + onClose: () => {}, + }) assert.equal(tree.type, 'Modal') + // The class is the whole width fix: the primitive's own card is 380px and it + // takes only a class name, so the stylesheet this bundle installs outranks it. + assert.equal(tree.props.className, mod.PANEL_CLASS) // Walk the tree for the text and controls a user would see. - const texts = [] - const buttons = [] + const texts = textsOf(tree) + const buttons = buttonsOf(tree) const inputs = [] - const walk = (node) => { - if (typeof node === 'string') return texts.push(node) - if (node === null || node === undefined || typeof node !== 'object') return - if (Array.isArray(node)) return node.forEach(walk) - if (node.type === 'button') buttons.push(node) + walkTree(tree, (node) => { if (node.type === 'input') inputs.push(node) - // Children arrive either as createElement arguments or, as the card does it, - // through the `children` prop. - if (Array.isArray(node.children)) node.children.forEach(walk) - walk(node.props?.children) - } - walk(tree) + }) const labelOf = (node) => (node.children ?? [])[0] const prose = texts.join('\n') assert.match(prose, /What this conversation may see/) - // The two tabs the preset picker cannot show for a custom preset live here. + // The two tabs the preset picker cannot show for a custom preset live here, + // folded away so the controls are not buried under them. assert.match(prose, /Mode details/) assert.match(prose, /How to use/) // The honest bit about this deployment, so the panel does not promise a @@ -397,9 +414,250 @@ test('the panel renders the scope, the guidance, and a way to add and remove', a assert.match(prose, /danger-full-access/) // The declared scope is on screen, and the existing extra has its own control. - assert.equal(inputs[0].props.value, 'src/room') + assert.ok(texts.includes('src/room'), 'the focus is shown as the declared path') + assert.ok(texts.some((text) => text.includes('/work/root')), 'the session folder relative to what is named') assert.equal(buttons.filter((button) => labelOf(button) === 'Remove').length, 1) - assert.ok(buttons.some((button) => labelOf(button) === 'Add')) + + // Both controls are the listing. `Set` is gone with the box it committed: + // picking writes the declaration straight away, as every other control here + // already did. + assert.ok(buttons.some((button) => labelOf(button) === 'Browse\u2026')) assert.ok(buttons.some((button) => labelOf(button) === 'Clear')) - assert.ok(buttons.some((button) => labelOf(button) === 'Set')) + assert.ok(buttons.some((button) => labelOf(button) === '+ Add folder or path\u2026')) + assert.ok(!buttons.some((button) => labelOf(button) === 'Set')) + assert.deepEqual(inputs, [], 'the panel itself has no free-text box left') }) + +test('a deployment with no directory browser keeps the panel and says so', async () => { + const { module: mod } = await loadClient() + const form = { + getSnapshot: () => ({ status: 'ready', writable: true, revision: 1, value: { mode: 'deny', scopes: {} } }), + subscribe: () => () => {}, + mutate: () => true, + } + + const tree = mod.FacetContextPanel({ + form, + sessionId: 's', + cwd: '', + browse: mod.directoryBrowse({ get: () => undefined }), + onClose: () => {}, + }) + const texts = textsOf(tree) + const label = (button) => (button.children ?? [])[0] + + // The panel is the only way to declare scope, so it renders without a + // browser: the listing affordances go dead, the explanation stays. + assert.deepEqual( + buttonsOf(tree) + .filter((button) => label(button) === 'Browse\u2026') + .map((button) => button.props.disabled), + [true], + ) + assert.deepEqual( + buttonsOf(tree) + .filter((button) => label(button) === '+ Add folder or path\u2026') + .map((button) => button.props.disabled), + [true], + ) + assert.ok(texts.some((text) => text.includes('no directory browser'))) + // The mode is read from the document rather than from a slot prop that never + // carried it, so the line does not claim "ask" whatever the setting is. + assert.ok(texts.some((text) => text.includes('Deployment mode: deny'))) +}) + +// ── the directory listing ──────────────────────────────────────────────────── + +/** A listing shaped the way the Host's browse primitive answers. */ +function listingFixture() { + return { + path: '/work/root/src', + home: '/home/dev', + crumbs: [ + { name: '/', path: '/', hidden: false }, + { name: 'work', path: '/work', hidden: false }, + { name: 'root', path: '/work/root', hidden: false }, + { name: 'src', path: '/work/root/src', hidden: false }, + ], + entries: [ + { name: 'shared', path: '/work/root/src/shared', hidden: false }, + { name: '.git', path: '/work/root/src/.git', hidden: true }, + ], + truncated: false, + } +} + +/** Let the model's own promise chain settle. */ +function settle() { + return new Promise((resolve) => setTimeout(resolve, 0)) +} + +/** Every button in a rendered tree, in document order. */ +function buttonsOf(tree) { + const found = [] + walkTree(tree, (node) => { + if (node.type === 'button') found.push(node) + }) + return found +} + +/** Every string in a rendered tree, in document order. */ +function textsOf(tree) { + const found = [] + walkTree(tree, (node) => { + if (typeof node === 'string') found.push(node) + }) + return found +} + +/** Walk a rendered tree the way it was built: children as arguments or as a prop. */ +function walkTree(node, visit) { + if (typeof node === 'string') return visit(node) + if (node === null || node === undefined || typeof node !== 'object') return + if (Array.isArray(node)) { + for (const child of node) walkTree(child, visit) + return + } + visit(node) + if (Array.isArray(node.children)) node.children.forEach((child) => walkTree(child, visit)) + walkTree(node.props?.children, visit) +} + +test('a path is declared relative to the session folder wherever it can be', async () => { + const { module: mod } = await loadClient() + + assert.equal(mod.relativeTo('/work/root', '/work/root'), '', 'the session folder itself has no name of its own') + assert.equal(mod.relativeTo('/work/root', '/work/root/src/shared'), 'src/shared') + assert.equal(mod.relativeTo('/work/root/dsh-facet', '/work/root'), '..') + assert.equal(mod.relativeTo('/work/root/dsh-facet/lib', '/work/root'), '../..') + // Nothing shared: another tree, or another drive. Absolute is the only + // spelling that still names it. + assert.equal(mod.relativeTo('/work/root', '/srv/other'), '/srv/other') + assert.equal(mod.relativeTo('C:\\work\\root', 'D:\\work\\root'), 'D:\\work\\root') + assert.equal(mod.relativeTo('', '/srv/other'), '/srv/other') + // Windows separators split and join like POSIX ones. + assert.equal(mod.relativeTo('C:\\work\\root', 'C:\\work\\other'), '../other') + + assert.equal(mod.absoluteFrom('/work/root', 'src/shared'), '/work/root/src/shared') + assert.equal(mod.absoluteFrom('/work/root', '/srv/other'), '/srv/other') + assert.equal(mod.absoluteFrom('/work/root', ''), '', 'empty stays empty: it means the session folder') + assert.equal(mod.absoluteFrom('', 'src/shared'), 'src/shared', 'with no folder to join, a relative path is left alone') + assert.equal(mod.isAbsolutePath('C:\\x'), true) + assert.equal(mod.isAbsolutePath('\\\\server\\share'), true) + assert.equal(mod.isAbsolutePath('src/x'), false) +}) + +test('the browse face is read from uiWorkspace at the call, not at registration', async () => { + const { module: mod } = await loadClient() + + // Absent service: the probe answers false and the call rejects with something + // the picker can show rather than a TypeError from an undefined property. + const missing = mod.directoryBrowse({ get: () => undefined }) + assert.equal(missing.available(), false) + await assert.rejects(missing.list('/tmp'), /no directory browser/) + + const calls = [] + const present = mod.directoryBrowse({ + get: () => ({ + listDirectory: (path, signal) => { + calls.push([path, signal]) + return Promise.resolve('listing') + }, + }), + }) + assert.equal(present.available(), true) + assert.equal(await present.list('/tmp', 'signal'), 'listing') + assert.deepEqual(calls, [['/tmp', 'signal']]) +}) + +test('the picker model lists a level, supersedes a stale one, and keeps a typed path on failure', async () => { + const { module: mod } = await loadClient() + + const asked = [] + const model = new mod.DirectoryPickerModel({ + list: (path) => { + asked.push(path) + return Promise.resolve(listingFixture()) + }, + root: '/work/root', + start: '/work/root/src', + }) + await settle() + + assert.deepEqual(asked, ['/work/root/src'], 'the opening level is listed, not guessed at') + assert.equal(model.getSnapshot().listing.path, '/work/root/src') + assert.equal(model.getSnapshot().draft, '/work/root/src', 'the field follows a successful listing') + assert.equal(model.getSnapshot().loading, false) + + // A second navigation wins even when the first is still in flight: the + // sequence number is what stops an early listing landing on a later view. + model.open('/work/root/other') + model.setDraft('notes/CONTROLLER.md') + + // A failure keeps the level on screen and the path as typed: the Host lists + // folders, and the extras legitimately name a file. + const failing = new mod.DirectoryPickerModel({ + list: () => Promise.reject(Object.assign(new Error('transport'), { rpcError: { message: 'directory-unreadable' } })), + root: '/work/root', + start: 'notes/CONTROLLER.md', + }) + await settle() + assert.equal(failing.getSnapshot().error, 'directory-unreadable', 'the Host business message is preferred') + assert.equal(failing.getSnapshot().draft, 'notes/CONTROLLER.md') +}) + +test('the picker lists folders, hides hidden ones until asked, and answers in declared form', async () => { + const { module: mod } = await loadClient() + + const model = new mod.DirectoryPickerModel({ + list: () => Promise.resolve(listingFixture()), + root: '/work/root', + start: '/work/root/src', + }) + await settle() + + const picked = [] + const render = () => + mod.DirectoryPicker({ + model, + root: '/work/root', + target: 'focus', + disabled: false, + onClose: () => {}, + onPick: (path) => picked.push(path), + }) + + const tree = render() + assert.equal(tree.type, 'Modal') + assert.equal(tree.props.className, mod.PICKER_CLASS) + assert.equal(tree.props.title, 'Choose the focus folder') + + // The level is the rows, one per listed folder; the trail above them is the + // ancestry, and a hidden entry stays hidden until the toggle asks for it. + const rowTitles = (node) => + buttonsOf(node) + .filter((button) => button.props.className === mod.ROW_CLASS) + .map((button) => button.props.title) + assert.deepEqual(rowTitles(tree), ['/work/root/src/shared']) + assert.ok( + buttonsOf(tree).some((button) => button.props.title === '/work/root' && (button.children ?? [])[0] === 'root'), + 'the trail offers the ancestry as jump targets', + ) + + // The footer's own action takes the field's value and hands it back in the + // declared form, so the workspace can move without the grant meaning + // something else. + const footer = tree.props.footer + const pickButton = footer.find((entry) => (entry.children ?? [])[0] === 'Set focus') + assert.notEqual(pickButton, undefined, 'the footer commits the picked path') + pickButton.props.onClick() + assert.deepEqual(picked, ['src']) + + model.setShowHidden(true) + assert.deepEqual(rowTitles(render()), ['/work/root/src/shared', '/work/root/src/.git']) + + // A row is a way down, not a pick: standing in the folder is what chooses it. + model.open('/work/root/other') + await settle() +}) +