fix(directory-picker-browse): resolve quiet-navigation review

This commit is contained in:
creatixchu
2026-07-30 15:22:28 +08:00
parent 3a3f68f36d
commit e596d300d7
6 changed files with 80 additions and 22 deletions

View File

@@ -242,6 +242,10 @@
.status,
.error {
padding: 4px;
/* The loading pill occupies the opposite corner while a stale status stays
* visible. Reserve its widest localized footprint so wrapped text cannot
* run underneath it on a narrow card. */
padding-right: 120px;
font-size: 12px;
line-height: 18px;
}
@@ -259,15 +263,15 @@
* the columns' height, and the stale view keeps rendering beneath it (it
* only appears at all once a scan outlives SLOW_SCAN_DELAY_MS). Right,
* not left: the truncated/error status rows flow at the bottom LEFT and
* stay on screen through a scan, so the opposite corner keeps both
* legible. After .status in the cascade — the element carries both
* classes and this padding must win the same-specificity race. */
* stay on screen through a scan, with their reserved right padding keeping
* both legible even on a narrow card. After .status in the cascade — the
* element carries both classes and this padding must win the
* same-specificity race. */
.loadingFloat {
position: absolute;
right: 16px;
bottom: 8px;
padding: 2px 8px;
border-radius: 6px;
background: var(--dsw-alias-bg-layer-2);
}

View File

@@ -186,10 +186,14 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
const [selected, setSelected] = useState<DirectoryEntry | null>(null)
const [child, setChild] = useState<DirectoryListing | null>(null)
const [loading, setLoading] = useState(false)
// Derived from `loading` by the slow-scan effect below: true only once a
// scan has been in flight for SLOW_SCAN_DELAY_MS, so fast listings never
// render the indicator at all.
// Derived from `loading` and `scanWindow` by the slow-scan effect below:
// true only once the current listing call has been in flight for
// SLOW_SCAN_DELAY_MS, so fast listings never render the indicator at all.
const [slowScan, setSlowScan] = useState(false)
// Every listing call owns a fresh silence window. `loading` may stay true
// across a superseding row pick or across a navigation's target and parent
// legs, so its boolean edge cannot identify the start of each scan.
const [scanWindow, setScanWindow] = useState(0)
const [error, setError] = useState<string | null>(null)
// Path-edit state: null = breadcrumb mode; a string = the draft being typed.
const [pathDraft, setPathDraft] = useState<string | null>(null)
@@ -233,13 +237,20 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
return ++requestSeq.current
}, [])
/** Hide any prior indicator and start a fresh silence window for one listing call. */
const restartSlowScanWindow = useCallback((): void => {
setSlowScan(false)
setScanWindow(value => value + 1)
}, [])
/** Launch one listing under a fresh controller so a later supersession can abort it. */
const launchListing = useCallback((path: string | undefined): { seq: number; scan: Promise<DirectoryListing> } => {
const seq = supersede()
const controller = new AbortController()
scanController.current = controller
restartSlowScanWindow()
return { seq, scan: listDirectory(path, controller.signal) }
}, [supersede, listDirectory])
}, [supersede, restartSlowScanWindow, listDirectory])
/**
* Launch a follow-up listing under the CURRENT supersession seq: a newer
@@ -248,8 +259,9 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
const continueScan = useCallback((path: string): Promise<DirectoryListing> => {
const controller = new AbortController()
scanController.current = controller
restartSlowScanWindow()
return listDirectory(path, controller.signal)
}, [listDirectory])
}, [restartSlowScanWindow, listDirectory])
/**
* Replace the whole view with a freshly navigated level. Away from the
@@ -474,9 +486,10 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
})
}
// The slow-scan gate for the loading indicator: arm a timer when a scan
// starts, retire it (and the indicator) the moment loading ends. A settle
// inside the window means the swap happened with nothing shown.
// The slow-scan gate for the loading indicator: each listing call restarts
// the timer even when a superseding scan or a navigation's parent leg keeps
// `loading` continuously true. A settle inside its own window means the swap
// happened with nothing shown.
useEffect(() => {
if (!loading) {
setSlowScan(false)
@@ -484,7 +497,7 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
}
const timer = window.setTimeout(() => { setSlowScan(true) }, SLOW_SCAN_DELAY_MS)
return () => { window.clearTimeout(timer) }
}, [loading])
}, [loading, scanWindow])
// After the hooks: a closed dialog renders nothing and evaluates no copy.
const crumbSource = child ?? parent

View File

@@ -111,6 +111,12 @@ function rowButton(item: HTMLElement): HTMLButtonElement {
}
describe('DirectoryBrowser', () => {
it('renders nothing and launches no listing while initially closed', () => {
const b = mount({ open: false })
expect(screen.queryByRole('dialog')).toBeNull()
expect(b.listDirectory).not.toHaveBeenCalled()
})
it('opens at the Host home as one wide column, hides hidden entries, and roots the crumbs at Home', async () => {
const b = mount()
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
@@ -335,9 +341,16 @@ describe('DirectoryBrowser', () => {
fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
fireEvent.change(screen.getByLabelText<HTMLInputElement>('browser.editPath'), { target: { value: DOCS } })
fireEvent.keyDown(screen.getByLabelText('browser.editPath'), { key: 'Enter' })
// The target can consume most of the outer scan's silence window.
await act(async () => { vi.advanceTimersByTime(250) })
await act(async () => { settlers.get(DOCS)!(listingFor(DOCS)) })
// Its parent leg gets a fresh silence window. Crossing the original
// scan's 300ms deadline therefore cannot flash the indicator during the
// bounded landing wait.
await act(async () => { vi.advanceTimersByTime(199) })
expect(screen.queryByText('browser.loading')).toBeNull()
// The parent leg outlives PARENT_LEG_WAIT_MS: the target lands alone.
await act(async () => { vi.advanceTimersByTime(200) })
await act(async () => { vi.advanceTimersByTime(1) })
expect(columns()).toHaveLength(1)
expect(screen.getByRole('listitem').textContent).toBe('harness')
expect(screen.queryByLabelText('browser.editPath', { selector: 'input' })).toBeNull()
@@ -417,6 +430,34 @@ describe('DirectoryBrowser', () => {
}
})
it('restarts the silence window when a row pick supersedes a pending scan', async () => {
vi.useFakeTimers()
try {
const pending: ((value: DirectoryListing) => void)[] = []
const listDirectory = vi.fn((path?: string, _signal?: AbortSignal) => {
if (path === undefined) return Promise.resolve(listingFor(path))
return new Promise<DirectoryListing>((resolve) => { pending.push(resolve) })
})
mount({ listDirectory })
await act(async () => {})
const documents = rowButton(screen.getByRole('listitem'))
fireEvent.click(documents)
await act(async () => { vi.advanceTimersByTime(300) })
expect(screen.getByText('browser.loading')).toBeTruthy()
// The same row remains actionable while its preview is pending. A second
// pick starts a new listing without a false `loading` edge.
fireEvent.click(documents)
expect(screen.queryByText('browser.loading')).toBeNull()
await act(async () => { vi.advanceTimersByTime(299) })
expect(screen.queryByText('browser.loading')).toBeNull()
await act(async () => { vi.advanceTimersByTime(1) })
expect(screen.getByText('browser.loading')).toBeTruthy()
await act(async () => { pending.at(-1)!(listingFor(DOCS)) })
} finally {
vi.useRealTimers()
}
})
it('a close mid-scan resets the slow-scan gate: reopening waits a fresh silence window', async () => {
vi.useFakeTimers()
try {