fix(directory-picker-browse): advertise the path editor and walk the panes with the draft

The Select Workspace Directory dialog hid its one route into typing a path
behind an invisible click target, and once the editor opened the panes stayed
on whatever level was listed when it opened — so the typed text and the list
under it disagreed for the whole edit.

The edit zone now carries a pencil glyph at the bar's right edge and lights in
the editor's own footprint on hover/focus (the bar keeps one height across the
swap). While editing, the panes follow the draft: a directory part no pane
lists is scanned after a 250ms rest and lands in place, so typing deeper
descends and erasing segments steps back up without leaving the editor, and a
final segment nobody matches releases the prefix filter instead of emptying
the pane it is being spelled into. The draft-following scan is speculative and
silent on failure; Enter still owns the view from submission until landing and
remains the only path that surfaces an error.
This commit is contained in:
creatixchu
2026-08-03 11:24:35 +08:00
parent 8bf606ba80
commit 79072e356c
11 changed files with 345 additions and 45 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/host/directory-picker-browse/README.md
README.md: 52b5fe7e89f915be3b50324628e9d5c48f1ef94c
README.zh.md: 742da39470083887a71ddba4a7c8012f0ce0ea1f
README.md: 7cb0ec785766e954ff4bb39df6825ee7e8c9d821
README.zh.md: ec71a90bbcd004ec9f9c0d8a9a236882a7487b73

View File

@@ -6,7 +6,7 @@ The **in-app browsing backend** of the [directory-picker seam](../directory-pick
Behavior facts: listings return **directories only**, name-sorted, with symlinks-to-directories followed (broken/cyclic links skipped — the probe `stat` failing means "not enterable") and a host-owned `hidden` flag (POSIX dot convention) left for the client to act on; `crumbs` is the root-to-target ancestor chain, the root crumb labeled by its full path (`/`, `C:\`); an absent `list` path means the host account's home directory. `createDirectory` is non-recursive (a missing parent is a real failure, not a level to invent) and validates the name as a single non-blank segment even when called directly, mirroring the wire schema's fence. Both primitives reject an explicit path that is not fully qualified — relative forms, and on Windows the rooted drive-less forms (`\foo`, `/foo`) and incomplete UNC prefixes (`\\`, `\\server`) that `isAbsolute` accepts — with `directory-unreadable`/`directory-create-failed`, instead of letting `resolve` rebase it under the host process cwd or current drive. One `list` call returns at most `maxEntries` rows (config, default 1000 — the bound GitHub's web UI applies to directory listings), and the level streams through a bounded window so memory stays O(maxEntries) no matter how many children the directory holds: a cut level keeps the name-sorted head, counts hidden rows against the bound, probes only windowed candidates, and reports `truncated: true` so the client can say the level is incomplete (a windowed broken symlink is not backfilled from beyond the window — the eviction already marks the level truncated); window insertion is binary with an O(1) full-window tail rejection, and `list` threads the caller's `AbortSignal` so a disconnect or timeout stops the scan instead of letting it outlive the caller. Failures throw the seam's typed `DirectoryPickerError`. Policy rationale: [the directory-picker capability seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md).
**Dual-face package**: the browser half (`./client`) fills [ui-workspace's](../../client/ui-workspace/README.md) two directory-flow holes with the in-app **Select Workspace Directory** dialog (figma `Harness` 813-23126 family — Miller two-column view whose navigations land selection-anchored 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 lit on hover in the editor's own footprint, whose editor seeds a trailing separator and then keeps the panes under the draft: the final segment prefix-filters the level its 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 a directory part no pane lists is scanned after a 250ms rest and shown in place, so typing deeper descends and erasing segments steps back up without leaving the editor — 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); 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

View File

@@ -6,7 +6,7 @@
行为事实:列举**只返回目录**、按名称排序,指向目录的符号链接会被跟随(断链/循环链接被跳过——探测 `stat` 失败即"不可进入"),并携带宿主判定的 `hidden` 标志(POSIX 点前缀约定),展示决策留给客户端;`crumbs` 是从根到目标的祖先链,根 crumb 以完整路径标注(`/`、`C:\`);`list` 不带路径即列举宿主账户的家目录。`createDirectory` 不递归(父目录缺失是真实失败,不是要补造的层级),且即便被直接调用也把名称校验为单个非空段,与协议 schema 的栅栏一致。两个原语都拒绝非完全限定的显式路径——相对形态,以及 Windows 上 `isAbsolute` 会放行的无盘符有根形态(`\foo`、`/foo`)与不完整的 UNC 前缀(`\\`、`\\server`)——报 `directory-unreadable`/`directory-create-failed`,而不是任由 `resolve` 把它重定位到宿主进程 cwd 或当前盘符之下。单次 `list` 至多返回 `maxEntries` 行(配置项,默认 1000——GitHub 网页端对目录列举采用的同一上限),且层级以流式方式经过一个有界窗口,无论目录有多少子项内存都保持 O(maxEntries):被截断的层级保留按名排序的头部、隐藏行计入上限、只探测窗口内候选,并报告 `truncated: true`,供客户端提示层级不完整(窗口内的断链符号链接不会从窗口外回填——发生过驱逐本身已把层级标记为截断);窗口插入为二分查找、满窗尾部单次比较即拒绝,且 `list` 透传调用方的 `AbortSignal`,断连或超时会停止扫描而不是让它在调用方离开后继续。失败抛出 seam 的类型化 `DirectoryPickerError`。策略依据:[目录选择能力 seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md)。
**双面包**:browser half(`./client`)以应用内 **选择工作区目录** 对话框(figma `Harness` 813-23126 家族——Miller 双列视图,其导航以选中项为锚、安静落地:扫描 crumb 跳转或提交的路径期间,先前视图持续渲染("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 后被扫描并就地展示,于是继续键入即下潜、删掉末段即上退,全程不必离开编辑器——推测性扫描失败时保持沉默,而 Enter 仍按确切文本导航,并在落地前独占视图;编辑器按 Escape 或焦点离开对话框卡片即取消(窗口/标签页切换与卡片内焦点移动保留草稿);基于宿主 `hidden` 标志、标签固定的"显示隐藏"footer 开关,键入以点开头的前缀会显出其匹配项,且当前选中项不受任何过滤影响;嵌套新建文件夹对话框)填入 [ui-workspace](../../client/ui-workspace/README.md) 的两个目录流洞,驱动 `host.listDirectory`/`host.createDirectory`,并注册自己的 locale 命名空间(`directory-browser`,zh 默认/en)。因此一行 cordis.yml 同时组合浏览交互的两侧;client 侧不含任何能力 kind 分支,挂载第二个流程包会在加载期失败(洞为 `single` kind)。
## 模型体验

View File

@@ -53,7 +53,8 @@
display: flex;
align-items: center;
gap: 4px;
min-height: 20px;
/* The path editor's height: crumb mode and edit mode occupy the same bar. */
min-height: 24px;
}
/* Deep chains scroll inside the trail (the effect pins the tail into view)
@@ -118,17 +119,52 @@
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 zone itself stays flush with the crumbs; the
* pencil glyph seated at its right edge is the standing affordance, and
* hover/focus lights the zone in the editor's own rounded shape so the
* gesture reads before the click. */
.crumbEditZone {
display: flex;
align-items: center;
justify-content: flex-end;
flex: 1 0 34px;
min-width: 34px;
align-self: stretch;
border: none;
/* The editor's own height, so hover previews the input's exact footprint
* and the bar does not resize when the two swap. */
height: 24px;
padding: 0 6px;
border: 1px solid transparent;
border-radius: 8px;
background: transparent;
cursor: text;
}
.crumbEditZone:hover,
.crumbEditZone:focus-visible {
border-color: var(--dsw-alias-border-l2);
outline: none;
}
.crumbEditGlyph {
flex: none;
color: var(--dsw-alias-label-tertiary);
}
.crumbEditZone:hover .crumbEditGlyph,
.crumbEditZone:focus-visible .crumbEditGlyph {
color: var(--dsw-alias-label-primary);
}
.crumbEditZone:disabled {
border-color: transparent;
cursor: default;
}
.crumbEditZone:disabled .crumbEditGlyph {
color: var(--dsw-alias-label-caption);
}
.pathInput {
box-sizing: border-box;
flex: 1 1 0;

View File

@@ -18,15 +18,20 @@
* 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 hover-lit zone, opens seeded with a trailing
* separator, and keeps the panes under the draft: the final segment
* prefix-filters the level its directory part names (a dot-led prefix also
* reveals the hidden entries it names, and a prefix nobody matches releases
* the filter), while a directory part no pane lists is scanned after a short
* debounce and shown in place — so typing deeper descends and erasing
* segments steps back up without leaving the editor.
*/
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 +80,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 +114,84 @@ function separatorOf(listing: DirectoryListing): '\\' | '/' {
return listing.home.includes('\\') ? '\\' : '/'
}
/** 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)
return listing.path.endsWith(sep) ? listing.path : `${listing.path}${sep}`
}
/**
* 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 separator comes from `listing`, so the caller
* passes any listing of the host's filesystem.
*/
function draftDirectory(listing: DirectoryListing, draft: string): string | null {
const cut = draft.lastIndexOf(separatorOf(listing))
return cut === -1 ? null : draft.slice(0, cut + 1)
}
/**
* 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.
* exactly (it is the host's own path text, reached by seeding, erasing, or a
* draft-following scan); only the name filter downstream is case-insensitive.
*/
function draftPrefixFor(listing: DirectoryListing, draft: string | null): string | null {
if (draft === null) return null
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
const directory = draftDirectory(listing, draft)
if (directory === null) return null
return directory === levelDirectory(listing) ? draft.slice(directory.length) : null
}
/**
* The directory a draft addresses that no rendered pane lists — the level the
* editor must scan for the panes to keep following the typed path. Null when
* a pane already lists it (the prefix filter alone answers the draft), when
* no separator has been typed yet, and when no level is listed at all: the
* platform separator is read off a listing, so the editor's
* failed-home-listing recovery path types blind until Enter.
*/
function pendingPreviewDirectory(
parent: DirectoryListing | null,
child: DirectoryListing | null,
draft: string | null,
): string | null {
if (parent === null || draft === null) return null
const directory = draftDirectory(parent, draft)
if (directory === null || directory === levelDirectory(parent)) return null
if (child !== null && directory === levelDirectory(child)) return null
return directory
}
/**
* 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 matches it — 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, the dot-led reveal included).
*/
function visibleEntries(
entries: readonly DirectoryEntry[],
selectedPath: string | null,
showHidden: boolean,
filterPrefix: string | null,
): readonly DirectoryEntry[] {
const needle = filterPrefix === null ? '' : filterPrefix.toLowerCase()
const matches = (entry: DirectoryEntry): boolean => entry.name.toLowerCase().startsWith(needle)
const narrowing = needle !== '' && entries.some(matches)
// A dot-led prefix names hidden entries explicitly, so matching ones
// surface even while the toggle keeps the rest hidden.
const revealHidden = narrowing && needle.startsWith('.')
return entries.filter((entry) => {
if (entry.path === selectedPath) return true
if (narrowing && !matches(entry)) return false
return showHidden || !entry.hidden || revealHidden
})
}
/** One column of folder rows (the Miller view renders one or two of these). */
@@ -127,16 +204,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) => {
@@ -381,6 +449,41 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
})
}, [launchListing, pathDraft])
/**
* 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, so the rejected path is not immediately re-scanned as a preview.
*/
const previewSuspended = useRef(false)
/**
* List the directory the draft addresses and show it WITHOUT closing the
* editor: the level replaces the panes single-wide (the selection and its
* child preview belonged to the level the draft left), and the draft's
* final segment prefix-filters it from the next render on. Unlike Enter,
* this is speculative — half-typed directories are unreadable most of the
* time — so a failure keeps the last readable panes and stays silent,
* leaving submission to surface the real error. A landing clears a stale
* error for the same reason: it, not the launch, is what makes the message
* obsolete.
*/
const previewDraftLevel = useCallback((directory: string) => {
const { seq, scan } = launchListing(directory)
setLoading(true)
scan.then((level) => {
if (seq !== requestSeq.current) return
setParent(level)
setSelected(null)
setChild(null)
setLoading(false)
setError(null)
}, () => {
if (seq !== requestSeq.current) return
setLoading(false)
})
}, [launchListing])
/** 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,6 +602,22 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
return () => { window.clearTimeout(timer) }
}, [loading, scanWindow])
// The panes follow the draft: a directory part no pane lists is scanned
// once the typing rests. The dependency is the target STRING, so the
// landing it commits cannot re-arm the timer (a host that answers with a
// differently spelled path leaves the target unchanged, hence unrepeated),
// and every further keystroke replaces the pending timer instead of
// queueing another scan.
const previewDirectory = pendingPreviewDirectory(parent, child, pathDraft)
useEffect(() => {
if (previewDirectory === null) return
const timer = window.setTimeout(() => {
if (previewSuspended.current) return
previewDraftLevel(previewDirectory)
}, DRAFT_PREVIEW_DEBOUNCE_MS)
return () => { window.clearTimeout(timer) }
}, [previewDirectory, previewDraftLevel])
// 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'))
@@ -637,11 +756,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 +778,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 +791,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 +810,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 +830,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)
}
}

View File

@@ -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,
@@ -659,9 +664,14 @@ 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()
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 draft naming some other directory (or none) leaves the level whole.
fireEvent.change(input, { target: { value: 'no-separator' } })
expect(screen.getByRole('listitem').textContent).toBe('Documents')
@@ -679,17 +689,95 @@ describe('DirectoryBrowser', () => {
expect(input.value).toBe(`${DOCS}/`)
fireEvent.change(input, { target: { value: `${DOCS}/h` } })
expect(within(columns()[1]!).getByText('harness')).toBeTruthy()
// 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()[1]!).getByText('harness')).toBeTruthy()
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.
// pane and releases the right one — no scan, both levels are on screen.
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()
})
it('follows the draft into a directory no pane lists, and back up when segments are erased', 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')
// Typing past a separator addresses a level nobody shows: the panes
// follow it once the typing rests, and the tail filters the arrival.
fireEvent.change(input, { target: { value: `${DOCS}/h` } })
await waitFor(() => { expect(screen.getByText('harness')).toBeTruthy() })
expect(b.listDirectory.mock.calls.at(-1)?.[0]).toBe(`${DOCS}/`)
// Still editing: the panes moved under the draft, the editor stayed.
expect(screen.getByLabelText<HTMLInputElement>('browser.editPath').value).toBe(`${DOCS}/h`)
// Erasing back past the separator steps the panes up a level again.
fireEvent.change(input, { target: { value: `${HOME}/Do` } })
await waitFor(() => { expect(screen.getByText('Documents')).toBeTruthy() })
expect(b.listDirectory.mock.calls.at(-1)?.[0]).toBe(`${HOME}/`)
expect(columns()).toHaveLength(1)
})
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 () => {
mount()
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })