fix(host): review round 16 — UNC/forward-slash home forms; root-crumb separator; scrollbar symmetry; test dedup
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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('\\') ? '\\' : '/'
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user