fix(host): address review — Escape scoping, relist gating, crumb overflow, aria golden

- Escape (and the mask) now reaches only the topmost dialog: while the
  nested New-folder dialog is up the browser ignores its own Modal close,
  and the nested dialog's in-flight fence keeps both open during creation.
- New folder disables while any listing loads, so a slow post-create
  relist/select sequence cannot host a second create against a target the
  pending listing is about to change.
- Deep ancestry scrolls inside a dedicated crumb trail whose tail is pinned
  into view; the path-edit zone keeps its reserved width instead of being
  clipped by the card, preserving cross-drive path entry.
- The workspace-management e2e records a directory-browser aria golden at a
  staged tree (host HOME pointed at the scaffold cwd collapses ancestry into
  the Home crumb, keeping the artifact machine-independent), and the keyless
  snapshot's row targeting goes through visible label text — listitem
  accessible-name computation differs across dom-accessibility-api
  environments (the CI-only miss).
This commit is contained in:
creatixchu
2026-07-28 23:21:27 +08:00
parent 0d9ac53fb0
commit 6cd63f741c
6 changed files with 161 additions and 26 deletions

View File

@@ -39,6 +39,18 @@
min-height: 20px;
}
/* Deep chains scroll inside the trail (the effect pins the tail into view)
* so the edit zone to the right never leaves the bar. */
.crumbTrail {
display: flex;
align-items: center;
gap: 4px;
flex: 0 1 auto;
min-width: 0;
overflow-x: auto;
scrollbar-width: none;
}
.crumbSeat {
display: inline-flex;
align-items: center;
@@ -74,7 +86,7 @@
/* The empty remainder of the bar: invisible, but a real click target that
* flips the bar into path-edit mode. */
.crumbEditZone {
flex: 1 1 0;
flex: 1 0 34px;
min-width: 34px;
align-self: stretch;
border: none;

View File

@@ -112,6 +112,9 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
const [creatingFolder, setCreatingFolder] = useState(false)
const [createError, setCreateError] = useState<string | null>(null)
const requestSeq = useRef(0)
// Deep ancestry overflows the trail; keep its tail (the current directory
// and the edit zone beside it) in view whenever the chain changes.
const crumbTrailRef = useRef<HTMLSpanElement | null>(null)
/** Replace the whole view with one freshly listed level (no selection). */
const navigate = useCallback((path?: string) => {
@@ -213,16 +216,24 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
}
// After the hooks: a closed dialog renders nothing and evaluates no copy.
if (!open) return null
const crumbSource = child ?? parent
const crumbs = crumbSource === null ? [] : displayCrumbs(crumbSource, t('browser.home'))
const crumbTail = crumbs.at(-1)?.path
useEffect(() => {
const trail = crumbTrailRef.current
if (trail !== null) trail.scrollLeft = trail.scrollWidth
}, [crumbTail])
if (!open) return null
const twoPane = selected !== null
return (
<Modal
open={open}
onClose={onClose}
// Escape and mask reach every mounted Modal's document listener; while
// the nested create dialog is up, only that topmost dialog may close
// (its own guard keeps an in-flight creation open).
onClose={() => { if (folderDraft === null) onClose() }}
title={t('browser.title')}
className={clsx(css.dialog)}
headless
@@ -233,19 +244,21 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
{pathDraft === null
? (
<>
{crumbs.map((crumb, index) => (
<span key={crumb.path} className={css.crumbSeat}>
{index > 0 && <IconChevronRightOutline14 size={12} className={css.crumbChevron} />}
<button
type="button"
className={css.crumb}
disabled={busy}
onClick={() => { navigate(crumb.path) }}
>
{crumb.name}
</button>
</span>
))}
<span className={css.crumbTrail} ref={crumbTrailRef}>
{crumbs.map((crumb, index) => (
<span key={crumb.path} className={css.crumbSeat}>
{index > 0 && <IconChevronRightOutline14 size={12} className={css.crumbChevron} />}
<button
type="button"
className={css.crumb}
disabled={busy}
onClick={() => { navigate(crumb.path) }}
>
{crumb.name}
</button>
</span>
))}
</span>
{/* The empty zone right of the crumbs is the path-edit affordance. */}
<button
type="button"
@@ -308,7 +321,7 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
<Button
variant="outline"
icon={<IconPlusOutline16 size={14} />}
disabled={parent === null || busy || folderDraft !== null}
disabled={parent === null || busy || loading || folderDraft !== null}
onClick={() => {
setFolderDraft('')
setCreateError(null)