feat: list files in the extras picker, not just folders
Release / release (push) Successful in 6s

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.
This commit is contained in:
NotExpectedYet committed 2026-09-28 07:47:24 +01:00
1 parent a40040ec17
commit c49acfdb2c
4 files changed
+590 -82

No files matched your search

+41 -20
View File
@@ -173,14 +173,32 @@ 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 — which is why that action reads **Add path or file** and its field **Path
or file**, rather than promising a folder the list would then fail to show.
panel, shaped like the host's own workspace browser — one level of children, a
breadcrumb trail, a hidden-entries toggle, and a file-type glyph per row. 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 listing is driven by **two Host listers**, because they answer different
questions and the extras need both:
| lister | returns | reach |
|---|---|---|
| `uiWorkspace.listDirectory` | folders | anywhere on the machine |
| `remote.workspaceFiles.list` | files and folders | the session's workspace only |
The extras picker takes the file listing while the level is inside the session
folder, which is how a file is chosen by clicking its name instead of typing it —
the same listing behind the sidebar's own file tree, applied one level at a time.
Above the session folder the Host confines it, and the level falls back to folders
and says so in the dialog: a file outside the workspace is still added by typing
its path, which is why the action reads **Add path or file** and its field **Path
or file**. The focus picker never offers files at all — a focus is a folder, so a
file row there would offer something that cannot be chosen.
A folder row is a way down; a file row is a thing to take. Clicking a file fills
the path field and marks the row, and the footer's action then adds it in declared
form; pressing Enter on a name the level already lists is not an error, because
the path is exactly what this dialog is for.
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
@@ -246,7 +264,7 @@ Honest boundaries, because each one is a decision rather than an oversight:
Verified:
- 88 tests (`npm test`) over the scope algebra, the policy state, the guard, and
- 93 tests (`npm test`) over the scope algebra, the policy state, the guard, and
the browser half — including that containment is decided on canonical targets
(a symlink inside the facet pointing outside it is refused), that a prefix is
not a parent (`/a/room` does not contain `/a/roommate`), that a malformed tool
@@ -256,8 +274,9 @@ Verified:
declared scope rather than the session directory, that a search naming no path
is refused when the directory it defaults to is outside the facet, that a
delegated session inherits its parent's root at any depth and is not a way back
out of a self-narrowing, and that every `facet` result satisfies the tool's own
declared output schema.
out of a self-narrowing, that both listings normalise into one picker level and
that a file listing the Host confines still leaves a browsable one, and that
every `facet` result satisfies the tool's own declared output schema.
- The bundle's patch layers parse, merge and land correctly against the real
profile: `dsh --profile web --patch ./cordis.patch.yml --patch
./presets/facet.patch.yml --dump-config` shows `facet-policy`, `preset-facet`,
@@ -335,7 +354,7 @@ lib/scope.js the pure half — argument extraction, text, reason
lib/guard.js preset row: rule, binding, tool, pre-execute guard
presets/facet.patch.yml the `facet` agent preset
presets/standard-compaction.patch.yml optional: the same budget for `standard`
test/ 88 tests
test/ 93 tests
```
`client/client.js` is a browser half rather than a module the host imports, and
@@ -360,14 +379,16 @@ refuses a path that is not under a volatile node, and refusing is exactly right
the panel is the only writer, and a plugin that could not serve the namespace
would be a control that silently stored nothing.
The listing reaches the panel through `ctx.get('uiWorkspace')` rather than through
an injection, and the injection's `available()` is asked on every render. Both are
deliberate: `ctx.inject` would make the whole context button conditional on a
service the plugin does not own, and the panel is still the only way to declare
scope in a deployment with no directory browser — it would simply have no listing
to offer, which is what it says instead. The service is resolved at the call
because the renderer memoizes a registration's inject result, so a boolean read
there would freeze at whatever the first render saw.
The listings reach the panel through `ctx.get` rather than through an injection —
`uiWorkspace` for folders and `remote.workspaceFiles` for files — and each
`available()` is asked on every render. Both are deliberate: `ctx.inject` would
make the whole context button conditional on services 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 services are 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. Both packages are named in this bundle's `dsh.client.inject`, so
a profile that installs this one gets the services its dialog asks for.
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