feat: implement file explorer context menus and dialogs (#934)

This commit is contained in:
Anso
2026-05-06 08:46:02 -04:00
committed by GitHub
parent 166ba21ff1
commit 0c3ce4b224
10 changed files with 896 additions and 37 deletions
+62
View File
@@ -977,3 +977,65 @@ stacksRouter.post('/:stackName/files/folder', async (req: Request, res: Response
return sendFsError(res, err, 'Failed to create folder');
}
});
stacksRouter.patch('/:stackName/files/rename', async (req: Request, res: Response) => {
if (!requirePaid(req, res)) return;
const stackName = req.params.stackName as string;
if (!requirePermission(req, res, 'stack:edit', 'stack', stackName)) return;
const { from, to } = req.body as { from?: unknown; to?: unknown };
if (typeof from !== 'string' || !from) {
return res.status(400).json({ error: '"from" must be a non-empty string' });
}
if (typeof to !== 'string' || !to) {
return res.status(400).json({ error: '"to" must be a non-empty string' });
}
if (!isValidRelativeStackPath(from)) {
return res.status(400).json({ error: 'Invalid source path', code: 'INVALID_PATH' });
}
if (!isValidRelativeStackPath(to)) {
return res.status(400).json({ error: 'Invalid destination path', code: 'INVALID_PATH' });
}
try {
await FileSystemService.getInstance(req.nodeId).renameStackPath(stackName, from, to);
return res.status(204).send();
} catch (err: unknown) {
return sendFsError(res, err, 'Failed to rename');
}
});
stacksRouter.get('/:stackName/files/permissions', async (req: Request, res: Response) => {
const stackName = req.params.stackName as string;
const relPath = getRelPath(req);
if (!relPath) return res.status(400).json({ error: 'path query parameter is required', code: 'INVALID_PATH' });
if (!isValidRelativeStackPath(relPath)) {
return res.status(400).json({ error: 'Invalid path', code: 'INVALID_PATH' });
}
try {
const result = await FileSystemService.getInstance(req.nodeId).getStackEntryMode(stackName, relPath);
return res.json(result);
} catch (err: unknown) {
return sendFsError(res, err, 'Failed to read permissions');
}
});
stacksRouter.put('/:stackName/files/permissions', async (req: Request, res: Response) => {
if (!requirePaid(req, res)) return;
const stackName = req.params.stackName as string;
if (!requirePermission(req, res, 'stack:edit', 'stack', stackName)) return;
const relPath = getRelPath(req);
if (!relPath) return res.status(400).json({ error: 'path query parameter is required', code: 'INVALID_PATH' });
if (!isValidRelativeStackPath(relPath)) {
return res.status(400).json({ error: 'Invalid path', code: 'INVALID_PATH' });
}
const { mode } = req.body as { mode?: unknown };
if (typeof mode !== 'number') {
return res.status(400).json({ error: '"mode" must be a number' });
}
try {
await FileSystemService.getInstance(req.nodeId).chmodStackPath(stackName, relPath, mode);
return res.status(204).send();
} catch (err: unknown) {
return sendFsError(res, err, 'Failed to set permissions');
}
});
+37
View File
@@ -565,6 +565,43 @@ export class FileSystemService {
await fsPromises.mkdir(safePath, { recursive: true });
}
async renameStackPath(stackName: string, fromRel: string, toRel: string): Promise<void> {
const fromPath = await this.resolveSafeStackPath(stackName, fromRel);
// toRel must resolve to the same parent directory (rename only, no cross-dir move).
const toPath = await this.resolveSafeStackPath(stackName, toRel);
if (path.dirname(fromPath) !== path.dirname(toPath)) {
throw Object.assign(new Error('Cross-directory rename is not supported'), { code: 'INVALID_PATH' });
}
const toName = path.basename(toPath);
if (!toName || toName === '.' || toName === '..') {
throw Object.assign(new Error('Invalid destination name'), { code: 'INVALID_PATH' });
}
// Prevent overwriting an existing path.
try {
await fsPromises.access(toPath);
throw Object.assign(new Error('A file or folder with that name already exists'), { code: 'EEXIST' });
} catch (e: unknown) {
const fe = e as NodeJS.ErrnoException;
if (fe.code !== 'ENOENT') throw e;
}
await fsPromises.rename(fromPath, toPath);
}
async getStackEntryMode(stackName: string, relPath: string): Promise<{ mode: number; octal: string }> {
const safePath = await this.resolveSafeStackPath(stackName, relPath);
const stat = await fsPromises.stat(safePath);
const mode = stat.mode & 0o777;
return { mode, octal: mode.toString(8).padStart(3, '0') };
}
async chmodStackPath(stackName: string, relPath: string, mode: number): Promise<void> {
if (!Number.isInteger(mode) || mode < 0 || mode > 0o777) {
throw Object.assign(new Error('Invalid permission bits'), { code: 'INVALID_PATH' });
}
const safePath = await this.resolveSafeStackPath(stackName, relPath);
await fsPromises.chmod(safePath, mode);
}
async statStackEntry(stackName: string, relPath: string): Promise<FileEntry> {
const safePath = await this.resolveSafeStackPath(stackName, relPath);
// Use lstat so symlinks are reported as 'symlink' rather than resolved to target type.