From 7b80b8ce60d703a6727479663152f67f18548b8a Mon Sep 17 00:00:00 2001 From: creatixchu Date: Wed, 29 Jul 2026 03:53:48 +0800 Subject: [PATCH] fix(host): keep the child pane reachable on narrow viewports; surface truncated levels The Miller row now scrolls horizontally with the child pane pinned into view when its preview lands, so descent stays reachable when the dialog is narrower than two fixed panes. The dialog also says when a visible level was cut at the backend's complete-result bound (817's maxEntries) instead of letting the tail of a huge directory go silently missing. --- .../src/client/DirectoryBrowser.module.css | 4 ++ .../src/client/DirectoryBrowser.tsx | 16 +++++++- .../src/client/index.ts | 2 + .../tests/client-flow.spec.tsx | 1 + .../tests/directory-browser.spec.tsx | 37 ++++++++++++++++++- 5 files changed, 58 insertions(+), 2 deletions(-) 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 47564236fe..4e9378290b 100644 --- a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css +++ b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css @@ -43,12 +43,16 @@ * so the edit zone to the right never leaves the bar. */ /* The Miller columns keep their own row so a status/error line below never * competes with the fixed column widths for horizontal space. */ +/* A narrow viewport shrinks the dialog below two fixed panes; the row + * scrolls horizontally (the effect pins the child pane into view) so + * descent never hides behind the Modal's clipping. */ .millerRow { display: flex; align-items: stretch; flex: 1 1 0; min-height: 0; gap: 20px; + overflow-x: auto; } .crumbTrail { diff --git a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx index 27a5c85aed..fe8b7ff5c7 100644 --- a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx +++ b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx @@ -252,6 +252,15 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, const trail = crumbTrailRef.current if (trail !== null) trail.scrollLeft = trail.scrollWidth }, [crumbTail]) + // On viewports too narrow for both fixed panes the Miller row scrolls; + // whenever a child preview lands, pin it into view the way the crumb tail + // pins — otherwise descent is unreachable on a phone-width window. + const millerRowRef = useRef(null) + const childPath = child?.path + useEffect(() => { + const row = millerRowRef.current + if (row !== null && childPath !== undefined) row.scrollLeft = row.scrollWidth + }, [childPath]) if (!open) return null const twoPane = selected !== null @@ -362,7 +371,7 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
-
+
{parent !== null && ( {loading &&
{t('browser.loading')}
} + {/* The backend bounds a level at its complete-result limit; say so + * whenever a visible pane was cut instead of letting the tail of a + * huge directory go silently missing. */} + {(parent?.truncated === true || child?.truncated === true) && !loading + &&
{t('browser.truncated')}
} {error !== null &&
{error}
}
diff --git a/packages/host/directory-picker-browse/src/client/index.ts b/packages/host/directory-picker-browse/src/client/index.ts index 78d5e1c006..bc409483ab 100644 --- a/packages/host/directory-picker-browse/src/client/index.ts +++ b/packages/host/directory-picker-browse/src/client/index.ts @@ -41,6 +41,7 @@ export function apply(ctx: ClientContext): void { 'browser.open': '打开', 'browser.editPath': '编辑路径', 'browser.loading': '加载中…', + 'browser.truncated': '文件夹过多,仅显示开头部分。', }), ctx.locale.register(LOCALE_NS, 'en', { 'browser.title': 'Select Workspace Directory', @@ -54,6 +55,7 @@ export function apply(ctx: ClientContext): void { 'browser.open': 'Open', 'browser.editPath': 'Edit path', 'browser.loading': 'Loading…', + 'browser.truncated': 'Too many folders to list; only the beginning is shown.', }), ] return () => { for (const dispose of disposers) dispose() } diff --git a/packages/host/directory-picker-browse/tests/client-flow.spec.tsx b/packages/host/directory-picker-browse/tests/client-flow.spec.tsx index 387bcd156d..80e8b19db0 100644 --- a/packages/host/directory-picker-browse/tests/client-flow.spec.tsx +++ b/packages/host/directory-picker-browse/tests/client-flow.spec.tsx @@ -19,6 +19,7 @@ const homeListing: DirectoryListing = { home: HOME, crumbs: [{ name: '/', path: '/', hidden: false }, { name: 'u', path: HOME, hidden: false }], entries: [{ name: 'Documents', path: `${HOME}/Documents`, hidden: false }], + truncated: false, } async function bench() { 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 2b5cedddfd..a590bd3b17 100644 --- a/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx +++ b/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx @@ -27,6 +27,7 @@ function listingFor(path?: string): DirectoryListing { { name: '.config', path: `${HOME}/.config`, hidden: true }, { name: 'Documents', path: DOCS, hidden: false }, ], + truncated: false, }, [DOCS]: { path: DOCS, @@ -38,6 +39,7 @@ function listingFor(path?: string): DirectoryListing { { name: 'Documents', path: DOCS, hidden: false }, ], entries: [{ name: 'harness', path: HARNESS, hidden: false }], + truncated: false, }, [HARNESS]: { path: HARNESS, @@ -50,6 +52,7 @@ function listingFor(path?: string): DirectoryListing { { name: 'harness', path: HARNESS, hidden: false }, ], entries: [], + truncated: false, }, } const found = tree[target] @@ -205,6 +208,7 @@ describe('DirectoryBrowser', () => { { name: 'data', path: '/srv/data', hidden: false }, ], entries: [], + truncated: false, } mount({ listDirectory: vi.fn(async () => outside) }) await waitFor(() => { expect(screen.getByRole('button', { name: 'data' })).toBeTruthy() }) @@ -253,6 +257,7 @@ describe('DirectoryBrowser', () => { path: `${HOME}/fresh`, home: HOME, crumbs: [...listingFor(HOME).crumbs, { name: 'fresh', path: `${HOME}/fresh`, hidden: false }], entries: [], + truncated: false, } b.listDirectory.mockImplementation((path?: string) => new Promise((settle) => { @@ -519,6 +524,7 @@ describe('DirectoryBrowser', () => { path: `${DOCS}/fresh`, home: HOME, crumbs: [...listingFor(DOCS).crumbs, { name: 'fresh', path: `${DOCS}/fresh`, hidden: false }], entries: [], + truncated: false, } } if (path === DOCS) { @@ -648,7 +654,7 @@ describe('DirectoryBrowser', () => { }) it('names the create target by its path when the level reports no crumbs', async () => { - const bare: DirectoryListing = { path: '/srv/data', home: HOME, crumbs: [], entries: [] } + const bare: DirectoryListing = { path: '/srv/data', home: HOME, crumbs: [], entries: [], truncated: false } mount({ listDirectory: vi.fn(async () => bare) }) await waitFor(() => { expect(screen.getByRole('button', { name: 'browser.newFolder' })).toBeTruthy() }) await waitFor(() => { @@ -676,6 +682,35 @@ describe('DirectoryBrowser', () => { await waitFor(() => { expect(screen.queryByLabelText('browser.folderName')).toBeNull() }) }) + it('says a level is incomplete when the backend cut it at its bound', async () => { + const cut = { ...listingFor(HOME), truncated: true } + mount({ listDirectory: vi.fn(async () => cut) }) + await screen.findByText('browser.truncated') + }) + + it('flags a truncated child preview under a complete level', async () => { + mount({ + listDirectory: vi.fn(async (path?: string) => + (path === DOCS ? { ...listingFor(DOCS), truncated: true } : listingFor(path))), + }) + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + expect(screen.queryByText('browser.truncated')).toBeNull() + fireEvent.click(rowButton(screen.getByRole('listitem'))) + await waitFor(() => { expect(columns()).toHaveLength(2) }) + await screen.findByText('browser.truncated') + }) + + it('pins the child pane into view when its preview lands (narrow viewports scroll the miller row)', async () => { + mount() + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + const row = document.querySelector('[class*=millerRow]') as HTMLElement + // jsdom does no layout: stub the overflow width the effect pins against. + Object.defineProperty(row, 'scrollWidth', { value: 640, configurable: true }) + fireEvent.click(rowButton(screen.getByRole('listitem'))) + await waitFor(() => { expect(columns()).toHaveLength(2) }) + await waitFor(() => { expect(row.scrollLeft).toBe(640) }) + }) + it('starts back at home on reopen', async () => { const b = mount() await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })