Merge remote-tracking branch 'origin/master' into xtr/react-loop-simplification
# Conflicts: # apps/cli/tests/snapshots/queued-manual-compact/terminal.expected.txt # apps/web/tests/snapshots/queue-actions/preserved.expected.md # packages/client/runtime/README.i18n.yaml # packages/client/runtime/src/client/sessions/session.ts # packages/client/ui-conversation/README.md # packages/client/ui-conversation/README.zh.md # packages/ui/tui/README.i18n.yaml # packages/ui/tui/tests/snapshots/untrusted-controls.expected.txt
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: 52b5fe7e89f915be3b50324628e9d5c48f1ef94c
|
||||
README.zh.md: 742da39470083887a71ddba4a7c8012f0ce0ea1f
|
||||
README.md: 62384cc0b0e5756e56c1d608252721c506a0915f
|
||||
README.zh.md: 8f495e1e4d87486d0565eadcbf7df694494c7096
|
||||
|
||||
@@ -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 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).
|
||||
**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, advertised by the pencil glyph at the bar's right edge and lighting the whole bar — the editor's own box — on hover, whose editor seeds a trailing separator and then keeps the panes under the draft: the final segment prefix-filters the LAST pane while that pane lists the level the directory part names (case-insensitively, over the listed — possibly truncated — rows only; a tail nobody matches releases the filter instead of emptying the pane), while any other directory part is scanned after a 250ms rest and lands like any other navigation — selection-anchored, two-pane away from the display root, both legs waited out so one keystroke moves the view once — so typing deeper descends and erasing segments walks back up without leaving the editor; the pane arity is the invariant, the last pane always listing the level the path names with its parent beside it (only that level's own tail costs no scan, and only a display root lists alone), and a level still answers the text that produced it after the Host resolved it (`..` segments, Windows forward slashes) — a speculative scan is silent when it fails, and Enter still navigates by the exact text, owning the view until it lands; the editor cancels on Escape or when focus leaves the dialog card (window/tab switches and in-card focus moves keep the draft), and panes the draft walked to stay where the walk ended — the crumbs name that level and Open's fallback target follows them, so cancelling closes the editor rather than rewinding the walk; 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 every filter; 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 跳转或提交的路径期间,先前视图持续渲染("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)。
|
||||
**双面包**:browser half(`./client`)以应用内 **选择工作区目录** 对话框(figma `Harness` 813-23126 家族——Miller 双列视图,其导航以选中项为锚、安静落地:扫描 crumb 跳转或提交的路径期间,先前视图持续渲染("Loading…" 胶囊仅在扫描超出 300ms 静默窗口后才浮于其上,绝不挪动各列),随后目标与父层级两程以单个双栏帧落地,目标被重新选中为其在父层级中的实际条目——因此后退绝不塌缩,也没有中间帧闪现(父层级这一程超出其 200ms 等待上限时,目标单独落地,随后就地升级;父层级这一程失败或被截断时保持单栏落地;展示根保持单个宽层级);带点击即编辑路径区的面包屑,该区由栏右端的铅笔图标点明,悬停时整条栏——也就是编辑器自身的那只框——亮起,其编辑器预填尾随分隔符,随后让下方各栏跟随草稿:当最后一栏正是目录部分所指的层级时,末段对这一栏做前缀过滤(不区分大小写,且仅作用于已列出、可能被截断的行;无一匹配的末段会解除过滤,而不是把该栏清空),而其余任何目录部分都会在停顿 250ms 后被扫描,并像其他任何一次导航那样落地——以选中项为锚,在展示根之外即双栏,且两程都等齐,于是一次按键只让视图移动一次——继续键入即下潜、删掉末段即上退,全程不必离开编辑器;分栏个数是这里的不变量:最后一栏永远是路径所指的那一层,其上一层在它旁边(只有这一层自己的末段不触发扫描,也只有展示根会独占一栏),而宿主规范化过路径之后(`..` 段、Windows 的正斜杠),该层级仍然应答产生它的那段文本——推测性扫描失败时保持沉默,而 Enter 仍按确切文本导航,并在落地前独占视图;编辑器按 Escape 或焦点离开对话框卡片即取消(窗口/标签页切换与卡片内焦点移动保留草稿),而草稿走到的层级会留在原地——面包屑指明该层级、Open 的兜底目标随之而动,因此取消只是关闭编辑器,并不回退这段行走;基于宿主 `hidden` 标志、标签固定的"显示隐藏"footer 开关,键入以点开头的前缀会显出其匹配项,且当前选中项不受任何过滤影响;嵌套新建文件夹对话框)填入 [ui-workspace](../../client/ui-workspace/README.md) 的两个目录流洞,驱动 `host.listDirectory`/`host.createDirectory`,并注册自己的 locale 命名空间(`directory-browser`,zh 默认/en)。因此一行 cordis.yml 同时组合浏览交互的两侧;client 侧不含任何能力 kind 分支,挂载第二个流程包会在加载期失败(洞为 `single` kind)。
|
||||
|
||||
## 模型体验
|
||||
|
||||
|
||||
@@ -49,11 +49,29 @@
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
/* The bar IS the editor's box in both modes: it carries the rounded outline
|
||||
* and the inner padding, the crumbs and the input sit inside it, and hovering
|
||||
* the edit zone lights the whole row rather than the remainder right of the
|
||||
* crumbs. The negative left margin pays back the border and padding, so the
|
||||
* crumb (and input) text keeps the column the title sits in. */
|
||||
.crumbBar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
min-height: 20px;
|
||||
box-sizing: border-box;
|
||||
min-height: 24px;
|
||||
margin-left: -9px;
|
||||
padding: 0 8px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
/* Lit by the affordance the row belongs to, never by a crumb: a crumb's hover
|
||||
* offers navigation, not path entry. Editing keeps the outline standing. */
|
||||
.crumbBar:has(.crumbEditZone:enabled:hover),
|
||||
.crumbBar:has(.crumbEditZone:focus-visible),
|
||||
.crumbBar:has(.pathInput) {
|
||||
border-color: var(--dsw-alias-border-l2);
|
||||
}
|
||||
|
||||
/* Deep chains scroll inside the trail (the effect pins the tail into view)
|
||||
@@ -118,25 +136,50 @@
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
/* The empty remainder of the bar: invisible, but a real click target that
|
||||
* flips the bar into path-edit mode. */
|
||||
/* The empty remainder of the bar: a real click target that flips the bar into
|
||||
* path-edit mode. The pencil glyph seated at its right edge is the standing
|
||||
* affordance; the outline the gesture lights belongs to the bar, so the whole
|
||||
* row reads as the box the input will occupy. */
|
||||
.crumbEditZone {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
flex: 1 0 34px;
|
||||
min-width: 34px;
|
||||
align-self: stretch;
|
||||
height: 22px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: text;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.crumbEditGlyph {
|
||||
flex: none;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.crumbEditZone:enabled:hover .crumbEditGlyph,
|
||||
.crumbEditZone:focus-visible .crumbEditGlyph {
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.crumbEditZone:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.crumbEditZone:disabled .crumbEditGlyph {
|
||||
color: var(--dsw-alias-label-caption);
|
||||
}
|
||||
|
||||
/* Chrome-free: the bar around it draws the box (border, radius, padding). */
|
||||
.pathInput {
|
||||
box-sizing: border-box;
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
height: 24px;
|
||||
padding: 0 8px;
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
border-radius: 8px;
|
||||
height: 22px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
font-size: 13px;
|
||||
|
||||
@@ -18,15 +18,27 @@
|
||||
* owning flow decides what "Open" means and owns the workspace-creation
|
||||
* error surface. Hidden entries are host-flagged and hidden by default; the
|
||||
* footer's fixed-label "Show hidden files" toggle (aria-pressed, check when
|
||||
* on) reveals them (client-side only). The path editor opens seeded with a
|
||||
* trailing separator, and while the draft's directory part names a listed
|
||||
* level, its final segment prefix-filters that level's rows (a dot-led
|
||||
* prefix also reveals the hidden entries it names).
|
||||
* on) reveals them (client-side only). The path editor announces itself with
|
||||
* a pencil glyph and a bar-wide hover-lit outline, opens seeded with a
|
||||
* trailing separator, and keeps the panes under the draft: the final segment
|
||||
* prefix-filters the LAST pane while that pane's level is the one the draft's
|
||||
* directory part names (a dot-led prefix also reveals the hidden entries it
|
||||
* names, and a prefix nobody matches releases the filter), while any other
|
||||
* directory part is scanned after a short debounce and lands like any other
|
||||
* navigation — selection-anchored and two-pane away from the display root,
|
||||
* both legs waited out so one keystroke moves the view once. The pane arity
|
||||
* holds throughout: the last pane is the level the path names and the one
|
||||
* beside it is its parent, so typing deeper descends and erasing segments
|
||||
* walks back up, moving the Miller view without leaving the editor. Panes the
|
||||
* draft walked to stay put when the editor closes (cancellation included):
|
||||
* the crumbs name where the walk ended, and Open's fallback target follows
|
||||
* them.
|
||||
*/
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import {
|
||||
Button, IconCheckOutline16, IconChevronRightOutline14, IconFolderClose16, IconFolderOpen16, IconPlusOutline16, Modal,
|
||||
Button, IconCheckOutline16, IconChevronRightOutline14, IconEditOutline16, IconFolderClose16, IconFolderOpen16,
|
||||
IconPlusOutline16, Modal,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { DirectoryEntry, DirectoryListing } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { DirectoryBrowseError } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
@@ -75,6 +87,15 @@ const SLOW_SCAN_DELAY_MS = 300
|
||||
*/
|
||||
const PARENT_LEG_WAIT_MS = 200
|
||||
|
||||
/**
|
||||
* How long a typed draft rests before the panes follow it to a directory no
|
||||
* pane lists. The window absorbs the keystrokes that walk through
|
||||
* intermediate directory parts (every character of `/usr/lo` past the
|
||||
* separator would otherwise be its own scan) while staying short enough that
|
||||
* a pause reads as "the list moved with me".
|
||||
*/
|
||||
const DRAFT_PREVIEW_DEBOUNCE_MS = 250
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -100,21 +121,89 @@ function separatorOf(listing: DirectoryListing): '\\' | '/' {
|
||||
return listing.home.includes('\\') ? '\\' : '/'
|
||||
}
|
||||
|
||||
/**
|
||||
* The path draft's final segment, when its directory part is exactly the
|
||||
* level `listing` lists — the segment the level prefix-filters on while the
|
||||
* user types. Any other draft (no separator yet, or naming some other
|
||||
* directory) leaves the level unfiltered. The directory part compares
|
||||
* exactly (it is the host's own path text, reached by seeding or erasing);
|
||||
* only the name filter downstream is case-insensitive.
|
||||
*/
|
||||
function draftPrefixFor(listing: DirectoryListing, draft: string | null): string | null {
|
||||
if (draft === null) return null
|
||||
/** The listed level as a directory part: its own path, separator-terminated (the root already is). */
|
||||
function levelDirectory(listing: DirectoryListing): string {
|
||||
const sep = separatorOf(listing)
|
||||
const cut = draft.lastIndexOf(sep)
|
||||
if (cut === -1) return null
|
||||
const level = listing.path.endsWith(sep) ? listing.path : `${listing.path}${sep}`
|
||||
return draft.slice(0, cut + 1) === level ? draft.slice(cut + 1) : null
|
||||
return listing.path.endsWith(sep) ? listing.path : `${listing.path}${sep}`
|
||||
}
|
||||
|
||||
/** The directory text a draft-following scan last sent, with the level path the host answered it with. */
|
||||
interface ScannedDirectory {
|
||||
/** The draft's directory part, verbatim as it went to the host. */
|
||||
readonly directory: string
|
||||
/** `path` of the listing that came back. */
|
||||
readonly landed: string
|
||||
}
|
||||
|
||||
/**
|
||||
* The draft's directory part — everything through its last separator — or
|
||||
* null while no separator has been typed at all (nothing addresses a
|
||||
* directory yet). The platform comes from `listing`: on Windows a forward
|
||||
* slash separates too (the host's `resolve` accepts either), while on POSIX a
|
||||
* backslash is a legal name character and never separates.
|
||||
*/
|
||||
function draftDirectory(listing: DirectoryListing, draft: string): string | null {
|
||||
const cut = separatorOf(listing) === '\\'
|
||||
? Math.max(draft.lastIndexOf('\\'), draft.lastIndexOf('/'))
|
||||
: draft.lastIndexOf('/')
|
||||
return cut === -1 ? null : draft.slice(0, cut + 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* How the draft reads against one level: the directory part it names, and —
|
||||
* when `listing` is the level that directory part addresses — the final
|
||||
* segment that prefix-filters it while the user types (case-insensitively,
|
||||
* downstream). A level answers a directory part when its own path is that
|
||||
* part, or when it is the level that very text just produced (`scanned`): the
|
||||
* host resolves what it is given, so `..` segments and Windows forward
|
||||
* slashes reach a level whose path spells the request differently.
|
||||
* @param listing - the level to read the draft against.
|
||||
* @param draft - the current path draft.
|
||||
* @param scanned - the last draft-following scan's directory and landing.
|
||||
* @returns the draft's directory part (null with no separator typed) and its
|
||||
* filtering tail (null when this level does not answer that directory).
|
||||
*/
|
||||
function readDraft(
|
||||
listing: DirectoryListing,
|
||||
draft: string,
|
||||
scanned: ScannedDirectory | null,
|
||||
): { directory: string | null; tail: string | null } {
|
||||
const directory = draftDirectory(listing, draft)
|
||||
if (directory === null) return { directory: null, tail: null }
|
||||
const answers = directory === levelDirectory(listing)
|
||||
|| (scanned !== null && scanned.directory === directory && scanned.landed === listing.path)
|
||||
return { directory, tail: answers ? draft.slice(directory.length) : null }
|
||||
}
|
||||
|
||||
/**
|
||||
* The rows one column renders. The selection is exempt from every filter: it
|
||||
* anchors the two-pane view (crumbs and the child pane point at it), so
|
||||
* neither the hidden filter after a dot-reveal pick nor a prefix miss may
|
||||
* orphan it. A prefix narrows the level only while some row it would actually
|
||||
* show matches — a tail nobody matches is a name being spelled, not a demand
|
||||
* for an empty pane, so the level shows whole and its hidden rows return to
|
||||
* obeying the toggle. Counting only displayable rows is what keeps that true:
|
||||
* were a hidden row ever to match a prefix that does not reveal it (today
|
||||
* `hidden` means dot-prefixed, so it cannot), the level would narrow to
|
||||
* nothing.
|
||||
*/
|
||||
function visibleEntries(
|
||||
entries: readonly DirectoryEntry[],
|
||||
selectedPath: string | null,
|
||||
showHidden: boolean,
|
||||
filterPrefix: string | null,
|
||||
): readonly DirectoryEntry[] {
|
||||
const needle = filterPrefix === null ? '' : filterPrefix.toLowerCase()
|
||||
// A dot-led prefix names hidden entries explicitly, so matching ones
|
||||
// surface even while the toggle keeps the rest hidden.
|
||||
const displayable = (entry: DirectoryEntry): boolean => showHidden || !entry.hidden || needle.startsWith('.')
|
||||
const matches = (entry: DirectoryEntry): boolean => displayable(entry) && entry.name.toLowerCase().startsWith(needle)
|
||||
const narrowing = needle !== '' && entries.some(matches)
|
||||
return entries.filter((entry) => {
|
||||
if (entry.path === selectedPath) return true
|
||||
if (narrowing) return matches(entry)
|
||||
return showHidden || !entry.hidden
|
||||
})
|
||||
}
|
||||
|
||||
/** One column of folder rows (the Miller view renders one or two of these). */
|
||||
@@ -127,16 +216,7 @@ function LevelColumn({ entries, selectedPath, busy, onPick, showHidden, filterPr
|
||||
filterPrefix: string | null
|
||||
pathEditing: boolean
|
||||
}) {
|
||||
const visible = entries.filter((entry) => {
|
||||
// The selection is exempt from both filters: it anchors the two-pane
|
||||
// view (crumbs and the child pane point at it), so neither the hidden
|
||||
// filter after a dot-reveal pick nor a prefix miss may orphan it.
|
||||
if (entry.path === selectedPath) return true
|
||||
if (filterPrefix !== null && !entry.name.toLowerCase().startsWith(filterPrefix.toLowerCase())) return false
|
||||
// A dot-led prefix names hidden entries explicitly, so matching ones
|
||||
// surface even while the toggle keeps the rest hidden.
|
||||
return showHidden || !entry.hidden || filterPrefix?.startsWith('.') === true
|
||||
})
|
||||
const visible = visibleEntries(entries, selectedPath, showHidden, filterPrefix)
|
||||
return (
|
||||
<div className={css.column} role="list">
|
||||
{visible.map((entry) => {
|
||||
@@ -264,27 +344,83 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
|
||||
}, [restartSlowScanWindow, listDirectory])
|
||||
|
||||
/**
|
||||
* Replace the whole view with a freshly navigated level. Away from the
|
||||
* Enter owns the view from submission until its navigation lands, so the
|
||||
* debounce timer the same keystrokes armed must not supersede it. Cleared
|
||||
* by the next edit (and by opening the editor); a failed submission leaves
|
||||
* it set until the operator edits again, so the rejected path is not
|
||||
* immediately re-scanned as a preview.
|
||||
*/
|
||||
const previewSuspended = useRef(false)
|
||||
|
||||
// The panes as the draft-following scan must read them when its wait
|
||||
// fires: current, but NOT a dependency of the wait (see the effect below).
|
||||
const viewRef = useRef<{ parent: DirectoryListing | null; child: DirectoryListing | null }>({ parent: null, child: null })
|
||||
useEffect(() => { viewRef.current = { parent, child } }, [parent, child])
|
||||
|
||||
// What the last draft-following scan asked for and what came back, so a
|
||||
// level still answers the text that produced it after the host respelled
|
||||
// it. Stale entries are harmless: a match needs both the directory text and
|
||||
// that level's own path, which together already mean the same directory.
|
||||
const scanned = useRef<ScannedDirectory | null>(null)
|
||||
|
||||
/**
|
||||
* A landed preview replaced the pane a keyboard operator may have Tabbed
|
||||
* onto, so the focus it drops is re-parked on the still-open editor (the
|
||||
* Modal has no focus trap). Consumed by the refocus effect below.
|
||||
*/
|
||||
const refocusPathInput = useRef(false)
|
||||
|
||||
/**
|
||||
* Replace the whole view with a freshly scanned 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.
|
||||
* target commits alone — single wide level, 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: a landing swaps the panes, it never blanks them.
|
||||
*
|
||||
* Two callers, one landing shape. A submitted path (Enter, a crumb) closes
|
||||
* the editor on arrival, announces its failure, and takes the wait bound —
|
||||
* it is answering a gesture, so it may not hang on a stalled parent. The
|
||||
* editor's own draft-following scan keeps all three to itself: it is
|
||||
* speculative, nothing waits on it, and the stale view keeps rendering, so
|
||||
* it waits for BOTH legs rather than flashing a single pane it would then
|
||||
* upgrade — one keystroke must move the view once. A failure leaves the
|
||||
* last readable panes standing and says nothing, while an arrival clears
|
||||
* the stale message and re-parks focus the swap dropped.
|
||||
* @param path - the level to list; absent lists the Host home directory.
|
||||
* @param options - `closeEditor` retires the path draft on arrival and
|
||||
* bounds the wait for the parent leg; `announce` surfaces a failure as the
|
||||
* dialog's alert.
|
||||
*/
|
||||
const navigate = useCallback((path?: string) => {
|
||||
const land = useCallback((path: string | undefined, options: { closeEditor: boolean; announce: boolean }) => {
|
||||
const { seq, scan } = launchListing(path)
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
if (options.announce) setError(null)
|
||||
// What every landing does once its panes are committed, whichever shape
|
||||
// committed them.
|
||||
const settle = (): void => {
|
||||
setLoading(false)
|
||||
if (options.closeEditor) {
|
||||
setPathDraft(null)
|
||||
return
|
||||
}
|
||||
setError(null)
|
||||
refocusPathInput.current = true
|
||||
}
|
||||
scan.then((target) => {
|
||||
if (seq !== requestSeq.current) return
|
||||
// The level the panes will present as current answers this exact
|
||||
// directory text, however the host respelled it (`..`, a Windows
|
||||
// forward slash): the tail filters, and the same text asks for no
|
||||
// second scan.
|
||||
if (!options.closeEditor && path !== undefined) scanned.current = { directory: path, landed: target.path }
|
||||
// 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
|
||||
@@ -294,8 +430,7 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
|
||||
setParent(target)
|
||||
setSelected(null)
|
||||
setChild(null)
|
||||
setLoading(false)
|
||||
setPathDraft(null)
|
||||
settle()
|
||||
}
|
||||
// Arity is label-independent: only the collapsed chain's depth decides.
|
||||
if (displayCrumbs(target, '').length < 2) { landSingle(); return }
|
||||
@@ -316,23 +451,29 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
|
||||
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)
|
||||
// here means the settlement is still this landing's own.
|
||||
settle()
|
||||
}, () => {
|
||||
// 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)
|
||||
// Only a submitted navigation is bounded: the walk waits both legs out
|
||||
// (see the contract above), and a keystroke aborts it if the operator
|
||||
// moves on first.
|
||||
if (options.closeEditor) window.setTimeout(landSingle, PARENT_LEG_WAIT_MS)
|
||||
}, (reason: unknown) => {
|
||||
if (seq !== requestSeq.current) return
|
||||
setLoading(false)
|
||||
setError(failureText(reason))
|
||||
if (options.announce) setError(failureText(reason))
|
||||
})
|
||||
}, [launchListing, continueScan])
|
||||
|
||||
/** Commit a submitted path (Enter, a crumb, the initial home listing): the editor closes, failures surface. */
|
||||
const navigate = useCallback((path?: string) => {
|
||||
land(path, { closeEditor: true, announce: true })
|
||||
}, [land])
|
||||
|
||||
// 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
|
||||
// input-focused Escape park on the crumb edit zone that replaces the
|
||||
@@ -381,6 +522,17 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
|
||||
})
|
||||
}, [launchListing, pathDraft])
|
||||
|
||||
/**
|
||||
* Walk the panes to the directory the draft addresses, WITHOUT closing the
|
||||
* editor. The landing is an ordinary one — selection-anchored and two-pane
|
||||
* away from the display root — so typing a path moves the Miller view
|
||||
* exactly as a crumb jump does, and the draft's final segment
|
||||
* prefix-filters the arrival from the next render on.
|
||||
*/
|
||||
const previewDraftLevel = useCallback((directory: string) => {
|
||||
land(directory, { closeEditor: false, announce: false })
|
||||
}, [land])
|
||||
|
||||
/** Abandon path editing (Escape or clicking away) and restore the crumb view. */
|
||||
const cancelPathEdit = useCallback(() => {
|
||||
// Cancel also withdraws a navigation the editor already launched: its
|
||||
@@ -499,8 +651,40 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
|
||||
return () => { window.clearTimeout(timer) }
|
||||
}, [loading, scanWindow])
|
||||
|
||||
// The panes follow the draft: EVERY keystroke replaces the pending timer,
|
||||
// and the target is decided when it fires, off the panes as they stand
|
||||
// then. Keying the wait on the draft (not on the directory part it names)
|
||||
// is what makes a keystroke that superseded an in-flight scan re-arm one,
|
||||
// and what lets an edit after a rejected submission release the hold the
|
||||
// submission took. The panes are read through a ref for the converse
|
||||
// reason: were they dependencies, the landing this commits would re-arm the
|
||||
// wait, and a host answering with a differently spelled path would scan
|
||||
// forever.
|
||||
useEffect(() => {
|
||||
if (pathDraft === null) return
|
||||
const timer = window.setTimeout(() => {
|
||||
if (previewSuspended.current) return
|
||||
// The level the panes present as current: it alone may answer the
|
||||
// draft, so anything else it names is a level to walk to.
|
||||
const current = viewRef.current.child ?? viewRef.current.parent
|
||||
if (current === null) return
|
||||
const { directory, tail } = readDraft(current, pathDraft, scanned.current)
|
||||
if (directory === null || tail !== null) return
|
||||
previewDraftLevel(directory)
|
||||
}, DRAFT_PREVIEW_DEBOUNCE_MS)
|
||||
return () => { window.clearTimeout(timer) }
|
||||
}, [pathDraft, previewDraftLevel])
|
||||
|
||||
// After the hooks: a closed dialog renders nothing and evaluates no copy.
|
||||
const crumbSource = child ?? parent
|
||||
// The draft's tail filters the level it names, which by the pane invariant
|
||||
// is the LAST pane — never a pane the draft has already walked away from.
|
||||
// Narrowing that stale pane would move the view twice for one keystroke:
|
||||
// once as it narrows, again as its landing replaces it. It holds still
|
||||
// instead, and the filter arrives with the level it belongs to.
|
||||
const typedPrefix = crumbSource === null || pathDraft === null
|
||||
? null
|
||||
: readDraft(crumbSource, pathDraft, scanned.current).tail
|
||||
const crumbs = crumbSource === null ? [] : displayCrumbs(crumbSource, t('browser.home'))
|
||||
const crumbTail = crumbs.at(-1)?.path
|
||||
useEffect(() => {
|
||||
@@ -523,6 +707,12 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
|
||||
// replacing the picked button's column — while Enter and an input-focused
|
||||
// Escape land on the crumb edit zone that replaces the input.
|
||||
useEffect(() => {
|
||||
if (refocusPathInput.current) {
|
||||
refocusPathInput.current = false
|
||||
// Only when the swap actually dropped focus to body: focus the operator
|
||||
// still holds (the input itself, a surviving row) stays theirs.
|
||||
if (document.activeElement === document.body) pathInputRef.current?.focus()
|
||||
}
|
||||
if (pathDraft !== null) return
|
||||
if (refocusPick.current) {
|
||||
refocusPick.current = false
|
||||
@@ -637,11 +827,17 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
{/* The empty zone right of the crumbs is the path-edit affordance. */}
|
||||
{/* The empty zone right of the crumbs is the path-edit
|
||||
* affordance: the whole remainder of the bar clicks into
|
||||
* the editor, and the pencil glyph parked at its right
|
||||
* edge (with the same tooltip) is what says so — an
|
||||
* invisible target the operator must guess at is the one
|
||||
* way into typing a path. */}
|
||||
<button
|
||||
type="button"
|
||||
className={css.crumbEditZone}
|
||||
aria-label={t('browser.editPath')}
|
||||
title={t('browser.editPath')}
|
||||
// Stays available with no listed level: when the home
|
||||
// listing itself fails, typing an absolute path is the one
|
||||
// remaining way forward.
|
||||
@@ -653,6 +849,7 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
|
||||
// otherwise close the editor via navigate's draft reset.
|
||||
supersede()
|
||||
setLoading(false)
|
||||
previewSuspended.current = false
|
||||
// Seed with a trailing separator so typing immediately
|
||||
// continues into child names (and prefix-filters below).
|
||||
// No listed level means nothing to seed from (the editor
|
||||
@@ -665,7 +862,9 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
|
||||
const sep = separatorOf(parent)
|
||||
setPathDraft(base.endsWith(sep) ? base : `${base}${sep}`)
|
||||
}}
|
||||
/>
|
||||
>
|
||||
<IconEditOutline16 size={14} className={css.crumbEditGlyph} />
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
: (
|
||||
@@ -682,6 +881,9 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
|
||||
// repopulate the view with the older path.
|
||||
supersede()
|
||||
setLoading(false)
|
||||
// A fresh edit releases the submission hold: the panes
|
||||
// may follow the new text wherever it points.
|
||||
previewSuspended.current = false
|
||||
setPathDraft(event.target.value)
|
||||
}}
|
||||
{...compositionGuard}
|
||||
@@ -699,6 +901,11 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
|
||||
// focus on the returning crumb edit zone (a failure
|
||||
// keeps the editor, so the flag waits until close).
|
||||
refocusEditZone.current = true
|
||||
// The submitted path owns the view now: a debounce
|
||||
// timer still pending from these keystrokes would
|
||||
// otherwise supersede this navigation and land the
|
||||
// draft's parent directory instead.
|
||||
previewSuspended.current = true
|
||||
navigate(pathDraft)
|
||||
}
|
||||
}
|
||||
@@ -716,7 +923,7 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
|
||||
busy={parentInert}
|
||||
onPick={select}
|
||||
showHidden={showHidden}
|
||||
filterPrefix={draftPrefixFor(parent, pathDraft)}
|
||||
filterPrefix={child === null ? typedPrefix : null}
|
||||
pathEditing={draftPending}
|
||||
/>
|
||||
)}
|
||||
@@ -728,7 +935,7 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
|
||||
busy={parentInert}
|
||||
onPick={advance}
|
||||
showHidden={showHidden}
|
||||
filterPrefix={draftPrefixFor(child, pathDraft)}
|
||||
filterPrefix={typedPrefix}
|
||||
pathEditing={draftPending}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -11,9 +11,14 @@ const HOME = '/home/u'
|
||||
const DOCS = `${HOME}/Documents`
|
||||
const HARNESS = `${DOCS}/harness`
|
||||
|
||||
/** Listing fake over a tiny fixed tree; unknown paths reject like the Host. */
|
||||
/**
|
||||
* Listing fake over a tiny fixed tree; unknown paths reject like the Host.
|
||||
* A trailing separator is dropped the way the Host's own `resolve` drops it,
|
||||
* so a directory part typed into the path editor addresses its level.
|
||||
*/
|
||||
function listingFor(path?: string): DirectoryListing {
|
||||
const target = path ?? HOME
|
||||
const asked = path ?? HOME
|
||||
const target = asked.length > 1 && asked.endsWith('/') ? asked.slice(0, -1) : asked
|
||||
const tree: Record<string, DirectoryListing> = {
|
||||
[HOME]: {
|
||||
path: HOME,
|
||||
@@ -647,7 +652,7 @@ describe('DirectoryBrowser', () => {
|
||||
})
|
||||
|
||||
it('prefix-filters the listed level from the draft tail, dot revealing hidden matches', async () => {
|
||||
mount()
|
||||
const b = mount()
|
||||
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
|
||||
const input = screen.getByLabelText<HTMLInputElement>('browser.editPath')
|
||||
@@ -659,16 +664,29 @@ describe('DirectoryBrowser', () => {
|
||||
// A dot-led prefix names hidden entries, so it reveals the match.
|
||||
fireEvent.change(input, { target: { value: `${HOME}/.co` } })
|
||||
expect(screen.getByRole('listitem').textContent).toBe('.config')
|
||||
// A prefix matching nothing empties the level (no stale rows linger).
|
||||
// A prefix nobody matches releases the filter: the level shows whole
|
||||
// (hidden rows back under the toggle) instead of emptying under a name
|
||||
// the operator is still spelling.
|
||||
fireEvent.change(input, { target: { value: `${HOME}/zzz` } })
|
||||
expect(screen.queryByRole('listitem')).toBeNull()
|
||||
// A draft naming some other directory (or none) leaves the level whole.
|
||||
expect(screen.getAllByRole('listitem').map(item => item.textContent)).toEqual(['Documents'])
|
||||
// Its dot-led reveal lapses with it.
|
||||
fireEvent.change(input, { target: { value: `${HOME}/.zzz` } })
|
||||
expect(screen.getAllByRole('listitem').map(item => item.textContent)).toEqual(['Documents'])
|
||||
// A tail inside the listed level names no level to walk to: the wait
|
||||
// fires and finds nothing to scan.
|
||||
const settled = b.listDirectory.mock.calls.length
|
||||
await act(async () => { await new Promise((resolve) => { setTimeout(resolve, 400) }) })
|
||||
expect(b.listDirectory.mock.calls).toHaveLength(settled)
|
||||
// A draft naming some other directory (or none) leaves the level whole —
|
||||
// and a draft with no separator at all addresses no directory either.
|
||||
fireEvent.change(input, { target: { value: 'no-separator' } })
|
||||
expect(screen.getByRole('listitem').textContent).toBe('Documents')
|
||||
await act(async () => { await new Promise((resolve) => { setTimeout(resolve, 400) }) })
|
||||
expect(b.listDirectory.mock.calls).toHaveLength(settled)
|
||||
})
|
||||
|
||||
it('filters the child pane in two-pane mode and follows the draft back up a level', async () => {
|
||||
mount()
|
||||
const b = mount()
|
||||
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
|
||||
fireEvent.click(rowButton(screen.getByRole('listitem')))
|
||||
await waitFor(() => { expect(columns()).toHaveLength(2) })
|
||||
@@ -679,15 +697,301 @@ describe('DirectoryBrowser', () => {
|
||||
expect(input.value).toBe(`${DOCS}/`)
|
||||
fireEvent.change(input, { target: { value: `${DOCS}/h` } })
|
||||
expect(within(columns()[1]!).getByText('harness')).toBeTruthy()
|
||||
// The child pane already lists that directory: no scan follows, and both
|
||||
// panes stay.
|
||||
const settled = b.listDirectory.mock.calls.length
|
||||
await act(async () => { await new Promise((resolve) => { setTimeout(resolve, 400) }) })
|
||||
expect(b.listDirectory.mock.calls).toHaveLength(settled)
|
||||
expect(columns()).toHaveLength(2)
|
||||
// A miss releases the right pane's filter rather than emptying it.
|
||||
fireEvent.change(input, { target: { value: `${DOCS}/zzz` } })
|
||||
expect(within(columns()[1]!).queryAllByRole('listitem')).toHaveLength(0)
|
||||
expect(within(columns()[0]!).getByText('Documents')).toBeTruthy()
|
||||
// Erasing back into the parent's own path moves the filter to the LEFT
|
||||
// pane and releases the right one. The selected row is exempt (it
|
||||
// anchors the two-pane view), so it alone survives the miss.
|
||||
fireEvent.change(input, { target: { value: `${HOME}/zz` } })
|
||||
expect(within(columns()[0]!).getAllByRole('listitem').map(item => item.textContent)).toEqual(['Documents'])
|
||||
expect(within(columns()[1]!).getByText('harness')).toBeTruthy()
|
||||
expect(within(columns()[0]!).getByText('Documents')).toBeTruthy()
|
||||
// Erasing back into the parent's own path re-lands on it rather than
|
||||
// filtering the LEFT pane: the level being typed is always the last pane,
|
||||
// never a pane with a deeper level standing to its right. Home is the
|
||||
// display root, so it lands alone.
|
||||
fireEvent.change(input, { target: { value: `${HOME}/zz` } })
|
||||
await waitFor(() => { expect(columns()).toHaveLength(1) })
|
||||
expect(screen.getAllByRole('listitem').map(item => item.textContent)).toEqual(['Documents'])
|
||||
})
|
||||
|
||||
it('follows the draft into a directory no pane lists, landing the two-pane Miller view', async () => {
|
||||
const b = mount()
|
||||
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
|
||||
expect(columns()).toHaveLength(1)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
|
||||
const input = screen.getByLabelText<HTMLInputElement>('browser.editPath')
|
||||
// Typing past a separator addresses a level nobody shows: the panes walk
|
||||
// to it once the typing rests, landing the ordinary selection-anchored
|
||||
// two-pane view (level | its children) with the tail filtering the right
|
||||
// pane — a typed path moves the Miller view exactly as a crumb jump does.
|
||||
fireEvent.change(input, { target: { value: `${DOCS}/h` } })
|
||||
await waitFor(() => { expect(columns()).toHaveLength(2) })
|
||||
expect(b.listDirectory).toHaveBeenCalledWith(`${DOCS}/`, expect.anything())
|
||||
expect(within(columns()[0]!).getByText('Documents')).toBeTruthy()
|
||||
expect(within(columns()[1]!).getByText('harness')).toBeTruthy()
|
||||
// Still editing: the panes moved under the draft, the editor stayed.
|
||||
expect(screen.getByLabelText<HTMLInputElement>('browser.editPath').value).toBe(`${DOCS}/h`)
|
||||
// Typing on inside a level the panes already list costs no scan at all:
|
||||
// the prefix filter alone answers the draft, both panes stay.
|
||||
const settled = b.listDirectory.mock.calls.length
|
||||
fireEvent.change(input, { target: { value: `${DOCS}/ha` } })
|
||||
await act(async () => { await new Promise((resolve) => { setTimeout(resolve, 400) }) })
|
||||
expect(b.listDirectory.mock.calls).toHaveLength(settled)
|
||||
expect(columns()).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('keeps the typed level in the last pane, its parent beside it, as the draft walks', async () => {
|
||||
const b = mount()
|
||||
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
|
||||
const input = screen.getByLabelText<HTMLInputElement>('browser.editPath')
|
||||
// Two levels down: the typed level on the right, its parent on the left.
|
||||
fireEvent.change(input, { target: { value: `${HARNESS}/` } })
|
||||
await waitFor(() => { expect(within(columns()[0]!).getByText('harness')).toBeTruthy() })
|
||||
expect(columns()).toHaveLength(2)
|
||||
expect(within(columns()[1]!).queryAllByRole('listitem')).toHaveLength(0)
|
||||
// Erasing back to the parent's own path re-lands on it: the level being
|
||||
// typed moves BACK into the last pane instead of staying on the left with
|
||||
// its own child pane still to the right.
|
||||
fireEvent.change(input, { target: { value: `${DOCS}/ha` } })
|
||||
await waitFor(() => { expect(within(columns()[0]!).getByText('Documents')).toBeTruthy() })
|
||||
expect(columns()).toHaveLength(2)
|
||||
expect(within(columns()[1]!).getAllByRole('listitem').map(item => item.textContent)).toEqual(['harness'])
|
||||
expect(b.listDirectory).toHaveBeenCalledWith(`${DOCS}/`, expect.anything())
|
||||
})
|
||||
|
||||
it('holds a stale pane still until its landing, instead of narrowing it first', async () => {
|
||||
// Own three-level tree: the level that goes stale needs two rows for the
|
||||
// narrowing this pins against to be visible at all.
|
||||
const ROOT = '/u'
|
||||
const MID = `${ROOT}/mid`
|
||||
const LEAF = `${MID}/leaf`
|
||||
const chain = [{ name: '/', path: '/', hidden: false }, { name: 'u', path: ROOT, hidden: false }]
|
||||
const tree: Record<string, DirectoryListing> = {
|
||||
[ROOT]: {
|
||||
path: ROOT,
|
||||
home: ROOT,
|
||||
crumbs: chain,
|
||||
entries: [{ name: 'mid', path: MID, hidden: false }, { name: 'other', path: `${ROOT}/other`, hidden: false }],
|
||||
truncated: false,
|
||||
},
|
||||
[MID]: {
|
||||
path: MID,
|
||||
home: ROOT,
|
||||
crumbs: [...chain, { name: 'mid', path: MID, hidden: false }],
|
||||
entries: [{ name: 'leaf', path: LEAF, hidden: false }, { name: 'sibling', path: `${MID}/sibling`, hidden: false }],
|
||||
truncated: false,
|
||||
},
|
||||
[LEAF]: {
|
||||
path: LEAF,
|
||||
home: ROOT,
|
||||
crumbs: [...chain, { name: 'mid', path: MID, hidden: false }, { name: 'leaf', path: LEAF, hidden: false }],
|
||||
entries: [],
|
||||
truncated: false,
|
||||
},
|
||||
}
|
||||
mount({
|
||||
listDirectory: vi.fn(async (path?: string) => {
|
||||
const asked = path ?? ROOT
|
||||
const found = tree[asked.length > 1 && asked.endsWith('/') ? asked.slice(0, -1) : asked]
|
||||
if (found === undefined) throw new Error(`cannot list ${asked}`)
|
||||
return found
|
||||
}),
|
||||
})
|
||||
await waitFor(() => { expect(screen.getByText('mid')).toBeTruthy() })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
|
||||
const input = screen.getByLabelText<HTMLInputElement>('browser.editPath')
|
||||
fireEvent.change(input, { target: { value: `${LEAF}/` } })
|
||||
await waitFor(() => { expect(columns()).toHaveLength(2) })
|
||||
expect(within(columns()[0]!).getAllByRole('listitem').map(item => item.textContent)).toEqual(['leaf', 'sibling'])
|
||||
// Deleting the separator names the level the LEFT pane lists. That pane
|
||||
// is stale — its landing will move it right — so it must not narrow to
|
||||
// the tail first: one deletion, one movement.
|
||||
fireEvent.change(input, { target: { value: LEAF } })
|
||||
expect(within(columns()[0]!).getAllByRole('listitem').map(item => item.textContent)).toEqual(['leaf', 'sibling'])
|
||||
await waitFor(() => { expect(within(columns()[0]!).getByText('other')).toBeTruthy() })
|
||||
expect(within(columns()[1]!).getAllByRole('listitem').map(item => item.textContent)).toEqual(['leaf'])
|
||||
})
|
||||
|
||||
it('keeps the walked-to panes when the editor is cancelled, Open adopting where the walk ended', async () => {
|
||||
const b = mount()
|
||||
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
|
||||
const input = screen.getByLabelText<HTMLInputElement>('browser.editPath')
|
||||
fireEvent.change(input, { target: { value: `${DOCS}/h` } })
|
||||
await waitFor(() => { expect(columns()).toHaveLength(2) })
|
||||
fireEvent.keyDown(input, { key: 'Escape' })
|
||||
// Cancel closes the editor; it does not rewind the walk. The operator
|
||||
// watched the panes move, so the crumbs, the panes, and Open's target all
|
||||
// stay where the walk ended.
|
||||
expect(screen.queryByLabelText('browser.editPath', { selector: 'input' })).toBeNull()
|
||||
expect(columns()).toHaveLength(2)
|
||||
expect(within(columns()[0]!).getByText('Documents')).toBeTruthy()
|
||||
expect(within(columns()[1]!).getByText('harness')).toBeTruthy()
|
||||
expect(screen.getByRole('navigation').textContent).toContain('Documents')
|
||||
const open = screen.getByRole<HTMLButtonElement>('button', { name: 'browser.open' })
|
||||
expect(open.disabled).toBe(false)
|
||||
fireEvent.click(open)
|
||||
expect(b.onOpen).toHaveBeenCalledWith(DOCS)
|
||||
})
|
||||
|
||||
it('waits both legs out for a walk: one keystroke never flashes a single pane', async () => {
|
||||
let landParent = (): void => {}
|
||||
const listDirectory = vi.fn(async (path?: string) => {
|
||||
// The parent leg outlives the submitted-navigation wait bound; a walk
|
||||
// has nothing waiting on it, so it holds the stale view instead of
|
||||
// landing single-pane and upgrading.
|
||||
if (path === HOME) return await new Promise<DirectoryListing>((resolve) => { landParent = () => { resolve(listingFor(HOME)) } })
|
||||
return listingFor(path)
|
||||
})
|
||||
mount({ listDirectory })
|
||||
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
|
||||
const input = screen.getByLabelText<HTMLInputElement>('browser.editPath')
|
||||
fireEvent.change(input, { target: { value: `${DOCS}/h` } })
|
||||
await waitFor(() => { expect(listDirectory).toHaveBeenCalledWith(HOME, expect.anything()) })
|
||||
await act(async () => { await new Promise((resolve) => { setTimeout(resolve, 400) }) })
|
||||
// Well past the submitted-navigation bound: still the pre-walk view.
|
||||
expect(columns()).toHaveLength(1)
|
||||
expect(screen.getByText('Documents')).toBeTruthy()
|
||||
await act(async () => { landParent() })
|
||||
await waitFor(() => { expect(columns()).toHaveLength(2) })
|
||||
expect(within(columns()[1]!).getByText('harness')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('walks the panes back up when erased segments leave the listed levels', async () => {
|
||||
const b = mount()
|
||||
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
|
||||
const input = screen.getByLabelText<HTMLInputElement>('browser.editPath')
|
||||
fireEvent.change(input, { target: { value: `${DOCS}/h` } })
|
||||
await waitFor(() => { expect(columns()).toHaveLength(2) })
|
||||
// Erasing back to a directory neither pane lists walks up to it; the
|
||||
// filesystem root is the display root, so it lands the single wide level
|
||||
// with the tail filtering it.
|
||||
fireEvent.change(input, { target: { value: '/ho' } })
|
||||
await waitFor(() => { expect(columns()).toHaveLength(1) })
|
||||
expect(b.listDirectory).toHaveBeenCalledWith('/', expect.anything())
|
||||
expect(screen.getAllByRole('listitem').map(item => item.textContent)).toEqual(['home'])
|
||||
})
|
||||
|
||||
it('re-arms the draft-following scan after a keystroke superseded one in flight', async () => {
|
||||
let started = 0
|
||||
const listDirectory = vi.fn(async (path?: string) => {
|
||||
if (path !== `${DOCS}/`) return listingFor(path)
|
||||
started += 1
|
||||
// The first scan never settles: the next keystroke aborts it, and only
|
||||
// a re-armed wait can still land the level the draft names.
|
||||
if (started === 1) return await new Promise<DirectoryListing>(() => {})
|
||||
return listingFor(path)
|
||||
})
|
||||
mount({ listDirectory })
|
||||
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
|
||||
const input = screen.getByLabelText<HTMLInputElement>('browser.editPath')
|
||||
fireEvent.change(input, { target: { value: `${DOCS}/h` } })
|
||||
await waitFor(() => { expect(started).toBe(1) })
|
||||
// A further tail keystroke supersedes the in-flight scan; the panes must
|
||||
// still follow, not sit on the stale level until a separator is typed.
|
||||
fireEvent.change(input, { target: { value: `${DOCS}/ha` } })
|
||||
await waitFor(() => { expect(screen.getByText('harness')).toBeTruthy() })
|
||||
})
|
||||
|
||||
it('follows the draft again after an edit releases a failed submission hold', async () => {
|
||||
const listDirectory = vi.fn(async (path?: string) => {
|
||||
if (path === HARNESS) throw new Error('target unreadable')
|
||||
return listingFor(path)
|
||||
})
|
||||
mount({ listDirectory })
|
||||
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
|
||||
const input = screen.getByLabelText<HTMLInputElement>('browser.editPath')
|
||||
// Submitting inside the debounce window holds the pending scan back.
|
||||
fireEvent.change(input, { target: { value: HARNESS } })
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
await waitFor(() => { expect(screen.getByRole('alert').textContent).toBe('target unreadable') })
|
||||
// Correcting only the final segment leaves the directory part unchanged;
|
||||
// the edit must still release the hold and re-arm the wait.
|
||||
fireEvent.change(input, { target: { value: `${HARNESS}x` } })
|
||||
await waitFor(() => { expect(listDirectory).toHaveBeenCalledWith(`${DOCS}/`, expect.anything()) })
|
||||
await waitFor(() => { expect(screen.getByText('harness')).toBeTruthy() })
|
||||
})
|
||||
|
||||
it('re-parks focus on the editor when a landed scan unmounts the focused row', async () => {
|
||||
mount()
|
||||
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
|
||||
const input = screen.getByLabelText<HTMLInputElement>('browser.editPath')
|
||||
// Two levels down, so the walk replaces the LEFT pane the focused row
|
||||
// lives in (a landing that re-lists the same level reuses its rows).
|
||||
fireEvent.change(input, { target: { value: `${HARNESS}/` } })
|
||||
// The keyboard path: focus Tabbed onto a row of the level about to be
|
||||
// replaced. Without a re-park it would fall to body, outside a Modal that
|
||||
// has no focus trap.
|
||||
rowButton(screen.getByRole('listitem')).focus()
|
||||
await waitFor(() => { expect(within(columns()[0]!).getByText('harness')).toBeTruthy() })
|
||||
expect(document.activeElement).toBe(screen.getByLabelText('browser.editPath'))
|
||||
})
|
||||
|
||||
it('keeps the panes and stays silent when a draft-following scan fails', async () => {
|
||||
const b = mount()
|
||||
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
|
||||
const input = screen.getByLabelText<HTMLInputElement>('browser.editPath')
|
||||
fireEvent.change(input, { target: { value: `${HOME}/nope/x` } })
|
||||
await waitFor(() => { expect(b.listDirectory).toHaveBeenCalledWith(`${HOME}/nope/`, expect.anything()) })
|
||||
// A half-typed directory is unreadable most of the time: the last
|
||||
// readable level keeps rendering and no error interrupts the typing.
|
||||
expect(screen.getByText('Documents')).toBeTruthy()
|
||||
expect(screen.queryByRole('alert')).toBeNull()
|
||||
})
|
||||
|
||||
it('holds the draft-following scan while a submitted path is in flight', async () => {
|
||||
const listDirectory = vi.fn(async (path?: string) => {
|
||||
// The submitted leg never settles, so the debounce window elapses with
|
||||
// the navigation still owning the view.
|
||||
if (path === HARNESS) return await new Promise<DirectoryListing>(() => {})
|
||||
return listingFor(path)
|
||||
})
|
||||
mount({ listDirectory })
|
||||
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
|
||||
const input = screen.getByLabelText<HTMLInputElement>('browser.editPath')
|
||||
fireEvent.change(input, { target: { value: HARNESS } })
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
await act(async () => { await new Promise((resolve) => { setTimeout(resolve, 400) }) })
|
||||
// Only the initial home listing and the submitted path — the draft's
|
||||
// directory part was never scanned behind the navigation's back.
|
||||
expect(listDirectory.mock.calls.map(call => call[0])).toEqual([undefined, HARNESS])
|
||||
})
|
||||
|
||||
it('discards draft-following scans that a newer edit superseded', async () => {
|
||||
let landDocs = (): void => {}
|
||||
let failRoot = (): void => {}
|
||||
const listDirectory = vi.fn(async (path?: string) => {
|
||||
if (path === `${DOCS}/`) return await new Promise<DirectoryListing>((resolve) => { landDocs = () => { resolve(listingFor(DOCS)) } })
|
||||
if (path === '/') {
|
||||
return await new Promise<DirectoryListing>((_, reject) => {
|
||||
failRoot = () => { reject(new Error('root unreadable')) }
|
||||
})
|
||||
}
|
||||
return listingFor(path)
|
||||
})
|
||||
mount({ listDirectory })
|
||||
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
|
||||
const input = screen.getByLabelText<HTMLInputElement>('browser.editPath')
|
||||
fireEvent.change(input, { target: { value: `${DOCS}/h` } })
|
||||
await waitFor(() => { expect(listDirectory).toHaveBeenCalledWith(`${DOCS}/`, expect.anything()) })
|
||||
fireEvent.change(input, { target: { value: '/x' } })
|
||||
await waitFor(() => { expect(listDirectory).toHaveBeenCalledWith('/', expect.anything()) })
|
||||
// Back onto the listed level: neither pending scan may still land.
|
||||
fireEvent.change(input, { target: { value: `${HOME}/D` } })
|
||||
await act(async () => { landDocs(); failRoot() })
|
||||
expect(screen.getAllByRole('listitem').map(item => item.textContent)).toEqual(['Documents'])
|
||||
expect(screen.queryByRole('alert')).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps the draft and filter through window focus loss and in-dialog focus moves', async () => {
|
||||
@@ -813,7 +1117,8 @@ describe('DirectoryBrowser', () => {
|
||||
],
|
||||
truncated: false,
|
||||
}
|
||||
mount({ listDirectory: vi.fn(async () => windowsListing) })
|
||||
const listDirectory = vi.fn(async () => windowsListing)
|
||||
mount({ listDirectory })
|
||||
await waitFor(() => { expect(screen.getAllByRole('listitem')).toHaveLength(2) })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
|
||||
const input = screen.getByLabelText<HTMLInputElement>('browser.editPath')
|
||||
@@ -821,6 +1126,18 @@ describe('DirectoryBrowser', () => {
|
||||
expect(input.value).toBe(ROOT)
|
||||
fireEvent.change(input, { target: { value: `${ROOT}u` } })
|
||||
expect(screen.getByRole('listitem').textContent).toBe('Users')
|
||||
// Windows separates on a forward slash too (so does the Host's resolve),
|
||||
// so a path typed that way names its directory; the level the Host
|
||||
// answers with spells it back with a backslash, and once that scan lands
|
||||
// the level answers the typed spelling — the tail filters it.
|
||||
fireEvent.change(input, { target: { value: 'C:/p' } })
|
||||
await waitFor(() => { expect(screen.getByRole('listitem').textContent).toBe('Program Files') })
|
||||
// And the same spelling asks for no second scan.
|
||||
const settled = listDirectory.mock.calls.length
|
||||
fireEvent.change(input, { target: { value: 'C:/pr' } })
|
||||
await act(async () => { await new Promise((resolve) => { setTimeout(resolve, 400) }) })
|
||||
expect(listDirectory.mock.calls).toHaveLength(settled)
|
||||
expect(screen.getByRole('listitem').textContent).toBe('Program Files')
|
||||
})
|
||||
|
||||
it('clicking away from the path editor cancels it back to the crumb view', async () => {
|
||||
@@ -977,6 +1294,10 @@ describe('DirectoryBrowser', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
|
||||
const input = screen.getByLabelText('browser.editPath')
|
||||
fireEvent.change(input, { target: { value: DOCS } })
|
||||
// With no level listed there is no platform separator to read, so the
|
||||
// draft-following wait resolves to nothing and the editor types blind.
|
||||
await act(async () => { await new Promise((resolve) => { setTimeout(resolve, 400) }) })
|
||||
expect(listDirectory).toHaveBeenCalledTimes(1)
|
||||
listDirectory.mockImplementation(async (path?: string) => listingFor(path))
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
await waitFor(() => { expect(screen.getByText('harness')).toBeTruthy() })
|
||||
|
||||
Reference in New Issue
Block a user