feat(web): show hidden directories in workspace browser (#1331)

* feat(web): show hidden directories in workspace browser

Add optional includeHidden param to the machine list-directory RPC so the
WorkspaceBrowser can toggle hidden (dot-prefixed) entries. Default remains
filtered for backward compatibility; the toggle persists via localStorage.

* fix(web): disable show-hidden toggle while directory loading

Prevent overlapping list-directory requests with opposite includeHidden
values; the toggle is now disabled while a directory load is active.
This commit is contained in:
weixiang1862
2026-08-03 12:32:50 +08:00
committed by GitHub
parent a111d0bc82
commit cc39021abc
10 changed files with 138 additions and 21 deletions
+57
View File
@@ -541,3 +541,60 @@ describe('ApiMachineClient keepAlive lifecycle', () => {
expect(priv.keepAliveInterval).toBeNull()
})
})
describe('ApiMachineClient list-directory handler', () => {
let workspaceRoot: string
beforeEach(() => {
ioMock.mockReset()
workspaceRoot = mkdtempSync(join(tmpdir(), 'hapi-machine-ls-'))
mkdirSync(join(workspaceRoot, 'visible-dir'))
mkdirSync(join(workspaceRoot, '.hidden-dir'))
writeFileSync(join(workspaceRoot, 'plain.txt'), 'x')
writeFileSync(join(workspaceRoot, '.hidden-file'), 'x')
})
afterEach(() => {
rmSync(workspaceRoot, { recursive: true, force: true })
})
async function callListDirectory(client: ApiMachineClient, machineId: string, params: { path: string; includeHidden?: boolean }): Promise<unknown> {
const manager = (client as unknown as { rpcHandlerManager: { handleRequest: (req: { method: string; params: string }) => Promise<string> } }).rpcHandlerManager
const raw = await manager.handleRequest({
method: `${machineId}:list-directory`,
params: JSON.stringify(params)
})
return JSON.parse(raw) as unknown
}
function entryNames(result: unknown): string[] {
const entries = (result as { success: boolean; entries?: { name: string }[] }).entries ?? []
return entries.map((entry) => entry.name).sort()
}
it('filters dot-prefixed entries by default', async () => {
const machine = makeMachine('machine-ls-1')
const client = new ApiMachineClient('cli-token', machine, [workspaceRoot])
try {
const result = await callListDirectory(client, machine.id, { path: workspaceRoot })
expect((result as { success: boolean }).success).toBe(true)
expect(entryNames(result)).toEqual(['plain.txt', 'visible-dir'])
} finally {
client.shutdown()
}
})
it('includes dot-prefixed entries when includeHidden is true', async () => {
const machine = makeMachine('machine-ls-2')
const client = new ApiMachineClient('cli-token', machine, [workspaceRoot])
try {
const result = await callListDirectory(client, machine.id, { path: workspaceRoot, includeHidden: true })
expect((result as { success: boolean }).success).toBe(true)
expect(entryNames(result)).toEqual(['.hidden-dir', '.hidden-file', 'plain.txt', 'visible-dir'])
} finally {
client.shutdown()
}
})
})
+4 -1
View File
@@ -56,6 +56,7 @@ interface PathExistsRequest {
interface ListMachineDirectoryRequest {
path: string
includeHidden?: boolean
}
interface CursorChatStoreStatusRequest {
@@ -168,6 +169,8 @@ export class ApiMachineClient {
return { success: false, error: 'Path is required' }
}
const includeHidden = params?.includeHidden === true
const targetPath = await this.resolveForWorkspaceCheck(rawPath)
if (!this.isWithinWorkspaceRoots(targetPath)) {
return { success: false, error: 'Path is outside workspace roots' }
@@ -183,7 +186,7 @@ export class ApiMachineClient {
const entries: MachineDirectoryEntry[] = []
await Promise.all(dirEntries.map(async (entry) => {
if (entry.name.startsWith('.')) return
if (!includeHidden && entry.name.startsWith('.')) return
const fullPath = join(targetPath, entry.name)
let type: 'file' | 'directory' | 'other' = 'other'
+2 -2
View File
@@ -224,8 +224,8 @@ export class RpcGateway {
}
}
async listMachineDirectory(machineId: string, path: string): Promise<RpcListDirectoryResponse> {
const result = await this.machineRpc(machineId, RPC_METHODS.ListMachineDirectory, { path }) as RpcListDirectoryResponse | unknown
async listMachineDirectory(machineId: string, path: string, includeHidden?: boolean): Promise<RpcListDirectoryResponse> {
const result = await this.machineRpc(machineId, RPC_METHODS.ListMachineDirectory, { path, includeHidden }) as RpcListDirectoryResponse | unknown
if (!result || typeof result !== 'object') {
return { success: false, error: 'Unexpected list-directory result' }
}
+2 -2
View File
@@ -2806,8 +2806,8 @@ async uploadScratchlistAttachment(
return await this.rpcGateway.checkPathsExist(machineId, paths)
}
async listMachineDirectory(machineId: string, path: string): Promise<RpcListDirectoryResponse> {
return await this.rpcGateway.listMachineDirectory(machineId, path)
async listMachineDirectory(machineId: string, path: string, includeHidden?: boolean): Promise<RpcListDirectoryResponse> {
return await this.rpcGateway.listMachineDirectory(machineId, path, includeHidden)
}
async getGitStatus(sessionId: string, cwd?: string): Promise<RpcCommandResponse> {
+1 -1
View File
@@ -118,7 +118,7 @@ export function createMachinesRoutes(getSyncEngine: () => SyncEngine | null): Ho
}
try {
const result = await engine.listMachineDirectory(machineId, parsed.data.path)
const result = await engine.listMachineDirectory(machineId, parsed.data.path, parsed.data.includeHidden)
return c.json(result)
} catch (error) {
return c.json({ error: error instanceof Error ? error.message : 'Failed to list directory' }, 500)
+2 -1
View File
@@ -497,7 +497,8 @@ export const SpawnSessionRequestSchema = z.object({
export type SpawnSessionRequest = z.infer<typeof SpawnSessionRequestSchema>
export const MachineListDirectoryRequestSchema = z.object({
path: z.string().min(1)
path: z.string().min(1),
includeHidden: z.boolean().optional()
})
export type MachineListDirectoryRequest = z.infer<typeof MachineListDirectoryRequestSchema>
+3 -2
View File
@@ -649,13 +649,14 @@ export class ApiClient {
async listMachineDirectory(
machineId: string,
path: string
path: string,
options?: { includeHidden?: boolean }
): Promise<MachineListDirectoryResponse> {
return await this.request<MachineListDirectoryResponse>(
`/api/machines/${encodeURIComponent(machineId)}/list-directory`,
{
method: 'POST',
body: JSON.stringify({ path })
body: JSON.stringify({ path, includeHidden: options?.includeHidden === true })
}
)
}
+65 -12
View File
@@ -53,6 +53,22 @@ function RefreshIcon(props: { className?: string }) {
)
}
function CheckboxBlankIcon(props: { className?: string }) {
return (
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="currentColor" className={props.className}>
<path d="M4 3H20C20.5523 3 21 3.44772 21 4V20C21 20.5523 20.5523 21 20 21H4C3.44772 21 3 20.5523 3 20V4C3 3.44772 3.44772 3 4 3ZM5 5V19H19V5H5Z" />
</svg>
)
}
function CheckboxCheckedIcon(props: { className?: string }) {
return (
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="currentColor" className={props.className}>
<path d="M4 3H20C20.5523 3 21 3.44772 21 4V20C21 20.5523 20.5523 21 20 21H4C3.44772 21 3 20.5523 3 20V4C3 3.44772 3.44772 3 4 3ZM5 5V19H19V5H5ZM11.0026 16L6.75999 11.7574L8.17421 10.3431L11.0026 13.1716L16.6595 7.51472L18.0737 8.92893L11.0026 16Z" />
</svg>
)
}
function getMachineTitle(machine: Machine): string {
if (machine.metadata?.displayName) return machine.metadata.displayName
if (machine.metadata?.host) return machine.metadata.host
@@ -142,6 +158,16 @@ function buildBreadcrumbs(currentPath: string, root: string): { label: string; p
return crumbs
}
const SHOW_HIDDEN_STORAGE_KEY = 'hapi:workspaceBrowserShowHidden'
function readShowHidden(): boolean {
try {
return localStorage.getItem(SHOW_HIDDEN_STORAGE_KEY) === '1'
} catch {
return false
}
}
export function WorkspaceBrowser(props: {
api: ApiClient
machines: Machine[]
@@ -159,6 +185,7 @@ export function WorkspaceBrowser(props: {
const [entries, setEntries] = useState<MachineDirectoryEntry[]>([])
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [showHidden, setShowHidden] = useState<boolean>(readShowHidden)
useEffect(() => {
if (machines.length === 0) {
@@ -190,12 +217,12 @@ export function WorkspaceBrowser(props: {
[selectedMachine?.metadata?.workspaceRoots]
)
const loadDirectory = useCallback(async (path: string) => {
const loadDirectory = useCallback(async (path: string, includeHiddenOverride?: boolean) => {
if (!machineId) return
setIsLoading(true)
setError(null)
try {
const result = await api.listMachineDirectory(machineId, path)
const result = await api.listMachineDirectory(machineId, path, { includeHidden: includeHiddenOverride ?? showHidden })
if (result.success && result.entries) {
setEntries(result.entries)
setCurrentPath(path)
@@ -212,7 +239,7 @@ export function WorkspaceBrowser(props: {
} finally {
setIsLoading(false)
}
}, [api, machineId, queryClient])
}, [api, machineId, queryClient, showHidden])
useEffect(() => {
if (workspaceRoots.length === 0) {
@@ -256,6 +283,16 @@ export function WorkspaceBrowser(props: {
if (currentPath) void loadDirectory(currentPath)
}, [currentPath, loadDirectory])
const handleToggleHidden = useCallback(() => {
const next = !showHidden
setShowHidden(next)
try {
localStorage.setItem(SHOW_HIDDEN_STORAGE_KEY, next ? '1' : '0')
} catch {
}
if (currentPath) void loadDirectory(currentPath, next)
}, [showHidden, currentPath, loadDirectory])
const handleStartSession = useCallback(() => {
if (!machineId || !currentPath) return
props.onStartSession(machineId, currentPath)
@@ -368,15 +405,31 @@ export function WorkspaceBrowser(props: {
</button>
</span>
))}
<button
type="button"
onClick={handleRefresh}
disabled={isLoading}
className="ml-auto shrink-0 p-0.5 rounded hover:bg-[var(--app-subtle-bg)] text-[var(--app-hint)] hover:text-[var(--app-fg)] transition-colors"
title={t('browse.refresh')}
>
<RefreshIcon className={`h-3.5 w-3.5 ${isLoading ? 'animate-spin' : ''}`} />
</button>
<div className="ml-auto shrink-0 flex items-center gap-1">
<button
type="button"
onClick={handleToggleHidden}
aria-pressed={showHidden}
disabled={isLoading}
className="shrink-0 flex items-center gap-1.5 rounded px-1.5 py-0.5 text-xs text-[var(--app-hint)] hover:bg-[var(--app-subtle-bg)] hover:text-[var(--app-fg)] transition-colors disabled:pointer-events-none disabled:opacity-50"
>
{showHidden ? (
<CheckboxCheckedIcon className="h-3.5 w-3.5 text-[var(--app-link)] shrink-0" />
) : (
<CheckboxBlankIcon className="h-3.5 w-3.5 shrink-0" />
)}
{t('browse.showHidden')}
</button>
<button
type="button"
onClick={handleRefresh}
disabled={isLoading}
className="shrink-0 p-0.5 rounded hover:bg-[var(--app-subtle-bg)] text-[var(--app-hint)] hover:text-[var(--app-fg)] transition-colors"
title={t('browse.refresh')}
>
<RefreshIcon className={`h-3.5 w-3.5 ${isLoading ? 'animate-spin' : ''}`} />
</button>
</div>
</div>
)}
</div>
+1
View File
@@ -898,6 +898,7 @@ export default {
'browse.goUp': 'Go up',
'browse.empty': 'No subdirectories found',
'browse.refresh': 'Refresh',
'browse.showHidden': 'Show hidden',
'browse.startSession': 'Start Session',
'browse.nav': 'Browse',
'browse.noRootTitle': 'Workspace browsing is off',
+1
View File
@@ -902,6 +902,7 @@ export default {
'browse.goUp': '返回上层',
'browse.empty': '未找到子目录',
'browse.refresh': '刷新',
'browse.showHidden': '显示隐藏项',
'browse.startSession': '启动会话',
'browse.nav': '浏览',
'browse.noRootTitle': '未启用 workspace 浏览',