fix(host,client): review round 23 — create-path contract at every client declaration; close-edge resets; NFD tripwire; canonical double join

This commit is contained in:
creatixchu
2026-07-30 03:17:30 +08:00
parent de24461b91
commit df313b7531
10 changed files with 46 additions and 21 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: 01e2b9e5afcfd7c47a3f42a76cc1388a25477334
README.zh.md: 8cb63048713dc964f92762546de783d2ce6ce5a7
README.md: d6ed7181ffbec85d11e0abf2aa8d0053173ba4e9
README.zh.md: 67f5f2bc40297d96bc3fcd3cfba0a3fd26855adc

View File

@@ -19,6 +19,6 @@ None; this package neither assembles nor sends a provider request.
## Known Limitations and Deferred Work
- **Windows hidden attribute is not read** — Node dirents do not expose `FILE_ATTRIBUTE_HIDDEN`, so `hidden` means dot-prefixed on every platform until a native probe is worth its cost.
- **Name-normalizing volumes void the create-path equality** — `createDirectory` promises its return verbatim-equal to the child's next `entries[].path`; Node's namespaced Win32 paths store even trailing-dot/space segments literally, but a volume that rewrites names on storage (NFD normalization on HFS+-style volumes) breaks the match, and the create landing degrades to the documented single-pane / edit-zone fallback.
- **Name-normalizing volumes void the create-path equality** — `createDirectory` promises its return verbatim-equal to the child's next `entries[].path`; Node's namespaced Win32 paths store even trailing-dot/space segments literally, but a volume that rewrites names on storage (NFD normalization on HFS+-style volumes) breaks the match, and the create landing degrades to a two-pane view whose left pane lacks the aria-current row while focus falls back to the crumb edit zone.
- **No drive-root enumeration** — on Windows the ancestry stops at the drive root; crossing drives waits for the browser UI's path-entry affordance rather than an enumeration primitive here.
- **Whole-filesystem scope** — no per-deployment browse-root restriction; `workspace.create` accepts arbitrary paths today, so a root here would be UX scoping, not a boundary — deferred until a deployment needs it.

View File

@@ -19,6 +19,6 @@
## 已知限制与延期工作
- **不读取 Windows 隐藏属性**——Node 的 dirent 不暴露 `FILE_ATTRIBUTE_HIDDEN`,因此在所有平台上 `hidden` 都意味着点前缀,直到原生探测值回其成本为止。
- **名称规范化的卷会使创建路径等式失效**——`createDirectory` 承诺其返回值与该子项下一次的 `entries[].path` 逐字相等Node 带命名空间的 Win32 路径连末尾点空格段都按字面存储但在存储时改写名称的卷HFS+ 风格卷上的 NFD 规范化)会破坏这一匹配,创建落地随之退化为文档所述的单栏/编辑区回退
- **名称规范化的卷会使创建路径等式失效**——`createDirectory` 承诺其返回值与该子项下一次的 `entries[].path` 逐字相等Node 带命名空间的 Win32 路径连末尾点空格段都按字面存储但在存储时改写名称的卷HFS+ 风格卷上的 NFD 规范化)会破坏这一匹配,创建落地随之退化为左栏缺少 aria-current 行的双栏视图,同时焦点回落至 crumb 编辑区。
- **不枚举盘符根**——Windows 上祖先链止于盘符根;跨盘依赖浏览器 UI 的路径输入入口,而不是这里的枚举原语。
- **全盘可浏览**——没有按部署限定的浏览根;`workspace.create` 今天就接受任意路径,这里的根只会是 UX 范围而非边界——等到有部署需要时再做。

View File

@@ -41,7 +41,12 @@ export interface DirectoryBrowserProps {
open: boolean
/** List one directory level (absent path = the Host home directory); the signal aborts a superseded scan on the wire. */
listDirectory: (path?: string, signal?: AbortSignal) => Promise<DirectoryListing>
/** Create one child directory under an existing parent. */
/**
* Create one child directory under an existing parent; the returned path
* is verbatim the child's `entries[].path` in the parent's next listing
* (`IWorkspaces.createDirectory`'s contract) — the create landing anchors
* its selection and focus on that equality.
*/
createDirectory: (path: string, name: string) => Promise<string>
/** The operator confirmed a directory (the selection, else the listed level). */
onOpen: (path: string) => void
@@ -500,19 +505,23 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
}, [child, select])
// Every open starts fresh at the Host home directory; closing invalidates
// any in-flight response so a late arrival cannot repopulate a closed dialog.
// any in-flight response so a late arrival cannot repopulate a closed
// dialog. The per-open state resets live on the CLOSE edge: resetting on
// open would let the reopen's first commit paint one frame of the stale
// view (revealed hidden rows, a pressed toggle) before this passive
// effect runs.
useEffect(() => {
openGeneration.current += 1
if (open) {
setParent(null)
setSelected(null)
setChild(null)
setCreatingFolder(false)
setShowHidden(false)
navigate()
return
}
supersede()
setParent(null)
setSelected(null)
setChild(null)
setCreatingFolder(false)
setShowHidden(false)
setError(null)
setPathDraft(null)
setFolderDraft(null)

View File

@@ -15,7 +15,11 @@ import { DirectoryBrowser } from './DirectoryBrowser.tsx'
export interface BrowseFlowInjected {
/** List one directory level (absent path = the Host home directory); the signal aborts a superseded scan. */
listDirectory: (path?: string, signal?: AbortSignal) => Promise<DirectoryListing>
/** Create one child directory under an existing parent. */
/**
* Create one child directory under an existing parent; returns the
* created path in the shape `IWorkspaces.createDirectory` contracts
* (verbatim equal to the child's next `entries[].path`).
*/
createDirectory: (path: string, name: string) => Promise<string>
/** Localized dialog copy (this package's namespace). */
t: Translate

View File

@@ -203,14 +203,18 @@ describe('BrowseDirectoryPicker', () => {
})
it('creates one child directory and surfaces it in the next listing', async () => {
const created = await capability.createDirectory(root, 'fresh')
expect(created).toBe(join(root, 'fresh'))
// The composed-form name (U+00E9) doubles as the name-rewriting
// tripwire: a volume that stores names NFD-decomposed hands back a
// different dirent.name and the equality below goes red — the README's
// documented boundary.
const created = await capability.createDirectory(root, 'café')
expect(created).toBe(join(root, 'café'))
const listing = await capability.list(root)
expect(listing.entries.map(entry => entry.name)).toContain('fresh')
expect(listing.entries.map(entry => entry.name)).toContain('café')
// The contract's cross-method equality: the returned path is verbatim
// the child's entries[].path (clients anchor the create landing's
// selection and focus on it).
expect(listing.entries.find(entry => entry.name === 'fresh')!.path).toBe(created)
expect(listing.entries.find(entry => entry.name === 'café')!.path).toBe(created)
})
it('refuses an existing child with directory-exists', async () => {