mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-21 23:56:39 +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:
@@ -6,7 +6,12 @@
|
||||
* client before it would otherwise be caught by the server.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { isClientSafeRelPath } from '../stackFilesApi';
|
||||
import {
|
||||
isClientSafeRelPath,
|
||||
isProtectedRootRelPath,
|
||||
isSameOrDescendantPath,
|
||||
relPathParentDir,
|
||||
} from '../stackFilesApi';
|
||||
|
||||
describe('isClientSafeRelPath', () => {
|
||||
it('accepts the empty string (means the stack root)', () => {
|
||||
@@ -65,3 +70,48 @@ describe('isClientSafeRelPath', () => {
|
||||
expect(isClientSafeRelPath(42 as unknown as string)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isProtectedRootRelPath', () => {
|
||||
it('flags compose and env files at the stack root', () => {
|
||||
expect(isProtectedRootRelPath('compose.yaml')).toBe(true);
|
||||
expect(isProtectedRootRelPath('compose.yml')).toBe(true);
|
||||
expect(isProtectedRootRelPath('docker-compose.yaml')).toBe(true);
|
||||
expect(isProtectedRootRelPath('docker-compose.yml')).toBe(true);
|
||||
expect(isProtectedRootRelPath('.env')).toBe(true);
|
||||
});
|
||||
|
||||
it('does not flag the same names nested in a subdirectory', () => {
|
||||
expect(isProtectedRootRelPath('configs/.env')).toBe(false);
|
||||
expect(isProtectedRootRelPath('nested/compose.yaml')).toBe(false);
|
||||
});
|
||||
|
||||
it('does not flag ordinary files or the empty string', () => {
|
||||
expect(isProtectedRootRelPath('app.conf')).toBe(false);
|
||||
expect(isProtectedRootRelPath('')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isSameOrDescendantPath', () => {
|
||||
it('is true for the path itself', () => {
|
||||
expect(isSameOrDescendantPath('src', 'src')).toBe(true);
|
||||
});
|
||||
|
||||
it('is true for a nested descendant', () => {
|
||||
expect(isSameOrDescendantPath('src', 'src/lib/util.ts')).toBe(true);
|
||||
});
|
||||
|
||||
it('is false for a sibling sharing a name prefix', () => {
|
||||
expect(isSameOrDescendantPath('src', 'src-extra')).toBe(false);
|
||||
expect(isSameOrDescendantPath('src', 'other')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('relPathParentDir', () => {
|
||||
it('returns the empty string for a root-level entry', () => {
|
||||
expect(relPathParentDir('app.conf')).toBe('');
|
||||
});
|
||||
|
||||
it('returns the directory portion for a nested entry', () => {
|
||||
expect(relPathParentDir('configs/redis/redis.conf')).toBe('configs/redis');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user