diff --git a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css index 6cce6fabd0..8c8033d825 100644 --- a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css +++ b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css @@ -151,6 +151,13 @@ scrollbar-width: none; } +/* Engines that predate scrollbar-width take the pseudo-element path (the + * two are mutually exclusive by construction — see ui-theme's scrollbar + * contract); hide the row's horizontal bar there too. */ +.millerRow::-webkit-scrollbar { + display: none; +} + /* Columns split the row evenly around the divider (a solo column takes the * whole row); 256px is the floor below which the row scrolls (scrollbar * hidden, the effect pins the child pane into view) instead of squeezing @@ -292,6 +299,13 @@ 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. */ +.toggleCheck { + flex: none; +} + .footerGap { flex: 1 1 0; } diff --git a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx index 4c67d74722..8507681150 100644 --- a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx +++ b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx @@ -73,30 +73,46 @@ function foldPathFor(sep: '\\' | '/'): (value: string) => string { } /** - * Drops every trailing separator (`HOME=/home/u//` ships verbatim while - * resolve() strips them) down to, but never past, one leading character — - * which keeps the POSIX root `/` intact. A backslash drive root (`C:\`) - * does lose its separator; that stays safe only because every comparison - * trims both sides symmetrically. + * 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). */ -function trimTrailingSeparator(path: string, sep: '\\' | '/'): string { - let end = path.length - while (end > sep.length && path.endsWith(sep, end)) end -= sep.length - return path.slice(0, end) +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) { + if (segment === '' || segment === '.') continue + if (segment === '..') { + out.pop() + continue + } + out.push(segment) + } + return `${head}${sep}${out.join(sep)}` + } } /** * Breadcrumb rows for display: inside the home subtree the chain starts at a * localized Home crumb; outside it the full ancestry shows, the root labeled - * by its own path. The home comparison folds per platform and normalizes a - * trailing separator, so a typed-case Windows path or a `HOME=/home/u/` + * by its own path. The home comparison folds per platform and lexically + * normalizes both sides, so a typed-case Windows path or a `HOME=/home/u/.` * shape still collapses to the Home crumb. */ function displayCrumbs(listing: DirectoryListing, homeLabel: string): DirectoryEntry[] { const sep = separatorOf(listing) const fold = foldPathFor(sep) - const home = fold(trimTrailingSeparator(listing.home, sep)) - const homeIndex = listing.crumbs.findIndex(crumb => fold(trimTrailingSeparator(crumb.path, sep)) === home) + const normalize = normalizePathFor(sep) + const home = fold(normalize(listing.home)) + const homeIndex = listing.crumbs.findIndex(crumb => fold(normalize(crumb.path)) === home) if (homeIndex === -1) return listing.crumbs const tail = listing.crumbs.slice(homeIndex + 1) return [{ name: homeLabel, path: listing.home, hidden: false }, ...tail] @@ -800,7 +816,7 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, onClick={() => { setShowHidden(prev => !prev) }} > {t('browser.showHidden')} - {showHidden && } + {showHidden && } diff --git a/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx b/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx index c27522a6cf..e2adc75881 100644 --- a/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx +++ b/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx @@ -349,6 +349,16 @@ 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() }) @@ -1176,6 +1186,7 @@ describe('DirectoryBrowser', () => { expect(cancels.map(button => button.disabled).sort()).toEqual([false, true]) expect(screen.getByRole('button', { name: 'browser.open' }).disabled).toBe(true) expect(screen.getByRole('button', { name: 'browser.editPath' }).disabled).toBe(true) + expect(screen.getByRole('button', { name: 'browser.showHidden' }).disabled).toBe(true) for (const row of screen.getAllByRole('listitem')) { expect(rowButton(row).disabled).toBe(true) }