feat: integrate CodeEditor and HexEditor components for enhanced file editing capabilities

This commit is contained in:
Jaime Idolpx 2026-06-07 23:11:07 -04:00
parent 0e684077b2
commit 0efc19b4d4
4 changed files with 568 additions and 54 deletions

View File

@ -8,6 +8,9 @@
"dev": "vite" "dev": "vite"
}, },
"dependencies": { "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/react": "11.14.0",
"@emotion/styled": "11.14.1", "@emotion/styled": "11.14.1",
"@mui/icons-material": "7.3.5", "@mui/icons-material": "7.3.5",
@ -40,6 +43,7 @@
"@radix-ui/react-toggle-group": "1.1.2", "@radix-ui/react-toggle-group": "1.1.2",
"@radix-ui/react-tooltip": "1.1.8", "@radix-ui/react-tooltip": "1.1.8",
"@types/react-syntax-highlighter": "^15.5.13", "@types/react-syntax-highlighter": "^15.5.13",
"@uiw/react-codemirror": "^4.25.10",
"canvas-confetti": "1.9.4", "canvas-confetti": "1.9.4",
"class-variance-authority": "0.7.1", "class-variance-authority": "0.7.1",
"clsx": "2.1.1", "clsx": "2.1.1",

View File

@ -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<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>
);
}

View File

@ -24,6 +24,7 @@ import {
Move, Move,
Pencil, Pencil,
RefreshCw, RefreshCw,
Save,
Search, Search,
Trash2, Trash2,
Upload, Upload,
@ -33,6 +34,10 @@ import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism'; import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';
import ReactMarkdown from 'react-markdown'; import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm'; 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 { import {
copyPath, copyPath,
createFolder, createFolder,
@ -108,10 +113,6 @@ const VIEWER_LABEL: Record<ViewMode, string> = {
text: 'Text', markdown: 'Markdown', json: 'JSON', xml: 'XML', hex: 'Hex', image: 'Image', text: 'Text', markdown: 'Markdown', json: 'JSON', xml: 'XML', hex: 'Hex', image: 'Image',
}; };
const SYNTAX_LANG: Partial<Record<ViewMode, string>> = {
json: 'json', xml: 'xml', text: 'text',
};
// ─── Viewer components ─────────────────────────────────────────────────────── // ─── Viewer components ───────────────────────────────────────────────────────
function ViewerModeIcon({ mode, className }: { mode: ViewMode; className?: string }) { 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 (
<div className="p-4 overflow-auto h-full">
{data.length > MAX && (
<div className="text-amber-400 text-xs mb-2 font-sans">
Showing first {MAX.toLocaleString()} of {data.length.toLocaleString()} bytes
</div>
)}
<pre className="text-green-400 text-xs font-mono whitespace-pre leading-5">{lines.join('\n')}</pre>
</div>
);
}
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 (
<div className="h-full overflow-auto text-xs">
<SyntaxHighlighter
language={lang}
style={vscDarkPlus}
customStyle={{ margin: 0, minHeight: '100%', background: '#0a0a0a', fontSize: '12px', lineHeight: '1.5' }}
showLineNumbers
wrapLongLines
>
{source}
</SyntaxHighlighter>
</div>
);
}
function MarkdownViewer({ text }: { text: string }) { function MarkdownViewer({ text }: { text: string }) {
return ( return (
<div className="p-6 overflow-auto h-full text-neutral-200 text-sm leading-relaxed"> <div className="p-6 overflow-auto h-full text-neutral-200 text-sm leading-relaxed">
@ -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<void> }) {
const [editMode, setEditMode] = useState(false);
const [saving, setSaving] = useState(false);
const editorViewRef = useRef<EditorView | null>(null);
const save = async () => {
if (!editorViewRef.current || !onSave) return;
setSaving(true);
try { await onSave(editorViewRef.current.state.doc.toString()); }
finally { setSaving(false); }
};
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(v => !v)}
className={editMode
? 'px-2 py-1 rounded bg-amber-600 text-white inline-flex items-center gap-1'
: 'px-2 py-1 rounded bg-neutral-700 text-neutral-300 hover:bg-neutral-600 inline-flex items-center gap-1'}
>
{editMode ? <><Eye className="w-3.5 h-3.5" /> View</> : <><Pencil className="w-3.5 h-3.5" /> Edit</>}
</button>
{editMode && onSave && (
<button onClick={() => void save()} 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>
)}
{editMode && <span className="text-neutral-600 ml-auto">Ctrl+Z/Y undo · Ctrl+F search</span>}
</div>
{editMode ? (
<div className="flex-1 overflow-hidden">
<CodeMirror
defaultValue={text}
extensions={[CM_THEME]}
theme={oneDark}
height="100%"
onCreateEditor={v => { editorViewRef.current = v; }}
/>
</div>
) : (
<MarkdownViewer text={text} />
)}
</div>
);
}
// ─── Entry icon ─────────────────────────────────────────────────────────────── // ─── Entry icon ───────────────────────────────────────────────────────────────
function EntryIcon({ entry }: { entry: EntryInfo }) { function EntryIcon({ entry }: { entry: EntryInfo }) {
@ -373,6 +382,19 @@ export default function FileManager({ initialPath = '/', config, setConfig, onBa
setViewText(null); setViewHexData(null); 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 ───────────────────────────────────────────────────────────── // ── Download ─────────────────────────────────────────────────────────────
const triggerDownload = (blob: Blob, name: string) => { const triggerDownload = (blob: Blob, name: string) => {
@ -922,13 +944,13 @@ export default function FileManager({ initialPath = '/', config, setConfig, onBa
</div> </div>
)} )}
{!viewLoading && viewMode === 'hex' && viewHexData && ( {!viewLoading && viewMode === 'hex' && viewHexData && (
<HexViewer data={viewHexData} /> <HexEditor data={viewHexData} onSave={d => saveViewFile(d)} />
)} )}
{!viewLoading && viewMode === 'markdown' && viewText !== null && ( {!viewLoading && viewMode === 'markdown' && viewText !== null && (
<MarkdownViewer text={viewText} /> <MarkdownEditor text={viewText} onSave={s => saveViewFile(s)} />
)} )}
{!viewLoading && (viewMode === 'text' || viewMode === 'json' || viewMode === 'xml') && viewText !== null && ( {!viewLoading && (viewMode === 'text' || viewMode === 'json' || viewMode === 'xml') && viewText !== null && (
<CodeViewer text={viewText} mode={viewMode} /> <CodeEditor text={viewText} mode={viewMode} onSave={s => saveViewFile(s)} />
)} )}
</div> </div>
</div> </div>

View File

@ -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<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>
);
}