diff --git a/src/app/components/FileManager.tsx b/src/app/components/FileManager.tsx index 98ad9b8..bdc2928 100644 --- a/src/app/components/FileManager.tsx +++ b/src/app/components/FileManager.tsx @@ -43,7 +43,6 @@ import { createFolder, deletePath, getFileContents, - getFileRange, humanFileSize, joinPath, listDirectory, @@ -254,6 +253,52 @@ interface FileManagerProps { onNavigateToDevice?: (deviceId: string) => void; } +// ─── File cache (session + localStorage) ───────────────────────────────────── + +const _sessionCache = new Map(); +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 { + 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 ─────────────────────────────────────────────────────────── const FM_PATH_KEY = 'fileManager.path'; @@ -274,16 +319,10 @@ export default function FileManager({ initialPath = '/', config, setConfig, onBa // Viewer const [viewEntry, setViewEntry] = useState(null); const [viewMode, setViewMode] = useState(null); - const [viewBlob, setViewBlob] = useState(null); const [viewText, setViewText] = useState(null); const [viewImgUrl, setViewImgUrl] = useState(null); const [viewHexData, setViewHexData] = useState(null); const [viewLoading, setViewLoading] = useState(false); - const [viewOffset, setViewOffset] = useState(0); - const [bufferSize, setBufferSize] = useState(() => { - const v = Number(localStorage.getItem('fileManager.bufferSize')); - return v > 0 ? v : 65536; - }); // Rename / folder const [showNewFolder, setShowNewFolder] = useState(false); @@ -352,47 +391,26 @@ export default function FileManager({ initialPath = '/', config, setConfig, onBa // ── 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) => { if (renameEntry !== null) return; if (entry.type === 'folder') { navigateTo(joinPath(path, entry.name)); return; } const targetMode = mode ?? defaultViewMode(entry); setViewEntry(entry); setViewMode(targetMode); - setViewBlob(null); setViewText(null); - setViewImgUrl(null); + setViewImgUrl(prev => { if (prev) URL.revokeObjectURL(prev); return null; }); setViewHexData(null); - setViewOffset(0); setViewLoading(true); 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) { toast.error(`Failed to open ${entry.name}: ${e?.message ?? e}`); setViewEntry(null); @@ -406,49 +424,39 @@ export default function FileManager({ initialPath = '/', config, setConfig, onBa if (!viewEntry) return; setViewMode(mode); setViewLoading(true); - try { await loadBuffer(viewEntry, viewOffset, mode); } finally { setViewLoading(false); } - }; - - const prevBuffer = async () => { - if (!viewEntry || !viewMode || viewOffset <= 0) return; - setViewLoading(true); - try { await loadBuffer(viewEntry, Math.max(0, viewOffset - bufferSize), viewMode); } - finally { setViewLoading(false); } - }; - - const nextBuffer = async () => { - if (!viewEntry || !viewMode) return; - 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); } + try { + const bytes = await _getEntryBytes(viewEntry); + if (mode === 'image') { + const ab = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer; + setViewImgUrl(prev => { if (prev) URL.revokeObjectURL(prev); return URL.createObjectURL(new Blob([ab])); }); + } else if (mode === 'hex') { + setViewHexData(bytes); + } else { + setViewText(new TextDecoder().decode(bytes)); + } + } finally { + setViewLoading(false); + } }; const closeViewer = () => { setViewImgUrl(prev => { if (prev) URL.revokeObjectURL(prev); return null; }); - setViewEntry(null); setViewMode(null); setViewBlob(null); - setViewText(null); setViewHexData(null); setViewOffset(0); + setViewEntry(null); setViewMode(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); + const bytes: Uint8Array = typeof content === 'string' + ? new TextEncoder().encode(content) + : content; + await putFileContents(viewEntry.path, bytes); 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}`); void load(path); }; @@ -980,104 +988,59 @@ export default function FileManager({ initialPath = '/', config, setConfig, onBa {/* ── File viewer overlay ── */} - {viewEntry && (() => { - const fileSize = viewEntry.size; - const isFullyLoaded = fileSize === 0 || fileSize <= bufferSize; - const windowEnd = fileSize > 0 ? Math.min(viewOffset + bufferSize, fileSize) : viewOffset + bufferSize; - const hasPrev = viewOffset > 0; - const hasNext = fileSize > 0 ? viewOffset + bufferSize < fileSize : false; - - return ( -
- {/* Title + mode switcher */} -
- - {viewEntry.name} -
- {viewMode && availableViewers(viewEntry).map(mode => ( - - ))} -
- + {viewEntry && ( +
+ {/* Title + mode switcher */} +
+ + {viewEntry.name} +
+ {viewMode && availableViewers(viewEntry).map(mode => ( + + ))}
+ +
- {/* Buffer navigation bar (hidden for images) */} - {viewMode !== 'image' && ( -
- - - {fileSize > 0 - ? `${viewOffset.toLocaleString()} – ${windowEnd.toLocaleString()} of ${fileSize.toLocaleString()} bytes` - : `offset ${viewOffset.toLocaleString()}`} - - - Buffer - +
+ {viewLoading && ( +
+ Loading…
)} - -
- {viewLoading && ( -
- Loading… -
- )} - {!viewLoading && viewMode === 'image' && viewImgUrl && ( -
- {viewEntry.name} -
- )} - {!viewLoading && viewMode === 'hex' && viewHexData && ( - saveViewFile(d) : undefined} /> - )} - {!viewLoading && viewMode === 'markdown' && viewText !== null && ( - saveViewFile(s) : undefined} /> - )} - {!viewLoading && (viewMode === 'text' || viewMode === 'json' || viewMode === 'xml') && viewText !== null && ( - saveViewFile(s) : undefined} /> - )} -
+ {!viewLoading && viewMode === 'image' && viewImgUrl && ( +
+ {viewEntry.name} +
+ )} + {!viewLoading && viewMode === 'hex' && viewHexData && ( + saveViewFile(d)} /> + )} + {!viewLoading && viewMode === 'markdown' && viewText !== null && ( + saveViewFile(s)} /> + )} + {!viewLoading && (viewMode === 'text' || viewMode === 'json' || viewMode === 'xml') && viewText !== null && ( + saveViewFile(s)} /> + )}
- ); - })()} +
+ )} {/* ── Mount dialog ── */} !open && setMountEntry(null)}> diff --git a/src/app/components/HexEditor.tsx b/src/app/components/HexEditor.tsx index 6a6722e..9ada6f0 100644 --- a/src/app/components/HexEditor.tsx +++ b/src/app/components/HexEditor.tsx @@ -2,7 +2,8 @@ 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; +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 ─────────────────────────────────────────────────────────────────── @@ -74,30 +75,48 @@ export default function HexEditor({ data, readOnly = false, onSave }: HexEditorP const [searchType, setSearchType] = useState<'text' | 'hex'>('text'); const [matchIdx, setMatchIdx] = useState(-1); - const containerRef = useRef(null); - const searchRef = useRef(null); + // Virtual scroll + const scrollRef = useRef(null); + const searchRef = useRef(null); + const [scrollTop, setScrollTop] = useState(0); - const needle = query.trim() ? (searchType === 'text' ? new TextEncoder().encode(query) : parseHex(query)) : null; - const matches = needle ? matchAll(current, needle) : []; + const totalRows = Math.ceil(current.length / BYTES_PER_ROW); + 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 dirty = hist.idx > 0; - const canUndo = hist.idx > 0; - const canRedo = hist.idx < hist.stack.length - 1; + const dirty = hist.idx > 0; + const canUndo = hist.idx > 0; + 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 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 + // Scroll cursor row into view (programmatic, works with virtual scroll) useEffect(() => { if (cursor < 0) return; - containerRef.current - ?.querySelector(`[data-byte="${cursor}"]`) - ?.scrollIntoView({ block: 'nearest' }); + const row = Math.floor(cursor / BYTES_PER_ROW); + const rowTop = row * ROW_HEIGHT + 12; // 12 = top padding equivalent + 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]); 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; setMatchIdx(i); setCursor(matches[i]); - containerRef.current?.focus(); + scrollRef.current?.focus(); }; const openSearch = () => { @@ -132,7 +151,7 @@ export default function HexEditor({ data, readOnly = false, onSave }: HexEditorP const closeSearch = () => { setSearchOpen(false); setQuery(''); - containerRef.current?.focus(); + scrollRef.current?.focus(); }; 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 const allMatchSet = new Set(); 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
{!readOnly && (