From 888f658a7a2406a7f059d08fe4d81a078a423f0b Mon Sep 17 00:00:00 2001 From: Anso Date: Sun, 14 Jun 2026 21:56:06 -0400 Subject: [PATCH] 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. --- .../__tests__/filesystem-stack-paths.test.ts | 168 ++++++++++++ .../src/__tests__/stack-files-routes.test.ts | 53 ++++ backend/src/routes/stacks.ts | 6 +- backend/src/services/FileSystemService.ts | 46 +++- docs/features/stack-file-explorer.mdx | 23 +- frontend/src/components/files/FileTree.tsx | 46 +++- .../components/files/FileTreeContextMenu.tsx | 17 +- .../src/components/files/FileTreeNode.tsx | 68 ++++- .../src/components/files/MoveFileDialog.tsx | 256 ++++++++++++++++++ .../src/components/files/RenameDialog.tsx | 6 +- .../components/files/StackFileExplorer.tsx | 53 +++- .../files/__tests__/FileTree.test.tsx | 119 +++++++- .../files/__tests__/MoveFileDialog.test.tsx | 148 ++++++++++ .../__tests__/StackFileExplorer.test.tsx | 119 +++++++- .../src/lib/__tests__/stackFilesApi.test.ts | 52 +++- frontend/src/lib/stackFilesApi.ts | 69 +++++ 16 files changed, 1204 insertions(+), 45 deletions(-) create mode 100644 frontend/src/components/files/MoveFileDialog.tsx create mode 100644 frontend/src/components/files/__tests__/MoveFileDialog.test.tsx diff --git a/backend/src/__tests__/filesystem-stack-paths.test.ts b/backend/src/__tests__/filesystem-stack-paths.test.ts index 6cd1d73f..3ef08e92 100644 --- a/backend/src/__tests__/filesystem-stack-paths.test.ts +++ b/backend/src/__tests__/filesystem-stack-paths.test.ts @@ -416,6 +416,127 @@ describe('FileSystemService stack methods', () => { }); }); + // ── renameStackPath (rename + cross-directory move) ────────────────────── + + describe('renameStackPath', () => { + it('moves a file from the stack root into a subdirectory', async () => { + await fs.writeFile(path.join(stackDir, 'app.conf'), 'data'); + await fs.mkdir(path.join(stackDir, 'configs')); + + const service = FileSystemService.getInstance(); + await service.renameStackPath(STACK, 'app.conf', 'configs/app.conf'); + + await expect(fs.access(path.join(stackDir, 'app.conf'))).rejects.toMatchObject({ code: 'ENOENT' }); + expect(await fs.readFile(path.join(stackDir, 'configs', 'app.conf'), 'utf-8')).toBe('data'); + }); + + it('moves a file from a subdirectory back to the stack root', async () => { + await fs.mkdir(path.join(stackDir, 'configs')); + await fs.writeFile(path.join(stackDir, 'configs', 'app.conf'), 'data'); + + const service = FileSystemService.getInstance(); + await service.renameStackPath(STACK, 'configs/app.conf', 'app.conf'); + + await expect(fs.access(path.join(stackDir, 'configs', 'app.conf'))).rejects.toMatchObject({ code: 'ENOENT' }); + expect(await fs.readFile(path.join(stackDir, 'app.conf'), 'utf-8')).toBe('data'); + }); + + it('moves a directory into another directory', async () => { + await fs.mkdir(path.join(stackDir, 'src')); + await fs.writeFile(path.join(stackDir, 'src', 'child.txt'), 'inner'); + await fs.mkdir(path.join(stackDir, 'dest')); + + const service = FileSystemService.getInstance(); + await service.renameStackPath(STACK, 'src', 'dest/src'); + + await expect(fs.access(path.join(stackDir, 'src'))).rejects.toMatchObject({ code: 'ENOENT' }); + expect(await fs.readFile(path.join(stackDir, 'dest', 'src', 'child.txt'), 'utf-8')).toBe('inner'); + }); + + it('renames a file in place (same directory)', async () => { + await fs.writeFile(path.join(stackDir, 'old.txt'), 'x'); + + const service = FileSystemService.getInstance(); + await service.renameStackPath(STACK, 'old.txt', 'new.txt'); + + await expect(fs.access(path.join(stackDir, 'old.txt'))).rejects.toMatchObject({ code: 'ENOENT' }); + expect(await fs.readFile(path.join(stackDir, 'new.txt'), 'utf-8')).toBe('x'); + }); + + it('rejects moving a directory into its own descendant', async () => { + await fs.mkdir(path.join(stackDir, 'parent', 'child'), { recursive: true }); + + const service = FileSystemService.getInstance(); + await expect(service.renameStackPath(STACK, 'parent', 'parent/child/parent')).rejects.toMatchObject({ + code: 'INVALID_PATH', + }); + }); + + it('rejects overwriting an existing destination', async () => { + await fs.writeFile(path.join(stackDir, 'source.txt'), 'a'); + await fs.mkdir(path.join(stackDir, 'sub')); + await fs.writeFile(path.join(stackDir, 'sub', 'source.txt'), 'b'); + + const service = FileSystemService.getInstance(); + await expect(service.renameStackPath(STACK, 'source.txt', 'sub/source.txt')).rejects.toMatchObject({ + code: 'EEXIST', + }); + }); + + it('rejects an in-place rename onto an existing sibling name', async () => { + await fs.writeFile(path.join(stackDir, 'old.txt'), 'a'); + await fs.writeFile(path.join(stackDir, 'existing.txt'), 'b'); + + const service = FileSystemService.getInstance(); + await expect(service.renameStackPath(STACK, 'old.txt', 'existing.txt')).rejects.toMatchObject({ + code: 'EEXIST', + }); + }); + + it('rejects moving a protected root file out of the stack root', async () => { + await fs.writeFile(path.join(stackDir, 'compose.yaml'), 'services: {}\n'); + await fs.mkdir(path.join(stackDir, 'sub')); + + const service = FileSystemService.getInstance(); + await expect(service.renameStackPath(STACK, 'compose.yaml', 'sub/compose.yaml')).rejects.toMatchObject({ + code: 'PROTECTED_FILE', + }); + }); + + it('rejects a destination that becomes a protected root name', async () => { + await fs.mkdir(path.join(stackDir, 'sub')); + await fs.writeFile(path.join(stackDir, 'sub', 'compose.yaml'), 'services: {}\n'); + + const service = FileSystemService.getInstance(); + await expect(service.renameStackPath(STACK, 'sub/compose.yaml', 'compose.yaml')).rejects.toMatchObject({ + code: 'PROTECTED_FILE', + }); + }); + + // On a case-insensitive filesystem a differently-cased request resolves to the + // real protected file, so the gate must fold case. These only reproduce the + // bypass on Windows/macOS; on Linux the cased name is a distinct, unprotected + // file and the scenario does not arise. + it.skipIf(!isWindows)('rejects moving a protected root file referenced by a different case', async () => { + await fs.writeFile(path.join(stackDir, 'compose.yaml'), 'services: {}\n'); + await fs.mkdir(path.join(stackDir, 'sub')); + + const service = FileSystemService.getInstance(); + await expect(service.renameStackPath(STACK, 'COMPOSE.YAML', 'sub/COMPOSE.YAML')).rejects.toMatchObject({ + code: 'PROTECTED_FILE', + }); + }); + + it.skipIf(!isWindows)('rejects moving a directory into its own descendant when the source case differs', async () => { + await fs.mkdir(path.join(stackDir, 'parent', 'child'), { recursive: true }); + + const service = FileSystemService.getInstance(); + await expect(service.renameStackPath(STACK, 'Parent', 'parent/child/x')).rejects.toMatchObject({ + code: 'INVALID_PATH', + }); + }); + }); + // ── path traversal ────────────────────────────────────────────────────── describe('path traversal protection', () => { @@ -553,5 +674,52 @@ describe('FileSystemService stack methods', () => { const kept = await fs.readFile(path.join(targetDir, 'kept.txt'), 'utf-8'); expect(kept).toBe('preserve'); }); + + it('move on a symlink relocates the link entry and leaves an internal target intact', async () => { + const targetPath = path.join(stackDir, 'target.txt'); + const linkPath = path.join(stackDir, 'link.txt'); + await fs.writeFile(targetPath, 'payload'); + await fs.symlink(targetPath, linkPath); + await fs.mkdir(path.join(stackDir, 'sub')); + + const service = FileSystemService.getInstance(); + await service.renameStackPath(STACK, 'link.txt', 'sub/link.txt'); + + // The link entry moved; the old location is gone and the target is untouched. + await expect(fs.lstat(linkPath)).rejects.toMatchObject({ code: 'ENOENT' }); + const movedLink = await fs.lstat(path.join(stackDir, 'sub', 'link.txt')); + expect(movedLink.isSymbolicLink()).toBe(true); + expect(await fs.readFile(targetPath, 'utf-8')).toBe('payload'); + // Following the moved link still resolves to the original target content. + expect(await fs.readFile(path.join(stackDir, 'sub', 'link.txt'), 'utf-8')).toBe('payload'); + }); + + it('rejects a move whose destination is occupied by a dangling symlink', async () => { + await fs.writeFile(path.join(stackDir, 'real.txt'), 'payload'); + await fs.mkdir(path.join(stackDir, 'sub')); + // A symlink to a now-removed target: lstat sees the link, so the slot is occupied. + await fs.symlink(path.join(stackDir, 'sub', 'gone-target'), path.join(stackDir, 'sub', 'real.txt')); + + const service = FileSystemService.getInstance(); + await expect(service.renameStackPath(STACK, 'real.txt', 'sub/real.txt')).rejects.toMatchObject({ + code: 'EEXIST', + }); + }); + + it('move on a symlink whose target is outside the stack relocates only the link, not the target', async () => { + const externalFile = path.join(tmpBase, 'outside.txt'); + await fs.writeFile(externalFile, 'external'); + const linkPath = path.join(stackDir, 'escape-link'); + await fs.symlink(externalFile, linkPath); + await fs.mkdir(path.join(stackDir, 'sub')); + + const service = FileSystemService.getInstance(); + await service.renameStackPath(STACK, 'escape-link', 'sub/escape-link'); + + await expect(fs.lstat(linkPath)).rejects.toMatchObject({ code: 'ENOENT' }); + const moved = await fs.lstat(path.join(stackDir, 'sub', 'escape-link')); + expect(moved.isSymbolicLink()).toBe(true); + expect(await fs.readFile(externalFile, 'utf-8')).toBe('external'); + }); }); }); diff --git a/backend/src/__tests__/stack-files-routes.test.ts b/backend/src/__tests__/stack-files-routes.test.ts index 9ee8ff93..b3fb02d3 100644 --- a/backend/src/__tests__/stack-files-routes.test.ts +++ b/backend/src/__tests__/stack-files-routes.test.ts @@ -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 ──────────────────────────────────────── diff --git a/backend/src/routes/stacks.ts b/backend/src/routes/stacks.ts index b31b8e93..1fa96219 100644 --- a/backend/src/routes/stacks.ts +++ b/backend/src/routes/stacks.ts @@ -1703,7 +1703,8 @@ type FsErrorCode = | 'FILE_EXISTS' | 'DIR_EXISTS' | 'PROTECTED_FILE' - | 'LINK_CHMOD_UNSUPPORTED'; + | 'LINK_CHMOD_UNSUPPORTED' + | 'EXDEV'; function sendFsError( res: Response, @@ -1730,6 +1731,9 @@ function sendFsError( if (e.code === 'EEXIST') { return res.status(409).json({ error: e.message, code: 'ALREADY_EXISTS' satisfies FsErrorCode }); } + if (e.code === 'EXDEV') { + return res.status(409).json({ error: 'Cannot move across a storage boundary', code: 'EXDEV' satisfies FsErrorCode }); + } if (e.code === 'ENOTDIR') { return res.status(400).json({ error: 'Target path is not a directory', code: 'INVALID_PATH' satisfies FsErrorCode }); } diff --git a/backend/src/services/FileSystemService.ts b/backend/src/services/FileSystemService.ts index 68c8eb5d..dd896ae4 100644 --- a/backend/src/services/FileSystemService.ts +++ b/backend/src/services/FileSystemService.ts @@ -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 { 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; diff --git a/docs/features/stack-file-explorer.mdx b/docs/features/stack-file-explorer.mdx index 515e71f1..78c18a12 100644 --- a/docs/features/stack-file-explorer.mdx +++ b/docs/features/stack-file-explorer.mdx @@ -21,7 +21,7 @@ The file explorer gives you direct access to everything inside a stack's directo The **files** shortcut button next to **edit** in the Anatomy header opens the editor and selects the Files tab in one click. -The file explorer is available on every Sencho tier. Read actions (browse, preview, download, inspect permissions) require **stack read** permission, which every signed-in role has by default. Write actions (upload, edit, create, rename, change permissions, delete) require **stack edit** permission on your account. +The file explorer is available on every Sencho tier. Read actions (browse, preview, download, inspect permissions) require **stack read** permission, which every signed-in role has by default. Write actions (upload, edit, create, rename, move, change permissions, delete) require **stack edit** permission on your account. ## Layout @@ -116,9 +116,18 @@ When a file is selected, the right pane action bar shows **Download**. Files str ## Renaming -Right-click any file or folder and choose **Rename**. The dialog accepts a new name following the same rules as creation. +Right-click any file or folder and choose **Rename**. The dialog accepts a new name following the same rules as creation. Rename keeps the entry in its current folder. -Rename appears only when your account has stack edit permission. The rename is in-place; cross-directory moves are not supported. To move an entry between directories, copy it via the host shell or upload to the new location and delete the original. +Rename appears only when your account has stack edit permission. + +## Moving + +Move a file or folder into a different directory in one of two ways: + +- **Move to…** Right-click the entry and choose **Move to…**. The dialog shows the stack's folder tree; pick a destination folder (or **Stack root**) and choose **Move**. Destinations that would collide or make no sense (the entry's current folder, a folder inside the entry itself, or a name that is reserved at the stack root) are not selectable. +- **Drag and drop.** Drag the entry onto a folder in the tree, or onto empty space to move it to the stack root. + +Moving requires stack edit permission. The protected stack files (compose / docker-compose / `.env`) at the stack root stay put: the compose CLI reads them from the stack directory, so they are not offered as move sources or root destinations. Moving a file changes only where it lives on disk; it does not redeploy or restart the stack. ## Permissions (chmod) @@ -164,8 +173,10 @@ When the entry is one of the five protected names, the modal asks you to type th | Right-click target | Admin entries (with stack edit) | Viewer entries | |---|---|---| -| Folder | New File, New Folder, Rename, Delete | No write entries | -| File | Rename, Permissions, Delete | Permissions (read-only) | +| Folder | New File, New Folder, Rename, Move to…, Delete | No write entries | +| File | Rename, Move to…, Permissions, Delete | Permissions (read-only) | + +**Move to…** is absent for the protected stack files (compose / docker-compose / `.env`) at the stack root, which cannot be moved out of the stack directory. The Permissions dialog opens for everyone; only users with stack edit permission can save changes. @@ -203,6 +214,6 @@ The Permissions dialog opens for everyone; only users with stack edit permission Each directory render is capped at 1000 entries to keep the tree responsive. Use the filter input above the tree to narrow the list, or drop into a host shell with `cd` into the stack directory if you need to work with entries past the cap. - Upload, create, rename, chmod save, and delete require **stack edit** permission. Viewer accounts can browse, preview text files, and inspect permissions in read-only mode. + Upload, create, rename, move, chmod save, and delete require **stack edit** permission. Viewer accounts can browse, preview text files, and inspect permissions in read-only mode. diff --git a/frontend/src/components/files/FileTree.tsx b/frontend/src/components/files/FileTree.tsx index 2365ba2d..f6701312 100644 --- a/frontend/src/components/files/FileTree.tsx +++ b/frontend/src/components/files/FileTree.tsx @@ -1,12 +1,17 @@ import { useState, useEffect, useRef, Fragment } from 'react'; -import type { ReactNode } from 'react'; +import type { ReactNode, DragEvent } from 'react'; import { Search, X } from 'lucide-react'; import { Input } from '@/components/ui/input'; import { ScrollArea } from '@/components/ui/scroll-area'; import { Skeleton } from '@/components/ui/skeleton'; import { toast } from '@/components/ui/toast-store'; -import type { FileEntry } from '@/lib/stackFilesApi'; +import { + readFileEntryDragPayload, + relPathParentDir, + type FileEntry, +} from '@/lib/stackFilesApi'; import { FileTreeNode } from './FileTreeNode'; +import { cn } from '@/lib/utils'; interface FileTreeProps { /** Loads directory contents at `relPath` (use '' for the tree root). */ @@ -21,10 +26,13 @@ interface FileTreeProps { // Context menu wiring canEdit?: boolean; onContextMenuRename?: (relPath: string) => void; + onContextMenuMove?: (relPath: string, entry: FileEntry) => void; onContextMenuNewFile?: (dirRelPath: string) => void; onContextMenuNewFolder?: (dirRelPath: string) => void; onContextMenuDelete?: (relPath: string, entry: FileEntry) => void; onContextMenuPermissions?: (relPath: string, entry: FileEntry) => void; + /** Relocate `fromRel` into `destDir` (''=stack root) via drag-and-drop. */ + onMove?: (fromRel: string, entryName: string, destDir: string) => void; } const COMPOSE_NAMES = new Set(['compose.yaml', 'compose.yml']); @@ -44,10 +52,12 @@ export function FileTree({ onNavigateToEnv, canEdit = false, onContextMenuRename = () => undefined, + onContextMenuMove = () => undefined, onContextMenuNewFile = () => undefined, onContextMenuNewFolder = () => undefined, onContextMenuDelete = () => undefined, onContextMenuPermissions = () => undefined, + onMove = () => undefined, }: FileTreeProps) { const [rootEntries, setRootEntries] = useState(null); const [rootLoading, setRootLoading] = useState(true); @@ -56,6 +66,28 @@ export function FileTree({ const [dirContents, setDirContents] = useState>(new Map()); const [loadingDirs, setLoadingDirs] = useState>(new Set()); const [filter, setFilter] = useState(''); + const [isRootDropTarget, setIsRootDropTarget] = useState(false); + + // The scroll area is the stack-root drop target. Folder nodes stop propagation + // on their own drops, so an event only reaches here when it lands on a file + // row or empty space. A root-level entry dropped here is a no-op and ignored. + function handleRootDragOver(e: DragEvent) { + if (!canEdit) return; + const payload = readFileEntryDragPayload(e.dataTransfer); + if (!payload || relPathParentDir(payload.relPath) === '') return; + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + if (!isRootDropTarget) setIsRootDropTarget(true); + } + + function handleRootDrop(e: DragEvent) { + if (!canEdit) return; + const payload = readFileEntryDragPayload(e.dataTransfer); + setIsRootDropTarget(false); + if (!payload || relPathParentDir(payload.relPath) === '') return; + e.preventDefault(); + onMove(payload.relPath, payload.name, ''); + } const sourceKeyRef = useRef(sourceKey); const loadDirRef = useRef(loadDir); @@ -214,10 +246,12 @@ export function FileTree({ }} canEdit={canEdit} onContextMenuRename={onContextMenuRename} + onContextMenuMove={onContextMenuMove} onContextMenuNewFile={onContextMenuNewFile} onContextMenuNewFolder={onContextMenuNewFolder} onContextMenuDelete={onContextMenuDelete} onContextMenuPermissions={onContextMenuPermissions} + onMove={onMove} /> {isDir && isExpanded && children !== undefined && ( children.length === 0 @@ -295,7 +329,13 @@ export function FileTree({ )} -
+
setIsRootDropTarget(false)} + onDrop={handleRootDrop} + > {renderEntries(rootEntries, '', 0)}
diff --git a/frontend/src/components/files/FileTreeContextMenu.tsx b/frontend/src/components/files/FileTreeContextMenu.tsx index 2f66b295..e960c8ab 100644 --- a/frontend/src/components/files/FileTreeContextMenu.tsx +++ b/frontend/src/components/files/FileTreeContextMenu.tsx @@ -1,5 +1,5 @@ import type { ReactNode } from 'react'; -import { FilePlus, FolderPlus, Pencil, Lock, Trash2 } from 'lucide-react'; +import { FilePlus, FolderPlus, Pencil, FolderInput, Lock, Trash2 } from 'lucide-react'; import { ContextMenu, ContextMenuContent, @@ -7,13 +7,14 @@ import { ContextMenuSeparator, ContextMenuTrigger, } from '@/components/ui/context-menu'; -import type { FileEntry } from '@/lib/stackFilesApi'; +import { isProtectedRootRelPath, type FileEntry } from '@/lib/stackFilesApi'; interface FileTreeContextMenuProps { entry: FileEntry; relPath: string; canEdit: boolean; onRequestRename: (relPath: string) => void; + onRequestMove: (relPath: string, entry: FileEntry) => void; onRequestNewFile: (dirRelPath: string) => void; onRequestNewFolder: (dirRelPath: string) => void; onRequestDelete: (relPath: string, entry: FileEntry) => void; @@ -26,6 +27,7 @@ export function FileTreeContextMenu({ relPath, canEdit, onRequestRename, + onRequestMove, onRequestNewFile, onRequestNewFolder, onRequestDelete, @@ -34,6 +36,15 @@ export function FileTreeContextMenu({ }: FileTreeContextMenuProps) { const isDir = entry.type === 'directory'; const canWrite = canEdit; + // Protected root files (compose/.env) can never leave the stack root, so they + // are not offered as move sources. + const canMove = canWrite && !isProtectedRootRelPath(relPath); + const moveItem = canMove && ( + onRequestMove(relPath, entry)}> + + Move to… + + ); return ( @@ -64,6 +75,7 @@ export function FileTreeContextMenu({ Rename )} + {moveItem} {canWrite && ( <> @@ -85,6 +97,7 @@ export function FileTreeContextMenu({ Rename )} + {moveItem} onRequestPermissions(relPath, entry)}> Permissions diff --git a/frontend/src/components/files/FileTreeNode.tsx b/frontend/src/components/files/FileTreeNode.tsx index 8147d633..a37dc440 100644 --- a/frontend/src/components/files/FileTreeNode.tsx +++ b/frontend/src/components/files/FileTreeNode.tsx @@ -1,5 +1,15 @@ +import { useState } from 'react'; +import type { DragEvent } from 'react'; import { ChevronRight, ChevronDown, Folder, File, Link, Loader2 } from 'lucide-react'; -import type { FileEntry } from '@/lib/stackFilesApi'; +import { + FILE_ENTRY_DND_MIME, + isProtectedRootRelPath, + isSameOrDescendantPath, + readFileEntryDragPayload, + relPathParentDir, + type FileEntry, + type FileEntryDragPayload, +} from '@/lib/stackFilesApi'; import { cn } from '@/lib/utils'; import { FileTreeContextMenu } from './FileTreeContextMenu'; @@ -14,10 +24,13 @@ interface FileTreeNodeProps { // Context menu wiring canEdit: boolean; onContextMenuRename: (relPath: string) => void; + onContextMenuMove: (relPath: string, entry: FileEntry) => void; onContextMenuNewFile: (dirRelPath: string) => void; onContextMenuNewFolder: (dirRelPath: string) => void; onContextMenuDelete: (relPath: string, entry: FileEntry) => void; onContextMenuPermissions: (relPath: string, entry: FileEntry) => void; + // Drag-and-drop move: relocate `fromRel` into `destDir`. + onMove: (fromRel: string, entryName: string, destDir: string) => void; } export function FileTreeNode({ @@ -30,12 +43,56 @@ export function FileTreeNode({ onClick, canEdit, onContextMenuRename, + onContextMenuMove, onContextMenuNewFile, onContextMenuNewFolder, onContextMenuDelete, onContextMenuPermissions, + onMove, }: FileTreeNodeProps) { const isDir = entry.type === 'directory'; + const [isDropTarget, setIsDropTarget] = useState(false); + + const canDrag = canEdit && !isProtectedRootRelPath(relPath); + + // A directory accepts a dropped entry unless the drop would be a no-op (the + // entry already lives here) or would move a folder into its own subtree. + const wouldAcceptDrop = (payload: FileEntryDragPayload): boolean => { + if (relPathParentDir(payload.relPath) === relPath) return false; + if (payload.type === 'directory' && isSameOrDescendantPath(payload.relPath, relPath)) return false; + return true; + }; + + const handleDragStart = (e: DragEvent) => { + const payload: FileEntryDragPayload = { relPath, name: entry.name, type: entry.type }; + e.dataTransfer.setData(FILE_ENTRY_DND_MIME, JSON.stringify(payload)); + e.dataTransfer.effectAllowed = 'move'; + }; + + const handleDragOver = (e: DragEvent) => { + if (!isDir || !canEdit) return; + const payload = readFileEntryDragPayload(e.dataTransfer); + if (!payload || !wouldAcceptDrop(payload)) return; + e.preventDefault(); + e.stopPropagation(); + e.dataTransfer.dropEffect = 'move'; + if (!isDropTarget) setIsDropTarget(true); + }; + + const handleDragLeave = () => { + if (isDropTarget) setIsDropTarget(false); + }; + + const handleDrop = (e: DragEvent) => { + if (!isDir || !canEdit) return; + const payload = readFileEntryDragPayload(e.dataTransfer); + if (!payload) return; + e.preventDefault(); + e.stopPropagation(); + setIsDropTarget(false); + if (!wouldAcceptDrop(payload)) return; + onMove(payload.relPath, payload.name, relPath); + }; return ( { if (e.key === 'Enter' || e.key === ' ') onClick(); @@ -60,7 +123,8 @@ export function FileTreeNode({ 'flex items-center gap-1.5 py-0.5 cursor-pointer select-none rounded-sm', isSelected ? 'bg-accent text-accent-foreground' - : 'hover:bg-accent/50 text-foreground' + : 'hover:bg-accent/50 text-foreground', + isDropTarget && 'ring-1 ring-inset ring-accent-foreground/40 bg-accent/40' )} style={{ paddingLeft: depth * 16 + 8 }} > diff --git a/frontend/src/components/files/MoveFileDialog.tsx b/frontend/src/components/files/MoveFileDialog.tsx new file mode 100644 index 00000000..3e3bbc22 --- /dev/null +++ b/frontend/src/components/files/MoveFileDialog.tsx @@ -0,0 +1,256 @@ +import { useState, useEffect, useRef, Fragment } from 'react'; +import type { ReactNode } from 'react'; +import { ChevronRight, ChevronDown, Folder, FolderRoot, Loader2 } from 'lucide-react'; +import { Modal, ModalHeader, ModalBody, ModalFooter } from '@/components/ui/modal'; +import { Button } from '@/components/ui/button'; +import { toast } from '@/components/ui/toast-store'; +import { cn } from '@/lib/utils'; +import { + listStackDirectory, + isProtectedRootRelPath, + isSameOrDescendantPath, + relPathParentDir, + type FileEntry, +} from '@/lib/stackFilesApi'; + +interface MoveFileDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + stackName: string; + /** Full relative path of the entry being moved, e.g. "configs/app.conf". */ + relPath: string; + /** The entry being moved (null until a source is chosen). */ + entry: FileEntry | null; + /** Relocate `fromRel` into `destDir` (''=stack root). Resolves true only when + * the entry actually moved, so the dialog stays open on a blocked/failed move. */ + onMove: (fromRel: string, entryName: string, destDir: string) => boolean | Promise; +} + +export function MoveFileDialog({ + open, + onOpenChange, + stackName, + relPath, + entry, + onMove, +}: MoveFileDialogProps) { + // Loaded directory children, keyed by directory rel path ('' = stack root). + const [dirChildren, setDirChildren] = useState>(new Map()); + const [expanded, setExpanded] = useState>(new Set()); + const [loading, setLoading] = useState>(new Set()); + const [loadError, setLoadError] = useState>(new Set()); + const [selectedDest, setSelectedDest] = useState(null); + const [moving, setMoving] = useState(false); + // Bumped on every (re)open so stale async directory loads are discarded. + const requestSeqRef = useRef(0); + + const currentParent = relPathParentDir(relPath); + + // A destination directory is valid unless it is the entry's current parent + // (a no-op), the entry itself or a descendant (for a directory), or the stack + // root when the entry's name is reserved at the root (compose/.env files). + const isValidDest = (dir: string): boolean => { + if (!entry) return false; + if (dir === currentParent) return false; + if (entry.type === 'directory' && isSameOrDescendantPath(relPath, dir)) return false; + if (dir === '' && isProtectedRootRelPath(entry.name)) return false; + return true; + }; + + const loadDir = (dir: string) => { + const seq = requestSeqRef.current; + setLoading((prev) => new Set(prev).add(dir)); + setLoadError((prev) => { + if (!prev.has(dir)) return prev; + const next = new Set(prev); + next.delete(dir); + return next; + }); + listStackDirectory(stackName, dir) + .then((entries) => { + if (requestSeqRef.current !== seq) return; + setDirChildren((prev) => new Map(prev).set(dir, entries.filter((e) => e.type === 'directory'))); + }) + .catch((err: unknown) => { + if (requestSeqRef.current !== seq) return; + // Mark the dir as failed so its row shows an inline retry instead of + // collapsing to look like an empty folder after the toast fades. + setLoadError((prev) => new Set(prev).add(dir)); + toast.error(err instanceof Error ? err.message : 'Failed to load folders.'); + }) + .finally(() => { + if (requestSeqRef.current !== seq) return; + setLoading((prev) => { + const next = new Set(prev); + next.delete(dir); + return next; + }); + }); + }; + + useEffect(() => { + if (!open) return; + requestSeqRef.current += 1; + setDirChildren(new Map()); + setExpanded(new Set()); + setLoading(new Set()); + setLoadError(new Set()); + setSelectedDest(null); + loadDir(''); + // loadDir is stable enough for this reset; re-running only on open/source change. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open, stackName, relPath]); + + const toggleDir = (dir: string) => { + if (expanded.has(dir)) { + setExpanded((prev) => { + const next = new Set(prev); + next.delete(dir); + return next; + }); + return; + } + if (!dirChildren.has(dir)) loadDir(dir); + setExpanded((prev) => new Set(prev).add(dir)); + }; + + const handleClose = (next: boolean) => { + if (moving) return; + onOpenChange(next); + }; + + const handleMove = async () => { + if (!entry || selectedDest === null || !isValidDest(selectedDest)) return; + setMoving(true); + try { + // Close only when the move actually succeeded; a blocked or failed move + // (handled and toasted upstream) leaves the picker open to retry. + if (await onMove(relPath, entry.name, selectedDest)) onOpenChange(false); + } finally { + setMoving(false); + } + }; + + function renderDir(dir: string, depth: number): ReactNode { + const children = dirChildren.get(dir); + if (children === undefined) { + if (loadError.has(dir)) { + return ( +
+ Couldn’t load folders. + +
+ ); + } + return null; + } + if (children.length === 0) { + return ( +
+ No subfolders +
+ ); + } + return children.map((child) => { + const childRel = dir ? `${dir}/${child.name}` : child.name; + const isOpen = expanded.has(childRel); + const isLoading = loading.has(childRel); + const selectable = isValidDest(childRel); + return ( + +
+ + +
+ {isOpen && renderDir(childRel, depth + 1)} +
+ ); + }); + } + + const rootSelectable = isValidDest(''); + const rootLoading = loading.has(''); + + return ( + + + +
+ {/* Stack root row */} + + {rootLoading && dirChildren.get('') === undefined ? ( +
+ + Loading folders… +
+ ) : ( + renderDir('', 0) + )} +
+
+ handleClose(false)} disabled={moving}> + Cancel + + } + primary={ + + } + /> +
+ ); +} diff --git a/frontend/src/components/files/RenameDialog.tsx b/frontend/src/components/files/RenameDialog.tsx index 861b1b5e..5cebf6d5 100644 --- a/frontend/src/components/files/RenameDialog.tsx +++ b/frontend/src/components/files/RenameDialog.tsx @@ -5,9 +5,7 @@ import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { toast } from '@/components/ui/toast-store'; -import { renameStackPath } from '@/lib/stackFilesApi'; - -const PROTECTED_NAMES = new Set(['compose.yaml', 'compose.yml', '.env']); +import { renameStackPath, isProtectedRootRelPath } from '@/lib/stackFilesApi'; function isValidName(name: string): boolean { if (!name || name === '.' || name === '..') return false; @@ -75,7 +73,7 @@ export function RenameDialog({ } }; - const isProtected = PROTECTED_NAMES.has(currentName); + const isProtected = isProtectedRootRelPath(relPath); return ( diff --git a/frontend/src/components/files/StackFileExplorer.tsx b/frontend/src/components/files/StackFileExplorer.tsx index 3205a84e..220fa788 100644 --- a/frontend/src/components/files/StackFileExplorer.tsx +++ b/frontend/src/components/files/StackFileExplorer.tsx @@ -3,7 +3,7 @@ import { Trash2, FolderPlus, Download, Loader2 } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { ConfirmModal } from '@/components/ui/modal'; import { toast } from '@/components/ui/toast-store'; -import { downloadStackFile, listStackDirectory } from '@/lib/stackFilesApi'; +import { downloadStackFile, listStackDirectory, renameStackPath } from '@/lib/stackFilesApi'; import { FileTree } from './FileTree'; import { FileViewer } from './FileViewer'; import { FileUploadDropzone } from './FileUploadDropzone'; @@ -11,6 +11,7 @@ import { NewFolderDialog } from './NewFolderDialog'; import { NewFileDialog } from './NewFileDialog'; import { DeleteFileConfirm } from './DeleteFileConfirm'; import { RenameDialog } from './RenameDialog'; +import { MoveFileDialog } from './MoveFileDialog'; import { FilePermissionsDialog } from './FilePermissionsDialog'; import type { FileEntry } from '@/lib/stackFilesApi'; @@ -51,6 +52,11 @@ export function StackFileExplorer({ const [renameRelPath, setRenameRelPath] = useState(''); const [renameCurrentName, setRenameCurrentName] = useState(''); + // ── context menu: move ── + const [moveOpen, setMoveOpen] = useState(false); + const [moveRelPath, setMoveRelPath] = useState(''); + const [moveEntry, setMoveEntry] = useState(null); + // ── context menu: delete ── const [ctxDeleteOpen, setCtxDeleteOpen] = useState(false); const [ctxDeletePath, setCtxDeletePath] = useState(''); @@ -124,8 +130,41 @@ export function StackFileExplorer({ } }; + // Shared move handler for both the "Move to…" dialog and tree drag-and-drop. + // Relocates `fromRel` into `destDir` (''=stack root). Blocks the move when it + // would discard unsaved edits to the open file, and deselects when the open + // file (or a folder containing it) is the thing being moved. Returns true only + // when the entry actually moved, so the dialog closes on success and stays open + // when the move was a no-op, blocked, or failed. + const handleMove = useCallback(async (fromRel: string, entryName: string, destDir: string): Promise => { + const toRel = destDir ? `${destDir}/${entryName}` : entryName; + if (toRel === fromRel) return false; + const affectsOpen = selectedPath === fromRel + || (selectedPath !== null && selectedPath.startsWith(`${fromRel}/`)); + if (affectsOpen && isViewerDirty) { + toast.error('Save or discard your changes before moving this file.'); + return false; + } + try { + await renameStackPath(stackName, fromRel, toRel); + toast.success('Moved successfully.'); + if (affectsOpen) handleDeleted(); + else refresh(); + return true; + } catch (e) { + toast.error(e instanceof Error ? e.message : 'Move failed.'); + return false; + } + }, [stackName, selectedPath, isViewerDirty, handleDeleted, refresh]); + // ── Context menu callbacks ── + const handleContextMenuMove = useCallback((relPath: string, entry: FileEntry) => { + setMoveRelPath(relPath); + setMoveEntry(entry); + setMoveOpen(true); + }, []); + const handleContextMenuRename = useCallback((relPath: string) => { const name = relPath.split('/').pop() ?? relPath; setRenameRelPath(relPath); @@ -195,10 +234,12 @@ export function StackFileExplorer({ onNavigateToEnv={onNavigateToEnv} canEdit={canEdit} onContextMenuRename={handleContextMenuRename} + onContextMenuMove={handleContextMenuMove} onContextMenuNewFile={handleContextMenuNewFile} onContextMenuNewFolder={handleContextMenuNewFolder} onContextMenuDelete={handleContextMenuDelete} onContextMenuPermissions={handleContextMenuPermissions} + onMove={handleMove} />
@@ -306,6 +347,16 @@ export function StackFileExplorer({ }} /> + {/* Move */} + + {/* Permissions */} ({ toast: { @@ -250,3 +250,118 @@ describe('FileTree', () => { expect(screen.getByText(/no entries match/i)).toBeInTheDocument(); }); }); + +// ── drag-and-drop move ────────────────────────────────────────────────────── + +/** A minimal DataTransfer stand-in carrying our custom move payload (or an OS file drag). */ +function makeDataTransfer(payload: FileEntryDragPayload | null): DataTransfer { + const types = payload ? [FILE_ENTRY_DND_MIME] : ['Files']; + return { + types, + getData: (type: string) => (payload && type === FILE_ENTRY_DND_MIME ? JSON.stringify(payload) : ''), + setData: () => undefined, + dropEffect: 'none', + effectAllowed: 'all', + } as unknown as DataTransfer; +} + +function rowFor(name: string): HTMLElement { + const el = screen.getByText(name).closest('[role="button"]'); + if (!el) throw new Error(`no row for ${name}`); + return el as HTMLElement; +} + +describe('FileTree drag-and-drop move', () => { + it('calls onMove when an entry is dropped on a folder node', async () => { + mockLoadDir.mockReturnValue(fakeOk(ROOT_ENTRIES)); + const onMove = vi.fn(); + + render(); + await screen.findByText('src'); + + const payload: FileEntryDragPayload = { relPath: 'README.md', name: 'README.md', type: 'file' }; + fireEvent.drop(rowFor('src'), { dataTransfer: makeDataTransfer(payload) }); + + expect(onMove).toHaveBeenCalledWith('README.md', 'README.md', 'src'); + }); + + it('ignores an OS file drag (dataTransfer carries Files, not our payload)', async () => { + mockLoadDir.mockReturnValue(fakeOk(ROOT_ENTRIES)); + const onMove = vi.fn(); + + render(); + await screen.findByText('src'); + + fireEvent.drop(rowFor('src'), { dataTransfer: makeDataTransfer(null) }); + + expect(onMove).not.toHaveBeenCalled(); + }); + + it('moves a nested entry to the stack root when dropped on the root area', async () => { + mockLoadDir + .mockReturnValueOnce(fakeOk(ROOT_ENTRIES)) + .mockReturnValueOnce(fakeOk(SRC_ENTRIES)); + const onMove = vi.fn(); + const user = userEvent.setup(); + + render(); + await screen.findByText('src'); + await user.click(screen.getByText('src')); + await screen.findByText('index.ts'); + + const rootZone = screen.getByTestId('file-tree-root-dropzone'); + const payload: FileEntryDragPayload = { relPath: 'src/index.ts', name: 'index.ts', type: 'file' }; + fireEvent.dragOver(rootZone, { dataTransfer: makeDataTransfer(payload) }); + fireEvent.drop(rootZone, { dataTransfer: makeDataTransfer(payload) }); + + expect(onMove).toHaveBeenCalledWith('src/index.ts', 'index.ts', ''); + }); + + it('ignores a drop of a folder onto one of its own descendants', async () => { + mockLoadDir + .mockReturnValueOnce(fakeOk([makeDir('src'), makeFile('README.md')])) + .mockReturnValueOnce(fakeOk([makeDir('lib'), makeFile('index.ts')])); + const onMove = vi.fn(); + const user = userEvent.setup(); + + render(); + await screen.findByText('src'); + await user.click(screen.getByText('src')); + await screen.findByText('lib'); + + // Drop the `src` folder onto `src/lib`, its own descendant. + const payload: FileEntryDragPayload = { relPath: 'src', name: 'src', type: 'directory' }; + fireEvent.drop(rowFor('lib'), { dataTransfer: makeDataTransfer(payload) }); + + expect(onMove).not.toHaveBeenCalled(); + }); + + it('ignores a drop onto the entry\'s current parent (no-op)', async () => { + mockLoadDir + .mockReturnValueOnce(fakeOk([makeDir('src'), makeFile('README.md')])) + .mockReturnValueOnce(fakeOk([makeFile('index.ts')])); + const onMove = vi.fn(); + const user = userEvent.setup(); + + render(); + await screen.findByText('src'); + await user.click(screen.getByText('src')); + await screen.findByText('index.ts'); + + // Drop `src/index.ts` back onto `src`, where it already lives. + const payload: FileEntryDragPayload = { relPath: 'src/index.ts', name: 'index.ts', type: 'file' }; + fireEvent.drop(rowFor('src'), { dataTransfer: makeDataTransfer(payload) }); + + expect(onMove).not.toHaveBeenCalled(); + }); + + it('makes ordinary entries draggable but not protected root files', async () => { + mockLoadDir.mockReturnValue(fakeOk([makeFile('compose.yaml'), makeFile('README.md')])); + + render(); + await screen.findByText('README.md'); + + expect(rowFor('README.md').draggable).toBe(true); + expect(rowFor('compose.yaml').draggable).toBe(false); + }); +}); diff --git a/frontend/src/components/files/__tests__/MoveFileDialog.test.tsx b/frontend/src/components/files/__tests__/MoveFileDialog.test.tsx new file mode 100644 index 00000000..82eff0e5 --- /dev/null +++ b/frontend/src/components/files/__tests__/MoveFileDialog.test.tsx @@ -0,0 +1,148 @@ +/** + * Coverage for MoveFileDialog's destination gating and confirm behaviour. + * + * The folder picker must never offer an invalid destination: the entry's own + * current parent (a no-op), the entry itself or a descendant (for a directory), + * or the stack root when the entry's name is reserved there (compose/.env). + * listStackDirectory is mocked; the real path helpers are kept so the gating + * logic under test runs for real. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import type { FileEntry } from '@/lib/stackFilesApi'; + +const listMock = vi.hoisted(() => vi.fn<(stack: string, dir: string) => Promise>()); + +vi.mock('@/lib/stackFilesApi', async (orig) => ({ + ...(await orig()), + listStackDirectory: listMock, +})); + +vi.mock('@/components/ui/toast-store', () => ({ + toast: { error: vi.fn(), success: vi.fn(), loading: vi.fn(() => 'id'), dismiss: vi.fn() }, +})); + +import { MoveFileDialog } from '../MoveFileDialog'; + +function dir(name: string): FileEntry { + return { name, type: 'directory', size: 0, mtime: 0, isProtected: false }; +} + +function file(name: string, type: FileEntry['type'] = 'file'): FileEntry { + return { name, type, size: 1, mtime: 0, isProtected: false }; +} + +function labelButton(name: string): HTMLButtonElement { + const btn = screen.getByText(name).closest('button'); + if (!btn) throw new Error(`no button for ${name}`); + return btn as HTMLButtonElement; +} + +beforeEach(() => { + listMock.mockReset(); +}); + +describe('MoveFileDialog', () => { + it('disables the current parent and confirms a valid destination, closing on success', async () => { + listMock.mockResolvedValue([dir('configs'), dir('services'), dir('logs')]); + const onMove = vi.fn().mockResolvedValue(true); + const onOpenChange = vi.fn(); + const user = userEvent.setup(); + + render( + , + ); + + await screen.findByText('services'); + + // Move is disabled until a destination is chosen. + const moveBtn = screen.getByRole('button', { name: /^move$/i }); + expect(moveBtn).toBeDisabled(); + + // The entry's current parent is a no-op destination and is disabled. + expect(labelButton('configs')).toBeDisabled(); + // The stack root is valid here (app.conf is not a reserved root name). + expect(labelButton('Stack root')).toBeEnabled(); + + await user.click(labelButton('services')); + expect(moveBtn).toBeEnabled(); + + await user.click(moveBtn); + expect(onMove).toHaveBeenCalledWith('configs/app.conf', 'app.conf', 'services'); + await waitFor(() => expect(onOpenChange).toHaveBeenCalledWith(false)); + }); + + it('stays open when the move is blocked or fails', async () => { + listMock.mockResolvedValue([dir('services')]); + const onMove = vi.fn().mockResolvedValue(false); + const onOpenChange = vi.fn(); + const user = userEvent.setup(); + + render( + , + ); + + await screen.findByText('services'); + await user.click(labelButton('services')); + await user.click(screen.getByRole('button', { name: /^move$/i })); + + expect(onMove).toHaveBeenCalledWith('configs/app.conf', 'app.conf', 'services'); + // A falsy result must not dismiss the picker. + expect(onOpenChange).not.toHaveBeenCalledWith(false); + }); + + it('disables the stack root when the entry name is reserved there', async () => { + listMock.mockResolvedValue([dir('configs')]); + + render( + , + ); + + await screen.findByText('configs'); + expect(labelButton('Stack root')).toBeDisabled(); + }); + + it('disables a directory destination that is the source itself', async () => { + listMock.mockResolvedValue([dir('parent'), dir('other')]); + + render( + , + ); + + await screen.findByText('other'); + // Source-into-itself is blocked; an unrelated sibling stays selectable. + expect(labelButton('parent')).toBeDisabled(); + expect(labelButton('other')).toBeEnabled(); + // Root is the source's current parent here, so it is also a no-op. + expect(labelButton('Stack root')).toBeDisabled(); + }); +}); diff --git a/frontend/src/components/files/__tests__/StackFileExplorer.test.tsx b/frontend/src/components/files/__tests__/StackFileExplorer.test.tsx index 11cb6ef9..d3bb075c 100644 --- a/frontend/src/components/files/__tests__/StackFileExplorer.test.tsx +++ b/frontend/src/components/files/__tests__/StackFileExplorer.test.tsx @@ -7,20 +7,30 @@ * exposes a "Mark dirty" button so the test can drive the dirty signal * without instantiating Monaco. */ -import { describe, it, expect, vi } from 'vitest'; -import { render, screen } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import type { FileEntry } from '@/lib/stackFilesApi'; +// Holder for the renameStackPath mock and the captured onMove callback, so tests +// can drive the shared move handler directly (the DnD path passes it as onMove). +const h = vi.hoisted(() => ({ + renameMock: vi.fn<(stack: string, from: string, to: string) => Promise>(), + onMove: null as null | ((fromRel: string, entryName: string, destDir: string) => void), + toastError: vi.fn(), + toastSuccess: vi.fn(), +})); + vi.mock('@/lib/stackFilesApi', () => ({ listStackDirectory: vi.fn().mockResolvedValue([]), downloadStackFile: vi.fn(), readStackFile: vi.fn(), writeStackFile: vi.fn(), + renameStackPath: h.renameMock, })); vi.mock('@/components/ui/toast-store', () => ({ - toast: { error: vi.fn(), success: vi.fn(), loading: vi.fn(() => 'id'), dismiss: vi.fn() }, + toast: { error: h.toastError, success: h.toastSuccess, loading: vi.fn(() => 'id'), dismiss: vi.fn() }, })); vi.mock('../FileUploadDropzone', () => ({ @@ -31,20 +41,31 @@ vi.mock('../NewFolderDialog', () => ({ NewFolderDialog: () => null })); vi.mock('../NewFileDialog', () => ({ NewFileDialog: () => null })); vi.mock('../DeleteFileConfirm', () => ({ DeleteFileConfirm: () => null })); vi.mock('../RenameDialog', () => ({ RenameDialog: () => null })); +vi.mock('../MoveFileDialog', () => ({ MoveFileDialog: () => null })); vi.mock('../FilePermissionsDialog', () => ({ FilePermissionsDialog: () => null })); -// FileTree mock exposes two buttons that synthesise selection of two siblings. +// FileTree mock: selection buttons (two siblings + one nested file) plus capture +// of the onMove callback so move-handler behaviour can be driven directly. vi.mock('../FileTree', () => ({ - FileTree: ({ onSelectFile }: { onSelectFile: (rel: string, entry: FileEntry) => void }) => ( -
- - -
- ), + FileTree: ({ onSelectFile, onMove }: { + onSelectFile: (rel: string, entry: FileEntry) => void; + onMove?: (fromRel: string, entryName: string, destDir: string) => void; + }) => { + h.onMove = onMove ?? null; + return ( +
+ + + +
+ ); + }, })); // FileViewer mock exposes a button that flips its dirty signal. @@ -124,3 +145,73 @@ describe('StackFileExplorer unsaved-changes interception', () => { expect(screen.queryByText(/discard unsaved changes/i)).not.toBeInTheDocument(); }); }); + +describe('StackFileExplorer move handling', () => { + beforeEach(() => { + h.renameMock.mockReset().mockResolvedValue(undefined); + h.toastError.mockReset(); + h.toastSuccess.mockReset(); + }); + + it('moves an unaffected entry and reports success without deselecting', async () => { + const user = userEvent.setup(); + setup(); + await user.click(screen.getByText('select-a')); + + h.onMove?.('other.txt', 'other.txt', 'sub'); + + await waitFor(() => expect(h.renameMock).toHaveBeenCalledWith('my-stack', 'other.txt', 'sub/other.txt')); + await waitFor(() => expect(h.toastSuccess).toHaveBeenCalledWith('Moved successfully.')); + // The open file was not the one moved, so the viewer keeps its selection. + expect(screen.getByTestId('viewer-selected').textContent).toBe('a.txt'); + }); + + it('blocks the move and warns when the open file has unsaved edits', async () => { + const user = userEvent.setup(); + setup(); + await user.click(screen.getByText('select-a')); + await user.click(screen.getByText('mark-dirty')); + + h.onMove?.('a.txt', 'a.txt', 'sub'); + + await waitFor(() => expect(h.toastError).toHaveBeenCalledWith(expect.stringMatching(/save or discard/i))); + expect(h.renameMock).not.toHaveBeenCalled(); + expect(screen.getByTestId('viewer-selected').textContent).toBe('a.txt'); + }); + + it('deselects the viewer when the open file itself is moved', async () => { + const user = userEvent.setup(); + setup(); + await user.click(screen.getByText('select-a')); + + h.onMove?.('a.txt', 'a.txt', 'sub'); + + await waitFor(() => expect(h.renameMock).toHaveBeenCalledWith('my-stack', 'a.txt', 'sub/a.txt')); + await waitFor(() => expect(screen.getByTestId('viewer-selected').textContent).toBe('(none)')); + }); + + it('deselects the viewer when a folder containing the open file is moved', async () => { + const user = userEvent.setup(); + setup(); + await user.click(screen.getByText('select-nested')); + expect(screen.getByTestId('viewer-selected').textContent).toBe('dir/a.txt'); + + h.onMove?.('dir', 'dir', 'other'); + + await waitFor(() => expect(h.renameMock).toHaveBeenCalledWith('my-stack', 'dir', 'other/dir')); + await waitFor(() => expect(screen.getByTestId('viewer-selected').textContent).toBe('(none)')); + }); + + it('surfaces an error toast when the move fails', async () => { + h.renameMock.mockRejectedValueOnce(new Error('Cannot move across a storage boundary')); + const user = userEvent.setup(); + setup(); + await user.click(screen.getByText('select-a')); + + // Move a different file so the open file is unaffected; only the toast matters. + h.onMove?.('other.txt', 'other.txt', 'sub'); + + await waitFor(() => expect(h.toastError).toHaveBeenCalledWith('Cannot move across a storage boundary')); + expect(screen.getByTestId('viewer-selected').textContent).toBe('a.txt'); + }); +}); diff --git a/frontend/src/lib/__tests__/stackFilesApi.test.ts b/frontend/src/lib/__tests__/stackFilesApi.test.ts index 180961b6..4d64764e 100644 --- a/frontend/src/lib/__tests__/stackFilesApi.test.ts +++ b/frontend/src/lib/__tests__/stackFilesApi.test.ts @@ -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'); + }); +}); diff --git a/frontend/src/lib/stackFilesApi.ts b/frontend/src/lib/stackFilesApi.ts index 33531a82..bea414cc 100644 --- a/frontend/src/lib/stackFilesApi.ts +++ b/frontend/src/lib/stackFilesApi.ts @@ -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 = 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';