diff --git a/backend/src/routes/stacks.ts b/backend/src/routes/stacks.ts index 1c103464..7a83beae 100644 --- a/backend/src/routes/stacks.ts +++ b/backend/src/routes/stacks.ts @@ -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'); + } +}); + diff --git a/backend/src/services/FileSystemService.ts b/backend/src/services/FileSystemService.ts index cd2cb968..699cf1a1 100644 --- a/backend/src/services/FileSystemService.ts +++ b/backend/src/services/FileSystemService.ts @@ -565,6 +565,43 @@ export class FileSystemService { await fsPromises.mkdir(safePath, { recursive: true }); } + async renameStackPath(stackName: string, fromRel: string, toRel: string): Promise { + 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 { + 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 { const safePath = await this.resolveSafeStackPath(stackName, relPath); // Use lstat so symlinks are reported as 'symlink' rather than resolved to target type. diff --git a/frontend/src/components/files/FilePermissionsDialog.tsx b/frontend/src/components/files/FilePermissionsDialog.tsx new file mode 100644 index 00000000..918bc6ee --- /dev/null +++ b/frontend/src/components/files/FilePermissionsDialog.tsx @@ -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(0o644); + const [loading, setLoading] = useState(false); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(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 ( + + + + {loading ? ( +
+ +
+ ) : error ? ( +

{error}

+ ) : ( +
+ {/* bit grid */} +
+ {/* header row */} +
{/* empty corner */} + {BITS.map((b) => ( +
+ {b.label} +
+ ))} + {/* category rows */} + {CATEGORIES.map((cat) => ( + <> +
{cat.label}
+ {BITS.map((bit) => { + const totalShift = cat.baseShift + bit.shift; + const checked = getBit(mode, totalShift); + return ( + + ); + })} + + ))} +
+ + {/* octal summary */} +
+ Octal + {octal} +
+
+ )} + + handleClose(false)} disabled={saving}> + {isPaid ? 'Cancel' : 'Close'} + + } + primary={ + isPaid ? ( + + ) : null + } + /> + + ); +} diff --git a/frontend/src/components/files/FileTree.tsx b/frontend/src/components/files/FileTree.tsx index 8635ce33..1319b31a 100644 --- a/frontend/src/components/files/FileTree.tsx +++ b/frontend/src/components/files/FileTree.tsx @@ -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(null); const [rootLoading, setRootLoading] = useState(true); @@ -145,6 +160,7 @@ export function FileTree({ {isDir && isExpanded && children !== undefined && ( children.length === 0 diff --git a/frontend/src/components/files/FileTreeContextMenu.tsx b/frontend/src/components/files/FileTreeContextMenu.tsx new file mode 100644 index 00000000..2ad9b55f --- /dev/null +++ b/frontend/src/components/files/FileTreeContextMenu.tsx @@ -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 ( + + {children} + + {isDir ? ( + <> + {isPaid && ( + <> + onRequestNewFile(relPath)} + > + + New File + + onRequestNewFolder(relPath)} + > + + New Folder + + + + )} + {canEdit && ( + onRequestRename(relPath)}> + + Rename + + )} + {canEdit && ( + <> + + onRequestDelete(relPath, entry)} + className="text-destructive focus:text-destructive" + > + + Delete + + + )} + + ) : ( + <> + {canEdit && ( + onRequestRename(relPath)}> + + Rename + + )} + onRequestPermissions(relPath, entry)}> + + Permissions + + {canEdit && ( + <> + + onRequestDelete(relPath, entry)} + className="text-destructive focus:text-destructive" + > + + Delete + + + )} + + )} + + + ); +} diff --git a/frontend/src/components/files/FileTreeNode.tsx b/frontend/src/components/files/FileTreeNode.tsx index c2785f65..aa3a2972 100644 --- a/frontend/src/components/files/FileTreeNode.tsx +++ b/frontend/src/components/files/FileTreeNode.tsx @@ -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 ( -
{ - 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 - ? - : isExpanded - ? - : - )} - {isDir - ? - : entry.type === 'symlink' - ? - : - } - {entry.name} - {entry.isProtected && ( - - )} -
+
{ + 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 + ? + : isExpanded + ? + : + )} + {isDir + ? + : entry.type === 'symlink' + ? + : + } + {entry.name} + {entry.isProtected && ( + + )} +
+ ); } diff --git a/frontend/src/components/files/NewFileDialog.tsx b/frontend/src/components/files/NewFileDialog.tsx new file mode 100644 index 00000000..41139a48 --- /dev/null +++ b/frontend/src/components/files/NewFileDialog.tsx @@ -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(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 ( + + + +
+ + { + setName(e.target.value); + setValidationError(null); + }} + onKeyDown={(e) => { if (e.key === 'Enter') void handleCreate(); }} + placeholder="config.yaml" + disabled={creating} + autoFocus + /> + {validationError && ( +

{validationError}

+ )} +
+
+ handleClose(false)} disabled={creating}> + Cancel + + } + primary={ + + } + /> +
+ ); +} diff --git a/frontend/src/components/files/RenameDialog.tsx b/frontend/src/components/files/RenameDialog.tsx new file mode 100644 index 00000000..861b1b5e --- /dev/null +++ b/frontend/src/components/files/RenameDialog.tsx @@ -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(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 ( + + + + {isProtected && ( +
+ + This is a critical stack file. Renaming it may break the stack. +
+ )} +
+ + { + setName(e.target.value); + setValidationError(null); + }} + onFocus={(e) => e.target.select()} + onKeyDown={(e) => { if (e.key === 'Enter') void handleRename(); }} + disabled={renaming} + autoFocus + /> + {validationError && ( +

{validationError}

+ )} +
+
+ handleClose(false)} disabled={renaming}> + Cancel + + } + primary={ + + } + /> +
+ ); +} diff --git a/frontend/src/components/files/StackFileExplorer.tsx b/frontend/src/components/files/StackFileExplorer.tsx index 8c587cdf..22ab7622 100644 --- a/frontend/src/components/files/StackFileExplorer.tsx +++ b/frontend/src/components/files/StackFileExplorer.tsx @@ -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(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(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 (
{/* 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); + }} > @@ -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} />
@@ -162,6 +230,9 @@ export function StackFileExplorer({ + {/* ── Dialogs ── */} + + {/* Toolbar delete (currently selected file) */} + {/* Context menu delete */} + { + if (ctxDeletePath === selectedPath) handleDeleted(); + else refresh(); + setCtxDeletePath(''); + setCtxDeleteEntry(null); + }} + /> + + {/* New folder (toolbar + context menu) */} + + {/* New file (context menu) */} + + + {/* Rename */} + { + // If the renamed item was selected, deselect since the path changed. + if (renameRelPath === selectedPath) handleDeleted(); + else refresh(); + }} + /> + + {/* Permissions */} + ); } diff --git a/frontend/src/lib/stackFilesApi.ts b/frontend/src/lib/stackFilesApi.ts index b3e3dcf1..56d1a64c 100644 --- a/frontend/src/lib/stackFilesApi.ts +++ b/frontend/src/lib/stackFilesApi.ts @@ -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 { + 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 { + const res = await apiFetch(stackFilesUrl(stackName, `/permissions?path=${encodeURIComponent(relPath)}`)); + if (!res.ok) throw new Error(await parseApiError(res)); + return res.json() as Promise; +} + +export async function setStackEntryPermissions( + stackName: string, + relPath: string, + mode: number +): Promise { + 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)); +} +