|
|
@@ -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>
|