feat: implement file explorer context menus and dialogs (#934)

This commit is contained in:
Anso
2026-05-06 08:46:02 -04:00
committed by GitHub
parent 166ba21ff1
commit 0c3ce4b224
10 changed files with 896 additions and 37 deletions
+62
View File
@@ -977,3 +977,65 @@ stacksRouter.post('/:stackName/files/folder', async (req: Request, res: Response
return sendFsError(res, err, 'Failed to create folder');
}
});
stacksRouter.patch('/:stackName/files/rename', async (req: Request, res: Response) => {
if (!requirePaid(req, res)) return;
const stackName = req.params.stackName as string;
if (!requirePermission(req, res, 'stack:edit', 'stack', stackName)) return;
const { from, to } = req.body as { from?: unknown; to?: unknown };
if (typeof from !== 'string' || !from) {
return res.status(400).json({ error: '"from" must be a non-empty string' });
}
if (typeof to !== 'string' || !to) {
return res.status(400).json({ error: '"to" must be a non-empty string' });
}
if (!isValidRelativeStackPath(from)) {
return res.status(400).json({ error: 'Invalid source path', code: 'INVALID_PATH' });
}
if (!isValidRelativeStackPath(to)) {
return res.status(400).json({ error: 'Invalid destination path', code: 'INVALID_PATH' });
}
try {
await FileSystemService.getInstance(req.nodeId).renameStackPath(stackName, from, to);
return res.status(204).send();
} catch (err: unknown) {
return sendFsError(res, err, 'Failed to rename');
}
});
stacksRouter.get('/:stackName/files/permissions', async (req: Request, res: Response) => {
const stackName = req.params.stackName as string;
const relPath = getRelPath(req);
if (!relPath) return res.status(400).json({ error: 'path query parameter is required', code: 'INVALID_PATH' });
if (!isValidRelativeStackPath(relPath)) {
return res.status(400).json({ error: 'Invalid path', code: 'INVALID_PATH' });
}
try {
const result = await FileSystemService.getInstance(req.nodeId).getStackEntryMode(stackName, relPath);
return res.json(result);
} catch (err: unknown) {
return sendFsError(res, err, 'Failed to read permissions');
}
});
stacksRouter.put('/:stackName/files/permissions', async (req: Request, res: Response) => {
if (!requirePaid(req, res)) return;
const stackName = req.params.stackName as string;
if (!requirePermission(req, res, 'stack:edit', 'stack', stackName)) return;
const relPath = getRelPath(req);
if (!relPath) return res.status(400).json({ error: 'path query parameter is required', code: 'INVALID_PATH' });
if (!isValidRelativeStackPath(relPath)) {
return res.status(400).json({ error: 'Invalid path', code: 'INVALID_PATH' });
}
const { mode } = req.body as { mode?: unknown };
if (typeof mode !== 'number') {
return res.status(400).json({ error: '"mode" must be a number' });
}
try {
await FileSystemService.getInstance(req.nodeId).chmodStackPath(stackName, relPath, mode);
return res.status(204).send();
} catch (err: unknown) {
return sendFsError(res, err, 'Failed to set permissions');
}
});
+37
View File
@@ -565,6 +565,43 @@ export class FileSystemService {
await fsPromises.mkdir(safePath, { recursive: true });
}
async renameStackPath(stackName: string, fromRel: string, toRel: string): Promise<void> {
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 toName = path.basename(toPath);
if (!toName || toName === '.' || toName === '..') {
throw Object.assign(new Error('Invalid destination name'), { code: 'INVALID_PATH' });
}
// Prevent overwriting an existing path.
try {
await fsPromises.access(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;
if (fe.code !== 'ENOENT') throw e;
}
await fsPromises.rename(fromPath, toPath);
}
async getStackEntryMode(stackName: string, relPath: string): Promise<{ mode: number; octal: string }> {
const safePath = await this.resolveSafeStackPath(stackName, relPath);
const stat = await fsPromises.stat(safePath);
const mode = stat.mode & 0o777;
return { mode, octal: mode.toString(8).padStart(3, '0') };
}
async chmodStackPath(stackName: string, relPath: string, mode: number): Promise<void> {
if (!Number.isInteger(mode) || mode < 0 || mode > 0o777) {
throw Object.assign(new Error('Invalid permission bits'), { code: 'INVALID_PATH' });
}
const safePath = await this.resolveSafeStackPath(stackName, relPath);
await fsPromises.chmod(safePath, mode);
}
async statStackEntry(stackName: string, relPath: string): Promise<FileEntry> {
const safePath = await this.resolveSafeStackPath(stackName, relPath);
// Use lstat so symlinks are reported as 'symlink' rather than resolved to target type.
@@ -0,0 +1,190 @@
import { useState, useEffect, useCallback } from 'react';
import { 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 { getStackEntryPermissions, setStackEntryPermissions } from '@/lib/stackFilesApi';
import { cn } from '@/lib/utils';
// ---------------------------------------------------------------------------
// Bit mapping: owner (6-8), group (3-5), other (0-2)
// Within each set: read=4, write=2, execute=1
// ---------------------------------------------------------------------------
interface BitInfo {
label: 'r' | 'w' | 'x';
shift: number; // bit position
}
const BITS: BitInfo[] = [
{ label: 'r', shift: 2 },
{ label: 'w', shift: 1 },
{ label: 'x', shift: 0 },
];
interface Category {
label: string;
baseShift: number; // owner=6, group=3, other=0
}
const CATEGORIES: Category[] = [
{ label: 'Owner', baseShift: 6 },
{ label: 'Group', baseShift: 3 },
{ label: 'Other', baseShift: 0 },
];
function getBit(mode: number, totalShift: number): boolean {
return Boolean(mode & (1 << totalShift));
}
function toggleBit(mode: number, totalShift: number): number {
return mode ^ (1 << totalShift);
}
// ---------------------------------------------------------------------------
interface FilePermissionsDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
stackName: string;
relPath: string;
entryName: string;
isPaid: boolean;
}
export function FilePermissionsDialog({
open,
onOpenChange,
stackName,
relPath,
entryName,
isPaid,
}: FilePermissionsDialogProps) {
const [mode, setMode] = useState<number>(0o644);
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const load = useCallback(async () => {
setLoading(true);
setError(null);
try {
const result = await getStackEntryPermissions(stackName, relPath);
setMode(result.mode);
} catch (e) {
setError(e instanceof Error ? e.message : 'Failed to load permissions.');
} finally {
setLoading(false);
}
}, [stackName, relPath]);
useEffect(() => {
if (open) void load();
}, [open, load]);
const handleClose = (next: boolean) => {
if (saving) return;
onOpenChange(next);
};
const handleSave = async () => {
setSaving(true);
try {
await setStackEntryPermissions(stackName, relPath, mode);
toast.success('Permissions updated.');
onOpenChange(false);
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Failed to update permissions.');
} finally {
setSaving(false);
}
};
const octal = mode.toString(8).padStart(3, '0');
return (
<Modal open={open} onOpenChange={handleClose} size="sm">
<ModalHeader
kicker={`${stackName.toUpperCase()} · PERMISSIONS`}
title="Permissions"
description={`Unix permission bits for ${entryName}.`}
/>
<ModalBody>
{loading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" strokeWidth={1.5} />
</div>
) : error ? (
<p className="text-sm text-destructive">{error}</p>
) : (
<div className="space-y-4">
{/* bit grid */}
<div className="grid grid-cols-4 gap-x-3 gap-y-2 text-xs">
{/* header row */}
<div /> {/* empty corner */}
{BITS.map((b) => (
<div key={b.label} className="text-center font-mono font-medium text-muted-foreground uppercase tracking-wider">
{b.label}
</div>
))}
{/* category rows */}
{CATEGORIES.map((cat) => (
<>
<div key={cat.label} className="text-muted-foreground flex items-center">{cat.label}</div>
{BITS.map((bit) => {
const totalShift = cat.baseShift + bit.shift;
const checked = getBit(mode, totalShift);
return (
<button
key={bit.label}
type="button"
disabled={!isPaid || saving}
onClick={() => setMode((m) => toggleBit(m, totalShift))}
className={cn(
'mx-auto flex h-7 w-7 items-center justify-center rounded-md border text-xs font-mono transition-colors',
checked
? 'border-primary/60 bg-primary/10 text-primary'
: 'border-border bg-muted/30 text-muted-foreground',
isPaid && !saving && 'hover:border-primary/50 cursor-pointer',
(!isPaid || saving) && 'opacity-50 cursor-not-allowed'
)}
aria-label={`${cat.label} ${bit.label} ${checked ? 'on' : 'off'}`}
>
{bit.label}
</button>
);
})}
</>
))}
</div>
{/* octal summary */}
<div className="flex items-center justify-between rounded-md border border-border bg-muted/20 px-3 py-2">
<span className="text-xs text-muted-foreground">Octal</span>
<span className="font-mono text-sm tracking-widest">{octal}</span>
</div>
</div>
)}
</ModalBody>
<ModalFooter
secondary={
<Button variant="outline" size="sm" onClick={() => handleClose(false)} disabled={saving}>
{isPaid ? 'Cancel' : 'Close'}
</Button>
}
primary={
isPaid ? (
<Button
size="sm"
onClick={() => void handleSave()}
disabled={saving || loading}
>
{saving && <Loader2 className="w-3.5 h-3.5 mr-1.5 animate-spin" strokeWidth={1.5} />}
Save
</Button>
) : null
}
/>
</Modal>
);
}
@@ -16,6 +16,14 @@ interface FileTreeProps {
onSelectFile: (relPath: string, entry: FileEntry) => void;
onNavigateToCompose?: () => void;
onNavigateToEnv?: () => void;
// Context menu wiring
canEdit?: boolean;
isPaid?: boolean;
onContextMenuRename?: (relPath: string) => void;
onContextMenuNewFile?: (dirRelPath: string) => void;
onContextMenuNewFolder?: (dirRelPath: string) => void;
onContextMenuDelete?: (relPath: string, entry: FileEntry) => void;
onContextMenuPermissions?: (relPath: string, entry: FileEntry) => void;
}
const COMPOSE_NAMES = new Set(['compose.yaml', 'compose.yml']);
@@ -30,6 +38,13 @@ export function FileTree({
onSelectFile,
onNavigateToCompose,
onNavigateToEnv,
canEdit = false,
isPaid = false,
onContextMenuRename = () => undefined,
onContextMenuNewFile = () => undefined,
onContextMenuNewFolder = () => undefined,
onContextMenuDelete = () => undefined,
onContextMenuPermissions = () => undefined,
}: FileTreeProps) {
const [rootEntries, setRootEntries] = useState<FileEntry[] | null>(null);
const [rootLoading, setRootLoading] = useState(true);
@@ -145,6 +160,7 @@ export function FileTree({
<Fragment key={entryRelPath}>
<FileTreeNode
entry={entry}
relPath={entryRelPath}
depth={depth}
isSelected={selectedPath === entryRelPath}
isExpanded={isExpanded}
@@ -156,6 +172,13 @@ export function FileTree({
handleFileClick(entryRelPath, entry);
}
}}
canEdit={canEdit}
isPaid={isPaid}
onContextMenuRename={onContextMenuRename}
onContextMenuNewFile={onContextMenuNewFile}
onContextMenuNewFolder={onContextMenuNewFolder}
onContextMenuDelete={onContextMenuDelete}
onContextMenuPermissions={onContextMenuPermissions}
/>
{isDir && isExpanded && children !== undefined && (
children.length === 0
@@ -0,0 +1,110 @@
import type { ReactNode } from 'react';
import { FilePlus, FolderPlus, Pencil, Lock, Trash2 } from 'lucide-react';
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from '@/components/ui/context-menu';
import type { FileEntry } from '@/lib/stackFilesApi';
interface FileTreeContextMenuProps {
entry: FileEntry;
relPath: string;
canEdit: boolean;
isPaid: boolean;
onRequestRename: (relPath: string) => void;
onRequestNewFile: (dirRelPath: string) => void;
onRequestNewFolder: (dirRelPath: string) => void;
onRequestDelete: (relPath: string, entry: FileEntry) => void;
onRequestPermissions: (relPath: string, entry: FileEntry) => void;
children: ReactNode;
}
export function FileTreeContextMenu({
entry,
relPath,
canEdit,
isPaid,
onRequestRename,
onRequestNewFile,
onRequestNewFolder,
onRequestDelete,
onRequestPermissions,
children,
}: FileTreeContextMenuProps) {
const isDir = entry.type === 'directory';
return (
<ContextMenu>
<ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
<ContextMenuContent className="min-w-[180px]">
{isDir ? (
<>
{isPaid && (
<>
<ContextMenuItem
onSelect={() => onRequestNewFile(relPath)}
>
<FilePlus className="h-4 w-4 mr-2" strokeWidth={1.5} />
<span>New File</span>
</ContextMenuItem>
<ContextMenuItem
onSelect={() => onRequestNewFolder(relPath)}
>
<FolderPlus className="h-4 w-4 mr-2" strokeWidth={1.5} />
<span>New Folder</span>
</ContextMenuItem>
<ContextMenuSeparator />
</>
)}
{canEdit && (
<ContextMenuItem onSelect={() => onRequestRename(relPath)}>
<Pencil className="h-4 w-4 mr-2" strokeWidth={1.5} />
<span>Rename</span>
</ContextMenuItem>
)}
{canEdit && (
<>
<ContextMenuSeparator />
<ContextMenuItem
onSelect={() => onRequestDelete(relPath, entry)}
className="text-destructive focus:text-destructive"
>
<Trash2 className="h-4 w-4 mr-2" strokeWidth={1.5} />
<span>Delete</span>
</ContextMenuItem>
</>
)}
</>
) : (
<>
{canEdit && (
<ContextMenuItem onSelect={() => onRequestRename(relPath)}>
<Pencil className="h-4 w-4 mr-2" strokeWidth={1.5} />
<span>Rename</span>
</ContextMenuItem>
)}
<ContextMenuItem onSelect={() => onRequestPermissions(relPath, entry)}>
<Lock className="h-4 w-4 mr-2" strokeWidth={1.5} />
<span>Permissions</span>
</ContextMenuItem>
{canEdit && (
<>
<ContextMenuSeparator />
<ContextMenuItem
onSelect={() => onRequestDelete(relPath, entry)}
className="text-destructive focus:text-destructive"
>
<Trash2 className="h-4 w-4 mr-2" strokeWidth={1.5} />
<span>Delete</span>
</ContextMenuItem>
</>
)}
</>
)}
</ContextMenuContent>
</ContextMenu>
);
}
+63 -33
View File
@@ -1,60 +1,90 @@
import { ChevronRight, ChevronDown, Folder, File, Link, Loader2 } from 'lucide-react';
import type { FileEntry } from '@/lib/stackFilesApi';
import { cn } from '@/lib/utils';
import { FileTreeContextMenu } from './FileTreeContextMenu';
interface FileTreeNodeProps {
entry: FileEntry;
relPath: string;
depth: number;
isSelected: boolean;
isExpanded?: boolean;
isLoading?: boolean;
onClick: () => void;
// Context menu wiring
canEdit: boolean;
isPaid: boolean;
onContextMenuRename: (relPath: string) => void;
onContextMenuNewFile: (dirRelPath: string) => void;
onContextMenuNewFolder: (dirRelPath: string) => void;
onContextMenuDelete: (relPath: string, entry: FileEntry) => void;
onContextMenuPermissions: (relPath: string, entry: FileEntry) => void;
}
export function FileTreeNode({
entry,
relPath,
depth,
isSelected,
isExpanded,
isLoading,
onClick,
canEdit,
isPaid,
onContextMenuRename,
onContextMenuNewFile,
onContextMenuNewFolder,
onContextMenuDelete,
onContextMenuPermissions,
}: FileTreeNodeProps) {
const isDir = entry.type === 'directory';
return (
<div
role="button"
tabIndex={0}
onClick={onClick}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') onClick();
}}
aria-expanded={isDir ? isExpanded : undefined}
className={cn(
'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'
)}
style={{ paddingLeft: depth * 16 + 8 }}
<FileTreeContextMenu
entry={entry}
relPath={relPath}
canEdit={canEdit}
isPaid={isPaid}
onRequestRename={onContextMenuRename}
onRequestNewFile={onContextMenuNewFile}
onRequestNewFolder={onContextMenuNewFolder}
onRequestDelete={onContextMenuDelete}
onRequestPermissions={onContextMenuPermissions}
>
{isDir && (
isLoading
? <Loader2 className="w-3.5 h-3.5 shrink-0 animate-spin" strokeWidth={1.5} />
: isExpanded
? <ChevronDown className="w-3.5 h-3.5 shrink-0" strokeWidth={1.5} />
: <ChevronRight className="w-3.5 h-3.5 shrink-0" strokeWidth={1.5} />
)}
{isDir
? <Folder className="w-3.5 h-3.5 shrink-0" strokeWidth={1.5} />
: entry.type === 'symlink'
? <Link className="w-3.5 h-3.5 shrink-0" strokeWidth={1.5} />
: <File className="w-3.5 h-3.5 shrink-0" strokeWidth={1.5} />
}
<span className="font-mono text-sm truncate">{entry.name}</span>
{entry.isProtected && (
<span className="w-1.5 h-1.5 rounded-full bg-amber-500 shrink-0" />
)}
</div>
<div
role="button"
tabIndex={0}
onClick={onClick}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') onClick();
}}
aria-expanded={isDir ? isExpanded : undefined}
className={cn(
'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'
)}
style={{ paddingLeft: depth * 16 + 8 }}
>
{isDir && (
isLoading
? <Loader2 className="w-3.5 h-3.5 shrink-0 animate-spin" strokeWidth={1.5} />
: isExpanded
? <ChevronDown className="w-3.5 h-3.5 shrink-0" strokeWidth={1.5} />
: <ChevronRight className="w-3.5 h-3.5 shrink-0" strokeWidth={1.5} />
)}
{isDir
? <Folder className="w-3.5 h-3.5 shrink-0" strokeWidth={1.5} />
: entry.type === 'symlink'
? <Link className="w-3.5 h-3.5 shrink-0" strokeWidth={1.5} />
: <File className="w-3.5 h-3.5 shrink-0" strokeWidth={1.5} />
}
<span className="font-mono text-sm truncate">{entry.name}</span>
{entry.isProtected && (
<span className="w-1.5 h-1.5 rounded-full bg-amber-500 shrink-0" />
)}
</div>
</FileTreeContextMenu>
);
}
@@ -0,0 +1,116 @@
import { useState } from 'react';
import { Loader2 } from 'lucide-react';
import { Modal, ModalHeader, ModalBody, ModalFooter } from '@/components/ui/modal';
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 { writeStackFile } from '@/lib/stackFilesApi';
function isValidFileName(name: string): boolean {
if (!name || name === '.' || name === '..') return false;
return /^[^/\\]+$/.test(name);
}
interface NewFileDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
stackName: string;
/** Directory within the stack where the file will be created */
currentDir: string;
onCreated: () => void;
}
export function NewFileDialog({
open,
onOpenChange,
stackName,
currentDir,
onCreated,
}: NewFileDialogProps) {
const [name, setName] = useState('');
const [creating, setCreating] = useState(false);
const [validationError, setValidationError] = useState<string | null>(null);
const handleClose = (next: boolean) => {
if (creating) return;
onOpenChange(next);
if (!next) {
setName('');
setValidationError(null);
}
};
const handleCreate = async () => {
const trimmed = name.trim();
if (!isValidFileName(trimmed)) {
setValidationError('File name must not be empty and must not contain / or \\.');
return;
}
setValidationError(null);
setCreating(true);
const relPath = currentDir ? `${currentDir}/${trimmed}` : trimmed;
try {
await writeStackFile(stackName, relPath, '');
toast.success('File created.');
onCreated();
onOpenChange(false);
setName('');
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Failed to create file.');
} finally {
setCreating(false);
}
};
const parentLabel = currentDir || stackName;
return (
<Modal open={open} onOpenChange={handleClose} size="sm">
<ModalHeader
kicker={`${stackName.toUpperCase()} · NEW FILE`}
title="New file"
description="Enter a name for the new file."
/>
<ModalBody>
<div className="space-y-1.5">
<Label htmlFor="new-file-name">File name</Label>
<Input
id="new-file-name"
value={name}
onChange={(e) => {
setName(e.target.value);
setValidationError(null);
}}
onKeyDown={(e) => { if (e.key === 'Enter') void handleCreate(); }}
placeholder="config.yaml"
disabled={creating}
autoFocus
/>
{validationError && (
<p className="text-xs text-destructive">{validationError}</p>
)}
</div>
</ModalBody>
<ModalFooter
hint="PARENT"
hintAccent={parentLabel}
secondary={
<Button variant="outline" size="sm" onClick={() => handleClose(false)} disabled={creating}>
Cancel
</Button>
}
primary={
<Button
size="sm"
onClick={() => void handleCreate()}
disabled={creating || !name.trim()}
>
{creating && <Loader2 className="w-3.5 h-3.5 mr-1.5 animate-spin" strokeWidth={1.5} />}
Create
</Button>
}
/>
</Modal>
);
}
@@ -0,0 +1,132 @@
import { useState, useEffect } from 'react';
import { AlertTriangle, Loader2 } from 'lucide-react';
import { Modal, ModalHeader, ModalBody, ModalFooter } from '@/components/ui/modal';
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']);
function isValidName(name: string): boolean {
if (!name || name === '.' || name === '..') return false;
return /^[^/\\]+$/.test(name);
}
interface RenameDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
stackName: string;
/** Full relative path of the entry to rename, e.g. "configs/my-file.conf" */
relPath: string;
/** Current basename of the entry */
currentName: string;
onRenamed: () => void;
}
export function RenameDialog({
open,
onOpenChange,
stackName,
relPath,
currentName,
onRenamed,
}: RenameDialogProps) {
const [name, setName] = useState('');
const [renaming, setRenaming] = useState(false);
const [validationError, setValidationError] = useState<string | null>(null);
useEffect(() => {
if (open) {
setName(currentName);
setValidationError(null);
}
}, [open, currentName]);
const handleClose = (next: boolean) => {
if (renaming) return;
onOpenChange(next);
};
const handleRename = async () => {
const trimmed = name.trim();
if (!isValidName(trimmed)) {
setValidationError('Name must not be empty and must not contain / or \\.');
return;
}
if (trimmed === currentName) {
onOpenChange(false);
return;
}
setValidationError(null);
setRenaming(true);
const parentDir = relPath.includes('/') ? relPath.slice(0, relPath.lastIndexOf('/')) : '';
const toRel = parentDir ? `${parentDir}/${trimmed}` : trimmed;
try {
await renameStackPath(stackName, relPath, toRel);
toast.success('Renamed successfully.');
onRenamed();
onOpenChange(false);
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Rename failed.');
} finally {
setRenaming(false);
}
};
const isProtected = PROTECTED_NAMES.has(currentName);
return (
<Modal open={open} onOpenChange={handleClose} size="sm">
<ModalHeader
kicker={`${stackName.toUpperCase()} · RENAME`}
title="Rename"
description={`Enter a new name for ${currentName}.`}
/>
<ModalBody>
{isProtected && (
<div className="flex items-start gap-2 rounded-md border border-amber-500/30 bg-amber-500/5 px-3 py-2 text-xs text-amber-500 mb-3">
<AlertTriangle className="w-4 h-4 shrink-0 mt-0.5" strokeWidth={1.5} />
<span>This is a critical stack file. Renaming it may break the stack.</span>
</div>
)}
<div className="space-y-1.5">
<Label htmlFor="rename-input">New name</Label>
<Input
id="rename-input"
value={name}
onChange={(e) => {
setName(e.target.value);
setValidationError(null);
}}
onFocus={(e) => e.target.select()}
onKeyDown={(e) => { if (e.key === 'Enter') void handleRename(); }}
disabled={renaming}
autoFocus
/>
{validationError && (
<p className="text-xs text-destructive">{validationError}</p>
)}
</div>
</ModalBody>
<ModalFooter
secondary={
<Button variant="outline" size="sm" onClick={() => handleClose(false)} disabled={renaming}>
Cancel
</Button>
}
primary={
<Button
size="sm"
onClick={() => void handleRename()}
disabled={renaming || !name.trim() || name.trim() === currentName}
>
{renaming && <Loader2 className="w-3.5 h-3.5 mr-1.5 animate-spin" strokeWidth={1.5} />}
Rename
</Button>
}
/>
</Modal>
);
}
@@ -8,7 +8,10 @@ import { FileTree } from './FileTree';
import { FileViewer } from './FileViewer';
import { FileUploadDropzone } from './FileUploadDropzone';
import { NewFolderDialog } from './NewFolderDialog';
import { NewFileDialog } from './NewFileDialog';
import { DeleteFileConfirm } from './DeleteFileConfirm';
import { RenameDialog } from './RenameDialog';
import { FilePermissionsDialog } from './FilePermissionsDialog';
import type { FileEntry } from '@/lib/stackFilesApi';
interface StackFileExplorerProps {
@@ -31,10 +34,34 @@ export function StackFileExplorer({
const [selectedEntry, setSelectedEntry] = useState<FileEntry | null>(null);
const [currentDir, setCurrentDir] = useState('');
const [refreshKey, setRefreshKey] = useState(0);
const [deleteOpen, setDeleteOpen] = useState(false);
const [newFolderOpen, setNewFolderOpen] = useState(false);
const [isDownloading, setIsDownloading] = useState(false);
// ── toolbar delete (existing behaviour) ──
const [deleteOpen, setDeleteOpen] = useState(false);
// ── new folder dialog (toolbar button + context menu) ──
const [newFolderOpen, setNewFolderOpen] = useState(false);
const [newFolderDir, setNewFolderDir] = useState('');
// ── context menu: new file ──
const [newFileOpen, setNewFileOpen] = useState(false);
const [newFileDir, setNewFileDir] = useState('');
// ── context menu: rename ──
const [renameOpen, setRenameOpen] = useState(false);
const [renameRelPath, setRenameRelPath] = useState('');
const [renameCurrentName, setRenameCurrentName] = useState('');
// ── context menu: delete ──
const [ctxDeleteOpen, setCtxDeleteOpen] = useState(false);
const [ctxDeletePath, setCtxDeletePath] = useState('');
const [ctxDeleteEntry, setCtxDeleteEntry] = useState<FileEntry | null>(null);
// ── context menu: permissions ──
const [permissionsOpen, setPermissionsOpen] = useState(false);
const [permissionsRelPath, setPermissionsRelPath] = useState('');
const [permissionsEntryName, setPermissionsEntryName] = useState('');
useEffect(() => {
setSelectedPath(null);
setSelectedEntry(null);
@@ -83,6 +110,37 @@ export function StackFileExplorer({
}
};
// ── Context menu callbacks ──
const handleContextMenuRename = useCallback((relPath: string) => {
const name = relPath.split('/').pop() ?? relPath;
setRenameRelPath(relPath);
setRenameCurrentName(name);
setRenameOpen(true);
}, []);
const handleContextMenuNewFile = useCallback((dirRelPath: string) => {
setNewFileDir(dirRelPath);
setNewFileOpen(true);
}, []);
const handleContextMenuNewFolder = useCallback((dirRelPath: string) => {
setNewFolderDir(dirRelPath);
setNewFolderOpen(true);
}, []);
const handleContextMenuDelete = useCallback((relPath: string, entry: FileEntry) => {
setCtxDeletePath(relPath);
setCtxDeleteEntry(entry);
setCtxDeleteOpen(true);
}, []);
const handleContextMenuPermissions = useCallback((relPath: string, entry: FileEntry) => {
setPermissionsRelPath(relPath);
setPermissionsEntryName(entry.name);
setPermissionsOpen(true);
}, []);
return (
<div className="flex h-full min-h-0">
{/* Left pane: tree + upload + new folder */}
@@ -101,7 +159,10 @@ export function StackFileExplorer({
size="icon"
className="h-6 w-6 shrink-0"
title="New folder"
onClick={() => setNewFolderOpen(true)}
onClick={() => {
setNewFolderDir(currentDir);
setNewFolderOpen(true);
}}
>
<FolderPlus className="w-3.5 h-3.5" strokeWidth={1.5} />
</Button>
@@ -117,6 +178,13 @@ export function StackFileExplorer({
onSelectFile={handleSelectFile}
onNavigateToCompose={onNavigateToCompose}
onNavigateToEnv={onNavigateToEnv}
canEdit={canEdit}
isPaid={isPaid}
onContextMenuRename={handleContextMenuRename}
onContextMenuNewFile={handleContextMenuNewFile}
onContextMenuNewFolder={handleContextMenuNewFolder}
onContextMenuDelete={handleContextMenuDelete}
onContextMenuPermissions={handleContextMenuPermissions}
/>
</div>
</div>
@@ -162,6 +230,9 @@ export function StackFileExplorer({
</div>
</div>
{/* ── Dialogs ── */}
{/* Toolbar delete (currently selected file) */}
<DeleteFileConfirm
open={deleteOpen}
onOpenChange={setDeleteOpen}
@@ -171,13 +242,62 @@ export function StackFileExplorer({
onDeleted={handleDeleted}
/>
{/* Context menu delete */}
<DeleteFileConfirm
open={ctxDeleteOpen}
onOpenChange={setCtxDeleteOpen}
stackName={stackName}
relPath={ctxDeletePath}
entry={ctxDeleteEntry}
onDeleted={() => {
if (ctxDeletePath === selectedPath) handleDeleted();
else refresh();
setCtxDeletePath('');
setCtxDeleteEntry(null);
}}
/>
{/* New folder (toolbar + context menu) */}
<NewFolderDialog
open={newFolderOpen}
onOpenChange={setNewFolderOpen}
stackName={stackName}
currentDir={currentDir}
currentDir={newFolderDir}
onCreated={refresh}
/>
{/* New file (context menu) */}
<NewFileDialog
open={newFileOpen}
onOpenChange={setNewFileOpen}
stackName={stackName}
currentDir={newFileDir}
onCreated={refresh}
/>
{/* Rename */}
<RenameDialog
open={renameOpen}
onOpenChange={setRenameOpen}
stackName={stackName}
relPath={renameRelPath}
currentName={renameCurrentName}
onRenamed={() => {
// If the renamed item was selected, deselect since the path changed.
if (renameRelPath === selectedPath) handleDeleted();
else refresh();
}}
/>
{/* Permissions */}
<FilePermissionsDialog
open={permissionsOpen}
onOpenChange={setPermissionsOpen}
stackName={stackName}
relPath={permissionsRelPath}
entryName={permissionsEntryName}
isPaid={isPaid}
/>
</div>
);
}
+39
View File
@@ -132,3 +132,42 @@ export async function mkdirStackPath(
);
if (!res.ok) throw new Error(await parseApiError(res));
}
export async function renameStackPath(
stackName: string,
fromRel: string,
toRel: string
): Promise<void> {
const res = await apiFetch(
stackFilesUrl(stackName, '/rename'),
{ method: 'PATCH', body: JSON.stringify({ from: fromRel, to: toRel }) }
);
if (!res.ok) throw new Error(await parseApiError(res));
}
export interface EntryPermissions {
mode: number;
octal: string;
}
export async function getStackEntryPermissions(
stackName: string,
relPath: string
): Promise<EntryPermissions> {
const res = await apiFetch(stackFilesUrl(stackName, `/permissions?path=${encodeURIComponent(relPath)}`));
if (!res.ok) throw new Error(await parseApiError(res));
return res.json() as Promise<EntryPermissions>;
}
export async function setStackEntryPermissions(
stackName: string,
relPath: string,
mode: number
): Promise<void> {
const res = await apiFetch(
stackFilesUrl(stackName, `/permissions?path=${encodeURIComponent(relPath)}`),
{ method: 'PUT', body: JSON.stringify({ mode }) }
);
if (!res.ok) throw new Error(await parseApiError(res));
}