feat(picker): New folder button in the path picker, on for Link Existing

The picker could only choose an existing folder. POST /api/filesystem/mkdir
creates one folder inside a folder the picker may browse (same roots,
ownership and blocked-tree rules as browse), and PathPicker gains an opt-in
allowNewFolder that adds a New folder button and selects the result.
This commit is contained in:
Devvyn committed 2026-10-31 18:26:00 +08:00
1 parent f1f7eec4d6
commit d5e4fe0f87
9 files changed
+401 -4

No files matched your search

+1 -1
View File
@@ -230,7 +230,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
The terminal provider scans `terminalLinkLine()`, which extends `terminalPathLine()` for URL continuations in indented paragraphs and table cells. List markers, bare numbers, version tokens, whole email addresses, labels ending in `:`, measurements and clock times (`45%`, `12.3ms`, `10:00:00`) and a `name=value` field under a URL ending in `/` never continue a URL; a token with only `-`, `_` or `.` is also refused when it carries a call paren (`client.get()`) or ends a sentence (`Done.`). A URL in the last column of a space-aligned table never takes the next row's first cell: a continuation row must start inside the URL's own cell, so a gap of two or more spaces between its indent and where the URL starts on the row above means it is the next record. `terminalCellWrappedPaths()` follows each table cell independently: a fragment carries on into the next row only when it ends its cell on `/` or `-` (where a wrapper breaks a path; codex's cell wrap never breaks at `_`), the continuation row is blank to the left of the cell (a wrapper wraps every column of a table row together, so a label there such as `Entry` or `│ Log │` is the next record, unless a left column's path continued on that row too; a left column of wrapped prose, as in a `Description | Path` table, fills that row as well, so the path beside it keeps its per-row fragment links, the safe direction), the next row's token passes `pathContinuation()`, and the cell is no wider than the one above. So a directory that is a whole cell value (`/home/u/proj` over `config.yaml`, or `/home/u/proj/app/` over a labelled `main.py`) keeps its folder link and the next row keeps its own file link. With no label to tell them apart, a directory written with its trailing slash over a relative path in the same column (`/home/u/proj/src/` over `lib/util.ts`) still joins, a known trade-off: it reads exactly like a wrap at `/`. Accepted paths displace other links on their cells. Full-width divider rows stop `terminalLogicalLine()` stitching for both links and touch selection. OSC 8 links confirm the real target URI before the shared opener runs.
**Filesystem path picker** (Link Existing "Browse" + the mobile keyboard's `📁 Path` key): lazy one-directory browsing via `GET /api/filesystem/browse`, with `GET /api/filesystem/preview` for the tapped file. Inserts the path **without** Enter, so the prompt is never submitted; the sibling `⌫ All` key clears only the unsent prompt and must never send the agent's `/clear`. ⚠️ This is a **second file-serving surface and inherits neither the attachment confinement nor its ownership scoping** — it allowlists Home, `CASES_DIR`, `/mnt/d` and `CODEMAN_FILE_PICKER_ROOTS`, blocks sensitive trees, and rejects symlink escapes **after** `realpath`. ⚠️ The optional `sessionId` is an ownership boundary that must be `canAccessOwned`-checked by hand (it does not go through `findSessionOrFail`), and in multi-user mode a non-admin gets only their own `userSpacePath` as a root: per-user spaces live INSIDE `homedir()`, so a `Home` root exposes every other user's workspace. Previews go through the same global conversion limiter, and Markdown/TXT/JSON are served as inert `text/plain`. → [architecture-invariants#filesystem-path-picker](docs/architecture-invariants.md#filesystem-path-picker)
**Filesystem path picker** (Link Existing "Browse" + the mobile keyboard's `📁 Path` key): lazy one-directory browsing via `GET /api/filesystem/browse`, with `GET /api/filesystem/preview` for the tapped file. Inserts the path **without** Enter, so the prompt is never submitted; the sibling `⌫ All` key clears only the unsent prompt and must never send the agent's `/clear`. ⚠️ This is a **second file-serving surface and inherits neither the attachment confinement nor its ownership scoping** — it allowlists Home, `CASES_DIR`, `/mnt/d` and `CODEMAN_FILE_PICKER_ROOTS`, blocks sensitive trees, and rejects symlink escapes **after** `realpath`. ⚠️ The optional `sessionId` is an ownership boundary that must be `canAccessOwned`-checked by hand (it does not go through `findSessionOrFail`), and in multi-user mode a non-admin gets only their own `userSpacePath` as a root: per-user spaces live INSIDE `homedir()`, so a `Home` root exposes every other user's workspace. Previews go through the same global conversion limiter, and Markdown/TXT/JSON are served as inert `text/plain`. **New folder** (opt-in per caller, `allowNewFolder`; Link Existing has it): `POST /api/filesystem/mkdir` `{ path, name, sessionId? }` creates ONE folder (non-recursive) directly inside a folder the picker may browse, resolved through the same roots, ownership and blocked-tree rules as browse (`403` outside them or in a blocked tree or unwritable, `404` missing parent, `409` exists, `400` a name with a separator, `.`/`..` or a leading dot); the picker reloads the folder and selects the new one. A picker that browses somewhere this route cannot create in (the container-workdir picker, `fetchListing`) must not set the flag. → [architecture-invariants#filesystem-path-picker](docs/architecture-invariants.md#filesystem-path-picker)
**File Viewer edit mode** (issue #212): the file-preview overlay edits workspace text files in place — `GET .../file-content?edit=1` + `PUT /api/sessions/:id/file-content`, policy in `src/config/file-editing.ts`. This is a **third file surface and the only one that WRITES**: read-path confinement (realpath + workspace + ownership) plus sensitive/blocked/`.git` denies and an extension **allowlist**; writes are `wx`-temp + rename (no `O_CREAT` anywhere = edit-in-place is structural); optimistic concurrency via sha256 `baseHash` → 409. ⚠️ `edit=1` never truncates and the client must never save a plain-preview buffer (the 500-line truncation would silently delete the rest). ⚠️ CRLF/UTF-8 guards: EOL re-applied server-side, non-UTF-8 refused via round-trip compare. → [architecture-invariants#file-viewer-edit-mode](docs/architecture-invariants.md#file-viewer-edit-mode), `docs/file-viewer-edit-plan.md`
+1 -1
View File
@@ -429,7 +429,7 @@ The terminal provider scans `terminalLinkLine()`, which extends `terminalPathLin
### Filesystem path picker
**Filesystem path picker** (Link Existing "Browse" button + the extended mobile keyboard's `📁 Path` key): a lazy one-directory-at-a-time browser over `GET /api/filesystem/browse`, with `GET /api/filesystem/preview` serving the tapped file. It starts at the active session's working directory (falling back to the Codeman Cases root, then `/mnt/d`, then the first root), hides dot entries, and inserts the chosen path **without** Enter so the prompt is not submitted. The companion `⌫ All` key clears only the current unsent prompt buffer and must never emit the agent's `/clear` command.
**Filesystem path picker** (Link Existing "Browse" button + the extended mobile keyboard's `📁 Path` key): a lazy one-directory-at-a-time browser over `GET /api/filesystem/browse`, with `GET /api/filesystem/preview` serving the tapped file. It starts at the active session's working directory (falling back to the Codeman Cases root, then `/mnt/d`, then the first root), hides dot entries, and inserts the chosen path **without** Enter so the prompt is not submitted. The companion `⌫ All` key clears only the current unsent prompt buffer and must never emit the agent's `/clear` command. **New folder** (opt-in per caller, `allowNewFolder`; Link Existing has it): `POST /api/filesystem/mkdir` `{ path, name, sessionId? }` creates ONE folder (non-recursive) directly inside a folder the picker may browse, resolved through the same roots, ownership and blocked-tree rules as browse (`403` outside them or in a blocked tree or unwritable, `404` missing parent, `409` exists, `400` a name with a separator, `.`/`..` or a leading dot); the picker reloads the folder and selects the new one. A picker that browses somewhere this route cannot create in (the container-workdir picker, `fetchListing`) must not set the flag.
⚠️ **This is a second file-serving surface, so it carries the same confinement burden as [Attachments](#attachments) and does not inherit it automatically.** Traversal is allowlisted to Home, `CASES_DIR`, `/mnt/d`, or extra roots explicitly configured via `CODEMAN_FILE_PICKER_ROOTS`; sensitive trees are blocked and symlink escapes are rejected after `realpath` resolution rather than before. Without the realpath step a symlink inside an allowed root would walk straight out of it. `preview` reuses the shared conversion cache and the **global** `document-conversion-limiter`, which is what stops N concurrent large-document previews from forking N multi-minute converter processes. Content types are pinned: images and PDF inline, DOCX/PPTX through the converters, and Markdown/TXT/JSON as inert `text/plain` (never `text/html`, which would be stored XSS on our own origin). Size caps are 2MB for text and 50MB for binary/document previews.
+62 -1
View File
@@ -80,7 +80,7 @@ const PathPicker = {
/**
* Open the lazy filesystem browser.
* @param {{sessionId?: string, initialPath?: string, directoriesOnly?: boolean,
* title?: string, onSelect: (path: string) => void}} options
* allowNewFolder?: boolean, title?: string, onSelect: (path: string) => void}} options
*/
open(options) {
this.close(false);
@@ -114,9 +114,15 @@ const PathPicker = {
<input type="text" class="path-picker-current" aria-label="Current folder path" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false" enterkeyhint="go">
<button type="submit" class="path-picker-go" title="Go to this path" aria-label="Go to this path">Go</button>
</form>
<button type="button" class="path-picker-newfolder" title="New folder" aria-label="New folder" hidden>+&#x1F4C1;</button>
<button type="button" class="path-picker-hidden" title="Show hidden files and folders" aria-label="Show hidden files and folders" aria-pressed="false">.*</button>
<button type="button" class="path-picker-refresh" title="Refresh" aria-label="Refresh">&#x21BB;</button>
</div>
<form class="path-picker-newfolder-form" hidden>
<input type="text" class="path-picker-newfolder-name" aria-label="New folder name" placeholder="New folder name" maxlength="255" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false" enterkeyhint="done">
<button type="submit" class="path-picker-newfolder-create">Create</button>
<button type="button" class="path-picker-newfolder-cancel">Cancel</button>
</form>
<div class="path-picker-toolbar">
<div class="path-picker-status" aria-live="polite">Loading...</div>
<label class="path-picker-sort-label">Sort
@@ -147,6 +153,7 @@ const PathPicker = {
overlay.querySelector('.path-picker-refresh').addEventListener('click', () => this.load(this._currentPath));
overlay.querySelector('.path-picker-hidden').addEventListener('click', () => this.toggleHidden());
this._syncHiddenButton();
this._wireNewFolder(overlay, !!options.allowNewFolder);
// Typing a path is the fast way there. The listing is loaded ONLY on Enter/Go,
// never on each keystroke: a half-typed path is a 404 the server has to
// answer for nothing, and jumping mid-edit would yank the field around.
@@ -183,6 +190,60 @@ const PathPicker = {
this.load(options.initialPath || '');
},
/**
* "New folder": opt-in per caller (`allowNewFolder`), so a picker that browses somewhere the host
* filesystem route cannot create in (the container-workdir picker) never shows it. The folder is made
* inside the folder being viewed and is selected once it exists.
*/
_wireNewFolder(overlay, enabled) {
const button = overlay.querySelector('.path-picker-newfolder');
const form = overlay.querySelector('.path-picker-newfolder-form');
const input = overlay.querySelector('.path-picker-newfolder-name');
if (!enabled) return;
button.hidden = false;
const close = () => {
form.hidden = true;
input.value = '';
};
button.addEventListener('click', () => {
form.hidden = !form.hidden;
if (!form.hidden) input.focus();
});
overlay.querySelector('.path-picker-newfolder-cancel').addEventListener('click', close);
input.addEventListener('keydown', (event) => {
// Escape closes the form first; the document handler would close the whole picker.
if (event.key !== 'Escape') return;
event.preventDefault();
event.stopPropagation();
close();
});
form.addEventListener('submit', async (event) => {
event.preventDefault();
const name = input.value.trim();
if (!name || !this._currentPath) return;
const status = overlay.querySelector('.path-picker-status');
const parent = this._currentPath;
try {
const res = await fetch('/api/filesystem/mkdir', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: parent, name, sessionId: this._options?.sessionId || undefined }),
});
const result = await res.json();
if (!result?.success) throw new Error(result?.error || 'Could not create the folder');
if (!this.overlay) return;
close();
await this.load(parent);
// The listing may have moved on while the request ran; only select it if it is still on screen.
if (this._currentPath === parent) this.select(result.data.path);
} catch (error) {
if (!this.overlay) return;
status.textContent = error.message || 'Could not create the folder';
status.classList.add('error');
}
});
},
_loadShowHidden() {
try {
return localStorage.getItem(PATH_PICKER_SHOW_HIDDEN_KEY) === '1';
+1
View File
@@ -4315,6 +4315,7 @@ Object.assign(CodemanApp.prototype, {
title: 'Select Existing Project Folder',
initialPath: pathInput.value.trim(),
directoriesOnly: true,
allowNewFolder: true,
onSelect: (path) => {
pathInput.value = path;
const nameInput = document.getElementById('linkCaseName');
+40
View File
@@ -12140,6 +12140,45 @@ html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="cat
gap: 0.25rem;
}
.path-picker-newfolder {
flex-basis: auto;
padding: 0 10px;
white-space: nowrap;
}
.path-picker-newfolder[hidden],
.path-picker-newfolder-form[hidden] {
display: none;
}
.path-picker-newfolder-form {
display: flex;
gap: 8px;
padding: 0 12px 10px;
}
.path-picker-newfolder-name {
flex: 1 1 auto;
min-width: 0;
height: 38px;
padding: 0 10px;
color: var(--text);
background: var(--bg-input);
border: 1px solid var(--border);
border-radius: 8px;
}
.path-picker-newfolder-create,
.path-picker-newfolder-cancel {
height: 38px;
padding: 0 12px;
color: var(--text);
background: var(--bg-input);
border: 1px solid var(--border);
border-radius: 8px;
cursor: pointer;
}
/* Show-hidden toggle: a literal `.*` glyph rather than an icon, so its meaning
* (dot-prefixed files and folders) survives every skin and font stack. */
.btn-file-browser-hidden {
@@ -15764,6 +15803,7 @@ body.touch-device.cjk-input-visible .main {
.path-picker-up,
.path-picker-refresh,
.path-picker-newfolder,
.path-picker-hidden {
flex: 0 0 38px;
height: 38px;
+39 -1
View File
@@ -74,7 +74,12 @@ import { parseByteRange } from '../http-range.js';
import { isSensitivePath } from '../sensitive-path.js';
import { SseEvent } from '../sse-events.js';
import type { ConfigPort, EventPort, SessionPort } from '../ports/index.js';
import { FilesystemBrowseQuerySchema, FilesystemPreviewQuerySchema, FileWriteSchema } from '../schemas.js';
import {
FilesystemBrowseQuerySchema,
FilesystemMkdirSchema,
FilesystemPreviewQuerySchema,
FileWriteSchema,
} from '../schemas.js';
import {
MAX_EDITABLE_BYTES,
applyEol,
@@ -1449,6 +1454,39 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
});
// Inline preview for files selected through the root-confined filesystem picker.
// "New folder" in the path picker: creates ONE folder directly inside a folder the picker may browse.
app.post('/api/filesystem/mkdir', async (req, reply): Promise<ApiResponse<{ path: string }>> => {
const { path: parentPath, name, sessionId } = parseBody(FilesystemMkdirSchema, req.body);
const { candidatePath, resolvedPath, blockedTrees } = await resolveFilesystemPickerPath(
ctx,
req,
parentPath,
sessionId
);
const target = join(resolvedPath, name);
if (isBlockedPickerPath(resolvedPath, blockedTrees, true) || isBlockedPickerPath(target, blockedTrees, true)) {
reply.code(403);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Folders cannot be created here');
}
try {
// Not recursive: the parent was just resolved inside an allowed root, and an existing folder must be a 409.
await fs.mkdir(target);
} catch (err) {
const code = (err as NodeJS.ErrnoException).code;
if (code === 'EEXIST') {
reply.code(409);
return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, `"${name}" already exists in this folder`);
}
if (code === 'EACCES' || code === 'EPERM' || code === 'EROFS') {
reply.code(403);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'This folder is not writable');
}
reply.code(500);
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Could not create the folder: ${getErrorMessage(err)}`);
}
return { success: true, data: { path: join(candidatePath, name) } };
});
app.get('/api/filesystem/preview', { compress: false }, async (req, reply): Promise<void> => {
const { path: requestedPath, sessionId, showHidden } = parseBody(FilesystemPreviewQuerySchema, req.query);
const { candidatePath, resolvedPath, blockedTrees } = await resolveFilesystemPickerPath(
+22
View File
@@ -82,6 +82,28 @@ const filesystemPickerPathSchema = z
*/
const showHiddenQuerySchema = z.enum(['true', 'false']).optional();
/**
* Body for the path picker's "New folder": one folder name created directly inside `path`. The name is a
* single path segment (no separator, no `.`/`..`, not dot-prefixed, which the picker hides by default).
*/
export const FilesystemMkdirSchema = z.object({
path: filesystemPickerPathSchema,
name: z
.string()
.trim()
.min(1, 'Folder name is required')
.max(255)
.refine((n) => !/[/\\\0\n\r]/.test(n), { message: 'Folder name cannot contain slashes or control characters' })
.refine((n) => n !== '.' && n !== '..' && !n.startsWith('.'), {
message: 'Folder name cannot start with a dot',
}),
sessionId: z
.string()
.max(100)
.regex(/^[a-zA-Z0-9_-]+$/, 'Invalid session id')
.optional(),
});
/** Query validation for the lazy, allowlisted filesystem path picker. */
export const FilesystemBrowseQuerySchema = z.object({
path: filesystemPickerPathSchema.optional(),
+154
View File
@@ -0,0 +1,154 @@
/**
* @fileoverview PathPicker "New folder" (opt-in via `allowNewFolder`). Same jsdom harness as
* path-picker-hidden.test.ts (see its header for why the environment is built by hand).
*
* What can silently go wrong: the button showing for a picker that never asked for it (the
* container-workdir picker cannot create on the host), the folder made in the wrong place, the new
* folder not selected afterwards, and Escape closing the whole picker instead of just the form.
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { JSDOM } from 'jsdom';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
const accessoryJs = readFileSync(resolve(PUBLIC, 'keyboard-accessory.js'), 'utf8');
const sessionUiJs = readFileSync(resolve(PUBLIC, 'session-ui.js'), 'utf8');
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>', { url: 'https://localhost/' });
const jsdomWindow = dom.window as unknown as Window & typeof globalThis;
const doc = jsdomWindow.document;
type Call = { url: string; init?: { method?: string; body?: string } };
function loadPathPicker(fetchImpl: (url: string, init?: Call['init']) => Promise<unknown>): any {
const factory = new Function(
'window',
'document',
'localStorage',
'fetch',
'MobileDetection',
`${accessoryJs}\nreturn PathPicker;`
);
return factory(jsdomWindow, doc, jsdomWindow.localStorage, fetchImpl, { isTouchDevice: () => false });
}
const listing = (path: string, names: string[]) => ({
json: async () => ({
success: true,
data: {
path,
parent: null,
root: '/home/dev',
roots: [{ label: 'Home', path: '/home/dev' }],
entries: names.map((name) => ({ name, type: 'directory', path: `${path}/${name}` })),
truncated: false,
},
}),
});
describe('PathPicker New folder', () => {
let PathPicker: any;
let calls: Call[];
let names: string[];
let mkdirReply: () => unknown;
beforeEach(() => {
jsdomWindow.localStorage.clear();
doc.body.replaceChildren();
calls = [];
names = ['src'];
mkdirReply = () => {
names.push('fresh');
return { json: async () => ({ success: true, data: { path: '/home/dev/project/fresh' } }) };
};
PathPicker = loadPathPicker(async (url, init) => {
calls.push({ url, init });
return url.startsWith('/api/filesystem/mkdir') ? mkdirReply() : listing('/home/dev/project', names);
});
});
afterEach(() => {
PathPicker?.close?.(false);
doc.body.replaceChildren();
});
const open = async (options: Record<string, unknown> = {}) => {
PathPicker.open({ onSelect: () => {}, directoriesOnly: true, ...options });
await vi.waitFor(() => expect(doc.querySelectorAll('.path-picker-item').length).toBeGreaterThan(0));
};
const button = () => doc.querySelector('.path-picker-newfolder') as HTMLButtonElement;
const form = () => doc.querySelector('.path-picker-newfolder-form') as HTMLFormElement;
const nameInput = () => doc.querySelector('.path-picker-newfolder-name') as HTMLInputElement;
const submit = (name: string) => {
button().click();
nameInput().value = name;
form().dispatchEvent(new jsdomWindow.Event('submit', { cancelable: true, bubbles: true }));
};
it('is hidden unless the caller opts in', async () => {
await open();
expect(button().hidden).toBe(true);
expect(form().hidden).toBe(true);
});
it('shows the button, and opens the name form on click, when allowed', async () => {
await open({ allowNewFolder: true });
expect(button().hidden).toBe(false);
expect(form().hidden).toBe(true);
button().click();
expect(form().hidden).toBe(false);
});
it('creates inside the folder being viewed, reloads it and selects the new folder', async () => {
await open({ allowNewFolder: true, sessionId: 'abc123' });
submit('fresh');
await vi.waitFor(() =>
expect(doc.querySelector('.path-picker-selection-value')?.textContent).toBe('/home/dev/project/fresh')
);
const post = calls.find((c) => c.url === '/api/filesystem/mkdir');
expect(post?.init?.method).toBe('POST');
expect(JSON.parse(post?.init?.body ?? '{}')).toEqual({
path: '/home/dev/project',
name: 'fresh',
sessionId: 'abc123',
});
expect(Array.from(doc.querySelectorAll('.path-picker-item-name')).map((e) => e.textContent)).toContain('fresh');
expect(form().hidden).toBe(true);
expect((doc.querySelector('.path-picker-confirm') as HTMLButtonElement).disabled).toBe(false);
});
it('shows the server reason and keeps the form open when the folder cannot be made', async () => {
mkdirReply = () => ({ json: async () => ({ success: false, error: '"fresh" already exists in this folder' }) });
await open({ allowNewFolder: true });
submit('fresh');
await vi.waitFor(() =>
expect(doc.querySelector('.path-picker-status')?.textContent).toBe('"fresh" already exists in this folder')
);
expect(doc.querySelector('.path-picker-status')?.classList.contains('error')).toBe(true);
expect(form().hidden).toBe(false);
});
it('sends nothing for a blank name', async () => {
await open({ allowNewFolder: true });
submit(' ');
await Promise.resolve();
expect(calls.some((c) => c.url === '/api/filesystem/mkdir')).toBe(false);
});
it('Escape in the name field closes the form, not the picker', async () => {
await open({ allowNewFolder: true });
button().click();
nameInput().dispatchEvent(new jsdomWindow.KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
expect(form().hidden).toBe(true);
expect(doc.querySelector('.path-picker-overlay')).not.toBeNull();
});
it('is on for Link Existing and off for the other session-ui pickers', () => {
const block = (name: string) => {
const start = sessionUiJs.indexOf(`${name}() {`);
return sessionUiJs.slice(start, sessionUiJs.indexOf('PathPicker.open', start) + 400);
};
expect(block('openLinkCasePathPicker')).toContain('allowNewFolder: true');
expect(block('openDockerWorkspacePathPicker')).not.toContain('allowNewFolder');
});
});
@@ -0,0 +1,81 @@
/**
* @fileoverview POST /api/filesystem/mkdir: the path picker's "New folder". Real filesystem under
* test/setup.ts's temp HOME (a root the picker offers), so this proves what is on disk, not a mock.
* Port: N/A (app.inject()).
*/
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { existsSync, mkdirSync, mkdtempSync, readdirSync, realpathSync, writeFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { createRouteTestHarness } from './_route-test-utils.js';
import { registerFileRoutes } from '../../src/web/routes/file-routes.js';
import { isSandboxedHome, safeRmHomeTree } from '../mocks/index.js';
describe('POST /api/filesystem/mkdir', () => {
let workDir = '';
beforeEach(() => {
if (!isSandboxedHome()) throw new Error('filesystem-mkdir-routes.test.ts creates folders: run it via npm test');
workDir = realpathSync(mkdtempSync(join(homedir(), 'picker-mkdir-')));
});
afterEach(() => {
if (workDir) safeRmHomeTree(workDir);
workDir = '';
});
const mkdir = async (payload: Record<string, unknown>) => {
const { app } = await createRouteTestHarness(registerFileRoutes);
return app.inject({ method: 'POST', url: '/api/filesystem/mkdir', payload });
};
it('creates one folder inside the folder being viewed and answers its path', async () => {
const res = await mkdir({ path: workDir, name: 'fresh' });
expect(res.statusCode).toBe(200);
expect(res.json().data).toEqual({ path: join(workDir, 'fresh') });
expect(readdirSync(workDir)).toEqual(['fresh']);
});
it('trims the name', async () => {
const res = await mkdir({ path: workDir, name: ' spaced ' });
expect(res.json().data.path).toBe(join(workDir, 'spaced'));
});
it('refuses an existing folder (409) without touching it', async () => {
mkdirSync(join(workDir, 'taken'));
writeFileSync(join(workDir, 'taken', 'keep.txt'), 'mine');
const res = await mkdir({ path: workDir, name: 'taken' });
expect(res.statusCode).toBe(409);
expect(res.json().error).toContain('already exists');
expect(readdirSync(join(workDir, 'taken'))).toEqual(['keep.txt']);
});
it.each([
['a slash', 'a/b'],
['a backslash', 'a\\b'],
['a traversal', '..'],
['a dot name', '.'],
['a hidden name', '.secret'],
['an empty name', ' '],
])('refuses %s in the name (400) and creates nothing', async (_label, name) => {
const res = await mkdir({ path: workDir, name });
expect(res.statusCode).toBe(400);
expect(readdirSync(workDir)).toEqual([]);
});
it('refuses a parent that does not exist (404)', async () => {
const res = await mkdir({ path: join(workDir, 'nope'), name: 'x' });
expect(res.statusCode).toBe(404);
expect(existsSync(join(workDir, 'nope'))).toBe(false);
});
it('refuses a parent outside the browse roots (403)', async () => {
const res = await mkdir({ path: '/etc', name: 'x' });
expect(res.statusCode).toBe(403);
expect(existsSync('/etc/x')).toBe(false);
});
it('refuses a relative or traversing parent (400)', async () => {
expect((await mkdir({ path: 'relative', name: 'x' })).statusCode).toBe(400);
expect((await mkdir({ path: `${workDir}/../x`, name: 'x' })).statusCode).toBe(400);
});
});