feat(host): seed path editor with a trailing separator; prefix-filter levels from the draft tail

This commit is contained in:
creatixchu
2026-07-29 13:47:56 +08:00
parent 60383efef9
commit e561c28232
2 changed files with 86 additions and 5 deletions

View File

@@ -12,7 +12,10 @@
* owning flow decides what "Open" means and owns the workspace-creation
* error surface. Hidden entries are host-flagged and hidden by default; the
* footer's fixed-label "Show hidden files" toggle (aria-pressed, check when
* on) reveals them (client-side only).
* on) reveals them (client-side only). The path editor opens seeded with a
* trailing separator, and while the draft's directory part names a listed
* level, its final segment prefix-filters that level's rows (a dot-led
* prefix also reveals the hidden entries it names).
*/
import { useCallback, useEffect, useRef, useState } from 'react'
import clsx from 'clsx'
@@ -60,18 +63,45 @@ function displayCrumbs(listing: DirectoryListing, homeLabel: string): DirectoryE
return [{ name: homeLabel, path: listing.home, hidden: false }, ...tail]
}
/** The separator a Host path's own platform uses (Windows listings carry backslashes). */
function separatorOf(path: string): string {
return path.includes('\\') ? '\\' : '/'
}
/**
* The path draft's final segment, when its directory part is exactly the
* level `listing` lists — the segment the level prefix-filters on while the
* user types. Any other draft (no separator yet, or naming some other
* directory) leaves the level unfiltered.
*/
function draftPrefixFor(listing: DirectoryListing, draft: string | null): string | null {
if (draft === null) return null
const sep = separatorOf(draft)
const cut = draft.lastIndexOf(sep)
if (cut === -1) return null
const level = listing.path.endsWith(sep) ? listing.path : `${listing.path}${sep}`
return draft.slice(0, cut + 1) === level ? draft.slice(cut + 1) : null
}
/** One column of folder rows (the Miller view renders one or two of these). */
function LevelColumn({ entries, selectedPath, busy, onPick, wide, showHidden }: {
function LevelColumn({ entries, selectedPath, busy, onPick, wide, showHidden, filterPrefix }: {
entries: readonly DirectoryEntry[]
selectedPath: string | null
busy: boolean
onPick: (entry: DirectoryEntry) => void
wide: boolean
showHidden: boolean
filterPrefix: string | null
}) {
const visible = entries.filter((entry) => {
if (filterPrefix !== null && !entry.name.toLowerCase().startsWith(filterPrefix.toLowerCase())) return false
// A dot-led prefix names hidden entries explicitly, so matching ones
// surface even while the toggle keeps the rest hidden.
return showHidden || !entry.hidden || filterPrefix?.startsWith('.') === true
})
return (
<div className={clsx(css.column, wide && css.columnWide)} role="list">
{entries.filter(entry => showHidden || !entry.hidden).map((entry) => {
{visible.map((entry) => {
const selected = entry.path === selectedPath
return (
// The wrapper carries the list semantics; the row keeps its NATIVE
@@ -370,7 +400,11 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
// otherwise close the editor via navigate's draft reset.
supersede()
setLoading(false)
setPathDraft(selected?.path ?? parent?.path ?? '')
// Seed with a trailing separator so typing immediately
// continues into child names (and prefix-filters below).
const base = selected?.path ?? parent?.path ?? ''
const sep = separatorOf(base)
setPathDraft(base === '' || base.endsWith(sep) ? base : `${base}${sep}`)
}}
/>
</>
@@ -423,6 +457,7 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
onPick={select}
wide={!twoPane}
showHidden={showHidden}
filterPrefix={draftPrefixFor(parent, pathDraft)}
/>
)}
{twoPane && <span className={css.divider} />}
@@ -434,6 +469,7 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
onPick={advance}
wide={false}
showHidden={showHidden}
filterPrefix={draftPrefixFor(child, pathDraft)}
/>
)}
</div>

View File

@@ -206,7 +206,9 @@ describe('DirectoryBrowser', () => {
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
const input = screen.getByLabelText<HTMLInputElement>('browser.editPath')
expect(input.value).toBe(HOME)
// The editor seeds with a trailing separator so typing continues into
// child names.
expect(input.value).toBe(`${HOME}/`)
fireEvent.change(input, { target: { value: DOCS } })
fireEvent.keyDown(input, { key: 'Enter' })
await waitFor(() => { expect(screen.getByRole('listitem').textContent).toBe('harness') })
@@ -220,6 +222,49 @@ describe('DirectoryBrowser', () => {
expect(screen.queryByLabelText('browser.editPath', { selector: 'input' })).toBeNull()
})
it('prefix-filters the listed level from the draft tail, dot revealing hidden matches', async () => {
mount()
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
const input = screen.getByLabelText<HTMLInputElement>('browser.editPath')
// The seeded empty segment leaves the level as-is: hidden stays hidden.
expect(screen.getByRole('listitem').textContent).toBe('Documents')
// Case-insensitive prefix narrows the rows.
fireEvent.change(input, { target: { value: `${HOME}/do` } })
expect(screen.getByRole('listitem').textContent).toBe('Documents')
// A dot-led prefix names hidden entries, so it reveals the match.
fireEvent.change(input, { target: { value: `${HOME}/.co` } })
expect(screen.getByRole('listitem').textContent).toBe('.config')
// A prefix matching nothing empties the level (no stale rows linger).
fireEvent.change(input, { target: { value: `${HOME}/zzz` } })
expect(screen.queryByRole('listitem')).toBeNull()
// A draft naming some other directory (or none) leaves the level whole.
fireEvent.change(input, { target: { value: 'no-separator' } })
expect(screen.getByRole('listitem').textContent).toBe('Documents')
})
it('seeds and filters with backslashes on a Windows-rooted listing', async () => {
const ROOT = 'C:\\'
const windowsListing: DirectoryListing = {
path: ROOT,
home: ROOT,
crumbs: [{ name: 'C:\\', path: ROOT, hidden: false }],
entries: [
{ name: 'Program Files', path: `${ROOT}Program Files`, hidden: false },
{ name: 'Users', path: `${ROOT}Users`, hidden: false },
],
truncated: false,
}
mount({ listDirectory: vi.fn(async () => windowsListing) })
await waitFor(() => { expect(screen.getAllByRole('listitem')).toHaveLength(2) })
fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
const input = screen.getByLabelText<HTMLInputElement>('browser.editPath')
// The root already ends in its separator: no doubled backslash.
expect(input.value).toBe(ROOT)
fireEvent.change(input, { target: { value: `${ROOT}u` } })
expect(screen.getByRole('listitem').textContent).toBe('Users')
})
it('clicking away from the path editor cancels it back to the crumb view', async () => {
mount()
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })