From 0efc19b4d465baa84d73e520870eeb4d108c8f77 Mon Sep 17 00:00:00 2001 From: Jaime Idolpx Date: Sun, 7 Jun 2026 23:11:07 -0400 Subject: [PATCH] feat: integrate CodeEditor and HexEditor components for enhanced file editing capabilities --- package.json | 4 + src/app/components/CodeEditor.tsx | 117 +++++++++ src/app/components/FileManager.tsx | 130 +++++----- src/app/components/HexEditor.tsx | 371 +++++++++++++++++++++++++++++ 4 files changed, 568 insertions(+), 54 deletions(-) create mode 100644 src/app/components/CodeEditor.tsx create mode 100644 src/app/components/HexEditor.tsx diff --git a/package.json b/package.json index 748e0d5..48e261d 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,9 @@ "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", @@ -40,6 +43,7 @@ "@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", diff --git a/src/app/components/CodeEditor.tsx b/src/app/components/CodeEditor.tsx new file mode 100644 index 0000000..2368881 --- /dev/null +++ b/src/app/components/CodeEditor.tsx @@ -0,0 +1,117 @@ +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; +} + +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 = { + json: json(), + xml: xml(), + text: [], +}; + +const syntaxLang: Record = { + 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(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 ( +
+ {!readOnly && ( +
+ +
+ )} +
+ + {displayText} + +
+
+ ); + } + + return ( +
+
+ + {onSave && ( + + )} + Ctrl+Z/Y undo · Ctrl+F search +
+
+ { editorViewRef.current = view; }} + /> +
+
+ ); +} diff --git a/src/app/components/FileManager.tsx b/src/app/components/FileManager.tsx index dd158d9..dcbd75a 100644 --- a/src/app/components/FileManager.tsx +++ b/src/app/components/FileManager.tsx @@ -24,6 +24,7 @@ import { Move, Pencil, RefreshCw, + Save, Search, Trash2, Upload, @@ -33,6 +34,10 @@ import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism'; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; +import CodeMirror, { EditorView } from '@uiw/react-codemirror'; +import { oneDark } from '@codemirror/theme-one-dark'; +import HexEditor from './HexEditor'; +import CodeEditor from './CodeEditor'; import { copyPath, createFolder, @@ -108,10 +113,6 @@ const VIEWER_LABEL: Record = { text: 'Text', markdown: 'Markdown', json: 'JSON', xml: 'XML', hex: 'Hex', image: 'Image', }; -const SYNTAX_LANG: Partial> = { - json: 'json', xml: 'xml', text: 'text', -}; - // ─── Viewer components ─────────────────────────────────────────────────────── function ViewerModeIcon({ mode, className }: { mode: ViewMode; className?: string }) { @@ -126,53 +127,6 @@ function ViewerModeIcon({ mode, className }: { mode: ViewMode; className?: strin } } -function HexViewer({ data }: { data: Uint8Array }) { - const MAX = 65536; - const view = data.length > MAX ? data.slice(0, MAX) : data; - const lines: string[] = []; - for (let i = 0; i < view.length; i += 16) { - const row = view.slice(i, i + 16); - const addr = i.toString(16).padStart(8, '0').toUpperCase(); - const hex = Array.from({ length: 16 }, (_, j) => - j < row.length ? row[j].toString(16).padStart(2, '0').toUpperCase() : ' ', - ); - const hexStr = hex.slice(0, 8).join(' ') + ' ' + hex.slice(8).join(' '); - const ascii = Array.from(row).map(b => b >= 32 && b < 127 ? String.fromCharCode(b) : '.').join(''); - lines.push(`${addr} ${hexStr} |${ascii}|`); - } - return ( -
- {data.length > MAX && ( -
- Showing first {MAX.toLocaleString()} of {data.length.toLocaleString()} bytes -
- )} -
{lines.join('\n')}
-
- ); -} - -function CodeViewer({ text, mode }: { text: string; mode: ViewMode }) { - const lang = SYNTAX_LANG[mode] ?? 'text'; - const source = mode === 'json' ? (() => { - try { return JSON.stringify(JSON.parse(text), null, 2); } catch { return text; } - })() : text; - - return ( -
- - {source} - -
- ); -} - function MarkdownViewer({ text }: { text: string }) { return (
@@ -221,6 +175,61 @@ function MarkdownViewer({ text }: { text: string }) { ); } +const CM_THEME = 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' }, +}); + +function MarkdownEditor({ text, onSave }: { text: string; onSave?: (s: string) => Promise }) { + const [editMode, setEditMode] = useState(false); + const [saving, setSaving] = useState(false); + const editorViewRef = useRef(null); + + const save = async () => { + if (!editorViewRef.current || !onSave) return; + setSaving(true); + try { await onSave(editorViewRef.current.state.doc.toString()); } + finally { setSaving(false); } + }; + + return ( +
+
+ + {editMode && onSave && ( + + )} + {editMode && Ctrl+Z/Y undo · Ctrl+F search} +
+ {editMode ? ( +
+ { editorViewRef.current = v; }} + /> +
+ ) : ( + + )} +
+ ); +} + // ─── Entry icon ─────────────────────────────────────────────────────────────── function EntryIcon({ entry }: { entry: EntryInfo }) { @@ -373,6 +382,19 @@ export default function FileManager({ initialPath = '/', config, setConfig, onBa setViewText(null); setViewHexData(null); }; + const saveViewFile = async (content: string | Uint8Array) => { + if (!viewEntry) throw new Error('No file open'); + await putFileContents(viewEntry.path, content); + const newBlob = typeof content === 'string' + ? new Blob([content], { type: 'text/plain' }) + : new Blob([content.buffer.slice(content.byteOffset, content.byteOffset + content.byteLength) as ArrayBuffer], { type: 'application/octet-stream' }); + setViewBlob(newBlob); + if (typeof content === 'string') setViewText(content); + else setViewHexData(new Uint8Array(content)); + toast.success(`Saved ${viewEntry.name}`); + void load(path); + }; + // ── Download ───────────────────────────────────────────────────────────── const triggerDownload = (blob: Blob, name: string) => { @@ -922,13 +944,13 @@ export default function FileManager({ initialPath = '/', config, setConfig, onBa
)} {!viewLoading && viewMode === 'hex' && viewHexData && ( - + saveViewFile(d)} /> )} {!viewLoading && viewMode === 'markdown' && viewText !== null && ( - + saveViewFile(s)} /> )} {!viewLoading && (viewMode === 'text' || viewMode === 'json' || viewMode === 'xml') && viewText !== null && ( - + saveViewFile(s)} /> )} diff --git a/src/app/components/HexEditor.tsx b/src/app/components/HexEditor.tsx new file mode 100644 index 0000000..6a6722e --- /dev/null +++ b/src/app/components/HexEditor.tsx @@ -0,0 +1,371 @@ +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; +} + +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(null); + const searchRef = useRef(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(); + 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(); + if (curStart >= 0) { for (let i = 0; i < needleLen; i++) curMatchSet.add(curStart + i); } + + return ( +
+ + {/* ── Toolbar ── */} +
+ {!readOnly && ( + + )} + {editMode && ( + <> + + + {dirty && onSave && ( + + )} + + )} +
+ {current.length > MAX_DISPLAY && ( + First {MAX_DISPLAY.toLocaleString()} bytes + )} + {current.length.toLocaleString()} bytes + +
+ + {/* ── Search bar ── */} + {searchOpen && ( +
+ + 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" + /> + + {query.trim() && ( + 0 ? 'text-neutral-400' : 'text-red-400'}> + {matches.length > 0 ? `${matchIdx + 1}/${matches.length}` : 'Not found'} + + )} + {matches.length > 1 && ( + <> + + + + )} + +
+ )} + + {/* ── Hex grid ── */} +
containerRef.current?.focus()} + > + {cursor < 0 && ( +
+ Click a cell to position cursor{editMode ? ' · type hex digits or ASCII to edit' : ''} +
+ )} +
+ {Array.from({ length: numRows }, (_, row) => { + const base = row * BYTES_PER_ROW; + return ( +
+ + {/* Address */} + + {base.toString(16).padStart(8, '0').toUpperCase()} + + + {/* Hex pane */} +
+ {Array.from({ length: BYTES_PER_ROW }, (_, col) => { + const idx = base + col; + if (idx >= view.length) { + return ; + } + 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 ( + { setCursor(idx); setNibble(0); setPane('hex'); containerRef.current?.focus(); }} + > + {byte.toString(16).padStart(2, '0').toUpperCase()} + + ); + })} +
+ + {/* Separator */} + + + {/* ASCII pane */} +
+ {Array.from({ length: BYTES_PER_ROW }, (_, col) => { + const idx = base + col; + if (idx >= view.length) return ; + 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 ( + { setCursor(idx); setPane('ascii'); containerRef.current?.focus(); }} + > + {char} + + ); + })} +
+ +
+ ); + })} +
+
+
+ ); +}