mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-27 12:18:59 +00:00
feat: implement file explorer context menus and dialogs (#934)
This commit is contained in:
@@ -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');
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user