fix(host): review round 6 — progressive selection-anchored landing; anchor on actual parent entries; focus-flag hygiene
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/host/directory-picker-browse/README.md
|
||||
README.md: 51e27115b5179796810c19b618b08a3523de8d10
|
||||
README.zh.md: 4caeff1c2ebfea2c12e6bb598dae2c34f2e34d84
|
||||
README.md: 23153881b84dcb71dfb05d4f297a5818c410ca77
|
||||
README.zh.md: d7010e2941a801ba6358082824330eaae46e42b7
|
||||
|
||||
@@ -6,7 +6,7 @@ The **in-app browsing backend** of the [directory-picker seam](../directory-pick
|
||||
|
||||
Behavior facts: listings return **directories only**, name-sorted, with symlinks-to-directories followed (broken/cyclic links skipped — the probe `stat` failing means "not enterable") and a host-owned `hidden` flag (POSIX dot convention) left for the client to act on; `crumbs` is the root-to-target ancestor chain, the root crumb labeled by its full path (`/`, `C:\`); an absent `list` path means the host account's home directory. `createDirectory` is non-recursive (a missing parent is a real failure, not a level to invent) and validates the name as a single non-blank segment even when called directly, mirroring the wire schema's fence. Both primitives reject an explicit path that is not fully qualified — relative forms, and on Windows the rooted drive-less forms (`\foo`, `/foo`) and incomplete UNC prefixes (`\\`, `\\server`) that `isAbsolute` accepts — with `directory-unreadable`/`directory-create-failed`, instead of letting `resolve` rebase it under the host process cwd or current drive. One `list` call returns at most `maxEntries` rows (config, default 1000 — the bound GitHub's web UI applies to directory listings), and the level streams through a bounded window so memory stays O(maxEntries) no matter how many children the directory holds: a cut level keeps the name-sorted head, counts hidden rows against the bound, probes only windowed candidates, and reports `truncated: true` so the client can say the level is incomplete (a windowed broken symlink is not backfilled from beyond the window — the eviction already marks the level truncated); window insertion is binary with an O(1) full-window tail rejection, and `list` threads the caller's `AbortSignal` so a disconnect or timeout stops the scan instead of letting it outlive the caller. Failures throw the seam's typed `DirectoryPickerError`. Policy rationale: [the directory-picker capability seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md).
|
||||
|
||||
**Dual-face package**: the browser half (`./client`) fills [ui-workspace's](../../client/ui-workspace/README.md) two directory-flow holes with the in-app **Select Workspace Directory** dialog (figma `Harness` 813-23126 family — Miller two-column view whose navigations land selection-anchored: a crumb jump or a submitted path lists the target's parent level with the target selected, so stepping back keeps two panes while the display root keeps the single wide level; breadcrumb with a click-to-edit path zone whose editor seeds a trailing separator, prefix-filters the listed level from the draft's final segment while typing (case-insensitively, over the listed — possibly truncated — rows only; Enter still navigates by the exact text), and cancels on Escape or when focus leaves the dialog card (window/tab switches and in-card focus moves keep the draft); a fixed-label show-hidden footer toggle over the host's `hidden` flags, with a dot-led typed prefix revealing its matches and the current selection exempt from both filters; nested New-folder dialog), driving `host.listDirectory`/`host.createDirectory` and registering its own locale namespace (`directory-browser`, zh default / en). One cordis.yml row therefore composes both sides of the browse interaction; the client carries no capability-kind branching, and mounting a second flow package fails at load (the holes are `single` kind).
|
||||
**Dual-face package**: the browser half (`./client`) fills [ui-workspace's](../../client/ui-workspace/README.md) two directory-flow holes with the in-app **Select Workspace Directory** dialog (figma `Harness` 813-23126 family — Miller two-column view whose navigations land selection-anchored: a crumb jump or a submitted path commits the target immediately, then re-selects its actual entry in its parent level once that level arrives — two panes, so stepping back never collapses (a failed or truncated parent leg keeps the single-pane landing; the display root keeps the single wide level); breadcrumb with a click-to-edit path zone whose editor seeds a trailing separator, prefix-filters the listed level from the draft's final segment while typing (case-insensitively, over the listed — possibly truncated — rows only; Enter still navigates by the exact text), and cancels on Escape or when focus leaves the dialog card (window/tab switches and in-card focus moves keep the draft); a fixed-label show-hidden footer toggle over the host's `hidden` flags, with a dot-led typed prefix revealing its matches and the current selection exempt from both filters; nested New-folder dialog), driving `host.listDirectory`/`host.createDirectory` and registering its own locale namespace (`directory-browser`, zh default / en). One cordis.yml row therefore composes both sides of the browse interaction; the client carries no capability-kind branching, and mounting a second flow package fails at load (the holes are `single` kind).
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
行为事实:列举**只返回目录**、按名称排序,指向目录的符号链接会被跟随(断链/循环链接被跳过——探测 `stat` 失败即"不可进入"),并携带宿主判定的 `hidden` 标志(POSIX 点前缀约定),展示决策留给客户端;`crumbs` 是从根到目标的祖先链,根 crumb 以完整路径标注(`/`、`C:\`);`list` 不带路径即列举宿主账户的家目录。`createDirectory` 不递归(父目录缺失是真实失败,不是要补造的层级),且即便被直接调用也把名称校验为单个非空段,与协议 schema 的栅栏一致。两个原语都拒绝非完全限定的显式路径——相对形态,以及 Windows 上 `isAbsolute` 会放行的无盘符有根形态(`\foo`、`/foo`)与不完整的 UNC 前缀(`\\`、`\\server`)——报 `directory-unreadable`/`directory-create-failed`,而不是任由 `resolve` 把它重定位到宿主进程 cwd 或当前盘符之下。单次 `list` 至多返回 `maxEntries` 行(配置项,默认 1000——GitHub 网页端对目录列举采用的同一上限),且层级以流式方式经过一个有界窗口,无论目录有多少子项内存都保持 O(maxEntries):被截断的层级保留按名排序的头部、隐藏行计入上限、只探测窗口内候选,并报告 `truncated: true`,供客户端提示层级不完整(窗口内的断链符号链接不会从窗口外回填——发生过驱逐本身已把层级标记为截断);窗口插入为二分查找、满窗尾部单次比较即拒绝,且 `list` 透传调用方的 `AbortSignal`,断连或超时会停止扫描而不是让它在调用方离开后继续。失败抛出 seam 的类型化 `DirectoryPickerError`。策略依据:[目录选择能力 seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md)。
|
||||
|
||||
**双面包**:browser half(`./client`)以应用内 **选择工作区目录** 对话框(figma `Harness` 813-23126 家族——Miller 双列视图,其导航以选中项为锚落地:crumb 跳转或提交的路径会列出目标的父层级并选中目标,因此后退仍保持双栏,而展示根保持单个宽层级;带点击即编辑路径区的面包屑,其编辑器预填尾随分隔符、输入时以草稿末段对所列层级做前缀过滤(不区分大小写,且仅作用于已列出、可能被截断的行;Enter 仍按确切文本导航)、按 Escape 或焦点离开对话框卡片即取消(窗口/标签页切换与卡片内焦点移动保留草稿);基于宿主 `hidden` 标志、标签固定的"显示隐藏"footer 开关,键入以点开头的前缀会显出其匹配项,且当前选中项不受这两种过滤影响;嵌套新建文件夹对话框)填入 [ui-workspace](../../client/ui-workspace/README.md) 的两个目录流洞,驱动 `host.listDirectory`/`host.createDirectory`,并注册自己的 locale 命名空间(`directory-browser`,zh 默认/en)。因此一行 cordis.yml 同时组合浏览交互的两侧;client 侧不含任何能力 kind 分支,挂载第二个流程包会在加载期失败(洞为 `single` kind)。
|
||||
**双面包**:browser half(`./client`)以应用内 **选择工作区目录** 对话框(figma `Harness` 813-23126 家族——Miller 双列视图,其导航以选中项为锚落地:crumb 跳转或提交的路径会立即提交目标,待父层级到达后再在其中重新选中目标的实际条目——双栏,因此后退绝不塌缩(父层级这一程失败或被截断时保持单栏落地;展示根保持单个宽层级);带点击即编辑路径区的面包屑,其编辑器预填尾随分隔符、输入时以草稿末段对所列层级做前缀过滤(不区分大小写,且仅作用于已列出、可能被截断的行;Enter 仍按确切文本导航)、按 Escape 或焦点离开对话框卡片即取消(窗口/标签页切换与卡片内焦点移动保留草稿);基于宿主 `hidden` 标志、标签固定的"显示隐藏"footer 开关,键入以点开头的前缀会显出其匹配项,且当前选中项不受这两种过滤影响;嵌套新建文件夹对话框)填入 [ui-workspace](../../client/ui-workspace/README.md) 的两个目录流洞,驱动 `host.listDirectory`/`host.createDirectory`,并注册自己的 locale 命名空间(`directory-browser`,zh 默认/en)。因此一行 cordis.yml 同时组合浏览交互的两侧;client 侧不含任何能力 kind 分支,挂载第二个流程包会在加载期失败(洞为 `single` kind)。
|
||||
|
||||
## 模型体验
|
||||
|
||||
|
||||
@@ -261,8 +261,9 @@
|
||||
}
|
||||
|
||||
/* Show-hidden toggle: a subtle fixed-label text button left of the gap;
|
||||
* the pressed state seats a check glyph before the label (Menu's selected
|
||||
* vocabulary) instead of flipping the wording. */
|
||||
* the pressed state seats a check glyph after the label (Menu's selected
|
||||
* vocabulary; trailing so the label never shifts) instead of flipping the
|
||||
* wording. */
|
||||
.showHiddenToggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -5,7 +5,10 @@
|
||||
* breadcrumb, and a click-to-edit path zone; below it a Miller view — one
|
||||
* full-width level until a row is selected, then two columns splitting the
|
||||
* row evenly (256px floor; level | selected folder's children) around a
|
||||
* hairline divider. Selecting in the
|
||||
* hairline divider. Navigations land selection-anchored: a crumb jump or a
|
||||
* submitted path commits the target immediately, then re-selects it in its
|
||||
* parent level once that level arrives, so stepping back keeps two panes
|
||||
* away from the display root. Selecting in the
|
||||
* right column shifts the view one level deeper. "New folder" opens a nested
|
||||
* create dialog targeting the selected folder (or the level itself) and
|
||||
* selects the created folder. Open adopts the selected folder, falling back
|
||||
@@ -215,12 +218,27 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
|
||||
}, [supersede, listDirectory])
|
||||
|
||||
/**
|
||||
* Replace the whole view with a freshly navigated level. Away from the
|
||||
* display root the landing keeps the navigated directory SELECTED inside
|
||||
* its parent level (left pane = parent, right pane = its children), so a
|
||||
* crumb jump or a submitted path reads as stepping back one pane instead
|
||||
* of collapsing to a single column; the display root (the home level, or
|
||||
* a chain with no parent) keeps the single wide level.
|
||||
* Launch a follow-up listing under the CURRENT supersession seq: a newer
|
||||
* intent aborts it like the leg it continues, and it supersedes nothing.
|
||||
*/
|
||||
const continueScan = useCallback((path: string): Promise<DirectoryListing> => {
|
||||
const controller = new AbortController()
|
||||
scanController.current = controller
|
||||
return listDirectory(path, controller.signal)
|
||||
}, [listDirectory])
|
||||
|
||||
/**
|
||||
* Replace the whole view with a freshly navigated level. The target level
|
||||
* commits the moment it arrives (single wide level: the editor closes and
|
||||
* loading ends on this first settlement, so an Enter-submitted navigation
|
||||
* is never withdrawn waiting on anything further). Away from the display
|
||||
* root — the same collapse the crumb header renders, so crumbs and pane
|
||||
* shape never disagree — a parent leg then upgrades the landing in place:
|
||||
* the target's ACTUAL parent-level entry re-selected (left pane = parent,
|
||||
* right pane = the target), so a crumb jump reads as stepping back one
|
||||
* pane. A failed parent leg, or a truncated parent window that lacks the
|
||||
* target, leaves the committed single-pane landing — the upgrade must
|
||||
* never orphan the selection it exists to anchor.
|
||||
*/
|
||||
const navigate = useCallback((path?: string) => {
|
||||
const { seq, scan } = launchListing(path)
|
||||
@@ -228,46 +246,38 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
|
||||
setError(null)
|
||||
scan.then((target) => {
|
||||
if (seq !== requestSeq.current) return
|
||||
setParent(target)
|
||||
setSelected(null)
|
||||
setChild(null)
|
||||
setLoading(false)
|
||||
setPathDraft(null)
|
||||
// Arity is label-independent: only the collapsed chain's depth decides.
|
||||
if (displayCrumbs(target, '').length < 2) return
|
||||
const parentCrumb = target.crumbs.at(-2)
|
||||
const anchor = target.crumbs.at(-1)
|
||||
if (target.path === target.home || parentCrumb === undefined
|
||||
/* v8 ignore next -- narrowing: the anchor crumb exists whenever a parent crumb does (root-to-target inclusive chain). */
|
||||
|| anchor === undefined) {
|
||||
setParent(target)
|
||||
setSelected(null)
|
||||
setChild(null)
|
||||
setLoading(false)
|
||||
setPathDraft(null)
|
||||
return
|
||||
}
|
||||
// Two-pane landing: the parent leg runs under the same supersession
|
||||
// scope (a newer intent aborts it like the first leg).
|
||||
const controller = new AbortController()
|
||||
scanController.current = controller
|
||||
listDirectory(parentCrumb.path, controller.signal).then((parentLevel) => {
|
||||
/* v8 ignore next -- narrowing: a two-deep display chain implies a parent crumb (root-to-target inclusive). */
|
||||
if (parentCrumb === undefined) return
|
||||
continueScan(parentCrumb.path).then((parentLevel) => {
|
||||
if (seq !== requestSeq.current) return
|
||||
// Windows resolves a typed path preserving its case; anchor on the
|
||||
// parent level's actual entry so selection comparisons hold.
|
||||
const sep = separatorOf(parentLevel)
|
||||
const fold = (value: string): string => (sep === '\\' ? value.toLowerCase() : value)
|
||||
const match = parentLevel.entries.find(entry => fold(entry.path) === fold(target.path))
|
||||
if (match === undefined) return
|
||||
setParent(parentLevel)
|
||||
setSelected(anchor)
|
||||
setSelected(match)
|
||||
setChild(target)
|
||||
setLoading(false)
|
||||
setPathDraft(null)
|
||||
}, () => {
|
||||
if (seq !== requestSeq.current) return
|
||||
// The target listed fine and is what the user asked for; a parent
|
||||
// leg failure quietly falls back to the single-pane landing rather
|
||||
// than surfacing an error for a level nobody requested.
|
||||
setParent(target)
|
||||
setSelected(null)
|
||||
setChild(null)
|
||||
setLoading(false)
|
||||
setPathDraft(null)
|
||||
// Swallows the parent-leg failure (its abort included): the
|
||||
// committed single-pane landing stands, and nobody asked to see
|
||||
// the parent level.
|
||||
})
|
||||
}, (reason: unknown) => {
|
||||
if (seq !== requestSeq.current) return
|
||||
setLoading(false)
|
||||
setError(failureText(reason))
|
||||
})
|
||||
}, [launchListing, listDirectory])
|
||||
}, [launchListing, continueScan])
|
||||
|
||||
// Editor-close focus parking (consumed by the refocus effect below the
|
||||
// miller-row ref): a pick parks on the selection's row, Enter and an
|
||||
@@ -302,6 +312,10 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
|
||||
// An unreadable selection cannot be the committing target while the
|
||||
// breadcrumb still names the level: fall back to the single pane.
|
||||
setSelected(null)
|
||||
// Clearing the selection can unmount the very row the pick parked
|
||||
// focus on (a dot-revealed hidden row re-hides); the refocus effect
|
||||
// re-parks on the edit zone only if focus actually fell to body.
|
||||
refocusEditZone.current = true
|
||||
})
|
||||
}, [launchListing, pathDraft])
|
||||
|
||||
@@ -350,6 +364,10 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
|
||||
setPathDraft(null)
|
||||
setFolderDraft(null)
|
||||
setCreateError(null)
|
||||
// A close mid-flight (failed Enter, then Cancel) may leave refocus
|
||||
// flags armed; retire them so a later render cannot consume them.
|
||||
refocusPick.current = false
|
||||
refocusEditZone.current = false
|
||||
}, [open, navigate, supersede])
|
||||
|
||||
/** The folder a create or Open acts on: the selection, else the listed level. */
|
||||
@@ -436,6 +454,9 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
|
||||
}
|
||||
if (refocusEditZone.current) {
|
||||
refocusEditZone.current = false
|
||||
// Re-park only when the close actually dropped focus to body; focus
|
||||
// the user parked elsewhere (a surviving row) stays theirs.
|
||||
if (document.activeElement !== document.body) return
|
||||
const zone = editZoneRef.current
|
||||
/* v8 ignore next -- narrowing guard: crumb mode renders the edit zone whenever the editor just closed. */
|
||||
if (zone === null) return
|
||||
@@ -483,8 +504,9 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
|
||||
event.stopPropagation()
|
||||
// Escape while the input holds focus is about to unmount it; with
|
||||
// focus already parked on a row, that row survives the cancel and
|
||||
// keeps focus naturally.
|
||||
if (document.activeElement === pathInputRef.current) refocusEditZone.current = true
|
||||
// keeps focus naturally. Assignment (not a conditional set) also
|
||||
// retires a stale flag a failed or still-upgrading Enter left.
|
||||
refocusEditZone.current = document.activeElement === pathInputRef.current
|
||||
cancelPathEdit()
|
||||
}}
|
||||
// Focus leaving THIS dialog card while editing cancels like Escape.
|
||||
|
||||
@@ -194,7 +194,7 @@ describe('DirectoryBrowser', () => {
|
||||
expect(signals[2]?.aborted).toBe(true)
|
||||
})
|
||||
|
||||
it('jumps back through a crumb into a fresh single-column level', async () => {
|
||||
it('a crumb jump to the display root (home) lands the single wide level', async () => {
|
||||
mount()
|
||||
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
|
||||
fireEvent.click(rowButton(screen.getByRole('listitem')))
|
||||
@@ -235,35 +235,130 @@ describe('DirectoryBrowser', () => {
|
||||
expect(columns()).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('drops a parent leg that settles after a newer intent, resolving or rejecting', async () => {
|
||||
const settlers: { resolve: (value: DirectoryListing) => void; reject: (reason: unknown) => void }[] = []
|
||||
const listDirectory = vi.fn(async (path?: string) => {
|
||||
if (path === HOME) {
|
||||
return new Promise<DirectoryListing>((resolve, reject) => { settlers.push({ resolve, reject }) })
|
||||
it('commits the target immediately, aborts a superseded parent leg on the wire, and drops its late resolution', async () => {
|
||||
const signals: (AbortSignal | undefined)[] = []
|
||||
const settlers: ((value: DirectoryListing) => void)[] = []
|
||||
// Only the FIRST explicit HOME request (the parent leg) hangs; the later
|
||||
// home crumb jump lists normally.
|
||||
let homeCalls = 0
|
||||
const listDirectory = vi.fn(async (path?: string, signal?: AbortSignal) => {
|
||||
signals.push(signal)
|
||||
if (path === HOME && ++homeCalls === 1) {
|
||||
return new Promise<DirectoryListing>((resolve) => { settlers.push(resolve) })
|
||||
}
|
||||
return listingFor(path)
|
||||
})
|
||||
mount({ listDirectory })
|
||||
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
|
||||
// Enter lands the target leg; the parent leg hangs. Escape supersedes
|
||||
// the landing, and the late parent RESOLUTION must change nothing.
|
||||
fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
|
||||
fireEvent.change(screen.getByLabelText<HTMLInputElement>('browser.editPath'), { target: { value: DOCS } })
|
||||
fireEvent.keyDown(screen.getByLabelText('browser.editPath'), { key: 'Enter' })
|
||||
// The target leg commits at once: editor closed, single-pane DOCS level,
|
||||
// while the parent leg (upgrade) is still in flight.
|
||||
await waitFor(() => { expect(screen.getByRole('listitem').textContent).toBe('harness') })
|
||||
expect(screen.queryByLabelText('browser.editPath', { selector: 'input' })).toBeNull()
|
||||
expect(columns()).toHaveLength(1)
|
||||
await waitFor(() => { expect(settlers).toHaveLength(1) })
|
||||
fireEvent.keyDown(screen.getByLabelText('browser.editPath'), { key: 'Escape' })
|
||||
await act(async () => { settlers[0]!.resolve(listingFor(HOME)) })
|
||||
// A newer jump aborts the pending parent leg ON THE WIRE, not merely
|
||||
// dropping its settlement.
|
||||
fireEvent.click(screen.getByRole('button', { name: 'browser.home' }))
|
||||
expect(signals[2]?.aborted).toBe(true)
|
||||
await waitFor(() => { expect(screen.getByRole('listitem').textContent).toBe('Documents') })
|
||||
// Its late resolution changes nothing either.
|
||||
await act(async () => { settlers[0]!(listingFor(HOME)) })
|
||||
expect(columns()).toHaveLength(1)
|
||||
expect(screen.getByRole('listitem').textContent).toBe('Documents')
|
||||
// Same shape, late parent REJECTION: equally silent.
|
||||
expect(rowButton(screen.getByRole('listitem')).getAttribute('aria-current')).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps the single-pane landing when the truncated parent level lacks the target', async () => {
|
||||
const listDirectory = vi.fn(async (path?: string) => {
|
||||
// The parent leg names HOME explicitly; serve it a truncated window
|
||||
// that misses Documents (the initial open uses the absent-path form).
|
||||
if (path === HOME) return { ...listingFor(HOME), entries: [], truncated: true }
|
||||
return listingFor(path)
|
||||
})
|
||||
mount({ listDirectory })
|
||||
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
|
||||
fireEvent.change(screen.getByLabelText<HTMLInputElement>('browser.editPath'), { target: { value: DOCS } })
|
||||
fireEvent.keyDown(screen.getByLabelText('browser.editPath'), { key: 'Enter' })
|
||||
await waitFor(() => { expect(settlers).toHaveLength(2) })
|
||||
fireEvent.keyDown(screen.getByLabelText('browser.editPath'), { key: 'Escape' })
|
||||
await act(async () => { settlers[1]!.reject(new Error('late')) })
|
||||
await waitFor(() => { expect(screen.getByRole('listitem').textContent).toBe('harness') })
|
||||
// The upgrade would orphan the selection (no source row): it stays off.
|
||||
await act(async () => {})
|
||||
expect(columns()).toHaveLength(1)
|
||||
expect(screen.queryByRole('alert')).toBeNull()
|
||||
expect(screen.queryByText('browser.truncated')).toBeNull()
|
||||
})
|
||||
|
||||
it('anchors the upgrade on the parent level actual entry under Windows case folding', async () => {
|
||||
const ROOT = 'C:\\'
|
||||
const TYPED = 'c:\\users'
|
||||
const winRoot: DirectoryListing = {
|
||||
path: ROOT,
|
||||
home: ROOT,
|
||||
crumbs: [{ name: 'C:\\', path: ROOT, hidden: false }],
|
||||
entries: [{ name: 'Users', path: 'C:\\Users', hidden: false }],
|
||||
truncated: false,
|
||||
}
|
||||
const winUsers: DirectoryListing = {
|
||||
path: TYPED,
|
||||
home: ROOT,
|
||||
crumbs: [{ name: 'C:\\', path: ROOT, hidden: false }, { name: 'users', path: TYPED, hidden: false }],
|
||||
entries: [],
|
||||
truncated: false,
|
||||
}
|
||||
mount({ listDirectory: vi.fn(async (path?: string) => (path === TYPED ? winUsers : winRoot)) })
|
||||
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
|
||||
fireEvent.change(screen.getByLabelText<HTMLInputElement>('browser.editPath'), { target: { value: TYPED } })
|
||||
fireEvent.keyDown(screen.getByLabelText('browser.editPath'), { key: 'Enter' })
|
||||
// The typed case differs from the real entry; the upgrade selects the
|
||||
// parent level's ACTUAL entry so aria-current and exemptions hold.
|
||||
await waitFor(() => {
|
||||
expect(rowButton(within(columns()[0]!).getByRole('listitem')).getAttribute('aria-current')).toBe('true')
|
||||
})
|
||||
expect(within(columns()[0]!).getByText('Users')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('re-parks focus on the edit zone when a failed pick unmounts a dot-revealed row', async () => {
|
||||
const listDirectory = vi.fn(async (path?: string) => {
|
||||
if (path === `${HOME}/.config`) {
|
||||
throw new DirectoryBrowseError({ code: 'directory-unreadable', message: 'denied', details: { path } })
|
||||
}
|
||||
return listingFor(path)
|
||||
})
|
||||
mount({ listDirectory })
|
||||
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
|
||||
fireEvent.change(screen.getByLabelText<HTMLInputElement>('browser.editPath'), { target: { value: `${HOME}/.co` } })
|
||||
const row = rowButton(screen.getByRole('listitem'))
|
||||
fireEvent.mouseDown(row)
|
||||
fireEvent.click(row)
|
||||
// The failed selection re-hides the picked row; focus fell to body and
|
||||
// re-parks on the crumb edit zone.
|
||||
await screen.findByRole('alert')
|
||||
expect(screen.queryByText('.config')).toBeNull()
|
||||
expect(document.activeElement).toBe(screen.getByRole('button', { name: 'browser.editPath' }))
|
||||
})
|
||||
|
||||
it('leaves focus on a surviving row when its pick fails', async () => {
|
||||
const listDirectory = vi.fn(async (path?: string) => {
|
||||
if (path === DOCS) {
|
||||
throw new DirectoryBrowseError({ code: 'directory-unreadable', message: 'denied', details: { path } })
|
||||
}
|
||||
return listingFor(path)
|
||||
})
|
||||
mount({ listDirectory })
|
||||
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
|
||||
fireEvent.change(screen.getByLabelText<HTMLInputElement>('browser.editPath'), { target: { value: `${HOME}/do` } })
|
||||
const row = rowButton(screen.getByRole('listitem'))
|
||||
row.focus()
|
||||
fireEvent.mouseDown(row)
|
||||
fireEvent.click(row)
|
||||
// Documents survives the cleared selection (it is not hidden): the
|
||||
// user's focus on it is not yanked to the edit zone.
|
||||
await screen.findByRole('alert')
|
||||
expect(document.activeElement).toBe(row)
|
||||
})
|
||||
|
||||
it('falls back to the single-pane landing when the parent leg of a navigation fails', async () => {
|
||||
|
||||
Reference in New Issue
Block a user