feat(directory-picker-browse): quiet navigation — one-frame landings and a slow-scan loading pill
Navigations keep the previous view rendering while scanning: target and parent legs land as one two-pane frame when the parent leg settles within a 200ms wait bound (past it the target lands alone and the late leg upgrades in place; Escape inside the landing window withdraws the navigation). The loading indicator floats over the content on the card background and appears only once a scan outlives a 300ms silence window, so navigation never shifts the columns or flashes an intermediate frame. The truncated note now describes the on-screen panes instead of hiding during scans.
This commit is contained in:
@@ -146,11 +146,26 @@
|
||||
flex-direction: column;
|
||||
flex: 1 1 0;
|
||||
min-height: 0;
|
||||
/* Anchors the floating loading pill (.loadingFloat). */
|
||||
position: relative;
|
||||
/* Right inset is slimmer than the left: the trailing column's own 8px
|
||||
* scrollbar clearance makes up the optical difference. */
|
||||
padding: 16px 16px 16px 24px;
|
||||
}
|
||||
|
||||
/* The slow-scan indicator floats over the content's bottom-left on the card
|
||||
* background instead of occupying a row: a scan must never shift the
|
||||
* columns' height, and the stale view keeps rendering beneath it (it only
|
||||
* appears at all once a scan outlives SLOW_SCAN_DELAY_MS). */
|
||||
.loadingFloat {
|
||||
position: absolute;
|
||||
left: 24px;
|
||||
bottom: 8px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 6px;
|
||||
background: var(--dsw-alias-bg-layer-2);
|
||||
}
|
||||
|
||||
/* Columns split the row evenly around the divider (a solo column takes the
|
||||
* whole row); 256px is the floor below which the row scrolls (scrollbar
|
||||
* hidden, the effect pins the child pane into view) instead of squeezing
|
||||
|
||||
@@ -5,10 +5,12 @@
|
||||
* breadcrumb, and a click-to-edit path zone; below it a Miller view — one
|
||||
* full-width level until a row is selected, then two columns splitting the
|
||||
* row evenly (256px floor; level | selected folder's children) around a
|
||||
* hairline divider. Navigations land selection-anchored: a crumb jump or a
|
||||
* submitted path commits the target immediately, then re-selects it in its
|
||||
* parent level once that level arrives, so stepping back keeps two panes
|
||||
* away from the display root. Selecting in the
|
||||
* hairline divider. Navigations land selection-anchored and quiet: the
|
||||
* previous view keeps rendering while a crumb jump or a submitted path is
|
||||
* scanned, then target and parent legs land as one two-pane frame (a slow
|
||||
* parent leg falls back to landing the target alone and upgrading in
|
||||
* place), so stepping back keeps two panes away from the display root and
|
||||
* navigation never flashes an intermediate frame. Selecting in the
|
||||
* right column shifts the view one level deeper. "New folder" opens a nested
|
||||
* create dialog targeting the selected folder (or the level itself) and
|
||||
* selects the created folder. Open adopts the selected folder, falling back
|
||||
@@ -55,6 +57,24 @@ function failureText(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
|
||||
/**
|
||||
* How long a scan may stay visually silent before the floating "Loading…"
|
||||
* pill appears. The stale view keeps rendering while a scan is in flight, so
|
||||
* a listing that settles inside this window swaps the panes with no
|
||||
* intermediate frame at all; only a genuinely slow host (a network mount, a
|
||||
* cold disk) surfaces the indicator.
|
||||
*/
|
||||
const SLOW_SCAN_DELAY_MS = 300
|
||||
|
||||
/**
|
||||
* How long a navigation landing waits for its parent leg before committing
|
||||
* the target alone. Inside the window both legs land as ONE two-pane frame —
|
||||
* no single-pane flash between them; past it the target commits single-pane
|
||||
* at once (an Enter-submitted navigation is never held hostage by a stalled
|
||||
* parent) and the late parent leg upgrades the landing in place.
|
||||
*/
|
||||
const PARENT_LEG_WAIT_MS = 200
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -166,6 +186,10 @@ 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.
|
||||
const [slowScan, setSlowScan] = useState(false)
|
||||
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)
|
||||
@@ -228,17 +252,20 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
|
||||
}, [listDirectory])
|
||||
|
||||
/**
|
||||
* Replace the whole view with a freshly navigated level. The target level
|
||||
* commits the moment it arrives (single wide level: the editor closes and
|
||||
* loading ends on this first settlement, so an Enter-submitted navigation
|
||||
* is never withdrawn waiting on anything further). Away from the display
|
||||
* root — the same collapse the crumb header renders, so crumbs and pane
|
||||
* shape never disagree — a parent leg then upgrades the landing in place:
|
||||
* the target's ACTUAL parent-level entry re-selected (left pane = parent,
|
||||
* right pane = the target), so a crumb jump reads as stepping back one
|
||||
* pane. A failed parent leg, or a truncated parent window that lacks the
|
||||
* target, leaves the committed single-pane landing — the upgrade must
|
||||
* never orphan the selection it exists to anchor.
|
||||
* Replace the whole view with a freshly navigated level. Away from the
|
||||
* display root — the same collapse the crumb header renders, so crumbs and
|
||||
* pane shape never disagree — the landing is two-pane: the target's ACTUAL
|
||||
* parent-level entry re-selected (left pane = parent, right pane = the
|
||||
* target), so a crumb jump reads as stepping back one pane. Both legs land
|
||||
* as one frame when the parent leg settles within
|
||||
* {@link PARENT_LEG_WAIT_MS}; past that bound (or at the display root) the
|
||||
* target commits alone — single wide level, the editor closes, loading
|
||||
* ends — and a late parent leg still upgrades the landing in place. A
|
||||
* failed parent leg, or a truncated parent window that lacks the target,
|
||||
* leaves the single-pane landing — the upgrade must never orphan the
|
||||
* selection it exists to anchor. Until whichever commit comes first, the
|
||||
* previous view keeps rendering: navigation swaps the panes, it never
|
||||
* blanks them.
|
||||
*/
|
||||
const navigate = useCallback((path?: string) => {
|
||||
const { seq, scan } = launchListing(path)
|
||||
@@ -246,16 +273,23 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
|
||||
setError(null)
|
||||
scan.then((target) => {
|
||||
if (seq !== requestSeq.current) return
|
||||
setParent(target)
|
||||
setSelected(null)
|
||||
setChild(null)
|
||||
setLoading(false)
|
||||
setPathDraft(null)
|
||||
// The single-pane landing; `landed` makes it first-commit-only, while
|
||||
// the two-pane commit below may still upgrade an already-landed view.
|
||||
let landed = false
|
||||
const landSingle = (): void => {
|
||||
if (landed || seq !== requestSeq.current) return
|
||||
landed = true
|
||||
setParent(target)
|
||||
setSelected(null)
|
||||
setChild(null)
|
||||
setLoading(false)
|
||||
setPathDraft(null)
|
||||
}
|
||||
// Arity is label-independent: only the collapsed chain's depth decides.
|
||||
if (displayCrumbs(target, '').length < 2) return
|
||||
if (displayCrumbs(target, '').length < 2) { landSingle(); return }
|
||||
const parentCrumb = target.crumbs.at(-2)
|
||||
/* v8 ignore next -- narrowing: a two-deep display chain implies a parent crumb (root-to-target inclusive). */
|
||||
if (parentCrumb === undefined) return
|
||||
if (parentCrumb === undefined) { landSingle(); return }
|
||||
continueScan(parentCrumb.path).then((parentLevel) => {
|
||||
if (seq !== requestSeq.current) return
|
||||
// Windows resolves a typed path preserving its case; anchor on the
|
||||
@@ -263,15 +297,23 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
|
||||
const sep = separatorOf(parentLevel)
|
||||
const fold = (value: string): string => (sep === '\\' ? value.toLowerCase() : value)
|
||||
const match = parentLevel.entries.find(entry => fold(entry.path) === fold(target.path))
|
||||
if (match === undefined) return
|
||||
if (match === undefined) { landSingle(); return }
|
||||
landed = true
|
||||
setParent(parentLevel)
|
||||
setSelected(match)
|
||||
setChild(target)
|
||||
// Idempotent on a late upgrade of a timed-out landing: reopening the
|
||||
// editor or starting a newer scan supersedes this seq, so reaching
|
||||
// here means the draft is closed and the loading flag is this
|
||||
// navigation's own.
|
||||
setLoading(false)
|
||||
setPathDraft(null)
|
||||
}, () => {
|
||||
// Swallows the parent-leg failure (its abort included): the
|
||||
// committed single-pane landing stands, and nobody asked to see
|
||||
// the parent level.
|
||||
// The parent-leg failure (its abort included) never surfaces: the
|
||||
// target listed fine, and nobody asked to see the parent level.
|
||||
landSingle()
|
||||
})
|
||||
window.setTimeout(landSingle, PARENT_LEG_WAIT_MS)
|
||||
}, (reason: unknown) => {
|
||||
if (seq !== requestSeq.current) return
|
||||
setLoading(false)
|
||||
@@ -415,6 +457,18 @@ 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.
|
||||
useEffect(() => {
|
||||
if (!loading) {
|
||||
setSlowScan(false)
|
||||
return
|
||||
}
|
||||
const timer = window.setTimeout(() => { setSlowScan(true) }, SLOW_SCAN_DELAY_MS)
|
||||
return () => { window.clearTimeout(timer) }
|
||||
}, [loading])
|
||||
|
||||
// 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'))
|
||||
@@ -649,11 +703,15 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{loading && <div className={css.status} role="status">{t('browser.loading')}</div>}
|
||||
{loading && slowScan
|
||||
&& <div className={clsx(css.status, css.loadingFloat)} role="status">{t('browser.loading')}</div>}
|
||||
{/* The backend bounds a level at its complete-result limit; say so
|
||||
* whenever a visible pane was cut instead of letting the tail of a
|
||||
* huge directory go silently missing. */}
|
||||
{(parent?.truncated === true || child?.truncated === true) && !loading
|
||||
* huge directory go silently missing. The note describes the panes
|
||||
* on screen, so an in-flight scan leaves it alone — hiding it while
|
||||
* the stale view still shows the cut level would shift the columns
|
||||
* on every navigation away from it. */}
|
||||
{(parent?.truncated === true || child?.truncated === true)
|
||||
&& <div className={css.status} role="status">{t('browser.truncated')}</div>}
|
||||
{error !== null && <div className={css.error} role="alert">{error}</div>}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user