From e561c28232a273f834e66e557e8375a3bb2cf7d0 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Wed, 29 Jul 2026 13:47:56 +0800 Subject: [PATCH] feat(host): seed path editor with a trailing separator; prefix-filter levels from the draft tail --- .../src/client/DirectoryBrowser.tsx | 44 +++++++++++++++-- .../tests/directory-browser.spec.tsx | 47 ++++++++++++++++++- 2 files changed, 86 insertions(+), 5 deletions(-) diff --git a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx index 5c3391454e..01e69f314c 100644 --- a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx +++ b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx @@ -12,7 +12,10 @@ * owning flow decides what "Open" means and owns the workspace-creation * error surface. Hidden entries are host-flagged and hidden by default; the * footer's fixed-label "Show hidden files" toggle (aria-pressed, check when - * on) reveals them (client-side only). + * on) reveals them (client-side only). The path editor opens seeded with a + * trailing separator, and while the draft's directory part names a listed + * level, its final segment prefix-filters that level's rows (a dot-led + * prefix also reveals the hidden entries it names). */ import { useCallback, useEffect, useRef, useState } from 'react' import clsx from 'clsx' @@ -60,18 +63,45 @@ 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 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. + */ +function draftPrefixFor(listing: DirectoryListing, draft: string | null): string | null { + if (draft === null) return null + const sep = separatorOf(draft) + const cut = draft.lastIndexOf(sep) + if (cut === -1) return null + const level = listing.path.endsWith(sep) ? listing.path : `${listing.path}${sep}` + return draft.slice(0, cut + 1) === level ? draft.slice(cut + 1) : null +} + /** One column of folder rows (the Miller view renders one or two of these). */ -function LevelColumn({ entries, selectedPath, busy, onPick, wide, showHidden }: { +function LevelColumn({ entries, selectedPath, busy, onPick, wide, showHidden, filterPrefix }: { entries: readonly DirectoryEntry[] selectedPath: string | null busy: boolean onPick: (entry: DirectoryEntry) => void wide: boolean showHidden: boolean + filterPrefix: string | null }) { + const visible = entries.filter((entry) => { + if (filterPrefix !== null && !entry.name.toLowerCase().startsWith(filterPrefix.toLowerCase())) return false + // A dot-led prefix names hidden entries explicitly, so matching ones + // surface even while the toggle keeps the rest hidden. + return showHidden || !entry.hidden || filterPrefix?.startsWith('.') === true + }) return (
- {entries.filter(entry => showHidden || !entry.hidden).map((entry) => { + {visible.map((entry) => { const selected = entry.path === selectedPath return ( // The wrapper carries the list semantics; the row keeps its NATIVE @@ -370,7 +400,11 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, // otherwise close the editor via navigate's draft reset. supersede() setLoading(false) - setPathDraft(selected?.path ?? parent?.path ?? '') + // 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}`) }} /> @@ -423,6 +457,7 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, onPick={select} wide={!twoPane} showHidden={showHidden} + filterPrefix={draftPrefixFor(parent, pathDraft)} /> )} {twoPane && } @@ -434,6 +469,7 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, onPick={advance} wide={false} showHidden={showHidden} + filterPrefix={draftPrefixFor(child, pathDraft)} /> )}
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 d4fc9e2b89..98f3bd6e4f 100644 --- a/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx +++ b/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx @@ -206,7 +206,9 @@ describe('DirectoryBrowser', () => { await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) const input = screen.getByLabelText('browser.editPath') - expect(input.value).toBe(HOME) + // The editor seeds with a trailing separator so typing continues into + // child names. + expect(input.value).toBe(`${HOME}/`) fireEvent.change(input, { target: { value: DOCS } }) fireEvent.keyDown(input, { key: 'Enter' }) await waitFor(() => { expect(screen.getByRole('listitem').textContent).toBe('harness') }) @@ -220,6 +222,49 @@ describe('DirectoryBrowser', () => { expect(screen.queryByLabelText('browser.editPath', { selector: 'input' })).toBeNull() }) + it('prefix-filters the listed level from the draft tail, dot revealing hidden matches', async () => { + mount() + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) + const input = screen.getByLabelText('browser.editPath') + // The seeded empty segment leaves the level as-is: hidden stays hidden. + expect(screen.getByRole('listitem').textContent).toBe('Documents') + // Case-insensitive prefix narrows the rows. + fireEvent.change(input, { target: { value: `${HOME}/do` } }) + expect(screen.getByRole('listitem').textContent).toBe('Documents') + // A dot-led prefix names hidden entries, so it reveals the match. + fireEvent.change(input, { target: { value: `${HOME}/.co` } }) + expect(screen.getByRole('listitem').textContent).toBe('.config') + // A prefix matching nothing empties the level (no stale rows linger). + fireEvent.change(input, { target: { value: `${HOME}/zzz` } }) + expect(screen.queryByRole('listitem')).toBeNull() + // A draft naming some other directory (or none) leaves the level whole. + fireEvent.change(input, { target: { value: 'no-separator' } }) + expect(screen.getByRole('listitem').textContent).toBe('Documents') + }) + + it('seeds and filters with backslashes on a Windows-rooted listing', async () => { + const ROOT = 'C:\\' + const windowsListing: DirectoryListing = { + path: ROOT, + home: ROOT, + crumbs: [{ name: 'C:\\', path: ROOT, hidden: false }], + entries: [ + { name: 'Program Files', path: `${ROOT}Program Files`, hidden: false }, + { name: 'Users', path: `${ROOT}Users`, hidden: false }, + ], + truncated: false, + } + mount({ listDirectory: vi.fn(async () => windowsListing) }) + await waitFor(() => { expect(screen.getAllByRole('listitem')).toHaveLength(2) }) + fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) + const input = screen.getByLabelText('browser.editPath') + // The root already ends in its separator: no doubled backslash. + expect(input.value).toBe(ROOT) + fireEvent.change(input, { target: { value: `${ROOT}u` } }) + expect(screen.getByRole('listitem').textContent).toBe('Users') + }) + it('clicking away from the path editor cancels it back to the crumb view', async () => { mount() await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })