velxio/frontend/src/components/raspberry-pi/VirtualFileSystem.tsx

519 lines
17 KiB
TypeScript
Raw Normal View History

/**
* VirtualFileSystem tree explorer for the Pi's VFS.
* Supports expand/collapse, file selection, context menu (new/rename/delete),
* and an "Upload to Pi" button.
*/
import React, { useState, useRef, useEffect, useCallback } from 'react';
import { useVfsStore } from '../../store/useVfsStore';
import type { VfsNode } from '../../store/useVfsStore';
import { getBoardBridge } from '../../store/useSimulatorStore';
// ── Icons ─────────────────────────────────────────
const IcoFolder = ({ open }: { open: boolean }) => (
<svg width="14" height="14" viewBox="0 0 24 24" fill={open ? '#f0c040' : '#c8a040'} stroke="none">
{open
? <path d="M2 6a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V6z" />
: <path d="M2 6a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V6z" />
}
</svg>
);
const IcoFile = ({ name }: { name: string }) => {
const ext = name.split('.').pop()?.toLowerCase() ?? '';
const color = ext === 'py' ? '#4fc3f7' : ext === 'sh' ? '#a5d6a7' : '#aaa';
return (
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke={color} strokeWidth="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
</svg>
);
};
const IcoChevron = ({ open }: { open: boolean }) => (
<svg
width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor"
strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"
style={{ transform: open ? 'rotate(90deg)' : 'none', transition: 'transform 0.12s', flexShrink: 0 }}
>
<polyline points="9 18 15 12 9 6" />
</svg>
);
const IcoUpload = () => (
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="17 8 12 3 7 8" />
<line x1="12" y1="3" x2="12" y2="15" />
</svg>
);
// ── Context menu ───────────────────────────────────
interface CtxMenu { nodeId: string; x: number; y: number; isDir: boolean }
// ── Single node renderer ────────────────────────────
interface NodeRowProps {
boardId: string;
nodeId: string;
depth: number;
selectedNodeId: string | null;
onSelect: (nodeId: string) => void;
onContext: (e: React.MouseEvent, nodeId: string, isDir: boolean) => void;
renamingId: string | null;
renameValue: string;
renameInputRef: React.RefObject<HTMLInputElement>;
onRenameChange: (v: string) => void;
onRenameCommit: () => void;
onRenameCancel: () => void;
creatingIn: string | null;
newNodeName: string;
newNodeType: 'file' | 'directory';
newNodeInputRef: React.RefObject<HTMLInputElement>;
onNewNameChange: (v: string) => void;
onNewNameCommit: () => void;
onNewNameCancel: () => void;
}
const NodeRow: React.FC<NodeRowProps> = ({
boardId, nodeId, depth, selectedNodeId, onSelect, onContext,
renamingId, renameValue, renameInputRef, onRenameChange, onRenameCommit, onRenameCancel,
creatingIn, newNodeName, newNodeType, newNodeInputRef, onNewNameChange, onNewNameCommit, onNewNameCancel,
}) => {
const node = useVfsStore((s) => s.getNode(boardId, nodeId));
const [open, setOpen] = useState(true);
if (!node) return null;
const isSelected = nodeId === selectedNodeId;
const isDir = node.type === 'directory';
const isRoot = node.parentId === null;
return (
<>
<div
className={`vfs-row${isSelected ? ' vfs-row-selected' : ''}`}
style={{ paddingLeft: depth * 14 + 6 }}
onClick={() => {
if (isDir) setOpen((v) => !v);
onSelect(nodeId);
}}
onContextMenu={(e) => { e.preventDefault(); onContext(e, nodeId, isDir); }}
title={isRoot ? '/' : node.name}
>
{isDir && <IcoChevron open={open} />}
{!isDir && <span style={{ width: 10, flexShrink: 0 }} />}
<span style={{ marginLeft: 4, flexShrink: 0 }}>
{isDir ? <IcoFolder open={open} /> : <IcoFile name={node.name} />}
</span>
{renamingId === nodeId ? (
<input
ref={renameInputRef}
className="vfs-rename-input"
value={renameValue}
onChange={(e) => onRenameChange(e.target.value)}
onBlur={onRenameCommit}
onKeyDown={(e) => { if (e.key === 'Enter') onRenameCommit(); if (e.key === 'Escape') onRenameCancel(); }}
onClick={(e) => e.stopPropagation()}
/>
) : (
<span className="vfs-node-name">{isRoot ? '/' : node.name}</span>
)}
</div>
{/* Children */}
{isDir && open && (
<>
{(node.children ?? []).map((childId) => (
<NodeRow
key={childId}
boardId={boardId}
nodeId={childId}
depth={depth + 1}
selectedNodeId={selectedNodeId}
onSelect={onSelect}
onContext={onContext}
renamingId={renamingId}
renameValue={renameValue}
renameInputRef={renameInputRef}
onRenameChange={onRenameChange}
onRenameCommit={onRenameCommit}
onRenameCancel={onRenameCancel}
creatingIn={creatingIn}
newNodeName={newNodeName}
newNodeType={newNodeType}
newNodeInputRef={newNodeInputRef}
onNewNameChange={onNewNameChange}
onNewNameCommit={onNewNameCommit}
onNewNameCancel={onNewNameCancel}
/>
))}
{/* Inline new-node input */}
{creatingIn === nodeId && (
<div className="vfs-row" style={{ paddingLeft: (depth + 1) * 14 + 6 }}>
<span style={{ width: 10, flexShrink: 0 }} />
<span style={{ marginLeft: 4, flexShrink: 0 }}>
{newNodeType === 'directory' ? <IcoFolder open={false} /> : <IcoFile name={newNodeName || 'newfile'} />}
</span>
<input
ref={newNodeInputRef}
className="vfs-rename-input"
value={newNodeName}
placeholder={newNodeType === 'directory' ? 'folder' : 'file.py'}
onChange={(e) => onNewNameChange(e.target.value)}
onBlur={onNewNameCommit}
onKeyDown={(e) => { if (e.key === 'Enter') onNewNameCommit(); if (e.key === 'Escape') onNewNameCancel(); }}
onClick={(e) => e.stopPropagation()}
/>
</div>
)}
</>
)}
</>
);
};
// ── Main component ─────────────────────────────────
interface VirtualFileSystemProps {
boardId: string;
onFileSelect: (nodeId: string, content: string, filename: string) => void;
}
export const VirtualFileSystem: React.FC<VirtualFileSystemProps> = ({ boardId, onFileSelect }) => {
const { initBoardVfs, getRootId, getNode, setSelectedNode, selectedNodeId: selectedMap,
createNode, deleteNode, renameNode, serializeForUpload } = useVfsStore();
useEffect(() => { initBoardVfs(boardId); }, [boardId, initBoardVfs]);
const rootId = getRootId(boardId);
const selectedNodeId = selectedMap[boardId] ?? null;
const [ctxMenu, setCtxMenu] = useState<CtxMenu | null>(null);
const [renamingId, setRenamingId] = useState<string | null>(null);
const [renameValue, setRenameValue] = useState('');
const [creatingIn, setCreatingIn] = useState<string | null>(null);
const [newNodeName, setNewNodeName] = useState('');
const [newNodeType, setNewNodeType] = useState<'file' | 'directory'>('file');
const [uploadStatus, setUploadStatus] = useState<'idle' | 'uploading' | 'done' | 'error'>('idle');
const renameInputRef = useRef<HTMLInputElement>(null);
const newNodeInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (renamingId && renameInputRef.current) {
renameInputRef.current.focus(); renameInputRef.current.select();
}
}, [renamingId]);
useEffect(() => {
if (creatingIn && newNodeInputRef.current) newNodeInputRef.current.focus();
}, [creatingIn]);
// Close context menu on outside click
useEffect(() => {
if (!ctxMenu) return;
const h = () => setCtxMenu(null);
document.addEventListener('click', h);
return () => document.removeEventListener('click', h);
}, [ctxMenu]);
const handleSelect = useCallback((nodeId: string) => {
setSelectedNode(boardId, nodeId);
const node = getNode(boardId, nodeId);
if (node?.type === 'file') {
onFileSelect(nodeId, node.content ?? '', node.name);
}
}, [boardId, setSelectedNode, getNode, onFileSelect]);
const handleContext = useCallback((e: React.MouseEvent, nodeId: string, isDir: boolean) => {
setCtxMenu({ nodeId, x: e.clientX, y: e.clientY, isDir });
}, []);
const startRename = (nodeId: string) => {
const node = getNode(boardId, nodeId);
if (!node || node.parentId === null) return;
setRenameValue(node.name);
setRenamingId(nodeId);
setCtxMenu(null);
};
const commitRename = useCallback(() => {
if (renamingId && renameValue.trim()) renameNode(boardId, renamingId, renameValue.trim());
setRenamingId(null);
}, [boardId, renamingId, renameValue, renameNode]);
const startCreate = (parentId: string, type: 'file' | 'directory') => {
setCreatingIn(parentId);
setNewNodeType(type);
setNewNodeName('');
setCtxMenu(null);
};
const commitCreate = useCallback(() => {
const name = newNodeName.trim();
if (name && creatingIn) createNode(boardId, creatingIn, name, newNodeType);
setCreatingIn(null);
setNewNodeName('');
}, [boardId, creatingIn, newNodeName, newNodeType, createNode]);
const handleDelete = (nodeId: string) => {
setCtxMenu(null);
const node = getNode(boardId, nodeId);
if (!node || node.parentId === null) return;
if (!window.confirm(`Delete "${node.name}"?`)) return;
if (selectedNodeId === nodeId) setSelectedNode(boardId, null);
deleteNode(boardId, nodeId);
};
const handleUpload = async () => {
const bridge = getBoardBridge(boardId);
if (!bridge || !bridge.connected) {
alert('Pi is not connected. Start the simulation first.');
return;
}
const files = serializeForUpload(boardId);
if (files.length === 0) return;
setUploadStatus('uploading');
const enc = new TextEncoder();
const send = (text: string) =>
bridge.sendSerialBytes(Array.from(enc.encode(text)));
// Ensure clean prompt and root filesystem is mounted read-write
// (init=/bin/sh boots with rootfs read-only; 'rw' in cmdline fixes it but
// we also send the remount just in case)
send('\n');
await new Promise((r) => setTimeout(r, 200));
send('mount -o remount,rw / 2>/dev/null; true\n');
await new Promise((r) => setTimeout(r, 400));
for (const { path, content } of files) {
// Create parent directory if needed
const dir = path.substring(0, path.lastIndexOf('/'));
if (dir) {
send(`mkdir -p ${dir}\n`);
await new Promise((r) => setTimeout(r, 150));
}
// Write file via shell heredoc.
// Use a unique random delimiter so it never collides with file content.
const delim = `VELXIO_${Math.random().toString(36).slice(2, 10).toUpperCase()}`;
const normalized = content.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
// Send the entire heredoc as one string so the shell receives it atomically
send(`cat > ${path} << '${delim}'\n${normalized}\n${delim}\n`);
await new Promise((r) => setTimeout(r, 400));
// Make scripts executable
if (path.endsWith('.py') || path.endsWith('.sh')) {
send(`chmod +x ${path}\n`);
await new Promise((r) => setTimeout(r, 100));
}
}
setUploadStatus('done');
setTimeout(() => setUploadStatus('idle'), 2500);
};
if (!rootId) return <div style={styles.empty}>Loading file system</div>;
return (
<div style={styles.container}>
{/* Header */}
<div style={styles.header}>
<span style={styles.title}>FILE SYSTEM</span>
<div style={styles.headerBtns}>
<button
style={{
...styles.headerBtn,
color: uploadStatus === 'done' ? '#4caf50'
: uploadStatus === 'error' ? '#ef5350'
: uploadStatus === 'uploading' ? '#f59e0b'
: '#4fc3f7',
opacity: uploadStatus === 'uploading' ? 0.7 : 1,
}}
onClick={handleUpload}
disabled={uploadStatus === 'uploading'}
title="Upload all files to the running Pi via serial"
>
<IcoUpload />
<span style={{ marginLeft: 4, fontSize: 10 }}>
{uploadStatus === 'done' ? 'Done!' : uploadStatus === 'uploading' ? 'Sending…' : 'Upload'}
</span>
</button>
</div>
</div>
{/* Tree */}
<div style={styles.tree}>
<NodeRow
boardId={boardId}
nodeId={rootId}
depth={0}
selectedNodeId={selectedNodeId}
onSelect={handleSelect}
onContext={handleContext}
renamingId={renamingId}
renameValue={renameValue}
renameInputRef={renameInputRef}
onRenameChange={setRenameValue}
onRenameCommit={commitRename}
onRenameCancel={() => setRenamingId(null)}
creatingIn={creatingIn}
newNodeName={newNodeName}
newNodeType={newNodeType}
newNodeInputRef={newNodeInputRef}
onNewNameChange={setNewNodeName}
onNewNameCommit={commitCreate}
onNewNameCancel={() => setCreatingIn(null)}
/>
</div>
{/* Context menu */}
{ctxMenu && (
<div
style={{ ...styles.ctxMenu, top: ctxMenu.y, left: ctxMenu.x }}
onClick={(e) => e.stopPropagation()}
>
{ctxMenu.isDir && (
<>
<button style={styles.ctxBtn} onClick={() => startCreate(ctxMenu.nodeId, 'file')}>New File</button>
<button style={styles.ctxBtn} onClick={() => startCreate(ctxMenu.nodeId, 'directory')}>New Folder</button>
<div style={styles.ctxSep} />
</>
)}
{getNode(boardId, ctxMenu.nodeId)?.parentId !== null && (
<>
<button style={styles.ctxBtn} onClick={() => startRename(ctxMenu.nodeId)}>Rename</button>
<button style={{ ...styles.ctxBtn, color: '#ef5350' }} onClick={() => handleDelete(ctxMenu.nodeId)}>Delete</button>
</>
)}
</div>
)}
<style>{`
.vfs-row {
display: flex;
align-items: center;
gap: 3px;
padding-top: 4px;
padding-bottom: 4px;
padding-right: 6px;
cursor: pointer;
color: #ccc;
font-size: 12px;
user-select: none;
white-space: nowrap;
overflow: hidden;
border-radius: 3px;
margin: 1px 3px;
}
.vfs-row:hover { background: rgba(255,255,255,0.06); }
.vfs-row-selected { background: rgba(79,195,247,0.12); color: #e2e2e2; }
.vfs-node-name { overflow: hidden; text-overflow: ellipsis; flex: 1; }
.vfs-rename-input {
flex: 1;
background: #3c3c3c;
border: 1px solid #4fc3f7;
color: #fff;
font-size: 12px;
padding: 1px 4px;
border-radius: 2px;
outline: none;
min-width: 0;
font-family: inherit;
}
`}</style>
</div>
);
};
const styles: Record<string, React.CSSProperties> = {
container: {
width: '100%',
height: '100%',
background: '#252526',
borderRight: '1px solid #333',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
fontSize: 12,
fontFamily: 'Segoe UI, sans-serif',
position: 'relative',
},
header: {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '0 8px',
height: 36,
borderBottom: '1px solid #333',
flexShrink: 0,
},
title: {
color: '#9d9d9d',
fontSize: 10,
fontWeight: 700,
letterSpacing: 0.5,
},
headerBtns: {
display: 'flex',
alignItems: 'center',
gap: 4,
},
headerBtn: {
background: 'none',
border: 'none',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
padding: '2px 4px',
borderRadius: 3,
fontSize: 11,
fontFamily: 'inherit',
},
tree: {
flex: 1,
overflowY: 'auto',
padding: '4px 0',
scrollbarWidth: 'thin',
scrollbarColor: '#3c3c3c transparent',
},
empty: {
color: '#666',
fontSize: 11,
padding: 12,
},
ctxMenu: {
position: 'fixed',
background: '#2d2d2d',
border: '1px solid #3c3c3c',
borderRadius: 4,
zIndex: 2000,
display: 'flex',
flexDirection: 'column',
minWidth: 140,
boxShadow: '0 4px 14px rgba(0,0,0,0.5)',
overflow: 'hidden',
},
ctxBtn: {
background: 'none',
border: 'none',
color: '#ccc',
padding: '7px 14px',
textAlign: 'left',
cursor: 'pointer',
fontSize: 12,
fontFamily: 'inherit',
},
ctxSep: {
height: 1,
background: '#3c3c3c',
margin: '2px 0',
},
};