Merge latest web transcript base into manual compact
Build-review stack propagation round 1.
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: 23153881b84dcb71dfb05d4f297a5818c410ca77
|
||||
README.zh.md: d7010e2941a801ba6358082824330eaae46e42b7
|
||||
README.md: 52b5fe7e89f915be3b50324628e9d5c48f1ef94c
|
||||
README.zh.md: 742da39470083887a71ddba4a7c8012f0ce0ea1f
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)。
|
||||
|
||||
## 模型体验
|
||||
|
||||
|
||||
@@ -13,6 +13,12 @@
|
||||
height: min(500px, calc(100dvh - 32px));
|
||||
padding: 0;
|
||||
gap: 0;
|
||||
/* The Modal card is an l2 surface and the columns below scroll on it:
|
||||
* rebind the scrollbar indirection to the elevation pair here, on the
|
||||
* surface, so it inherits down to whichever descendant scrolls (the
|
||||
* rebinding contract in ui-theme styles/scrollbar.css). */
|
||||
--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2);
|
||||
--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2);
|
||||
}
|
||||
|
||||
/* Card-scope wrapper hosting the path editor's Escape and focus-leave
|
||||
@@ -146,6 +152,8 @@
|
||||
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;
|
||||
@@ -234,6 +242,10 @@
|
||||
.status,
|
||||
.error {
|
||||
padding: 4px;
|
||||
/* The loading pill occupies the opposite corner while a stale status stays
|
||||
* visible. Reserve its widest localized footprint so wrapped text cannot
|
||||
* run underneath it on a narrow card. */
|
||||
padding-right: 120px;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
}
|
||||
@@ -246,6 +258,23 @@
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
}
|
||||
|
||||
/* The slow-scan indicator floats over the content's bottom-RIGHT corner 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). Right,
|
||||
* not left: the truncated/error status rows flow at the bottom LEFT and
|
||||
* stay on screen through a scan, with their reserved right padding keeping
|
||||
* both legible even on a narrow card. After .status in the cascade — the
|
||||
* element carries both classes and this padding must win the
|
||||
* same-specificity race. */
|
||||
.loadingFloat {
|
||||
position: absolute;
|
||||
right: 16px;
|
||||
bottom: 8px;
|
||||
padding: 2px 8px;
|
||||
background: var(--dsw-alias-bg-layer-2);
|
||||
}
|
||||
|
||||
/* Footer: l3 separator on top, symmetric padding so the row sits vertically
|
||||
* centered in the bar; New-folder and the show-hidden toggle pin left. */
|
||||
.footerBar {
|
||||
|
||||
@@ -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,14 @@ 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` and `scanWindow` by the slow-scan effect below:
|
||||
// true only once the current listing call has been in flight for
|
||||
// SLOW_SCAN_DELAY_MS, so fast listings never render the indicator at all.
|
||||
const [slowScan, setSlowScan] = useState(false)
|
||||
// Every listing call owns a fresh silence window. `loading` may stay true
|
||||
// across a superseding row pick or across a navigation's target and parent
|
||||
// legs, so its boolean edge cannot identify the start of each scan.
|
||||
const [scanWindow, setScanWindow] = useState(0)
|
||||
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)
|
||||
@@ -209,13 +237,20 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
|
||||
return ++requestSeq.current
|
||||
}, [])
|
||||
|
||||
/** Hide any prior indicator and start a fresh silence window for one listing call. */
|
||||
const restartSlowScanWindow = useCallback((): void => {
|
||||
setSlowScan(false)
|
||||
setScanWindow(value => value + 1)
|
||||
}, [])
|
||||
|
||||
/** Launch one listing under a fresh controller so a later supersession can abort it. */
|
||||
const launchListing = useCallback((path: string | undefined): { seq: number; scan: Promise<DirectoryListing> } => {
|
||||
const seq = supersede()
|
||||
const controller = new AbortController()
|
||||
scanController.current = controller
|
||||
restartSlowScanWindow()
|
||||
return { seq, scan: listDirectory(path, controller.signal) }
|
||||
}, [supersede, listDirectory])
|
||||
}, [supersede, restartSlowScanWindow, listDirectory])
|
||||
|
||||
/**
|
||||
* Launch a follow-up listing under the CURRENT supersession seq: a newer
|
||||
@@ -224,21 +259,25 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
|
||||
const continueScan = useCallback((path: string): Promise<DirectoryListing> => {
|
||||
const controller = new AbortController()
|
||||
scanController.current = controller
|
||||
restartSlowScanWindow()
|
||||
return listDirectory(path, controller.signal)
|
||||
}, [listDirectory])
|
||||
}, [restartSlowScanWindow, 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 +285,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 +309,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)
|
||||
@@ -289,7 +343,15 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
|
||||
const pathInputRef = useRef<HTMLInputElement | null>(null)
|
||||
const editZoneRef = useRef<HTMLButtonElement | null>(null)
|
||||
|
||||
/** Select a row of the listed level and preview its children on the right. */
|
||||
/**
|
||||
* Select a row of the listed level and preview its children on the right.
|
||||
* Deliberately NOT one-frame like navigate(): a pick's first duty is the
|
||||
* immediate selected state on the clicked row, and the pane split IS that
|
||||
* feedback (aria-current pill, crumbs following the selection) — holding
|
||||
* it back for the child listing would make clicks feel dropped. The quiet
|
||||
* rule governs whole-view replacement, where nothing acknowledges the
|
||||
* click but the swap itself.
|
||||
*/
|
||||
const select = useCallback((entry: DirectoryEntry) => {
|
||||
const { seq, scan } = launchListing(entry.path)
|
||||
// A pick while the path editor is open adopts the (filtered) row and
|
||||
@@ -360,6 +422,11 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
|
||||
return
|
||||
}
|
||||
supersede()
|
||||
// Closing mid-scan leaves nothing to load: without this edge the
|
||||
// slow-scan effect keeps arming while hidden and the reopened dialog
|
||||
// would show the indicator on its first frame instead of waiting out a
|
||||
// fresh silence window (reopen's navigate() produces no loading edge).
|
||||
setLoading(false)
|
||||
setError(null)
|
||||
setPathDraft(null)
|
||||
setFolderDraft(null)
|
||||
@@ -396,6 +463,10 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
|
||||
// create target becomes the listed level and the new folder its selection.
|
||||
const { seq, scan } = launchListing(targetPath)
|
||||
setLoading(true)
|
||||
// Symmetric with navigate/select: a launched scan clears the stale
|
||||
// failure text (and keeps the floating indicator's corner the only
|
||||
// occupant of the content's right edge while it shows).
|
||||
setError(null)
|
||||
scan.then((level) => {
|
||||
/* v8 ignore next -- same fence as navigate/select; the modal blocks superseding input */
|
||||
if (seq !== requestSeq.current) return
|
||||
@@ -415,6 +486,19 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
|
||||
})
|
||||
}
|
||||
|
||||
// The slow-scan gate for the loading indicator: each listing call restarts
|
||||
// the timer even when a superseding scan or a navigation's parent leg keeps
|
||||
// `loading` continuously true. A settle inside its own 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, scanWindow])
|
||||
|
||||
// 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 +733,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>
|
||||
|
||||
@@ -111,6 +111,12 @@ function rowButton(item: HTMLElement): HTMLButtonElement {
|
||||
}
|
||||
|
||||
describe('DirectoryBrowser', () => {
|
||||
it('renders nothing and launches no listing while initially closed', () => {
|
||||
const b = mount({ open: false })
|
||||
expect(screen.queryByRole('dialog')).toBeNull()
|
||||
expect(b.listDirectory).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('opens at the Host home as one wide column, hides hidden entries, and roots the crumbs at Home', async () => {
|
||||
const b = mount()
|
||||
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
|
||||
@@ -235,39 +241,249 @@ 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 () => {
|
||||
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)
|
||||
it('lands the target single-pane at the wait bound, aborts a superseded parent leg on the wire, and drops its late resolution', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
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((path?: string, signal?: AbortSignal) => {
|
||||
signals.push(signal)
|
||||
if (path === HOME && ++homeCalls === 1) {
|
||||
return new Promise<DirectoryListing>((resolve) => { settlers.push(resolve) })
|
||||
}
|
||||
return Promise.resolve(listingFor(path))
|
||||
})
|
||||
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 settled but the parent leg hangs: inside the wait bound
|
||||
// nothing commits yet.
|
||||
await act(async () => {})
|
||||
expect(settlers).toHaveLength(1)
|
||||
expect(screen.getByLabelText('browser.editPath', { selector: 'input' })).toBeTruthy()
|
||||
// The wait bound expires: the target commits alone — editor closed,
|
||||
// single-pane DOCS level.
|
||||
await act(async () => { vi.advanceTimersByTime(200) })
|
||||
expect(screen.getByRole('listitem').textContent).toBe('harness')
|
||||
expect(screen.queryByLabelText('browser.editPath', { selector: 'input' })).toBeNull()
|
||||
expect(columns()).toHaveLength(1)
|
||||
// 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 act(async () => {})
|
||||
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(rowButton(screen.getByRole('listitem')).getAttribute('aria-current')).toBeNull()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 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) })
|
||||
})
|
||||
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' })
|
||||
// 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) })
|
||||
// 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(rowButton(screen.getByRole('listitem')).getAttribute('aria-current')).toBeNull()
|
||||
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' })
|
||||
// The target can consume most of the outer scan's silence window.
|
||||
await act(async () => { vi.advanceTimersByTime(250) })
|
||||
await act(async () => { settlers.get(DOCS)!(listingFor(DOCS)) })
|
||||
// Its parent leg gets a fresh silence window. Crossing the original
|
||||
// scan's 300ms deadline therefore cannot flash the indicator during the
|
||||
// bounded landing wait.
|
||||
await act(async () => { vi.advanceTimersByTime(199) })
|
||||
expect(screen.queryByText('browser.loading')).toBeNull()
|
||||
// The parent leg outlives PARENT_LEG_WAIT_MS: the target lands alone.
|
||||
await act(async () => { vi.advanceTimersByTime(1) })
|
||||
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 {
|
||||
// The home level is truncated so its note is on screen when the slow
|
||||
// scan starts: dropping the note's old !loading guard means it must
|
||||
// keep rendering through the scan, coexisting with the indicator.
|
||||
const settlers = new Map<string, (value: DirectoryListing) => void>()
|
||||
const listDirectory = vi.fn((path?: string, _signal?: AbortSignal) => {
|
||||
if (path === undefined) return Promise.resolve({ ...listingFor(path), truncated: true })
|
||||
return new Promise<DirectoryListing>((resolve) => { settlers.set(path, resolve) })
|
||||
})
|
||||
mount({ listDirectory })
|
||||
await act(async () => {})
|
||||
expect(screen.queryByText('browser.loading')).toBeNull()
|
||||
expect(screen.getByText('browser.truncated')).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' })
|
||||
// In flight but still inside the silence window: no indicator, and the
|
||||
// stale level's truncated note stays put (no layout churn on launch).
|
||||
expect(screen.queryByText('browser.loading')).toBeNull()
|
||||
expect(screen.getByText('browser.truncated')).toBeTruthy()
|
||||
await act(async () => { vi.advanceTimersByTime(300) })
|
||||
// Past it: the indicator floats while the stale level — truncated note
|
||||
// included — keeps rendering beneath it.
|
||||
expect(screen.getByText('browser.loading')).toBeTruthy()
|
||||
expect(screen.getByText('browser.truncated')).toBeTruthy()
|
||||
expect(screen.getByText('Documents')).toBeTruthy()
|
||||
// Landing (both legs) retires the indicator with the scan, and the
|
||||
// fresh listings' own truncated state replaces the stale note.
|
||||
await act(async () => { settlers.get(DOCS)!(listingFor(DOCS)) })
|
||||
await act(async () => { settlers.get(HOME)!(listingFor(HOME)) })
|
||||
expect(screen.queryByText('browser.loading')).toBeNull()
|
||||
expect(screen.queryByText('browser.truncated')).toBeNull()
|
||||
expect(columns()).toHaveLength(2)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('restarts the silence window when a row pick supersedes a pending scan', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const pending: ((value: DirectoryListing) => void)[] = []
|
||||
const listDirectory = vi.fn((path?: string, _signal?: AbortSignal) => {
|
||||
if (path === undefined) return Promise.resolve(listingFor(path))
|
||||
return new Promise<DirectoryListing>((resolve) => { pending.push(resolve) })
|
||||
})
|
||||
mount({ listDirectory })
|
||||
await act(async () => {})
|
||||
const documents = rowButton(screen.getByRole('listitem'))
|
||||
fireEvent.click(documents)
|
||||
await act(async () => { vi.advanceTimersByTime(300) })
|
||||
expect(screen.getByText('browser.loading')).toBeTruthy()
|
||||
// The same row remains actionable while its preview is pending. A second
|
||||
// pick starts a new listing without a false `loading` edge.
|
||||
fireEvent.click(documents)
|
||||
expect(screen.queryByText('browser.loading')).toBeNull()
|
||||
await act(async () => { vi.advanceTimersByTime(299) })
|
||||
expect(screen.queryByText('browser.loading')).toBeNull()
|
||||
await act(async () => { vi.advanceTimersByTime(1) })
|
||||
expect(screen.getByText('browser.loading')).toBeTruthy()
|
||||
await act(async () => { pending.at(-1)!(listingFor(DOCS)) })
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('a close mid-scan resets the slow-scan gate: reopening waits a fresh silence window', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
// Every home listing hangs: the initial open's scan is the one the
|
||||
// close interrupts, and the reopen's scan proves the fresh window.
|
||||
const settlers: ((value: DirectoryListing) => void)[] = []
|
||||
const listDirectory = vi.fn((_path?: string, _signal?: AbortSignal) =>
|
||||
new Promise<DirectoryListing>((resolve) => { settlers.push(resolve) }))
|
||||
const { view, props } = mount({ listDirectory })
|
||||
await act(async () => { vi.advanceTimersByTime(300) })
|
||||
expect(screen.getByText('browser.loading')).toBeTruthy()
|
||||
// Close while the scan is in flight, then reopen: the first frame must
|
||||
// wait out a fresh silence window, not inherit the armed indicator.
|
||||
view.rerender(<DirectoryBrowser {...props} open={false} />)
|
||||
view.rerender(<DirectoryBrowser {...props} open />)
|
||||
await act(async () => {})
|
||||
expect(screen.queryByText('browser.loading')).toBeNull()
|
||||
await act(async () => { vi.advanceTimersByTime(300) })
|
||||
expect(screen.getByText('browser.loading')).toBeTruthy()
|
||||
// The reopened scan settles normally.
|
||||
await act(async () => { settlers.at(-1)!(listingFor(undefined)) })
|
||||
expect(screen.queryByText('browser.loading')).toBeNull()
|
||||
expect(screen.getByText('Documents')).toBeTruthy()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps the single-pane landing when the truncated parent level lacks the target', async () => {
|
||||
|
||||
Reference in New Issue
Block a user