Files
dsh-facet/test/client.test.js
NotExpectedYet c49acfdb2c
Release / release (push) Successful in 6s
feat: list files in the extras picker, not just folders
The extras control accepts a file — the field and its label said so — and the
listing under it could not show one, because the Host primitive behind the picker
browses folders and nothing else. So a grant for a single file meant typing its
name exactly, which is the one thing a listing exists to avoid.

The Host has a second lister and it does return files: `remote.workspaceFiles.list`,
the same listing behind the sidebar's own file tree, confined to the session's
workspace. The extras picker now takes that one while the level is inside the
session folder — which is where an extra usually is — and falls back to the folder
lister above it, saying so in the dialog rather than letting file rows vanish. The
focus picker never offers files: a focus is a folder, so a file row there would
offer something that cannot be chosen.

The two listings normalise into one level shape, so the picker does not care which
answered, and a row now carries its type: a folder is a way down, a file is a
thing to take. Clicking a file fills the path field and marks the row, and the
footer adds it in declared form. Pressing Enter on a name the level already lists
is no longer reported as a failure — the path is exactly what the dialog is for.
Everything stays additive: with no workspace file service, or above the session
folder, the picker lists folders exactly as before.
2026-09-28 07:47:24 +01:00

947 lines
38 KiB
JavaScript

/**
* The browser half: the two keys it binds, and the fields it stages.
*
* This file exists because of a bug that no host-side test could catch. Marking
* the schema's fields `volatile()` makes the Host *serve* the namespace, but the
* Plugins page draws only what a browser half registers — so the plugin showed no
* settings card at all, and every host-side test passed.
*
* The wiring is therefore the thing worth testing, and the two keys are the trap:
* the settings DOCUMENT is keyed by the profile entry id (`facet-policy`) while
* the CARD is keyed by the package name (`dsh-facet`). Every sibling plugin
* happens to name those identically, which is exactly why this drifted. A card
* bound to the wrong id renders, saves, and writes nowhere.
*
* `client/client.js` is a `window.__ModuleLoader__` module rather than an ES
* module — that is how the harness loads a browser half without a bundler — so
* the test stands up the two globals it reads and drives it from there.
*/
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
import { test } from 'node:test'
import { Config } from '../index.js'
/** Load the browser half with `window` and `require` faked, as the seed would. */
async function loadClient() {
const source = await readFile(new URL('../client/client.js', import.meta.url), 'utf8')
/** Every spec a card staged, in construction order. */
const staged = []
const primitives = {
Modal: 'Modal',
SettingsForm: 'SettingsForm',
SettingsValueField: 'SettingsValueField',
SegmentedControl: 'SegmentedControl',
// The listing's row glyphs; a client half may only use what the primitives
// actually export, so the stand-ins are named the same way.
IconFolderCloseRegular: 'IconFolderCloseRegular',
FileTypeIcon: 'FileTypeIcon',
/** Stands in for the real model: records the specs and answers with the value. */
SettingsFormModel: class {
constructor(scope, specs) {
this.scope = scope
this.specs = specs
staged.push(...specs)
}
bind(project) {
this.project = project
return { project }
}
shell() {
return { available: true, writable: true, dirty: false, invalid: false, saving: false, failed: false }
}
field(name) {
const spec = this.specs.find((candidate) => candidate.field === name)
return { text: spec?.format(this.scope.value?.[name]) ?? '', overridden: false, invalid: false }
}
actions() {
return { edit: () => {}, resetField: () => {}, save: () => {}, discard: () => {} }
}
dispose() {}
},
}
const require = (id) => {
if (id === 'react') {
return {
createElement: (type, props, ...children) => ({ type, props, children }),
// Single-pass stand-ins: the tests render a component once and read the
// tree, so a hook only has to answer, not to re-render.
useState: (initial) => [typeof initial === 'function' ? initial() : initial, () => {}],
useSyncExternalStore: (_subscribe, getSnapshot) => getSnapshot(),
}
}
if (id === '@deepseek-ai/dsh-client-ui-primitives') return primitives
throw new Error(`client.js required an unexpected module: ${id}`)
}
let registered
const loader = { load: (value) => (registered = value) }
// eslint-disable-next-line no-new-func -- the file is a ModuleLoader script, not an ES module.
new Function('window', 'require', source)({ __ModuleLoader__: loader }, require)
assert.notEqual(registered, undefined, 'client.js never called window.__ModuleLoader__.load')
return { module: registered.factory(require), staged, primitives }
}
/** Drive `apply` against a fake slot/configForms surface and report what it did. */
function drive(mod, { served = true, value = {}, workspace } = {}) {
const seen = { formNs: undefined, watched: undefined, registrations: [] }
const disposers = []
let currentSlot
// The optional service the context panel browses through. `get` is the
// Cordis read that answers `undefined` rather than throwing, which is what
// the plugin relies on to keep the panel in a deployment with no browser.
const get = (name) => (name === 'uiWorkspace' ? workspace : undefined)
const ctx = {
get,
inject: (names, scope) => {
const scoped = {
get,
configForms: {
get: (ns) => {
seen.formNs = ns
return { value, getSnapshot: () => ({ value, writable: true }), subscribe: () => () => {}, mutate: () => true }
},
whileServed: (namespaces, register) => {
seen.watched = namespaces
// The real `whileServed` registers only while the Host serves the
// namespace; `served` is what the Host's `settings.describe` answers.
if (!served) return () => {}
return register(new Set(namespaces))
},
},
slots: {
inject: (name, register) => {
currentSlot = name
register()
currentSlot = undefined
},
register: (options, component) => {
seen.registrations.push({ slot: currentSlot, options, component })
return () => {}
},
},
effect: (fn) => {
const dispose = fn()
if (typeof dispose === 'function') disposers.push(dispose)
return dispose
},
}
scope(scoped)
},
}
mod.apply(ctx)
return { seen, disposers }
}
/** The one registration a test cares about, by the slot it went into. */
function registrationFor(seen, slot) {
const found = seen.registrations.filter((entry) => entry.slot === slot)
assert.equal(found.length, 1, `expected exactly one registration into ${slot}`)
return found[0]
}
test('the module is the one the Plugins page loads, and asks for the slots it registers into', async () => {
const { module: mod } = await loadClient()
assert.equal(mod.name, 'dsh-facet')
assert.deepEqual(mod.inject, ['slots'])
assert.equal(typeof mod.apply, 'function')
})
test('the form binds to the profile entry id while the card registers under the package name', async () => {
const { module: mod } = await loadClient()
const { seen } = drive(mod)
const card = registrationFor(seen, 'plugins.bundle.config')
// These two are deliberately different strings. `facet-policy` is the `id` of
// this bundle's row in cordis.patch.yml — what `Config` is filed under. The
// card is dispatched by the BUNDLE's package name.
assert.equal(seen.formNs, 'facet-policy')
assert.equal(card.options.key, 'dsh-facet')
assert.notEqual(seen.formNs, card.options.key, 'if these ever coincide, the trap this test guards has moved')
assert.equal(card.slot, 'plugins.bundle.config')
assert.deepEqual(seen.watched, ['facet-policy'])
})
test('the context button rides the composer tool row, gated on the same namespace', async () => {
const { module: mod } = await loadClient()
const { seen } = drive(mod)
const button = registrationFor(seen, 'conversation.input.left')
// An id, not a key: `conversation.input.left` is a list, and the row shows one
// entry per id.
assert.equal(button.options.id, 'facet-context')
assert.equal(typeof button.options.inject, 'function')
// It has to receive the settings form, because the panel writes through the
// very same document the plugin card does — and the browse face, because the
// panel's two controls are directory listings.
assert.equal(typeof button.options.inject().form, 'object')
assert.equal(typeof button.options.inject().browse.list, 'function')
})
test('the host row the form binds to is the one cordis.patch.yml actually declares', async () => {
const patch = await readFile(new URL('../cordis.patch.yml', import.meta.url), 'utf8')
const { module: mod } = await loadClient()
const { seen } = drive(mod)
const card = registrationFor(seen, 'plugins.bundle.config')
// A rename in the patch without a rename here is a card that writes nowhere,
// so the id is read out of the patch rather than trusted to the test.
assert.match(patch, new RegExp(`id:\\s*${seen.formNs}\\b`))
assert.equal((await import('../package.json', { with: { type: 'json' } })).default.name, card.options.key)
})
test('nothing is registered while the Host does not serve the namespace', async () => {
const { module: mod } = await loadClient()
const { seen } = drive(mod, { served: false })
assert.equal(seen.formNs, 'facet-policy', 'the form is still bound')
// Neither the card nor the button: each would be a control that writes nowhere.
assert.deepEqual(seen.registrations, [])
})
test('the card stages exactly the deployment fields the host marks volatile', async () => {
const { module: mod, staged } = await loadClient()
drive(mod)
const volatileFields = Object.entries(Config.dict)
.filter(([, schema]) => schema.meta.volatile === true)
.map(([key]) => key)
// `scopes` is volatile too, but it is not a deployment field and must not be a
// control on the card: it is per-session state, written by the context panel
// and keyed by session id. Staging it here would render a dictionary blob a
// user could not sensibly edit, and would put the panel's store on the one
// surface that edits the deployment.
assert.ok(volatileFields.includes('scopes'), 'scopes must be volatile or the panel cannot write it')
const declared = volatileFields.filter((key) => key !== 'scopes')
// A field served but never staged is invisible in the UI; a field staged but
// never served makes `whileServed` withhold the whole card.
assert.deepEqual(
staged.map((spec) => spec.field).sort(),
declared.sort(),
'the card and the host schema have drifted apart',
)
})
test('the choice field refuses a value the schema would reject, and clears on empty', async () => {
const { module: mod, staged } = await loadClient()
drive(mod)
const mode = staged.find((spec) => spec.field === 'mode')
assert.equal(mode.parse('deny').value, 'deny')
assert.equal(mode.parse(' DENY ').value, 'deny')
assert.deepEqual(mode.parse(''), { kind: 'clear' })
// Undefined marks the draft invalid, which blocks the save rather than
// coercing a typo into a mode the Host would reject anyway.
assert.equal(mode.parse('maybe'), undefined)
})
test('the list field clears rather than storing an empty array that shadows the default', async () => {
const { module: mod, staged } = await loadClient()
drive(mod)
const roots = staged.find((spec) => spec.field === 'extraRoots')
assert.deepEqual(roots.parse('a, b\nc').value, ['a', 'b', 'c'])
assert.deepEqual(roots.parse(' '), { kind: 'clear' })
assert.equal(roots.format(['a', 'b']), 'a, b')
assert.equal(roots.format(undefined), '')
})
test('the card renders both views without reaching for anything it was not given', async () => {
const { module: mod } = await loadClient()
const { seen } = drive(mod)
const { component: card } = registrationFor(seen, 'plugins.bundle.config')
assert.equal(typeof card, 'function')
assert.equal(card({ view: 'summary' }), 'How a call outside the session\u2019s own folder is handled, and which paths are always in scope.')
const tree = card({
view: 'page',
useFacetCard: (select) =>
select({
available: true,
writable: true,
dirty: false,
invalid: false,
saving: false,
failed: false,
mode: { text: 'deny', overridden: false, invalid: false },
extraRoots: { text: 'src/shared', overridden: true, invalid: false },
}),
edit: () => {},
resetField: () => {},
save: () => {},
discard: () => {},
})
// The form frame, with the segmented control reading the staged mode. Children
// ride the `children` prop, which is how the platform's own cards pass them.
assert.equal(tree.type, 'SettingsForm')
const controls = tree.props.children.children
assert.equal(controls[0].type, 'div')
assert.equal(controls[1].type, 'SettingsValueField')
assert.equal(controls[0].children[1].props.value, 'deny')
assert.equal(controls[1].props.text, 'src/shared')
})
test('the client half loads the packages whose slots it registers into', async () => {
const pkg = (await import('../package.json', { with: { type: 'json' } })).default
const inject = pkg.dsh.client.inject
// Registering into a slot is a dependency on the package that declares it.
// `slots.inject` waits for a slot rather than creating one, so a half that
// names a slot belonging to a package the boot graph has not loaded yet
// registers nothing at all — and silently, because nothing errors.
assert.ok(inject.includes('@deepseek-ai/dsh-client-ui-plugin-manager'), 'registers into plugins.bundle.config')
assert.ok(inject.includes('@deepseek-ai/dsh-client-ui-conversation'), 'registers into conversation.input.left')
assert.ok(inject.includes('@deepseek-ai/dsh-client-ui-settings'), 'reads configForms')
// Not a slot this half registers into: the panel's directory listing drives
// `uiWorkspace.listDirectory`, so the package that provides it has to be in
// the boot graph before this half asks for it.
assert.ok(inject.includes('@deepseek-ai/dsh-client-ui-workspace'), 'browses through uiWorkspace')
})
// ── the context button and panel ─────────────────────────────────────────────
test('the context button appears for a facet session and nowhere else', async () => {
const { module: mod } = await loadClient()
const { seen } = drive(mod)
const { component: button, options } = registrationFor(seen, 'conversation.input.left')
const { form } = options.inject()
const render = (preset) =>
button({
sessionId: 'session-1',
useSessions: (select) => select({ byId: { 'session-1': { projectionValues: { agentPreset: preset } } } }),
locked: false,
form,
})
assert.notEqual(render('facet'), null)
// Every other preset in the deployment is untouched: a control for a plugin
// that is not running would be worse than no control.
assert.equal(render('standard'), null)
assert.equal(render(undefined), null)
})
test('declaredFor normalises whatever the document holds', async () => {
const { module: mod } = await loadClient()
assert.deepEqual(mod.declaredFor({ value: { scopes: { a: { focus: 'src/x', extras: ['p'] } } } }, 'a'), {
focus: 'src/x',
extras: ['p'],
})
// A session with no entry, a document with no scopes, and junk in either all
// answer with the same empty declaration rather than throwing in a render.
const empty = { focus: '', extras: [] }
assert.deepEqual(mod.declaredFor({ value: {} }, 'a'), empty)
assert.deepEqual(mod.declaredFor({}, 'a'), empty)
assert.deepEqual(mod.declaredFor({ value: { scopes: { a: 'nonsense' } } }, 'a'), empty)
assert.deepEqual(mod.declaredFor({ value: { scopes: { a: { focus: 7, extras: ['ok', 3, null] } } } }, 'a'), {
focus: '',
extras: ['ok'],
})
})
test('writeDeclaration writes one whole declaration, and removes an empty one', async () => {
const { module: mod } = await loadClient()
const calls = []
const form = { mutate: (ops) => (calls.push(ops), true) }
mod.writeDeclaration(form, 'session-1', { focus: ' src/x ', extras: ['p', ' ', 'q'] })
mod.writeDeclaration(form, 'session-1', { focus: '', extras: [] })
// One path, one write: a queued edit can never half-apply.
assert.deepEqual(calls[0], [{ op: 'set', path: ['scopes', 'session-1'], value: { focus: 'src/x', extras: ['p', 'q'] } }])
// Both fields empty means "the session directory", which is the schema default
// — so it is removed rather than stored as an empty override.
assert.deepEqual(calls[1], [{ op: 'unset', path: ['scopes', 'session-1'] }])
})
test('the panel renders the scope, the guidance, and a way to add and remove', async () => {
const { module: mod } = await loadClient()
const written = []
const form = {
getSnapshot: () => ({
status: 'ready',
writable: true,
revision: 4,
value: { mode: 'deny', scopes: { 'session-1': { focus: 'src/room', extras: ['CONTROLLER.md'] } } },
}),
subscribe: () => () => {},
mutate: (ops) => (written.push(ops), true),
}
const tree = mod.FacetContextPanel({
form,
sessionId: 'session-1',
cwd: '/work/root',
browse: { available: () => true, list: () => Promise.resolve() },
onClose: () => {},
})
assert.equal(tree.type, 'Modal')
// The class is the whole width fix: the primitive's own card is 380px and it
// takes only a class name, so the stylesheet this bundle installs outranks it.
assert.equal(tree.props.className, mod.PANEL_CLASS)
// Walk the tree for the text and controls a user would see.
const texts = textsOf(tree)
const buttons = buttonsOf(tree)
const inputs = []
walkTree(tree, (node) => {
if (node.type === 'input') inputs.push(node)
})
const labelOf = (node) => (node.children ?? [])[0]
const prose = texts.join('\n')
assert.match(prose, /What this conversation may see/)
// The two tabs the preset picker cannot show for a custom preset live here,
// folded away so the controls are not buried under them.
assert.match(prose, /Mode details/)
assert.match(prose, /How to use/)
// The honest bit about this deployment, so the panel does not promise a
// question no approval channel can carry.
assert.match(prose, /no approval channel/)
assert.match(prose, /danger-full-access/)
// The declared scope is on screen, and the existing extra has its own control.
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)
// 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) === '+ Add path or file\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 path or file\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))
// `footer` is a prop rather than a child, and it is rendered: a control there
// is as much part of the tree as one in the body.
walkTree(node.props?.children, visit)
walkTree(node.props?.footer, 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/)
// The file lister is a second service and probes separately: a deployment can
// have the folder browser without the workspace file service.
assert.equal(missing.files.available(), false)
await assert.rejects(missing.files.list('session-1', '/tmp'), /cannot list files/)
const calls = []
const present = mod.directoryBrowse({
get: (name) =>
({
uiWorkspace: {
listDirectory: (path, signal) => {
calls.push(['folders', path, signal])
return Promise.resolve('listing')
},
},
'remote.workspaceFiles': {
list: (sessionId, path, signal) => {
calls.push(['files', sessionId, path, signal])
return Promise.resolve({ ok: true, value: 'workspace listing' })
},
},
})[name],
})
assert.equal(present.available(), true)
assert.equal(await present.list('/tmp', 'signal'), 'listing')
assert.deepEqual(calls, [['folders', '/tmp', 'signal']])
// The remote answers with a result envelope rather than rejecting, so its
// failure branch is turned into one here — the model's catch expects a throw.
assert.equal(present.files.available(), true)
assert.equal(await present.files.list('session-1', '/work/root', 'signal'), 'workspace listing')
assert.deepEqual(calls[1], ['files', 'session-1', '/work/root', 'signal'])
const refusing = mod.directoryBrowse({
get: (name) =>
name === 'remote.workspaceFiles'
? { list: () => Promise.resolve({ ok: false, error: new Error('workspace files are unavailable') }) }
: undefined,
})
await assert.rejects(refusing.files.list('session-1', '/work/root'), /workspace files are unavailable/)
})
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({
folders: (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.base, '/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: a listing can
// fail on a path this panel may still legitimately declare.
const failing = new mod.DirectoryPickerModel({
folders: () =>
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({
folders: () => 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()
})
test('the extras picker names what it accepts and says what the list holds', async () => {
const { module: mod } = await loadClient()
const model = new mod.DirectoryPickerModel({
folders: () => Promise.resolve(listingFixture()),
root: '/work/root',
start: '/work/root/src',
})
await settle()
const tree = mod.DirectoryPicker({
model,
root: '/work/root',
target: 'extra',
disabled: false,
onClose: () => {},
onPick: () => {},
})
// "folder or path" promised one thing and offered another. The rows now carry
// files, and the copy says what a row of each kind does.
assert.equal(tree.props.title, 'Add a path or a file')
const texts = textsOf(tree)
assert.ok(texts.includes('Path or file'), 'the field is named for what it accepts')
assert.ok(
texts.some((text) => text.includes('Folders open; a file is taken')),
'the copy explains what a file row does',
)
assert.ok(
buttonsOf(tree).some((button) => (button.children ?? [])[0] === 'Add'),
'the action is Add for an extra, not Set focus',
)
})
// ── files in the extras picker ───────────────────────────────────────────────
//
// Two Host listers, one of which carries files. The folder primitive browses
// anywhere and returns folders; the workspace file listing returns files and
// folders but confines itself to the session's workspace. The extras picker
// takes the file listing wherever the Host allows it, and the folder listing
// everywhere else, so a path above the session folder is still reachable.
/** A level shaped the way `remote.workspaceFiles.list` answers. */
function workspaceFixture() {
return {
path: 'src',
entries: [
{ name: 'shared', type: 'directory' },
{ name: '.git', type: 'directory' },
{ name: 'index.ts', type: 'file', size: 120 },
{ name: 'CONTROLLER.md', type: 'file', size: 900 },
{ name: 'socket', type: 'other' },
],
truncated: false,
}
}
test('the model takes the file listing inside the workspace and the folder listing above it', async () => {
const { module: mod } = await loadClient()
const asked = []
const model = new mod.DirectoryPickerModel({
folders: (path) => {
asked.push(['folders', path])
return Promise.resolve(listingFixture())
},
files: (sessionId, path) => {
asked.push(['files', sessionId, path])
return Promise.resolve(workspaceFixture())
},
sessionId: 'session-1',
root: '/work/root',
start: '/work/root/src',
})
await settle()
// Inside the workspace: the file listing, normalised into the picker's own
// shape, with the workspace-relative paths joined onto the session folder.
assert.deepEqual(asked, [['files', 'session-1', '/work/root/src']])
const level = model.getSnapshot().listing
assert.equal(level.base, '/work/root/src')
assert.equal(level.files, true, 'this level could carry file rows')
assert.deepEqual(
level.entries.map((entry) => [entry.name, entry.path, entry.directory, entry.hidden]),
[
['shared', '/work/root/src/shared', true, false],
['.git', '/work/root/src/.git', true, true],
['index.ts', '/work/root/src/index.ts', false, false],
['CONTROLLER.md', '/work/root/src/CONTROLLER.md', false, false],
['socket', '/work/root/src/socket', false, false],
],
)
// The trail is the workspace's own, root first: the file listing stops at the
// session folder, so there is nothing above it to offer.
assert.deepEqual(
level.crumbs.map((crumb) => crumb.path),
['/work/root', '/work/root/src'],
)
// Above the workspace root: the Host confines the file listing to the
// workspace, so the level falls back to folders — which browse anywhere.
model.open('/work')
await settle()
assert.deepEqual(asked[1], ['folders', '/work'])
assert.equal(model.getSnapshot().listing.files, false)
})
test('a file listing that fails still leaves a browsable level', async () => {
const { module: mod } = await loadClient()
// The comparison the model makes is a prefix test on a path the Host may
// resolve differently — a symlinked session folder, say — so a refusal from
// the file listing is answered rather than shown: the folder listing runs.
const model = new mod.DirectoryPickerModel({
folders: () => Promise.resolve(listingFixture()),
files: () => Promise.reject(new Error('outside the workspace')),
sessionId: 'session-1',
root: '/work/root',
start: '/work/root/src',
})
await settle()
assert.equal(model.getSnapshot().error, '')
assert.equal(model.getSnapshot().listing.files, false)
assert.equal(model.getSnapshot().listing.base, '/work/root/src')
})
test('a file row is taken as the path to add rather than walked into', async () => {
const { module: mod } = await loadClient()
// Both listers refuse a path that names a file, which is what the Host does:
// the file listing because a file has no children, the folder listing because
// the same. Only a level can be listed.
const isFile = (path) => /\.(md|ts)$/.test(String(path))
const refuseFile = (path, answer) =>
isFile(path) ? Promise.reject(new Error('not a directory')) : Promise.resolve(answer)
const model = new mod.DirectoryPickerModel({
folders: (path) => refuseFile(path, listingFixture()),
files: (sessionId, path) => refuseFile(path, workspaceFixture()),
sessionId: 'session-1',
root: '/work/root',
start: '/work/root/src',
})
await settle()
const picked = []
const render = () =>
mod.DirectoryPicker({
model,
root: '/work/root',
target: 'extra',
disabled: false,
onClose: () => {},
onPick: (path) => picked.push(path),
})
const rowAt = (tree, path) =>
buttonsOf(tree)
.filter((button) => button.props.className === mod.ROW_CLASS)
.find((button) => button.props.title === path)
const fileRow = rowAt(render(), '/work/root/src/CONTROLLER.md')
assert.notEqual(fileRow, undefined, 'the file is a row in the listing')
assert.equal(fileRow.props.style.background, 'transparent', 'an unpicked file row is not marked')
// A folder row carries the folder glyph and a file row the file-type one, which
// is the only thing telling the two apart before the click.
const glyphOf = (row) => (row.children ?? [])[0].children?.[0]?.type
assert.equal(glyphOf(fileRow), 'FileTypeIcon')
assert.equal(glyphOf(rowAt(render(), '/work/root/src/shared')), 'IconFolderCloseRegular')
// Clicking it fills the field instead of listing it, and marks the row: a file
// is taken, not entered.
fileRow.props.onClick()
assert.equal(model.getSnapshot().draft, '/work/root/src/CONTROLLER.md')
assert.notEqual(rowAt(render(), '/work/root/src/CONTROLLER.md').props.style.background, 'transparent')
// Enter on a file the level lists asks for a listing of a file, which is not a
// failure: the level stays and nothing is reported.
model.open(model.getSnapshot().draft)
await settle()
assert.equal(model.getSnapshot().error, '')
assert.equal(model.getSnapshot().listing.base, '/work/root/src', 'the level survives it')
// The footer then adds exactly that, in the declared form.
const addButton = render().props.footer.find((entry) => (entry.children ?? [])[0] === 'Add')
addButton.props.onClick()
assert.deepEqual(picked, ['src/CONTROLLER.md'])
})
test('files are offered to the extras control only, and only where the Host has them', async () => {
const { module: mod } = await loadClient()
const browse = mod.directoryBrowse({
get: (name) => (name === 'remote.workspaceFiles' ? { list: () => {} } : undefined),
})
assert.equal(mod.fileLister(browse, 'extra'), browse.files.list, 'an extra can name a file')
// A focus is a folder, and file rows there would only offer something that
// cannot be chosen.
assert.equal(mod.fileLister(browse, 'focus'), undefined)
// No file service, or no browse face at all: the folder listing stands.
assert.equal(mod.fileLister(mod.directoryBrowse({ get: () => undefined }), 'extra'), undefined)
assert.equal(mod.fileLister({}, 'extra'), undefined)
assert.equal(mod.fileLister(undefined, 'extra'), undefined)
})
test('the picker says so when a level is folders only, and not when it is not', async () => { const { module: mod } = await loadClient()
const model = new mod.DirectoryPickerModel({
folders: () => Promise.resolve(listingFixture()),
root: '/work/root',
start: '/work/root/src',
})
await settle()
const tree = mod.DirectoryPicker({
model,
root: '/work/root',
target: 'extra',
disabled: false,
onClose: () => {},
onPick: () => {},
})
assert.ok(
textsOf(tree).some((text) => text.includes('Files are listed inside the session folder only')),
'a folders-only level explains itself',
)
// A level that does carry files needs no note about it.
const withFiles = new mod.DirectoryPickerModel({
folders: () => Promise.resolve(listingFixture()),
files: () => Promise.resolve(workspaceFixture()),
sessionId: 'session-1',
root: '/work/root',
start: '/work/root/src',
})
await settle()
assert.equal(
textsOf(
mod.DirectoryPicker({
model: withFiles,
root: '/work/root',
target: 'extra',
disabled: false,
onClose: () => {},
onPick: () => {},
}),
).some((text) => text.includes('Files are listed inside the session folder only')),
false,
)
})