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

+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(),