fix(host): review round — host-derived separator, click-safe blur cancel, dead wide prop

This commit is contained in:
creatixchu
2026-07-29 14:27:01 +08:00
parent 7714c9fa8b
commit 998a5f3381
6 changed files with 112 additions and 33 deletions

View File

@@ -131,8 +131,8 @@
}
/* Miller content: symmetric 16px vertical padding so the divider clears the
* header and footer rules evenly; columns are 256 wide (or full width solo)
* with the hairline divider centered between them; each column scrolls alone. */
* header and footer rules evenly; each column scrolls alone (column widths
* live at .column). */
.content {
display: flex;
flex-direction: column;
@@ -143,9 +143,10 @@
padding: 16px 16px 16px 24px;
}
/* Two-pane columns split the row evenly around the divider; 256px is the
* floor below which the row scrolls (scrollbar hidden, the effect pins the
* child pane into view) instead of squeezing the panes. */
/* 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
* the panes. */
.column {
display: flex;
flex-direction: column;
@@ -158,10 +159,6 @@
padding-right: 8px;
}
.columnWide {
width: 100%;
}
.divider {
flex: none;
width: 1px;

View File

@@ -1,10 +1,11 @@
/**
* The in-app workspace-directory browser (figma Harness 813-23126 family): a
* 600×420 dialog (clamped to short/narrow viewports — the Miller row scrolls
* 680×500 dialog (clamped to short/narrow viewports — the Miller row scrolls
* sideways, the columns scroll down) whose header carries the title, the selection-path
* breadcrumb, and a click-to-edit path zone; below it a Miller view — one
* full-width level until a row is selected, then two 256px columns (level |
* selected folder's children) around a hairline divider. Selecting in the
* full-width level until a row is selected, then two columns splitting the
* row evenly (256px floor; level | selected folder's children) around a
* hairline divider. Selecting in the
* right column shifts the view one level deeper. "New folder" opens a nested
* create dialog targeting the selected folder (or the level itself) and
* selects the created folder. Open adopts the selected folder, falling back
@@ -63,20 +64,26 @@ function displayCrumbs(listing: DirectoryListing, homeLabel: string): DirectoryE
return [{ name: homeLabel, path: listing.home, hidden: false }, ...tail]
}
/** The separator a Host path's own platform uses (Windows listings carry backslashes). */
function separatorOf(path: string): string {
return path.includes('\\') ? '\\' : '/'
/**
* The listing's platform separator, read from the host-stamped home path —
* never sniffed from typed text or entry paths, where a backslash is a legal
* POSIX name character rather than a platform fact.
*/
function separatorOf(listing: DirectoryListing): '\\' | '/' {
return listing.home.includes('\\') ? '\\' : '/'
}
/**
* The path draft's final segment, when its directory part is exactly the
* level `listing` lists — the segment the level prefix-filters on while the
* user types. Any other draft (no separator yet, or naming some other
* directory) leaves the level unfiltered.
* directory) leaves the level unfiltered. The directory part compares
* exactly (it is the host's own path text, reached by seeding or erasing);
* only the name filter downstream is case-insensitive.
*/
function draftPrefixFor(listing: DirectoryListing, draft: string | null): string | null {
if (draft === null) return null
const sep = separatorOf(draft)
const sep = separatorOf(listing)
const cut = draft.lastIndexOf(sep)
if (cut === -1) return null
const level = listing.path.endsWith(sep) ? listing.path : `${listing.path}${sep}`
@@ -84,12 +91,11 @@ function draftPrefixFor(listing: DirectoryListing, draft: string | null): string
}
/** One column of folder rows (the Miller view renders one or two of these). */
function LevelColumn({ entries, selectedPath, busy, onPick, wide, showHidden, filterPrefix }: {
function LevelColumn({ entries, selectedPath, busy, onPick, showHidden, filterPrefix }: {
entries: readonly DirectoryEntry[]
selectedPath: string | null
busy: boolean
onPick: (entry: DirectoryEntry) => void
wide: boolean
showHidden: boolean
filterPrefix: string | null
}) {
@@ -100,7 +106,7 @@ function LevelColumn({ entries, selectedPath, busy, onPick, wide, showHidden, fi
return showHidden || !entry.hidden || filterPrefix?.startsWith('.') === true
})
return (
<div className={clsx(css.column, wide && css.columnWide)} role="list">
<div className={css.column} role="list">
{visible.map((entry) => {
const selected = entry.path === selectedPath
return (
@@ -112,6 +118,10 @@ function LevelColumn({ entries, selectedPath, busy, onPick, wide, showHidden, fi
aria-current={selected || undefined}
className={clsx(css.row, selected && css.rowSelected)}
disabled={busy}
// Keep focus where it is (the path editor, notably): a focus
// steal on mousedown would blur-cancel the editor, unmount the
// filtered rows mid-gesture, and drop this very click.
onMouseDown={(event) => { event.preventDefault() }}
onClick={() => { onPick(entry) }}
>
{selected
@@ -142,7 +152,7 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
const [error, setError] = useState<string | null>(null)
// Path-edit state: null = breadcrumb mode; a string = the draft being typed.
const [pathDraft, setPathDraft] = useState<string | null>(null)
// Show-hidden toggle state (pure client-side filter, reset on close).
// Show-hidden toggle state (pure client-side filter, reset on each open).
const [showHidden, setShowHidden] = useState(false)
// Create-folder state: null = closed; a string = the nested dialog's draft.
const [folderDraft, setFolderDraft] = useState<string | null>(null)
@@ -212,6 +222,9 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
/** Select a row of the listed level and preview its children on the right. */
const select = useCallback((entry: DirectoryEntry) => {
const { seq, scan } = launchListing(entry.path)
// A pick while the path editor is open adopts the (filtered) row and
// closes the editor — the draft served its purpose.
setPathDraft(null)
setSelected(entry)
setChild(null)
setLoading(true)
@@ -402,9 +415,15 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
setLoading(false)
// Seed with a trailing separator so typing immediately
// continues into child names (and prefix-filters below).
const base = selected?.path ?? parent?.path ?? ''
const sep = separatorOf(base)
setPathDraft(base === '' || base.endsWith(sep) ? base : `${base}${sep}`)
// No listed level means nothing to seed from (the editor
// is the recovery path for a failed home listing).
if (parent === null) {
setPathDraft('')
return
}
const base = selected?.path ?? parent.path
const sep = separatorOf(parent)
setPathDraft(base.endsWith(sep) ? base : `${base}${sep}`)
}}
/>
</>
@@ -441,8 +460,15 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
// Clicking anywhere outside the editor reads as leaving it:
// focus loss cancels the edit like Escape. Enter keeps focus
// in the input while its navigation is in flight, so a
// submitted path is never withdrawn by this handler.
onBlur={cancelPathEdit}
// submitted path is never withdrawn by this handler; rows and
// the show-hidden toggle suppress focus steal on mousedown so
// a click on them lands before any cancel. Window/tab focus
// loss also fires blur in some engines — only a focus move
// within a focused document reads as leaving the editor.
onBlur={() => {
if (!document.hasFocus()) return
cancelPathEdit()
}}
/>
)}
</div>
@@ -455,7 +481,6 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
selectedPath={selected?.path ?? null}
busy={parentInert}
onPick={select}
wide={!twoPane}
showHidden={showHidden}
filterPrefix={draftPrefixFor(parent, pathDraft)}
/>
@@ -467,7 +492,6 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
selectedPath={null}
busy={parentInert}
onPick={advance}
wide={false}
showHidden={showHidden}
filterPrefix={draftPrefixFor(child, pathDraft)}
/>
@@ -498,6 +522,10 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
className={clsx(css.showHiddenToggle, showHidden && css.showHiddenToggleActive)}
aria-pressed={showHidden}
disabled={parentInert}
// The toggle composes with the path editor (dot-led prefixes and
// this filter interleave): don't steal focus, so toggling never
// blur-cancels a draft mid-thought.
onMouseDown={(event) => { event.preventDefault() }}
onClick={() => { setShowHidden(prev => !prev) }}
>
{showHidden && <IconCheckOutline16 size={14} />}