mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-29 03:36:55 +00:00
feat(files): move files and folders across directories in the stack explorer (#1373)
* feat(files): move files and folders across directories in the stack explorer Add a cross-directory move to the stack file explorer. Files and folders can be relocated either through a "Move to..." context-menu item that opens a folder-picker dialog, or by dragging an entry onto a folder node (or onto the root area to move it to the stack root). The backend reuses the existing rename endpoint: renameStackPath now resolves both ends through the leaf helper, so a symlink moves as the link entry rather than its target, and it guards against moving a directory into its own subtree. A cross-filesystem rename surfaces as a clean 409 instead of a 500. Protected root files (compose / docker-compose / .env) stay put. Moving the open file, or a folder containing it, deselects the viewer; a move that would discard unsaved edits is blocked with a clear message. * fix(files): fold case in move guards and keep the move dialog open on failure Harden the cross-directory move against case-insensitive filesystems and fix a dialog dismissal edge: - Protected root files (compose / docker-compose / .env) were gated by an exact, lowercase name match. On a case-insensitive filesystem a request like COMPOSE.YAML resolves to the real compose.yaml and slipped past the gate, so a protected file could be moved out of the stack root via the API. The gate now folds case on case-insensitive platforms; Linux stays case-sensitive, where a differently-cased name is a distinct, unprotected file. - The directory-into-descendant guard compared resolved paths case-sensitively, so a source supplied with non-disk casing skipped the guard and fell through to an opaque OS error (500) instead of a clean 400. The comparison now folds case the same way. - The move dialog closed after awaiting the move regardless of outcome, so a blocked move (unsaved edits) or a failed move dismissed the picker as if it had succeeded. The shared handler now reports success and the dialog only closes on an actual move.
This commit is contained in:
@@ -1129,6 +1129,59 @@ describe('PATCH /api/stacks/:stackName/files/rename', () => {
|
||||
const moved = await fs.readFile(path.join(stacksDir, STACK, 'community-rename-to.txt'), 'utf-8');
|
||||
expect(moved).toBe('src');
|
||||
});
|
||||
|
||||
it('moves a file into a subdirectory (cross-directory move) with 204', async () => {
|
||||
await fs.writeFile(path.join(stacksDir, STACK, 'move-me.txt'), 'payload');
|
||||
await fs.mkdir(path.join(stacksDir, STACK, 'movedest'), { recursive: true });
|
||||
|
||||
const res = await request(app)
|
||||
.patch(`/api/stacks/${STACK}/files/rename`)
|
||||
.set('Cookie', adminCookie)
|
||||
.send({ from: 'move-me.txt', to: 'movedest/move-me.txt' });
|
||||
expect(res.status).toBe(204);
|
||||
|
||||
await expect(fs.access(path.join(stacksDir, STACK, 'move-me.txt'))).rejects.toMatchObject({ code: 'ENOENT' });
|
||||
expect(await fs.readFile(path.join(stacksDir, STACK, 'movedest', 'move-me.txt'), 'utf-8')).toBe('payload');
|
||||
});
|
||||
|
||||
it('returns 400 INVALID_PATH when moving a directory into its own descendant', async () => {
|
||||
await fs.mkdir(path.join(stacksDir, STACK, 'movparent', 'movchild'), { recursive: true });
|
||||
|
||||
const res = await request(app)
|
||||
.patch(`/api/stacks/${STACK}/files/rename`)
|
||||
.set('Cookie', adminCookie)
|
||||
.send({ from: 'movparent', to: 'movparent/movchild/movparent' });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.code).toBe('INVALID_PATH');
|
||||
});
|
||||
|
||||
it('returns 409 PROTECTED_FILE when moving a nested compose file to the stack root', async () => {
|
||||
await fs.mkdir(path.join(stacksDir, STACK, 'nested'), { recursive: true });
|
||||
await fs.writeFile(path.join(stacksDir, STACK, 'nested', 'compose.yaml'), 'services: {}\n');
|
||||
|
||||
const res = await request(app)
|
||||
.patch(`/api/stacks/${STACK}/files/rename`)
|
||||
.set('Cookie', adminCookie)
|
||||
.send({ from: 'nested/compose.yaml', to: 'compose.yaml' });
|
||||
expect(res.status).toBe(409);
|
||||
expect(res.body.code).toBe('PROTECTED_FILE');
|
||||
});
|
||||
|
||||
it('maps a cross-device EXDEV rename failure to 409 EXDEV', async () => {
|
||||
await fs.writeFile(path.join(stacksDir, STACK, 'exdev-src.txt'), 'data');
|
||||
await fs.mkdir(path.join(stacksDir, STACK, 'exdev-dest'), { recursive: true });
|
||||
const renameSpy = vi.spyOn(fs, 'rename').mockRejectedValueOnce(
|
||||
Object.assign(new Error('cross-device link not permitted'), { code: 'EXDEV' }),
|
||||
);
|
||||
|
||||
const res = await request(app)
|
||||
.patch(`/api/stacks/${STACK}/files/rename`)
|
||||
.set('Cookie', adminCookie)
|
||||
.send({ from: 'exdev-src.txt', to: 'exdev-dest/exdev-src.txt' });
|
||||
expect(res.status).toBe(409);
|
||||
expect(res.body.code).toBe('EXDEV');
|
||||
renameSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
// ── PUT /:stackName/files/permissions ────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user