feat(web): miller two-pane directory browser per the full figma flow
The single-column dialog missed the design's interaction model (figma
813-23126/813-23278, sibling frames of the linked node): selection is
not navigation. The browser now opens as one wide level; selecting a
row keeps it marked (pill + open-folder accent) and previews its
children in a second 256px column across a hairline divider, a
right-column pick advances one level, and the breadcrumb follows the
selection. New folder becomes the design's nested create dialog
("New folder in ...", Untitled-folder placeholder, Cancel/Create),
creating inside the selection and landing with the new folder selected.
Open adopts the selection, falling back to the listed level, so the
e2e path-edit flow is unchanged. The card is the design's fixed
600x420 with per-column scrolling.
This commit is contained in:
@@ -1,13 +1,14 @@
|
|||||||
/* Directory-browser dialog (figma 802-56979). The shared Modal renders
|
/* Directory-browser dialog (figma 813-23126 family). The shared Modal renders
|
||||||
* headless here — mask, card, Escape only — and this module owns the figma
|
* headless here — mask, card, Escape only — and this module owns the figma
|
||||||
* frame exactly: header (title + crumbs, l3 separator), one directory level,
|
* frame exactly: fixed 600×420 card, header (title + crumbs, l3 separator),
|
||||||
* and the bordered footer. Card: w600 r24, bottom pad 12, no close chrome. */
|
* the one-or-two-column Miller content, and the bordered footer. */
|
||||||
|
|
||||||
/* Doubled class beats Modal's own .dialog regardless of stylesheet order. */
|
/* Doubled class beats Modal's own .dialog regardless of stylesheet order. */
|
||||||
.dialog.dialog {
|
.dialog.dialog {
|
||||||
width: min(600px, 100%);
|
width: min(600px, 100%);
|
||||||
padding: 0 0 12px;
|
height: 420px;
|
||||||
gap: 16px;
|
padding: 0;
|
||||||
|
gap: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Header block: pl24 pr14 pt22 pb12, 8px between title row and crumb row. */
|
/* Header block: pl24 pr14 pt22 pb12, 8px between title row and crumb row. */
|
||||||
@@ -15,6 +16,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
flex: none;
|
||||||
padding: 22px 14px 12px 24px;
|
padding: 22px 14px 12px 24px;
|
||||||
border-bottom: 1px solid var(--dsw-alias-border-l3);
|
border-bottom: 1px solid var(--dsw-alias-border-l3);
|
||||||
}
|
}
|
||||||
@@ -95,16 +97,37 @@
|
|||||||
color: var(--dsw-alias-label-primary);
|
color: var(--dsw-alias-label-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* One directory level: content column pt16 px24, 28px rows with 2px gaps. */
|
/* Miller content: pt16 px24; columns are 256 wide (or full width solo) with
|
||||||
.level {
|
* the hairline divider centered between them; each column scrolls alone. */
|
||||||
|
.content {
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
flex: 1 1 0;
|
||||||
|
min-height: 0;
|
||||||
|
gap: 20px;
|
||||||
|
padding: 16px 24px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.column {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
padding: 16px 24px 0;
|
width: 256px;
|
||||||
max-height: 320px;
|
flex: none;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.columnWide {
|
||||||
|
width: 100%;
|
||||||
|
flex: 1 1 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.divider {
|
||||||
|
flex: none;
|
||||||
|
width: 1px;
|
||||||
|
background: var(--dsw-alias-border-l3);
|
||||||
|
}
|
||||||
|
|
||||||
.row {
|
.row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -123,11 +146,22 @@
|
|||||||
background: var(--dsw-alias-interactive-bg-hover);
|
background: var(--dsw-alias-interactive-bg-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Selection: pill fill + the open-folder glyph in the info accent. */
|
||||||
|
.rowSelected,
|
||||||
|
.rowSelected:hover {
|
||||||
|
background: var(--dsw-alias-interactive-bg-active, var(--dsw-alias-interactive-bg-hover));
|
||||||
|
}
|
||||||
|
|
||||||
.rowIcon {
|
.rowIcon {
|
||||||
flex: none;
|
flex: none;
|
||||||
color: var(--dsw-alias-label-secondary);
|
color: var(--dsw-alias-label-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.rowIconSelected {
|
||||||
|
flex: none;
|
||||||
|
color: var(--dsw-alias-button-info-fill);
|
||||||
|
}
|
||||||
|
|
||||||
.rowName {
|
.rowName {
|
||||||
flex: 1 1 0;
|
flex: 1 1 0;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@@ -145,29 +179,6 @@
|
|||||||
color: var(--dsw-alias-label-tertiary);
|
color: var(--dsw-alias-label-tertiary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.folderRow {
|
|
||||||
cursor: default;
|
|
||||||
}
|
|
||||||
|
|
||||||
.folderInput {
|
|
||||||
box-sizing: border-box;
|
|
||||||
flex: 1 1 0;
|
|
||||||
min-width: 0;
|
|
||||||
height: 24px;
|
|
||||||
padding: 0 6px;
|
|
||||||
border: 1px solid var(--dsw-alias-border-l2);
|
|
||||||
border-radius: 6px;
|
|
||||||
outline: none;
|
|
||||||
background: transparent;
|
|
||||||
font-size: 13px;
|
|
||||||
line-height: 20px;
|
|
||||||
color: var(--dsw-alias-label-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.folderInput::placeholder {
|
|
||||||
color: var(--dsw-alias-label-caption);
|
|
||||||
}
|
|
||||||
|
|
||||||
.status,
|
.status,
|
||||||
.error {
|
.error {
|
||||||
padding: 4px;
|
padding: 4px;
|
||||||
@@ -183,12 +194,14 @@
|
|||||||
color: var(--dsw-alias-state-error-primary);
|
color: var(--dsw-alias-state-error-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Footer: l3 separator on top, pt12 px24, New-folder pinned left. */
|
/* Footer: l3 separator on top, pt12 px24, New-folder pinned left; the fixed
|
||||||
|
* card leaves the figma 28px below the 36px buttons. */
|
||||||
.footerBar {
|
.footerBar {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 12px 24px 0;
|
flex: none;
|
||||||
|
padding: 12px 24px 28px;
|
||||||
border-top: 1px solid var(--dsw-alias-border-l3);
|
border-top: 1px solid var(--dsw-alias-border-l3);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -199,3 +212,58 @@
|
|||||||
.footerAction {
|
.footerAction {
|
||||||
min-width: 72px;
|
min-width: 72px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Nested create dialog (figma 813:23278): a small centered card. */
|
||||||
|
.createDialog.createDialog {
|
||||||
|
width: min(380px, 100%);
|
||||||
|
padding: 0;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.createBody {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 22px 24px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.createTitle {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 24px;
|
||||||
|
font-weight: 510;
|
||||||
|
color: var(--dsw-alias-label-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.createIn {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 22px;
|
||||||
|
color: var(--dsw-alias-label-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.createInput {
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 100%;
|
||||||
|
height: 44px;
|
||||||
|
padding: 7px 14px;
|
||||||
|
border: 1px solid var(--dsw-alias-border-l2);
|
||||||
|
border-radius: 22px;
|
||||||
|
outline: none;
|
||||||
|
background: transparent;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 22px;
|
||||||
|
color: var(--dsw-alias-label-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.createInput::placeholder {
|
||||||
|
color: var(--dsw-alias-label-caption);
|
||||||
|
}
|
||||||
|
|
||||||
|
.createActions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,15 +1,21 @@
|
|||||||
/**
|
/**
|
||||||
* The in-app workspace-directory browser (figma Harness 802-56979): breadcrumb
|
* The in-app workspace-directory browser (figma Harness 813-23126 family): a
|
||||||
* header with a click-to-edit path zone, one navigable directory level, an
|
* fixed 600×420 dialog whose header carries the title, the selection-path
|
||||||
* inline New-folder row, and the Cancel/Open footer. Pure consumer of the
|
* breadcrumb, and a click-to-edit path zone; below it a Miller view — one
|
||||||
* injected browse calls — the owning flow decides what "Open" means and owns
|
* full-width level until a row is selected, then two 256px columns (level |
|
||||||
* the workspace-creation error surface. Hidden entries are host-flagged and
|
* selected folder's children) around a hairline divider. Selecting in the
|
||||||
* filtered here (a show-hidden toggle is deferred work, client-side only).
|
* 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 filtered here (a
|
||||||
|
* show-hidden toggle is deferred work, client-side only).
|
||||||
*/
|
*/
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import {
|
import {
|
||||||
Button, IconChevronRightOutline14, IconFolderClose16, IconPlusOutline16, Modal,
|
Button, IconChevronRightOutline14, IconFolderClose16, IconFolderOpen16, IconPlusOutline16, Modal,
|
||||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||||
import type { DirectoryEntry, DirectoryListing } from '@deepseek-ai/dsh-client-runtime/client'
|
import type { DirectoryEntry, DirectoryListing } from '@deepseek-ai/dsh-client-runtime/client'
|
||||||
import { DirectoryBrowseError } from '@deepseek-ai/dsh-client-runtime/client'
|
import { DirectoryBrowseError } from '@deepseek-ai/dsh-client-runtime/client'
|
||||||
@@ -22,13 +28,13 @@ export interface DirectoryBrowserProps {
|
|||||||
open: boolean
|
open: boolean
|
||||||
/** List one directory level (absent path = the Host home directory). */
|
/** List one directory level (absent path = the Host home directory). */
|
||||||
listDirectory: (path?: string) => Promise<DirectoryListing>
|
listDirectory: (path?: string) => Promise<DirectoryListing>
|
||||||
/** Create one child directory under the listed level. */
|
/** Create one child directory under an existing parent. */
|
||||||
createDirectory: (path: string, name: string) => Promise<string>
|
createDirectory: (path: string, name: string) => Promise<string>
|
||||||
/** The operator confirmed the currently listed directory. */
|
/** The operator confirmed a directory (the selection, else the listed level). */
|
||||||
onOpen: (path: string) => void
|
onOpen: (path: string) => void
|
||||||
/** Close without picking (mask, Escape, Cancel). */
|
/** Close without picking (mask, Escape, Cancel). */
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
/** The owner's confirm is in flight: Open disables, the level freezes. */
|
/** The owner's confirm is in flight: Open disables, the view freezes. */
|
||||||
busy: boolean
|
busy: boolean
|
||||||
/** Localized copy. */
|
/** Localized copy. */
|
||||||
t: Translate
|
t: Translate
|
||||||
@@ -52,32 +58,73 @@ function displayCrumbs(listing: DirectoryListing, homeLabel: string): DirectoryE
|
|||||||
return [{ name: homeLabel, path: listing.home, hidden: false }, ...tail]
|
return [{ name: homeLabel, path: listing.home, hidden: false }, ...tail]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One column of folder rows (the Miller view renders one or two of these). */
|
||||||
|
function LevelColumn({ entries, selectedPath, busy, onPick, wide }: {
|
||||||
|
entries: readonly DirectoryEntry[]
|
||||||
|
selectedPath: string | null
|
||||||
|
busy: boolean
|
||||||
|
onPick: (entry: DirectoryEntry) => void
|
||||||
|
wide: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className={clsx(css.column, wide && css.columnWide)} role="list">
|
||||||
|
{entries.filter(entry => !entry.hidden).map((entry) => {
|
||||||
|
const selected = entry.path === selectedPath
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={entry.path}
|
||||||
|
type="button"
|
||||||
|
role="listitem"
|
||||||
|
aria-current={selected || undefined}
|
||||||
|
className={clsx(css.row, selected && css.rowSelected)}
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => { onPick(entry) }}
|
||||||
|
>
|
||||||
|
{selected
|
||||||
|
? <IconFolderOpen16 size={16} className={css.rowIconSelected} />
|
||||||
|
: <IconFolderClose16 size={16} className={css.rowIcon} />}
|
||||||
|
<span className={css.rowName}>{entry.name}</span>
|
||||||
|
<IconChevronRightOutline14 size={12} className={css.rowChevron} />
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Render the directory-browser dialog.
|
* Render the directory-browser dialog.
|
||||||
* @param props - owner-controlled browser props.
|
* @param props - owner-controlled browser props.
|
||||||
* @returns the dialog element (null while closed, via Modal).
|
* @returns the dialog element (null while closed, via Modal).
|
||||||
*/
|
*/
|
||||||
export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, onClose, busy, t }: DirectoryBrowserProps) {
|
export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, onClose, busy, t }: DirectoryBrowserProps) {
|
||||||
const [listing, setListing] = useState<DirectoryListing | null>(null)
|
// 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<DirectoryListing | null>(null)
|
||||||
|
const [selected, setSelected] = useState<DirectoryEntry | null>(null)
|
||||||
|
const [child, setChild] = useState<DirectoryListing | null>(null)
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
// Path-edit state: null = breadcrumb mode; a string = the draft being typed.
|
// Path-edit state: null = breadcrumb mode; a string = the draft being typed.
|
||||||
const [pathDraft, setPathDraft] = useState<string | null>(null)
|
const [pathDraft, setPathDraft] = useState<string | null>(null)
|
||||||
// New-folder state: null = no inline row; a string = the name being typed.
|
// Create-folder state: null = closed; a string = the nested dialog's draft.
|
||||||
const [folderDraft, setFolderDraft] = useState<string | null>(null)
|
const [folderDraft, setFolderDraft] = useState<string | null>(null)
|
||||||
const [creatingFolder, setCreatingFolder] = useState(false)
|
const [creatingFolder, setCreatingFolder] = useState(false)
|
||||||
|
const [createError, setCreateError] = useState<string | null>(null)
|
||||||
const requestSeq = useRef(0)
|
const requestSeq = useRef(0)
|
||||||
|
|
||||||
|
/** Replace the whole view with one freshly listed level (no selection). */
|
||||||
const navigate = useCallback((path?: string) => {
|
const navigate = useCallback((path?: string) => {
|
||||||
const seq = ++requestSeq.current
|
const seq = ++requestSeq.current
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
listDirectory(path).then((next) => {
|
listDirectory(path).then((next) => {
|
||||||
if (seq !== requestSeq.current) return
|
if (seq !== requestSeq.current) return
|
||||||
setListing(next)
|
setParent(next)
|
||||||
|
setSelected(null)
|
||||||
|
setChild(null)
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
setPathDraft(null)
|
setPathDraft(null)
|
||||||
setFolderDraft(null)
|
|
||||||
}, (reason: unknown) => {
|
}, (reason: unknown) => {
|
||||||
if (seq !== requestSeq.current) return
|
if (seq !== requestSeq.current) return
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
@@ -85,11 +132,39 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
|
|||||||
})
|
})
|
||||||
}, [listDirectory])
|
}, [listDirectory])
|
||||||
|
|
||||||
|
/** Select a row of the listed level and preview its children on the right. */
|
||||||
|
const select = useCallback((entry: DirectoryEntry) => {
|
||||||
|
const seq = ++requestSeq.current
|
||||||
|
setSelected(entry)
|
||||||
|
setChild(null)
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
listDirectory(entry.path).then((next) => {
|
||||||
|
if (seq !== requestSeq.current) return
|
||||||
|
setChild(next)
|
||||||
|
setLoading(false)
|
||||||
|
}, (reason: unknown) => {
|
||||||
|
if (seq !== requestSeq.current) return
|
||||||
|
setLoading(false)
|
||||||
|
setError(failureText(reason))
|
||||||
|
})
|
||||||
|
}, [listDirectory])
|
||||||
|
|
||||||
|
/** 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
|
// Every open starts fresh at the Host home directory; closing invalidates
|
||||||
// any in-flight response so a late arrival cannot repopulate a closed dialog.
|
// any in-flight response so a late arrival cannot repopulate a closed dialog.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) {
|
if (open) {
|
||||||
setListing(null)
|
setParent(null)
|
||||||
|
setSelected(null)
|
||||||
|
setChild(null)
|
||||||
navigate()
|
navigate()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -97,29 +172,56 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
|
|||||||
setError(null)
|
setError(null)
|
||||||
setPathDraft(null)
|
setPathDraft(null)
|
||||||
setFolderDraft(null)
|
setFolderDraft(null)
|
||||||
|
setCreateError(null)
|
||||||
}, [open, navigate])
|
}, [open, navigate])
|
||||||
|
|
||||||
const confirmFolder = (): void => {
|
/** The folder a create or Open acts on: the selection, else the listed level. */
|
||||||
/* v8 ignore next -- reentry fence: the inline row only renders with a listing and a draft, and the input disables while creating. */
|
const targetPath = selected?.path ?? parent?.path ?? null
|
||||||
if (listing === null || folderDraft === null || creatingFolder) return
|
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
|
||||||
const name = folderDraft.trim()
|
const name = folderDraft.trim()
|
||||||
if (name === '') return
|
if (name === '') return
|
||||||
setCreatingFolder(true)
|
setCreatingFolder(true)
|
||||||
setError(null)
|
setCreateError(null)
|
||||||
createDirectory(listing.path, name).then(() => {
|
createDirectory(targetPath, name).then((createdPath) => {
|
||||||
setCreatingFolder(false)
|
setCreatingFolder(false)
|
||||||
setFolderDraft(null)
|
setFolderDraft(null)
|
||||||
navigate(listing.path)
|
// 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 = ++requestSeq.current
|
||||||
|
setLoading(true)
|
||||||
|
listDirectory(targetPath).then((level) => {
|
||||||
|
// Same seq fence as navigate/select; the nested dialog blocks
|
||||||
|
// superseding input during this relist.
|
||||||
|
/* v8 ignore next */
|
||||||
|
if (seq !== requestSeq.current) return
|
||||||
|
setParent(level)
|
||||||
|
setLoading(false)
|
||||||
|
select({ name, path: createdPath, hidden: false })
|
||||||
|
}, (reason: unknown) => {
|
||||||
|
// Same seq fence as navigate/select; the nested dialog blocks
|
||||||
|
// superseding input during this relist.
|
||||||
|
/* v8 ignore next */
|
||||||
|
if (seq !== requestSeq.current) return
|
||||||
|
setLoading(false)
|
||||||
|
setError(failureText(reason))
|
||||||
|
})
|
||||||
}, (reason: unknown) => {
|
}, (reason: unknown) => {
|
||||||
setCreatingFolder(false)
|
setCreatingFolder(false)
|
||||||
setError(failureText(reason))
|
setCreateError(failureText(reason))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// After the hooks: a closed dialog renders nothing and evaluates no copy.
|
// After the hooks: a closed dialog renders nothing and evaluates no copy.
|
||||||
if (!open) return null
|
if (!open) return null
|
||||||
|
|
||||||
const crumbs = listing === null ? [] : displayCrumbs(listing, t('browser.home'))
|
const crumbSource = child ?? parent
|
||||||
|
const crumbs = crumbSource === null ? [] : displayCrumbs(crumbSource, t('browser.home'))
|
||||||
|
const twoPane = selected !== null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
@@ -153,9 +255,9 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
|
|||||||
type="button"
|
type="button"
|
||||||
className={css.crumbEditZone}
|
className={css.crumbEditZone}
|
||||||
aria-label={t('browser.editPath')}
|
aria-label={t('browser.editPath')}
|
||||||
disabled={listing === null || busy}
|
disabled={parent === null || busy}
|
||||||
/* v8 ignore next -- narrowing guard: the zone disables while the listing is null. */
|
/* v8 ignore next -- narrowing guard: the zone disables while the level is null. */
|
||||||
onClick={() => { if (listing !== null) setPathDraft(listing.path) }}
|
onClick={() => { if (parent !== null) setPathDraft(selected?.path ?? parent.path) }}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
@@ -183,45 +285,26 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className={css.level} role="list" aria-label={t('browser.title')}>
|
<div className={css.content}>
|
||||||
{folderDraft !== null && listing !== null && (
|
{parent !== null && (
|
||||||
<div className={clsx(css.row, css.folderRow)}>
|
<LevelColumn
|
||||||
<IconFolderClose16 size={16} className={css.rowIcon} />
|
entries={parent.entries}
|
||||||
<input
|
selectedPath={selected?.path ?? null}
|
||||||
className={css.folderInput}
|
busy={busy}
|
||||||
value={folderDraft}
|
onPick={select}
|
||||||
aria-label={t('browser.newFolder')}
|
wide={!twoPane}
|
||||||
placeholder={t('browser.folderName')}
|
/>
|
||||||
autoFocus
|
)}
|
||||||
disabled={creatingFolder}
|
{twoPane && <span className={css.divider} />}
|
||||||
onChange={(event) => { setFolderDraft(event.target.value) }}
|
{twoPane && child !== null && (
|
||||||
onKeyDown={(event) => {
|
<LevelColumn
|
||||||
if (event.key === 'Enter') {
|
entries={child.entries}
|
||||||
event.preventDefault()
|
selectedPath={null}
|
||||||
confirmFolder()
|
busy={busy}
|
||||||
}
|
onPick={advance}
|
||||||
if (event.key === 'Escape') {
|
wide={false}
|
||||||
event.stopPropagation()
|
/>
|
||||||
setFolderDraft(null)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
{listing?.entries.filter(entry => !entry.hidden).map(entry => (
|
|
||||||
<button
|
|
||||||
key={entry.path}
|
|
||||||
type="button"
|
|
||||||
role="listitem"
|
|
||||||
className={css.row}
|
|
||||||
disabled={busy}
|
|
||||||
onClick={() => { navigate(entry.path) }}
|
|
||||||
>
|
|
||||||
<IconFolderClose16 size={16} className={css.rowIcon} />
|
|
||||||
<span className={css.rowName}>{entry.name}</span>
|
|
||||||
<IconChevronRightOutline14 size={12} className={css.rowChevron} />
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
{loading && <div className={css.status} role="status">{t('browser.loading')}</div>}
|
{loading && <div className={css.status} role="status">{t('browser.loading')}</div>}
|
||||||
{error !== null && <div className={css.error} role="alert">{error}</div>}
|
{error !== null && <div className={css.error} role="alert">{error}</div>}
|
||||||
</div>
|
</div>
|
||||||
@@ -229,8 +312,11 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
icon={<IconPlusOutline16 size={14} />}
|
icon={<IconPlusOutline16 size={14} />}
|
||||||
disabled={listing === null || busy || folderDraft !== null}
|
disabled={parent === null || busy || folderDraft !== null}
|
||||||
onClick={() => { setFolderDraft('') }}
|
onClick={() => {
|
||||||
|
setFolderDraft('')
|
||||||
|
setCreateError(null)
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{t('browser.newFolder')}
|
{t('browser.newFolder')}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -239,13 +325,56 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
|
|||||||
<Button
|
<Button
|
||||||
variant="primary"
|
variant="primary"
|
||||||
className={clsx(css.footerAction)}
|
className={clsx(css.footerAction)}
|
||||||
disabled={listing === null || loading || busy}
|
disabled={targetPath === null || loading || busy}
|
||||||
/* v8 ignore next -- narrowing guard: Open disables while the listing is null. */
|
/* v8 ignore next -- narrowing guard: Open disables while no target exists. */
|
||||||
onClick={() => { if (listing !== null) onOpen(listing.path) }}
|
onClick={() => { if (targetPath !== null) onOpen(targetPath) }}
|
||||||
>
|
>
|
||||||
{t('browser.open')}
|
{t('browser.open')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
{/* Nested create dialog (figma 813:23278): names one folder inside the target. */}
|
||||||
|
<Modal
|
||||||
|
open={folderDraft !== null}
|
||||||
|
onClose={() => { if (!creatingFolder) setFolderDraft(null) }}
|
||||||
|
title={t('browser.newFolder')}
|
||||||
|
className={clsx(css.createDialog)}
|
||||||
|
headless
|
||||||
|
>
|
||||||
|
<div className={css.createBody}>
|
||||||
|
<h3 className={css.createTitle}>{t('browser.newFolder')}</h3>
|
||||||
|
<p className={css.createIn}>{t('browser.createIn', { name: targetName })}</p>
|
||||||
|
<input
|
||||||
|
className={css.createInput}
|
||||||
|
value={folderDraft ?? ''}
|
||||||
|
aria-label={t('browser.folderName')}
|
||||||
|
placeholder={t('browser.untitledFolder')}
|
||||||
|
autoFocus
|
||||||
|
disabled={creatingFolder}
|
||||||
|
onChange={(event) => { setFolderDraft(event.target.value) }}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
event.preventDefault()
|
||||||
|
confirmCreate()
|
||||||
|
}
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
event.stopPropagation()
|
||||||
|
if (!creatingFolder) setFolderDraft(null)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{createError !== null && <div className={css.error} role="alert">{createError}</div>}
|
||||||
|
<div className={css.createActions}>
|
||||||
|
<Button variant="outline" disabled={creatingFolder} onClick={() => { setFolderDraft(null) }}>{t('browser.cancel')}</Button>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
disabled={creatingFolder || folderDraft === null || folderDraft.trim() === ''}
|
||||||
|
onClick={confirmCreate}
|
||||||
|
>
|
||||||
|
{t('browser.create')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
</Modal>
|
</Modal>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,6 +44,9 @@ export function apply(ctx: ClientContext): void {
|
|||||||
'browser.home': '主目录',
|
'browser.home': '主目录',
|
||||||
'browser.newFolder': '新建文件夹',
|
'browser.newFolder': '新建文件夹',
|
||||||
'browser.folderName': '文件夹名称',
|
'browser.folderName': '文件夹名称',
|
||||||
|
'browser.createIn': '在"{name}"中新建文件夹',
|
||||||
|
'browser.untitledFolder': '未命名文件夹',
|
||||||
|
'browser.create': '创建',
|
||||||
'browser.cancel': '取消',
|
'browser.cancel': '取消',
|
||||||
'browser.open': '打开',
|
'browser.open': '打开',
|
||||||
'browser.editPath': '编辑路径',
|
'browser.editPath': '编辑路径',
|
||||||
@@ -54,6 +57,9 @@ export function apply(ctx: ClientContext): void {
|
|||||||
'browser.home': 'Home',
|
'browser.home': 'Home',
|
||||||
'browser.newFolder': 'New folder',
|
'browser.newFolder': 'New folder',
|
||||||
'browser.folderName': 'Folder name',
|
'browser.folderName': 'Folder name',
|
||||||
|
'browser.createIn': 'New folder in "{name}"',
|
||||||
|
'browser.untitledFolder': 'Untitled folder',
|
||||||
|
'browser.create': 'Create',
|
||||||
'browser.cancel': 'Cancel',
|
'browser.cancel': 'Cancel',
|
||||||
'browser.open': 'Open',
|
'browser.open': 'Open',
|
||||||
'browser.editPath': 'Edit path',
|
'browser.editPath': 'Edit path',
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// @vitest-environment jsdom
|
// @vitest-environment jsdom
|
||||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||||
import type { DirectoryListing } from '@deepseek-ai/dsh-client-runtime/client'
|
import type { DirectoryListing } from '@deepseek-ai/dsh-client-runtime/client'
|
||||||
import { DirectoryBrowseError } from '@deepseek-ai/dsh-client-runtime/client'
|
import { DirectoryBrowseError } from '@deepseek-ai/dsh-client-runtime/client'
|
||||||
import { DirectoryBrowser } from '../src/client/DirectoryBrowser.tsx'
|
import { DirectoryBrowser } from '../src/client/DirectoryBrowser.tsx'
|
||||||
@@ -8,6 +8,8 @@ import { DirectoryBrowser } from '../src/client/DirectoryBrowser.tsx'
|
|||||||
afterEach(cleanup)
|
afterEach(cleanup)
|
||||||
|
|
||||||
const HOME = '/home/u'
|
const HOME = '/home/u'
|
||||||
|
const DOCS = `${HOME}/Documents`
|
||||||
|
const HARNESS = `${DOCS}/harness`
|
||||||
|
|
||||||
/** Listing fake over a tiny fixed tree; unknown paths reject like the Host. */
|
/** Listing fake over a tiny fixed tree; unknown paths reject like the Host. */
|
||||||
function listingFor(path?: string): DirectoryListing {
|
function listingFor(path?: string): DirectoryListing {
|
||||||
@@ -23,19 +25,31 @@ function listingFor(path?: string): DirectoryListing {
|
|||||||
],
|
],
|
||||||
entries: [
|
entries: [
|
||||||
{ name: '.config', path: `${HOME}/.config`, hidden: true },
|
{ name: '.config', path: `${HOME}/.config`, hidden: true },
|
||||||
{ name: 'Documents', path: `${HOME}/Documents`, hidden: false },
|
{ name: 'Documents', path: DOCS, hidden: false },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
[`${HOME}/Documents`]: {
|
[DOCS]: {
|
||||||
path: `${HOME}/Documents`,
|
path: DOCS,
|
||||||
home: HOME,
|
home: HOME,
|
||||||
crumbs: [
|
crumbs: [
|
||||||
{ name: '/', path: '/', hidden: false },
|
{ name: '/', path: '/', hidden: false },
|
||||||
{ name: 'home', path: '/home', hidden: false },
|
{ name: 'home', path: '/home', hidden: false },
|
||||||
{ name: 'u', path: HOME, hidden: false },
|
{ name: 'u', path: HOME, hidden: false },
|
||||||
{ name: 'Documents', path: `${HOME}/Documents`, hidden: false },
|
{ name: 'Documents', path: DOCS, hidden: false },
|
||||||
],
|
],
|
||||||
entries: [{ name: 'harness', path: `${HOME}/Documents/harness`, hidden: false }],
|
entries: [{ name: 'harness', path: HARNESS, hidden: false }],
|
||||||
|
},
|
||||||
|
[HARNESS]: {
|
||||||
|
path: HARNESS,
|
||||||
|
home: HOME,
|
||||||
|
crumbs: [
|
||||||
|
{ name: '/', path: '/', hidden: false },
|
||||||
|
{ name: 'home', path: '/home', hidden: false },
|
||||||
|
{ name: 'u', path: HOME, hidden: false },
|
||||||
|
{ name: 'Documents', path: DOCS, hidden: false },
|
||||||
|
{ name: 'harness', path: HARNESS, hidden: false },
|
||||||
|
],
|
||||||
|
entries: [],
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
const found = tree[target]
|
const found = tree[target]
|
||||||
@@ -57,49 +71,102 @@ function mount(overrides: Partial<Parameters<typeof DirectoryBrowser>[0]> = {})
|
|||||||
onOpen,
|
onOpen,
|
||||||
onClose,
|
onClose,
|
||||||
busy: false,
|
busy: false,
|
||||||
t: (key: string) => key,
|
t: (key: string, params?: Record<string, unknown>) => (params === undefined ? key : `${key}:${String(params.name)}`),
|
||||||
...overrides,
|
...overrides,
|
||||||
}
|
}
|
||||||
const view = render(<DirectoryBrowser {...props} />)
|
const view = render(<DirectoryBrowser {...props} />)
|
||||||
return { view, props, listDirectory, createDirectory, onOpen, onClose }
|
return { view, props, listDirectory, createDirectory, onOpen, onClose }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The rendered level columns, left-to-right. */
|
||||||
|
function columns(): HTMLElement[] {
|
||||||
|
return screen.getAllByRole('list')
|
||||||
|
}
|
||||||
|
|
||||||
describe('DirectoryBrowser', () => {
|
describe('DirectoryBrowser', () => {
|
||||||
it('opens at the Host home, hides hidden entries, and roots the crumbs at Home', async () => {
|
it('opens at the Host home as one wide column, hides hidden entries, and roots the crumbs at Home', async () => {
|
||||||
const b = mount()
|
const b = mount()
|
||||||
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
|
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
|
||||||
expect(b.listDirectory).toHaveBeenCalledWith(undefined)
|
expect(b.listDirectory).toHaveBeenCalledWith(undefined)
|
||||||
|
expect(columns()).toHaveLength(1)
|
||||||
expect(screen.getByRole('listitem').textContent).toBe('Documents')
|
expect(screen.getByRole('listitem').textContent).toBe('Documents')
|
||||||
expect(screen.queryByText('.config')).toBeNull()
|
expect(screen.queryByText('.config')).toBeNull()
|
||||||
// Inside the home subtree the chain collapses to a localized Home crumb.
|
|
||||||
expect(screen.getByRole('button', { name: 'browser.home' })).toBeTruthy()
|
expect(screen.getByRole('button', { name: 'browser.home' })).toBeTruthy()
|
||||||
expect(screen.queryByRole('button', { name: '/' })).toBeNull()
|
expect(screen.queryByRole('button', { name: '/' })).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('enters a row on click and jumps back through a crumb', async () => {
|
it('selects a row into the two-pane view: children preview right, crumbs follow the selection', async () => {
|
||||||
const b = mount()
|
const b = mount()
|
||||||
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
|
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
|
||||||
fireEvent.click(screen.getByRole('listitem'))
|
fireEvent.click(screen.getByRole('listitem'))
|
||||||
await waitFor(() => { expect(screen.getByRole('listitem').textContent).toBe('harness') })
|
await waitFor(() => { expect(columns()).toHaveLength(2) })
|
||||||
expect(b.listDirectory).toHaveBeenLastCalledWith(`${HOME}/Documents`)
|
const [level, preview] = columns()
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'browser.home' }))
|
const selectedRow = within(level!).getByRole('listitem')
|
||||||
await waitFor(() => { expect(screen.getByRole('listitem').textContent).toBe('Documents') })
|
expect(selectedRow.textContent).toBe('Documents')
|
||||||
|
expect(selectedRow.getAttribute('aria-current')).toBe('true')
|
||||||
|
expect(within(preview!).getByRole('listitem').textContent).toBe('harness')
|
||||||
|
expect(b.listDirectory).toHaveBeenLastCalledWith(DOCS)
|
||||||
|
expect(screen.getByRole('button', { name: 'Documents' })).toBeTruthy()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('edits the path from the crumb bar: Enter navigates, Escape restores', async () => {
|
it('advances one level when a right-column row is picked', async () => {
|
||||||
mount()
|
mount()
|
||||||
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
|
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
|
||||||
|
fireEvent.click(screen.getByRole('listitem'))
|
||||||
|
await waitFor(() => { expect(columns()).toHaveLength(2) })
|
||||||
|
fireEvent.click(within(columns()[1]!).getByRole('listitem'))
|
||||||
|
await waitFor(() => { expect(screen.getByRole('button', { name: 'harness' })).toBeTruthy() })
|
||||||
|
const [level] = columns()
|
||||||
|
const selectedRow = within(level!).getByRole('listitem')
|
||||||
|
expect(selectedRow.textContent).toBe('harness')
|
||||||
|
expect(selectedRow.getAttribute('aria-current')).toBe('true')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('jumps back through a crumb into a fresh single-column level', async () => {
|
||||||
|
mount()
|
||||||
|
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
|
||||||
|
fireEvent.click(screen.getByRole('listitem'))
|
||||||
|
await waitFor(() => { expect(columns()).toHaveLength(2) })
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'browser.home' }))
|
||||||
|
await waitFor(() => { expect(columns()).toHaveLength(1) })
|
||||||
|
expect(screen.getByRole('listitem').textContent).toBe('Documents')
|
||||||
|
expect(screen.getByRole('listitem').getAttribute('aria-current')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('opens the selection, else the listed level; Cancel closes; busy freezes Open', async () => {
|
||||||
|
const b = mount()
|
||||||
|
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'browser.open' }))
|
||||||
|
expect(b.onOpen).toHaveBeenCalledWith(HOME)
|
||||||
|
fireEvent.click(screen.getByRole('listitem'))
|
||||||
|
await waitFor(() => { expect(columns()).toHaveLength(2) })
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'browser.open' }))
|
||||||
|
expect(b.onOpen).toHaveBeenLastCalledWith(DOCS)
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'browser.cancel' }))
|
||||||
|
expect(b.onClose).toHaveBeenCalled()
|
||||||
|
|
||||||
|
const busy = mount({ busy: true })
|
||||||
|
await waitFor(() => { expect(busy.listDirectory).toHaveBeenCalled() })
|
||||||
|
expect(screen.getAllByRole<HTMLButtonElement>('button', { name: 'browser.open' }).at(-1)!.disabled).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('edits the path from the crumb bar: Enter navigates, Escape restores, blank is ignored', async () => {
|
||||||
|
const b = mount()
|
||||||
|
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
|
fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
|
||||||
const input = screen.getByLabelText<HTMLInputElement>('browser.editPath')
|
const input = screen.getByLabelText<HTMLInputElement>('browser.editPath')
|
||||||
expect(input.value).toBe(HOME)
|
expect(input.value).toBe(HOME)
|
||||||
fireEvent.change(input, { target: { value: `${HOME}/Documents` } })
|
fireEvent.change(input, { target: { value: DOCS } })
|
||||||
fireEvent.keyDown(input, { key: 'Enter' })
|
fireEvent.keyDown(input, { key: 'Enter' })
|
||||||
await waitFor(() => { expect(screen.getByRole('listitem').textContent).toBe('harness') })
|
await waitFor(() => { expect(screen.getByRole('listitem').textContent).toBe('harness') })
|
||||||
// Escape leaves an opened edit without navigating.
|
expect(columns()).toHaveLength(1)
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
|
fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
|
||||||
fireEvent.keyDown(screen.getByLabelText('browser.editPath'), { key: 'Escape' })
|
const again = screen.getByLabelText<HTMLInputElement>('browser.editPath')
|
||||||
|
fireEvent.change(again, { target: { value: ' ' } })
|
||||||
|
fireEvent.keyDown(again, { key: 'Enter' })
|
||||||
|
expect(b.listDirectory).toHaveBeenCalledTimes(2)
|
||||||
|
fireEvent.keyDown(again, { key: 'Escape' })
|
||||||
expect(screen.queryByLabelText('browser.editPath', { selector: 'input' })).toBeNull()
|
expect(screen.queryByLabelText('browser.editPath', { selector: 'input' })).toBeNull()
|
||||||
expect(screen.getByRole('listitem').textContent).toBe('harness')
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('surfaces an unreadable target as an alert and keeps the edit open for correction', async () => {
|
it('surfaces an unreadable target as an alert and keeps the edit open for correction', async () => {
|
||||||
@@ -114,40 +181,16 @@ describe('DirectoryBrowser', () => {
|
|||||||
expect(screen.getByRole('listitem').textContent).toBe('Documents')
|
expect(screen.getByRole('listitem').textContent).toBe('Documents')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('creates a folder inline and refreshes the level; failures land as alerts', async () => {
|
it('folds non-typed failures into readable text (Error message, String otherwise)', async () => {
|
||||||
const b = mount()
|
const b = mount({ listDirectory: vi.fn(async () => { throw new Error('socket down') }) })
|
||||||
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
|
await waitFor(() => { expect(screen.getByRole('alert').textContent).toBe('socket down') })
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'browser.newFolder' }))
|
b.view.rerender(<DirectoryBrowser {...b.props} open={false} />)
|
||||||
const input = screen.getByLabelText('browser.newFolder')
|
const raw = mount({ listDirectory: vi.fn(async () => { throw 'raw failure' }) })
|
||||||
fireEvent.change(input, { target: { value: 'fresh' } })
|
await waitFor(() => { expect(screen.getAllByRole('alert').at(-1)!.textContent).toBe('raw failure') })
|
||||||
fireEvent.keyDown(input, { key: 'Enter' })
|
expect(raw.onOpen).not.toHaveBeenCalled()
|
||||||
await waitFor(() => { expect(b.createDirectory).toHaveBeenCalledWith(HOME, 'fresh') })
|
|
||||||
// The level reloads after creation (initial + post-create).
|
|
||||||
await waitFor(() => { expect(b.listDirectory).toHaveBeenLastCalledWith(HOME) })
|
|
||||||
|
|
||||||
b.createDirectory.mockRejectedValueOnce(
|
|
||||||
new DirectoryBrowseError({ code: 'directory-exists', message: 'taken already', details: { path: `${HOME}/x` } }))
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'browser.newFolder' }))
|
|
||||||
const retry = screen.getByLabelText('browser.newFolder')
|
|
||||||
fireEvent.change(retry, { target: { value: 'x' } })
|
|
||||||
fireEvent.keyDown(retry, { key: 'Enter' })
|
|
||||||
await waitFor(() => { expect(screen.getByRole('alert').textContent).toBe('taken already') })
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('confirms the listed directory through Open, closes through Cancel, and freezes while busy', async () => {
|
it('renders the full ancestry when the level sits outside the home subtree', async () => {
|
||||||
const b = mount()
|
|
||||||
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'browser.open' }))
|
|
||||||
expect(b.onOpen).toHaveBeenCalledWith(HOME)
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'browser.cancel' }))
|
|
||||||
expect(b.onClose).toHaveBeenCalled()
|
|
||||||
|
|
||||||
const busy = mount({ busy: true })
|
|
||||||
await waitFor(() => { expect(busy.listDirectory).toHaveBeenCalled() })
|
|
||||||
expect(screen.getAllByRole<HTMLButtonElement>('button', { name: 'browser.open' }).at(-1)!.disabled).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('renders the full ancestry when the listing sits outside the home subtree', async () => {
|
|
||||||
const outside: DirectoryListing = {
|
const outside: DirectoryListing = {
|
||||||
path: '/srv/data',
|
path: '/srv/data',
|
||||||
home: HOME,
|
home: HOME,
|
||||||
@@ -164,53 +207,110 @@ describe('DirectoryBrowser', () => {
|
|||||||
expect(screen.queryByRole('button', { name: 'browser.home' })).toBeNull()
|
expect(screen.queryByRole('button', { name: 'browser.home' })).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('folds non-typed failures into readable text (Error message, String otherwise)', async () => {
|
it('creates a folder through the nested dialog and lands with it selected', async () => {
|
||||||
const b = mount({ listDirectory: vi.fn(async () => { throw new Error('socket down') }) })
|
|
||||||
await waitFor(() => { expect(screen.getByRole('alert').textContent).toBe('socket down') })
|
|
||||||
b.view.rerender(<DirectoryBrowser {...b.props} open={false} />)
|
|
||||||
const raw = mount({ listDirectory: vi.fn(async () => { throw 'raw failure' }) })
|
|
||||||
await waitFor(() => { expect(screen.getAllByRole('alert').at(-1)!.textContent).toBe('raw failure') })
|
|
||||||
expect(raw.onOpen).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('cancels the inline folder row with Escape and ignores a blank name', async () => {
|
|
||||||
const b = mount()
|
const b = mount()
|
||||||
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
|
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
|
||||||
|
fireEvent.click(screen.getByRole('listitem'))
|
||||||
|
await waitFor(() => { expect(columns()).toHaveLength(2) })
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'browser.newFolder' }))
|
fireEvent.click(screen.getByRole('button', { name: 'browser.newFolder' }))
|
||||||
const input = screen.getByLabelText('browser.newFolder')
|
// The nested dialog names the create target (the selected folder).
|
||||||
|
expect(screen.getByText('browser.createIn:Documents')).toBeTruthy()
|
||||||
|
// The created folder becomes listable (like the real backend after mkdir).
|
||||||
|
b.listDirectory.mockImplementation(async (path?: string) => {
|
||||||
|
if (path === `${DOCS}/fresh`) {
|
||||||
|
return {
|
||||||
|
path: `${DOCS}/fresh`, home: HOME,
|
||||||
|
crumbs: [...listingFor(DOCS).crumbs, { name: 'fresh', path: `${DOCS}/fresh`, hidden: false }],
|
||||||
|
entries: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (path === DOCS) {
|
||||||
|
const docs = listingFor(DOCS)
|
||||||
|
return { ...docs, entries: [...docs.entries, { name: 'fresh', path: `${DOCS}/fresh`, hidden: false }] }
|
||||||
|
}
|
||||||
|
return listingFor(path)
|
||||||
|
})
|
||||||
|
const input = screen.getByLabelText('browser.folderName')
|
||||||
|
fireEvent.change(input, { target: { value: 'fresh' } })
|
||||||
|
fireEvent.keyDown(input, { key: 'Enter' })
|
||||||
|
await waitFor(() => { expect(b.createDirectory).toHaveBeenCalledWith(DOCS, 'fresh') })
|
||||||
|
// The create target became the level and the new folder its selection.
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole('button', { name: 'Documents' })).toBeTruthy()
|
||||||
|
const level = columns()[0]!
|
||||||
|
const rows = within(level).getAllByRole('listitem')
|
||||||
|
expect(rows.some(row => row.textContent === 'fresh' && row.getAttribute('aria-current') === 'true')).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps the nested dialog open on a creation failure and cancels cleanly', async () => {
|
||||||
|
const b = mount()
|
||||||
|
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
|
||||||
|
b.createDirectory.mockRejectedValueOnce(
|
||||||
|
new DirectoryBrowseError({ code: 'directory-exists', message: 'taken already', details: { path: `${HOME}/x` } }))
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'browser.newFolder' }))
|
||||||
|
expect(screen.getByText('browser.createIn:browser.home')).toBeTruthy()
|
||||||
|
const input = screen.getByLabelText('browser.folderName')
|
||||||
|
// A blank name never submits.
|
||||||
fireEvent.change(input, { target: { value: ' ' } })
|
fireEvent.change(input, { target: { value: ' ' } })
|
||||||
fireEvent.keyDown(input, { key: 'Enter' })
|
fireEvent.keyDown(input, { key: 'Enter' })
|
||||||
expect(b.createDirectory).not.toHaveBeenCalled()
|
expect(b.createDirectory).not.toHaveBeenCalled()
|
||||||
fireEvent.keyDown(input, { key: 'Escape' })
|
fireEvent.change(input, { target: { value: 'x' } })
|
||||||
expect(screen.queryByLabelText('browser.newFolder')).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('ignores a blank path draft on Enter', async () => {
|
|
||||||
const b = mount()
|
|
||||||
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
|
|
||||||
const input = screen.getByLabelText('browser.editPath')
|
|
||||||
fireEvent.change(input, { target: { value: ' ' } })
|
|
||||||
fireEvent.keyDown(input, { key: 'Enter' })
|
fireEvent.keyDown(input, { key: 'Enter' })
|
||||||
// Only the initial home listing ran; the blank draft navigated nowhere.
|
await waitFor(() => { expect(screen.getByRole('alert').textContent).toBe('taken already') })
|
||||||
expect(b.listDirectory).toHaveBeenCalledTimes(1)
|
fireEvent.keyDown(screen.getByLabelText('browser.folderName'), { key: 'Escape' })
|
||||||
|
await waitFor(() => { expect(screen.queryByLabelText('browser.folderName')).toBeNull() })
|
||||||
|
|
||||||
|
// The nested Cancel button and the nested mask both close only the child dialog.
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'browser.newFolder' }))
|
||||||
|
const nested = screen.getByRole('dialog', { name: 'browser.newFolder' })
|
||||||
|
fireEvent.click(within(nested).getByRole('button', { name: 'browser.cancel' }))
|
||||||
|
await waitFor(() => { expect(screen.queryByLabelText('browser.folderName')).toBeNull() })
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'browser.newFolder' }))
|
||||||
|
const masks = document.querySelectorAll('[aria-hidden="true"]')
|
||||||
|
fireEvent.click(masks[masks.length - 1]!)
|
||||||
|
await waitFor(() => { expect(screen.queryByLabelText('browser.folderName')).toBeNull() })
|
||||||
|
expect(screen.getByRole('dialog', { name: 'browser.title' })).toBeTruthy()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('drops a stale listing that resolves after a newer navigation', async () => {
|
it('surfaces a selection-preview failure while keeping the selection marked', async () => {
|
||||||
|
const b = mount()
|
||||||
|
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
|
||||||
|
b.listDirectory.mockRejectedValueOnce(
|
||||||
|
new DirectoryBrowseError({ code: 'directory-unreadable', message: 'denied', details: { path: DOCS } }))
|
||||||
|
fireEvent.click(screen.getByRole('listitem'))
|
||||||
|
await waitFor(() => { expect(screen.getByRole('alert').textContent).toBe('denied') })
|
||||||
|
expect(screen.getByRole('listitem').getAttribute('aria-current')).toBe('true')
|
||||||
|
// No preview column arrived for the failed selection.
|
||||||
|
expect(columns()).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('surfaces a post-create relist failure on the browser surface', async () => {
|
||||||
|
const b = mount()
|
||||||
|
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'browser.newFolder' }))
|
||||||
|
// Creation succeeds, but relisting the target fails afterwards.
|
||||||
|
b.listDirectory.mockRejectedValueOnce(new Error('level vanished'))
|
||||||
|
const input = screen.getByLabelText('browser.folderName')
|
||||||
|
fireEvent.change(input, { target: { value: 'fresh' } })
|
||||||
|
fireEvent.keyDown(input, { key: 'Enter' })
|
||||||
|
await waitFor(() => { expect(screen.getByRole('alert').textContent).toBe('level vanished') })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('drops a stale child listing that resolves after a crumb jump', async () => {
|
||||||
const b = mount()
|
const b = mount()
|
||||||
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
|
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
|
||||||
// The next navigation (into Documents) hangs; a Home-crumb jump supersedes it.
|
|
||||||
let resolveSlow!: (value: DirectoryListing) => void
|
let resolveSlow!: (value: DirectoryListing) => void
|
||||||
const slow = new Promise<DirectoryListing>((settle) => { resolveSlow = settle })
|
const slow = new Promise<DirectoryListing>((settle) => { resolveSlow = settle })
|
||||||
b.listDirectory.mockReturnValueOnce(slow)
|
b.listDirectory.mockReturnValueOnce(slow)
|
||||||
fireEvent.click(screen.getByRole('listitem'))
|
fireEvent.click(screen.getByRole('listitem'))
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'browser.home' }))
|
fireEvent.click(screen.getByRole('button', { name: 'browser.home' }))
|
||||||
await waitFor(() => { expect(b.listDirectory).toHaveBeenCalledTimes(3) })
|
await waitFor(() => { expect(b.listDirectory).toHaveBeenCalledTimes(3) })
|
||||||
await waitFor(() => { expect(screen.getByRole('listitem').textContent).toBe('Documents') })
|
await waitFor(() => { expect(columns()).toHaveLength(1) })
|
||||||
resolveSlow(listingFor(`${HOME}/Documents`))
|
resolveSlow(listingFor(DOCS))
|
||||||
await new Promise(settle => setTimeout(settle, 0))
|
await new Promise(settle => setTimeout(settle, 0))
|
||||||
// The stale Documents listing did not clobber the newer Home level.
|
// The superseded selection preview did not reopen the second pane.
|
||||||
expect(screen.getByRole('listitem').textContent).toBe('Documents')
|
expect(columns()).toHaveLength(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('drops a stale failure that rejects after a newer navigation', async () => {
|
it('drops a stale failure that rejects after a newer navigation', async () => {
|
||||||
@@ -224,19 +324,82 @@ describe('DirectoryBrowser', () => {
|
|||||||
await waitFor(() => { expect(b.listDirectory).toHaveBeenCalledTimes(3) })
|
await waitFor(() => { expect(b.listDirectory).toHaveBeenCalledTimes(3) })
|
||||||
rejectSlow(new Error('too late to matter'))
|
rejectSlow(new Error('too late to matter'))
|
||||||
await new Promise(settle => setTimeout(settle, 0))
|
await new Promise(settle => setTimeout(settle, 0))
|
||||||
// The superseded failure surfaces no alert over the newer level.
|
|
||||||
expect(screen.queryByRole('alert')).toBeNull()
|
expect(screen.queryByRole('alert')).toBeNull()
|
||||||
expect(screen.getByRole('listitem').textContent).toBe('Documents')
|
expect(screen.getByRole('listitem').textContent).toBe('Documents')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('drops a stale navigation failure that rejects after a newer jump', async () => {
|
||||||
|
const b = mount()
|
||||||
|
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
|
||||||
|
fireEvent.click(screen.getByRole('listitem'))
|
||||||
|
await waitFor(() => { expect(columns()).toHaveLength(2) })
|
||||||
|
let rejectSlow!: (reason: unknown) => void
|
||||||
|
const slow = new Promise<DirectoryListing>((_settle, fail) => { rejectSlow = fail })
|
||||||
|
b.listDirectory.mockReturnValueOnce(slow)
|
||||||
|
// A slow crumb jump superseded by a second jump.
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'browser.home' }))
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Documents' }))
|
||||||
|
await waitFor(() => { expect(b.listDirectory).toHaveBeenCalledTimes(4) })
|
||||||
|
rejectSlow(new Error('late nav failure'))
|
||||||
|
await new Promise(settle => setTimeout(settle, 0))
|
||||||
|
expect(screen.queryByRole('alert')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('drops a stale navigation listing that resolves after a newer jump', async () => {
|
||||||
|
const b = mount()
|
||||||
|
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
|
||||||
|
fireEvent.click(screen.getByRole('listitem'))
|
||||||
|
await waitFor(() => { expect(columns()).toHaveLength(2) })
|
||||||
|
let resolveSlow!: (value: DirectoryListing) => void
|
||||||
|
const slow = new Promise<DirectoryListing>((settle) => { resolveSlow = settle })
|
||||||
|
b.listDirectory.mockReturnValueOnce(slow)
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'browser.home' }))
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Documents' }))
|
||||||
|
await waitFor(() => { expect(screen.getByRole('listitem').textContent).toBe('harness') })
|
||||||
|
resolveSlow(listingFor(undefined))
|
||||||
|
await new Promise(settle => setTimeout(settle, 0))
|
||||||
|
// The stale home listing did not replace the newer Documents level.
|
||||||
|
expect(screen.getByRole('listitem').textContent).toBe('harness')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('names the create target by its path when the level reports no crumbs', async () => {
|
||||||
|
const bare: DirectoryListing = { path: '/srv/data', home: HOME, crumbs: [], entries: [] }
|
||||||
|
mount({ listDirectory: vi.fn(async () => bare) })
|
||||||
|
await waitFor(() => { expect(screen.getByRole('button', { name: 'browser.newFolder' })).toBeTruthy() })
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole<HTMLButtonElement>('button', { name: 'browser.newFolder' }).disabled).toBe(false)
|
||||||
|
})
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'browser.newFolder' }))
|
||||||
|
expect(screen.getByText('browser.createIn:/srv/data')).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('refuses to close the nested dialog while the creation is in flight', async () => {
|
||||||
|
const b = mount()
|
||||||
|
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
|
||||||
|
let settleCreate!: (path: string) => void
|
||||||
|
b.createDirectory.mockReturnValueOnce(new Promise<string>((settle) => { settleCreate = settle }))
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'browser.newFolder' }))
|
||||||
|
const input = screen.getByLabelText('browser.folderName')
|
||||||
|
fireEvent.change(input, { target: { value: 'slow' } })
|
||||||
|
fireEvent.keyDown(input, { key: 'Enter' })
|
||||||
|
// Escape and the mask are both inert while creating.
|
||||||
|
fireEvent.keyDown(screen.getByLabelText('browser.folderName'), { key: 'Escape' })
|
||||||
|
const masks = document.querySelectorAll('[aria-hidden="true"]')
|
||||||
|
fireEvent.click(masks[masks.length - 1]!)
|
||||||
|
expect(screen.getByLabelText('browser.folderName')).toBeTruthy()
|
||||||
|
settleCreate(`${HOME}/slow`)
|
||||||
|
await waitFor(() => { expect(screen.queryByLabelText('browser.folderName')).toBeNull() })
|
||||||
|
})
|
||||||
|
|
||||||
it('starts back at home on reopen', async () => {
|
it('starts back at home on reopen', async () => {
|
||||||
const b = mount()
|
const b = mount()
|
||||||
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
|
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
|
||||||
fireEvent.click(screen.getByRole('listitem'))
|
fireEvent.click(screen.getByRole('listitem'))
|
||||||
await waitFor(() => { expect(screen.getByRole('listitem').textContent).toBe('harness') })
|
await waitFor(() => { expect(columns()).toHaveLength(2) })
|
||||||
b.view.rerender(<DirectoryBrowser {...b.props} open={false} />)
|
b.view.rerender(<DirectoryBrowser {...b.props} open={false} />)
|
||||||
b.view.rerender(<DirectoryBrowser {...b.props} open />)
|
b.view.rerender(<DirectoryBrowser {...b.props} open />)
|
||||||
await waitFor(() => { expect(screen.getByRole('listitem').textContent).toBe('Documents') })
|
await waitFor(() => { expect(screen.getByRole('listitem').textContent).toBe('Documents') })
|
||||||
|
expect(columns()).toHaveLength(1)
|
||||||
expect(b.listDirectory).toHaveBeenLastCalledWith(undefined)
|
expect(b.listDirectory).toHaveBeenLastCalledWith(undefined)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ function pickingShare(): DirectoryPickingInjected {
|
|||||||
return {
|
return {
|
||||||
directoryPickerKind: vi.fn(async () => 'dialog' as const),
|
directoryPickerKind: vi.fn(async () => 'dialog' as const),
|
||||||
pickDirectory: vi.fn(async () => null),
|
pickDirectory: vi.fn(async () => null),
|
||||||
listDirectory: vi.fn(async () => ({ path: '/home/u', home: '/home/u', crumbs: [], entries: [] })),
|
listDirectory: vi.fn(async () => ({ path: '/home/u', home: '/home/u', crumbs: [{ name: 'u', path: '/home/u', hidden: false }], entries: [] })),
|
||||||
createDirectory: vi.fn(async () => '/home/u/new'),
|
createDirectory: vi.fn(async () => '/home/u/new'),
|
||||||
t: (key: string) => key,
|
t: (key: string) => key,
|
||||||
}
|
}
|
||||||
@@ -59,7 +59,7 @@ function mount(
|
|||||||
const share: DirectoryPickingInjected = {
|
const share: DirectoryPickingInjected = {
|
||||||
directoryPickerKind: vi.fn(async () => 'dialog' as const),
|
directoryPickerKind: vi.fn(async () => 'dialog' as const),
|
||||||
pickDirectory,
|
pickDirectory,
|
||||||
listDirectory: vi.fn(async () => ({ path: '/home/u', home: '/home/u', crumbs: [], entries: [] })),
|
listDirectory: vi.fn(async () => ({ path: '/home/u', home: '/home/u', crumbs: [{ name: 'u', path: '/home/u', hidden: false }], entries: [] })),
|
||||||
createDirectory: vi.fn(async () => '/home/u/new'),
|
createDirectory: vi.fn(async () => '/home/u/new'),
|
||||||
t: key => key,
|
t: key => key,
|
||||||
...picking,
|
...picking,
|
||||||
|
|||||||
Reference in New Issue
Block a user