mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 18:39:33 +01:00
Merge pull request #745: feat(picker): New folder button in the path picker, on for Link Existing
This commit is contained in:
commit
1efe1d40c3
10 files changed
+614
-5
No files matched your search
@@ -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,70 @@ 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 create = overlay.querySelector('.path-picker-newfolder-create');
|
||||
const parent = this._currentPath;
|
||||
// One request at a time: a double tap would send a second one whose 409 shows after the first succeeded.
|
||||
create.disabled = true;
|
||||
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,
|
||||
showHidden: this._showHidden ? 'true' : 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');
|
||||
} finally {
|
||||
create.disabled = false;
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
_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');
|
||||
|
||||
@@ -15771,6 +15771,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;
|
||||
@@ -15795,6 +15796,45 @@ body.touch-device.cjk-input-visible .main {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.path-picker-newfolder {
|
||||
flex: 0 0 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;
|
||||
}
|
||||
|
||||
.path-picker-up:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: default;
|
||||
|
||||
@@ -57,6 +57,7 @@ import {
|
||||
findSessionOrFail,
|
||||
getAuthUser,
|
||||
parseBody,
|
||||
resolveCasesDir,
|
||||
validateSessionFilePath,
|
||||
validateSessionFilePathLexical,
|
||||
} from '../route-helpers.js';
|
||||
@@ -74,7 +75,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,
|
||||
@@ -1448,6 +1454,74 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
};
|
||||
});
|
||||
|
||||
// "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, showHidden } = parseBody(FilesystemMkdirSchema, req.body);
|
||||
const { candidatePath, resolvedPath, blockedTrees } = await resolveFilesystemPickerPath(
|
||||
ctx,
|
||||
req,
|
||||
parentPath,
|
||||
sessionId,
|
||||
wantsHiddenPickerEntries(showHidden)
|
||||
);
|
||||
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');
|
||||
}
|
||||
// The cases directories (shared, and the caller's own in multi-user mode) are what GET /api/cases lists as
|
||||
// local cases: a folder made there is a case with no scaffold, which Link then refuses by name and which
|
||||
// deleting would take the linked folder with. A case there is made through Add Case > New Case.
|
||||
const casesDirs = await Promise.all(
|
||||
[...new Set([CASES_DIR, resolveCasesDir(getAuthUser(req))])].map((dir) =>
|
||||
fs.realpath(dir).catch(() => resolve(dir))
|
||||
)
|
||||
);
|
||||
if (casesDirs.includes(resolvedPath)) {
|
||||
reply.code(400);
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.INVALID_INPUT,
|
||||
'This is a cases folder: every folder in it is a case. Use Add Case > New Case to make one there.'
|
||||
);
|
||||
}
|
||||
// Like browse, a file is not a place to create in.
|
||||
try {
|
||||
if (!(await fs.stat(resolvedPath)).isDirectory()) throw new Error('not a directory');
|
||||
} catch {
|
||||
reply.code(400);
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'The folder to create in must be a directory');
|
||||
}
|
||||
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');
|
||||
}
|
||||
if (code === 'ENOENT') {
|
||||
reply.code(404);
|
||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, `Folder not found: ${candidatePath}`);
|
||||
}
|
||||
if (code === 'ENAMETOOLONG') {
|
||||
reply.code(400);
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Folder name is too long');
|
||||
}
|
||||
// Never the errno text: it names the resolved, symlink-followed path, not the one the picker shows.
|
||||
reply.code(500);
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.OPERATION_FAILED,
|
||||
`Could not create the folder${code ? ` (${code})` : ''}`
|
||||
);
|
||||
}
|
||||
return { success: true, data: { path: join(candidatePath, name) } };
|
||||
});
|
||||
|
||||
// Inline preview for files selected through the root-confined filesystem picker.
|
||||
app.get('/api/filesystem/preview', { compress: false }, async (req, reply): Promise<void> => {
|
||||
const { path: requestedPath, sessionId, showHidden } = parseBody(FilesystemPreviewQuerySchema, req.query);
|
||||
|
||||
@@ -82,6 +82,35 @@ 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 held
|
||||
* to the character set `safePathSchema` accepts (letters, digits, `_ - . ~` and space, no `..`), so every
|
||||
* name it accepts is one Link Existing and a session working directory accept (the parent's own path is
|
||||
* judged there separately), and never a control character. It is also one path segment, not dot-prefixed (the picker hides those by default) and not tilde-prefixed (a folder named `~` is an `rm -rf ~` accident), and at
|
||||
* most 255 BYTES, the usual filesystem limit.
|
||||
*/
|
||||
export const FilesystemMkdirSchema = z.object({
|
||||
path: filesystemPickerPathSchema,
|
||||
name: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, 'Folder name is required')
|
||||
.refine((n) => Buffer.byteLength(n) <= 255, { message: 'Folder name is too long' })
|
||||
.refine((n) => /^[\p{L}\p{N}_\-. ~]+$/u.test(n), {
|
||||
message: 'Folder name can only use letters, numbers, spaces and _ - . ~',
|
||||
})
|
||||
.refine((n) => !n.includes('..') && !n.startsWith('.') && !n.startsWith('~'), {
|
||||
message: 'Folder name cannot contain "..", or start with a dot or a tilde',
|
||||
}),
|
||||
sessionId: z
|
||||
.string()
|
||||
.max(100)
|
||||
.regex(/^[a-zA-Z0-9_-]+$/, 'Invalid session id')
|
||||
.optional(),
|
||||
/** Same opt-in flag as browse: lets the picker create inside a hidden folder it is showing. */
|
||||
showHidden: showHiddenQuerySchema,
|
||||
});
|
||||
|
||||
/** Query validation for the lazy, allowlisted filesystem path picker. */
|
||||
export const FilesystemBrowseQuerySchema = z.object({
|
||||
path: filesystemPickerPathSchema.optional(),
|
||||
|
||||
Reference in new issue
Block a user