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

+281 -41
View File
@@ -46,6 +46,7 @@ window.__ModuleLoader__.load({
SettingsFormModel,
SettingsValueField,
IconFolderCloseRegular,
FileTypeIcon,
} = primitives
const h = react.createElement
@@ -448,7 +449,10 @@ window.__ModuleLoader__.load({
*
* 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.
* is not absolute — and that sentence is the one worth reading. Both listers
* reach here: the folder primitive rejects with its own error and the remote
* one folds its failure into a `RemoteError`, which is an `Error` carrying
* the same message field.
*/
function browseFailure(error) {
const rpc = error !== null && typeof error === 'object' ? error.rpcError : undefined
@@ -456,6 +460,121 @@ window.__ModuleLoader__.load({
return error instanceof Error ? error.message : String(error)
}
// ── one level, in the picker's own vocabulary ────────────────────────────
//
// The Host has two listers and they answer different questions, so the
// picker reads them through one shape and does not care which answered:
//
// * `uiWorkspace.listDirectory` browses anywhere and returns folders only;
// * `remote.workspaceFiles.list` returns files as well, and confines itself
// to the session's workspace — the same listing the sidebar's file tree
// drives, and the only way this deployment can show a file at all.
//
// `{base, crumbs, entries, truncated, files}` is that shape. `files` says
// whether this level could carry file rows, which is what the copy under the
// path field tells the user.
/** Join one name onto a directory, with the separator the Host resolves. */
function joinUnder(base, name) {
return `${String(base).replace(/[\\/]+$/, '')}/${name}`
}
/** Whether a path is a directory or sits inside one, either separator. */
function withinPath(inner, outer) {
const path = String(inner).replace(/\\/g, '/').replace(/\/+$/, '')
const base = String(outer).replace(/\\/g, '/').replace(/\/+$/, '')
return base !== '' && (path === base || path.startsWith(`${base}/`))
}
/** The last segment of a path, for a trail's first crumb. */
function basenameOf(path) {
const segments = pathSegments(path)
return segments.length === 0 ? String(path) : segments[segments.length - 1]
}
/**
* The ancestry of one path inside the workspace, workspace root first.
*
* The folder primitive reports its own trail — from the filesystem root, so
* the user can go above the session — and the file listing reports none,
* because the workspace root is as far up as it goes. This builds the one it
* does not send.
*/
function trailTo(workspace, path) {
const trail = [{ name: basenameOf(workspace), path: workspace }]
const rest = relativeTo(workspace, path)
// A path outside the workspace cannot be walked to from it, and `relativeTo`
// says so by answering in `..` segments or absolutely.
if (rest === '' || rest.startsWith('..') || isAbsolutePath(rest)) return trail
let walked = workspace
for (const segment of rest.split('/')) {
if (segment === '') continue
walked = joinUnder(walked, segment)
trail.push({ name: segment, path: walked })
}
return trail
}
/**
* One folder listing, as the picker reads it.
*
* @param {object} listing - a `DirectoryListing` from the browse primitive.
* @param {string} [fallback] - the path asked for, when the answer carries none.
* @returns {object} the picker's level.
*/
function folderListing(listing, fallback) {
const base = typeof listing?.path === 'string' && listing.path !== '' ? listing.path : String(fallback ?? '')
const home = typeof listing?.home === 'string' ? listing.home : ''
return {
base,
crumbs: (Array.isArray(listing?.crumbs) ? listing.crumbs : []).map((crumb) => ({
// A root crumb carries its whole path as its name; "Home" is the label
// the host's own browser gives it.
name: crumb.path === home ? 'Home' : crumb.name,
path: crumb.path,
})),
entries: (Array.isArray(listing?.entries) ? listing.entries : []).map((entry) => ({
name: entry.name,
path: entry.path,
directory: true,
hidden: entry.hidden === true,
})),
truncated: listing?.truncated === true,
files: false,
}
}
/**
* One workspace listing, as the picker reads it.
*
* Paths arrive relative to the workspace root and the picker works in
* absolute paths, so they are joined here — with `/`, whatever the level's
* own separators, because the Host resolves mixed separators and this is only
* a key and something to display.
*
* @param {object} listing - a `WorkspaceDirectoryListing`.
* @param {string} workspace - the session folder, absolute.
* @returns {object} the picker's level.
*/
function workspaceListing(listing, workspace) {
const relative = typeof listing?.path === 'string' ? listing.path : ''
const base = relative === '' ? workspace : joinUnder(workspace, relative)
return {
base,
crumbs: trailTo(workspace, base),
entries: (Array.isArray(listing?.entries) ? listing.entries : []).map((entry) => ({
name: entry.name,
path: joinUnder(base, entry.name),
directory: entry.type === 'directory',
// The workspace listing has no hidden flag; the platform convention is
// the dot, which is what the folder primitive reports for the same name.
hidden: String(entry.name).startsWith('.'),
})),
truncated: listing?.truncated === true,
files: true,
}
}
/**
* The state behind the picker: the level on screen, the path being edited,
* and the last failure.
@@ -466,17 +585,25 @@ window.__ModuleLoader__.load({
* 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.
*
* Two listers in, one level out: see the note above {@link folderListing}
* for why there are two and what the picker does with either.
*/
var DirectoryPickerModel = class {
/**
* @param {object} options
* @param {(path?: string, signal?: AbortSignal) => Promise<object>} options.list - the host listing call.
* @param {(path?: string, signal?: AbortSignal) => Promise<object>} options.folders - the folder listing; browses anywhere.
* @param {(sessionId: string, path: string, signal?: AbortSignal) => Promise<object>} [options.files] - the file listing; files and folders, confined to the workspace.
* @param {string} [options.sessionId] - the session whose workspace the file listing is confined to.
* @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.folders = options.folders
this.files = options.files
this.sessionId = options.sessionId
this.root = root
this.listeners = new Set()
this.request = 0
this.state = {
@@ -524,18 +651,56 @@ window.__ModuleLoader__.load({
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) })
},
)
this.read(target, request)
}
/**
* One level, from whichever lister can answer for it.
*
* The file listing is tried first while the level is inside the session's
* workspace, because that is the listing the extras need: it carries files
* as well as folders. The Host confines it to the workspace, so a level
* above the workspace root — or a deployment with no file service — falls
* back to the folder listing, which browses anywhere and carries folders
* only. The fallback is also the answer to a file listing that failed for
* any other reason, so a level the file service cannot read is still
* browsable as folders rather than reported as unreadable.
*
* @param {string} target - the absolute directory, or '' for the Host's home.
* @param {number} request - the sequence number this read belongs to.
*/
read(target, request) {
const files = this.files
const inside = target === '' ? this.root !== '' : withinPath(target, this.root)
const asFolders = () => this.folders(target === '' ? undefined : target).then((listing) => folderListing(listing, target))
const listing =
files === undefined || !inside
? asFolders()
: files(this.sessionId, target === '' ? this.root : target)
.then((answer) => workspaceListing(answer, this.root))
.catch(asFolders)
Promise.resolve(listing).then(
(level) => {
if (request !== this.request) return
this.update({ listing: level, loading: false, error: '', draft: level.base })
},
(reason) => {
if (request !== this.request) return
// A path that names a file in the level already on screen is not a
// failure, it is the thing this dialog is for: the list asked for is
// not a list, and the footer can still add what it names. That
// matters now that a file row is one keystroke away from the field.
const wanted = absoluteFrom(this.root, target)
const named = (this.state.listing?.entries ?? []).some(
(entry) => entry.directory !== true && entry.path === wanted,
)
if (named) {
this.update({ loading: false, error: '' })
return
}
this.update({ loading: false, error: browseFailure(reason) })
},
)
}
/** @param {string} text - the path field's own value. */
@@ -571,6 +736,13 @@ window.__ModuleLoader__.load({
}
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)' }
/** A file row the path field already names, so a click is visibly a choice. */
const pickerChosenRowStyle = {
...pickerRowStyle,
// The token the Host's own directory browser uses for a chosen row, fallback
// and all, so a pick looks the same here as it does one panel over.
background: 'var(--dsw-alias-interactive-bg-active,var(--dsw-alias-interactive-bg-hover))',
}
const crumbStyle = {
flex: 'none',
border: 0,
@@ -599,7 +771,13 @@ window.__ModuleLoader__.load({
/**
* The directory picker: one editable path, the ancestry above it, and one
* level of child folders.
* level of children — folders always, and files wherever the Host will list
* them.
*
* A folder row is a way down; a file row is a thing to take, so it fills the
* path field rather than navigating, and the primary action adds what the
* field holds. That is the one interaction the two row kinds do not share,
* and it is why a file row is marked selected once the field names it.
*
* 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
@@ -622,6 +800,9 @@ window.__ModuleLoader__.load({
// folder: the focus's own default, and for an extra — already the root —
// nothing worth adding.
const declared = relativeTo(root, typed)
// The field's own value as an absolute path, so a file row can tell whether
// it is the one the field names.
const chosen = absoluteFrom(root, typed)
const rows = entries.map((entry) =>
h(
@@ -630,14 +811,22 @@ window.__ModuleLoader__.load({
key: entry.path,
type: 'button',
className: ROW_CLASS,
style: pickerRowStyle,
style: entry.directory || entry.path !== chosen ? pickerRowStyle : pickerChosenRowStyle,
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),
// A folder is chosen by standing in it; a file is chosen by naming
// it, so the click puts it in the field for the footer to add. The
// field holds an absolute path everywhere else in this dialog, and
// the declared form is what the panel's write turns it into.
onClick: () => (entry.directory || target === 'focus' ? model.open(entry.path) : model.setDraft(entry.path)),
},
h('span', { style: pickerGlyphStyle }, h(IconFolderCloseRegular, { size: 14 })),
h(
'span',
{ style: pickerGlyphStyle },
entry.directory
? h(IconFolderCloseRegular, { size: 14 })
: h(FileTypeIcon, { path: entry.name, size: 16 }),
),
h('span', { style: pickerNameStyle }, entry.name),
),
)
@@ -656,10 +845,7 @@ window.__ModuleLoader__.load({
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,
crumb.name,
),
)
}
@@ -672,10 +858,19 @@ window.__ModuleLoader__.load({
: 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.')
? h('p', { key: 'status', style: pickerStatusStyle }, 'Nothing in here.')
: listing.truncated === true
? h('p', { key: 'status', style: pickerStatusStyle }, 'Too many folders to list; only the beginning is shown.')
: null
? h('p', { key: 'status', style: pickerStatusStyle }, 'Too many entries to list; only the beginning is shown.')
: // Above the session folder the Host's file listing stops — it is
// confined to the workspace — so the rows become folders only.
// Saying so is better than letting file rows silently vanish.
target === 'extra' && listing.files !== true
? h(
'p',
{ key: 'status', style: pickerStatusStyle },
'Files are listed inside the session folder only; above it the list is folders.',
)
: null
return h(Modal, {
open: true,
@@ -702,8 +897,9 @@ window.__ModuleLoader__.load({
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.
// The listing carries the files it can reach; the box is still how a
// path outside the session's workspace — 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),
@@ -718,10 +914,10 @@ window.__ModuleLoader__.load({
{ style: hintStyle },
target === 'focus'
? 'Relative to the session folder, or absolute. Enter lists whatever is there.'
: // The list holds folders only — the Host's browse primitive has no
// file rows — so the box is where a file is named, and saying so
// is the difference between a dead end and an instruction.
'Relative to the session folder, or absolute. The list holds folders; type a file\u2019s name to add the file itself.',
: // Files where the Host lists them, folders everywhere. The box is
// still where a path the listing cannot reach gets named, so both
// halves of that are said rather than left to be discovered.
'Relative to the session folder, or absolute. Folders open; a file is taken as the path to add.',
),
),
h(
@@ -843,6 +1039,24 @@ window.__ModuleLoader__.load({
)
}
/**
* The file lister an opening should offer, if any.
*
* Files are what an extra may name, so that is the only control which offers
* them: a focus is a folder, and a listing that could hand the picker a file
* would only offer something that cannot be chosen. A deployment without the
* file service gets the folder listing as before, which is why this asks
* rather than assumes.
*
* @param {object} browse - the browsing face from {@link directoryBrowse}.
* @param {string} target - 'focus' or 'extra'.
* @returns {Function|undefined} the lister, or nothing to offer.
*/
function fileLister(browse, target) {
if (target !== 'extra') return undefined
return browse?.files?.available?.() === true ? browse.files.list : undefined
}
/**
* The facet context panel: what this conversation may see, and who decides.
*
@@ -856,8 +1070,8 @@ window.__ModuleLoader__.load({
* "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.
* still editable inside the picker, for a path no listing reaches: a folder
* the session will create, or a file above the session's workspace.
*
* @param {object} props - the bound form, the session, its folder, the browse face, and the close command.
*/
@@ -911,7 +1125,9 @@ window.__ModuleLoader__.load({
setPicker({
target,
model: new DirectoryPickerModel({
list: browse.list,
folders: browse.list,
files: fileLister(browse, target),
sessionId,
root,
start: isAbsolutePath(stored) ? stored : '',
}),
@@ -1016,7 +1232,7 @@ window.__ModuleLoader__.load({
h('h3', { style: { ...sectionTitleStyle, marginTop: '18px' } }, 'Also allowed'),
declared.extras.length === 0
? h('p', { style: bodyStyle }, 'Nothing yet. Shared types, project docs, or a sibling folder go here.')
? h('p', { style: bodyStyle }, 'Nothing yet. Shared types, project docs, or a single file go here.')
: h('ul', { style: { listStyle: 'none', margin: 0, padding: 0 } }, rows),
h(
'div',
@@ -1155,7 +1371,7 @@ window.__ModuleLoader__.load({
}
/**
* The directory-browsing face the picker drives.
* The browsing face the picker drives: two listers, because the Host has two.
*
* `uiWorkspace` is read lazily rather than injected. Injecting it would make
* the whole context button conditional on it, and the panel is still the
@@ -1168,8 +1384,8 @@ window.__ModuleLoader__.load({
* 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.
* @param {object} ctx - the client context to resolve the services from.
* @returns {object} `available()`/`list()` for folders, and the same pair again under `files`.
*/
function directoryBrowse(ctx) {
const workspace = () => ctx.get('uiWorkspace')
@@ -1183,6 +1399,24 @@ window.__ModuleLoader__.load({
}
return service.listDirectory(path, signal)
},
// Folders only, anywhere on the machine. The file lister below is what
// makes an extra selectable by name; this one is what makes it
// reachable at all, and the two are needed together.
files: {
available: () => typeof ctx.get('remote.workspaceFiles')?.list === 'function',
list: (sessionId, path, signal) => {
const server = ctx.get('remote.workspaceFiles')
if (server === undefined || typeof server.list !== 'function') {
return Promise.reject(new Error('This deployment cannot list files.'))
}
// The Host confines this to the Session's workspace, which is why the
// model falls back to the folder listing above the session folder.
return server.list(sessionId, path, signal).then((result) => {
if (result?.ok !== true) throw result?.error ?? new Error('The Host refused that listing.')
return result.value
})
},
},
}
}
@@ -1266,7 +1500,13 @@ window.__ModuleLoader__.load({
absoluteFrom,
relativeTo,
isAbsolutePath,
withinPath,
joinUnder,
trailTo,
folderListing,
workspaceListing,
directoryBrowse,
fileLister,
}
return module.exports
},