|
|
@@ -18,15 +18,27 @@
|
|
|
* owning flow decides what "Open" means and owns the workspace-creation
|
|
|
* error surface. Hidden entries are host-flagged and hidden by default; the
|
|
|
* footer's fixed-label "Show hidden files" toggle (aria-pressed, check when
|
|
|
- * on) reveals them (client-side only). The path editor opens seeded with a
|
|
|
- * trailing separator, and while the draft's directory part names a listed
|
|
|
- * level, its final segment prefix-filters that level's rows (a dot-led
|
|
|
- * prefix also reveals the hidden entries it names).
|
|
|
+ * on) reveals them (client-side only). The path editor announces itself with
|
|
|
+ * a pencil glyph and a bar-wide hover-lit outline, opens seeded with a
|
|
|
+ * trailing separator, and keeps the panes under the draft: the final segment
|
|
|
+ * prefix-filters the LAST pane while that pane's level is the one the draft's
|
|
|
+ * directory part names (a dot-led prefix also reveals the hidden entries it
|
|
|
+ * names, and a prefix nobody matches releases the filter), while any other
|
|
|
+ * directory part is scanned after a short debounce and lands like any other
|
|
|
+ * navigation — selection-anchored and two-pane away from the display root,
|
|
|
+ * both legs waited out so one keystroke moves the view once. The pane arity
|
|
|
+ * holds throughout: the last pane is the level the path names and the one
|
|
|
+ * beside it is its parent, so typing deeper descends and erasing segments
|
|
|
+ * walks back up, moving the Miller view 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'
|
|
|
import {
|
|
|
- Button, IconCheckOutline16, IconChevronRightOutline14, IconFolderClose16, IconFolderOpen16, IconPlusOutline16, Modal,
|
|
|
+ Button, IconCheckOutline16, IconChevronRightOutline14, IconEditOutline16, IconFolderClose16, IconFolderOpen16,
|
|
|
+ IconPlusOutline16, Modal,
|
|
|
} from '@deepseek-ai/dsh-client-ui-primitives'
|
|
|
import type { DirectoryEntry, DirectoryListing } from '@deepseek-ai/dsh-client-runtime/client'
|
|
|
import { DirectoryBrowseError } from '@deepseek-ai/dsh-client-runtime/client'
|
|
|
@@ -75,6 +87,15 @@ const SLOW_SCAN_DELAY_MS = 300
|
|
|
*/
|
|
|
const PARENT_LEG_WAIT_MS = 200
|
|
|
|
|
|
+/**
|
|
|
+ * How long a typed draft rests before the panes follow it to a directory no
|
|
|
+ * pane lists. The window absorbs the keystrokes that walk through
|
|
|
+ * intermediate directory parts (every character of `/usr/lo` past the
|
|
|
+ * separator would otherwise be its own scan) while staying short enough that
|
|
|
+ * a pause reads as "the list moved with me".
|
|
|
+ */
|
|
|
+const DRAFT_PREVIEW_DEBOUNCE_MS = 250
|
|
|
+
|
|
|
/**
|
|
|
* 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
|
|
|
@@ -100,21 +121,89 @@ function separatorOf(listing: DirectoryListing): '\\' | '/' {
|
|
|
return listing.home.includes('\\') ? '\\' : '/'
|
|
|
}
|
|
|
|
|
|
+/** The listed level as a directory part: its own path, separator-terminated (the root already is). */
|
|
|
+function levelDirectory(listing: DirectoryListing): string {
|
|
|
+ const sep = separatorOf(listing)
|
|
|
+ return listing.path.endsWith(sep) ? listing.path : `${listing.path}${sep}`
|
|
|
+}
|
|
|
+
|
|
|
+/** The directory text a draft-following scan last sent, with the level path the host answered it with. */
|
|
|
+interface ScannedDirectory {
|
|
|
+ /** The draft's directory part, verbatim as it went to the host. */
|
|
|
+ readonly directory: string
|
|
|
+ /** `path` of the listing that came back. */
|
|
|
+ readonly landed: string
|
|
|
+}
|
|
|
+
|
|
|
/**
|
|
|
- * The path draft's final segment, when its directory part is exactly the
|
|
|
- * level `listing` lists — the segment the level prefix-filters on while the
|
|
|
- * user types. Any other draft (no separator yet, or naming some other
|
|
|
- * directory) leaves the level unfiltered. The directory part compares
|
|
|
- * exactly (it is the host's own path text, reached by seeding or erasing);
|
|
|
- * only the name filter downstream is case-insensitive.
|
|
|
+ * The draft's directory part — everything through its last separator — or
|
|
|
+ * null while no separator has been typed at all (nothing addresses a
|
|
|
+ * directory yet). The platform comes from `listing`: on Windows a forward
|
|
|
+ * slash separates too (the host's `resolve` accepts either), while on POSIX a
|
|
|
+ * backslash is a legal name character and never separates.
|
|
|
*/
|
|
|
-function draftPrefixFor(listing: DirectoryListing, draft: string | null): string | null {
|
|
|
- if (draft === null) return null
|
|
|
- const sep = separatorOf(listing)
|
|
|
- const cut = draft.lastIndexOf(sep)
|
|
|
- if (cut === -1) return null
|
|
|
- const level = listing.path.endsWith(sep) ? listing.path : `${listing.path}${sep}`
|
|
|
- return draft.slice(0, cut + 1) === level ? draft.slice(cut + 1) : null
|
|
|
+function draftDirectory(listing: DirectoryListing, draft: string): string | null {
|
|
|
+ const cut = separatorOf(listing) === '\\'
|
|
|
+ ? Math.max(draft.lastIndexOf('\\'), draft.lastIndexOf('/'))
|
|
|
+ : draft.lastIndexOf('/')
|
|
|
+ return cut === -1 ? null : draft.slice(0, cut + 1)
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * How the draft reads against one level: the directory part it names, and —
|
|
|
+ * when `listing` is the level that directory part addresses — the final
|
|
|
+ * segment that prefix-filters it while the user types (case-insensitively,
|
|
|
+ * downstream). A level answers a directory part when its own path is that
|
|
|
+ * part, or when it is the level that very text just produced (`scanned`): the
|
|
|
+ * host resolves what it is given, so `..` segments and Windows forward
|
|
|
+ * slashes reach a level whose path spells the request differently.
|
|
|
+ * @param listing - the level to read the draft against.
|
|
|
+ * @param draft - the current path draft.
|
|
|
+ * @param scanned - the last draft-following scan's directory and landing.
|
|
|
+ * @returns the draft's directory part (null with no separator typed) and its
|
|
|
+ * filtering tail (null when this level does not answer that directory).
|
|
|
+ */
|
|
|
+function readDraft(
|
|
|
+ listing: DirectoryListing,
|
|
|
+ draft: string,
|
|
|
+ scanned: ScannedDirectory | null,
|
|
|
+): { directory: string | null; tail: string | null } {
|
|
|
+ const directory = draftDirectory(listing, draft)
|
|
|
+ if (directory === null) return { directory: null, tail: null }
|
|
|
+ const answers = directory === levelDirectory(listing)
|
|
|
+ || (scanned !== null && scanned.directory === directory && scanned.landed === listing.path)
|
|
|
+ return { directory, tail: answers ? draft.slice(directory.length) : null }
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * The rows one column renders. The selection is exempt from every filter: it
|
|
|
+ * anchors the two-pane view (crumbs and the child pane point at it), so
|
|
|
+ * neither the hidden filter after a dot-reveal pick nor a prefix miss may
|
|
|
+ * orphan it. A prefix narrows the level only while some row it would actually
|
|
|
+ * show matches — a tail nobody matches is a name being spelled, not a demand
|
|
|
+ * for an empty pane, so the level shows whole and its hidden rows return to
|
|
|
+ * obeying the toggle. Counting only displayable rows is what keeps that true:
|
|
|
+ * were a hidden row ever to match a prefix that does not reveal it (today
|
|
|
+ * `hidden` means dot-prefixed, so it cannot), the level would narrow to
|
|
|
+ * nothing.
|
|
|
+ */
|
|
|
+function visibleEntries(
|
|
|
+ entries: readonly DirectoryEntry[],
|
|
|
+ selectedPath: string | null,
|
|
|
+ showHidden: boolean,
|
|
|
+ filterPrefix: string | null,
|
|
|
+): readonly DirectoryEntry[] {
|
|
|
+ const needle = filterPrefix === null ? '' : filterPrefix.toLowerCase()
|
|
|
+ // A dot-led prefix names hidden entries explicitly, so matching ones
|
|
|
+ // surface even while the toggle keeps the rest hidden.
|
|
|
+ const displayable = (entry: DirectoryEntry): boolean => showHidden || !entry.hidden || needle.startsWith('.')
|
|
|
+ const matches = (entry: DirectoryEntry): boolean => displayable(entry) && entry.name.toLowerCase().startsWith(needle)
|
|
|
+ const narrowing = needle !== '' && entries.some(matches)
|
|
|
+ return entries.filter((entry) => {
|
|
|
+ if (entry.path === selectedPath) return true
|
|
|
+ if (narrowing) return matches(entry)
|
|
|
+ return showHidden || !entry.hidden
|
|
|
+ })
|
|
|
}
|
|
|
|
|
|
/** One column of folder rows (the Miller view renders one or two of these). */
|
|
|
@@ -127,16 +216,7 @@ function LevelColumn({ entries, selectedPath, busy, onPick, showHidden, filterPr
|
|
|
filterPrefix: string | null
|
|
|
pathEditing: boolean
|
|
|
}) {
|
|
|
- const visible = entries.filter((entry) => {
|
|
|
- // The selection is exempt from both filters: it anchors the two-pane
|
|
|
- // view (crumbs and the child pane point at it), so neither the hidden
|
|
|
- // filter after a dot-reveal pick nor a prefix miss may orphan it.
|
|
|
- if (entry.path === selectedPath) return true
|
|
|
- if (filterPrefix !== null && !entry.name.toLowerCase().startsWith(filterPrefix.toLowerCase())) return false
|
|
|
- // A dot-led prefix names hidden entries explicitly, so matching ones
|
|
|
- // surface even while the toggle keeps the rest hidden.
|
|
|
- return showHidden || !entry.hidden || filterPrefix?.startsWith('.') === true
|
|
|
- })
|
|
|
+ const visible = visibleEntries(entries, selectedPath, showHidden, filterPrefix)
|
|
|
return (
|
|
|
<div className={css.column} role="list">
|
|
|
{visible.map((entry) => {
|
|
|
@@ -264,27 +344,83 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
|
|
|
}, [restartSlowScanWindow, listDirectory])
|
|
|
|
|
|
/**
|
|
|
- * Replace the whole view with a freshly navigated level. Away from the
|
|
|
+ * 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 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])
|
|
|
+
|
|
|
+ // What the last draft-following scan asked for and what came back, so a
|
|
|
+ // level still answers the text that produced it after the host respelled
|
|
|
+ // it. Stale entries are harmless: a match needs both the directory text and
|
|
|
+ // that level's own path, which together already mean the same directory.
|
|
|
+ const scanned = useRef<ScannedDirectory | null>(null)
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 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)
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Replace the whole view with a freshly scanned 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.
|
|
|
+ * target commits alone — single wide level, 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: a landing swaps the panes, it never blanks them.
|
|
|
+ *
|
|
|
+ * Two callers, one landing shape. A submitted path (Enter, a crumb) closes
|
|
|
+ * the editor on arrival, announces its failure, and takes the wait bound —
|
|
|
+ * it is answering a gesture, so it may not hang on a stalled parent. The
|
|
|
+ * editor's own draft-following scan keeps all three to itself: it is
|
|
|
+ * speculative, nothing waits on it, and the stale view keeps rendering, so
|
|
|
+ * it waits for BOTH legs rather than flashing a single pane it would then
|
|
|
+ * upgrade — one keystroke must move the view once. A failure leaves the
|
|
|
+ * last readable panes standing and says nothing, while an arrival clears
|
|
|
+ * the stale message and re-parks focus the swap dropped.
|
|
|
+ * @param path - the level to list; absent lists the Host home directory.
|
|
|
+ * @param options - `closeEditor` retires the path draft on arrival and
|
|
|
+ * bounds the wait for the parent leg; `announce` surfaces a failure as the
|
|
|
+ * dialog's alert.
|
|
|
*/
|
|
|
- const navigate = useCallback((path?: string) => {
|
|
|
+ const land = useCallback((path: string | undefined, options: { closeEditor: boolean; announce: boolean }) => {
|
|
|
const { seq, scan } = launchListing(path)
|
|
|
setLoading(true)
|
|
|
- setError(null)
|
|
|
+ if (options.announce) setError(null)
|
|
|
+ // What every landing does once its panes are committed, whichever shape
|
|
|
+ // committed them.
|
|
|
+ const settle = (): void => {
|
|
|
+ setLoading(false)
|
|
|
+ if (options.closeEditor) {
|
|
|
+ setPathDraft(null)
|
|
|
+ return
|
|
|
+ }
|
|
|
+ setError(null)
|
|
|
+ refocusPathInput.current = true
|
|
|
+ }
|
|
|
scan.then((target) => {
|
|
|
if (seq !== requestSeq.current) return
|
|
|
+ // The level the panes will present as current answers this exact
|
|
|
+ // directory text, however the host respelled it (`..`, a Windows
|
|
|
+ // forward slash): the tail filters, and the same text asks for no
|
|
|
+ // second scan.
|
|
|
+ if (!options.closeEditor && path !== undefined) scanned.current = { directory: path, landed: target.path }
|
|
|
// 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
|
|
|
@@ -294,8 +430,7 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
|
|
|
setParent(target)
|
|
|
setSelected(null)
|
|
|
setChild(null)
|
|
|
- setLoading(false)
|
|
|
- setPathDraft(null)
|
|
|
+ settle()
|
|
|
}
|
|
|
// Arity is label-independent: only the collapsed chain's depth decides.
|
|
|
if (displayCrumbs(target, '').length < 2) { landSingle(); return }
|
|
|
@@ -316,23 +451,29 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
|
|
|
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)
|
|
|
+ // here means the settlement is still this landing's own.
|
|
|
+ settle()
|
|
|
}, () => {
|
|
|
// 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)
|
|
|
+ // Only a submitted navigation is bounded: the walk waits both legs out
|
|
|
+ // (see the contract above), and a keystroke aborts it if the operator
|
|
|
+ // moves on first.
|
|
|
+ if (options.closeEditor) window.setTimeout(landSingle, PARENT_LEG_WAIT_MS)
|
|
|
}, (reason: unknown) => {
|
|
|
if (seq !== requestSeq.current) return
|
|
|
setLoading(false)
|
|
|
- setError(failureText(reason))
|
|
|
+ if (options.announce) setError(failureText(reason))
|
|
|
})
|
|
|
}, [launchListing, continueScan])
|
|
|
|
|
|
+ /** Commit a submitted path (Enter, a crumb, the initial home listing): the editor closes, failures surface. */
|
|
|
+ const navigate = useCallback((path?: string) => {
|
|
|
+ land(path, { closeEditor: true, announce: true })
|
|
|
+ }, [land])
|
|
|
+
|
|
|
// Editor-close focus parking (consumed by the refocus effect below the
|
|
|
// miller-row ref): a pick parks on the selection's row, Enter and an
|
|
|
// input-focused Escape park on the crumb edit zone that replaces the
|
|
|
@@ -381,6 +522,17 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
|
|
|
})
|
|
|
}, [launchListing, pathDraft])
|
|
|
|
|
|
+ /**
|
|
|
+ * Walk the panes to the directory the draft addresses, WITHOUT closing the
|
|
|
+ * editor. The landing is an ordinary one — selection-anchored and two-pane
|
|
|
+ * away from the display root — so typing a path moves the Miller view
|
|
|
+ * exactly as a crumb jump does, and the draft's final segment
|
|
|
+ * prefix-filters the arrival from the next render on.
|
|
|
+ */
|
|
|
+ const previewDraftLevel = useCallback((directory: string) => {
|
|
|
+ land(directory, { closeEditor: false, announce: false })
|
|
|
+ }, [land])
|
|
|
+
|
|
|
/** Abandon path editing (Escape or clicking away) and restore the crumb view. */
|
|
|
const cancelPathEdit = useCallback(() => {
|
|
|
// Cancel also withdraws a navigation the editor already launched: its
|
|
|
@@ -499,8 +651,40 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
|
|
|
return () => { window.clearTimeout(timer) }
|
|
|
}, [loading, scanWindow])
|
|
|
|
|
|
+ // 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 (pathDraft === null) return
|
|
|
+ const timer = window.setTimeout(() => {
|
|
|
+ if (previewSuspended.current) return
|
|
|
+ // The level the panes present as current: it alone may answer the
|
|
|
+ // draft, so anything else it names is a level to walk to.
|
|
|
+ const current = viewRef.current.child ?? viewRef.current.parent
|
|
|
+ if (current === null) return
|
|
|
+ const { directory, tail } = readDraft(current, pathDraft, scanned.current)
|
|
|
+ if (directory === null || tail !== null) return
|
|
|
+ previewDraftLevel(directory)
|
|
|
+ }, DRAFT_PREVIEW_DEBOUNCE_MS)
|
|
|
+ return () => { window.clearTimeout(timer) }
|
|
|
+ }, [pathDraft, previewDraftLevel])
|
|
|
+
|
|
|
// After the hooks: a closed dialog renders nothing and evaluates no copy.
|
|
|
const crumbSource = child ?? parent
|
|
|
+ // The draft's tail filters the level it names, which by the pane invariant
|
|
|
+ // is the LAST pane — never a pane the draft has already walked away from.
|
|
|
+ // Narrowing that stale pane would move the view twice for one keystroke:
|
|
|
+ // once as it narrows, again as its landing replaces it. It holds still
|
|
|
+ // instead, and the filter arrives with the level it belongs to.
|
|
|
+ const typedPrefix = crumbSource === null || pathDraft === null
|
|
|
+ ? null
|
|
|
+ : readDraft(crumbSource, pathDraft, scanned.current).tail
|
|
|
const crumbs = crumbSource === null ? [] : displayCrumbs(crumbSource, t('browser.home'))
|
|
|
const crumbTail = crumbs.at(-1)?.path
|
|
|
useEffect(() => {
|
|
|
@@ -523,6 +707,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
|
|
|
@@ -637,11 +827,17 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
|
|
|
</span>
|
|
|
))}
|
|
|
</span>
|
|
|
- {/* The empty zone right of the crumbs is the path-edit affordance. */}
|
|
|
+ {/* The empty zone right of the crumbs is the path-edit
|
|
|
+ * affordance: the whole remainder of the bar clicks into
|
|
|
+ * the editor, and the pencil glyph parked at its right
|
|
|
+ * edge (with the same tooltip) is what says so — an
|
|
|
+ * invisible target the operator must guess at is the one
|
|
|
+ * way into typing a path. */}
|
|
|
<button
|
|
|
type="button"
|
|
|
className={css.crumbEditZone}
|
|
|
aria-label={t('browser.editPath')}
|
|
|
+ title={t('browser.editPath')}
|
|
|
// Stays available with no listed level: when the home
|
|
|
// listing itself fails, typing an absolute path is the one
|
|
|
// remaining way forward.
|
|
|
@@ -653,6 +849,7 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
|
|
|
// otherwise close the editor via navigate's draft reset.
|
|
|
supersede()
|
|
|
setLoading(false)
|
|
|
+ previewSuspended.current = false
|
|
|
// Seed with a trailing separator so typing immediately
|
|
|
// continues into child names (and prefix-filters below).
|
|
|
// No listed level means nothing to seed from (the editor
|
|
|
@@ -665,7 +862,9 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
|
|
|
const sep = separatorOf(parent)
|
|
|
setPathDraft(base.endsWith(sep) ? base : `${base}${sep}`)
|
|
|
}}
|
|
|
- />
|
|
|
+ >
|
|
|
+ <IconEditOutline16 size={14} className={css.crumbEditGlyph} />
|
|
|
+ </button>
|
|
|
</>
|
|
|
)
|
|
|
: (
|
|
|
@@ -682,6 +881,9 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
|
|
|
// repopulate the view with the older path.
|
|
|
supersede()
|
|
|
setLoading(false)
|
|
|
+ // A fresh edit releases the submission hold: the panes
|
|
|
+ // may follow the new text wherever it points.
|
|
|
+ previewSuspended.current = false
|
|
|
setPathDraft(event.target.value)
|
|
|
}}
|
|
|
{...compositionGuard}
|
|
|
@@ -699,6 +901,11 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
|
|
|
// focus on the returning crumb edit zone (a failure
|
|
|
// keeps the editor, so the flag waits until close).
|
|
|
refocusEditZone.current = true
|
|
|
+ // The submitted path owns the view now: a debounce
|
|
|
+ // timer still pending from these keystrokes would
|
|
|
+ // otherwise supersede this navigation and land the
|
|
|
+ // draft's parent directory instead.
|
|
|
+ previewSuspended.current = true
|
|
|
navigate(pathDraft)
|
|
|
}
|
|
|
}
|
|
|
@@ -716,7 +923,7 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
|
|
|
busy={parentInert}
|
|
|
onPick={select}
|
|
|
showHidden={showHidden}
|
|
|
- filterPrefix={draftPrefixFor(parent, pathDraft)}
|
|
|
+ filterPrefix={child === null ? typedPrefix : null}
|
|
|
pathEditing={draftPending}
|
|
|
/>
|
|
|
)}
|
|
|
@@ -728,7 +935,7 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
|
|
|
busy={parentInert}
|
|
|
onPick={advance}
|
|
|
showHidden={showHidden}
|
|
|
- filterPrefix={draftPrefixFor(child, pathDraft)}
|
|
|
+ filterPrefix={typedPrefix}
|
|
|
pathEditing={draftPending}
|
|
|
/>
|
|
|
)}
|