Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6b92656ed2 | ||
|
|
5d06829118 | ||
|
|
ef3df2d939 |
No files matched your search
@@ -21,6 +21,24 @@ release notes. Write it for that reader:
|
|||||||
`feat` has to be a word, so `Fix the feature flag leak` is a patch. Only the
|
`feat` has to be a word, so `Fix the feature flag leak` is a patch. Only the
|
||||||
subject line is read; the body is not.
|
subject line is read; the body is not.
|
||||||
|
|
||||||
|
## Commit as the work lands
|
||||||
|
|
||||||
|
Commit each step as soon as it is finished rather than leaving it in the tree
|
||||||
|
until the end. Two reasons, and the second is the one that bites:
|
||||||
|
|
||||||
|
- An uncommitted change has no release note at all. The subject is the sentence a
|
||||||
|
user reads, so work that never becomes a commit never becomes a sentence.
|
||||||
|
- A tree carrying four unrelated changes cannot be split into four honest
|
||||||
|
subjects after the fact — the reasoning behind each one is only in the
|
||||||
|
transcript by then, and the range picks its bump from whatever subjects it
|
||||||
|
ends up with. One `feat:` batched in with a handful of fixes is a minor bump
|
||||||
|
for all of them.
|
||||||
|
|
||||||
|
Keep a subject to one change and the version follows from it.
|
||||||
|
|
||||||
|
A commit here is local. Pushing to `master` is what releases, so it stays a
|
||||||
|
separate, deliberate step.
|
||||||
|
|
||||||
## The release job owns the version and the changelog
|
## The release job owns the version and the changelog
|
||||||
|
|
||||||
`package.json`'s `version` and `CHANGELOG.md` are the job's to write, derived
|
`package.json`'s `version` and `CHANGELOG.md` are the job's to write, derived
|
||||||
|
|||||||
@@ -2,6 +2,17 @@
|
|||||||
|
|
||||||
Every release, newest first, generated from the commits in each release.
|
Every release, newest first, generated from the commits in each release.
|
||||||
|
|
||||||
|
## v0.3.0 — 2026-09-27
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- pick the focus and the allowed paths from a directory listing
|
||||||
|
|
||||||
|
### Documented
|
||||||
|
|
||||||
|
- commit each step as the work lands, not in one lump at the end
|
||||||
|
|
||||||
|
|
||||||
## v0.2.0 — 2026-09-27
|
## v0.2.0 — 2026-09-27
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
},
|
},
|
||||||
|
|||||||
+3
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "dsh-facet",
|
"name": "dsh-facet",
|
||||||
"version": "0.2.0",
|
"version": "0.3.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Scope an agent session to one facet of a multi-faceted system: the session's own folder, plus whatever files the user grants it.",
|
"description": "Scope an agent session to one facet of a multi-faceted system: the session's own folder, plus whatever files the user grants it.",
|
||||||
@@ -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
@@ -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()
|
||||||
|
})
|
||||||
|
|
||||||
Reference in new issue
Block a user