mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-05 16:37:46 +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:
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 ────────────────────────────────────────
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
</Accordion>
|
||||
<Accordion title="Write controls are missing">
|
||||
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.
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
@@ -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<FileEntry[] | null>(null);
|
||||
const [rootLoading, setRootLoading] = useState(true);
|
||||
@@ -56,6 +66,28 @@ export function FileTree({
|
||||
const [dirContents, setDirContents] = useState<Map<string, FileEntry[]>>(new Map());
|
||||
const [loadingDirs, setLoadingDirs] = useState<Set<string>>(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({
|
||||
)}
|
||||
</div>
|
||||
<ScrollArea type="hover" className="flex-1 min-h-0">
|
||||
<div className="py-1">
|
||||
<div
|
||||
data-testid="file-tree-root-dropzone"
|
||||
className={cn('py-1 min-h-full', isRootDropTarget && 'bg-accent/20')}
|
||||
onDragOver={handleRootDragOver}
|
||||
onDragLeave={() => setIsRootDropTarget(false)}
|
||||
onDrop={handleRootDrop}
|
||||
>
|
||||
{renderEntries(rootEntries, '', 0)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
@@ -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 && (
|
||||
<ContextMenuItem onSelect={() => onRequestMove(relPath, entry)}>
|
||||
<FolderInput className="h-4 w-4 mr-2" strokeWidth={1.5} />
|
||||
<span>Move to…</span>
|
||||
</ContextMenuItem>
|
||||
);
|
||||
|
||||
return (
|
||||
<ContextMenu>
|
||||
@@ -64,6 +75,7 @@ export function FileTreeContextMenu({
|
||||
<span>Rename</span>
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
{moveItem}
|
||||
{canWrite && (
|
||||
<>
|
||||
<ContextMenuSeparator />
|
||||
@@ -85,6 +97,7 @@ export function FileTreeContextMenu({
|
||||
<span>Rename</span>
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
{moveItem}
|
||||
<ContextMenuItem onSelect={() => onRequestPermissions(relPath, entry)}>
|
||||
<Lock className="h-4 w-4 mr-2" strokeWidth={1.5} />
|
||||
<span>Permissions</span>
|
||||
|
||||
@@ -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 (
|
||||
<FileTreeContextMenu
|
||||
@@ -43,6 +100,7 @@ export function FileTreeNode({
|
||||
relPath={relPath}
|
||||
canEdit={canEdit}
|
||||
onRequestRename={onContextMenuRename}
|
||||
onRequestMove={onContextMenuMove}
|
||||
onRequestNewFile={onContextMenuNewFile}
|
||||
onRequestNewFolder={onContextMenuNewFolder}
|
||||
onRequestDelete={onContextMenuDelete}
|
||||
@@ -51,6 +109,11 @@ export function FileTreeNode({
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
draggable={canDrag}
|
||||
onDragStart={canDrag ? handleDragStart : undefined}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
onClick={onClick}
|
||||
onKeyDown={(e) => {
|
||||
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 }}
|
||||
>
|
||||
|
||||
@@ -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<boolean>;
|
||||
}
|
||||
|
||||
export function MoveFileDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
stackName,
|
||||
relPath,
|
||||
entry,
|
||||
onMove,
|
||||
}: MoveFileDialogProps) {
|
||||
// Loaded directory children, keyed by directory rel path ('' = stack root).
|
||||
const [dirChildren, setDirChildren] = useState<Map<string, FileEntry[]>>(new Map());
|
||||
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
||||
const [loading, setLoading] = useState<Set<string>>(new Set());
|
||||
const [loadError, setLoadError] = useState<Set<string>>(new Set());
|
||||
const [selectedDest, setSelectedDest] = useState<string | null>(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 (
|
||||
<div className="flex items-center gap-1.5 text-xs text-destructive" style={{ paddingLeft: depth * 16 + 28 }}>
|
||||
<span>Couldn’t load folders.</span>
|
||||
<button type="button" onClick={() => loadDir(dir)} className="underline hover:text-foreground">
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (children.length === 0) {
|
||||
return (
|
||||
<div className="text-xs text-muted-foreground italic" style={{ paddingLeft: depth * 16 + 28 }}>
|
||||
No subfolders
|
||||
</div>
|
||||
);
|
||||
}
|
||||
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 (
|
||||
<Fragment key={childRel}>
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-1 rounded-sm py-0.5 pr-2',
|
||||
selectedDest === childRel && 'bg-accent text-accent-foreground'
|
||||
)}
|
||||
style={{ paddingLeft: depth * 16 + 4 }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleDir(childRel)}
|
||||
className="shrink-0 text-muted-foreground hover:text-foreground"
|
||||
aria-label={isOpen ? 'Collapse folder' : 'Expand folder'}
|
||||
>
|
||||
{isLoading
|
||||
? <Loader2 className="w-3.5 h-3.5 animate-spin" strokeWidth={1.5} />
|
||||
: isOpen
|
||||
? <ChevronDown className="w-3.5 h-3.5" strokeWidth={1.5} />
|
||||
: <ChevronRight className="w-3.5 h-3.5" strokeWidth={1.5} />}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!selectable}
|
||||
onClick={() => setSelectedDest(childRel)}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 min-w-0 flex-1 text-left rounded-sm px-1',
|
||||
selectable ? 'hover:bg-accent/50' : 'opacity-40 cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
<Folder className="w-3.5 h-3.5 shrink-0" strokeWidth={1.5} />
|
||||
<span className="font-mono text-sm truncate">{child.name}</span>
|
||||
</button>
|
||||
</div>
|
||||
{isOpen && renderDir(childRel, depth + 1)}
|
||||
</Fragment>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
const rootSelectable = isValidDest('');
|
||||
const rootLoading = loading.has('');
|
||||
|
||||
return (
|
||||
<Modal open={open} onOpenChange={handleClose} size="sm">
|
||||
<ModalHeader
|
||||
kicker={`${stackName.toUpperCase()} · MOVE`}
|
||||
title="Move to…"
|
||||
description={entry ? `Choose a destination folder for ${entry.name}.` : 'Choose a destination folder.'}
|
||||
/>
|
||||
<ModalBody>
|
||||
<div className="rounded-md border border-glass-border max-h-72 overflow-y-auto p-1">
|
||||
{/* Stack root row */}
|
||||
<button
|
||||
type="button"
|
||||
disabled={!rootSelectable}
|
||||
onClick={() => setSelectedDest('')}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 w-full text-left rounded-sm px-2 py-1',
|
||||
selectedDest === '' && 'bg-accent text-accent-foreground',
|
||||
rootSelectable ? 'hover:bg-accent/50' : 'opacity-40 cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
<FolderRoot className="w-3.5 h-3.5 shrink-0" strokeWidth={1.5} />
|
||||
<span className="font-mono text-sm">Stack root</span>
|
||||
</button>
|
||||
{rootLoading && dirChildren.get('') === undefined ? (
|
||||
<div className="flex items-center gap-2 px-2 py-1 text-xs text-muted-foreground">
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" strokeWidth={1.5} />
|
||||
Loading folders…
|
||||
</div>
|
||||
) : (
|
||||
renderDir('', 0)
|
||||
)}
|
||||
</div>
|
||||
</ModalBody>
|
||||
<ModalFooter
|
||||
secondary={
|
||||
<Button variant="outline" size="sm" onClick={() => handleClose(false)} disabled={moving}>
|
||||
Cancel
|
||||
</Button>
|
||||
}
|
||||
primary={
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => void handleMove()}
|
||||
disabled={moving || selectedDest === null || !isValidDest(selectedDest)}
|
||||
>
|
||||
{moving && <Loader2 className="w-3.5 h-3.5 mr-1.5 animate-spin" strokeWidth={1.5} />}
|
||||
Move
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<Modal open={open} onOpenChange={handleClose} size="sm">
|
||||
|
||||
@@ -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<FileEntry | null>(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<boolean> => {
|
||||
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}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -306,6 +347,16 @@ export function StackFileExplorer({
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Move */}
|
||||
<MoveFileDialog
|
||||
open={moveOpen}
|
||||
onOpenChange={setMoveOpen}
|
||||
stackName={stackName}
|
||||
relPath={moveRelPath}
|
||||
entry={moveEntry}
|
||||
onMove={handleMove}
|
||||
/>
|
||||
|
||||
{/* Permissions */}
|
||||
<FilePermissionsDialog
|
||||
open={permissionsOpen}
|
||||
|
||||
@@ -6,9 +6,9 @@
|
||||
* re-expanded from cache (no second fetch) on third click.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import type { FileEntry } from '@/lib/stackFilesApi';
|
||||
import { FILE_ENTRY_DND_MIME, type FileEntry, type FileEntryDragPayload } from '@/lib/stackFilesApi';
|
||||
|
||||
vi.mock('@/components/ui/toast-store', () => ({
|
||||
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(<FileTree {...defaultProps()} canEdit onMove={onMove} />);
|
||||
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(<FileTree {...defaultProps()} canEdit onMove={onMove} />);
|
||||
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(<FileTree {...defaultProps()} canEdit onMove={onMove} />);
|
||||
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(<FileTree {...defaultProps()} canEdit onMove={onMove} />);
|
||||
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(<FileTree {...defaultProps()} canEdit onMove={onMove} />);
|
||||
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(<FileTree {...defaultProps()} canEdit onMove={vi.fn()} />);
|
||||
await screen.findByText('README.md');
|
||||
|
||||
expect(rowFor('README.md').draggable).toBe(true);
|
||||
expect(rowFor('compose.yaml').draggable).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<FileEntry[]>>());
|
||||
|
||||
vi.mock('@/lib/stackFilesApi', async (orig) => ({
|
||||
...(await orig<typeof import('@/lib/stackFilesApi')>()),
|
||||
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(
|
||||
<MoveFileDialog
|
||||
open
|
||||
onOpenChange={onOpenChange}
|
||||
stackName="my-stack"
|
||||
relPath="configs/app.conf"
|
||||
entry={file('app.conf')}
|
||||
onMove={onMove}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<MoveFileDialog
|
||||
open
|
||||
onOpenChange={onOpenChange}
|
||||
stackName="my-stack"
|
||||
relPath="configs/app.conf"
|
||||
entry={file('app.conf')}
|
||||
onMove={onMove}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<MoveFileDialog
|
||||
open
|
||||
onOpenChange={vi.fn()}
|
||||
stackName="my-stack"
|
||||
relPath="configs/.env"
|
||||
entry={file('.env')}
|
||||
onMove={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<MoveFileDialog
|
||||
open
|
||||
onOpenChange={vi.fn()}
|
||||
stackName="my-stack"
|
||||
relPath="parent"
|
||||
entry={file('parent', 'directory')}
|
||||
onMove={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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<void>>(),
|
||||
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 }) => (
|
||||
<div>
|
||||
<button onClick={() => onSelectFile('a.txt', { name: 'a.txt', type: 'file', size: 1, mtime: 0, isProtected: false })}>
|
||||
select-a
|
||||
</button>
|
||||
<button onClick={() => onSelectFile('b.txt', { name: 'b.txt', type: 'file', size: 1, mtime: 0, isProtected: false })}>
|
||||
select-b
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
FileTree: ({ onSelectFile, onMove }: {
|
||||
onSelectFile: (rel: string, entry: FileEntry) => void;
|
||||
onMove?: (fromRel: string, entryName: string, destDir: string) => void;
|
||||
}) => {
|
||||
h.onMove = onMove ?? null;
|
||||
return (
|
||||
<div>
|
||||
<button onClick={() => onSelectFile('a.txt', { name: 'a.txt', type: 'file', size: 1, mtime: 0, isProtected: false })}>
|
||||
select-a
|
||||
</button>
|
||||
<button onClick={() => onSelectFile('b.txt', { name: 'b.txt', type: 'file', size: 1, mtime: 0, isProtected: false })}>
|
||||
select-b
|
||||
</button>
|
||||
<button onClick={() => onSelectFile('dir/a.txt', { name: 'a.txt', type: 'file', size: 1, mtime: 0, isProtected: false })}>
|
||||
select-nested
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
// 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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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