feat(files): move files and folders across directories in the stack explorer (#1373)

* feat(files): move files and folders across directories in the stack explorer

Add a cross-directory move to the stack file explorer. Files and folders can
be relocated either through a "Move to..." context-menu item that opens a
folder-picker dialog, or by dragging an entry onto a folder node (or onto the
root area to move it to the stack root).

The backend reuses the existing rename endpoint: renameStackPath now resolves
both ends through the leaf helper, so a symlink moves as the link entry rather
than its target, and it guards against moving a directory into its own subtree.
A cross-filesystem rename surfaces as a clean 409 instead of a 500. Protected
root files (compose / docker-compose / .env) stay put. Moving the open file, or
a folder containing it, deselects the viewer; a move that would discard unsaved
edits is blocked with a clear message.

* fix(files): fold case in move guards and keep the move dialog open on failure

Harden the cross-directory move against case-insensitive filesystems and fix a
dialog dismissal edge:

- Protected root files (compose / docker-compose / .env) were gated by an exact,
  lowercase name match. On a case-insensitive filesystem a request like
  COMPOSE.YAML resolves to the real compose.yaml and slipped past the gate, so a
  protected file could be moved out of the stack root via the API. The gate now
  folds case on case-insensitive platforms; Linux stays case-sensitive, where a
  differently-cased name is a distinct, unprotected file.
- The directory-into-descendant guard compared resolved paths case-sensitively,
  so a source supplied with non-disk casing skipped the guard and fell through to
  an opaque OS error (500) instead of a clean 400. The comparison now folds case
  the same way.
- The move dialog closed after awaiting the move regardless of outcome, so a
  blocked move (unsaved edits) or a failed move dismissed the picker as if it had
  succeeded. The shared handler now reports success and the dialog only closes on
  an actual move.
This commit is contained in:
Anso
2026-06-14 21:56:06 -04:00
committed by GitHub
parent 0066887cee
commit 888f658a7a
16 changed files with 1204 additions and 45 deletions
+43 -3
View File
@@ -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>
+66 -2
View File
@@ -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&rsquo;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');
});
});