feat(host): show-hidden toggle in the directory browser footer

This commit is contained in:
creatixchu
2026-07-29 12:52:59 +08:00
parent 75b32f7d76
commit cdcdd2221e
5 changed files with 67 additions and 4 deletions

View File

@@ -242,6 +242,32 @@
border-top: 1px solid var(--dsw-alias-border-l3); border-top: 1px solid var(--dsw-alias-border-l3);
} }
/* Show-hidden toggle: a subtle text button in the footer, left of the gap. */
.showHiddenToggle {
border: none;
background: transparent;
padding: 0;
font-size: 13px;
line-height: 20px;
font-weight: 500;
color: var(--dsw-alias-label-secondary);
cursor: pointer;
white-space: nowrap;
}
.showHiddenToggle:hover {
color: var(--dsw-alias-label-primary);
}
.showHiddenToggle:disabled {
color: var(--dsw-alias-label-caption);
cursor: default;
}
.showHiddenToggleActive {
color: var(--dsw-alias-label-primary);
}
.footerGap { .footerGap {
flex: 1 1 0; flex: 1 1 0;
} }

View File

@@ -10,8 +10,8 @@
* selects the created folder. Open adopts the selected folder, falling back * selects the created folder. Open adopts the selected folder, falling back
* to the listed level. Pure consumer of the injected browse calls — the * to the listed level. Pure consumer of the injected browse calls — the
* owning flow decides what "Open" means and owns the workspace-creation * owning flow decides what "Open" means and owns the workspace-creation
* error surface. Hidden entries are host-flagged and filtered here (a * error surface. Hidden entries are host-flagged and hidden by default;
* show-hidden toggle is deferred work, client-side only). * a "Show hidden files" toggle in the footer reveals them (client-side only).
*/ */
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import clsx from 'clsx' import clsx from 'clsx'
@@ -60,16 +60,17 @@ function displayCrumbs(listing: DirectoryListing, homeLabel: string): DirectoryE
} }
/** One column of folder rows (the Miller view renders one or two of these). */ /** One column of folder rows (the Miller view renders one or two of these). */
function LevelColumn({ entries, selectedPath, busy, onPick, wide }: { function LevelColumn({ entries, selectedPath, busy, onPick, wide, showHidden }: {
entries: readonly DirectoryEntry[] entries: readonly DirectoryEntry[]
selectedPath: string | null selectedPath: string | null
busy: boolean busy: boolean
onPick: (entry: DirectoryEntry) => void onPick: (entry: DirectoryEntry) => void
wide: boolean wide: boolean
showHidden: boolean
}) { }) {
return ( return (
<div className={clsx(css.column, wide && css.columnWide)} role="list"> <div className={clsx(css.column, wide && css.columnWide)} role="list">
{entries.filter(entry => !entry.hidden).map((entry) => { {entries.filter(entry => showHidden || !entry.hidden).map((entry) => {
const selected = entry.path === selectedPath const selected = entry.path === selectedPath
return ( return (
// The wrapper carries the list semantics; the row keeps its NATIVE // The wrapper carries the list semantics; the row keeps its NATIVE
@@ -110,6 +111,8 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
// Path-edit state: null = breadcrumb mode; a string = the draft being typed. // Path-edit state: null = breadcrumb mode; a string = the draft being typed.
const [pathDraft, setPathDraft] = useState<string | null>(null) const [pathDraft, setPathDraft] = useState<string | null>(null)
// Show-hidden toggle state (pure client-side filter, reset on close).
const [showHidden, setShowHidden] = useState(false)
// Create-folder state: null = closed; a string = the nested dialog's draft. // Create-folder state: null = closed; a string = the nested dialog's draft.
const [folderDraft, setFolderDraft] = useState<string | null>(null) const [folderDraft, setFolderDraft] = useState<string | null>(null)
const [creatingFolder, setCreatingFolder] = useState(false) const [creatingFolder, setCreatingFolder] = useState(false)
@@ -213,6 +216,7 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
setSelected(null) setSelected(null)
setChild(null) setChild(null)
setCreatingFolder(false) setCreatingFolder(false)
setShowHidden(false)
navigate() navigate()
return return
} }
@@ -409,6 +413,7 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
busy={parentInert} busy={parentInert}
onPick={select} onPick={select}
wide={!twoPane} wide={!twoPane}
showHidden={showHidden}
/> />
)} )}
{twoPane && <span className={css.divider} />} {twoPane && <span className={css.divider} />}
@@ -419,6 +424,7 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
busy={parentInert} busy={parentInert}
onPick={advance} onPick={advance}
wide={false} wide={false}
showHidden={showHidden}
/> />
)} )}
</div> </div>
@@ -442,6 +448,14 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
> >
{t('browser.newFolder')} {t('browser.newFolder')}
</Button> </Button>
<button
type="button"
className={clsx(css.showHiddenToggle, showHidden && css.showHiddenToggleActive)}
disabled={parentInert}
onClick={() => { setShowHidden(prev => !prev) }}
>
{t(showHidden ? 'browser.hideHidden' : 'browser.showHidden')}
</button>
<span className={css.footerGap} /> <span className={css.footerGap} />
<Button variant="outline" className={clsx(css.footerAction)} disabled={parentInert} onClick={onClose}>{t('browser.cancel')}</Button> <Button variant="outline" className={clsx(css.footerAction)} disabled={parentInert} onClick={onClose}>{t('browser.cancel')}</Button>
<Button <Button

View File

@@ -46,6 +46,8 @@ export function apply(ctx: ClientContext): void {
'browser.editPath': '编辑路径', 'browser.editPath': '编辑路径',
'browser.loading': '加载中…', 'browser.loading': '加载中…',
'browser.truncated': '文件夹过多,仅显示开头部分。', 'browser.truncated': '文件夹过多,仅显示开头部分。',
'browser.showHidden': '显示隐藏文件',
'browser.hideHidden': '隐藏隐藏文件',
}], }],
['en', { ['en', {
'browser.title': 'Select Workspace Directory', 'browser.title': 'Select Workspace Directory',
@@ -60,6 +62,8 @@ export function apply(ctx: ClientContext): void {
'browser.editPath': 'Edit path', 'browser.editPath': 'Edit path',
'browser.loading': 'Loading…', 'browser.loading': 'Loading…',
'browser.truncated': 'Too many folders to list; only the beginning is shown.', 'browser.truncated': 'Too many folders to list; only the beginning is shown.',
'browser.showHidden': 'Show hidden files',
'browser.hideHidden': 'Hide hidden files',
}], }],
] ]
try { try {

View File

@@ -162,6 +162,8 @@ describe('directory-picker-browse client half', () => {
// zh is the shipped default locale. // zh is the shipped default locale.
expect(injected.t('browser.title')).toBe('选择工作区目录') expect(injected.t('browser.title')).toBe('选择工作区目录')
expect(injected.t('browser.newFolder')).toBe('新建文件夹') expect(injected.t('browser.newFolder')).toBe('新建文件夹')
expect(injected.t('browser.showHidden')).toBe('显示隐藏文件')
expect(injected.t('browser.hideHidden')).toBe('隐藏隐藏文件')
}) })
it('drives the injected browse calls through the hole entry', async () => { it('drives the injected browse calls through the hole entry', async () => {

View File

@@ -103,6 +103,23 @@ describe('DirectoryBrowser', () => {
expect(screen.queryByRole('button', { name: '/' })).toBeNull() expect(screen.queryByRole('button', { name: '/' })).toBeNull()
}) })
it('shows hidden entries when the toggle is on and hides them again on close', async () => {
const b = mount()
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
expect(screen.queryByText('.config')).toBeNull()
// Toggle hidden files on.
fireEvent.click(screen.getByRole('button', { name: 'browser.showHidden' }))
expect(screen.getByText('.config')).toBeTruthy()
// Toggle hidden files off.
fireEvent.click(screen.getByRole('button', { name: 'browser.hideHidden' }))
expect(screen.queryByText('.config')).toBeNull()
// Close resets the toggle.
b.view.rerender(<DirectoryBrowser {...b.props} open={false} />)
b.view.rerender(<DirectoryBrowser {...b.props} open />)
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
expect(screen.queryByText('.config')).toBeNull()
})
it('selects a row into the two-pane view: children preview right, crumbs follow the selection', async () => { it('selects a row into the two-pane view: children preview right, crumbs follow the selection', async () => {
const b = mount() const b = mount()
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })