From 99643e59a180619fde26f2da90725514cc0f8aca Mon Sep 17 00:00:00 2001 From: creatixchu Date: Tue, 28 Jul 2026 18:04:21 +0800 Subject: [PATCH] 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. --- .../src/client/DirectoryBrowser.module.css | 136 ++++++-- .../src/client/DirectoryBrowser.tsx | 267 ++++++++++---- .../client/ui-workspace/src/client/index.ts | 6 + .../tests/directory-browser.spec.tsx | 327 +++++++++++++----- .../tests/workspace-picker.spec.tsx | 4 +- 5 files changed, 553 insertions(+), 187 deletions(-) diff --git a/packages/client/ui-workspace/src/client/DirectoryBrowser.module.css b/packages/client/ui-workspace/src/client/DirectoryBrowser.module.css index 14f3e4f2ca..f59a74aa7e 100644 --- a/packages/client/ui-workspace/src/client/DirectoryBrowser.module.css +++ b/packages/client/ui-workspace/src/client/DirectoryBrowser.module.css @@ -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 - * frame exactly: header (title + crumbs, l3 separator), one directory level, - * and the bordered footer. Card: w600 r24, bottom pad 12, no close chrome. */ + * frame exactly: fixed 600×420 card, header (title + crumbs, l3 separator), + * the one-or-two-column Miller content, and the bordered footer. */ /* Doubled class beats Modal's own .dialog regardless of stylesheet order. */ .dialog.dialog { width: min(600px, 100%); - padding: 0 0 12px; - gap: 16px; + height: 420px; + padding: 0; + gap: 0; } /* Header block: pl24 pr14 pt22 pb12, 8px between title row and crumb row. */ @@ -15,6 +16,7 @@ display: flex; flex-direction: column; gap: 8px; + flex: none; padding: 22px 14px 12px 24px; border-bottom: 1px solid var(--dsw-alias-border-l3); } @@ -95,16 +97,37 @@ color: var(--dsw-alias-label-primary); } -/* One directory level: content column pt16 px24, 28px rows with 2px gaps. */ -.level { +/* Miller content: pt16 px24; columns are 256 wide (or full width solo) with + * 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; flex-direction: column; gap: 2px; - padding: 16px 24px 0; - max-height: 320px; + width: 256px; + flex: none; overflow-y: auto; } +.columnWide { + width: 100%; + flex: 1 1 0; +} + +.divider { + flex: none; + width: 1px; + background: var(--dsw-alias-border-l3); +} + .row { display: flex; align-items: center; @@ -123,11 +146,22 @@ 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 { flex: none; color: var(--dsw-alias-label-secondary); } +.rowIconSelected { + flex: none; + color: var(--dsw-alias-button-info-fill); +} + .rowName { flex: 1 1 0; min-width: 0; @@ -145,29 +179,6 @@ 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, .error { padding: 4px; @@ -183,12 +194,14 @@ 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 { display: flex; align-items: center; gap: 8px; - padding: 12px 24px 0; + flex: none; + padding: 12px 24px 28px; border-top: 1px solid var(--dsw-alias-border-l3); } @@ -199,3 +212,58 @@ .footerAction { 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; +} diff --git a/packages/client/ui-workspace/src/client/DirectoryBrowser.tsx b/packages/client/ui-workspace/src/client/DirectoryBrowser.tsx index 9454693969..28c0281c56 100644 --- a/packages/client/ui-workspace/src/client/DirectoryBrowser.tsx +++ b/packages/client/ui-workspace/src/client/DirectoryBrowser.tsx @@ -1,15 +1,21 @@ /** - * The in-app workspace-directory browser (figma Harness 802-56979): breadcrumb - * header with a click-to-edit path zone, one navigable directory level, an - * inline New-folder row, and the Cancel/Open footer. 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). + * The in-app workspace-directory browser (figma Harness 813-23126 family): a + * fixed 600×420 dialog 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 256px columns (level | + * selected folder's children) around a hairline divider. 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 filtered here (a + * show-hidden toggle is deferred work, client-side only). */ import { useCallback, useEffect, useRef, useState } from 'react' import clsx from 'clsx' import { - Button, IconChevronRightOutline14, IconFolderClose16, IconPlusOutline16, Modal, + Button, 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' @@ -22,13 +28,13 @@ export interface DirectoryBrowserProps { open: boolean /** List one directory level (absent path = the Host home directory). */ listDirectory: (path?: string) => Promise - /** Create one child directory under the listed level. */ + /** Create one child directory under an existing parent. */ createDirectory: (path: string, name: string) => Promise - /** The operator confirmed the currently listed directory. */ + /** 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 level freezes. */ + /** The owner's confirm is in flight: Open disables, the view freezes. */ busy: boolean /** Localized copy. */ t: Translate @@ -52,32 +58,73 @@ function displayCrumbs(listing: DirectoryListing, homeLabel: string): DirectoryE 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 ( +
+ {entries.filter(entry => !entry.hidden).map((entry) => { + const selected = entry.path === selectedPath + return ( + + ) + })} +
+ ) +} + /** * 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) { - const [listing, setListing] = useState(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(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) - // 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(null) const [creatingFolder, setCreatingFolder] = useState(false) + const [createError, setCreateError] = useState(null) const requestSeq = useRef(0) + /** Replace the whole view with one freshly listed level (no selection). */ const navigate = useCallback((path?: string) => { const seq = ++requestSeq.current setLoading(true) setError(null) listDirectory(path).then((next) => { if (seq !== requestSeq.current) return - setListing(next) + setParent(next) + setSelected(null) + setChild(null) setLoading(false) setPathDraft(null) - setFolderDraft(null) }, (reason: unknown) => { if (seq !== requestSeq.current) return setLoading(false) @@ -85,11 +132,39 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, }) }, [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 // any in-flight response so a late arrival cannot repopulate a closed dialog. useEffect(() => { if (open) { - setListing(null) + setParent(null) + setSelected(null) + setChild(null) navigate() return } @@ -97,29 +172,56 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, setError(null) setPathDraft(null) setFolderDraft(null) + setCreateError(null) }, [open, navigate]) - const confirmFolder = (): void => { - /* v8 ignore next -- reentry fence: the inline row only renders with a listing and a draft, and the input disables while creating. */ - if (listing === null || folderDraft === null || creatingFolder) return + /** 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 const name = folderDraft.trim() if (name === '') return setCreatingFolder(true) - setError(null) - createDirectory(listing.path, name).then(() => { + setCreateError(null) + createDirectory(targetPath, name).then((createdPath) => { setCreatingFolder(false) 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) => { setCreatingFolder(false) - setError(failureText(reason)) + setCreateError(failureText(reason)) }) } // After the hooks: a closed dialog renders nothing and evaluates no copy. 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 ( { if (listing !== null) setPathDraft(listing.path) }} + disabled={parent === null || busy} + /* v8 ignore next -- narrowing guard: the zone disables while the level is null. */ + onClick={() => { if (parent !== null) setPathDraft(selected?.path ?? parent.path) }} /> ) @@ -183,45 +285,26 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, )} -
- {folderDraft !== null && listing !== null && ( -
- - { setFolderDraft(event.target.value) }} - onKeyDown={(event) => { - if (event.key === 'Enter') { - event.preventDefault() - confirmFolder() - } - if (event.key === 'Escape') { - event.stopPropagation() - setFolderDraft(null) - } - }} - /> -
+
+ {parent !== null && ( + + )} + {twoPane && } + {twoPane && child !== null && ( + )} - {listing?.entries.filter(entry => !entry.hidden).map(entry => ( - - ))} {loading &&
{t('browser.loading')}
} {error !== null &&
{error}
}
@@ -229,8 +312,11 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, @@ -239,13 +325,56 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
+ {/* 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) }} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault() + confirmCreate() + } + if (event.key === 'Escape') { + event.stopPropagation() + if (!creatingFolder) setFolderDraft(null) + } + }} + /> + {createError !== null &&
{createError}
} +
+ + +
+
+
) } diff --git a/packages/client/ui-workspace/src/client/index.ts b/packages/client/ui-workspace/src/client/index.ts index 4c400109f5..91c62b8fa8 100644 --- a/packages/client/ui-workspace/src/client/index.ts +++ b/packages/client/ui-workspace/src/client/index.ts @@ -44,6 +44,9 @@ export function apply(ctx: ClientContext): void { 'browser.home': '主目录', 'browser.newFolder': '新建文件夹', 'browser.folderName': '文件夹名称', + 'browser.createIn': '在"{name}"中新建文件夹', + 'browser.untitledFolder': '未命名文件夹', + 'browser.create': '创建', 'browser.cancel': '取消', 'browser.open': '打开', 'browser.editPath': '编辑路径', @@ -54,6 +57,9 @@ export function apply(ctx: ClientContext): void { 'browser.home': 'Home', 'browser.newFolder': 'New folder', 'browser.folderName': 'Folder name', + 'browser.createIn': 'New folder in "{name}"', + 'browser.untitledFolder': 'Untitled folder', + 'browser.create': 'Create', 'browser.cancel': 'Cancel', 'browser.open': 'Open', 'browser.editPath': 'Edit path', diff --git a/packages/client/ui-workspace/tests/directory-browser.spec.tsx b/packages/client/ui-workspace/tests/directory-browser.spec.tsx index a78527d5f1..28c25b367f 100644 --- a/packages/client/ui-workspace/tests/directory-browser.spec.tsx +++ b/packages/client/ui-workspace/tests/directory-browser.spec.tsx @@ -1,6 +1,6 @@ // @vitest-environment jsdom 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 { DirectoryBrowseError } from '@deepseek-ai/dsh-client-runtime/client' import { DirectoryBrowser } from '../src/client/DirectoryBrowser.tsx' @@ -8,6 +8,8 @@ import { DirectoryBrowser } from '../src/client/DirectoryBrowser.tsx' afterEach(cleanup) 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. */ function listingFor(path?: string): DirectoryListing { @@ -23,19 +25,31 @@ function listingFor(path?: string): DirectoryListing { ], entries: [ { name: '.config', path: `${HOME}/.config`, hidden: true }, - { name: 'Documents', path: `${HOME}/Documents`, hidden: false }, + { name: 'Documents', path: DOCS, hidden: false }, ], }, - [`${HOME}/Documents`]: { - path: `${HOME}/Documents`, + [DOCS]: { + path: DOCS, home: HOME, crumbs: [ { name: '/', path: '/', hidden: false }, { name: 'home', 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] @@ -57,49 +71,102 @@ function mount(overrides: Partial[0]> = {}) onOpen, onClose, busy: false, - t: (key: string) => key, + t: (key: string, params?: Record) => (params === undefined ? key : `${key}:${String(params.name)}`), ...overrides, } const view = render() return { view, props, listDirectory, createDirectory, onOpen, onClose } } +/** The rendered level columns, left-to-right. */ +function columns(): HTMLElement[] { + return screen.getAllByRole('list') +} + 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() await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) expect(b.listDirectory).toHaveBeenCalledWith(undefined) + expect(columns()).toHaveLength(1) expect(screen.getByRole('listitem').textContent).toBe('Documents') 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.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() await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) fireEvent.click(screen.getByRole('listitem')) - await waitFor(() => { expect(screen.getByRole('listitem').textContent).toBe('harness') }) - expect(b.listDirectory).toHaveBeenLastCalledWith(`${HOME}/Documents`) - fireEvent.click(screen.getByRole('button', { name: 'browser.home' })) - await waitFor(() => { expect(screen.getByRole('listitem').textContent).toBe('Documents') }) + await waitFor(() => { expect(columns()).toHaveLength(2) }) + const [level, preview] = columns() + const selectedRow = within(level!).getByRole('listitem') + 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() 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('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' })) const input = screen.getByLabelText('browser.editPath') expect(input.value).toBe(HOME) - fireEvent.change(input, { target: { value: `${HOME}/Documents` } }) + fireEvent.change(input, { target: { value: DOCS } }) fireEvent.keyDown(input, { key: 'Enter' }) 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.keyDown(screen.getByLabelText('browser.editPath'), { key: 'Escape' }) + const again = screen.getByLabelText('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.getByRole('listitem').textContent).toBe('harness') }) 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') }) - it('creates a folder inline and refreshes the level; failures land as alerts', async () => { - const b = mount() - await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) - fireEvent.click(screen.getByRole('button', { name: 'browser.newFolder' })) - const input = screen.getByLabelText('browser.newFolder') - fireEvent.change(input, { target: { value: 'fresh' } }) - fireEvent.keyDown(input, { key: 'Enter' }) - 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('folds non-typed failures into readable text (Error message, String otherwise)', 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() + 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('confirms the listed directory through Open, closes through Cancel, and freezes while busy', 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('button', { name: 'browser.open' }).at(-1)!.disabled).toBe(true) - }) - - it('renders the full ancestry when the listing sits outside the home subtree', async () => { + it('renders the full ancestry when the level sits outside the home subtree', async () => { const outside: DirectoryListing = { path: '/srv/data', home: HOME, @@ -164,53 +207,110 @@ describe('DirectoryBrowser', () => { expect(screen.queryByRole('button', { name: 'browser.home' })).toBeNull() }) - it('folds non-typed failures into readable text (Error message, String otherwise)', 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() - 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 () => { + it('creates a folder through the nested dialog and lands with it selected', async () => { const b = 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.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.keyDown(input, { key: 'Enter' }) expect(b.createDirectory).not.toHaveBeenCalled() - fireEvent.keyDown(input, { key: 'Escape' }) - 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.change(input, { target: { value: 'x' } }) fireEvent.keyDown(input, { key: 'Enter' }) - // Only the initial home listing ran; the blank draft navigated nowhere. - expect(b.listDirectory).toHaveBeenCalledTimes(1) + await waitFor(() => { expect(screen.getByRole('alert').textContent).toBe('taken already') }) + 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() await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) - // The next navigation (into Documents) hangs; a Home-crumb jump supersedes it. let resolveSlow!: (value: DirectoryListing) => void const slow = new Promise((settle) => { resolveSlow = settle }) b.listDirectory.mockReturnValueOnce(slow) fireEvent.click(screen.getByRole('listitem')) fireEvent.click(screen.getByRole('button', { name: 'browser.home' })) await waitFor(() => { expect(b.listDirectory).toHaveBeenCalledTimes(3) }) - await waitFor(() => { expect(screen.getByRole('listitem').textContent).toBe('Documents') }) - resolveSlow(listingFor(`${HOME}/Documents`)) + await waitFor(() => { expect(columns()).toHaveLength(1) }) + resolveSlow(listingFor(DOCS)) await new Promise(settle => setTimeout(settle, 0)) - // The stale Documents listing did not clobber the newer Home level. - expect(screen.getByRole('listitem').textContent).toBe('Documents') + // The superseded selection preview did not reopen the second pane. + expect(columns()).toHaveLength(1) }) 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) }) rejectSlow(new Error('too late to matter')) await new Promise(settle => setTimeout(settle, 0)) - // The superseded failure surfaces no alert over the newer level. expect(screen.queryByRole('alert')).toBeNull() 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((_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((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('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((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 () => { const b = mount() await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) fireEvent.click(screen.getByRole('listitem')) - await waitFor(() => { expect(screen.getByRole('listitem').textContent).toBe('harness') }) + await waitFor(() => { expect(columns()).toHaveLength(2) }) b.view.rerender() b.view.rerender() await waitFor(() => { expect(screen.getByRole('listitem').textContent).toBe('Documents') }) + expect(columns()).toHaveLength(1) expect(b.listDirectory).toHaveBeenLastCalledWith(undefined) }) }) diff --git a/packages/client/ui-workspace/tests/workspace-picker.spec.tsx b/packages/client/ui-workspace/tests/workspace-picker.spec.tsx index 0c634fbd60..2d7531a710 100644 --- a/packages/client/ui-workspace/tests/workspace-picker.spec.tsx +++ b/packages/client/ui-workspace/tests/workspace-picker.spec.tsx @@ -41,7 +41,7 @@ function pickingShare(): DirectoryPickingInjected { return { directoryPickerKind: vi.fn(async () => 'dialog' as const), 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'), t: (key: string) => key, } @@ -59,7 +59,7 @@ function mount( const share: DirectoryPickingInjected = { directoryPickerKind: vi.fn(async () => 'dialog' as const), 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'), t: key => key, ...picking,