fix(host): review round 15 — lexical home normalization; webkit scrollbar hide; flex-none check; inert enumeration

This commit is contained in:
creatixchu
2026-07-30 00:31:11 +08:00
parent 9a96aa2755
commit abb3b62564
3 changed files with 55 additions and 14 deletions

View File

@@ -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;
}

View File

@@ -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 && <IconCheckOutline16 size={14} />}
{showHidden && <IconCheckOutline16 size={14} className={css.toggleCheck} />}
</button>
<span className={css.footerGap} />
<Button variant="outline" className={clsx(css.footerAction)} disabled={parentInert} onClick={onClose}>{t('browser.cancel')}</Button>

View File

@@ -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<HTMLButtonElement>('button', { name: 'browser.open' }).disabled).toBe(true)
expect(screen.getByRole<HTMLButtonElement>('button', { name: 'browser.editPath' }).disabled).toBe(true)
expect(screen.getByRole<HTMLButtonElement>('button', { name: 'browser.showHidden' }).disabled).toBe(true)
for (const row of screen.getAllByRole('listitem')) {
expect(rowButton(row).disabled).toBe(true)
}