diff --git a/app/(main)/_components/files.tsx b/app/(main)/_components/files.tsx index 6e432b7..ea09ec6 100644 --- a/app/(main)/_components/files.tsx +++ b/app/(main)/_components/files.tsx @@ -11,6 +11,14 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from 'ui-web/components/dropdown-menu'; + +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuTrigger, +} from 'ui-web/components/context-menu'; import { Dialog, DialogContent, @@ -20,6 +28,7 @@ import { DialogTitle, DialogTrigger, } from 'ui-web/components/dialog'; +import EventEmitterContext from '../../_context/events'; import { Input } from 'ui-web/components/input'; import { Label } from 'ui-web/components/label'; import { @@ -42,7 +51,19 @@ import { SaveIcon, XIcon, PencilIcon, + FilePlus, + FileCode, + FileJson, + FileType, + FileImage, + FileText, + FileArchive, + FileVideo, + FileAudio, + FileSpreadsheet, + FileBox, } from 'lucide-react'; +import { Progress } from 'ui-web/components/progress'; import { Alert, AlertDescription, AlertTitle } from 'ui-web/components/alert'; import Editor from '@monaco-editor/react'; import DataTable from 'ui-web/components/data-table'; @@ -53,10 +74,17 @@ interface FileBrowserProps { isLoading: boolean; isError: any; currentPath: string; + instanceName: string; + homePath?: string; onNavigate: (path: string) => void; - onUpload: (file: File) => Promise; + onUpload: ( + file: File, + onProgress?: (progress: number) => void, + ) => Promise; onCreateDirectory: (name: string) => Promise; + onCreateFile: (name: string) => Promise; onDelete: (path: string) => Promise; + onRename?: (oldName: string, newName: string) => Promise; onDownload: (path: string) => void; onFetchContent: (path: string) => Promise<{ content: string; mode?: string }>; onSaveContent: ( @@ -89,22 +117,129 @@ function formatBytes(value?: number) { return `${num.toFixed(num >= 10 ? 0 : 1)} ${units[exponent]}`; } +function getFileExtension(filename: string) { + const parts = filename.split('.'); + // No extension or dotfile without a real extension + if (parts.length <= 1 || (parts.length === 2 && filename.startsWith('.'))) { + return undefined; + } + return parts.pop()?.toLowerCase(); +} + +const FILE_ICON_CLASS = 'h-4 w-4 text-gray-500'; +const FILE_TYPE_CONFIG: Record< + string, + { icon: React.ComponentType<{ className?: string }>; language?: string } +> = { + js: { icon: FileCode, language: 'javascript' }, + jsx: { icon: FileCode, language: 'javascript' }, + ts: { icon: FileCode, language: 'typescript' }, + tsx: { icon: FileCode, language: 'typescript' }, + json: { icon: FileJson, language: 'json' }, + html: { icon: FileCode, language: 'html' }, + xml: { icon: FileCode, language: 'html' }, + css: { icon: FileType, language: 'css' }, + scss: { icon: FileType, language: 'css' }, + less: { icon: FileType, language: 'css' }, + png: { icon: FileImage }, + jpg: { icon: FileImage }, + jpeg: { icon: FileImage }, + gif: { icon: FileImage }, + svg: { icon: FileImage }, + webp: { icon: FileImage }, + txt: { icon: FileText }, + md: { icon: FileText, language: 'markdown' }, + zip: { icon: FileArchive }, + tar: { icon: FileArchive }, + gz: { icon: FileArchive }, + '7z': { icon: FileArchive }, + rar: { icon: FileArchive }, + mp4: { icon: FileVideo }, + mov: { icon: FileVideo }, + avi: { icon: FileVideo }, + mkv: { icon: FileVideo }, + mp3: { icon: FileAudio }, + wav: { icon: FileAudio }, + ogg: { icon: FileAudio }, + csv: { icon: FileSpreadsheet }, + xls: { icon: FileSpreadsheet }, + xlsx: { icon: FileSpreadsheet }, + iso: { icon: FileBox }, + img: { icon: FileBox }, + py: { icon: FileCode, language: 'python' }, + go: { icon: FileCode, language: 'go' }, + sh: { icon: FileCode, language: 'shell' }, + bash: { icon: FileCode, language: 'shell' }, + yaml: { icon: FileCode, language: 'yaml' }, + yml: { icon: FileCode, language: 'yaml' }, +}; + +function getFileIcon(filename: string) { + const ext = getFileExtension(filename) || ''; + const config = FILE_TYPE_CONFIG[ext]; + const Icon = config?.icon || FileIcon; + return ; +} + +function isEditableFile(filename: string): boolean { + const ext = getFileExtension(filename); + // Files with language mapping are editable + if (ext && FILE_TYPE_CONFIG[ext]?.language) { + return true; + } + // Files without extension are treated as text (editable) + if (!ext || !filename.includes('.')) { + return true; + } + // All other files (binary) are not editable + return false; +} + export function FileBrowser({ files, isLoading, isError, currentPath, + instanceName, + homePath = '/', onNavigate, onUpload, onCreateDirectory, + onCreateFile, onDelete, + onRename, onDownload, onFetchContent, onSaveContent, }: FileBrowserProps) { const [actionError, setActionError] = React.useState(null); const [isCreateDirOpen, setIsCreateDirOpen] = React.useState(false); + const [isCreateFileOpen, setIsCreateFileOpen] = React.useState(false); const [isUploadOpen, setIsUploadOpen] = React.useState(false); + const [isRenameOpen, setIsRenameOpen] = React.useState(false); + const [renameTarget, setRenameTarget] = React.useState(null); + const [isDragging, setIsDragging] = React.useState(false); + const [deletedFile, setDeletedFile] = React.useState(null); + const [isInitialLoad, setIsInitialLoad] = React.useState(true); + const [dropProgress, setDropProgress] = React.useState(null); + const [dropFileName, setDropFileName] = React.useState(null); + const dragCounter = React.useRef(0); + const { socket } = React.useContext(EventEmitterContext); + + // Reset editing state when path changes + React.useEffect(() => { + setEditingFile(null); + setFileContent(''); + setFileMode(undefined); + setDeletedFile(null); + }, [currentPath]); + + // Track when data loads for the first time (to distinguish initial load from refetches) + React.useEffect(() => { + if (!isLoading && isInitialLoad) { + setIsInitialLoad(false); + } + }, [isLoading, isInitialLoad]); // Editor State const [editingFile, setEditingFile] = React.useState(null); @@ -113,6 +248,46 @@ export function FileBrowser({ const [isFetchingContent, setIsFetchingContent] = React.useState(false); const [isSaving, setIsSaving] = React.useState(false); + // Listen for file deletion lifecycle events directly from the WebSocket + React.useEffect(() => { + if (!socket) return; + + const handleMessage = (event: MessageEvent) => { + try { + const data = JSON.parse(event.data) as { + type?: string; + metadata?: { + action?: string; + source?: string; + context?: Record; + }; + }; + + if (data.type !== 'lifecycle' || !data.metadata) return; + + const { action, source, context } = data.metadata; + if (action !== 'instance-file-deleted') return; + + const instanceMatch = source?.match( + /\/1\.0\/instances\/([^\/]+)\/files/, + ); + if (!instanceMatch || instanceMatch[1] !== instanceName) return; + + const filePath = context?.path; + if (filePath && editingFile === filePath) { + setDeletedFile(filePath); + } + } catch (e) { + console.error('Failed to handle lifecycle message in FileBrowser', e); + } + }; + + socket.addEventListener('message', handleMessage); + return () => { + socket.removeEventListener('message', handleMessage); + }; + }, [socket, editingFile, instanceName]); + // Prepare data for DataTable // If files already have metadata, use them. If they are just strings (legacy), map them. const fileData: FileItem[] = React.useMemo(() => { @@ -135,6 +310,13 @@ export function FileBrowser({ const handleEdit = async (fileName: string) => { const filePath = `${currentPath === '/' ? '' : currentPath}/${fileName}`; + + // Download binary files instead of opening in editor + if (!isEditableFile(fileName)) { + onDownload(filePath); + return; + } + setEditingFile(filePath); setIsFetchingContent(true); setActionError(null); @@ -163,8 +345,9 @@ export function FileBrowser({ setActionError(null); try { await onSaveContent(editingFile, fileContent, fileMode); - setEditingFile(null); - setFileMode(undefined); + // Clear deleted file warning if it was set (file is being recreated) + setDeletedFile(null); + // Don't close editor on save } catch (err: any) { setActionError(err.message); } finally { @@ -199,27 +382,81 @@ export function FileBrowser({ type === 'directory' || (!type && !name.includes('.')); return ( -
- {isDirectory ? ( - - ) : ( - - )} - { - if (isDirectory) { - onNavigate( - `${currentPath === '/' ? '' : currentPath}/${name}`, - ); - } else { - handleEdit(name); - } - }} - > - {name} - -
+ + +
+ {isDirectory ? ( + + ) : ( + getFileIcon(name) + )} + { + if (isDirectory) { + onNavigate( + `${currentPath === '/' ? '' : currentPath}/${name}`, + ); + } else { + handleEdit(name); + } + }} + > + {name} + +
+
+ + {!isDirectory && ( + handleEdit(name)}> + + Edit + + )} + { + const fullPath = `${currentPath === '/' ? '' : currentPath}/${name}`; + onDownload(fullPath); + }} + > + + Download + + { + setRenameTarget(name); + setIsRenameOpen(true); + }} + > + + Rename + + { + const fullPath = `${currentPath === '/' ? '' : currentPath}/${name}`; + if (isDirectory) { + onNavigate(fullPath); + } else { + handleEdit(name); + } + }} + > + + Open + + + { + const fullPath = `${currentPath === '/' ? '' : currentPath}/${name}`; + onDelete(fullPath).catch((e) => setActionError(e.message)); + }} + className="text-red-600" + > + + Delete + + +
); }, }, @@ -268,12 +505,33 @@ export function FileBrowser({ Edit )} + { + setRenameTarget(name); + setIsRenameOpen(true); + }} + > + + Rename + onDownload(fullPath)}> Download - onNavigate(fullPath)}> - + { + if (isDirectory) { + onNavigate(fullPath); + } else { + handleEdit(name); + } + }} + > + {isDirectory ? ( + + ) : ( + + )} Open @@ -294,15 +552,99 @@ export function FileBrowser({ }, ]; + const handleDragEnter = (e: React.DragEvent) => { + e.preventDefault(); + dragCounter.current += 1; + setIsDragging(true); + }; + + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault(); + }; + + const handleDragLeave = (e: React.DragEvent) => { + e.preventDefault(); + dragCounter.current = Math.max(0, dragCounter.current - 1); + if (dragCounter.current === 0) { + setIsDragging(false); + } + }; + + const handleDrop = async (e: React.DragEvent) => { + e.preventDefault(); + dragCounter.current = 0; + setIsDragging(false); + setActionError(null); + const files = Array.from(e.dataTransfer.files); + if (files.length === 0) return; + + const errors: string[] = []; + + await Promise.all( + files.map(async (file) => { + try { + setDropFileName(file.name); + setDropProgress(0); + await onUpload(file, (p) => setDropProgress(p)); + } catch (err: any) { + errors.push(err?.message || 'Upload failed'); + } finally { + setDropProgress(null); + setDropFileName(null); + } + }), + ); + + if (errors.length > 0) { + setActionError(errors.join('\n')); + } + }; + + const getLanguageFromFilename = (filename: string) => { + const ext = getFileExtension(filename) || ''; + return FILE_TYPE_CONFIG[ext]?.language || 'plaintext'; + }; + return ( -
+
+ {isDragging && ( +
+
+ +

Drop files to upload

+
+
+ )} + {dropProgress !== null && dropFileName && ( +
+
+ Uploading {dropFileName} +
+
+ + {Math.round(dropProgress)}% +
+
+ )}
@@ -310,7 +652,7 @@ export function FileBrowser({ onNavigate('/')} + onClick={() => onNavigate(homePath)} className="cursor-pointer" > @@ -321,8 +663,8 @@ export function FileBrowser({ !editingFile && onNavigate(crumb.path)} - className={!editingFile ? 'cursor-pointer' : ''} + onClick={() => onNavigate(crumb.path)} + className="cursor-pointer" > {crumb.name} @@ -362,14 +704,42 @@ export function FileBrowser({ onCreateDirectory(name).catch((e) => setActionError(e.message)) } /> + + onCreateFile(name).catch((e) => setActionError(e.message)) + } + /> - onUpload(file).catch((e) => setActionError(e.message)) + onUpload={(file, onProgress) => + onUpload(file, onProgress).catch((e) => + setActionError(e.message), + ) } /> + {renameTarget && ( + { + setIsRenameOpen(open); + if (!open) setRenameTarget(null); + }} + onRename={(newName) => { + if (onRename) { + return onRename(renameTarget, newName).catch((e) => + setActionError(e.message), + ); + } + return Promise.reject(new Error('Rename not implemented')); + }} + /> + )}
)}
@@ -377,7 +747,20 @@ export function FileBrowser({ {actionError && ( Action Failed - {actionError} + + {actionError} + + + )} + + {deletedFile && editingFile && ( + + File Deleted + + The file {deletedFile} has been + deleted. You can save the current content to recreate it, or close + the editor to return to the file listing. + )} @@ -391,7 +774,7 @@ export function FileBrowser({ ) : ( setFileContent(value || '')} theme="vs-dark" // Or based on system theme @@ -404,7 +787,15 @@ export function FileBrowser({
) : ( <> - {isLoading ? ( + {isLoading && !isInitialLoad ? ( +
+ +
+ ) : isLoading && isInitialLoad ? (
@@ -491,6 +882,90 @@ function CreateDirectoryDialog({ ); } +function RenameFileDialog({ + oldName, + open, + onOpenChange, + onRename, +}: { + oldName: string; + open: boolean; + onOpenChange: (open: boolean) => void; + onRename: ( + newName: string, + onProgress?: (progress: number) => void, + ) => Promise; +}) { + const [name, setName] = React.useState(oldName); + const [isLoading, setIsLoading] = React.useState(false); + const [progress, setProgress] = React.useState(0); + + // Reset name when dialog opens with a new file + React.useEffect(() => { + if (open) setName(oldName); + }, [open, oldName]); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (name === oldName) { + onOpenChange(false); + return; + } + setIsLoading(true); + setProgress(0); + try { + await onRename(name, (p) => setProgress(p)); + onOpenChange(false); + } finally { + setIsLoading(false); + setProgress(0); + } + }; + + return ( + + + + Rename File + Enter a new name for {oldName}. + +
+
+ + setName(e.target.value)} + placeholder={oldName} + required + /> +
+ {isLoading && ( +
+
+ Renaming... + {Math.round(progress)}% +
+ +
+ )} + + + +
+
+
+ ); +} + function UploadFileDialog({ currentPath, open, @@ -500,36 +975,43 @@ function UploadFileDialog({ currentPath: string; open: boolean; onOpenChange: (open: boolean) => void; - onUpload: (file: File) => Promise; + onUpload: (file: File, onProgress?: (progress: number) => void) => Promise; }) { const [file, setFile] = React.useState(null); const [isLoading, setIsLoading] = React.useState(false); + const [progress, setProgress] = React.useState(0); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); if (!file) return; + setIsLoading(true); + setProgress(0); + try { - await onUpload(file); + await onUpload(file, (p) => setProgress(p)); onOpenChange(false); setFile(null); } finally { setIsLoading(false); + setProgress(0); } }; return ( - Upload File - Upload a file to {currentPath}. + + Upload a file to {currentPath}. +
@@ -537,10 +1019,24 @@ function UploadFileDialog({ setFile(e.target.files?.[0] || null)} + onChange={(e) => { + const files = e.target.files; + if (files && files.length > 0) { + setFile(files[0]); + } + }} required />
+ {isLoading && ( +
+
+ Uploading... + {Math.round(progress)}% +
+ +
+ )}
); } + + + +function CreateFileDialog({ + currentPath, + open, + onOpenChange, + onCreate, +}: { + currentPath: string; + open: boolean; + onOpenChange: (open: boolean) => void; + onCreate: (name: string) => Promise; +}) { + const [name, setName] = React.useState(''); + const [isLoading, setIsLoading] = React.useState(false); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setIsLoading(true); + try { + await onCreate(name); + onOpenChange(false); + setName(''); + } finally { + setIsLoading(false); + } + }; + + return ( + + + + + + + Create File + + Create a new file in {currentPath}. + + + +
+ + setName(e.target.value)} + placeholder="new-file.txt" + required + /> +
+ + + + +
+
+ ); +} diff --git a/app/(main)/instance/_hooks/files.ts b/app/(main)/instance/_hooks/files.ts index 3f60c49..f6882f0 100644 --- a/app/(main)/instance/_hooks/files.ts +++ b/app/(main)/instance/_hooks/files.ts @@ -1,5 +1,6 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useContext } from 'react'; import useSWR, { mutate } from 'swr'; +import EventEmitterContext from '../../../_context/events'; import { uploadFile as apiUploadFile, createDirectory as apiCreateDirectory, @@ -8,7 +9,9 @@ import { fetchFileContent as apiFetchFileContent, saveFileContent as apiSaveFileContent, getFileMetadata as apiFetchFileMetadata, + createFile as apiCreateFile, } from '../_lib/files'; +import { incusEventTarget } from '../../../_context/events'; const directoryFetcher = async (url: string) => { const res = await fetch(url); @@ -34,10 +37,21 @@ const directoryFetcher = async (url: string) => { export function useFiles(instanceName: string, path: string) { // Ensure path starts with / const normalizedPath = path.startsWith('/') ? path : `/${path}`; + const getSWRKey = (p: string) => { + const norm = p.startsWith('/') ? p : `/${p}`; + return `/1.0/instances/${instanceName}/files?path=${encodeURIComponent(norm)}`; + }; + const revalidateCurrentPath = () => + mutate(getSWRKey(normalizedPath), undefined, { revalidate: true }); + const scheduleRevalidate = () => { + revalidateCurrentPath(); + // Some Incus operations emit events slightly before the listing is updated; re-run shortly after. + setTimeout(revalidateCurrentPath, 300); + }; // Fetch file listing const { data, error, isLoading } = useSWR( - `/1.0/instances/${instanceName}/files?path=${encodeURIComponent(normalizedPath)}`, + getSWRKey(normalizedPath), directoryFetcher, ); @@ -84,27 +98,175 @@ export function useFiles(instanceName: string, path: string) { fetchMetadata(); }, [data, instanceName, normalizedPath]); - const uploadFile = async (currentPath: string, file: File) => { - await apiUploadFile(instanceName, currentPath, file); - await mutate( - `/1.0/instances/${instanceName}/files?path=${encodeURIComponent(currentPath)}`, - ); + const { socket } = useContext(EventEmitterContext); + + // Listen for Incus events (via WebSocket and shared EventTarget) to revalidate when files change + useEffect(() => { + const handleIncusEvent = (data: any) => { + try { + const { type, metadata } = data || {}; + if (!type) return; + + if (type === 'lifecycle' && metadata) { + const { action, source } = metadata as { + action?: string; + source?: string; + }; + const sourceText = source || ''; + const isSameInstance = sourceText.includes( + `/1.0/instances/${instanceName}`, + ); + if (!isSameInstance) return; + + // Prefer matching known actions but don't rely on exact names. + const fileActions = new Set([ + 'instance-file-pushed', + 'instance-file-deleted', + 'instance-file-retrieved', + 'instance-file-created', + ]); + if (action && fileActions.has(action)) { + scheduleRevalidate(); + return; + } + + // Fallback: refresh on any lifecycle event for this instance (covers mislabelled actions). + scheduleRevalidate(); + return; + } + + if (type === 'operation' && metadata) { + const resources = (metadata as any).resources as + | Record + | undefined; + const instanceResources = + resources?.instances || resources?.instance || resources?.target; + const touchesInstance = Array.isArray(instanceResources) + ? instanceResources.some((r) => + r.includes(`/instances/${instanceName}`), + ) + : false; + if (touchesInstance) { + scheduleRevalidate(); + return; + } + + // Some operations report the target instance in metadata context instead of resources. + const maybeContext = (metadata as any).context; + const ctxInstance = + maybeContext?.instance || + maybeContext?.target || + maybeContext?.name || + ''; + if ( + typeof ctxInstance === 'string' && + ctxInstance.includes(instanceName) + ) { + scheduleRevalidate(); + } + } + } catch (e) { + console.error('Failed to handle lifecycle message', e); + } + }; + + const socketListener = (event: MessageEvent) => { + try { + const parsed = JSON.parse(event.data); + handleIncusEvent(parsed); + } catch (e) { + console.error('Failed to parse Incus event', e); + } + }; + + if (socket) { + socket.addEventListener('message', socketListener); + } + + const eventTargetListener = (event: Event) => { + const custom = event as CustomEvent; + handleIncusEvent(custom.detail); + }; + + if (incusEventTarget) { + incusEventTarget.addEventListener('incus-event', eventTargetListener); + } + + return () => { + if (socket) { + socket.removeEventListener('message', socketListener); + } + if (incusEventTarget) { + incusEventTarget.removeEventListener( + 'incus-event', + eventTargetListener, + ); + } + }; + }, [socket, instanceName, normalizedPath]); + + const uploadFile = async ( + currentPath: string, + file: File, + onProgress?: (progress: number) => void, + ) => { + await apiUploadFile(instanceName, currentPath, file, onProgress); + await revalidateCurrentPath(); + }; + + const createFile = async (currentPath: string, fileName: string) => { + await apiCreateFile(instanceName, currentPath, fileName); + await revalidateCurrentPath(); }; const createDirectory = async (currentPath: string, dirName: string) => { await apiCreateDirectory(instanceName, currentPath, dirName); - await mutate( - `/1.0/instances/${instanceName}/files?path=${encodeURIComponent(currentPath)}`, - ); + await revalidateCurrentPath(); }; const deleteFile = async (filePath: string) => { await apiDeleteFile(instanceName, filePath); - // Mutate the parent directory - const parentPath = filePath.substring(0, filePath.lastIndexOf('/')) || '/'; - await mutate( - `/1.0/instances/${instanceName}/files?path=${encodeURIComponent(parentPath)}`, - ); + await revalidateCurrentPath(); + }; + + const renameFile = async ( + oldName: string, + newName: string, + onProgress?: (progress: number) => void, + ) => { + const parentPath = normalizedPath === '/' ? '' : normalizedPath; + const oldPath = `${parentPath}/${oldName}`; + + try { + // Signal start + onProgress?.(0); + + // 1. Read old content + const { content } = await apiFetchFileContent( + instanceName, + oldPath, + ); + onProgress?.(50); + + // 2. Upload new file with progress tracking + const blob = new Blob([content], { type: 'application/octet-stream' }); + const file = new File([blob], newName, { + type: 'application/octet-stream', + }); + await apiUploadFile(instanceName, parentPath, file, (p) => { + if (p === undefined || p === null) return; + // Map 0-100 upload to 50-100 overall + const scaled = 50 + p / 2; + onProgress?.(scaled); + }); + // 3. Delete old file + await apiDeleteFile(instanceName, oldPath); + onProgress?.(100); + await revalidateCurrentPath(); + } catch (e) { + console.error('Failed to rename file:', e); + throw e; + } }; const downloadFile = (filePath: string) => { @@ -121,6 +283,7 @@ export function useFiles(instanceName: string, path: string) { mode?: string, ) => { await apiSaveFileContent(instanceName, filePath, content, mode); + await revalidateCurrentPath(); }; return { @@ -129,7 +292,9 @@ export function useFiles(instanceName: string, path: string) { isError: error, uploadFile, createDirectory, + createFile, deleteFile, + renameFile, downloadFile, fetchFileContent, saveFileContent, diff --git a/app/(main)/instance/_lib/files.ts b/app/(main)/instance/_lib/files.ts index ed01887..d6bbc61 100644 --- a/app/(main)/instance/_lib/files.ts +++ b/app/(main)/instance/_lib/files.ts @@ -3,32 +3,75 @@ export function getApiUrl(path: string) { return `${window.location.origin}${path}`; } -export async function uploadFile( +export function uploadFile( instanceName: string, currentPath: string, file: File, -) { - const filePath = `${currentPath === '/' ? '' : currentPath}/${file.name}`; - const res = await fetch( - getApiUrl( + onProgress?: (progress: number) => void, +): Promise { + return new Promise((resolve, reject) => { + const filePath = `${currentPath === '/' ? '' : currentPath}/${file.name}`; + const xhr = new XMLHttpRequest(); + const url = getApiUrl( `/1.0/instances/${instanceName}/files?path=${encodeURIComponent(filePath)}`, - ), - { - method: 'POST', - headers: { - 'X-Incus-uid': '0', - 'X-Incus-gid': '0', - 'X-Incus-mode': '0755', - 'X-Incus-type': 'file', - 'X-Incus-write': 'overwrite', - }, - body: file, - }, - ); + ); - if (!res.ok) { - throw new Error(res.statusText); - } + xhr.open('POST', url); + xhr.setRequestHeader('X-Incus-uid', '0'); + xhr.setRequestHeader('X-Incus-gid', '0'); + const scriptExtensions = [ + '.sh', + '.bash', + '.py', + '.pl', + '.rb', + '.js', + '.mjs', + '.cjs', + '.bat', + '.cgi', + '.php', + ]; + const extIndex = file.name.lastIndexOf('.'); + const ext = + extIndex > 0 ? file.name.slice(extIndex).toLowerCase() : undefined; + const isExecutable = ext ? scriptExtensions.includes(ext) : false; + xhr.setRequestHeader('X-Incus-mode', isExecutable ? '0755' : '0644'); + xhr.setRequestHeader('X-Incus-type', 'file'); + xhr.setRequestHeader('X-Incus-write', 'overwrite'); + + if (onProgress) { + xhr.upload.onprogress = (event) => { + if (event.lengthComputable) { + const percentComplete = (event.loaded / event.total) * 100; + onProgress(percentComplete); + } + }; + } + + xhr.onload = () => { + if (xhr.status >= 200 && xhr.status < 300) { + resolve(); + } else { + reject(new Error(xhr.statusText || 'Upload failed')); + } + }; + + xhr.onerror = () => { + reject(new Error('Network error')); + }; + + xhr.send(file); + }); +} + +export async function createFile( + instanceName: string, + currentPath: string, + fileName: string, +) { + const filePath = `${currentPath === '/' ? '' : currentPath}/${fileName}`; + await saveFileContent(instanceName, filePath, ''); } export async function createDirectory( diff --git a/app/(main)/instance/files/page.tsx b/app/(main)/instance/files/page.tsx index aafc88c..a0813f7 100644 --- a/app/(main)/instance/files/page.tsx +++ b/app/(main)/instance/files/page.tsx @@ -5,7 +5,9 @@ import { useRouter, usePathname } from 'next/navigation'; import { useInstanceContext } from '../_context/instance'; import { useFiles } from '../_hooks/files'; import { Spinner } from 'ui-web/components/spinner'; +import { Alert, AlertDescription, AlertTitle } from 'ui-web/components/alert'; import { FileBrowser } from '../../_components/files'; +import { getFileMetadata } from '../_lib/files'; export default function FilesPage() { const { instance, isLoading } = useInstanceContext(); @@ -24,16 +26,69 @@ export default function FilesPage() { function Files({ instance }: { instance: any }) { const router = useRouter(); const pathname = usePathname(); - const [currentPath, setCurrentPath] = React.useState('/'); + const normalizePath = (value: string) => + value?.startsWith('/') ? value : `/${value || ''}`; + const homePath = normalizePath(instance?.expanded_config?.['oci.cwd'] || '/'); + const initialPath = React.useMemo(() => { + if (typeof window === 'undefined') return homePath; + const params = new URLSearchParams(window.location.search); + return normalizePath(params.get('path') || homePath); + }, [homePath]); + + const [currentPath, setCurrentPath] = React.useState(initialPath); + const validatedPathRef = React.useRef(null); + const [pathError, setPathError] = React.useState(null); // Read path from URL on mount using manual JS React.useEffect(() => { - if (typeof window !== 'undefined') { - const params = new URLSearchParams(window.location.search); - const pathParam = params.get('path') || '/'; - setCurrentPath(pathParam); - } - }, []); + if (typeof window === 'undefined') return; + const params = new URLSearchParams(window.location.search); + const pathParam = params.get('path'); + const targetPath = normalizePath(pathParam || homePath); + + if (validatedPathRef.current === targetPath) return; + let cancelled = false; + + const updateUrl = (nextPath: string) => { + const nextParams = new URLSearchParams(window.location.search); + if (nextPath === '/' && homePath === '/') { + nextParams.delete('path'); + } else { + nextParams.set('path', nextPath); + } + const nextQuery = nextParams.toString(); + const nextUrl = nextQuery ? `${pathname}?${nextQuery}` : pathname; + const currentUrl = `${window.location.pathname}${window.location.search}`; + if (nextUrl !== currentUrl) { + router.replace(nextUrl); + } + }; + + (async () => { + try { + await getFileMetadata(instance.name, targetPath); + if (cancelled) return; + validatedPathRef.current = targetPath; + setCurrentPath(targetPath); + setPathError(null); + if (!pathParam && homePath !== '/') { + updateUrl(targetPath); + } + } catch (err) { + if (cancelled) return; + validatedPathRef.current = '/'; + setCurrentPath('/'); + setPathError( + `Default path "${targetPath}" is not accessible. Showing root instead.`, + ); + updateUrl('/'); + } + })(); + + return () => { + cancelled = true; + }; + }, [homePath, pathname, router, instance.name]); // Listen for back/forward navigation React.useEffect(() => { @@ -41,24 +96,28 @@ function Files({ instance }: { instance: any }) { const handlePopState = () => { const params = new URLSearchParams(window.location.search); - const pathParam = params.get('path') || '/'; - setCurrentPath(pathParam); + const pathParam = params.get('path') || homePath; + setCurrentPath(normalizePath(pathParam)); }; window.addEventListener('popstate', handlePopState); return () => window.removeEventListener('popstate', handlePopState); - }, []); + }, [homePath]); const handleNavigate = (path: string) => { - setCurrentPath(path); + setPathError(null); + const nextPath = normalizePath(path); + setCurrentPath(nextPath); if (typeof window !== 'undefined') { const params = new URLSearchParams(window.location.search); - if (path === '/') { + if (nextPath === '/' && homePath === '/') { params.delete('path'); } else { - params.set('path', path); + params.set('path', nextPath); } - router.push(`${pathname}?${params.toString()}`); + const target = params.toString(); + const url = target ? `${pathname}?${target}` : pathname; + router.push(url); } }; @@ -68,25 +127,41 @@ function Files({ instance }: { instance: any }) { isError, uploadFile, createDirectory, + createFile, deleteFile, + renameFile, downloadFile, fetchFileContent, saveFileContent, } = useFiles(instance.name, currentPath); return ( - uploadFile(currentPath, file)} - onCreateDirectory={(name) => createDirectory(currentPath, name)} - onDelete={deleteFile} - onDownload={downloadFile} - onFetchContent={fetchFileContent} - onSaveContent={saveFileContent} - /> + <> + {pathError && ( + + Default path unavailable + {pathError} + + )} + + uploadFile(currentPath, file, onProgress) + } + onCreateDirectory={(name) => createDirectory(currentPath, name)} + onCreateFile={(name) => createFile(currentPath, name)} + onDelete={deleteFile} + onRename={renameFile} + onDownload={downloadFile} + onFetchContent={fetchFileContent} + onSaveContent={saveFileContent} + /> + ); } diff --git a/app/_context/events.tsx b/app/_context/events.tsx index 0ea5be9..72e4a2d 100644 --- a/app/_context/events.tsx +++ b/app/_context/events.tsx @@ -3,6 +3,11 @@ import React, { createContext, useEffect, useState, useRef } from 'react'; import { toast } from 'sonner'; +// Shared client-side event target for broadcasting Incus operation events +// to consumers (e.g., file manager revalidation). Guarded for SSR safety. +export const incusEventTarget = + typeof window !== 'undefined' ? new EventTarget() : null; + type EventType = 'operation' | 'logging' | 'lifecycle'; interface IncusEvent { @@ -11,6 +16,16 @@ interface IncusEvent { metadata: unknown; } +interface LifecycleMetadata { + action: string; + source: string; + context?: Record; + requestor?: { + protocol: string; + username: string; + }; +} + interface OperationMetadata { id: string; class: string; @@ -32,10 +47,12 @@ interface OperationMetadata { type EventEmitterContextValue = { isConnected: boolean; + socket: WebSocket | null; }; const EventEmitterContext = createContext({ isConnected: false, + socket: null, }); export default EventEmitterContext; @@ -51,6 +68,7 @@ export function EventEmitterProvider({ children: React.ReactNode; }) { const [isConnected, setIsConnected] = useState(false); + const [socket, setSocket] = useState(null); const wsRef = useRef(null); // Track operations we are already showing toasts for to avoid duplicates/spam const activeOperations = useRef>(new Set()); @@ -70,6 +88,7 @@ export function EventEmitterProvider({ console.log('Connecting to events:', url); const ws = new WebSocket(url); wsRef.current = ws; + setSocket(ws); ws.onopen = () => { console.log('Events WebSocket connected'); @@ -83,6 +102,7 @@ export function EventEmitterProvider({ console.log('Events WebSocket disconnected'); setIsConnected(false); wsRef.current = null; + setSocket(null); // Reconnect with exponential backoff starting from the initial delay const reconnectDelay = Math.min( @@ -101,6 +121,13 @@ export function EventEmitterProvider({ try { const data = JSON.parse(event.data) as IncusEvent; + // Broadcast to shared event target so consumers can react to all events + if (incusEventTarget) { + incusEventTarget.dispatchEvent( + new CustomEvent('incus-event', { detail: data }), + ); + } + if (data.type === 'operation') { handleOperationEvent(data.metadata as OperationMetadata); } @@ -173,7 +200,7 @@ export function EventEmitterProvider({ }, []); return ( - + {children} ); diff --git a/bun.lock b/bun.lock index 2395170..38d3019 100644 --- a/bun.lock +++ b/bun.lock @@ -55,6 +55,7 @@ "@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-avatar": "^1.1.11", "@radix-ui/react-checkbox": "^1.3.3", + "@radix-ui/react-context-menu": "^2.2.16", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-hover-card": "^1.1.15", @@ -337,6 +338,8 @@ "@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], + "@radix-ui/react-context-menu": ["@radix-ui/react-context-menu@2.2.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-menu": "2.1.16", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-O8morBEW+HsVG28gYDZPTrT9UUovQUlJue5YO836tiTJhuIWBm/zQHc7j388sHWtdH/xUZurK9olD2+pcqx5ww=="], + "@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw=="], "@radix-ui/react-direction": ["@radix-ui/react-direction@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw=="],