Compare commits
No commits in common. "435a1b38c0cdfa8a4a5746b472baa46ea236c447" and "3f7cc16ce1b8adfa8c902231443f111fe4955691" have entirely different histories.
435a1b38c0
...
3f7cc16ce1
|
|
@ -8,9 +8,6 @@
|
|||
"dev": "vite"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/lang-json": "^6.0.2",
|
||||
"@codemirror/lang-xml": "^6.1.0",
|
||||
"@codemirror/theme-one-dark": "^6.1.3",
|
||||
"@emotion/react": "11.14.0",
|
||||
"@emotion/styled": "11.14.1",
|
||||
"@mui/icons-material": "7.3.5",
|
||||
|
|
@ -42,8 +39,6 @@
|
|||
"@radix-ui/react-toggle": "1.1.2",
|
||||
"@radix-ui/react-toggle-group": "1.1.2",
|
||||
"@radix-ui/react-tooltip": "1.1.8",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"@uiw/react-codemirror": "^4.25.10",
|
||||
"canvas-confetti": "1.9.4",
|
||||
"class-variance-authority": "0.7.1",
|
||||
"clsx": "2.1.1",
|
||||
|
|
@ -58,15 +53,12 @@
|
|||
"react-dnd": "16.0.1",
|
||||
"react-dnd-html5-backend": "16.0.1",
|
||||
"react-hook-form": "7.55.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-popper": "2.3.0",
|
||||
"react-resizable-panels": "2.1.7",
|
||||
"react-responsive-masonry": "2.7.1",
|
||||
"react-router": "^7.17.0",
|
||||
"react-slick": "0.31.0",
|
||||
"react-syntax-highlighter": "^16.1.1",
|
||||
"recharts": "2.15.2",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"sonner": "2.0.3",
|
||||
"tailwind-merge": "3.2.0",
|
||||
"tw-animate-css": "1.3.8",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useState } from 'react';
|
||||
import { Cpu, Settings, Wifi, Network, HardDrive, Activity, MoreHorizontal, Search, Wrench, User, LogOut, Bell, FileText, AppWindow, Folder, Edit, Eye, Database, Upload, Download, Code2, LayoutList, Image, ChevronLeft, Loader2, Check, AlertCircle, RefreshCw, Terminal, Link } from 'lucide-react';
|
||||
import { Cpu, Settings, Wifi, Network, HardDrive, Activity, MoreHorizontal, Search, Wrench, User, LogOut, Bell, FileText, AppWindow, Folder, Edit, Eye, Database, Upload, Download, Code2, LayoutList, Image, ChevronLeft, Loader2, Check, AlertCircle, RefreshCw } from 'lucide-react';
|
||||
import { Toaster } from 'sonner';
|
||||
import StatusPage from './components/StatusPage';
|
||||
import DevicesPage from './components/DevicesPage';
|
||||
|
|
@ -9,15 +9,12 @@ import IECPage from './components/IECPage';
|
|||
import OtherPage from './components/OtherPage';
|
||||
import ToolsPage from './components/ToolsPage';
|
||||
import SearchOverlay from './components/SearchOverlay';
|
||||
import FileManager from './components/FileManager';
|
||||
import logoSvg from '../imports/logo.svg';
|
||||
import { useSettings } from './settings';
|
||||
|
||||
type Page = 'status' | 'devices' | 'iec' | 'network' | 'other' | 'general' | 'tools' | 'apps' | AppId;
|
||||
|
||||
type AppId =
|
||||
| 'file-manager'
|
||||
| 'serial-console'
|
||||
| 'directory-editor'
|
||||
| 'sector-editor'
|
||||
| 'bam-editor'
|
||||
|
|
@ -41,11 +38,10 @@ export default function App() {
|
|||
const { config, setConfig, saveStatus, pendingCount, flushNow, reload } = useSettings();
|
||||
const [showSearch, setShowSearch] = useState(false);
|
||||
const [showProfileMenu, setShowProfileMenu] = useState(false);
|
||||
const [devicesOpenId, setDevicesOpenId] = useState<string | null>(null);
|
||||
|
||||
const pages = {
|
||||
status: <StatusPage config={config} setConfig={setConfig} />,
|
||||
devices: <DevicesPage config={config} setConfig={setConfig} openDeviceId={devicesOpenId} onClearOpenDevice={() => setDevicesOpenId(null)} />,
|
||||
status: <StatusPage config={config} setConfig={setConfig} />,
|
||||
devices: <DevicesPage config={config} setConfig={setConfig} />,
|
||||
iec: <IECPage config={config} setConfig={setConfig} />,
|
||||
network: <NetworkPage config={config} setConfig={setConfig} />,
|
||||
other: <OtherPage config={config} setConfig={setConfig} />,
|
||||
|
|
@ -55,15 +51,6 @@ export default function App() {
|
|||
<div className="max-w-3xl mx-auto py-8 px-4">
|
||||
<h1 className="text-2xl font-bold mb-6 text-center">Apps</h1>
|
||||
<div className="space-y-10">
|
||||
{/* Manangement Group */}
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold mb-4 text-blue-700">Management</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">
|
||||
<AppCard icon={<Folder className="w-7 h-7" />} label="File Manager" onClick={() => setCurrentPage('file-manager')} />
|
||||
<AppCard icon={<Terminal className="w-7 h-7" />} label="Serial Console" onClick={() => setCurrentPage('serial-console')} />
|
||||
<AppCard icon={<Link className="w-7 h-7" />} label="Short Codes" onClick={() => setCurrentPage('serial-console')} />
|
||||
</div>
|
||||
</div>
|
||||
{/* Disk Group */}
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold mb-4 text-blue-700">Disk</h2>
|
||||
|
|
@ -109,13 +96,6 @@ export default function App() {
|
|||
</div>
|
||||
),
|
||||
// Individual app pages
|
||||
'file-manager': <FileManager
|
||||
onBack={() => setCurrentPage('apps')}
|
||||
config={config}
|
||||
setConfig={setConfig}
|
||||
onNavigateToDevice={(id) => { setCurrentPage('devices'); setDevicesOpenId(id); }}
|
||||
/>,
|
||||
'serial-console': <AppPage title="Serial Console" onBack={() => setCurrentPage('apps')} />,
|
||||
'directory-editor': <AppPage title="Directory Editor" onBack={() => setCurrentPage('apps')} />,
|
||||
'sector-editor': <AppPage title="Sector Editor" onBack={() => setCurrentPage('apps')} />,
|
||||
'bam-editor': <AppPage title="BAM Editor" onBack={() => setCurrentPage('apps')} />,
|
||||
|
|
|
|||
|
|
@ -1,117 +0,0 @@
|
|||
import { useRef, useState } from 'react';
|
||||
import CodeMirror, { EditorView } from '@uiw/react-codemirror';
|
||||
import { json } from '@codemirror/lang-json';
|
||||
import { xml } from '@codemirror/lang-xml';
|
||||
import { oneDark } from '@codemirror/theme-one-dark';
|
||||
import { Eye, Pencil, Save } from 'lucide-react';
|
||||
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||||
import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';
|
||||
|
||||
export type CodeMode = 'text' | 'json' | 'xml';
|
||||
|
||||
interface CodeEditorProps {
|
||||
text: string;
|
||||
mode: CodeMode;
|
||||
readOnly?: boolean;
|
||||
onSave?: (text: string) => Promise<void>;
|
||||
}
|
||||
|
||||
const cmTheme = EditorView.theme({
|
||||
'&': { height: '100%', background: '#0a0a0a' },
|
||||
'.cm-scroller': { overflow: 'auto', fontFamily: 'ui-monospace,monospace', fontSize: '12px', lineHeight: '1.5' },
|
||||
'.cm-content': { padding: '12px 0' },
|
||||
'.cm-focused': { outline: 'none' },
|
||||
});
|
||||
|
||||
const langExt: Record<CodeMode, any> = {
|
||||
json: json(),
|
||||
xml: xml(),
|
||||
text: [],
|
||||
};
|
||||
|
||||
const syntaxLang: Record<CodeMode, string> = {
|
||||
text: 'text', json: 'json', xml: 'xml',
|
||||
};
|
||||
|
||||
function prettify(text: string, mode: CodeMode): string {
|
||||
if (mode === 'json') {
|
||||
try { return JSON.stringify(JSON.parse(text), null, 2); } catch { /* fall through */ }
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
export default function CodeEditor({ text, mode, readOnly = false, onSave }: CodeEditorProps) {
|
||||
const [editMode, setEditMode] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const editorViewRef = useRef<EditorView | null>(null);
|
||||
|
||||
const displayText = prettify(text, mode);
|
||||
const extensions = [langExt[mode], cmTheme].flat();
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!editorViewRef.current || !onSave) return;
|
||||
setSaving(true);
|
||||
try { await onSave(editorViewRef.current.state.doc.toString()); }
|
||||
finally { setSaving(false); }
|
||||
};
|
||||
|
||||
if (!editMode) {
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{!readOnly && (
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 bg-neutral-900 border-b border-neutral-700 flex-shrink-0 text-xs">
|
||||
<button
|
||||
onClick={() => setEditMode(true)}
|
||||
className="px-2 py-1 rounded bg-neutral-700 text-neutral-300 hover:bg-neutral-600 inline-flex items-center gap-1"
|
||||
>
|
||||
<Pencil className="w-3.5 h-3.5" /> Edit
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 overflow-auto text-xs">
|
||||
<SyntaxHighlighter
|
||||
language={syntaxLang[mode]}
|
||||
style={vscDarkPlus}
|
||||
customStyle={{ margin: 0, minHeight: '100%', background: '#0a0a0a', fontSize: '12px', lineHeight: '1.5' }}
|
||||
showLineNumbers
|
||||
wrapLongLines
|
||||
>
|
||||
{displayText}
|
||||
</SyntaxHighlighter>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 bg-neutral-900 border-b border-neutral-700 flex-shrink-0 text-xs">
|
||||
<button
|
||||
onClick={() => setEditMode(false)}
|
||||
className="px-2 py-1 rounded bg-amber-600 text-white inline-flex items-center gap-1"
|
||||
>
|
||||
<Eye className="w-3.5 h-3.5" /> View
|
||||
</button>
|
||||
{onSave && (
|
||||
<button
|
||||
onClick={() => void handleSave()}
|
||||
disabled={saving}
|
||||
className="px-2 py-1 rounded bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50 inline-flex items-center gap-1"
|
||||
>
|
||||
<Save className="w-3.5 h-3.5" /> {saving ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
)}
|
||||
<span className="text-neutral-600 ml-auto">Ctrl+Z/Y undo · Ctrl+F search</span>
|
||||
</div>
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<CodeMirror
|
||||
defaultValue={displayText}
|
||||
extensions={extensions}
|
||||
theme={oneDark}
|
||||
height="100%"
|
||||
onCreateEditor={view => { editorViewRef.current = view; }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { Printer, HardDrive, Network, Box, ChevronRight, RefreshCw } from 'lucide-react';
|
||||
import DeviceDetailOverlay from './DeviceDetailOverlay';
|
||||
import { toast } from 'sonner';
|
||||
|
|
@ -16,11 +16,9 @@ interface Device {
|
|||
interface DevicesPageProps {
|
||||
config: any;
|
||||
setConfig: (config: any) => void;
|
||||
openDeviceId?: string | null;
|
||||
onClearOpenDevice?: () => void;
|
||||
}
|
||||
|
||||
export default function DevicesPage({ config, setConfig, openDeviceId, onClearOpenDevice }: DevicesPageProps) {
|
||||
export default function DevicesPage({ config, setConfig }: DevicesPageProps) {
|
||||
// Host Settings update function
|
||||
const updateSetting = (path: string[], value: any) => {
|
||||
const newConfig = JSON.parse(JSON.stringify(config));
|
||||
|
|
@ -108,15 +106,6 @@ export default function DevicesPage({ config, setConfig, openDeviceId, onClearOp
|
|||
});
|
||||
}
|
||||
|
||||
// Auto-open the overlay when the parent passes a device ID (e.g. from a toast action)
|
||||
useEffect(() => {
|
||||
if (!openDeviceId) return;
|
||||
const idx = devices.findIndex(d => d.id === openDeviceId);
|
||||
if (idx >= 0) setSelectedDeviceIndex(idx);
|
||||
onClearOpenDevice?.();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [openDeviceId]);
|
||||
|
||||
const getDeviceIcon = (type: Device['type']) => {
|
||||
switch (type) {
|
||||
case 'printer':
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,371 +0,0 @@
|
|||
import { useCallback, useEffect, useReducer, useRef, useState } from 'react';
|
||||
import { Eye, Pencil, Redo2, Save, Search, Undo2, X } from 'lucide-react';
|
||||
|
||||
const BYTES_PER_ROW = 16;
|
||||
const MAX_DISPLAY = 65536;
|
||||
|
||||
// ── History ───────────────────────────────────────────────────────────────────
|
||||
|
||||
type HistState = { stack: Uint8Array[]; idx: number };
|
||||
type HistAction =
|
||||
| { type: 'push'; data: Uint8Array }
|
||||
| { type: 'undo' }
|
||||
| { type: 'redo' }
|
||||
| { type: 'reset'; data: Uint8Array };
|
||||
|
||||
function histReducer(s: HistState, a: HistAction): HistState {
|
||||
switch (a.type) {
|
||||
case 'push': {
|
||||
const prev = s.stack.slice(0, s.idx + 1);
|
||||
const capped = prev.length > 100 ? prev.slice(prev.length - 100) : prev;
|
||||
const next = [...capped, a.data];
|
||||
return { stack: next, idx: next.length - 1 };
|
||||
}
|
||||
case 'undo': return s.idx > 0 ? { ...s, idx: s.idx - 1 } : s;
|
||||
case 'redo': return s.idx < s.stack.length - 1 ? { ...s, idx: s.idx + 1 } : s;
|
||||
case 'reset': return { stack: [a.data], idx: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
// ── Search ────────────────────────────────────────────────────────────────────
|
||||
|
||||
function matchAll(data: Uint8Array, needle: Uint8Array): number[] {
|
||||
if (needle.length === 0) return [];
|
||||
const results: number[] = [];
|
||||
outer: for (let i = 0; i <= data.length - needle.length; i++) {
|
||||
for (let j = 0; j < needle.length; j++) {
|
||||
if (data[i + j] !== needle[j]) continue outer;
|
||||
}
|
||||
results.push(i);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
function parseHex(s: string): Uint8Array | null {
|
||||
const clean = s.replace(/\s+/g, '');
|
||||
if (!clean || clean.length % 2 !== 0 || !/^[0-9a-fA-F]+$/.test(clean)) return null;
|
||||
return new Uint8Array(
|
||||
Array.from({ length: clean.length / 2 }, (_, i) =>
|
||||
parseInt(clean.slice(i * 2, i * 2 + 2), 16),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ── Component ─────────────────────────────────────────────────────────────────
|
||||
|
||||
interface HexEditorProps {
|
||||
data: Uint8Array;
|
||||
readOnly?: boolean;
|
||||
onSave?: (data: Uint8Array) => Promise<void>;
|
||||
}
|
||||
|
||||
export default function HexEditor({ data, readOnly = false, onSave }: HexEditorProps) {
|
||||
const [hist, dispatch] = useReducer(histReducer, { stack: [data.slice()], idx: 0 });
|
||||
const current = hist.stack[hist.idx];
|
||||
|
||||
const [editMode, setEditMode] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [cursor, setCursor] = useState(-1);
|
||||
const [nibble, setNibble] = useState<0 | 1>(0);
|
||||
const [pane, setPane] = useState<'hex' | 'ascii'>('hex');
|
||||
|
||||
const [searchOpen, setSearchOpen] = useState(false);
|
||||
const [query, setQuery] = useState('');
|
||||
const [searchType, setSearchType] = useState<'text' | 'hex'>('text');
|
||||
const [matchIdx, setMatchIdx] = useState(-1);
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const searchRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const needle = query.trim() ? (searchType === 'text' ? new TextEncoder().encode(query) : parseHex(query)) : null;
|
||||
const matches = needle ? matchAll(current, needle) : [];
|
||||
const needleLen = needle?.length ?? 0;
|
||||
const dirty = hist.idx > 0;
|
||||
const canUndo = hist.idx > 0;
|
||||
const canRedo = hist.idx < hist.stack.length - 1;
|
||||
|
||||
// Jump to first result when query / search type changes
|
||||
useEffect(() => {
|
||||
if (matches.length > 0) { setMatchIdx(0); setCursor(matches[0]); }
|
||||
else setMatchIdx(-1);
|
||||
// matches is derived from query+searchType+current — depend on the inputs, not matches itself
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [query, searchType]);
|
||||
|
||||
// Scroll cursor row into view
|
||||
useEffect(() => {
|
||||
if (cursor < 0) return;
|
||||
containerRef.current
|
||||
?.querySelector(`[data-byte="${cursor}"]`)
|
||||
?.scrollIntoView({ block: 'nearest' });
|
||||
}, [cursor]);
|
||||
|
||||
const pushByte = useCallback((offset: number, value: number) => {
|
||||
const next = current.slice();
|
||||
next[offset] = value;
|
||||
dispatch({ type: 'push', data: next });
|
||||
}, [current]);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!onSave || !dirty) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await onSave(current);
|
||||
dispatch({ type: 'reset', data: current.slice() });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const goToMatch = (idx: number) => {
|
||||
if (!matches.length) return;
|
||||
const i = ((idx % matches.length) + matches.length) % matches.length;
|
||||
setMatchIdx(i);
|
||||
setCursor(matches[i]);
|
||||
containerRef.current?.focus();
|
||||
};
|
||||
|
||||
const openSearch = () => {
|
||||
setSearchOpen(true);
|
||||
setTimeout(() => searchRef.current?.focus(), 50);
|
||||
};
|
||||
const closeSearch = () => {
|
||||
setSearchOpen(false);
|
||||
setQuery('');
|
||||
containerRef.current?.focus();
|
||||
};
|
||||
|
||||
const onKeyDown = (e: React.KeyboardEvent) => {
|
||||
const ctrl = e.ctrlKey || e.metaKey;
|
||||
|
||||
if (ctrl && e.key === 'f') { e.preventDefault(); openSearch(); return; }
|
||||
if (ctrl && e.key === 'z') { e.preventDefault(); dispatch({ type: 'undo' }); return; }
|
||||
if (ctrl && (e.key === 'y' || (e.shiftKey && e.key === 'Z'))) { e.preventDefault(); dispatch({ type: 'redo' }); return; }
|
||||
if (ctrl && e.key === 's' && editMode) { e.preventDefault(); void handleSave(); return; }
|
||||
if (e.key === 'Escape') { if (editMode) setEditMode(false); return; }
|
||||
|
||||
const len = current.length;
|
||||
if (e.key === 'ArrowRight') { e.preventDefault(); setNibble(0); setCursor(c => Math.min(c < 0 ? 0 : c + 1, len - 1)); return; }
|
||||
if (e.key === 'ArrowLeft') { e.preventDefault(); setNibble(0); setCursor(c => c <= 0 ? 0 : c - 1); return; }
|
||||
if (e.key === 'ArrowDown') { e.preventDefault(); setCursor(c => Math.min(c < 0 ? 0 : c + BYTES_PER_ROW, len - 1)); return; }
|
||||
if (e.key === 'ArrowUp') { e.preventDefault(); setCursor(c => c < 0 ? 0 : Math.max(c - BYTES_PER_ROW, 0)); return; }
|
||||
if (e.key === 'Home') { e.preventDefault(); setCursor(c => Math.floor(Math.max(c, 0) / BYTES_PER_ROW) * BYTES_PER_ROW); return; }
|
||||
if (e.key === 'End') { e.preventDefault(); setCursor(c => Math.min(Math.ceil((Math.max(c, 0) + 1) / BYTES_PER_ROW) * BYTES_PER_ROW - 1, len - 1)); return; }
|
||||
|
||||
if (!editMode || cursor < 0) return;
|
||||
|
||||
if (pane === 'hex') {
|
||||
const hex = e.key.toLowerCase();
|
||||
if (/^[0-9a-f]$/.test(hex) && !ctrl) {
|
||||
e.preventDefault();
|
||||
const v = parseInt(hex, 16);
|
||||
const cur = current[cursor] ?? 0;
|
||||
if (nibble === 0) {
|
||||
pushByte(cursor, (v << 4) | (cur & 0x0f));
|
||||
setNibble(1);
|
||||
} else {
|
||||
pushByte(cursor, (cur & 0xf0) | v);
|
||||
setNibble(0);
|
||||
setCursor(c => Math.min(c + 1, current.length - 1));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (e.key.length === 1 && !ctrl) {
|
||||
e.preventDefault();
|
||||
pushByte(cursor, e.key.charCodeAt(0) & 0xff);
|
||||
setCursor(c => Math.min(c + 1, current.length - 1));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const view = current.length > MAX_DISPLAY ? current.slice(0, MAX_DISPLAY) : current;
|
||||
const numRows = Math.ceil(view.length / BYTES_PER_ROW);
|
||||
|
||||
// Highlight sets
|
||||
const allMatchSet = new Set<number>();
|
||||
for (const pos of matches) { for (let i = 0; i < needleLen; i++) allMatchSet.add(pos + i); }
|
||||
const curStart = matchIdx >= 0 && matches[matchIdx] !== undefined ? matches[matchIdx] : -1;
|
||||
const curMatchSet = new Set<number>();
|
||||
if (curStart >= 0) { for (let i = 0; i < needleLen; i++) curMatchSet.add(curStart + i); }
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-neutral-950">
|
||||
|
||||
{/* ── Toolbar ── */}
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 bg-neutral-900 border-b border-neutral-700 flex-shrink-0 text-xs">
|
||||
{!readOnly && (
|
||||
<button
|
||||
onClick={() => { setEditMode(v => !v); containerRef.current?.focus(); }}
|
||||
className={`px-2 py-1 rounded inline-flex items-center gap-1 ${editMode ? 'bg-amber-600 text-white' : 'bg-neutral-700 text-neutral-300 hover:bg-neutral-600'}`}
|
||||
>
|
||||
{editMode ? <><Eye className="w-3.5 h-3.5" /> View</> : <><Pencil className="w-3.5 h-3.5" /> Edit</>}
|
||||
</button>
|
||||
)}
|
||||
{editMode && (
|
||||
<>
|
||||
<button onClick={() => dispatch({ type: 'undo' })} disabled={!canUndo}
|
||||
className="p-1 rounded text-neutral-400 hover:text-white disabled:opacity-30" title="Undo (Ctrl+Z)">
|
||||
<Undo2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button onClick={() => dispatch({ type: 'redo' })} disabled={!canRedo}
|
||||
className="p-1 rounded text-neutral-400 hover:text-white disabled:opacity-30" title="Redo (Ctrl+Y)">
|
||||
<Redo2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
{dirty && onSave && (
|
||||
<button onClick={() => void handleSave()} disabled={saving}
|
||||
className="px-2 py-1 rounded bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50 inline-flex items-center gap-1">
|
||||
<Save className="w-3.5 h-3.5" /> {saving ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
{current.length > MAX_DISPLAY && (
|
||||
<span className="text-amber-400 mr-2">First {MAX_DISPLAY.toLocaleString()} bytes</span>
|
||||
)}
|
||||
<span className="text-neutral-500 mr-1">{current.length.toLocaleString()} bytes</span>
|
||||
<button
|
||||
onClick={openSearch}
|
||||
className={`p-1 rounded ${searchOpen ? 'bg-neutral-700 text-white' : 'text-neutral-400 hover:text-white'}`}
|
||||
title="Search (Ctrl+F)"
|
||||
>
|
||||
<Search className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* ── Search bar ── */}
|
||||
{searchOpen && (
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 bg-neutral-800 border-b border-neutral-700 flex-shrink-0 text-xs">
|
||||
<Search className="w-3 h-3 text-neutral-500 flex-shrink-0" />
|
||||
<input
|
||||
ref={searchRef}
|
||||
value={query}
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter') { e.preventDefault(); goToMatch(matchIdx + (e.shiftKey ? -1 : 1)); }
|
||||
if (e.key === 'Escape') closeSearch();
|
||||
}}
|
||||
placeholder={searchType === 'text' ? 'Search text…' : 'Search hex (e.g. 48 65 6c)…'}
|
||||
className="flex-1 bg-transparent text-neutral-200 focus:outline-none placeholder:text-neutral-600"
|
||||
/>
|
||||
<button
|
||||
onClick={() => setSearchType(t => t === 'text' ? 'hex' : 'text')}
|
||||
className="px-2 py-0.5 rounded bg-neutral-700 text-neutral-300 hover:bg-neutral-600 font-mono"
|
||||
title="Toggle text / hex search"
|
||||
>
|
||||
{searchType === 'text' ? 'Txt' : 'Hex'}
|
||||
</button>
|
||||
{query.trim() && (
|
||||
<span className={matches.length > 0 ? 'text-neutral-400' : 'text-red-400'}>
|
||||
{matches.length > 0 ? `${matchIdx + 1}/${matches.length}` : 'Not found'}
|
||||
</span>
|
||||
)}
|
||||
{matches.length > 1 && (
|
||||
<>
|
||||
<button onClick={() => goToMatch(matchIdx - 1)} className="text-neutral-400 hover:text-white px-0.5" title="Previous (Shift+Enter)">‹</button>
|
||||
<button onClick={() => goToMatch(matchIdx + 1)} className="text-neutral-400 hover:text-white px-0.5" title="Next (Enter)">›</button>
|
||||
</>
|
||||
)}
|
||||
<button onClick={closeSearch} className="text-neutral-500 hover:text-neutral-300">
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Hex grid ── */}
|
||||
<div
|
||||
ref={containerRef}
|
||||
tabIndex={0}
|
||||
onKeyDown={onKeyDown}
|
||||
className="flex-1 overflow-auto p-3 focus:outline-none select-none"
|
||||
onClick={() => containerRef.current?.focus()}
|
||||
>
|
||||
{cursor < 0 && (
|
||||
<div className="text-neutral-600 text-xs mb-2 font-mono">
|
||||
Click a cell to position cursor{editMode ? ' · type hex digits or ASCII to edit' : ''}
|
||||
</div>
|
||||
)}
|
||||
<div className="font-mono text-xs leading-5 whitespace-nowrap">
|
||||
{Array.from({ length: numRows }, (_, row) => {
|
||||
const base = row * BYTES_PER_ROW;
|
||||
return (
|
||||
<div key={row} className="flex items-center">
|
||||
|
||||
{/* Address */}
|
||||
<span className="text-neutral-600 w-20 flex-shrink-0 select-none">
|
||||
{base.toString(16).padStart(8, '0').toUpperCase()}
|
||||
</span>
|
||||
|
||||
{/* Hex pane */}
|
||||
<div className="flex mr-2">
|
||||
{Array.from({ length: BYTES_PER_ROW }, (_, col) => {
|
||||
const idx = base + col;
|
||||
if (idx >= view.length) {
|
||||
return <span key={col} className={`inline-block w-6 text-center${col === 8 ? ' ml-2' : ''}`} />;
|
||||
}
|
||||
const byte = view[idx];
|
||||
const isCursor = idx === cursor;
|
||||
const isCurMatch = curMatchSet.has(idx);
|
||||
const isAllMatch = allMatchSet.has(idx) && !isCurMatch;
|
||||
|
||||
const color = isCurMatch ? 'bg-orange-500 text-white'
|
||||
: isAllMatch ? 'bg-yellow-800 text-yellow-200'
|
||||
: byte === 0 ? 'text-neutral-700'
|
||||
: 'text-green-400';
|
||||
const ring = isCursor && pane === 'hex' ? ' ring-1 ring-inset ring-blue-400' : '';
|
||||
const gap = col === 8 ? ' ml-2' : '';
|
||||
|
||||
return (
|
||||
<span
|
||||
key={col}
|
||||
data-byte={idx}
|
||||
className={`inline-block w-6 text-center cursor-pointer${gap} ${color}${ring}`}
|
||||
onClick={() => { setCursor(idx); setNibble(0); setPane('hex'); containerRef.current?.focus(); }}
|
||||
>
|
||||
{byte.toString(16).padStart(2, '0').toUpperCase()}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Separator */}
|
||||
<span className="text-neutral-700 mr-2 select-none">│</span>
|
||||
|
||||
{/* ASCII pane */}
|
||||
<div className="flex">
|
||||
{Array.from({ length: BYTES_PER_ROW }, (_, col) => {
|
||||
const idx = base + col;
|
||||
if (idx >= view.length) return <span key={col} className="inline-block w-[9px]" />;
|
||||
const byte = view[idx];
|
||||
const printable = byte >= 32 && byte < 127;
|
||||
const char = printable ? String.fromCharCode(byte) : '·';
|
||||
const isCursor = idx === cursor;
|
||||
const isCurMatch = curMatchSet.has(idx);
|
||||
const isAllMatch = allMatchSet.has(idx) && !isCurMatch;
|
||||
|
||||
const color = isCurMatch ? 'bg-orange-500 text-white'
|
||||
: isAllMatch ? 'bg-yellow-800 text-yellow-200'
|
||||
: printable ? 'text-blue-300'
|
||||
: 'text-neutral-700';
|
||||
const ring = isCursor && pane === 'ascii' ? ' ring-1 ring-inset ring-blue-400' : '';
|
||||
|
||||
return (
|
||||
<span
|
||||
key={col}
|
||||
className={`inline-block w-[9px] text-center cursor-pointer ${color}${ring}`}
|
||||
onClick={() => { setCursor(idx); setPane('ascii'); containerRef.current?.focus(); }}
|
||||
>
|
||||
{char}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,14 +1,9 @@
|
|||
import { useState } from 'react';
|
||||
import { FolderOpen } from 'lucide-react';
|
||||
import FileBrowser from './FileBrowser';
|
||||
|
||||
interface IECPageProps {
|
||||
config: any;
|
||||
setConfig: (config: any) => void;
|
||||
}
|
||||
|
||||
export default function IECPage({ config, setConfig }: IECPageProps) {
|
||||
const [showFileBrowser, setShowFileBrowser] = useState(false);
|
||||
const updateSetting = (path: string[], value: any) => {
|
||||
const newConfig = JSON.parse(JSON.stringify(config));
|
||||
let current = newConfig;
|
||||
|
|
@ -62,33 +57,13 @@ export default function IECPage({ config, setConfig }: IECPageProps) {
|
|||
|
||||
<div className="p-4">
|
||||
<label className="text-sm text-neutral-500 block mb-2">Boot Disk</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={iec.boot_disk || ''}
|
||||
onChange={(e) => updateSetting(['iec', 'boot_disk'], e.target.value)}
|
||||
className="flex-1 px-3 py-2 border border-neutral-300 rounded-lg"
|
||||
/>
|
||||
<button
|
||||
onClick={() => setShowFileBrowser(true)}
|
||||
className="px-3 py-2 border border-neutral-300 rounded-lg bg-neutral-50 hover:bg-neutral-100"
|
||||
title="Browse files"
|
||||
>
|
||||
<FolderOpen className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={iec.boot_disk || ''}
|
||||
onChange={(e) => updateSetting(['iec', 'boot_disk'], e.target.value)}
|
||||
className="w-full px-3 py-2 border border-neutral-300 rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{showFileBrowser && (
|
||||
<FileBrowser
|
||||
currentPath={iec.boot_disk || '/'}
|
||||
onSelect={(path) => {
|
||||
updateSetting(['iec', 'boot_disk'], path);
|
||||
setShowFileBrowser(false);
|
||||
}}
|
||||
onClose={() => setShowFileBrowser(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<h2 className="text-sm text-neutral-500 pt-4">Directory Settings</h2>
|
||||
|
|
|
|||
|
|
@ -271,17 +271,6 @@ export async function movePath(from: string, to: string): Promise<void> {
|
|||
);
|
||||
}
|
||||
|
||||
export async function copyPath(from: string, to: string): Promise<void> {
|
||||
const manager = getWebDAVClient();
|
||||
const base = manager.client.baseUrl;
|
||||
await manager.client.copymove(
|
||||
'COPY',
|
||||
pathToUrl(normalizePath(from), base),
|
||||
pathToUrl(normalizePath(to), base),
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
export async function putFileContents(
|
||||
path: string,
|
||||
data: string | ArrayBuffer | Uint8Array | Blob,
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user