mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-15 10:59:32 +01:00
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:
1 parent
f1f7eec4d6
commit
d5e4fe0f87
9 files changed
+401
-4
No files matched your search
@@ -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`
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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>+📁</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">↻</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';
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user