fix(host): review round 17 — separator-fold drafts; UNC noise scrub; honest empty-chain fallback; toggle focus keep

This commit is contained in:
creatixchu
2026-07-30 01:10:37 +08:00
parent 4921b63fe3
commit 12d302ea59
5 changed files with 102 additions and 19 deletions

View File

@@ -82,13 +82,26 @@ function foldPathFor(sep: '\\' | '/'): (value: string) => string {
* backend's own paths arrive already resolved. A lexical mirror only:
* symlinks are the backend's business.
*/
/**
* Folds separators to the platform's canonical one: win32 treats a forward
* slash as a separator too (resolve() folds them the same way), while
* POSIX must not — a backslash there is a name character.
*/
function foldSeparatorsFor(sep: '\\' | '/'): (value: string) => string {
return value => (sep === '\\' ? value.replaceAll('/', sep) : value)
}
function normalizePathFor(sep: '\\' | '/'): (value: string) => string {
const foldSeparators = foldSeparatorsFor(sep)
return (raw) => {
// win32 treats a forward slash as a separator too (resolve() folds
// them); POSIX must not — a backslash there is a name character.
const value = sep === '\\' ? raw.replaceAll('/', sep) : raw
const value = foldSeparators(raw)
const unc = sep === '\\' && value.startsWith(`${sep}${sep}`)
const segments = (unc ? value.slice(2) : value).split(sep)
const rawSegments = (unc ? value.slice(2) : value).split(sep)
// Empty segments are separator noise everywhere except POSIX's leading
// root marker, which must survive as the first segment; scrubbing them
// up front keeps a doubled separator from being locked into the UNC
// server + share root below.
const segments = unc ? rawSegments.filter(segment => segment !== '') : rawSegments
// The unpoppable root: POSIX's leading empty segment / the drive
// segment, or UNC's server + share pair.
const rootLength = unc ? 2 : 1
@@ -136,8 +149,10 @@ function displayCrumbs(listing: DirectoryListing, homeLabel: string): DirectoryE
*/
function separatorOf(listing: DirectoryListing): '\\' | '/' {
const rootCrumb = listing.crumbs.at(0)
// Home is the honest fallback for an impossible empty chain.
/* v8 ignore next -- narrowing guard: the wire chain is root-to-target inclusive. */
// The seam type allows an empty chain (this backend never emits one, but
// create-target naming supports it, see targetName): degrade to a
// best-effort read of the home text — the pre-root-crumb heuristic, with
// its backslash-in-a-POSIX-name blind spot.
if (rootCrumb === undefined) return listing.home.includes('\\') ? '\\' : '/'
return rootCrumb.path.includes('\\') ? '\\' : '/'
}
@@ -149,17 +164,19 @@ function separatorOf(listing: DirectoryListing): '\\' | '/' {
* leaves the level unfiltered. The directory part compares under the
* platform fold (exact on slash platforms; Windows folds case, since an
* upgraded selection may carry the actual entry's case while the level
* below still carries the typed one); the name filter downstream is
* case-insensitive everywhere.
* below still carries the typed one — and folds forward slashes, which
* win32 and the backend both accept in typed paths); the name filter
* downstream is case-insensitive everywhere.
*/
function draftPrefixFor(listing: DirectoryListing, draft: string | null): string | null {
if (draft === null) return null
const sep = separatorOf(listing)
const cut = draft.lastIndexOf(sep)
const folded = foldSeparatorsFor(sep)(draft)
const cut = folded.lastIndexOf(sep)
if (cut === -1) return null
const fold = foldPathFor(sep)
const level = listing.path.endsWith(sep) ? listing.path : `${listing.path}${sep}`
return fold(draft.slice(0, cut + 1)) === fold(level) ? draft.slice(cut + 1) : null
return fold(folded.slice(0, cut + 1)) === fold(level) ? folded.slice(cut + 1) : null
}
/** One column of folder rows (the Miller view renders one or two of these). */
@@ -824,7 +841,15 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
// toggling never blur-cancels a draft mid-thought. Outside editing
// it keeps native focus behavior.
onMouseDown={draftPending ? (event) => { event.preventDefault() } : undefined}
onClick={() => { setShowHidden(prev => !prev) }}
onClick={(event) => {
// The suppression above exists to protect the INPUT's focus;
// when focus is instead on a row that this very toggle may
// re-hide, restore the native outcome — the clicked toggle
// keeps focus in the card (the editing-time refocus effect
// deliberately stays out of the way).
if (focusInMillerRows()) event.currentTarget.focus()
setShowHidden(prev => !prev)
}}
>
{t('browser.showHidden')}
{showHidden && <IconCheckOutline16 size={14} className={css.toggleCheck} />}

View File

@@ -412,8 +412,9 @@ describe('DirectoryBrowser', () => {
const listing: DirectoryListing = {
path: `${SHARE}\\x`,
// USERPROFILE ships verbatim; win32.resolve keeps \\server\share as
// the unpoppable root, so this normalizes to \\server\share\x.
home: `${SHARE}\\..\\x`,
// the unpoppable root and folds the doubled separator, so this
// normalizes to \\server\share\x.
home: '\\\\server\\\\share\\..\\x',
crumbs: [
{ name: `${SHARE}\\`, path: `${SHARE}\\`, hidden: false },
{ name: 'x', path: `${SHARE}\\x`, hidden: false },
@@ -536,6 +537,10 @@ describe('DirectoryBrowser', () => {
expect(within(columns()[1]!).getByText('Alpha')).toBeTruthy()
fireEvent.change(input, { target: { value: 'C:\\Users\\z' } })
expect(within(columns()[1]!).queryAllByRole('listitem')).toHaveLength(0)
// Forward-slash drafts are equally legal on win32 (Enter navigates
// them); the filter folds them instead of going silent.
fireEvent.change(input, { target: { value: 'C:/Users/a' } })
expect(within(columns()[1]!).getByText('Alpha')).toBeTruthy()
})
it('re-parks focus on the edit zone when a failed pick unmounts a dot-revealed row', async () => {
@@ -1423,6 +1428,59 @@ describe('DirectoryBrowser', () => {
expect(screen.getByText('browser.createIn:/srv/data')).toBeTruthy()
})
it('a crumb-less Windows level still seeds the editor with a backslash', async () => {
// The empty chain degrades separatorOf to the home-text read; the
// backslash side of that fallback is the Windows shape.
const bare: DirectoryListing = { path: 'C:\\srv', home: 'C:\\Users\\u', crumbs: [], entries: [], truncated: false }
mount({ listDirectory: vi.fn(async () => bare) })
await waitFor(() => {
expect(screen.getByRole<HTMLButtonElement>('button', { name: 'browser.editPath' }).disabled).toBe(false)
})
fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
expect(screen.getByLabelText<HTMLInputElement>('browser.editPath').value).toBe('C:\\srv\\')
})
it('a POSIX home whose name contains a backslash still reads as the display root', async () => {
const WEIRD = '/home/we\\ird'
const listing: DirectoryListing = {
path: WEIRD,
home: WEIRD,
crumbs: [
{ name: '/', path: '/', hidden: false },
{ name: 'home', path: '/home', hidden: false },
{ name: 'we\\ird', path: WEIRD, hidden: false },
],
entries: [{ name: 'notes', path: `${WEIRD}/notes`, hidden: false }],
truncated: false,
}
const listDirectory = vi.fn(async () => listing)
mount({ listDirectory })
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
// The root crumb ('/') decides the platform: the backslash in the name
// neither flips the fold nor breaks the Home collapse.
expect(columns()).toHaveLength(1)
expect(screen.getByRole('button', { name: 'browser.home' })).toBeTruthy()
expect(listDirectory).toHaveBeenCalledTimes(1)
})
it('pointer-toggling hidden off keeps focus on the toggle as the focused row re-hides', async () => {
mount()
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
const toggle = screen.getByRole('button', { name: 'browser.showHidden' })
fireEvent.click(toggle)
fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
// Tab parked focus on the revealed hidden row; the pointer click below
// would unmount it (toggle off + empty seeded prefix hides it again).
const hiddenRow = within(columns()[0]!).getByText('.config').closest('button')!
hiddenRow.focus()
fireEvent.mouseDown(toggle)
fireEvent.click(toggle)
expect(screen.queryByText('.config')).toBeNull()
expect(document.activeElement).toBe(toggle)
// The editor survives the whole exchange.
expect(screen.getByLabelText('browser.editPath', { selector: 'input' })).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() })