feat(FileManager, HexEditor): implement file caching and enhance hex editor with virtual scrolling
This commit is contained in:
parent
487d53f77b
commit
0b547bf02b
|
|
@ -43,7 +43,6 @@ import {
|
||||||
createFolder,
|
createFolder,
|
||||||
deletePath,
|
deletePath,
|
||||||
getFileContents,
|
getFileContents,
|
||||||
getFileRange,
|
|
||||||
humanFileSize,
|
humanFileSize,
|
||||||
joinPath,
|
joinPath,
|
||||||
listDirectory,
|
listDirectory,
|
||||||
|
|
@ -254,6 +253,52 @@ interface FileManagerProps {
|
||||||
onNavigateToDevice?: (deviceId: string) => void;
|
onNavigateToDevice?: (deviceId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── File cache (session + localStorage) ─────────────────────────────────────
|
||||||
|
|
||||||
|
const _sessionCache = new Map<string, Uint8Array>();
|
||||||
|
const FM_LS_MAX = 2 * 1024 * 1024; // only persist ≤ 2 MB to localStorage
|
||||||
|
|
||||||
|
function _cacheKey(path: string, size: number, mtime: string | null) {
|
||||||
|
return `${path}|${size}|${mtime ?? ''}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _lsGet(key: string): Uint8Array | null {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem('fmcache:' + key);
|
||||||
|
if (!raw) return null;
|
||||||
|
const bin = atob(raw);
|
||||||
|
const out = new Uint8Array(bin.length);
|
||||||
|
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
||||||
|
return out;
|
||||||
|
} catch { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function _lsSet(key: string, data: Uint8Array) {
|
||||||
|
if (data.length > FM_LS_MAX) return;
|
||||||
|
try {
|
||||||
|
let b64 = '';
|
||||||
|
for (let i = 0; i < data.length; i += 8192) {
|
||||||
|
b64 += btoa(
|
||||||
|
String.fromCharCode.apply(null, data.slice(i, Math.min(i + 8192, data.length)) as unknown as number[]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
localStorage.setItem('fmcache:' + key, b64);
|
||||||
|
} catch { /* quota exceeded — skip */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _getEntryBytes(entry: EntryInfo): Promise<Uint8Array> {
|
||||||
|
const key = _cacheKey(entry.path, entry.size, entry.lastModified?.toISOString() ?? null);
|
||||||
|
const sess = _sessionCache.get(key);
|
||||||
|
if (sess) return sess;
|
||||||
|
const ls = _lsGet(key);
|
||||||
|
if (ls) { _sessionCache.set(key, ls); return ls; }
|
||||||
|
const blob = await getFileContents(entry.path);
|
||||||
|
const data = new Uint8Array(await blob.arrayBuffer());
|
||||||
|
_sessionCache.set(key, data);
|
||||||
|
_lsSet(key, data);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Main component ───────────────────────────────────────────────────────────
|
// ─── Main component ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const FM_PATH_KEY = 'fileManager.path';
|
const FM_PATH_KEY = 'fileManager.path';
|
||||||
|
|
@ -274,16 +319,10 @@ export default function FileManager({ initialPath = '/', config, setConfig, onBa
|
||||||
// Viewer
|
// Viewer
|
||||||
const [viewEntry, setViewEntry] = useState<EntryInfo | null>(null);
|
const [viewEntry, setViewEntry] = useState<EntryInfo | null>(null);
|
||||||
const [viewMode, setViewMode] = useState<ViewMode | null>(null);
|
const [viewMode, setViewMode] = useState<ViewMode | null>(null);
|
||||||
const [viewBlob, setViewBlob] = useState<Blob | null>(null);
|
|
||||||
const [viewText, setViewText] = useState<string | null>(null);
|
const [viewText, setViewText] = useState<string | null>(null);
|
||||||
const [viewImgUrl, setViewImgUrl] = useState<string | null>(null);
|
const [viewImgUrl, setViewImgUrl] = useState<string | null>(null);
|
||||||
const [viewHexData, setViewHexData] = useState<Uint8Array | null>(null);
|
const [viewHexData, setViewHexData] = useState<Uint8Array | null>(null);
|
||||||
const [viewLoading, setViewLoading] = useState(false);
|
const [viewLoading, setViewLoading] = useState(false);
|
||||||
const [viewOffset, setViewOffset] = useState(0);
|
|
||||||
const [bufferSize, setBufferSize] = useState<number>(() => {
|
|
||||||
const v = Number(localStorage.getItem('fileManager.bufferSize'));
|
|
||||||
return v > 0 ? v : 65536;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Rename / folder
|
// Rename / folder
|
||||||
const [showNewFolder, setShowNewFolder] = useState(false);
|
const [showNewFolder, setShowNewFolder] = useState(false);
|
||||||
|
|
@ -352,47 +391,26 @@ export default function FileManager({ initialPath = '/', config, setConfig, onBa
|
||||||
|
|
||||||
// ── File viewer ──────────────────────────────────────────────────────────
|
// ── File viewer ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const processBlob = async (blob: Blob, mode: ViewMode) => {
|
|
||||||
setViewImgUrl(prev => { if (prev) URL.revokeObjectURL(prev); return null; });
|
|
||||||
setViewText(null);
|
|
||||||
setViewHexData(null);
|
|
||||||
if (mode === 'image') setViewImgUrl(URL.createObjectURL(blob));
|
|
||||||
else if (mode === 'hex') setViewHexData(new Uint8Array(await blob.arrayBuffer()));
|
|
||||||
else setViewText(await blob.text());
|
|
||||||
};
|
|
||||||
|
|
||||||
// Fetch a buffer window and populate viewer state.
|
|
||||||
// For images always loads the full file; everything else uses Range requests.
|
|
||||||
const loadBuffer = async (entry: EntryInfo, offset: number, mode: ViewMode, size = bufferSize) => {
|
|
||||||
let blob: Blob;
|
|
||||||
if (mode === 'image') {
|
|
||||||
blob = await getFileContents(entry.path);
|
|
||||||
setViewOffset(0);
|
|
||||||
} else {
|
|
||||||
const fileSize = entry.size;
|
|
||||||
const clampedOffset = fileSize > 0 ? Math.max(0, Math.min(offset, fileSize - 1)) : Math.max(0, offset);
|
|
||||||
const rangeEnd = fileSize > 0 ? Math.min(clampedOffset + size - 1, fileSize - 1) : clampedOffset + size - 1;
|
|
||||||
blob = await getFileRange(entry.path, clampedOffset, rangeEnd);
|
|
||||||
setViewOffset(clampedOffset);
|
|
||||||
}
|
|
||||||
setViewBlob(blob);
|
|
||||||
await processBlob(blob, mode);
|
|
||||||
};
|
|
||||||
|
|
||||||
const openEntry = async (entry: EntryInfo, mode?: ViewMode) => {
|
const openEntry = async (entry: EntryInfo, mode?: ViewMode) => {
|
||||||
if (renameEntry !== null) return;
|
if (renameEntry !== null) return;
|
||||||
if (entry.type === 'folder') { navigateTo(joinPath(path, entry.name)); return; }
|
if (entry.type === 'folder') { navigateTo(joinPath(path, entry.name)); return; }
|
||||||
const targetMode = mode ?? defaultViewMode(entry);
|
const targetMode = mode ?? defaultViewMode(entry);
|
||||||
setViewEntry(entry);
|
setViewEntry(entry);
|
||||||
setViewMode(targetMode);
|
setViewMode(targetMode);
|
||||||
setViewBlob(null);
|
|
||||||
setViewText(null);
|
setViewText(null);
|
||||||
setViewImgUrl(null);
|
setViewImgUrl(prev => { if (prev) URL.revokeObjectURL(prev); return null; });
|
||||||
setViewHexData(null);
|
setViewHexData(null);
|
||||||
setViewOffset(0);
|
|
||||||
setViewLoading(true);
|
setViewLoading(true);
|
||||||
try {
|
try {
|
||||||
await loadBuffer(entry, 0, targetMode);
|
const bytes = await _getEntryBytes(entry);
|
||||||
|
if (targetMode === 'image') {
|
||||||
|
const ab = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer;
|
||||||
|
setViewImgUrl(URL.createObjectURL(new Blob([ab])));
|
||||||
|
} else if (targetMode === 'hex') {
|
||||||
|
setViewHexData(bytes);
|
||||||
|
} else {
|
||||||
|
setViewText(new TextDecoder().decode(bytes));
|
||||||
|
}
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
toast.error(`Failed to open ${entry.name}: ${e?.message ?? e}`);
|
toast.error(`Failed to open ${entry.name}: ${e?.message ?? e}`);
|
||||||
setViewEntry(null);
|
setViewEntry(null);
|
||||||
|
|
@ -406,49 +424,39 @@ export default function FileManager({ initialPath = '/', config, setConfig, onBa
|
||||||
if (!viewEntry) return;
|
if (!viewEntry) return;
|
||||||
setViewMode(mode);
|
setViewMode(mode);
|
||||||
setViewLoading(true);
|
setViewLoading(true);
|
||||||
try { await loadBuffer(viewEntry, viewOffset, mode); } finally { setViewLoading(false); }
|
try {
|
||||||
};
|
const bytes = await _getEntryBytes(viewEntry);
|
||||||
|
if (mode === 'image') {
|
||||||
const prevBuffer = async () => {
|
const ab = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer;
|
||||||
if (!viewEntry || !viewMode || viewOffset <= 0) return;
|
setViewImgUrl(prev => { if (prev) URL.revokeObjectURL(prev); return URL.createObjectURL(new Blob([ab])); });
|
||||||
setViewLoading(true);
|
} else if (mode === 'hex') {
|
||||||
try { await loadBuffer(viewEntry, Math.max(0, viewOffset - bufferSize), viewMode); }
|
setViewHexData(bytes);
|
||||||
finally { setViewLoading(false); }
|
} else {
|
||||||
};
|
setViewText(new TextDecoder().decode(bytes));
|
||||||
|
}
|
||||||
const nextBuffer = async () => {
|
} finally {
|
||||||
if (!viewEntry || !viewMode) return;
|
setViewLoading(false);
|
||||||
if (viewEntry.size > 0 && viewOffset + bufferSize >= viewEntry.size) return;
|
}
|
||||||
setViewLoading(true);
|
|
||||||
try { await loadBuffer(viewEntry, viewOffset + bufferSize, viewMode); }
|
|
||||||
finally { setViewLoading(false); }
|
|
||||||
};
|
|
||||||
|
|
||||||
const changeBufferSize = async (newSize: number) => {
|
|
||||||
localStorage.setItem('fileManager.bufferSize', String(newSize));
|
|
||||||
setBufferSize(newSize);
|
|
||||||
if (!viewEntry || !viewMode || viewMode === 'image') return;
|
|
||||||
const aligned = Math.floor(viewOffset / newSize) * newSize;
|
|
||||||
setViewLoading(true);
|
|
||||||
try { await loadBuffer(viewEntry, aligned, viewMode, newSize); }
|
|
||||||
finally { setViewLoading(false); }
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const closeViewer = () => {
|
const closeViewer = () => {
|
||||||
setViewImgUrl(prev => { if (prev) URL.revokeObjectURL(prev); return null; });
|
setViewImgUrl(prev => { if (prev) URL.revokeObjectURL(prev); return null; });
|
||||||
setViewEntry(null); setViewMode(null); setViewBlob(null);
|
setViewEntry(null); setViewMode(null);
|
||||||
setViewText(null); setViewHexData(null); setViewOffset(0);
|
setViewText(null); setViewHexData(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const saveViewFile = async (content: string | Uint8Array) => {
|
const saveViewFile = async (content: string | Uint8Array) => {
|
||||||
if (!viewEntry) throw new Error('No file open');
|
if (!viewEntry) throw new Error('No file open');
|
||||||
await putFileContents(viewEntry.path, content);
|
const bytes: Uint8Array = typeof content === 'string'
|
||||||
const newBlob = typeof content === 'string'
|
? new TextEncoder().encode(content)
|
||||||
? new Blob([content], { type: 'text/plain' })
|
: content;
|
||||||
: new Blob([content.buffer.slice(content.byteOffset, content.byteOffset + content.byteLength) as ArrayBuffer], { type: 'application/octet-stream' });
|
await putFileContents(viewEntry.path, bytes);
|
||||||
setViewBlob(newBlob);
|
|
||||||
if (typeof content === 'string') setViewText(content);
|
if (typeof content === 'string') setViewText(content);
|
||||||
else setViewHexData(new Uint8Array(content));
|
else setViewHexData(bytes);
|
||||||
|
// Keep cache coherent within this session
|
||||||
|
const key = _cacheKey(viewEntry.path, viewEntry.size, viewEntry.lastModified?.toISOString() ?? null);
|
||||||
|
_sessionCache.set(key, bytes);
|
||||||
|
_lsSet(key, bytes);
|
||||||
toast.success(`Saved ${viewEntry.name}`);
|
toast.success(`Saved ${viewEntry.name}`);
|
||||||
void load(path);
|
void load(path);
|
||||||
};
|
};
|
||||||
|
|
@ -980,104 +988,59 @@ export default function FileManager({ initialPath = '/', config, setConfig, onBa
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
{/* ── File viewer overlay ── */}
|
{/* ── File viewer overlay ── */}
|
||||||
{viewEntry && (() => {
|
{viewEntry && (
|
||||||
const fileSize = viewEntry.size;
|
<div className="fixed inset-0 bg-neutral-950 z-50 flex flex-col">
|
||||||
const isFullyLoaded = fileSize === 0 || fileSize <= bufferSize;
|
{/* Title + mode switcher */}
|
||||||
const windowEnd = fileSize > 0 ? Math.min(viewOffset + bufferSize, fileSize) : viewOffset + bufferSize;
|
<div className="bg-neutral-900 flex items-center px-4 py-2 gap-3 border-b border-neutral-700 flex-shrink-0">
|
||||||
const hasPrev = viewOffset > 0;
|
<button onClick={closeViewer} className="p-1.5 rounded hover:bg-neutral-700">
|
||||||
const hasNext = fileSize > 0 ? viewOffset + bufferSize < fileSize : false;
|
<X className="w-5 h-5 text-white" />
|
||||||
|
</button>
|
||||||
return (
|
<span className="font-medium truncate flex-1 text-sm text-white">{viewEntry.name}</span>
|
||||||
<div className="fixed inset-0 bg-neutral-950 z-50 flex flex-col">
|
<div className="flex items-center gap-1">
|
||||||
{/* Title + mode switcher */}
|
{viewMode && availableViewers(viewEntry).map(mode => (
|
||||||
<div className="bg-neutral-900 flex items-center px-4 py-2 gap-3 border-b border-neutral-700 flex-shrink-0">
|
<button
|
||||||
<button onClick={closeViewer} className="p-1.5 rounded hover:bg-neutral-700">
|
key={mode}
|
||||||
<X className="w-5 h-5 text-white" />
|
onClick={() => void switchViewMode(mode)}
|
||||||
</button>
|
title={VIEWER_LABEL[mode]}
|
||||||
<span className="font-medium truncate flex-1 text-sm text-white">{viewEntry.name}</span>
|
className={`px-2 py-1 rounded text-xs inline-flex items-center gap-1 transition-colors ${
|
||||||
<div className="flex items-center gap-1">
|
viewMode === mode
|
||||||
{viewMode && availableViewers(viewEntry).map(mode => (
|
? 'bg-blue-600 text-white'
|
||||||
<button
|
: 'text-neutral-400 hover:bg-neutral-700 hover:text-white'
|
||||||
key={mode}
|
}`}
|
||||||
onClick={() => void switchViewMode(mode)}
|
>
|
||||||
title={VIEWER_LABEL[mode]}
|
<ViewerModeIcon mode={mode} className="w-3.5 h-3.5" />
|
||||||
className={`px-2 py-1 rounded text-xs inline-flex items-center gap-1 transition-colors ${
|
<span className="hidden sm:inline">{VIEWER_LABEL[mode]}</span>
|
||||||
viewMode === mode
|
</button>
|
||||||
? 'bg-blue-600 text-white'
|
))}
|
||||||
: 'text-neutral-400 hover:bg-neutral-700 hover:text-white'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<ViewerModeIcon mode={mode} className="w-3.5 h-3.5" />
|
|
||||||
<span className="hidden sm:inline">{VIEWER_LABEL[mode]}</span>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<button onClick={() => void downloadEntry(viewEntry)} className="p-1.5 rounded hover:bg-neutral-700 text-neutral-300 hover:text-white" title="Download">
|
|
||||||
<Download className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<button onClick={() => void downloadEntry(viewEntry)} className="p-1.5 rounded hover:bg-neutral-700 text-neutral-300 hover:text-white" title="Download">
|
||||||
|
<Download className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Buffer navigation bar (hidden for images) */}
|
<div className="flex-1 overflow-hidden bg-neutral-950">
|
||||||
{viewMode !== 'image' && (
|
{viewLoading && (
|
||||||
<div className="bg-neutral-800 px-3 py-1.5 border-b border-neutral-700 flex items-center gap-2 text-xs flex-shrink-0">
|
<div className="h-full flex items-center justify-center gap-2 text-neutral-400">
|
||||||
<button
|
<Loader2 className="w-5 h-5 animate-spin" /> Loading…
|
||||||
onClick={() => void prevBuffer()}
|
|
||||||
disabled={!hasPrev || viewLoading}
|
|
||||||
className="px-2 py-0.5 rounded bg-neutral-700 text-neutral-300 hover:bg-neutral-600 disabled:opacity-30 disabled:cursor-default"
|
|
||||||
>
|
|
||||||
‹ Prev
|
|
||||||
</button>
|
|
||||||
<span className="text-neutral-400 flex-1 text-center tabular-nums">
|
|
||||||
{fileSize > 0
|
|
||||||
? `${viewOffset.toLocaleString()} – ${windowEnd.toLocaleString()} of ${fileSize.toLocaleString()} bytes`
|
|
||||||
: `offset ${viewOffset.toLocaleString()}`}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
onClick={() => void nextBuffer()}
|
|
||||||
disabled={!hasNext || viewLoading}
|
|
||||||
className="px-2 py-0.5 rounded bg-neutral-700 text-neutral-300 hover:bg-neutral-600 disabled:opacity-30 disabled:cursor-default"
|
|
||||||
>
|
|
||||||
Next ›
|
|
||||||
</button>
|
|
||||||
<span className="text-neutral-600 ml-2">Buffer</span>
|
|
||||||
<select
|
|
||||||
value={bufferSize}
|
|
||||||
onChange={e => void changeBufferSize(Number(e.target.value))}
|
|
||||||
className="bg-neutral-700 text-neutral-300 rounded px-1.5 py-0.5 border-0 focus:outline-none"
|
|
||||||
>
|
|
||||||
<option value={4096}>4 KB</option>
|
|
||||||
<option value={16384}>16 KB</option>
|
|
||||||
<option value={65536}>64 KB</option>
|
|
||||||
<option value={262144}>256 KB</option>
|
|
||||||
<option value={1048576}>1 MB</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{!viewLoading && viewMode === 'image' && viewImgUrl && (
|
||||||
<div className="flex-1 overflow-hidden bg-neutral-950">
|
<div className="h-full flex items-center justify-center overflow-auto p-4">
|
||||||
{viewLoading && (
|
<img src={viewImgUrl} alt={viewEntry.name} className="max-w-full max-h-full object-contain" />
|
||||||
<div className="h-full flex items-center justify-center gap-2 text-neutral-400">
|
</div>
|
||||||
<Loader2 className="w-5 h-5 animate-spin" /> Loading…
|
)}
|
||||||
</div>
|
{!viewLoading && viewMode === 'hex' && viewHexData && (
|
||||||
)}
|
<HexEditor key={viewEntry.path} data={viewHexData} onSave={d => saveViewFile(d)} />
|
||||||
{!viewLoading && viewMode === 'image' && viewImgUrl && (
|
)}
|
||||||
<div className="h-full flex items-center justify-center overflow-auto p-4">
|
{!viewLoading && viewMode === 'markdown' && viewText !== null && (
|
||||||
<img src={viewImgUrl} alt={viewEntry.name} className="max-w-full max-h-full object-contain" />
|
<MarkdownEditor key={viewEntry.path} text={viewText} onSave={s => saveViewFile(s)} />
|
||||||
</div>
|
)}
|
||||||
)}
|
{!viewLoading && (viewMode === 'text' || viewMode === 'json' || viewMode === 'xml') && viewText !== null && (
|
||||||
{!viewLoading && viewMode === 'hex' && viewHexData && (
|
<CodeEditor key={viewEntry.path} text={viewText} mode={viewMode} onSave={s => saveViewFile(s)} />
|
||||||
<HexEditor data={viewHexData} readOnly={!isFullyLoaded} onSave={isFullyLoaded ? d => saveViewFile(d) : undefined} />
|
)}
|
||||||
)}
|
|
||||||
{!viewLoading && viewMode === 'markdown' && viewText !== null && (
|
|
||||||
<MarkdownEditor text={viewText} onSave={isFullyLoaded ? s => saveViewFile(s) : undefined} />
|
|
||||||
)}
|
|
||||||
{!viewLoading && (viewMode === 'text' || viewMode === 'json' || viewMode === 'xml') && viewText !== null && (
|
|
||||||
<CodeEditor text={viewText} mode={viewMode} readOnly={!isFullyLoaded} onSave={isFullyLoaded ? s => saveViewFile(s) : undefined} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
</div>
|
||||||
})()}
|
)}
|
||||||
|
|
||||||
{/* ── Mount dialog ── */}
|
{/* ── Mount dialog ── */}
|
||||||
<Dialog open={mountEntry !== null} onOpenChange={open => !open && setMountEntry(null)}>
|
<Dialog open={mountEntry !== null} onOpenChange={open => !open && setMountEntry(null)}>
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,8 @@ import { useCallback, useEffect, useReducer, useRef, useState } from 'react';
|
||||||
import { Eye, Pencil, Redo2, Save, Search, Undo2, X } from 'lucide-react';
|
import { Eye, Pencil, Redo2, Save, Search, Undo2, X } from 'lucide-react';
|
||||||
|
|
||||||
const BYTES_PER_ROW = 16;
|
const BYTES_PER_ROW = 16;
|
||||||
const MAX_DISPLAY = 65536;
|
const ROW_HEIGHT = 20; // px — matches leading-5 at Tailwind's 16px base
|
||||||
|
const CHUNK_ROWS = 256; // virtual-scroll overscan: render prev+curr+next windows
|
||||||
|
|
||||||
// ── History ───────────────────────────────────────────────────────────────────
|
// ── History ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -74,30 +75,48 @@ export default function HexEditor({ data, readOnly = false, onSave }: HexEditorP
|
||||||
const [searchType, setSearchType] = useState<'text' | 'hex'>('text');
|
const [searchType, setSearchType] = useState<'text' | 'hex'>('text');
|
||||||
const [matchIdx, setMatchIdx] = useState(-1);
|
const [matchIdx, setMatchIdx] = useState(-1);
|
||||||
|
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
// Virtual scroll
|
||||||
const searchRef = useRef<HTMLInputElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
const searchRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [scrollTop, setScrollTop] = useState(0);
|
||||||
|
|
||||||
const needle = query.trim() ? (searchType === 'text' ? new TextEncoder().encode(query) : parseHex(query)) : null;
|
const totalRows = Math.ceil(current.length / BYTES_PER_ROW);
|
||||||
const matches = needle ? matchAll(current, needle) : [];
|
const firstRenderRow = Math.max(0, Math.floor(scrollTop / ROW_HEIGHT) - CHUNK_ROWS);
|
||||||
|
const lastRenderRow = Math.min(totalRows, firstRenderRow + CHUNK_ROWS * 3);
|
||||||
|
const paddingTop = firstRenderRow * ROW_HEIGHT;
|
||||||
|
const paddingBottom = (totalRows - lastRenderRow) * ROW_HEIGHT;
|
||||||
|
|
||||||
|
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 needleLen = needle?.length ?? 0;
|
||||||
const dirty = hist.idx > 0;
|
const dirty = hist.idx > 0;
|
||||||
const canUndo = hist.idx > 0;
|
const canUndo = hist.idx > 0;
|
||||||
const canRedo = hist.idx < hist.stack.length - 1;
|
const canRedo = hist.idx < hist.stack.length - 1;
|
||||||
|
|
||||||
|
// Reset history when the data prop changes (e.g. after a save that updates the prop)
|
||||||
|
useEffect(() => {
|
||||||
|
dispatch({ type: 'reset', data: data.slice() });
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
// Jump to first result when query / search type changes
|
// Jump to first result when query / search type changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (matches.length > 0) { setMatchIdx(0); setCursor(matches[0]); }
|
if (matches.length > 0) { setMatchIdx(0); setCursor(matches[0]); }
|
||||||
else setMatchIdx(-1);
|
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
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [query, searchType]);
|
}, [query, searchType]);
|
||||||
|
|
||||||
// Scroll cursor row into view
|
// Scroll cursor row into view (programmatic, works with virtual scroll)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (cursor < 0) return;
|
if (cursor < 0) return;
|
||||||
containerRef.current
|
const row = Math.floor(cursor / BYTES_PER_ROW);
|
||||||
?.querySelector(`[data-byte="${cursor}"]`)
|
const rowTop = row * ROW_HEIGHT + 12; // 12 = top padding equivalent
|
||||||
?.scrollIntoView({ block: 'nearest' });
|
const el = scrollRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
if (rowTop < el.scrollTop) {
|
||||||
|
el.scrollTop = rowTop;
|
||||||
|
} else if (rowTop + ROW_HEIGHT > el.scrollTop + el.clientHeight) {
|
||||||
|
el.scrollTop = rowTop + ROW_HEIGHT - el.clientHeight;
|
||||||
|
}
|
||||||
}, [cursor]);
|
}, [cursor]);
|
||||||
|
|
||||||
const pushByte = useCallback((offset: number, value: number) => {
|
const pushByte = useCallback((offset: number, value: number) => {
|
||||||
|
|
@ -122,7 +141,7 @@ export default function HexEditor({ data, readOnly = false, onSave }: HexEditorP
|
||||||
const i = ((idx % matches.length) + matches.length) % matches.length;
|
const i = ((idx % matches.length) + matches.length) % matches.length;
|
||||||
setMatchIdx(i);
|
setMatchIdx(i);
|
||||||
setCursor(matches[i]);
|
setCursor(matches[i]);
|
||||||
containerRef.current?.focus();
|
scrollRef.current?.focus();
|
||||||
};
|
};
|
||||||
|
|
||||||
const openSearch = () => {
|
const openSearch = () => {
|
||||||
|
|
@ -132,7 +151,7 @@ export default function HexEditor({ data, readOnly = false, onSave }: HexEditorP
|
||||||
const closeSearch = () => {
|
const closeSearch = () => {
|
||||||
setSearchOpen(false);
|
setSearchOpen(false);
|
||||||
setQuery('');
|
setQuery('');
|
||||||
containerRef.current?.focus();
|
scrollRef.current?.focus();
|
||||||
};
|
};
|
||||||
|
|
||||||
const onKeyDown = (e: React.KeyboardEvent) => {
|
const onKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
|
@ -178,9 +197,6 @@ export default function HexEditor({ data, readOnly = false, onSave }: HexEditorP
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const view = current.length > MAX_DISPLAY ? current.slice(0, MAX_DISPLAY) : current;
|
|
||||||
const numRows = Math.ceil(view.length / BYTES_PER_ROW);
|
|
||||||
|
|
||||||
// Highlight sets
|
// Highlight sets
|
||||||
const allMatchSet = new Set<number>();
|
const allMatchSet = new Set<number>();
|
||||||
for (const pos of matches) { for (let i = 0; i < needleLen; i++) allMatchSet.add(pos + i); }
|
for (const pos of matches) { for (let i = 0; i < needleLen; i++) allMatchSet.add(pos + i); }
|
||||||
|
|
@ -195,7 +211,7 @@ export default function HexEditor({ data, readOnly = false, onSave }: HexEditorP
|
||||||
<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">
|
<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 && (
|
{!readOnly && (
|
||||||
<button
|
<button
|
||||||
onClick={() => { setEditMode(v => !v); containerRef.current?.focus(); }}
|
onClick={() => { setEditMode(v => !v); scrollRef.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'}`}
|
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</>}
|
{editMode ? <><Eye className="w-3.5 h-3.5" /> View</> : <><Pencil className="w-3.5 h-3.5" /> Edit</>}
|
||||||
|
|
@ -220,9 +236,6 @@ export default function HexEditor({ data, readOnly = false, onSave }: HexEditorP
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<div className="flex-1" />
|
<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>
|
<span className="text-neutral-500 mr-1">{current.length.toLocaleString()} bytes</span>
|
||||||
<button
|
<button
|
||||||
onClick={openSearch}
|
onClick={openSearch}
|
||||||
|
|
@ -272,21 +285,27 @@ export default function HexEditor({ data, readOnly = false, onSave }: HexEditorP
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── Hex grid ── */}
|
{/* ── Hex grid (virtual scroll) ── */}
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={scrollRef}
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
onKeyDown={onKeyDown}
|
onKeyDown={onKeyDown}
|
||||||
className="flex-1 overflow-auto p-3 focus:outline-none select-none"
|
onScroll={e => setScrollTop(e.currentTarget.scrollTop)}
|
||||||
onClick={() => containerRef.current?.focus()}
|
className="flex-1 overflow-auto focus:outline-none select-none"
|
||||||
|
onClick={() => scrollRef.current?.focus()}
|
||||||
>
|
>
|
||||||
{cursor < 0 && (
|
{/* top spacer — 12px mimics former p-3 top padding */}
|
||||||
<div className="text-neutral-600 text-xs mb-2 font-mono">
|
<div style={{ height: paddingTop + 12 }} />
|
||||||
|
|
||||||
|
{cursor < 0 && firstRenderRow === 0 && (
|
||||||
|
<div className="px-3 text-neutral-600 text-xs mb-2 font-mono">
|
||||||
Click a cell to position cursor{editMode ? ' · type hex digits or ASCII to edit' : ''}
|
Click a cell to position cursor{editMode ? ' · type hex digits or ASCII to edit' : ''}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="font-mono text-xs leading-5 whitespace-nowrap">
|
|
||||||
{Array.from({ length: numRows }, (_, row) => {
|
<div className="px-3 font-mono text-xs leading-5 whitespace-nowrap">
|
||||||
|
{Array.from({ length: lastRenderRow - firstRenderRow }, (_, i) => {
|
||||||
|
const row = firstRenderRow + i;
|
||||||
const base = row * BYTES_PER_ROW;
|
const base = row * BYTES_PER_ROW;
|
||||||
return (
|
return (
|
||||||
<div key={row} className="flex items-center">
|
<div key={row} className="flex items-center">
|
||||||
|
|
@ -300,27 +319,24 @@ export default function HexEditor({ data, readOnly = false, onSave }: HexEditorP
|
||||||
<div className="flex mr-2">
|
<div className="flex mr-2">
|
||||||
{Array.from({ length: BYTES_PER_ROW }, (_, col) => {
|
{Array.from({ length: BYTES_PER_ROW }, (_, col) => {
|
||||||
const idx = base + col;
|
const idx = base + col;
|
||||||
if (idx >= view.length) {
|
if (idx >= current.length) {
|
||||||
return <span key={col} className={`inline-block w-6 text-center${col === 8 ? ' ml-2' : ''}`} />;
|
return <span key={col} className={`inline-block w-6 text-center${col === 8 ? ' ml-2' : ''}`} />;
|
||||||
}
|
}
|
||||||
const byte = view[idx];
|
const byte = current[idx];
|
||||||
const isCursor = idx === cursor;
|
const isCursor = idx === cursor;
|
||||||
const isCurMatch = curMatchSet.has(idx);
|
const isCurMatch = curMatchSet.has(idx);
|
||||||
const isAllMatch = allMatchSet.has(idx) && !isCurMatch;
|
const isAllMatch = allMatchSet.has(idx) && !isCurMatch;
|
||||||
|
|
||||||
const color = isCurMatch ? 'bg-orange-500 text-white'
|
const color = isCurMatch ? 'bg-orange-500 text-white'
|
||||||
: isAllMatch ? 'bg-yellow-800 text-yellow-200'
|
: isAllMatch ? 'bg-yellow-800 text-yellow-200'
|
||||||
: byte === 0 ? 'text-neutral-700'
|
: byte === 0 ? 'text-neutral-700'
|
||||||
: 'text-green-400';
|
: 'text-green-400';
|
||||||
const ring = isCursor && pane === 'hex' ? ' ring-1 ring-inset ring-blue-400' : '';
|
const ring = isCursor && pane === 'hex' ? ' ring-1 ring-inset ring-blue-400' : '';
|
||||||
const gap = col === 8 ? ' ml-2' : '';
|
const gap = col === 8 ? ' ml-2' : '';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
key={col}
|
key={col}
|
||||||
data-byte={idx}
|
|
||||||
className={`inline-block w-6 text-center cursor-pointer${gap} ${color}${ring}`}
|
className={`inline-block w-6 text-center cursor-pointer${gap} ${color}${ring}`}
|
||||||
onClick={() => { setCursor(idx); setNibble(0); setPane('hex'); containerRef.current?.focus(); }}
|
onClick={() => { setCursor(idx); setNibble(0); setPane('hex'); scrollRef.current?.focus(); }}
|
||||||
>
|
>
|
||||||
{byte.toString(16).padStart(2, '0').toUpperCase()}
|
{byte.toString(16).padStart(2, '0').toUpperCase()}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -335,25 +351,23 @@ export default function HexEditor({ data, readOnly = false, onSave }: HexEditorP
|
||||||
<div className="flex">
|
<div className="flex">
|
||||||
{Array.from({ length: BYTES_PER_ROW }, (_, col) => {
|
{Array.from({ length: BYTES_PER_ROW }, (_, col) => {
|
||||||
const idx = base + col;
|
const idx = base + col;
|
||||||
if (idx >= view.length) return <span key={col} className="inline-block w-[9px]" />;
|
if (idx >= current.length) return <span key={col} className="inline-block w-[9px]" />;
|
||||||
const byte = view[idx];
|
const byte = current[idx];
|
||||||
const printable = byte >= 32 && byte < 127;
|
const printable = byte >= 32 && byte < 127;
|
||||||
const char = printable ? String.fromCharCode(byte) : '·';
|
const char = printable ? String.fromCharCode(byte) : '·';
|
||||||
const isCursor = idx === cursor;
|
const isCursor = idx === cursor;
|
||||||
const isCurMatch = curMatchSet.has(idx);
|
const isCurMatch = curMatchSet.has(idx);
|
||||||
const isAllMatch = allMatchSet.has(idx) && !isCurMatch;
|
const isAllMatch = allMatchSet.has(idx) && !isCurMatch;
|
||||||
|
|
||||||
const color = isCurMatch ? 'bg-orange-500 text-white'
|
const color = isCurMatch ? 'bg-orange-500 text-white'
|
||||||
: isAllMatch ? 'bg-yellow-800 text-yellow-200'
|
: isAllMatch ? 'bg-yellow-800 text-yellow-200'
|
||||||
: printable ? 'text-blue-300'
|
: printable ? 'text-blue-300'
|
||||||
: 'text-neutral-700';
|
: 'text-neutral-700';
|
||||||
const ring = isCursor && pane === 'ascii' ? ' ring-1 ring-inset ring-blue-400' : '';
|
const ring = isCursor && pane === 'ascii' ? ' ring-1 ring-inset ring-blue-400' : '';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
key={col}
|
key={col}
|
||||||
className={`inline-block w-[9px] text-center cursor-pointer ${color}${ring}`}
|
className={`inline-block w-[9px] text-center cursor-pointer ${color}${ring}`}
|
||||||
onClick={() => { setCursor(idx); setPane('ascii'); containerRef.current?.focus(); }}
|
onClick={() => { setCursor(idx); setPane('ascii'); scrollRef.current?.focus(); }}
|
||||||
>
|
>
|
||||||
{char}
|
{char}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -365,6 +379,9 @@ export default function HexEditor({ data, readOnly = false, onSave }: HexEditorP
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* bottom spacer */}
|
||||||
|
<div style={{ height: paddingBottom + 12 }} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user