mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-18 06:23:18 +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:
@@ -27,6 +27,75 @@ function assertSafeRelPath(rel: string, label = 'path'): void {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirrors backend/src/services/FileSystemService.ts::isProtectedRelPath. Only the
|
||||
* compose and .env files at the stack ROOT are protected; an entry with the same
|
||||
* basename nested in a subdirectory is an ordinary file. FileEntry.isProtected is
|
||||
* basename-only, so callers that care about position (move source gating, root
|
||||
* destination gating) use this instead.
|
||||
*/
|
||||
const PROTECTED_ROOT_NAMES = new Set([
|
||||
'compose.yaml',
|
||||
'compose.yml',
|
||||
'docker-compose.yaml',
|
||||
'docker-compose.yml',
|
||||
'.env',
|
||||
]);
|
||||
|
||||
export function isProtectedRootRelPath(rel: string): boolean {
|
||||
if (!rel || rel.includes('/')) return false;
|
||||
return PROTECTED_ROOT_NAMES.has(rel);
|
||||
}
|
||||
|
||||
/** True when `candidateRel` is `ancestorRel` itself or sits inside it. */
|
||||
export function isSameOrDescendantPath(ancestorRel: string, candidateRel: string): boolean {
|
||||
return candidateRel === ancestorRel || candidateRel.startsWith(`${ancestorRel}/`);
|
||||
}
|
||||
|
||||
/** The directory portion of a relative path; '' for a root-level entry. */
|
||||
export function relPathParentDir(rel: string): string {
|
||||
return rel.includes('/') ? rel.slice(0, rel.lastIndexOf('/')) : '';
|
||||
}
|
||||
|
||||
/** Custom drag MIME so move drops are told apart from OS file drags (`Files`). */
|
||||
export const FILE_ENTRY_DND_MIME = 'application/x-sencho-file-entry';
|
||||
|
||||
export interface FileEntryDragPayload {
|
||||
relPath: string;
|
||||
name: string;
|
||||
type: FileEntry['type'];
|
||||
}
|
||||
|
||||
const ENTRY_TYPES: ReadonlySet<FileEntry['type']> = new Set(['file', 'directory', 'symlink']);
|
||||
|
||||
/**
|
||||
* Reads a tree-move drag payload from a DataTransfer, or null when this is not
|
||||
* one of our entry drags (e.g. an OS file drag carries `Files`, handled by the
|
||||
* upload dropzone). The parsed JSON is shape-validated rather than blindly cast,
|
||||
* so a malformed or foreign payload becomes an ignored no-op instead of flowing
|
||||
* downstream with undefined fields.
|
||||
*/
|
||||
export function readFileEntryDragPayload(dt: DataTransfer): FileEntryDragPayload | null {
|
||||
if (!dt.types.includes(FILE_ENTRY_DND_MIME)) return null;
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(dt.getData(FILE_ENTRY_DND_MIME));
|
||||
} catch (err) {
|
||||
console.warn('Ignored malformed file-entry drag payload', err);
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
typeof parsed === 'object' && parsed !== null &&
|
||||
typeof (parsed as FileEntryDragPayload).relPath === 'string' &&
|
||||
typeof (parsed as FileEntryDragPayload).name === 'string' &&
|
||||
ENTRY_TYPES.has((parsed as FileEntryDragPayload).type)
|
||||
) {
|
||||
return parsed as FileEntryDragPayload;
|
||||
}
|
||||
console.warn('Ignored file-entry drag payload with an unexpected shape');
|
||||
return null;
|
||||
}
|
||||
|
||||
export interface FileEntry {
|
||||
name: string;
|
||||
type: 'file' | 'directory' | 'symlink';
|
||||
|
||||
Reference in New Issue
Block a user