/** * The in-app workspace-directory browser (figma Harness 813-23126 family): a * 680×500 dialog (clamped to short/narrow viewports — the Miller row scrolls * sideways, the columns scroll down) whose header carries the title, the selection-path * 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 * 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 * to the listed level. Pure consumer of the injected browse calls — the * 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). */ import { useCallback, useEffect, useRef, useState } from 'react' import clsx from 'clsx' import { Button, IconCheckOutline16, IconChevronRightOutline14, 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' import type { Translate } from '@deepseek-ai/dsh-client-locale/client' import css from './DirectoryBrowser.module.css' /** Owner-supplied browser props: browse calls, pick semantics, and copy. */ export interface DirectoryBrowserProps { /** Dialog visibility (owner-local; closed unmounts nothing but resets on reopen). */ open: boolean /** List one directory level (absent path = the Host home directory); the signal aborts a superseded scan on the wire. */ listDirectory: (path?: string, signal?: AbortSignal) => Promise /** Create one child directory under an existing parent. */ createDirectory: (path: string, name: string) => Promise /** The operator confirmed a directory (the selection, else the listed level). */ onOpen: (path: string) => void /** Close without picking (mask, Escape, Cancel). */ onClose: () => void /** The owner's confirm is in flight: Open disables, the view freezes. */ busy: boolean /** Localized copy. */ t: Translate } /** Failure text: the Host business message when typed, else the throw's text. */ function failureText(error: unknown): string { if (error instanceof DirectoryBrowseError) return error.rpcError.message return error instanceof Error ? error.message : String(error) } /** * 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 * by its own path. */ function displayCrumbs(listing: DirectoryListing, homeLabel: string): DirectoryEntry[] { const homeIndex = listing.crumbs.findIndex(crumb => crumb.path === listing.home) if (homeIndex === -1) return listing.crumbs const tail = listing.crumbs.slice(homeIndex + 1) return [{ name: homeLabel, path: listing.home, hidden: false }, ...tail] } /** * The listing's platform separator, inferred from the home path the host * stamped — never from typed text or entry paths, where a backslash is a * legal POSIX name character. Still a heuristic at the last step: a POSIX * home directory whose own name contains a backslash would misread. * TODO: replace with a host-stamped `separator` field on the wire * DirectoryListing so the platform fact travels verbatim (the trade-off is * recorded in the directory-picker capability seam Agent Note). */ function separatorOf(listing: DirectoryListing): '\\' | '/' { return listing.home.includes('\\') ? '\\' : '/' } /** * 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. */ 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 } /** One column of folder rows (the Miller view renders one or two of these). */ function LevelColumn({ entries, selectedPath, busy, onPick, showHidden, filterPrefix, pathEditing }: { entries: readonly DirectoryEntry[] selectedPath: string | null busy: boolean onPick: (entry: DirectoryEntry) => void showHidden: boolean 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 }) return (
{visible.map((entry) => { const selected = entry.path === selectedPath return ( // The wrapper carries the list semantics; the row keeps its NATIVE // button role so assistive technology exposes an actionable control. ) })}
) } /** * Render the directory-browser dialog. * @param props - owner-controlled browser props. * @returns the dialog element (null while closed, via Modal). */ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, onClose, busy, t }: DirectoryBrowserProps) { // Miller state: the listed level, the selected row in it, and the selected // folder's own listing (the right column; null while nothing is selected). const [parent, setParent] = useState(null) const [selected, setSelected] = useState(null) const [child, setChild] = useState(null) const [loading, setLoading] = useState(false) const [error, setError] = useState(null) // Path-edit state: null = breadcrumb mode; a string = the draft being typed. const [pathDraft, setPathDraft] = useState(null) // Show-hidden toggle state (pure client-side filter, reset on each open). const [showHidden, setShowHidden] = useState(false) // Create-folder state: null = closed; a string = the nested dialog's draft. const [folderDraft, setFolderDraft] = useState(null) const [creatingFolder, setCreatingFolder] = useState(false) const [createError, setCreateError] = useState(null) const requestSeq = useRef(0) // The in-flight listing's controller: superseding intent aborts the wire // request too — the Host stops scanning — instead of only discarding the // eventual result while the scan keeps consuming host resources. const scanController = useRef(null) // Bumped on every open/close edge: settlements from a previous open (a // pending creation included) must never mutate a reopened dialog. const openGeneration = useRef(0) // Deep ancestry overflows the trail; keep its tail (the current directory // and the edit zone beside it) in view whenever the chain changes. const crumbTrailRef = useRef(null) // IME confirmation (Enter selecting a candidate) must not submit either // text input; the same guard the workspace-name inputs carry, shared by // the path editor and the folder-name input. const composingRef = useRef(false) // HMR/unmount invalidation: a completion from a disposed flow must not // update state or issue follow-up requests from a dead component. useEffect(() => () => { requestSeq.current += 1 openGeneration.current += 1 scanController.current?.abort() }, []) const compositionGuard = { onCompositionStart: () => { composingRef.current = true }, onCompositionEnd: () => { composingRef.current = false }, } /** Newer intent wins: invalidate the pending listing's settlement AND abort its wire request. */ const supersede = useCallback((): number => { scanController.current?.abort() scanController.current = null return ++requestSeq.current }, []) /** Launch one listing under a fresh controller so a later supersession can abort it. */ const launchListing = useCallback((path: string | undefined): { seq: number; scan: Promise } => { const seq = supersede() const controller = new AbortController() scanController.current = controller return { seq, scan: listDirectory(path, controller.signal) } }, [supersede, listDirectory]) /** * Launch a follow-up listing under the CURRENT supersession seq: a newer * intent aborts it like the leg it continues, and it supersedes nothing. */ const continueScan = useCallback((path: string): Promise => { const controller = new AbortController() scanController.current = controller return listDirectory(path, controller.signal) }, [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. */ const navigate = useCallback((path?: string) => { const { seq, scan } = launchListing(path) setLoading(true) setError(null) scan.then((target) => { if (seq !== requestSeq.current) return 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 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 continueScan(parentCrumb.path).then((parentLevel) => { if (seq !== requestSeq.current) return // Windows resolves a typed path preserving its case; anchor on the // parent level's actual entry so selection comparisons hold. 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 setParent(parentLevel) setSelected(match) setChild(target) }, () => { // Swallows the parent-leg failure (its abort included): the // committed single-pane landing stands, and nobody asked to see // the parent level. }) }, (reason: unknown) => { if (seq !== requestSeq.current) return setLoading(false) setError(failureText(reason)) }) }, [launchListing, continueScan]) // 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 // input. Pointer-out cancels never set (or clear) these — yanking focus // back from wherever the user clicked would be worse than the fall. const refocusPick = useRef(false) const refocusEditZone = useRef(false) const pathInputRef = useRef(null) const editZoneRef = useRef(null) /** Select a row of the listed level and preview its children on the right. */ const select = useCallback((entry: DirectoryEntry) => { const { seq, scan } = launchListing(entry.path) // A pick while the path editor is open adopts the (filtered) row and // closes the editor — the draft served its purpose. Focus re-parks on // the selection after commit (see the refocus effect below). if (pathDraft !== null) refocusPick.current = true setPathDraft(null) setSelected(entry) setChild(null) setLoading(true) setError(null) scan.then((next) => { if (seq !== requestSeq.current) return setChild(next) setLoading(false) }, (reason: unknown) => { if (seq !== requestSeq.current) return setLoading(false) setError(failureText(reason)) // An unreadable selection cannot be the committing target while the // breadcrumb still names the level: fall back to the single pane. setSelected(null) // Clearing the selection can unmount the very row the pick parked // focus on (a dot-revealed hidden row re-hides); the refocus effect // re-parks on the edit zone only if focus actually fell to body. refocusEditZone.current = true }) }, [launchListing, pathDraft]) /** 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 // late success must not jump to the cancelled path, so the pending // request is superseded and the view leaves the loading state. supersede() setLoading(false) setPathDraft(null) setError(null) // Editing may have superseded the selection's preview request; a // selection with no preview would render a half-empty two-pane view, so // cancel falls back to the single-pane level. if (child === null) setSelected(null) // With no level listed yet (the editor superseded the initial home // listing), plain cancellation would leave a permanently blank picker: // restart the home listing. if (parent === null) navigate() }, [supersede, child, parent, navigate]) /** A right-column pick advances the view one level: child becomes the level. */ const advance = useCallback((entry: DirectoryEntry) => { /* v8 ignore next -- narrowing guard: the right column only renders with a child listing. */ if (child === null) return setParent(child) select(entry) }, [child, select]) // Every open starts fresh at the Host home directory; closing invalidates // any in-flight response so a late arrival cannot repopulate a closed dialog. useEffect(() => { openGeneration.current += 1 if (open) { setParent(null) setSelected(null) setChild(null) setCreatingFolder(false) setShowHidden(false) navigate() return } supersede() setError(null) setPathDraft(null) setFolderDraft(null) setCreateError(null) // A close mid-flight (failed Enter, then Cancel) may leave refocus // flags armed; retire them so a later render cannot consume them. refocusPick.current = false refocusEditZone.current = false }, [open, navigate, supersede]) /** The folder a create or Open acts on: the selection, else the listed level. */ const targetPath = selected?.path ?? parent?.path ?? null const targetName = selected?.name ?? (parent === null ? '' : (displayCrumbs(parent, t('browser.home')).at(-1)?.name ?? parent.path)) const confirmCreate = (): void => { /* v8 ignore next -- reentry fence: the nested dialog only renders with a target and disables while creating. */ if (targetPath === null || folderDraft === null || creatingFolder) return // Trim only rejects an all-whitespace draft; the Host gets the original // spelling — the backend accepts any non-blank single segment verbatim, // and trimming here would create (and select) a different sibling. const name = folderDraft if (name.trim() === '') return setCreatingFolder(true) setCreateError(null) const generation = openGeneration.current createDirectory(targetPath, name).then((createdPath) => { // A settlement from a closed (possibly reopened) flow must not touch // the fresh dialog or issue a relist against the stale target. if (generation !== openGeneration.current) return setCreatingFolder(false) setFolderDraft(null) // Land like a right-column pick (figma 802:57446 → 813:23278 flow): the // create target becomes the listed level and the new folder its selection. const { seq, scan } = launchListing(targetPath) setLoading(true) scan.then((level) => { /* v8 ignore next -- same fence as navigate/select; the modal blocks superseding input */ if (seq !== requestSeq.current) return setParent(level) setLoading(false) select({ name, path: createdPath, hidden: false }) }, (reason: unknown) => { /* v8 ignore next -- same fence as navigate/select; the modal blocks superseding input */ if (seq !== requestSeq.current) return setLoading(false) setError(failureText(reason)) }) }, (reason: unknown) => { if (generation !== openGeneration.current) return setCreatingFolder(false) setCreateError(failureText(reason)) }) } // 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')) const crumbTail = crumbs.at(-1)?.path useEffect(() => { const trail = crumbTrailRef.current if (trail !== null) trail.scrollLeft = trail.scrollWidth }, [crumbTail]) // On viewports too narrow for both fixed panes the Miller row scrolls; // whenever a child preview lands, pin it into view the way the crumb tail // pins — otherwise descent is unreachable on a phone-width window. const millerRowRef = useRef(null) const childPath = child?.path useEffect(() => { const row = millerRowRef.current if (row !== null && childPath !== undefined) row.scrollLeft = row.scrollWidth }, [childPath]) // Every editor exit that would drop focus to body re-parks it after // commit, so keyboard traversal stays inside the dialog (the Modal has no // focus trap): a pick lands on the selection's row — aria-current in the // freshly rendered left pane, which survives even a right-pane advance // 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 (pathDraft !== null) return if (refocusPick.current) { refocusPick.current = false refocusEditZone.current = false const rowHost = millerRowRef.current /* v8 ignore next -- narrowing guard: the miller row is mounted whenever a pick just committed. */ if (rowHost === null) return const row = rowHost.querySelector('button[aria-current="true"]') /* v8 ignore next -- narrowing guard: the pick that set the flag just rendered its aria-current row. */ if (row === null) return row.focus() return } if (refocusEditZone.current) { refocusEditZone.current = false // Re-park only when the close actually dropped focus to body; focus // the user parked elsewhere (a surviving row) stays theirs. if (document.activeElement !== document.body) return const zone = editZoneRef.current /* v8 ignore next -- narrowing guard: crumb mode renders the edit zone whenever the editor just closed. */ if (zone === null) return zone.focus() } }) if (!open) return null const twoPane = selected !== null // The nested create dialog owns the interaction while open: Modal has no // focus trap, so every parent control goes inert (Shift-Tab or AT must not // close, adopt, or retarget underneath the child). const parentInert = busy || folderDraft !== null // An uncommitted path draft makes targetPath stale relative to the header: // committing actions must not act on the previous selection/listing while // a different path is displayed. const draftPending = pathDraft !== null return ( { if (folderDraft === null && !busy) onClose() }} title={t('browser.title')} className={clsx(css.dialog)} headless > {/* Path-edit cancellation is observed at the card scope, not the * input: once Tab parks focus on a filtered row the input is off the * event path, yet Escape must still collapse the editor (not the * dialog) and a further focus move out of the card must still * cancel. display:contents keeps header/content/footer as direct * flex children of the Modal card. */}
{ if (event.key !== 'Escape' || pathDraft === null) return // stopPropagation keeps the card-scope Escape from the Modal's // document listener — the same containment the input previously // provided for itself. event.stopPropagation() // Escape while the input holds focus is about to unmount it; with // focus already parked on a row, that row survives the cancel and // keeps focus naturally. Assignment (not a conditional set) also // retires a stale flag a failed or still-upgrading Enter left. refocusEditZone.current = document.activeElement === pathInputRef.current cancelPathEdit() }} // Focus leaving THIS dialog card while editing cancels like Escape. // Guarded non-cancel paths: window/tab focus loss (document no // longer focused); a focus move that stays inside the card (Tab // onto the filtered rows or the footer toggle); and pointer paths, // where rows and the toggle suppress focus steal on mousedown while // editing so their click lands first. Enter keeps focus in the // input while its navigation is in flight, so a submitted path is // never withdrawn here. Anchored to this card via closest, not any // [role="dialog"], so focus escaping into a sibling overlay cancels. onBlur={(event) => { if (pathDraft === null) return if (!document.hasFocus()) return const card = event.currentTarget.closest('[role="dialog"]') /* v8 ignore next -- narrowing guard: this scope always renders inside the Modal card. */ if (card === null) return if (event.relatedTarget instanceof Node && card.contains(event.relatedTarget)) return // The user moved focus out of the card themselves: cancel without // re-parking (a lingering Enter-failure flag must not yank focus // back either). refocusEditZone.current = false cancelPathEdit() }} >

{t('browser.title')}

{pathDraft === null ? ( <> {crumbs.map((crumb, index) => ( {index > 0 && } ))} {/* The empty zone right of the crumbs is the path-edit affordance. */}
{parent !== null && ( )} {twoPane && } {twoPane && child !== null && ( )}
{loading &&
{t('browser.loading')}
} {/* 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 &&
{t('browser.truncated')}
} {error !== null &&
{error}
}
{/* Nested create dialog (figma 813:23278): names one folder inside the target. */} { if (!creatingFolder) setFolderDraft(null) }} title={t('browser.newFolder')} className={clsx(css.createDialog)} headless >

{t('browser.newFolder')}

{t('browser.createIn', { name: targetName })}

{ setFolderDraft(event.target.value) }} {...compositionGuard} onKeyDown={(event) => { if (event.key === 'Enter' && !composingRef.current) { event.preventDefault() confirmCreate() } if (event.key === 'Escape') { event.stopPropagation() if (!creatingFolder) setFolderDraft(null) } }} /> {createError !== null &&
{createError}
}
) }