mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
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:
@@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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' }
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -902,6 +902,7 @@ export default {
|
||||
'browse.goUp': '返回上层',
|
||||
'browse.empty': '未找到子目录',
|
||||
'browse.refresh': '刷新',
|
||||
'browse.showHidden': '显示隐藏项',
|
||||
'browse.startSession': '启动会话',
|
||||
'browse.nav': '浏览',
|
||||
'browse.noRootTitle': '未启用 workspace 浏览',
|
||||
|
||||
Reference in New Issue
Block a user