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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)。
|
||||
|
||||
## 模型体验
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() })
|
||||
|
||||
Reference in New Issue
Block a user