mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-10 02:41:14 +00:00
feat: implement file explorer context menus and dialogs (#934)
This commit is contained in:
@@ -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');
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user