From cc39021abcbe16f8a64915d08e069a548c4fd6f2 Mon Sep 17 00:00:00 2001 From: weixiang1862 <652048614@qq.com> Date: Mon, 3 Aug 2026 12:32:50 +0800 Subject: [PATCH] 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. --- cli/src/api/apiMachine.test.ts | 57 ++++++++++++++++++ cli/src/api/apiMachine.ts | 5 +- hub/src/sync/rpcGateway.ts | 4 +- hub/src/sync/syncEngine.ts | 4 +- hub/src/web/routes/machines.ts | 2 +- shared/src/apiTypes.ts | 3 +- web/src/api/client.ts | 5 +- web/src/components/WorkspaceBrowser.tsx | 77 +++++++++++++++++++++---- web/src/lib/locales/en.ts | 1 + web/src/lib/locales/zh-CN.ts | 1 + 10 files changed, 138 insertions(+), 21 deletions(-) diff --git a/cli/src/api/apiMachine.test.ts b/cli/src/api/apiMachine.test.ts index b861c574..2c838417 100644 --- a/cli/src/api/apiMachine.test.ts +++ b/cli/src/api/apiMachine.test.ts @@ -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 { + const manager = (client as unknown as { rpcHandlerManager: { handleRequest: (req: { method: string; params: string }) => Promise } }).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() + } + }) +}) diff --git a/cli/src/api/apiMachine.ts b/cli/src/api/apiMachine.ts index 97c9beda..c704a9ab 100644 --- a/cli/src/api/apiMachine.ts +++ b/cli/src/api/apiMachine.ts @@ -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' diff --git a/hub/src/sync/rpcGateway.ts b/hub/src/sync/rpcGateway.ts index 69647e0b..d38465c1 100644 --- a/hub/src/sync/rpcGateway.ts +++ b/hub/src/sync/rpcGateway.ts @@ -224,8 +224,8 @@ export class RpcGateway { } } - async listMachineDirectory(machineId: string, path: string): Promise { - const result = await this.machineRpc(machineId, RPC_METHODS.ListMachineDirectory, { path }) as RpcListDirectoryResponse | unknown + async listMachineDirectory(machineId: string, path: string, includeHidden?: boolean): Promise { + 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' } } diff --git a/hub/src/sync/syncEngine.ts b/hub/src/sync/syncEngine.ts index 5ec9e1ab..88769216 100644 --- a/hub/src/sync/syncEngine.ts +++ b/hub/src/sync/syncEngine.ts @@ -2806,8 +2806,8 @@ async uploadScratchlistAttachment( return await this.rpcGateway.checkPathsExist(machineId, paths) } - async listMachineDirectory(machineId: string, path: string): Promise { - return await this.rpcGateway.listMachineDirectory(machineId, path) + async listMachineDirectory(machineId: string, path: string, includeHidden?: boolean): Promise { + return await this.rpcGateway.listMachineDirectory(machineId, path, includeHidden) } async getGitStatus(sessionId: string, cwd?: string): Promise { diff --git a/hub/src/web/routes/machines.ts b/hub/src/web/routes/machines.ts index 876b481b..4ca22090 100644 --- a/hub/src/web/routes/machines.ts +++ b/hub/src/web/routes/machines.ts @@ -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) diff --git a/shared/src/apiTypes.ts b/shared/src/apiTypes.ts index 6b6b06b0..ba51c68d 100644 --- a/shared/src/apiTypes.ts +++ b/shared/src/apiTypes.ts @@ -497,7 +497,8 @@ export const SpawnSessionRequestSchema = z.object({ export type SpawnSessionRequest = z.infer export const MachineListDirectoryRequestSchema = z.object({ - path: z.string().min(1) + path: z.string().min(1), + includeHidden: z.boolean().optional() }) export type MachineListDirectoryRequest = z.infer diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 6b09045b..e945c748 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -649,13 +649,14 @@ export class ApiClient { async listMachineDirectory( machineId: string, - path: string + path: string, + options?: { includeHidden?: boolean } ): Promise { return await this.request( `/api/machines/${encodeURIComponent(machineId)}/list-directory`, { method: 'POST', - body: JSON.stringify({ path }) + body: JSON.stringify({ path, includeHidden: options?.includeHidden === true }) } ) } diff --git a/web/src/components/WorkspaceBrowser.tsx b/web/src/components/WorkspaceBrowser.tsx index b82e71d1..42654ea8 100644 --- a/web/src/components/WorkspaceBrowser.tsx +++ b/web/src/components/WorkspaceBrowser.tsx @@ -53,6 +53,22 @@ function RefreshIcon(props: { className?: string }) { ) } +function CheckboxBlankIcon(props: { className?: string }) { + return ( + + + + ) +} + +function CheckboxCheckedIcon(props: { className?: string }) { + return ( + + + + ) +} + 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([]) const [isLoading, setIsLoading] = useState(false) const [error, setError] = useState(null) + const [showHidden, setShowHidden] = useState(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: { ))} - +
+ + +
)} diff --git a/web/src/lib/locales/en.ts b/web/src/lib/locales/en.ts index cfbb9296..05b3005d 100644 --- a/web/src/lib/locales/en.ts +++ b/web/src/lib/locales/en.ts @@ -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', diff --git a/web/src/lib/locales/zh-CN.ts b/web/src/lib/locales/zh-CN.ts index 83b837db..a6a84022 100644 --- a/web/src/lib/locales/zh-CN.ts +++ b/web/src/lib/locales/zh-CN.ts @@ -902,6 +902,7 @@ export default { 'browse.goUp': '返回上层', 'browse.empty': '未找到子目录', 'browse.refresh': '刷新', + 'browse.showHidden': '显示隐藏项', 'browse.startSession': '启动会话', 'browse.nav': '浏览', 'browse.noRootTitle': '未启用 workspace 浏览',