2026-03-16 00:04:01 +07:00
|
|
|
/**
|
|
|
|
|
* 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';
|
feat(ui): replace native confirm() dialogs with reusable modal
Convert the remaining window.confirm() call sites to the in-app
MessageDialogHost, extended with a new confirm mode (Cancel + Confirm
buttons, optional danger styling) via showConfirmDialog().
Sites converted:
- New workspace (EditorPage)
- Load project / delete file (FileExplorer)
- Overwrite SPIFFS file (BoardOptionsModal)
- Delete VFS node (VirtualFileSystem)
All dialog strings are internationalized across the 9 supported locales
(en, es, pt-br, it, fr, zh-cn, de, ja, ru); the two previously
English-only modals now pull from i18n too.
2026-07-18 06:59:18 +07:00
|
|
|
import { useTranslation } from 'react-i18next';
|
2026-03-16 00:04:01 +07:00
|
|
|
import { useVfsStore } from '../../store/useVfsStore';
|
|
|
|
|
import type { VfsNode } from '../../store/useVfsStore';
|
2026-06-23 01:21:06 +07:00
|
|
|
import { getBoardBridge, useSimulatorStore } from '../../store/useSimulatorStore';
|
feat(ui): replace native confirm() dialogs with reusable modal
Convert the remaining window.confirm() call sites to the in-app
MessageDialogHost, extended with a new confirm mode (Cancel + Confirm
buttons, optional danger styling) via showConfirmDialog().
Sites converted:
- New workspace (EditorPage)
- Load project / delete file (FileExplorer)
- Overwrite SPIFFS file (BoardOptionsModal)
- Delete VFS node (VirtualFileSystem)
All dialog strings are internationalized across the 9 supported locales
(en, es, pt-br, it, fr, zh-cn, de, ja, ru); the two previously
English-only modals now pull from i18n too.
2026-07-18 06:59:18 +07:00
|
|
|
import { showConfirmDialog } from '../../store/useMessageDialogStore';
|
2026-06-23 01:21:06 +07:00
|
|
|
|
|
|
|
|
/** Resolve true once the board's guest Linux has booted to a shell
|
|
|
|
|
* (board.piBooted), or false after timeoutMs. Polls the store. */
|
|
|
|
|
function waitForPiBooted(boardId: string, timeoutMs: number): Promise<boolean> {
|
|
|
|
|
const deadline = Date.now() + timeoutMs;
|
|
|
|
|
return new Promise((resolve) => {
|
|
|
|
|
const check = (): void => {
|
|
|
|
|
const b = useSimulatorStore.getState().boards.find((x) => x.id === boardId);
|
|
|
|
|
if (b?.piBooted) return resolve(true);
|
|
|
|
|
if (Date.now() > deadline) return resolve(false);
|
|
|
|
|
setTimeout(check, 250);
|
|
|
|
|
};
|
|
|
|
|
check();
|
|
|
|
|
});
|
|
|
|
|
}
|
2026-03-16 00:04:01 +07:00
|
|
|
|
|
|
|
|
// ── Icons ─────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
const IcoFolder = ({ open }: { open: boolean }) => (
|
|
|
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill={open ? '#f0c040' : '#c8a040'} stroke="none">
|
2026-04-22 02:45:45 +07:00
|
|
|
{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" />
|
|
|
|
|
)}
|
2026-03-16 00:04:01 +07:00
|
|
|
</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
|
2026-04-22 02:45:45 +07:00
|
|
|
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,
|
|
|
|
|
}}
|
2026-03-16 00:04:01 +07:00
|
|
|
>
|
|
|
|
|
<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 ───────────────────────────────────
|
|
|
|
|
|
2026-04-22 02:45:45 +07:00
|
|
|
interface CtxMenu {
|
|
|
|
|
nodeId: string;
|
|
|
|
|
x: number;
|
|
|
|
|
y: number;
|
|
|
|
|
isDir: boolean;
|
|
|
|
|
}
|
2026-03-16 00:04:01 +07:00
|
|
|
|
|
|
|
|
// ── 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> = ({
|
2026-04-22 02:45:45 +07:00
|
|
|
boardId,
|
|
|
|
|
nodeId,
|
|
|
|
|
depth,
|
|
|
|
|
selectedNodeId,
|
|
|
|
|
onSelect,
|
|
|
|
|
onContext,
|
|
|
|
|
renamingId,
|
|
|
|
|
renameValue,
|
|
|
|
|
renameInputRef,
|
|
|
|
|
onRenameChange,
|
|
|
|
|
onRenameCommit,
|
|
|
|
|
onRenameCancel,
|
|
|
|
|
creatingIn,
|
|
|
|
|
newNodeName,
|
|
|
|
|
newNodeType,
|
|
|
|
|
newNodeInputRef,
|
|
|
|
|
onNewNameChange,
|
|
|
|
|
onNewNameCommit,
|
|
|
|
|
onNewNameCancel,
|
2026-03-16 00:04:01 +07:00
|
|
|
}) => {
|
|
|
|
|
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);
|
|
|
|
|
}}
|
2026-04-22 02:45:45 +07:00
|
|
|
onContextMenu={(e) => {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
onContext(e, nodeId, isDir);
|
|
|
|
|
}}
|
2026-03-16 00:04:01 +07:00
|
|
|
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}
|
2026-04-22 02:45:45 +07:00
|
|
|
onKeyDown={(e) => {
|
|
|
|
|
if (e.key === 'Enter') onRenameCommit();
|
|
|
|
|
if (e.key === 'Escape') onRenameCancel();
|
|
|
|
|
}}
|
2026-03-16 00:04:01 +07:00
|
|
|
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 }}>
|
2026-04-22 02:45:45 +07:00
|
|
|
{newNodeType === 'directory' ? (
|
|
|
|
|
<IcoFolder open={false} />
|
|
|
|
|
) : (
|
|
|
|
|
<IcoFile name={newNodeName || 'newfile'} />
|
|
|
|
|
)}
|
2026-03-16 00:04:01 +07:00
|
|
|
</span>
|
|
|
|
|
<input
|
|
|
|
|
ref={newNodeInputRef}
|
|
|
|
|
className="vfs-rename-input"
|
|
|
|
|
value={newNodeName}
|
|
|
|
|
placeholder={newNodeType === 'directory' ? 'folder' : 'file.py'}
|
|
|
|
|
onChange={(e) => onNewNameChange(e.target.value)}
|
|
|
|
|
onBlur={onNewNameCommit}
|
2026-04-22 02:45:45 +07:00
|
|
|
onKeyDown={(e) => {
|
|
|
|
|
if (e.key === 'Enter') onNewNameCommit();
|
|
|
|
|
if (e.key === 'Escape') onNewNameCancel();
|
|
|
|
|
}}
|
2026-03-16 00:04:01 +07:00
|
|
|
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 }) => {
|
feat(ui): replace native confirm() dialogs with reusable modal
Convert the remaining window.confirm() call sites to the in-app
MessageDialogHost, extended with a new confirm mode (Cancel + Confirm
buttons, optional danger styling) via showConfirmDialog().
Sites converted:
- New workspace (EditorPage)
- Load project / delete file (FileExplorer)
- Overwrite SPIFFS file (BoardOptionsModal)
- Delete VFS node (VirtualFileSystem)
All dialog strings are internationalized across the 9 supported locales
(en, es, pt-br, it, fr, zh-cn, de, ja, ru); the two previously
English-only modals now pull from i18n too.
2026-07-18 06:59:18 +07:00
|
|
|
const { t } = useTranslation();
|
2026-04-22 02:45:45 +07:00
|
|
|
const {
|
|
|
|
|
initBoardVfs,
|
|
|
|
|
getRootId,
|
|
|
|
|
getNode,
|
|
|
|
|
setSelectedNode,
|
|
|
|
|
selectedNodeId: selectedMap,
|
|
|
|
|
createNode,
|
|
|
|
|
deleteNode,
|
|
|
|
|
renameNode,
|
|
|
|
|
serializeForUpload,
|
|
|
|
|
} = useVfsStore();
|
2026-03-16 00:04:01 +07:00
|
|
|
|
2026-04-22 02:45:45 +07:00
|
|
|
useEffect(() => {
|
|
|
|
|
initBoardVfs(boardId);
|
|
|
|
|
}, [boardId, initBoardVfs]);
|
2026-03-16 00:04:01 +07:00
|
|
|
|
|
|
|
|
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) {
|
2026-04-22 02:45:45 +07:00
|
|
|
renameInputRef.current.focus();
|
|
|
|
|
renameInputRef.current.select();
|
2026-03-16 00:04:01 +07:00
|
|
|
}
|
|
|
|
|
}, [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]);
|
|
|
|
|
|
2026-04-22 02:45:45 +07:00
|
|
|
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],
|
|
|
|
|
);
|
2026-03-16 00:04:01 +07:00
|
|
|
|
|
|
|
|
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]);
|
|
|
|
|
|
feat(ui): replace native confirm() dialogs with reusable modal
Convert the remaining window.confirm() call sites to the in-app
MessageDialogHost, extended with a new confirm mode (Cancel + Confirm
buttons, optional danger styling) via showConfirmDialog().
Sites converted:
- New workspace (EditorPage)
- Load project / delete file (FileExplorer)
- Overwrite SPIFFS file (BoardOptionsModal)
- Delete VFS node (VirtualFileSystem)
All dialog strings are internationalized across the 9 supported locales
(en, es, pt-br, it, fr, zh-cn, de, ja, ru); the two previously
English-only modals now pull from i18n too.
2026-07-18 06:59:18 +07:00
|
|
|
const handleDelete = async (nodeId: string) => {
|
2026-03-16 00:04:01 +07:00
|
|
|
setCtxMenu(null);
|
|
|
|
|
const node = getNode(boardId, nodeId);
|
|
|
|
|
if (!node || node.parentId === null) return;
|
feat(ui): replace native confirm() dialogs with reusable modal
Convert the remaining window.confirm() call sites to the in-app
MessageDialogHost, extended with a new confirm mode (Cancel + Confirm
buttons, optional danger styling) via showConfirmDialog().
Sites converted:
- New workspace (EditorPage)
- Load project / delete file (FileExplorer)
- Overwrite SPIFFS file (BoardOptionsModal)
- Delete VFS node (VirtualFileSystem)
All dialog strings are internationalized across the 9 supported locales
(en, es, pt-br, it, fr, zh-cn, de, ja, ru); the two previously
English-only modals now pull from i18n too.
2026-07-18 06:59:18 +07:00
|
|
|
const confirmed = await showConfirmDialog(
|
|
|
|
|
t('editor.pi.confirmDelete.message', { name: node.name }),
|
|
|
|
|
{
|
|
|
|
|
kind: 'error',
|
|
|
|
|
title: t('editor.pi.confirmDelete.title'),
|
|
|
|
|
confirmLabel: t('editor.pi.confirmDelete.confirm'),
|
|
|
|
|
cancelLabel: t('editor.pi.confirmDelete.cancel'),
|
|
|
|
|
danger: true,
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
if (!confirmed) return;
|
2026-03-16 00:04:01 +07:00
|
|
|
if (selectedNodeId === nodeId) setSelectedNode(boardId, null);
|
|
|
|
|
deleteNode(boardId, nodeId);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleUpload = async () => {
|
2026-06-23 01:21:06 +07:00
|
|
|
const sim = useSimulatorStore.getState();
|
|
|
|
|
const board = sim.boards.find((b) => b.id === boardId);
|
|
|
|
|
|
|
|
|
|
setUploadStatus('uploading');
|
|
|
|
|
|
|
|
|
|
// Auto-start the Pi if it isn't running, then wait for the guest shell —
|
|
|
|
|
// no more "Pi is not connected, start it first" dead-end. waitForPiBooted
|
|
|
|
|
// resolves false on timeout so we surface an error instead of hanging.
|
|
|
|
|
if (!board?.running) sim.startBoard(boardId);
|
|
|
|
|
const ready = await waitForPiBooted(boardId, 75_000);
|
2026-03-16 00:04:01 +07:00
|
|
|
const bridge = getBoardBridge(boardId);
|
2026-06-23 01:21:06 +07:00
|
|
|
if (!ready || !bridge || !bridge.connected) {
|
|
|
|
|
setUploadStatus('error');
|
|
|
|
|
setTimeout(() => setUploadStatus('idle'), 3000);
|
2026-03-16 00:04:01 +07:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-23 01:21:06 +07:00
|
|
|
const files = serializeForUpload(boardId);
|
|
|
|
|
if (files.length === 0) {
|
|
|
|
|
setUploadStatus('idle');
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-03-16 00:04:01 +07:00
|
|
|
|
2026-06-23 01:21:06 +07:00
|
|
|
// Flow-controlled sends: wait for the shell prompt to return after each
|
|
|
|
|
// command instead of guessing with fixed delays (long lines used to drop
|
|
|
|
|
// on the unflow-controlled console). Ensure a clean prompt + rw rootfs.
|
|
|
|
|
await bridge.sendAndWaitForPrompt('\n', 4000);
|
|
|
|
|
await bridge.sendAndWaitForPrompt('mount -o remount,rw / 2>/dev/null; true\n', 6000);
|
2026-03-16 00:04:01 +07:00
|
|
|
|
|
|
|
|
for (const { path, content } of files) {
|
2026-06-23 01:21:06 +07:00
|
|
|
// Create parent dir (before the heredoc, so the path exists).
|
2026-03-16 00:04:01 +07:00
|
|
|
const dir = path.substring(0, path.lastIndexOf('/'));
|
2026-06-23 01:21:06 +07:00
|
|
|
if (dir) await bridge.sendAndWaitForPrompt(`mkdir -p ${dir}\n`, 6000);
|
2026-03-16 00:04:01 +07:00
|
|
|
|
2026-06-23 01:21:06 +07:00
|
|
|
// Write the file via a heredoc with a unique delimiter. Open it, stream
|
|
|
|
|
// the body in small chunks so the console FIFO doesn't overflow on large
|
|
|
|
|
// files, then close it and wait for the prompt.
|
2026-03-16 00:04:01 +07:00
|
|
|
const delim = `VELXIO_${Math.random().toString(36).slice(2, 10).toUpperCase()}`;
|
|
|
|
|
const normalized = content.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
2026-06-23 01:21:06 +07:00
|
|
|
bridge.sendSerialText(`cat > ${path} << '${delim}'\n`);
|
|
|
|
|
const body = `${normalized}\n`;
|
|
|
|
|
for (let i = 0; i < body.length; i += 256) {
|
|
|
|
|
bridge.sendSerialText(body.slice(i, i + 256));
|
|
|
|
|
await new Promise((r) => setTimeout(r, 25));
|
|
|
|
|
}
|
|
|
|
|
await bridge.sendAndWaitForPrompt(`${delim}\n`, 8000);
|
2026-03-16 00:04:01 +07:00
|
|
|
|
2026-06-23 01:21:06 +07:00
|
|
|
// Make scripts executable (after the file exists).
|
2026-03-16 00:04:01 +07:00
|
|
|
if (path.endsWith('.py') || path.endsWith('.sh')) {
|
2026-06-23 01:21:06 +07:00
|
|
|
await bridge.sendAndWaitForPrompt(`chmod +x ${path}\n`, 5000);
|
2026-03-16 00:04:01 +07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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,
|
2026-04-22 02:45:45 +07:00
|
|
|
color:
|
|
|
|
|
uploadStatus === 'done'
|
|
|
|
|
? '#4caf50'
|
|
|
|
|
: uploadStatus === 'error'
|
|
|
|
|
? '#ef5350'
|
|
|
|
|
: uploadStatus === 'uploading'
|
|
|
|
|
? '#f59e0b'
|
|
|
|
|
: '#4fc3f7',
|
2026-03-16 00:04:01 +07:00
|
|
|
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 }}>
|
2026-04-22 02:45:45 +07:00
|
|
|
{uploadStatus === 'done'
|
|
|
|
|
? 'Done!'
|
2026-06-23 01:21:06 +07:00
|
|
|
: uploadStatus === 'error'
|
|
|
|
|
? 'Failed'
|
|
|
|
|
: uploadStatus === 'uploading'
|
|
|
|
|
? 'Sending…'
|
|
|
|
|
: 'Upload'}
|
2026-03-16 00:04:01 +07:00
|
|
|
</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 && (
|
|
|
|
|
<>
|
2026-04-22 02:45:45 +07:00
|
|
|
<button style={styles.ctxBtn} onClick={() => startCreate(ctxMenu.nodeId, 'file')}>
|
|
|
|
|
New File
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
style={styles.ctxBtn}
|
|
|
|
|
onClick={() => startCreate(ctxMenu.nodeId, 'directory')}
|
|
|
|
|
>
|
|
|
|
|
New Folder
|
|
|
|
|
</button>
|
2026-03-16 00:04:01 +07:00
|
|
|
<div style={styles.ctxSep} />
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
{getNode(boardId, ctxMenu.nodeId)?.parentId !== null && (
|
|
|
|
|
<>
|
2026-04-22 02:45:45 +07:00
|
|
|
<button style={styles.ctxBtn} onClick={() => startRename(ctxMenu.nodeId)}>
|
|
|
|
|
Rename
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
style={{ ...styles.ctxBtn, color: '#ef5350' }}
|
|
|
|
|
onClick={() => handleDelete(ctxMenu.nodeId)}
|
|
|
|
|
>
|
|
|
|
|
Delete
|
|
|
|
|
</button>
|
2026-03-16 00:04:01 +07:00
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
</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',
|
|
|
|
|
},
|
|
|
|
|
};
|