feat: Add directory tree tab to session files

This commit is contained in:
weishu
2026-02-06 20:18:46 +08:00
parent dc7472f1d0
commit fb9abc18d4
13 changed files with 573 additions and 32 deletions
@@ -0,0 +1,66 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { mkdir, rm, symlink, writeFile } from 'fs/promises'
import { join } from 'path'
import { tmpdir } from 'os'
import { RpcHandlerManager } from '../../../api/rpc/RpcHandlerManager'
import { registerDirectoryHandlers } from './directories'
async function createTempDir(prefix: string): Promise<string> {
const base = tmpdir()
const path = join(base, `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2)}`)
await mkdir(path, { recursive: true })
return path
}
describe('directory RPC handlers', () => {
let rootDir: string
let rpc: RpcHandlerManager
beforeEach(async () => {
if (rootDir) {
await rm(rootDir, { recursive: true, force: true })
}
rootDir = await createTempDir('hapi-dir-handler')
await mkdir(join(rootDir, 'src'), { recursive: true })
await writeFile(join(rootDir, 'src', 'index.ts'), 'console.log("ok")')
await writeFile(join(rootDir, 'README.md'), '# test')
rpc = new RpcHandlerManager({ scopePrefix: 'session-test' })
registerDirectoryHandlers(rpc, rootDir)
})
it('lists root directory via empty path', async () => {
const response = await rpc.handleRequest({
method: 'session-test:listDirectory',
params: JSON.stringify({ path: '' })
})
const parsed = JSON.parse(response) as { success: boolean; entries?: Array<{ name: string; type: string }> }
expect(parsed.success).toBe(true)
const names = (parsed.entries ?? []).map((entry) => entry.name)
expect(names).toContain('src')
expect(names).toContain('README.md')
})
it('skips symlink stat in listDirectory', async () => {
try {
await symlink('/definitely-not-a-real-path', join(rootDir, 'bad-link'))
} catch {
// symlink may be disallowed on some systems; skip the test
return
}
const response = await rpc.handleRequest({
method: 'session-test:listDirectory',
params: JSON.stringify({ path: '' })
})
const parsed = JSON.parse(response) as { success: boolean; entries?: Array<{ name: string; type: string; size?: number }> }
expect(parsed.success).toBe(true)
const link = (parsed.entries ?? []).find((entry) => entry.name === 'bad-link')
expect(link).toBeTruthy()
expect(link?.type).toBe('other')
expect(link?.size).toBeUndefined()
})
})
+24 -13
View File
@@ -1,6 +1,6 @@
import { logger } from '@/ui/logger'
import { readdir, stat } from 'fs/promises'
import { basename, join } from 'path'
import { basename, join, resolve } from 'path'
import type { RpcHandlerManager } from '@/api/rpc/RpcHandlerManager'
import { validatePath } from '../pathSecurity'
import { getErrorMessage, rpcError } from '../rpcResponses'
@@ -46,17 +46,20 @@ export function registerDirectoryHandlers(rpcHandlerManager: RpcHandlerManager,
rpcHandlerManager.registerHandler<ListDirectoryRequest, ListDirectoryResponse>('listDirectory', async (data) => {
logger.debug('List directory request:', data.path)
const validation = validatePath(data.path, workingDirectory)
const targetPath = data.path || '.'
const validation = validatePath(targetPath, workingDirectory)
if (!validation.valid) {
return rpcError(validation.error ?? 'Invalid directory path')
}
try {
const entries = await readdir(data.path, { withFileTypes: true })
const resolvedPath = resolve(workingDirectory, targetPath)
const entries = await readdir(resolvedPath, { withFileTypes: true })
const directoryEntries: DirectoryEntry[] = await Promise.all(
entries.map(async (entry) => {
const fullPath = join(data.path, entry.name)
const fullPath = join(resolvedPath, entry.name)
let type: 'file' | 'directory' | 'other' = 'other'
let size: number | undefined
let modified: number | undefined
@@ -65,14 +68,18 @@ export function registerDirectoryHandlers(rpcHandlerManager: RpcHandlerManager,
type = 'directory'
} else if (entry.isFile()) {
type = 'file'
} else if (entry.isSymbolicLink()) {
type = 'other'
}
try {
const stats = await stat(fullPath)
size = stats.size
modified = stats.mtime.getTime()
} catch (error) {
logger.debug(`Failed to stat ${fullPath}:`, error)
if (!entry.isSymbolicLink()) {
try {
const stats = await stat(fullPath)
size = stats.size
modified = stats.mtime.getTime()
} catch (error) {
logger.debug(`Failed to stat ${fullPath}:`, error)
}
}
return {
@@ -100,11 +107,15 @@ export function registerDirectoryHandlers(rpcHandlerManager: RpcHandlerManager,
rpcHandlerManager.registerHandler<GetDirectoryTreeRequest, GetDirectoryTreeResponse>('getDirectoryTree', async (data) => {
logger.debug('Get directory tree request:', data.path, 'maxDepth:', data.maxDepth)
const validation = validatePath(data.path, workingDirectory)
const targetPath = data.path || '.'
const validation = validatePath(targetPath, workingDirectory)
if (!validation.valid) {
return rpcError(validation.error ?? 'Invalid directory path')
}
const resolvedRoot = resolve(workingDirectory, targetPath)
async function buildTree(path: string, name: string, currentDepth: number): Promise<TreeNode | null> {
try {
const stats = await stat(path)
@@ -157,8 +168,8 @@ export function registerDirectoryHandlers(rpcHandlerManager: RpcHandlerManager,
return rpcError('maxDepth must be non-negative')
}
const baseName = data.path === '/' ? '/' : basename(data.path) || data.path
const tree = await buildTree(data.path, baseName, 0)
const baseName = resolvedRoot === '/' ? '/' : basename(resolvedRoot) || resolvedRoot
const tree = await buildTree(resolvedRoot, baseName, 0)
if (!tree) {
return rpcError('Failed to access the specified path')