feat(fs): add directory listing seam

This commit is contained in:
Yichen Jiang
2026-07-03 14:36:20 +08:00
parent d8ea99756d
commit 803ed4bd95
15 changed files with 268 additions and 23 deletions

View File

@@ -20,8 +20,8 @@
import { randomUUID } from 'node:crypto'
import { createReadStream } from 'node:fs'
import { chmod, mkdir, open, readFile, realpath, rename, rm, stat } from 'node:fs/promises'
import type { Stats } from 'node:fs'
import { chmod, mkdir, open, readFile, realpath, readdir, rename, rm, stat } from 'node:fs/promises'
import type { Dirent, Stats } from 'node:fs'
import { basename, dirname, join, resolve } from 'node:path'
import { TextDecoder } from 'node:util'
import { FsError, FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs'
@@ -55,6 +55,12 @@ function errorMessage(error: unknown): string {
}
/* v8 ignore stop */
/* v8 ignore start -- requires permission/kernel failures from readdir after a successful directory stat. */
function isPermissionError(error: unknown): boolean {
return error instanceof Error && 'code' in error && (error.code === 'EACCES' || error.code === 'EPERM')
}
/* v8 ignore stop */
function throwIfAborted(signal: AbortSignal | undefined, verb: string): void {
if (signal?.aborted) throw new FsError(`${verb} aborted`, 'FS_ABORTED')
}
@@ -112,6 +118,15 @@ export interface PathInfo {
size: number
}
/** One local directory child with a resolved target and cheap metadata. */
export interface LocalDirEntry {
name: string
type: 'file' | 'directory' | 'other'
target: LocalTarget
version?: FsVersion
size?: number
}
/**
* Resolve a path to its absolute display path and realpath identity. Relative
* paths are based on `cwd`. When the file itself does not yet exist, the
@@ -172,6 +187,51 @@ export async function probe(absolutePath: string): Promise<PathInfo | null> {
}
}
// --- Directory listing ---
/* v8 ignore start -- requires permission/kernel failures from readdir after a successful directory stat. */
function listingIoError(displayPath: string, error: unknown): FsError {
if (isENOENT(error) || isENOTDIR(error)) return new FsError(`cannot list "${displayPath}": not found`, 'FS_NOT_FOUND', { cause: error })
if (isPermissionError(error)) return new FsError(`cannot list "${displayPath}": permission denied`, 'FS_PERMISSION_DENIED', { cause: error })
return new FsError(`cannot list "${displayPath}": ${errorMessage(error)}`, 'FS_IO_ERROR', { cause: error })
}
/* v8 ignore stop */
/**
* List direct children of a directory in stable name order. Each child includes
* a resolved target plus stat metadata when still available; file contents are
* never read.
*/
export async function listDirectory(target: LocalTarget, signal?: AbortSignal): Promise<LocalDirEntry[]> {
throwIfAborted(signal, 'list')
const info = await probe(target.targetKey)
if (!info) throw new FsError(`cannot list "${target.displayPath}": not found`, 'FS_NOT_FOUND')
if (info.type !== 'directory') throw new FsError(`cannot list "${target.displayPath}": not a directory`, 'FS_NOT_DIRECTORY')
let entries: Dirent[]
try {
entries = await readdir(target.targetKey, { withFileTypes: true, encoding: 'utf8' })
} catch (error: unknown) {
/* v8 ignore next -- requires permission/kernel failure from readdir after a successful directory stat. */
throw listingIoError(target.displayPath, error)
}
throwIfAborted(signal, 'list')
return await Promise.all(entries
.sort((left, right) => left.name.localeCompare(right.name))
.map(async (entry): Promise<LocalDirEntry> => {
const childTarget = await resolveLocalTarget(target.displayPath, entry.name)
const childInfo = await probe(childTarget.targetKey)
return {
name: entry.name,
type: childInfo?.type ?? 'other',
target: childTarget,
...(childInfo ? { version: childInfo.version } : {}),
...(childInfo?.type === 'file' ? { size: childInfo.size } : {}),
}
}))
}
// --- Reading ---
function notTextError(verb: 'read' | 'edit', displayPath: string): FsError {

View File

@@ -1,6 +1,6 @@
/**
* Local-filesystem implementation of the `ctx.fs` provider seam.
* {@link LocalFileSystem} subclasses {@link FileSystem} and backs the six
* {@link LocalFileSystem} subclasses {@link FileSystem} and backs the seven
* text-storage primitives with the host filesystem via
* {@link module:@deepseek-ai/dsh-fs-local/fsio}. Path resolution uses
* `realpath`, so the stable `targetKey` is the real file identity (two input
@@ -17,6 +17,7 @@ import { Context } from 'cordis'
import z from 'schemastery'
import { FileSystem, FsError, FsVersion } from '@deepseek-ai/dsh-fs'
import type {
FsDirEntry,
FsEditOutcome,
FsEditRequest,
FsInfo,
@@ -26,6 +27,7 @@ import type {
} from '@deepseek-ai/dsh-fs'
import {
applyLiteralEdit,
listDirectory,
probe,
readForEdit,
readWholeText,
@@ -39,6 +41,7 @@ import type { FsIoInternals } from './fsio.ts'
export {
STREAM_MIN_SIZE,
applyLiteralEdit,
listDirectory,
probe,
readForEdit,
readWholeText,
@@ -47,7 +50,7 @@ export {
streamWholeText,
writeFileAtomic,
} from './fsio.ts'
export type { FsIoInternals, LineEndings, LocalTarget, PathInfo } from './fsio.ts'
export type { FsIoInternals, LineEndings, LocalDirEntry, LocalTarget, PathInfo } from './fsio.ts'
/** Configuration for the local filesystem backend. */
export interface Config {
@@ -117,6 +120,17 @@ export class LocalFileSystem extends FileSystem {
return Promise.resolve(streamWholeText({ displayPath: target.displayPath, targetKey: target.targetKey }, signal))
}
override async listDir(target: FsTarget, signal?: AbortSignal): Promise<FsDirEntry[]> {
const entries = await listDirectory({ displayPath: target.displayPath, targetKey: target.targetKey }, signal)
return entries.map(entry => ({
name: entry.name,
type: entry.type,
target: { inputPath: entry.target.displayPath, targetKey: entry.target.targetKey, displayPath: entry.target.displayPath },
...(entry.version !== undefined ? { version: entry.version } : {}),
...(entry.size !== undefined ? { size: entry.size } : {}),
}))
}
override async writeText(
target: FsTarget,
content: string,