fix(host): review round 16 — UNC/forward-slash home forms; root-crumb separator; scrollbar symmetry; test dedup

This commit is contained in:
creatixchu
2026-07-30 00:49:18 +08:00
parent abb3b62564
commit 4921b63fe3
3 changed files with 91 additions and 48 deletions

View File

@@ -62,6 +62,12 @@
scrollbar-width: none;
}
/* Pseudo-element-path engines (see .millerRow's twin rule): the 20px crumb
* bar has no room for a bar at all. */
.crumbTrail::-webkit-scrollbar {
display: none;
}
.crumbSeat {
display: inline-flex;
align-items: center;
@@ -299,9 +305,8 @@
color: var(--dsw-alias-label-primary);
}
/* Trailing pressed check (Menu's .check parallel): flex-none so wrap or
* narrow-viewport clamp pressure never squashes the glyph — the nowrap
* label refuses to shrink, leaving the icon as the only compressible item. */
/* Flex-none: the nowrap label refuses to shrink, which would leave the
* glyph as the only compressible item under wrap or narrow-viewport clamp. */
.toggleCheck {
flex: none;
}

View File

@@ -73,30 +73,37 @@ function foldPathFor(sep: '\\' | '/'): (value: string) => string {
}
/**
* Lexically normalizes an absolute host path for comparisons: collapses
* repeated and trailing separators, drops `.` segments, and applies `..` —
* mirroring the backend's resolve() for the shapes an environment-supplied
* HOME legally carries verbatim (`/home/u/`, `/home//u`, `/home/u/.`)
* while the backend's paths arrive already resolved. A lexical mirror
* only: symlinks are the backend's business, and the input always
* contains the separator (it is an absolute path).
* Lexically normalizes an absolute host path for comparisons: folds
* forward slashes on Windows (win32 accepts either), collapses repeated
* and trailing separators, drops `.` segments, and applies `..` without
* ever crossing the root — POSIX's `/`, a drive's `C:`, or UNC's
* `\\server\share` pair — mirroring the backend's resolve() for the
* shapes an environment-supplied HOME legally carries verbatim while the
* backend's own paths arrive already resolved. A lexical mirror only:
* symlinks are the backend's business.
*/
function normalizePathFor(sep: '\\' | '/'): (value: string) => string {
return (value) => {
const segments = value.split(sep)
const head = segments.shift()
/* v8 ignore next -- narrowing guard: split always yields at least one segment. */
if (head === undefined) return value
const out: string[] = []
for (const segment of segments) {
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 unc = sep === '\\' && value.startsWith(`${sep}${sep}`)
const segments = (unc ? value.slice(2) : value).split(sep)
// The unpoppable root: POSIX's leading empty segment / the drive
// segment, or UNC's server + share pair.
const rootLength = unc ? 2 : 1
const out = segments.slice(0, rootLength)
for (const segment of segments.slice(rootLength)) {
if (segment === '' || segment === '.') continue
if (segment === '..') {
out.pop()
if (out.length > rootLength) out.pop()
continue
}
out.push(segment)
}
return `${head}${sep}${out.join(sep)}`
// A bare root keeps (or regains) the trailing separator resolve()
// emits for `/`, `C:\`, and `\\server\share\`.
return `${unc ? sep + sep : ''}${out.join(sep)}${out.length === rootLength ? sep : ''}`
}
}
@@ -119,16 +126,20 @@ function displayCrumbs(listing: DirectoryListing, homeLabel: string): DirectoryE
}
/**
* The listing's platform separator, inferred from the home path the host
* stamped — never from typed text or entry paths, where a backslash is a
* legal POSIX name character. Still a heuristic at the last step: a POSIX
* home directory whose own name contains a backslash would misread.
* The listing's platform separator, read from the host-resolved root crumb
* (`/`, `C:\`, `\\server\share\`) — exact for every root form the backend
* emits, immune both to a home delivered in the other slash flavor
* (`USERPROFILE=C:/Users/Alice`) and to backslashes inside POSIX names.
* TODO: replace with a host-stamped `separator` field on the wire
* DirectoryListing so the platform fact travels verbatim (the trade-off is
* recorded in the directory-picker capability seam Agent Note).
*/
function separatorOf(listing: DirectoryListing): '\\' | '/' {
return listing.home.includes('\\') ? '\\' : '/'
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. */
if (rootCrumb === undefined) return listing.home.includes('\\') ? '\\' : '/'
return rootCrumb.path.includes('\\') ? '\\' : '/'
}
/**

View File

@@ -240,20 +240,12 @@ describe('DirectoryBrowser', () => {
expect(within(columns()[1]!).getByText('harness')).toBeTruthy()
})
it('a trailing-separator home is still the display root (single pane on open)', async () => {
const listDirectory = vi.fn(async (path?: string) => ({ ...listingFor(path), home: `${HOME}/` }))
mount({ listDirectory })
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
// `HOME=/home/u/` ships verbatim while the listing path resolves without
// the trailing separator; the normalized comparison still collapses to
// the Home crumb and no parent leg launches.
expect(columns()).toHaveLength(1)
expect(screen.getByRole('button', { name: 'browser.home' })).toBeTruthy()
expect(listDirectory).toHaveBeenCalledTimes(1)
})
it('a home with several trailing separators is still the display root', async () => {
const listDirectory = vi.fn(async (path?: string) => ({ ...listingFor(path), home: `${HOME}//` }))
// HOME ships verbatim from the environment while listing paths arrive
// resolved; each decoration (trailing, repeated, dot, dot-dot segments)
// must still normalize to the display root — single pane, Home crumb,
// and no parent leg launched.
it.each(['/', '//', '/foo/../.'])('a home decorated with "%s" is still the display root', async (decoration) => {
const listDirectory = vi.fn(async (path?: string) => ({ ...listingFor(path), home: `${HOME}${decoration}` }))
mount({ listDirectory })
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
expect(columns()).toHaveLength(1)
@@ -349,16 +341,6 @@ describe('DirectoryBrowser', () => {
expect(document.activeElement).toBe(screen.getByRole('button', { name: 'browser.editPath' }))
})
it('a home carrying dot segments is still the display root', async () => {
// os.homedir() ships HOME verbatim; the backend resolves listing paths.
const listDirectory = vi.fn(async (path?: string) => ({ ...listingFor(path), home: `${HOME}/foo/../.` }))
mount({ listDirectory })
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
expect(columns()).toHaveLength(1)
expect(screen.getByRole('button', { name: 'browser.home' })).toBeTruthy()
expect(listDirectory).toHaveBeenCalledTimes(1)
})
it('a navigation to the filesystem root keeps the single wide level', async () => {
mount()
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
@@ -425,6 +407,51 @@ describe('DirectoryBrowser', () => {
expect(document.activeElement?.getAttribute('aria-current')).toBe('true')
})
it('a UNC home with dot-dot never pops the share root and still collapses to Home', async () => {
const SHARE = '\\\\server\\share'
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`,
crumbs: [
{ name: `${SHARE}\\`, path: `${SHARE}\\`, hidden: false },
{ name: 'x', path: `${SHARE}\\x`, hidden: false },
],
entries: [],
truncated: false,
}
const listDirectory = vi.fn(async () => listing)
mount({ listDirectory })
await waitFor(() => { expect(listDirectory).toHaveBeenCalled() })
await waitFor(() => { expect(screen.getByRole('button', { name: 'browser.home' })).toBeTruthy() })
expect(columns()).toHaveLength(1)
expect(listDirectory).toHaveBeenCalledTimes(1)
})
it('a forward-slash Windows home still reads as the display root', async () => {
const listing: DirectoryListing = {
path: 'C:\\Users\\Alice',
// USERPROFILE may legally use forward slashes; the root crumb (not
// the home text) carries the platform, and normalization folds the
// slashes before comparing.
home: 'C:/Users/Alice',
crumbs: [
{ name: 'C:\\', path: 'C:\\', hidden: false },
{ name: 'Users', path: 'C:\\Users', hidden: false },
{ name: 'Alice', path: 'C:\\Users\\Alice', hidden: false },
],
entries: [{ name: 'Desktop', path: 'C:\\Users\\Alice\\Desktop', hidden: false }],
truncated: false,
}
const listDirectory = vi.fn(async () => listing)
mount({ listDirectory })
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
expect(columns()).toHaveLength(1)
expect(screen.getByRole('button', { name: 'browser.home' })).toBeTruthy()
expect(listDirectory).toHaveBeenCalledTimes(1)
})
it('collapses a typed-case Windows home to the display root (single pane, Home crumb)', async () => {
const CANON = 'C:\\Users\\Alice'
const TYPED = 'c:\\users\\alice'