feat: pick the focus and the allowed paths from a directory listing
Release / release (push) Successful in 6s

Both scope controls were free-text boxes, and the vocabulary they asked the user
for is paths relative to the session folder. "Which folder is `../shared`" is the
one question a text box cannot answer, so the panel now shows the Host's own
directory: the `uiWorkspace.listDirectory` primitive the workspace picker's
browser drives, one level of child folders, a breadcrumb trail, and a
hidden-entries toggle. The path field stays editable inside the picker, because
the Host lists folders and an extra may legitimately name a file.

Picks are stored in the form that keeps their meaning. A folder at
`/work/root/src/shared` is declared `src/shared` when the session started in
`/work/root`, and `../shared` going up; only a path with nothing shared — another
drive, another tree — stays absolute. The Focus row shows that declared form,
with the resolved path in its title, which is what the agent will see.

`Set` is gone with the box it committed: the panel writes on pick, as every other
control here already did. `Clear` stays, because empty means the session folder —
the schema's own default rather than an override naming the same place.

The card was 380px wide and grew without bound, a single column of prose taller
than the viewport. It is 760px now, with a two-column grid that `auto-fit`
collapses to one on a narrow card, the reference prose folded into `<details>`,
and a scroll-bounded body. `Modal` sizes its own card from a hashed class in the
primitives' stylesheet, so this bundle installs one small stylesheet of its own
and outranks that rule with a doubled class selector — no `!important`, and
nothing reaching into a class name it does not own.

Two fixes came with it. The footer's "Deployment mode:" line read a slot prop
that never carried the mode, so it said "ask" in every deployment; it reads the
settings document now. And `uiWorkspace` is resolved with `ctx.get` at the call
rather than injected, because the panel is still the only way to declare scope in
a deployment with no directory browser: it keeps the panel and says what it
cannot do, instead of losing the button.
This commit is contained in:
NotExpectedYet committed 2026-09-27 07:05:47 +01:00
1 parent ef3df2d939
commit 5d06829118
4 files changed
+1046 -140

No files matched your search

+32 -1
View File
@@ -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 package ships. Writes go to the profile patch, so reopening a chat gives back the
scope it had. 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 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 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 `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 lib/guard.js preset row: rule, binding, tool, pre-execute guard
presets/facet.patch.yml the `facet` agent preset presets/facet.patch.yml the `facet` agent preset
presets/standard-compaction.patch.yml optional: the same budget for `standard` 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 `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 the panel is the only writer, and a plugin that could not serve the namespace
would be a control that silently stored nothing. 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 ## Also in this repository
- [`facet-preset.md`](../facet-preset.md) — the design note this was built from, - [`facet-preset.md`](../facet-preset.md) — the design note this was built from,
+734 -118
View File
@@ -39,7 +39,14 @@ window.__ModuleLoader__.load({
const react = require('react') const react = require('react')
const primitives = require('@deepseek-ai/dsh-client-ui-primitives') 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 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 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)' } 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<object>} 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. */ /** The crosshair that marks the button: a scope, not a setting. */
const CONTEXT_GLYPH = h( const CONTEXT_GLYPH = h(
'svg', '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. * 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 * decides what is in scope" a property of the system rather than of the
* agent's instructions. * 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) { 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 // `form` is the settings document for `facet-policy`, already a store: the
// panel renders whatever the Host last accepted, including a write from // panel renders whatever the Host last accepted, including a write from
@@ -360,11 +863,26 @@ window.__ModuleLoader__.load({
() => form.getSnapshot(), () => form.getSnapshot(),
) )
const declared = declaredFor(snapshot, sessionId) const declared = declaredFor(snapshot, sessionId)
const [focusDraft, setFocusDraft] = react.useState(declared.focus) const [picker, setPicker] = react.useState(null)
const [extraDraft, setExtraDraft] = react.useState('')
const [failure, setFailure] = react.useState('') const [failure, setFailure] = react.useState('')
const disabled = snapshot.writable !== true 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) => { const write = (next) => {
writeDeclaration(form, sessionId, next).then( writeDeclaration(form, sessionId, next).then(
(ok) => setFailure(ok ? '' : 'The deployment did not accept that change.'), (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) => const rows = declared.extras.map((path) =>
h( h(
'li', 'li',
{ key: path, style: { display: 'flex', alignItems: 'center', gap: '8px', marginTop: '6px' } }, { 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( h(
'button', '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( return h(
Modal, 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( h(
'div', 'div',
null, { style: panelBodyStyle },
h('h3', { style: sectionTitleStyle }, 'What this conversation may see'), // Two columns, because the panel is now wide: the controls on the
h( // left where the eye starts, the reference prose on the right where it
'p', // reads at a sane line length. `auto-fit` collapses it back to one
{ style: bodyStyle }, // column on a narrow viewport, so nothing has to know the width.
'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'),
h( h(
'div', 'div',
{ style: { display: 'flex', gap: '8px' } }, { style: panelGridStyle },
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 })
},
}),
h( h(
'button', 'div',
{ type: 'button', style: quietButtonStyle, disabled, onClick: () => write({ ...declared, focus: focusDraft }) }, null,
'Set', h('h3', { style: sectionTitleStyle }, 'Focus'),
), h(
h( 'div',
'button', { style: { display: 'flex', gap: '8px', alignItems: 'center' } },
{ h(
type: 'button', 'span',
style: quietButtonStyle, { style: declared.focus === '' ? focusEmptyPathStyle : focusPathStyle, title: absoluteFrom(root, declared.focus) },
disabled, declared.focus === '' ? 'the session folder' : declared.focus,
onClick: () => { ),
setFocusDraft('') h(
write({ ...declared, focus: '' }) 'button',
}, { type: 'button', style: quietButtonStyle, disabled: !canBrowse, onClick: () => openPicker('focus') },
}, 'Browse\u2026',
'Clear', ),
), declared.focus === ''
), ? null
h('p', { style: bodyStyle }, 'Relative to the session folder, or absolute. Empty means the session folder itself.'), : 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'), h('h3', { style: { ...sectionTitleStyle, marginTop: '18px' } }, 'Also allowed'),
declared.extras.length === 0 declared.extras.length === 0
? h('p', { style: bodyStyle }, 'Nothing yet. Shared types, project docs, or a sibling folder go here.') ? 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('ul', { style: { listStyle: 'none', margin: 0, padding: 0 } }, rows),
h( h(
'div', 'div',
{ style: { display: 'flex', gap: '8px', marginTop: '10px' } }, { style: { marginTop: '10px' } },
h('input', { h(
id: 'facet-extra', 'button',
type: 'text', { type: 'button', style: quietButtonStyle, disabled: !canBrowse, onClick: () => openPicker('extra') },
style: { ...controlStyle, flex: 1 }, '+ Add folder or path\u2026',
placeholder: 'src/shared, ../../CONTROLLER.md', ),
value: extraDraft, ),
disabled, h(
onChange: (event) => setExtraDraft(event.target.value), 'p',
onKeyDown: (event) => { { style: bodyStyle },
if (event.key !== 'Enter') return 'Relative to the session folder, or absolute — anything the session needs beyond the focus.',
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.'),
failure === '' ? null : h('p', { style: { ...bodyStyle, color: 'var(--dsw-alias-state-error-primary)' } }, failure), canBrowse
h( ? null
'p', : h(
{ style: { ...bodyStyle, marginTop: '18px' } }, 'p',
`Changes take effect on the next tool call. Deployment mode: ${mode ?? 'ask'}.`, { 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( h(
'li', 'div',
{ style: { marginBottom: '6px' } }, null,
'Add what it needs beyond that — shared types, project docs, a folder it will create a sibling in.', 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 // Called before the early return, so the hook order is stable across the
// sessions that do and do not run the facet. // sessions that do and do not run the facet.
const preset = useSessions((state) => state.byId[sessionId]?.projectionValues?.agentPreset) 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) const [open, setOpen] = react.useState(false)
if (preset !== FACET_PRESET) return null 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 // Mounted only while open, so each visit starts from what the Host holds
// rather than from a draft left over from the last one. // 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. * Mount the facet settings card while the Host serves its namespace.
* *
@@ -617,7 +1224,7 @@ window.__ModuleLoader__.load({
name: 'conversation.input.left', name: 'conversation.input.left',
id: CONTEXT_ENTRY_ID, id: CONTEXT_ENTRY_ID,
order: 20, order: 20,
inject: () => ({ form }), inject: () => ({ form, browse: directoryBrowse(ctx) }),
}, },
FacetContextButton, FacetContextButton,
), ),
@@ -636,12 +1243,21 @@ window.__ModuleLoader__.load({
BUNDLE_CONFIG_KEY, BUNDLE_CONFIG_KEY,
FACET_PRESET, FACET_PRESET,
CONTEXT_ENTRY_ID, CONTEXT_ENTRY_ID,
PANEL_CLASS,
PICKER_CLASS,
ROW_CLASS,
FacetCard, FacetCard,
FacetCardController, FacetCardController,
FacetContextButton, FacetContextButton,
FacetContextPanel, FacetContextPanel,
DirectoryPicker,
DirectoryPickerModel,
declaredFor, declaredFor,
writeDeclaration, writeDeclaration,
absoluteFrom,
relativeTo,
isAbsolutePath,
directoryBrowse,
} }
return module.exports return module.exports
}, },
+2 -1
View File
@@ -50,7 +50,8 @@
"inject": [ "inject": [
"@deepseek-ai/dsh-client-ui-settings", "@deepseek-ai/dsh-client-ui-settings",
"@deepseek-ai/dsh-client-ui-plugin-manager", "@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"
] ]
} }
}, },
+278 -20
View File
@@ -35,6 +35,9 @@ async function loadClient() {
SettingsForm: 'SettingsForm', SettingsForm: 'SettingsForm',
SettingsValueField: 'SettingsValueField', SettingsValueField: 'SettingsValueField',
SegmentedControl: 'SegmentedControl', 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. */ /** Stands in for the real model: records the specs and answers with the value. */
SettingsFormModel: class { SettingsFormModel: class {
constructor(scope, specs) { constructor(scope, specs) {
@@ -84,14 +87,21 @@ async function loadClient() {
} }
/** Drive `apply` against a fake slot/configForms surface and report what it did. */ /** 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 seen = { formNs: undefined, watched: undefined, registrations: [] }
const disposers = [] const disposers = []
let currentSlot 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 = { const ctx = {
get,
inject: (names, scope) => { inject: (names, scope) => {
const scoped = { const scoped = {
get,
configForms: { configForms: {
get: (ns) => { get: (ns) => {
seen.formNs = 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(button.options.id, 'facet-context')
assert.equal(typeof button.options.inject, 'function') assert.equal(typeof button.options.inject, 'function')
// It has to receive the settings form, because the panel writes through the // 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().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 () => { 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-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-conversation'), 'registers into conversation.input.left')
assert.ok(inject.includes('@deepseek-ai/dsh-client-ui-settings'), 'reads configForms') 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 ───────────────────────────────────────────── // ── 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), 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') 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. // Walk the tree for the text and controls a user would see.
const texts = [] const texts = textsOf(tree)
const buttons = [] const buttons = buttonsOf(tree)
const inputs = [] const inputs = []
const walk = (node) => { walkTree(tree, (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)
if (node.type === 'input') inputs.push(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 labelOf = (node) => (node.children ?? [])[0]
const prose = texts.join('\n') const prose = texts.join('\n')
assert.match(prose, /What this conversation may see/) 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, /Mode details/)
assert.match(prose, /How to use/) assert.match(prose, /How to use/)
// The honest bit about this deployment, so the panel does not promise a // 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/) assert.match(prose, /danger-full-access/)
// The declared scope is on screen, and the existing extra has its own control. // 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.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) === '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()
})