feat(FileManager): implement buffer navigation and dynamic buffer size for file viewing
This commit is contained in:
parent
30e9c0949a
commit
487d53f77b
|
|
@ -43,6 +43,7 @@ import {
|
||||||
createFolder,
|
createFolder,
|
||||||
deletePath,
|
deletePath,
|
||||||
getFileContents,
|
getFileContents,
|
||||||
|
getFileRange,
|
||||||
humanFileSize,
|
humanFileSize,
|
||||||
joinPath,
|
joinPath,
|
||||||
listDirectory,
|
listDirectory,
|
||||||
|
|
@ -197,14 +198,16 @@ function MarkdownEditor({ text, onSave }: { text: string; onSave?: (s: string) =
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<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">
|
<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
|
{onSave && (
|
||||||
onClick={() => setEditMode(v => !v)}
|
<button
|
||||||
className={editMode
|
onClick={() => setEditMode(v => !v)}
|
||||||
? 'px-2 py-1 rounded bg-amber-600 text-white inline-flex items-center gap-1'
|
className={editMode
|
||||||
: 'px-2 py-1 rounded bg-neutral-700 text-neutral-300 hover:bg-neutral-600 inline-flex items-center gap-1'}
|
? '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 ? <><Eye className="w-3.5 h-3.5" /> View</> : <><Pencil className="w-3.5 h-3.5" /> Edit</>}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{editMode && onSave && (
|
{editMode && onSave && (
|
||||||
<button onClick={() => void save()} disabled={saving}
|
<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">
|
className="px-2 py-1 rounded bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50 inline-flex items-center gap-1">
|
||||||
|
|
@ -276,6 +279,11 @@ export default function FileManager({ initialPath = '/', config, setConfig, onBa
|
||||||
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);
|
||||||
|
|
@ -353,6 +361,24 @@ export default function FileManager({ initialPath = '/', config, setConfig, onBa
|
||||||
else setViewText(await blob.text());
|
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; }
|
||||||
|
|
@ -363,11 +389,10 @@ export default function FileManager({ initialPath = '/', config, setConfig, onBa
|
||||||
setViewText(null);
|
setViewText(null);
|
||||||
setViewImgUrl(null);
|
setViewImgUrl(null);
|
||||||
setViewHexData(null);
|
setViewHexData(null);
|
||||||
|
setViewOffset(0);
|
||||||
setViewLoading(true);
|
setViewLoading(true);
|
||||||
try {
|
try {
|
||||||
const blob = await getFileContents(entry.path);
|
await loadBuffer(entry, 0, targetMode);
|
||||||
setViewBlob(blob);
|
|
||||||
await processBlob(blob, targetMode);
|
|
||||||
} 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);
|
||||||
|
|
@ -378,16 +403,41 @@ export default function FileManager({ initialPath = '/', config, setConfig, onBa
|
||||||
};
|
};
|
||||||
|
|
||||||
const switchViewMode = async (mode: ViewMode) => {
|
const switchViewMode = async (mode: ViewMode) => {
|
||||||
if (!viewBlob) return;
|
if (!viewEntry) return;
|
||||||
setViewMode(mode);
|
setViewMode(mode);
|
||||||
setViewLoading(true);
|
setViewLoading(true);
|
||||||
try { await processBlob(viewBlob, mode); } finally { setViewLoading(false); }
|
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); }
|
||||||
};
|
};
|
||||||
|
|
||||||
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); setViewBlob(null);
|
||||||
setViewText(null); setViewHexData(null);
|
setViewText(null); setViewHexData(null); setViewOffset(0);
|
||||||
};
|
};
|
||||||
|
|
||||||
const saveViewFile = async (content: string | Uint8Array) => {
|
const saveViewFile = async (content: string | Uint8Array) => {
|
||||||
|
|
@ -930,59 +980,104 @@ export default function FileManager({ initialPath = '/', config, setConfig, onBa
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
{/* ── File viewer overlay ── */}
|
{/* ── File viewer overlay ── */}
|
||||||
{viewEntry && (
|
{viewEntry && (() => {
|
||||||
<div className="fixed inset-0 bg-neutral-950 z-50 flex flex-col">
|
const fileSize = viewEntry.size;
|
||||||
<div className="bg-neutral-900 flex items-center px-4 py-2 gap-3 border-b border-neutral-700 flex-shrink-0">
|
const isFullyLoaded = fileSize === 0 || fileSize <= bufferSize;
|
||||||
<button onClick={closeViewer} className="p-1.5 rounded hover:bg-neutral-700">
|
const windowEnd = fileSize > 0 ? Math.min(viewOffset + bufferSize, fileSize) : viewOffset + bufferSize;
|
||||||
<X className="w-5 h-5 text-white" />
|
const hasPrev = viewOffset > 0;
|
||||||
</button>
|
const hasNext = fileSize > 0 ? viewOffset + bufferSize < fileSize : false;
|
||||||
<span className="font-medium truncate flex-1 text-sm text-white">{viewEntry.name}</span>
|
|
||||||
{/* Mode switcher */}
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
{viewMode && availableViewers(viewEntry).map(mode => (
|
|
||||||
<button
|
|
||||||
key={mode}
|
|
||||||
onClick={() => void switchViewMode(mode)}
|
|
||||||
title={VIEWER_LABEL[mode]}
|
|
||||||
className={`px-2 py-1 rounded text-xs inline-flex items-center gap-1 transition-colors ${
|
|
||||||
viewMode === mode
|
|
||||||
? '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 className="flex-1 overflow-hidden bg-neutral-950">
|
return (
|
||||||
{viewLoading && (
|
<div className="fixed inset-0 bg-neutral-950 z-50 flex flex-col">
|
||||||
<div className="h-full flex items-center justify-center gap-2 text-neutral-400">
|
{/* Title + mode switcher */}
|
||||||
<Loader2 className="w-5 h-5 animate-spin" /> Loading…
|
<div className="bg-neutral-900 flex items-center px-4 py-2 gap-3 border-b border-neutral-700 flex-shrink-0">
|
||||||
|
<button onClick={closeViewer} className="p-1.5 rounded hover:bg-neutral-700">
|
||||||
|
<X className="w-5 h-5 text-white" />
|
||||||
|
</button>
|
||||||
|
<span className="font-medium truncate flex-1 text-sm text-white">{viewEntry.name}</span>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{viewMode && availableViewers(viewEntry).map(mode => (
|
||||||
|
<button
|
||||||
|
key={mode}
|
||||||
|
onClick={() => void switchViewMode(mode)}
|
||||||
|
title={VIEWER_LABEL[mode]}
|
||||||
|
className={`px-2 py-1 rounded text-xs inline-flex items-center gap-1 transition-colors ${
|
||||||
|
viewMode === mode
|
||||||
|
? '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>
|
||||||
|
|
||||||
|
{/* Buffer navigation bar (hidden for images) */}
|
||||||
|
{viewMode !== 'image' && (
|
||||||
|
<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">
|
||||||
|
<button
|
||||||
|
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="h-full flex items-center justify-center overflow-auto p-4">
|
<div className="flex-1 overflow-hidden bg-neutral-950">
|
||||||
<img src={viewImgUrl} alt={viewEntry.name} className="max-w-full max-h-full object-contain" />
|
{viewLoading && (
|
||||||
</div>
|
<div className="h-full flex items-center justify-center gap-2 text-neutral-400">
|
||||||
)}
|
<Loader2 className="w-5 h-5 animate-spin" /> Loading…
|
||||||
{!viewLoading && viewMode === 'hex' && viewHexData && (
|
</div>
|
||||||
<HexEditor data={viewHexData} onSave={d => saveViewFile(d)} />
|
)}
|
||||||
)}
|
{!viewLoading && viewMode === 'image' && viewImgUrl && (
|
||||||
{!viewLoading && viewMode === 'markdown' && viewText !== null && (
|
<div className="h-full flex items-center justify-center overflow-auto p-4">
|
||||||
<MarkdownEditor text={viewText} onSave={s => saveViewFile(s)} />
|
<img src={viewImgUrl} alt={viewEntry.name} className="max-w-full max-h-full object-contain" />
|
||||||
)}
|
</div>
|
||||||
{!viewLoading && (viewMode === 'text' || viewMode === 'json' || viewMode === 'xml') && viewText !== null && (
|
)}
|
||||||
<CodeEditor text={viewText} mode={viewMode} onSave={s => saveViewFile(s)} />
|
{!viewLoading && viewMode === 'hex' && viewHexData && (
|
||||||
)}
|
<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)}>
|
||||||
|
|
|
||||||
|
|
@ -317,6 +317,19 @@ export async function getFileContents(path: string): Promise<Blob> {
|
||||||
return r.blob();
|
return r.blob();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch a byte range of a file using HTTP Range requests.
|
||||||
|
* Returns a Blob containing the requested bytes. Falls back to a full
|
||||||
|
* fetch if the server responds 200 (no Range support).
|
||||||
|
*/
|
||||||
|
export async function getFileRange(path: string, start: number, end: number): Promise<Blob> {
|
||||||
|
const base = getWebDAVClient().client.baseUrl;
|
||||||
|
const url = pathToUrl(normalizePath(path), base);
|
||||||
|
const r = await fetch(url, { headers: { Range: `bytes=${start}-${end}` } });
|
||||||
|
if (r.status === 206 || r.status === 200) return r.blob();
|
||||||
|
throw new Error(`GET range failed: ${r.status} ${r.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
// ----- Helpers --------------------------------------------------------
|
// ----- Helpers --------------------------------------------------------
|
||||||
|
|
||||||
/** Convert a server-relative path to an absolute URL on the configured base. */
|
/** Convert a server-relative path to an absolute URL on the configured base. */
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user