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:
Anso
2026-06-14 21:56:06 -04:00
committed by GitHub
parent 0066887cee
commit 888f658a7a
16 changed files with 1204 additions and 45 deletions
+37 -9
View File
@@ -77,6 +77,14 @@ function stripTrailingSlash(s: string): string {
return s.endsWith('/') ? s.slice(0, -1) : s;
}
// On a case-insensitive filesystem (Windows, default macOS) two paths that differ
// only in case point at the same entry, so comparisons that gate filesystem
// mutations must fold case to stay authoritative. On Linux (where Sencho runs in
// production) paths are case-sensitive and this returns the input unchanged.
function fsCaseKey(s: string): string {
return process.platform === 'win32' || process.platform === 'darwin' ? s.toLowerCase() : s;
}
function isProtectedRelPath(relPath: string): boolean {
if (!relPath) return false;
const normalized = stripTrailingSlash(relPath);
@@ -84,7 +92,9 @@ function isProtectedRelPath(relPath: string): boolean {
// the stack directory itself, so a subdirectory entry named compose.yaml is just
// an arbitrary file and the user may want to delete it.
if (normalized.includes('/')) return false;
return PROTECTED_STACK_FILES.has(normalized);
// Fold case so e.g. a request for COMPOSE.YAML cannot dodge the gate on a
// case-insensitive filesystem where it resolves to the real compose.yaml.
return PROTECTED_STACK_FILES.has(fsCaseKey(normalized));
}
function protectedFileError(relPath: string): Error & { code: string } {
@@ -1385,22 +1395,40 @@ export class FileSystemService {
await fsPromises.mkdir(safePath, { recursive: true });
}
/**
* Renames or moves an entry within a stack. The source and destination may sit
* in different directories (a cross-directory move), since fs.rename relocates
* natively. Both paths resolve through the leaf helper so a symlink source is
* moved as the link entry itself rather than followed to its target, matching
* the delete/chmod policy. fs.rename fails with EXDEV across a filesystem
* boundary (e.g. a bind-mounted subdirectory); the route surfaces that as a 409.
*/
async renameStackPath(stackName: string, fromRel: string, toRel: string): Promise<void> {
if (isProtectedRelPath(fromRel)) throw protectedFileError(fromRel);
if (isProtectedRelPath(toRel)) throw protectedFileError(toRel);
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 fromPath = await this.resolveSafeStackLeafPath(stackName, fromRel);
const toPath = await this.resolveSafeStackLeafPath(stackName, toRel);
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.
// Block moving a directory into itself or one of its own descendants; fs.rename
// would otherwise fail with an opaque EINVAL/EPERM. Compare case-folded so the
// guard stays authoritative when the source is supplied with non-disk casing on
// a case-insensitive filesystem.
const fromStat = await fsPromises.lstat(fromPath);
if (fromStat.isDirectory()) {
const fromKey = fsCaseKey(fromPath);
const fromKeyWithSep = fromKey.endsWith(path.sep) ? fromKey : fromKey + path.sep;
const toKey = fsCaseKey(toPath);
if (toKey === fromKey || toKey.startsWith(fromKeyWithSep)) {
throw Object.assign(new Error('Cannot move a folder into itself'), { code: 'INVALID_PATH' });
}
}
// Prevent overwriting an existing path. lstat (not access) so a dangling
// symlink already at the destination still counts as occupied.
try {
await fsPromises.access(toPath);
await fsPromises.lstat(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;