mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-08-21 06:35:50 +00:00
feat(server-v2): add fs::browse and fs::home folder picker routes
- implement GET /api/v1/fs::browse and /api/v1/fs::home, matching the packages/server v1 wire contract (directory-only entries, git metadata, dot-last sorting, parent resolution, $HOME + recent workspace roots) - complete IHostFolderBrowser in agent-core-v2: realpath resolution, git detection, recent_roots from IWorkspaceRegistry, and HostFolder* domain errors mapped to protocol codes 40001/40409/40411 - register the routes behind a /fs:action semi-static path because find-my-way cannot register the literal /fs::browse / /fs::home paths
This commit is contained in:
parent
e083b923e5
commit
7d04f4f7dd
5 changed files with 478 additions and 18 deletions
|
|
@ -1,26 +1,69 @@
|
|||
/**
|
||||
* `hostFolderBrowser` domain (L1) — host-side folder picker.
|
||||
* `hostFolderBrowser` domain (L2) — host-side folder picker.
|
||||
*
|
||||
* Defines the `IHostFolderBrowser` used by the program side (TUI / server) to
|
||||
* let the user browse the real local filesystem when choosing a workspace
|
||||
* folder. Distinct from the Agent-side `agentFs`, which is sandboxed and may
|
||||
* be remote. Core-scoped.
|
||||
*
|
||||
* The wire shapes (`FsBrowseResponse` / `FsHomeResponse`) are sourced from
|
||||
* `@moonshot-ai/protocol` so the `/api/v1` and `/api/v2` transports share one
|
||||
* contract. Domain errors (`HostFolder*Error`) carry the failing path and are
|
||||
* translated to protocol error codes at the transport boundary.
|
||||
*/
|
||||
|
||||
import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
|
||||
import type { HostDirEntry } from '#/hostFs';
|
||||
|
||||
export interface FsBrowseResponse {
|
||||
import type { FsBrowseResponse, FsHomeResponse } from '@moonshot-ai/protocol';
|
||||
|
||||
export type { FsBrowseResponse, FsHomeResponse };
|
||||
|
||||
/** Thrown by `browse` when the requested path is not absolute. */
|
||||
export class HostFolderNotAbsoluteError extends Error {
|
||||
readonly path: string;
|
||||
readonly entries: readonly HostDirEntry[];
|
||||
constructor(path: string) {
|
||||
super(`path must be absolute: ${path}`);
|
||||
this.name = 'HostFolderNotAbsoluteError';
|
||||
this.path = path;
|
||||
}
|
||||
}
|
||||
|
||||
/** Thrown by `browse` when the requested path does not exist or is not a directory. */
|
||||
export class HostFolderNotFoundError extends Error {
|
||||
readonly path: string;
|
||||
constructor(path: string) {
|
||||
super(`path not found: ${path}`);
|
||||
this.name = 'HostFolderNotFoundError';
|
||||
this.path = path;
|
||||
}
|
||||
}
|
||||
|
||||
/** Thrown by `browse` when the process lacks permission to read the path. */
|
||||
export class HostFolderPermissionError extends Error {
|
||||
readonly path: string;
|
||||
constructor(path: string) {
|
||||
super(`permission denied: ${path}`);
|
||||
this.name = 'HostFolderPermissionError';
|
||||
this.path = path;
|
||||
}
|
||||
}
|
||||
|
||||
export interface IHostFolderBrowser {
|
||||
readonly _serviceBrand: undefined;
|
||||
|
||||
/**
|
||||
* List the immediate sub-directories of `absPath` (defaults to `$HOME`),
|
||||
* annotated with git metadata. The returned `path` is the realpath of the
|
||||
* target.
|
||||
*/
|
||||
browse(absPath?: string): Promise<FsBrowseResponse>;
|
||||
home(): Promise<string>;
|
||||
|
||||
/** `$HOME` plus the most recently opened workspace roots. */
|
||||
home(): Promise<FsHomeResponse>;
|
||||
}
|
||||
|
||||
export const IHostFolderBrowser: ServiceIdentifier<IHostFolderBrowser> =
|
||||
createDecorator<IHostFolderBrowser>('hostFolderBrowser');
|
||||
|
||||
/** Maximum number of recent workspace roots returned by `home()`. */
|
||||
export const RECENT_ROOTS_LIMIT = 8;
|
||||
|
|
|
|||
|
|
@ -1,35 +1,152 @@
|
|||
/**
|
||||
* `hostFolderBrowser` domain (L1) — `IHostFolderBrowser` implementation.
|
||||
* `hostFolderBrowser` domain (L2) — `IHostFolderBrowser` implementation.
|
||||
*
|
||||
* Browses the real local filesystem through the program-side `hostFs`
|
||||
* primitives. Bound at Core scope.
|
||||
* Browses the real local filesystem through `node:fs/promises` and derives
|
||||
* `recent_roots` from the process-wide `IWorkspaceRegistry`. Bound at Core
|
||||
* scope. Mirrors the v1 `WorkspaceFsService` behaviour so the `/api/v1`
|
||||
* transport stays wire-compatible: realpath resolution, directory-only
|
||||
* entries, git metadata, dot-last sorting, and `parent` resolution.
|
||||
*/
|
||||
|
||||
import { lstat, readFile, readdir, realpath } from 'node:fs/promises';
|
||||
import { homedir } from 'node:os';
|
||||
import { resolve } from 'node:path';
|
||||
import { dirname, isAbsolute, join } from 'node:path';
|
||||
|
||||
import type { FsBrowseEntry, FsBrowseResponse, FsHomeResponse } from '@moonshot-ai/protocol';
|
||||
|
||||
import { InstantiationType } from '#/_base/di/extensions';
|
||||
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
|
||||
import { IHostFileSystem } from '#/hostFs';
|
||||
import { IWorkspaceRegistry } from '#/workspaceRegistry';
|
||||
|
||||
import { type FsBrowseResponse, IHostFolderBrowser } from './hostFolderBrowser';
|
||||
import {
|
||||
HostFolderNotAbsoluteError,
|
||||
HostFolderNotFoundError,
|
||||
HostFolderPermissionError,
|
||||
IHostFolderBrowser,
|
||||
RECENT_ROOTS_LIMIT,
|
||||
} from './hostFolderBrowser';
|
||||
|
||||
export class HostFolderBrowser implements IHostFolderBrowser {
|
||||
declare readonly _serviceBrand: undefined;
|
||||
|
||||
constructor(@IHostFileSystem private readonly hostFs: IHostFileSystem) {}
|
||||
constructor(@IWorkspaceRegistry private readonly registry: IWorkspaceRegistry) {}
|
||||
|
||||
async browse(absPath?: string): Promise<FsBrowseResponse> {
|
||||
const path = resolve(absPath ?? homedir());
|
||||
const entries = await this.hostFs.readdir(path);
|
||||
return { path, entries };
|
||||
const target = absPath ?? homedir();
|
||||
if (!isAbsolute(target)) {
|
||||
throw new HostFolderNotAbsoluteError(target);
|
||||
}
|
||||
|
||||
let realTarget: string;
|
||||
try {
|
||||
realTarget = await realpath(target);
|
||||
} catch (err) {
|
||||
throw mapFsError(err, target);
|
||||
}
|
||||
|
||||
let dirents;
|
||||
try {
|
||||
dirents = await readdir(realTarget, { withFileTypes: true });
|
||||
} catch (err) {
|
||||
throw mapFsError(err, realTarget);
|
||||
}
|
||||
|
||||
const dirOnly = dirents.filter((d) => d.isDirectory());
|
||||
const entries: FsBrowseEntry[] = await Promise.all(
|
||||
dirOnly.map(async (d) => {
|
||||
const childAbs = join(realTarget, d.name);
|
||||
const git = await detectGit(childAbs);
|
||||
return {
|
||||
name: d.name,
|
||||
path: childAbs,
|
||||
is_dir: true as const,
|
||||
is_git_repo: git.is_git_repo,
|
||||
branch: git.branch ?? undefined,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
entries.sort(compareBrowseEntries);
|
||||
|
||||
const parent = dirname(realTarget);
|
||||
return {
|
||||
path: realTarget,
|
||||
parent: parent === realTarget ? null : parent,
|
||||
entries,
|
||||
};
|
||||
}
|
||||
|
||||
home(): Promise<string> {
|
||||
return Promise.resolve(homedir());
|
||||
async home(): Promise<FsHomeResponse> {
|
||||
const home = homedir();
|
||||
const workspaces = await this.registry.list();
|
||||
const recent_roots = workspaces.slice(0, RECENT_ROOTS_LIMIT).map((w) => w.root);
|
||||
return { home, recent_roots };
|
||||
}
|
||||
}
|
||||
|
||||
function mapFsError(err: unknown, path: string): Error {
|
||||
const code = (err as NodeJS.ErrnoException).code;
|
||||
if (code === 'ENOENT' || code === 'ENOTDIR') {
|
||||
return new HostFolderNotFoundError(path);
|
||||
}
|
||||
if (code === 'EACCES' || code === 'EPERM') {
|
||||
return new HostFolderPermissionError(path);
|
||||
}
|
||||
return err instanceof Error ? err : new Error(String(err));
|
||||
}
|
||||
|
||||
function compareBrowseEntries(a: FsBrowseEntry, b: FsBrowseEntry): number {
|
||||
const aDot = a.name.startsWith('.');
|
||||
const bDot = b.name.startsWith('.');
|
||||
if (aDot !== bDot) return aDot ? 1 : -1;
|
||||
return a.name.localeCompare(b.name);
|
||||
}
|
||||
|
||||
interface GitInfo {
|
||||
readonly is_git_repo: boolean;
|
||||
readonly branch: string | null;
|
||||
}
|
||||
|
||||
async function detectGit(root: string): Promise<GitInfo> {
|
||||
let dotGit;
|
||||
try {
|
||||
dotGit = await lstat(join(root, '.git'));
|
||||
} catch {
|
||||
return { is_git_repo: false, branch: null };
|
||||
}
|
||||
|
||||
let gitDir: string;
|
||||
if (dotGit.isDirectory()) {
|
||||
gitDir = join(root, '.git');
|
||||
} else if (dotGit.isFile()) {
|
||||
let text: string;
|
||||
try {
|
||||
text = await readFile(join(root, '.git'), 'utf8');
|
||||
} catch {
|
||||
return { is_git_repo: false, branch: null };
|
||||
}
|
||||
const m = /^gitdir:\s*(.+)$/m.exec(text);
|
||||
if (m === null) return { is_git_repo: false, branch: null };
|
||||
const ref = m[1] ?? '';
|
||||
if (ref === '') return { is_git_repo: false, branch: null };
|
||||
gitDir = ref.trim();
|
||||
if (!gitDir.startsWith('/')) {
|
||||
gitDir = join(root, gitDir);
|
||||
}
|
||||
} else {
|
||||
return { is_git_repo: false, branch: null };
|
||||
}
|
||||
|
||||
let head: string;
|
||||
try {
|
||||
head = (await readFile(join(gitDir, 'HEAD'), 'utf8')).trim();
|
||||
} catch {
|
||||
return { is_git_repo: true, branch: null };
|
||||
}
|
||||
const ref = /^ref:\s*refs\/heads\/(.+)$/.exec(head);
|
||||
return { is_git_repo: true, branch: ref ? (ref[1] ?? null) : null };
|
||||
}
|
||||
|
||||
registerScopedService(
|
||||
LifecycleScope.Core,
|
||||
IHostFolderBrowser,
|
||||
|
|
|
|||
|
|
@ -5,7 +5,8 @@
|
|||
* services from the `agent-core-v2` Core `Scope` instead of the v1 flat
|
||||
* `IInstantiationService`. v0.1 mounts the subset of routes that v2 can serve
|
||||
* end-to-end today (health, meta, auth readiness, OAuth device flow, config,
|
||||
* model/provider catalog, sessions, messages, approvals, workspaces, shutdown).
|
||||
* model/provider catalog, sessions, messages, approvals, workspaces, the fs
|
||||
* folder picker, shutdown).
|
||||
*/
|
||||
|
||||
import type { Scope } from '@moonshot-ai/agent-core-v2';
|
||||
|
|
@ -25,6 +26,7 @@ import { registerQuestionsRoutes } from './questions';
|
|||
import { registerSessionsRoutes } from './sessions';
|
||||
import { registerShutdownRoutes } from './shutdown';
|
||||
import { registerToolsRoutes } from './tools';
|
||||
import { registerWorkspaceFsRoutes } from './workspaceFs';
|
||||
import { registerWorkspacesRoutes } from './workspaces';
|
||||
|
||||
interface ApiV1AppHost {
|
||||
|
|
@ -94,6 +96,10 @@ export async function registerApiV1Routes(
|
|||
apiV1 as unknown as Parameters<typeof registerWorkspacesRoutes>[0],
|
||||
core,
|
||||
);
|
||||
registerWorkspaceFsRoutes(
|
||||
apiV1 as unknown as Parameters<typeof registerWorkspaceFsRoutes>[0],
|
||||
core,
|
||||
);
|
||||
registerFilesRoutes(apiV1 as unknown as Parameters<typeof registerFilesRoutes>[0], core);
|
||||
registerToolsRoutes(apiV1 as unknown as Parameters<typeof registerToolsRoutes>[0], core);
|
||||
registerShutdownRoutes(apiV1 as unknown as Parameters<typeof registerShutdownRoutes>[0], {
|
||||
|
|
|
|||
115
packages/server-v2/src/routes/workspaceFs.ts
Normal file
115
packages/server-v2/src/routes/workspaceFs.ts
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
/**
|
||||
* `/fs::browse` and `/fs::home` route handlers — server-v2 port.
|
||||
*
|
||||
* Implements the v1 folder-picker wire contract on top of `agent-core-v2`'s
|
||||
* `IHostFolderBrowser` (Core scope). The domain service owns the filesystem
|
||||
* work and returns protocol-shaped payloads; this module is a thin facade
|
||||
* that wraps results in the project envelope and translates domain errors to
|
||||
* protocol error codes:
|
||||
*
|
||||
* - `HostFolderNotAbsoluteError` → 40001 validation.failed
|
||||
* - `HostFolderNotFoundError` → 40409 fs.path_not_found
|
||||
* - `HostFolderPermissionError` → 40411 fs.permission_denied
|
||||
*
|
||||
* GET /fs::browse?path=<abs-path> list sub-directories (+ git metadata)
|
||||
* GET /fs::home $HOME + recent workspace roots
|
||||
*
|
||||
* **Why a single `/fs:action` semi-static route?** find-my-way (Fastify's
|
||||
* router) treats `:` as a parameter marker, so the literal v1 paths
|
||||
* `/fs::browse` / `/fs::home` cannot be registered directly — they collapse
|
||||
* into an unmatchable `/fs:browse` node. Registering `/fs:action` (static
|
||||
* prefix `fs` + `action` parameter) lets `/fs::browse` arrive with
|
||||
* `params.action === '::browse'`, which we dispatch on here.
|
||||
*/
|
||||
|
||||
import {
|
||||
HostFolderNotAbsoluteError,
|
||||
HostFolderNotFoundError,
|
||||
HostFolderPermissionError,
|
||||
IHostFolderBrowser,
|
||||
type Scope,
|
||||
} from '@moonshot-ai/agent-core-v2';
|
||||
import {
|
||||
ErrorCode,
|
||||
fsBrowseQuerySchema,
|
||||
fsBrowseResponseSchema,
|
||||
fsHomeResponseSchema,
|
||||
} from '@moonshot-ai/protocol';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { errEnvelope, okEnvelope } from '../envelope';
|
||||
import { defineRoute } from '../middleware/defineRoute';
|
||||
|
||||
const fsActionParamsSchema = z.object({ action: z.string() });
|
||||
|
||||
const detailsSchema = z.array(z.object({ path: z.string(), message: z.string() }));
|
||||
|
||||
interface WorkspaceFsRouteHost {
|
||||
get(
|
||||
path: string,
|
||||
options: { preHandler: unknown[]; schema?: Record<string, unknown> } | undefined,
|
||||
handler: (
|
||||
req: { id: string; params: { action: string }; query: { path?: string } },
|
||||
reply: { send(payload: unknown): unknown },
|
||||
) => Promise<void> | void,
|
||||
): unknown;
|
||||
}
|
||||
|
||||
export function registerWorkspaceFsRoutes(app: WorkspaceFsRouteHost, core: Scope): void {
|
||||
const route = defineRoute(
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/fs:action',
|
||||
params: fsActionParamsSchema,
|
||||
querystring: fsBrowseQuerySchema,
|
||||
success: { data: z.union([fsBrowseResponseSchema, fsHomeResponseSchema]) },
|
||||
errors: {
|
||||
[ErrorCode.VALIDATION_FAILED]: { detailsSchema },
|
||||
[ErrorCode.FS_PATH_NOT_FOUND]: {},
|
||||
[ErrorCode.FS_PERMISSION_DENIED]: {},
|
||||
},
|
||||
description:
|
||||
'Folder picker backend. Use GET /fs::browse?path=<abs-path> to list sub-directories, or GET /fs::home for $HOME + recent workspace roots.',
|
||||
tags: ['workspaces'],
|
||||
operationId: 'fsAction',
|
||||
},
|
||||
async (req, reply) => {
|
||||
const action = req.params.action;
|
||||
if (action !== '::browse' && action !== '::home') {
|
||||
reply.send(errEnvelope(ErrorCode.FS_PATH_NOT_FOUND, `unknown fs action: ${action}`, req.id));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const browser = core.accessor.get(IHostFolderBrowser);
|
||||
const data =
|
||||
action === '::browse'
|
||||
? await browser.browse(req.query.path)
|
||||
: await browser.home();
|
||||
reply.send(okEnvelope(data, req.id));
|
||||
} catch (err) {
|
||||
sendMappedError(reply, req.id, err);
|
||||
}
|
||||
},
|
||||
);
|
||||
app.get(route.path, route.options, route.handler as Parameters<WorkspaceFsRouteHost['get']>[2]);
|
||||
}
|
||||
|
||||
function sendMappedError(
|
||||
reply: { send(payload: unknown): unknown },
|
||||
requestId: string,
|
||||
err: unknown,
|
||||
): void {
|
||||
if (err instanceof HostFolderNotAbsoluteError) {
|
||||
reply.send(errEnvelope(ErrorCode.VALIDATION_FAILED, err.message, requestId));
|
||||
return;
|
||||
}
|
||||
if (err instanceof HostFolderNotFoundError) {
|
||||
reply.send(errEnvelope(ErrorCode.FS_PATH_NOT_FOUND, err.message, requestId));
|
||||
return;
|
||||
}
|
||||
if (err instanceof HostFolderPermissionError) {
|
||||
reply.send(errEnvelope(ErrorCode.FS_PERMISSION_DENIED, err.message, requestId));
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
179
packages/server-v2/test/workspaceFs.test.ts
Normal file
179
packages/server-v2/test/workspaceFs.test.ts
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
import { homedir, tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { mkdir, mkdtemp, realpath, rm, writeFile } from 'node:fs/promises';
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { type RunningServer, startServer } from '../src/start';
|
||||
|
||||
interface Envelope<T> {
|
||||
code: number;
|
||||
msg: string;
|
||||
data: T;
|
||||
request_id: string;
|
||||
details?: { path: string; message: string }[];
|
||||
}
|
||||
|
||||
interface BrowseEntryWire {
|
||||
name: string;
|
||||
path: string;
|
||||
is_dir: true;
|
||||
is_git_repo: boolean;
|
||||
branch?: string;
|
||||
}
|
||||
|
||||
interface BrowseWire {
|
||||
path: string;
|
||||
parent: string | null;
|
||||
entries: BrowseEntryWire[];
|
||||
}
|
||||
|
||||
interface HomeWire {
|
||||
home: string;
|
||||
recent_roots: string[];
|
||||
}
|
||||
|
||||
describe('server-v2 /api/v1 fs folder picker', () => {
|
||||
let server: RunningServer | undefined;
|
||||
let home: string | undefined;
|
||||
let base: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
home = await mkdtemp(join(tmpdir(), 'kimi-server-v2-fs-'));
|
||||
server = await startServer({
|
||||
host: '127.0.0.1',
|
||||
port: 0,
|
||||
homeDir: home,
|
||||
logLevel: 'silent',
|
||||
});
|
||||
base = `http://127.0.0.1:${server.port}`;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (server !== undefined) {
|
||||
await server.close();
|
||||
server = undefined;
|
||||
}
|
||||
if (home !== undefined) {
|
||||
await rm(home, { recursive: true, force: true });
|
||||
home = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
async function getJson<T>(path: string): Promise<{ status: number; body: Envelope<T> }> {
|
||||
const res = await fetch(`${base}${path}`);
|
||||
return { status: res.status, body: (await res.json()) as Envelope<T> };
|
||||
}
|
||||
|
||||
async function postJson<T>(
|
||||
path: string,
|
||||
body?: unknown,
|
||||
): Promise<{ status: number; body: Envelope<T> }> {
|
||||
const hasBody = body !== undefined;
|
||||
const res = await fetch(`${base}${path}`, {
|
||||
method: 'POST',
|
||||
headers: hasBody ? { 'content-type': 'application/json' } : undefined,
|
||||
body: hasBody ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
return { status: res.status, body: (await res.json()) as Envelope<T> };
|
||||
}
|
||||
|
||||
it('defaults browse to $HOME when path is omitted', async () => {
|
||||
const { status, body } = await getJson<BrowseWire>('/api/v1/fs::browse');
|
||||
expect(status).toBe(200);
|
||||
expect(body.code).toBe(0);
|
||||
expect(body.data.path).toBe(await realpath(homedir()));
|
||||
expect(typeof body.data.parent === 'string' || body.data.parent === null).toBe(true);
|
||||
expect(Array.isArray(body.data.entries)).toBe(true);
|
||||
});
|
||||
|
||||
it('lists only directories and filters files', async () => {
|
||||
const root = home as string;
|
||||
await mkdir(join(root, 'alpha'));
|
||||
await mkdir(join(root, 'beta'));
|
||||
await writeFile(join(root, 'README.md'), 'hi');
|
||||
|
||||
const { body } = await getJson<BrowseWire>(
|
||||
`/api/v1/fs::browse?path=${encodeURIComponent(root)}`,
|
||||
);
|
||||
expect(body.code).toBe(0);
|
||||
expect(body.data.path).toBe(await realpath(root));
|
||||
const names = body.data.entries.map((e) => e.name).sort();
|
||||
expect(names).toEqual(['alpha', 'beta']);
|
||||
for (const entry of body.data.entries) {
|
||||
expect(entry.is_dir).toBe(true);
|
||||
expect(entry.path).toBe(join(await realpath(root), entry.name));
|
||||
expect(typeof entry.is_git_repo).toBe('boolean');
|
||||
}
|
||||
});
|
||||
|
||||
it('annotates git repositories with the current branch', async () => {
|
||||
const root = home as string;
|
||||
const repo = join(root, 'repo');
|
||||
await mkdir(join(repo, '.git'), { recursive: true });
|
||||
await writeFile(join(repo, '.git', 'HEAD'), 'ref: refs/heads/main\n');
|
||||
await mkdir(join(root, 'plain'));
|
||||
|
||||
const { body } = await getJson<BrowseWire>(
|
||||
`/api/v1/fs::browse?path=${encodeURIComponent(root)}`,
|
||||
);
|
||||
expect(body.code).toBe(0);
|
||||
const byName = new Map(body.data.entries.map((e) => [e.name, e]));
|
||||
expect(byName.get('repo')?.is_git_repo).toBe(true);
|
||||
expect(byName.get('repo')?.branch).toBe('main');
|
||||
expect(byName.get('plain')?.is_git_repo).toBe(false);
|
||||
expect(byName.get('plain')?.branch).toBeUndefined();
|
||||
});
|
||||
|
||||
it('sorts dot-directories after regular ones', async () => {
|
||||
const root = home as string;
|
||||
await mkdir(join(root, '.zeta'));
|
||||
await mkdir(join(root, 'alpha'));
|
||||
|
||||
const { body } = await getJson<BrowseWire>(
|
||||
`/api/v1/fs::browse?path=${encodeURIComponent(root)}`,
|
||||
);
|
||||
expect(body.code).toBe(0);
|
||||
expect(body.data.entries.map((e) => e.name)).toEqual(['alpha', '.zeta']);
|
||||
});
|
||||
|
||||
it('returns parent=null for the filesystem root', async () => {
|
||||
const { body } = await getJson<BrowseWire>('/api/v1/fs::browse?path=%2F');
|
||||
expect(body.code).toBe(0);
|
||||
expect(body.data.path).toBe('/');
|
||||
expect(body.data.parent).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects a relative path (40001)', async () => {
|
||||
const { body } = await getJson<null>(
|
||||
`/api/v1/fs::browse?path=${encodeURIComponent('relative/path')}`,
|
||||
);
|
||||
expect(body.code).toBe(40001);
|
||||
});
|
||||
|
||||
it('rejects a nonexistent path (40409)', async () => {
|
||||
const missing = join(home as string, 'does-not-exist');
|
||||
const { body } = await getJson<null>(
|
||||
`/api/v1/fs::browse?path=${encodeURIComponent(missing)}`,
|
||||
);
|
||||
expect(body.code).toBe(40409);
|
||||
});
|
||||
|
||||
it('returns an empty recent_roots when no workspaces are registered', async () => {
|
||||
const { status, body } = await getJson<HomeWire>('/api/v1/fs::home');
|
||||
expect(status).toBe(200);
|
||||
expect(body.code).toBe(0);
|
||||
expect(body.data.home).toBe(homedir());
|
||||
expect(body.data.recent_roots).toEqual([]);
|
||||
});
|
||||
|
||||
it('reflects registered workspace roots in recent_roots', async () => {
|
||||
const root = home as string;
|
||||
const created = await postJson<{ id: string }>('/api/v1/workspaces', { root });
|
||||
expect(created.body.code).toBe(0);
|
||||
|
||||
const { body } = await getJson<HomeWire>('/api/v1/fs::home');
|
||||
expect(body.code).toBe(0);
|
||||
expect(body.data.recent_roots).toContain(root);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue