feat(host): seed path editor with a trailing separator; prefix-filter levels from the draft tail
This commit is contained in:
@@ -12,7 +12,10 @@
|
|||||||
* owning flow decides what "Open" means and owns the workspace-creation
|
* owning flow decides what "Open" means and owns the workspace-creation
|
||||||
* error surface. Hidden entries are host-flagged and hidden by default; the
|
* error surface. Hidden entries are host-flagged and hidden by default; the
|
||||||
* footer's fixed-label "Show hidden files" toggle (aria-pressed, check when
|
* 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 { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
@@ -60,18 +63,45 @@ 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]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 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). */
|
/** 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[]
|
entries: readonly DirectoryEntry[]
|
||||||
selectedPath: string | null
|
selectedPath: string | null
|
||||||
busy: boolean
|
busy: boolean
|
||||||
onPick: (entry: DirectoryEntry) => void
|
onPick: (entry: DirectoryEntry) => void
|
||||||
wide: boolean
|
wide: boolean
|
||||||
showHidden: 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 (
|
return (
|
||||||
<div className={clsx(css.column, wide && css.columnWide)} role="list">
|
<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
|
const selected = entry.path === selectedPath
|
||||||
return (
|
return (
|
||||||
// The wrapper carries the list semantics; the row keeps its NATIVE
|
// 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.
|
// otherwise close the editor via navigate's draft reset.
|
||||||
supersede()
|
supersede()
|
||||||
setLoading(false)
|
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}
|
onPick={select}
|
||||||
wide={!twoPane}
|
wide={!twoPane}
|
||||||
showHidden={showHidden}
|
showHidden={showHidden}
|
||||||
|
filterPrefix={draftPrefixFor(parent, pathDraft)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{twoPane && <span className={css.divider} />}
|
{twoPane && <span className={css.divider} />}
|
||||||
@@ -434,6 +469,7 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
|
|||||||
onPick={advance}
|
onPick={advance}
|
||||||
wide={false}
|
wide={false}
|
||||||
showHidden={showHidden}
|
showHidden={showHidden}
|
||||||
|
filterPrefix={draftPrefixFor(child, pathDraft)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -206,7 +206,9 @@ describe('DirectoryBrowser', () => {
|
|||||||
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
|
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)
|
// 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.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') })
|
||||||
@@ -220,6 +222,49 @@ describe('DirectoryBrowser', () => {
|
|||||||
expect(screen.queryByLabelText('browser.editPath', { selector: 'input' })).toBeNull()
|
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 () => {
|
it('clicking away from the path editor cancels it back to the crumb view', async () => {
|
||||||
mount()
|
mount()
|
||||||
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
|
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
|
||||||
|
|||||||
Reference in New Issue
Block a user