review(directory-picker-browse): re-arm the draft-following wait per keystroke

ds-review-bot round one. Keying the debounce on the directory part the draft
named left two states with no recovery until the operator crossed a separator:
a keystroke that superseded an in-flight scan never re-armed one, and an edit
after a rejected submission released the hold with no timer left to release.
The wait is now keyed on the draft itself and decides its target when it
fires, reading the panes through a ref so a landing cannot re-arm it (a host
answering with a differently spelled path would otherwise scan forever).

A landed scan that unmounts the row a keyboard operator Tabbed onto re-parks
focus on the still-open editor; the Modal has no focus trap. That a walked-to
level survives closing the editor is now stated in the README, the Agent Note,
and the module contract. The new e2e stages its own beta directory so running
it alone sees the tree its assertions describe.
This commit is contained in:
creatixchu
2026-08-03 12:11:11 +08:00
parent 79072e356c
commit 30f442d42e
9 changed files with 124 additions and 23 deletions

View File

@@ -25,7 +25,9 @@
* 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.
* segments steps back up without leaving the editor. Panes the draft walked
* to stay put when the editor closes (cancellation included): the crumbs name
* where the walk ended, and Open's fallback target follows them.
*/
import { useCallback, useEffect, useRef, useState } from 'react'
import clsx from 'clsx'
@@ -157,9 +159,9 @@ function draftPrefixFor(listing: DirectoryListing, draft: string | null): string
function pendingPreviewDirectory(
parent: DirectoryListing | null,
child: DirectoryListing | null,
draft: string | null,
draft: string,
): string | null {
if (parent === null || draft === null) return null
if (parent === null) return null
const directory = draftDirectory(parent, draft)
if (directory === null || directory === levelDirectory(parent)) return null
if (child !== null && directory === levelDirectory(child)) return null
@@ -453,10 +455,23 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
* 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.
* it set until the operator edits again, so the rejected path is not
* immediately re-scanned as a preview.
*/
const previewSuspended = useRef(false)
// The panes as the draft-following scan must read them when its wait
// fires: current, but NOT a dependency of the wait (see the effect below).
const viewRef = useRef<{ parent: DirectoryListing | null; child: DirectoryListing | null }>({ parent: null, child: null })
useEffect(() => { viewRef.current = { parent, child } }, [parent, child])
/**
* A landed preview replaced the pane a keyboard operator may have Tabbed
* onto, so the focus it drops is re-parked on the still-open editor (the
* Modal has no focus trap). Consumed by the refocus effect below.
*/
const refocusPathInput = 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
@@ -478,6 +493,7 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
setChild(null)
setLoading(false)
setError(null)
refocusPathInput.current = true
}, () => {
if (seq !== requestSeq.current) return
setLoading(false)
@@ -602,21 +618,25 @@ 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)
// The panes follow the draft: EVERY keystroke replaces the pending timer,
// and the target is decided when it fires, off the panes as they stand
// then. Keying the wait on the draft (not on the directory part it names)
// is what makes a keystroke that superseded an in-flight scan re-arm one,
// and what lets an edit after a rejected submission release the hold the
// submission took. The panes are read through a ref for the converse
// reason: were they dependencies, the landing this commits would re-arm the
// wait, and a host answering with a differently spelled path would scan
// forever.
useEffect(() => {
if (previewDirectory === null) return
if (pathDraft === null) return
const timer = window.setTimeout(() => {
if (previewSuspended.current) return
previewDraftLevel(previewDirectory)
const directory = pendingPreviewDirectory(viewRef.current.parent, viewRef.current.child, pathDraft)
if (directory === null) return
previewDraftLevel(directory)
}, DRAFT_PREVIEW_DEBOUNCE_MS)
return () => { window.clearTimeout(timer) }
}, [previewDirectory, previewDraftLevel])
}, [pathDraft, previewDraftLevel])
// After the hooks: a closed dialog renders nothing and evaluates no copy.
const crumbSource = child ?? parent
@@ -642,6 +662,12 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
// replacing the picked button's column — while Enter and an input-focused
// Escape land on the crumb edit zone that replaces the input.
useEffect(() => {
if (refocusPathInput.current) {
refocusPathInput.current = false
// Only when the swap actually dropped focus to body: focus the operator
// still holds (the input itself, a surviving row) stays theirs.
if (document.activeElement === document.body) pathInputRef.current?.focus()
}
if (pathDraft !== null) return
if (refocusPick.current) {
refocusPick.current = false