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() })