feat(directory-picker-browse): quiet navigation — one-frame landings and a slow-scan loading pill

Navigations keep the previous view rendering while scanning: target and
parent legs land as one two-pane frame when the parent leg settles within
a 200ms wait bound (past it the target lands alone and the late leg
upgrades in place; Escape inside the landing window withdraws the
navigation). The loading indicator floats over the content on the card
background and appears only once a scan outlives a 300ms silence window,
so navigation never shifts the columns or flashes an intermediate frame.
The truncated note now describes the on-screen panes instead of hiding
during scans.
This commit is contained in:
creatixchu
2026-07-30 11:50:16 +08:00
parent 0d5cf7ce4e
commit 7b22a3b454
9 changed files with 235 additions and 40 deletions

View File

@@ -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: 23153881b84dcb71dfb05d4f297a5818c410ca77
README.zh.md: d7010e2941a801ba6358082824330eaae46e42b7
README.md: 52b5fe7e89f915be3b50324628e9d5c48f1ef94c
README.zh.md: 742da39470083887a71ddba4a7c8012f0ce0ea1f

View File

@@ -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 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).
**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 and quiet: the previous view keeps rendering while a crumb jump or a submitted path is scanned (a "Loading…" pill floats over it only once the scan outlives a 300ms silence window, never shifting the columns), then target and parent legs land as one two-pane frame with the target re-selected as its actual parent-level entry — so stepping back never collapses and no intermediate frame flashes (a parent leg outliving its 200ms wait bound lands the target alone and upgrades in place; 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

View File

@@ -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 跳转或提交的路径期间,先前视图持续渲染("Loading…" 胶囊仅在扫描超出 300ms 静默窗口后才浮于其上,绝不挪动各列),随后目标与父层级两程以单个双栏帧落地,目标被重新选中为其在父层级中的实际条目——因此后退绝不塌缩,也没有中间帧闪现(父层级这一程超出其 200ms 等待上限时,目标单独落地,随后就地升级;父层级这一程失败或被截断时保持单栏落地展示根保持单个宽层级带点击即编辑路径区的面包屑其编辑器预填尾随分隔符、输入时以草稿末段对所列层级做前缀过滤不区分大小写且仅作用于已列出、可能被截断的行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
## 模型体验

View File

@@ -146,11 +146,26 @@
flex-direction: column;
flex: 1 1 0;
min-height: 0;
/* Anchors the floating loading pill (.loadingFloat). */
position: relative;
/* Right inset is slimmer than the left: the trailing column's own 8px
* scrollbar clearance makes up the optical difference. */
padding: 16px 16px 16px 24px;
}
/* The slow-scan indicator floats over the content's bottom-left on the card
* background instead of occupying a row: a scan must never shift the
* columns' height, and the stale view keeps rendering beneath it (it only
* appears at all once a scan outlives SLOW_SCAN_DELAY_MS). */
.loadingFloat {
position: absolute;
left: 24px;
bottom: 8px;
padding: 2px 8px;
border-radius: 6px;
background: var(--dsw-alias-bg-layer-2);
}
/* Columns split the row evenly around the divider (a solo column takes the
* whole row); 256px is the floor below which the row scrolls (scrollbar
* hidden, the effect pins the child pane into view) instead of squeezing

View File

@@ -5,10 +5,12 @@
* 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. 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
* hairline divider. Navigations land selection-anchored and quiet: the
* previous view keeps rendering while a crumb jump or a submitted path is
* scanned, then target and parent legs land as one two-pane frame (a slow
* parent leg falls back to landing the target alone and upgrading in
* place), so stepping back keeps two panes away from the display root and
* navigation never flashes an intermediate frame. 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
@@ -55,6 +57,24 @@ function failureText(error: unknown): string {
return error instanceof Error ? error.message : String(error)
}
/**
* How long a scan may stay visually silent before the floating "Loading…"
* pill appears. The stale view keeps rendering while a scan is in flight, so
* a listing that settles inside this window swaps the panes with no
* intermediate frame at all; only a genuinely slow host (a network mount, a
* cold disk) surfaces the indicator.
*/
const SLOW_SCAN_DELAY_MS = 300
/**
* How long a navigation landing waits for its parent leg before committing
* the target alone. Inside the window both legs land as ONE two-pane frame —
* no single-pane flash between them; past it the target commits single-pane
* at once (an Enter-submitted navigation is never held hostage by a stalled
* parent) and the late parent leg upgrades the landing in place.
*/
const PARENT_LEG_WAIT_MS = 200
/**
* Breadcrumb rows for display: inside the home subtree the chain starts at a
* localized Home crumb; outside it the full ancestry shows, the root labeled
@@ -166,6 +186,10 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
const [selected, setSelected] = useState<DirectoryEntry | null>(null)
const [child, setChild] = useState<DirectoryListing | null>(null)
const [loading, setLoading] = useState(false)
// Derived from `loading` by the slow-scan effect below: true only once a
// scan has been in flight for SLOW_SCAN_DELAY_MS, so fast listings never
// render the indicator at all.
const [slowScan, setSlowScan] = useState(false)
const [error, setError] = useState<string | null>(null)
// Path-edit state: null = breadcrumb mode; a string = the draft being typed.
const [pathDraft, setPathDraft] = useState<string | null>(null)
@@ -228,17 +252,20 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
}, [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.
* Replace the whole view with a freshly navigated level. Away from the
* display root — the same collapse the crumb header renders, so crumbs and
* pane shape never disagree — the landing is two-pane: 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. Both legs land
* as one frame when the parent leg settles within
* {@link PARENT_LEG_WAIT_MS}; past that bound (or at the display root) the
* target commits alone — single wide level, the editor closes, loading
* ends — and a late parent leg still upgrades the landing in place. A
* failed parent leg, or a truncated parent window that lacks the target,
* leaves the single-pane landing — the upgrade must never orphan the
* selection it exists to anchor. Until whichever commit comes first, the
* previous view keeps rendering: navigation swaps the panes, it never
* blanks them.
*/
const navigate = useCallback((path?: string) => {
const { seq, scan } = launchListing(path)
@@ -246,16 +273,23 @@ 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)
// The single-pane landing; `landed` makes it first-commit-only, while
// the two-pane commit below may still upgrade an already-landed view.
let landed = false
const landSingle = (): void => {
if (landed || seq !== requestSeq.current) return
landed = true
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
if (displayCrumbs(target, '').length < 2) { landSingle(); return }
const parentCrumb = target.crumbs.at(-2)
/* v8 ignore next -- narrowing: a two-deep display chain implies a parent crumb (root-to-target inclusive). */
if (parentCrumb === undefined) return
if (parentCrumb === undefined) { landSingle(); return }
continueScan(parentCrumb.path).then((parentLevel) => {
if (seq !== requestSeq.current) return
// Windows resolves a typed path preserving its case; anchor on the
@@ -263,15 +297,23 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
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
if (match === undefined) { landSingle(); return }
landed = true
setParent(parentLevel)
setSelected(match)
setChild(target)
// Idempotent on a late upgrade of a timed-out landing: reopening the
// editor or starting a newer scan supersedes this seq, so reaching
// here means the draft is closed and the loading flag is this
// navigation's own.
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.
// The parent-leg failure (its abort included) never surfaces: the
// target listed fine, and nobody asked to see the parent level.
landSingle()
})
window.setTimeout(landSingle, PARENT_LEG_WAIT_MS)
}, (reason: unknown) => {
if (seq !== requestSeq.current) return
setLoading(false)
@@ -415,6 +457,18 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
})
}
// The slow-scan gate for the loading indicator: arm a timer when a scan
// starts, retire it (and the indicator) the moment loading ends. A settle
// inside the window means the swap happened with nothing shown.
useEffect(() => {
if (!loading) {
setSlowScan(false)
return
}
const timer = window.setTimeout(() => { setSlowScan(true) }, SLOW_SCAN_DELAY_MS)
return () => { window.clearTimeout(timer) }
}, [loading])
// After the hooks: a closed dialog renders nothing and evaluates no copy.
const crumbSource = child ?? parent
const crumbs = crumbSource === null ? [] : displayCrumbs(crumbSource, t('browser.home'))
@@ -649,11 +703,15 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
/>
)}
</div>
{loading && <div className={css.status} role="status">{t('browser.loading')}</div>}
{loading && slowScan
&& <div className={clsx(css.status, css.loadingFloat)} role="status">{t('browser.loading')}</div>}
{/* The backend bounds a level at its complete-result limit; say so
* whenever a visible pane was cut instead of letting the tail of a
* huge directory go silently missing. */}
{(parent?.truncated === true || child?.truncated === true) && !loading
* huge directory go silently missing. The note describes the panes
* on screen, so an in-flight scan leaves it alone — hiding it while
* the stale view still shows the cut level would shift the columns
* on every navigation away from it. */}
{(parent?.truncated === true || child?.truncated === true)
&& <div className={css.status} role="status">{t('browser.truncated')}</div>}
{error !== null && <div className={css.error} role="alert">{error}</div>}
</div>

View File

@@ -235,7 +235,7 @@ describe('DirectoryBrowser', () => {
expect(columns()).toHaveLength(1)
})
it('commits the target immediately, aborts a superseded parent leg on the wire, and drops its late resolution', async () => {
it('lands the target single-pane at the wait bound, 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
@@ -253,8 +253,8 @@ describe('DirectoryBrowser', () => {
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.
// The parent leg (upgrade) hangs past the landing wait bound: the target
// commits alone — editor closed, single-pane DOCS level.
await waitFor(() => { expect(screen.getByRole('listitem').textContent).toBe('harness') })
expect(screen.queryByLabelText('browser.editPath', { selector: 'input' })).toBeNull()
expect(columns()).toHaveLength(1)
@@ -270,6 +270,128 @@ describe('DirectoryBrowser', () => {
expect(rowButton(screen.getByRole('listitem')).getAttribute('aria-current')).toBeNull()
})
/**
* Listing fake whose explicit-path scans stay pending until the test
* settles them by path; the absent-path form (the initial home listing)
* resolves normally so mounting is a one-flush setup.
*/
function manualLister() {
const settlers = new Map<string, (value: DirectoryListing) => void>()
const listDirectory = vi.fn((path?: string, _signal?: AbortSignal) => {
if (path === undefined) return Promise.resolve(listingFor(path))
return new Promise<DirectoryListing>((resolve) => { settlers.set(path, resolve) })
})
return { settlers, listDirectory }
}
it('lands a navigation as ONE two-pane frame: the stale view holds until both legs arrive', async () => {
vi.useFakeTimers()
try {
const { settlers, listDirectory } = manualLister()
mount({ listDirectory })
await act(async () => {})
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 settles while the parent leg is still in flight: nothing
// commits yet — the editor stays open over the stale home level, and no
// single-pane DOCS frame ever renders.
await act(async () => { settlers.get(DOCS)!(listingFor(DOCS)) })
expect(screen.getByLabelText('browser.editPath', { selector: 'input' })).toBeTruthy()
expect(screen.queryByText('harness')).toBeNull()
// The parent leg settles inside the wait bound: one commit straight to
// the two-pane landing, editor closed.
await act(async () => { settlers.get(HOME)!(listingFor(HOME)) })
expect(columns()).toHaveLength(2)
expect(rowButton(within(columns()[0]!).getByRole('listitem')).getAttribute('aria-current')).toBe('true')
expect(within(columns()[0]!).getByText('Documents')).toBeTruthy()
expect(within(columns()[1]!).getByText('harness')).toBeTruthy()
expect(screen.queryByLabelText('browser.editPath', { selector: 'input' })).toBeNull()
// The wait-bound timer firing after the landing is a no-op.
await act(async () => { vi.advanceTimersByTime(200) })
expect(columns()).toHaveLength(2)
} finally {
vi.useRealTimers()
}
})
it('a stalled parent leg lands the target alone at the wait bound, then upgrades in place', async () => {
vi.useFakeTimers()
try {
const { settlers, listDirectory } = manualLister()
mount({ listDirectory })
await act(async () => {})
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 act(async () => { settlers.get(DOCS)!(listingFor(DOCS)) })
// The parent leg outlives PARENT_LEG_WAIT_MS: the target lands alone.
await act(async () => { vi.advanceTimersByTime(200) })
expect(columns()).toHaveLength(1)
expect(screen.getByRole('listitem').textContent).toBe('harness')
expect(screen.queryByLabelText('browser.editPath', { selector: 'input' })).toBeNull()
// The late parent leg still upgrades the landing in place, exactly as
// if it had made the bound. (Reopening the editor meanwhile would
// supersede the upgrade — the editor-open handler withdraws pending
// listings — so a late upgrade can never close a resumed draft.)
await act(async () => { settlers.get(HOME)!(listingFor(HOME)) })
expect(columns()).toHaveLength(2)
expect(rowButton(within(columns()[0]!).getByRole('listitem')).getAttribute('aria-current')).toBe('true')
expect(within(columns()[1]!).getByText('harness')).toBeTruthy()
} finally {
vi.useRealTimers()
}
})
it('Escape inside the landing window withdraws the submitted navigation', async () => {
vi.useFakeTimers()
try {
const { settlers, listDirectory } = manualLister()
mount({ listDirectory })
await act(async () => {})
fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
const input = screen.getByLabelText<HTMLInputElement>('browser.editPath')
fireEvent.change(input, { target: { value: DOCS } })
fireEvent.keyDown(input, { key: 'Enter' })
await act(async () => { settlers.get(DOCS)!(listingFor(DOCS)) })
// Nothing has committed yet; Escape supersedes the landing entirely.
fireEvent.keyDown(input, { key: 'Escape' })
await act(async () => { vi.advanceTimersByTime(200) })
expect(columns()).toHaveLength(1)
expect(screen.queryByText('harness')).toBeNull()
expect(within(columns()[0]!).getByText('Documents')).toBeTruthy()
expect(screen.queryByLabelText('browser.editPath', { selector: 'input' })).toBeNull()
} finally {
vi.useRealTimers()
}
})
it('shows the loading indicator only once a scan outlives its silence window, floating over the stale view', async () => {
vi.useFakeTimers()
try {
const { settlers, listDirectory } = manualLister()
mount({ listDirectory })
await act(async () => {})
expect(screen.queryByText('browser.loading')).toBeNull()
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' })
// In flight but still inside the silence window: nothing shows.
expect(screen.queryByText('browser.loading')).toBeNull()
await act(async () => { vi.advanceTimersByTime(300) })
// Past it: the indicator floats while the stale level keeps rendering.
expect(screen.getByRole('status').textContent).toBe('browser.loading')
expect(screen.getByText('Documents')).toBeTruthy()
// Landing (both legs) retires the indicator with the scan.
await act(async () => { settlers.get(DOCS)!(listingFor(DOCS)) })
await act(async () => { settlers.get(HOME)!(listingFor(HOME)) })
expect(screen.queryByText('browser.loading')).toBeNull()
expect(columns()).toHaveLength(2)
} finally {
vi.useRealTimers()
}
})
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