Compare commits
No commits in common. "135d10861d02526c04045d0d643542918ed444b1" and "435a1b38c0cdfa8a4a5746b472baa46ea236c447" have entirely different histories.
135d10861d
...
435a1b38c0
|
|
@ -1,10 +1,7 @@
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { X, ChevronLeft, ChevronRight, Printer, HardDrive, Network, Box, FolderOpen, MoreVertical, Play, Pause, SkipForward, SkipBack, RotateCcw } from 'lucide-react';
|
import { X, ChevronLeft, ChevronRight, Printer, HardDrive, Network, Box, FolderOpen, MoreVertical, Play, Pause, SkipForward, SkipBack, RotateCcw } from 'lucide-react';
|
||||||
import { motion, AnimatePresence } from 'motion/react';
|
import { motion, AnimatePresence } from 'motion/react';
|
||||||
import { toast } from 'sonner';
|
|
||||||
import { getFileContents, joinPath } from '../webdav';
|
|
||||||
import FileBrowser from './FileBrowser';
|
import FileBrowser from './FileBrowser';
|
||||||
import MediaSet from './MediaSet';
|
|
||||||
|
|
||||||
interface Device {
|
interface Device {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -39,6 +36,7 @@ export default function DeviceDetailOverlay({
|
||||||
const [touchEnd, setTouchEnd] = useState(0);
|
const [touchEnd, setTouchEnd] = useState(0);
|
||||||
const [showFileBrowser, setShowFileBrowser] = useState(false);
|
const [showFileBrowser, setShowFileBrowser] = useState(false);
|
||||||
const [showCommandMenu, setShowCommandMenu] = useState(false);
|
const [showCommandMenu, setShowCommandMenu] = useState(false);
|
||||||
|
const [selectedMediaIndex, setSelectedMediaIndex] = useState(0);
|
||||||
|
|
||||||
const minSwipeDistance = 50;
|
const minSwipeDistance = 50;
|
||||||
|
|
||||||
|
|
@ -146,48 +144,13 @@ export default function DeviceDetailOverlay({
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
// Prefer an explicit .lst-derived mediaSet stored in config; fall back to pattern detection.
|
const mediaSet = detectMediaSet();
|
||||||
const mediaSetFiles: string[] | null = (() => {
|
|
||||||
if (Array.isArray(deviceData.mediaSet) && deviceData.mediaSet.length > 0) {
|
|
||||||
return deviceData.mediaSet as string[];
|
|
||||||
}
|
|
||||||
const detected = detectMediaSet();
|
|
||||||
return detected ? detected.files : null;
|
|
||||||
})();
|
|
||||||
|
|
||||||
const switchMedia = (file: string) => {
|
const switchMedia = (index: number) => {
|
||||||
|
if (!mediaSet) return;
|
||||||
const path = getDevicePath();
|
const path = getDevicePath();
|
||||||
updateDeviceSetting([...path, 'url'], file);
|
updateDeviceSetting([...path, 'url'], mediaSet.files[index]);
|
||||||
};
|
setSelectedMediaIndex(index);
|
||||||
|
|
||||||
const handleFileSelect = async (selectedPath: string) => {
|
|
||||||
const devicePath = getDevicePath();
|
|
||||||
if (selectedPath.toLowerCase().endsWith('.lst')) {
|
|
||||||
try {
|
|
||||||
const text = await (await getFileContents(selectedPath)).text();
|
|
||||||
const dir = selectedPath.split('/').slice(0, -1).join('/') || '/';
|
|
||||||
const files = text.split('\n')
|
|
||||||
.map(l => l.trim())
|
|
||||||
.filter(l => l.length > 0 && !l.startsWith('#'))
|
|
||||||
.map(l => l.startsWith('/') ? l : joinPath(dir, l));
|
|
||||||
if (files.length === 0) { toast.error('Swap list is empty'); return; }
|
|
||||||
const newConfig = JSON.parse(JSON.stringify(config));
|
|
||||||
let dev = newConfig;
|
|
||||||
for (const k of devicePath) dev = dev[k];
|
|
||||||
dev.url = files[0];
|
|
||||||
dev.mediaSet = files;
|
|
||||||
setConfig(newConfig);
|
|
||||||
} catch (e: any) {
|
|
||||||
toast.error(`Failed to read swap list: ${e?.message ?? e}`);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const newConfig = JSON.parse(JSON.stringify(config));
|
|
||||||
let dev = newConfig;
|
|
||||||
for (const k of devicePath) dev = dev[k];
|
|
||||||
dev.url = selectedPath;
|
|
||||||
delete dev.mediaSet;
|
|
||||||
setConfig(newConfig);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const sendCommand = (command: string) => {
|
const sendCommand = (command: string) => {
|
||||||
|
|
@ -210,7 +173,7 @@ export default function DeviceDetailOverlay({
|
||||||
animate={{ y: 0 }}
|
animate={{ y: 0 }}
|
||||||
exit={{ y: '100%' }}
|
exit={{ y: '100%' }}
|
||||||
transition={{ type: 'spring', damping: 30, stiffness: 300 }}
|
transition={{ type: 'spring', damping: 30, stiffness: 300 }}
|
||||||
className="fixed inset-0 bg-white overflow-y-auto z-50"
|
className="absolute inset-0 bg-white overflow-y-auto"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
onTouchStart={onTouchStart}
|
onTouchStart={onTouchStart}
|
||||||
onTouchMove={onTouchMove}
|
onTouchMove={onTouchMove}
|
||||||
|
|
@ -247,6 +210,26 @@ export default function DeviceDetailOverlay({
|
||||||
<RotateCcw className="w-4 h-4" />
|
<RotateCcw className="w-4 h-4" />
|
||||||
Reset Device
|
Reset Device
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => sendCommand('EJECT')}
|
||||||
|
className="w-full px-4 py-2 text-left hover:bg-neutral-50 flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<SkipBack className="w-4 h-4" />
|
||||||
|
Eject Media
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => sendCommand('MOUNT')}
|
||||||
|
className="w-full px-4 py-2 text-left hover:bg-neutral-50 flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<Play className="w-4 h-4" />
|
||||||
|
Mount Media
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => sendCommand('STATUS')}
|
||||||
|
className="w-full px-4 py-2 text-left hover:bg-neutral-50"
|
||||||
|
>
|
||||||
|
Get Status
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -339,9 +322,31 @@ export default function DeviceDetailOverlay({
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{mediaSetFiles && (
|
{mediaSet && (
|
||||||
<div className="mt-3">
|
<div className="mt-3">
|
||||||
<MediaSet files={mediaSetFiles} activeUrl={deviceData.url ?? ''} onSwitch={switchMedia} />
|
<label className="text-sm text-neutral-500 block mb-2">Media Set</label>
|
||||||
|
<div className="flex gap-2 flex-wrap">
|
||||||
|
{mediaSet.files.slice(0, 5).map((file, index) => {
|
||||||
|
// Attempt to extract a title from the filename, fallback to filename
|
||||||
|
// Example: /path/to/Game Disk.d64 or /path/to/disk1.d64
|
||||||
|
const fileName = file.split('/').pop() || file;
|
||||||
|
// If you have a title mapping, replace this logic
|
||||||
|
const title = fileName.replace(/\.[^.]+$/, '');
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={index}
|
||||||
|
onClick={() => switchMedia(index)}
|
||||||
|
className={`px-3 py-1.5 rounded-lg text-sm ${
|
||||||
|
deviceData.url === file
|
||||||
|
? 'bg-blue-600 text-white'
|
||||||
|
: 'bg-neutral-100 text-neutral-700 hover:bg-neutral-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{`${index + 1}: ${title}`}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -398,7 +403,10 @@ export default function DeviceDetailOverlay({
|
||||||
{showFileBrowser && (
|
{showFileBrowser && (
|
||||||
<FileBrowser
|
<FileBrowser
|
||||||
currentPath={deviceData.url || '/'}
|
currentPath={deviceData.url || '/'}
|
||||||
onSelect={(path) => { void handleFileSelect(path); setShowFileBrowser(false); }}
|
onSelect={(path) => {
|
||||||
|
const devicePath = getDevicePath();
|
||||||
|
updateDeviceSetting([...devicePath, 'url'], path);
|
||||||
|
}}
|
||||||
onClose={() => setShowFileBrowser(false)}
|
onClose={() => setShowFileBrowser(false)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,6 @@ import { useEffect, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Folder,
|
Folder,
|
||||||
File,
|
File,
|
||||||
FileText,
|
|
||||||
HardDrive,
|
|
||||||
Image as ImageIcon,
|
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Home,
|
Home,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
|
|
@ -37,19 +34,6 @@ import {
|
||||||
DialogDescription,
|
DialogDescription,
|
||||||
} from './ui/dialog';
|
} from './ui/dialog';
|
||||||
|
|
||||||
const TEXT_EXTS = new Set(['txt','cfg','ini','bas','asm','seq','rel','prg','log','csv','s','lst','md','markdown','json','xml','svg','html','htm']);
|
|
||||||
const IMAGE_EXTS = new Set(['png','jpg','jpeg','gif','bmp','webp']);
|
|
||||||
const DISK_EXTS = new Set(['d64','d71','d81','d82','g64','g71','t64','tap','crt','nib']);
|
|
||||||
|
|
||||||
function EntryIcon({ entry }: { entry: EntryInfo }) {
|
|
||||||
if (entry.type === 'folder') return <Folder className="w-5 h-5 text-blue-500 flex-shrink-0" />;
|
|
||||||
const ext = entry.name.split('.').pop()?.toLowerCase() ?? '';
|
|
||||||
if (IMAGE_EXTS.has(ext)) return <ImageIcon className="w-5 h-5 text-purple-500 flex-shrink-0" />;
|
|
||||||
if (DISK_EXTS.has(ext)) return <HardDrive className="w-5 h-5 text-amber-500 flex-shrink-0" />;
|
|
||||||
if (TEXT_EXTS.has(ext)) return <FileText className="w-5 h-5 text-green-600 flex-shrink-0" />;
|
|
||||||
return <File className="w-5 h-5 text-neutral-400 flex-shrink-0" />;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface FileBrowserProps {
|
interface FileBrowserProps {
|
||||||
currentPath: string;
|
currentPath: string;
|
||||||
onSelect: (path: string) => void;
|
onSelect: (path: string) => void;
|
||||||
|
|
@ -351,7 +335,13 @@ export default function FileBrowser({ currentPath, onSelect, onClose }: FileBrow
|
||||||
}}
|
}}
|
||||||
className="flex-1 flex items-center gap-3 text-left min-w-0"
|
className="flex-1 flex items-center gap-3 text-left min-w-0"
|
||||||
>
|
>
|
||||||
<EntryIcon entry={entry} />
|
<div className={entry.type === 'folder' ? 'text-blue-600' : 'text-neutral-400'}>
|
||||||
|
{entry.type === 'folder' ? (
|
||||||
|
<Folder className="w-5 h-5" />
|
||||||
|
) : (
|
||||||
|
<File className="w-5 h-5" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="text-neutral-900 truncate">{entry.name}</div>
|
<div className="text-neutral-900 truncate">{entry.name}</div>
|
||||||
{entry.type === 'file' && (
|
{entry.type === 'file' && (
|
||||||
|
|
|
||||||
|
|
@ -197,16 +197,14 @@ 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">
|
||||||
{onSave && (
|
<button
|
||||||
<button
|
onClick={() => setEditMode(v => !v)}
|
||||||
onClick={() => setEditMode(v => !v)}
|
className={editMode
|
||||||
className={editMode
|
? 'px-2 py-1 rounded bg-amber-600 text-white 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'}
|
||||||
: '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</>}
|
||||||
{editMode ? <><Eye className="w-3.5 h-3.5" /> View</> : <><Pencil className="w-3.5 h-3.5" /> Edit</>}
|
</button>
|
||||||
</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">
|
||||||
|
|
@ -253,52 +251,6 @@ 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';
|
||||||
|
|
@ -309,9 +261,9 @@ export default function FileManager({ initialPath = '/', config, setConfig, onBa
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||||
const [filter, setFilter] = useState(() => localStorage.getItem('fileManager.filter') ?? '');
|
const [filter, setFilter] = useState('');
|
||||||
const [sortKey, setSortKey] = useState<SortKey>(() => (localStorage.getItem('fileManager.sortKey') as SortKey) ?? 'name');
|
const [sortKey, setSortKey] = useState<SortKey>('name');
|
||||||
const [sortAsc, setSortAsc] = useState(() => localStorage.getItem('fileManager.sortAsc') !== 'false');
|
const [sortAsc, setSortAsc] = useState(true);
|
||||||
const [clipboard, setClipboard] = useState<Clipboard | null>(null);
|
const [clipboard, setClipboard] = useState<Clipboard | null>(null);
|
||||||
const [actionEntry, setActionEntry] = useState<EntryInfo | null>(null);
|
const [actionEntry, setActionEntry] = useState<EntryInfo | null>(null);
|
||||||
const [dragOver, setDragOver] = useState(false);
|
const [dragOver, setDragOver] = useState(false);
|
||||||
|
|
@ -319,6 +271,7 @@ 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);
|
||||||
|
|
@ -352,9 +305,6 @@ export default function FileManager({ initialPath = '/', config, setConfig, onBa
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => { void load(path); }, [path, load]);
|
useEffect(() => { void load(path); }, [path, load]);
|
||||||
useEffect(() => { localStorage.setItem('fileManager.filter', filter); }, [filter]);
|
|
||||||
useEffect(() => { localStorage.setItem('fileManager.sortKey', sortKey); }, [sortKey]);
|
|
||||||
useEffect(() => { localStorage.setItem('fileManager.sortAsc', String(sortAsc)); }, [sortAsc]);
|
|
||||||
|
|
||||||
const navigateTo = (p: string) => {
|
const navigateTo = (p: string) => {
|
||||||
const norm = normalizePath(p);
|
const norm = normalizePath(p);
|
||||||
|
|
@ -394,26 +344,30 @@ 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());
|
||||||
|
};
|
||||||
|
|
||||||
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(prev => { if (prev) URL.revokeObjectURL(prev); return null; });
|
setViewImgUrl(null);
|
||||||
setViewHexData(null);
|
setViewHexData(null);
|
||||||
setViewLoading(true);
|
setViewLoading(true);
|
||||||
try {
|
try {
|
||||||
const bytes = await _getEntryBytes(entry);
|
const blob = await getFileContents(entry.path);
|
||||||
if (targetMode === 'image') {
|
setViewBlob(blob);
|
||||||
const ab = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer;
|
await processBlob(blob, targetMode);
|
||||||
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);
|
||||||
|
|
@ -424,42 +378,27 @@ export default function FileManager({ initialPath = '/', config, setConfig, onBa
|
||||||
};
|
};
|
||||||
|
|
||||||
const switchViewMode = async (mode: ViewMode) => {
|
const switchViewMode = async (mode: ViewMode) => {
|
||||||
if (!viewEntry) return;
|
if (!viewBlob) return;
|
||||||
setViewMode(mode);
|
setViewMode(mode);
|
||||||
setViewLoading(true);
|
setViewLoading(true);
|
||||||
try {
|
try { await processBlob(viewBlob, mode); } finally { setViewLoading(false); }
|
||||||
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 = () => {
|
const closeViewer = () => {
|
||||||
setViewImgUrl(prev => { if (prev) URL.revokeObjectURL(prev); return null; });
|
setViewImgUrl(prev => { if (prev) URL.revokeObjectURL(prev); return null; });
|
||||||
setViewEntry(null); setViewMode(null);
|
setViewEntry(null); setViewMode(null); setViewBlob(null);
|
||||||
setViewText(null); setViewHexData(null);
|
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');
|
||||||
const bytes: Uint8Array = typeof content === 'string'
|
await putFileContents(viewEntry.path, content);
|
||||||
? new TextEncoder().encode(content)
|
const newBlob = typeof content === 'string'
|
||||||
: content;
|
? new Blob([content], { type: 'text/plain' })
|
||||||
await putFileContents(viewEntry.path, bytes);
|
: 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);
|
if (typeof content === 'string') setViewText(content);
|
||||||
else setViewHexData(bytes);
|
else setViewHexData(new Uint8Array(content));
|
||||||
// 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);
|
||||||
};
|
};
|
||||||
|
|
@ -528,7 +467,7 @@ export default function FileManager({ initialPath = '/', config, setConfig, onBa
|
||||||
|
|
||||||
// ── Mount ────────────────────────────────────────────────────────────────
|
// ── Mount ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const mountOnDevice = async (deviceType: 'drive' | 'meatloaf', key: string) => {
|
const mountOnDevice = (deviceType: 'drive' | 'meatloaf', key: string) => {
|
||||||
if (!mountEntry || !setConfig || !config) return;
|
if (!mountEntry || !setConfig || !config) return;
|
||||||
const newConfig = JSON.parse(JSON.stringify(config));
|
const newConfig = JSON.parse(JSON.stringify(config));
|
||||||
if (!newConfig.iec) newConfig.iec = {};
|
if (!newConfig.iec) newConfig.iec = {};
|
||||||
|
|
@ -536,43 +475,16 @@ export default function FileManager({ initialPath = '/', config, setConfig, onBa
|
||||||
if (!newConfig.iec.devices[deviceType]) newConfig.iec.devices[deviceType] = {};
|
if (!newConfig.iec.devices[deviceType]) newConfig.iec.devices[deviceType] = {};
|
||||||
if (!newConfig.iec.devices[deviceType][key]) newConfig.iec.devices[deviceType][key] = {};
|
if (!newConfig.iec.devices[deviceType][key]) newConfig.iec.devices[deviceType][key] = {};
|
||||||
const dev = newConfig.iec.devices[deviceType][key];
|
const dev = newConfig.iec.devices[deviceType][key];
|
||||||
|
dev.url = mountEntry.path;
|
||||||
if (mountEntry.name.toLowerCase().endsWith('.lst')) {
|
|
||||||
try {
|
|
||||||
const text = await (await getFileContents(mountEntry.path)).text();
|
|
||||||
const dir = splitPath(mountEntry.path).parent;
|
|
||||||
const files = text.split('\n')
|
|
||||||
.map(l => l.trim())
|
|
||||||
.filter(l => l.length > 0 && !l.startsWith('#'))
|
|
||||||
.map(l => l.startsWith('/') ? l : joinPath(dir, l));
|
|
||||||
if (files.length === 0) {
|
|
||||||
toast.error(`${mountEntry.name}: swap list is empty`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
dev.url = files[0];
|
|
||||||
dev.mediaSet = files;
|
|
||||||
} catch (e: any) {
|
|
||||||
toast.error(`Failed to read ${mountEntry.name}: ${e?.message ?? e}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
dev.url = mountEntry.path;
|
|
||||||
delete dev.mediaSet;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!dev.enabled) dev.enabled = 1;
|
if (!dev.enabled) dev.enabled = 1;
|
||||||
setConfig(newConfig);
|
setConfig(newConfig);
|
||||||
setMountEntry(null);
|
|
||||||
const deviceId = `${deviceType}-${key}`;
|
const deviceId = `${deviceType}-${key}`;
|
||||||
const isLst = mountEntry.name.toLowerCase().endsWith('.lst');
|
toast.success(`Mounted "${mountEntry.name}" on ${deviceType} #${key}`, {
|
||||||
const label = isLst
|
|
||||||
? `Loaded swap list "${mountEntry.name}" (${(dev.mediaSet as string[]).length} disks) on ${deviceType} #${key}`
|
|
||||||
: `Mounted "${mountEntry.name}" on ${deviceType} #${key}`;
|
|
||||||
toast.success(label, {
|
|
||||||
action: onNavigateToDevice
|
action: onNavigateToDevice
|
||||||
? { label: 'View Device', onClick: () => onNavigateToDevice(deviceId) }
|
? { label: 'View Device', onClick: () => onNavigateToDevice(deviceId) }
|
||||||
: undefined,
|
: undefined,
|
||||||
});
|
});
|
||||||
|
setMountEntry(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Clipboard ────────────────────────────────────────────────────────────
|
// ── Clipboard ────────────────────────────────────────────────────────────
|
||||||
|
|
@ -848,11 +760,7 @@ export default function FileManager({ initialPath = '/', config, setConfig, onBa
|
||||||
className="w-4 h-4 flex-shrink-0"
|
className="w-4 h-4 flex-shrink-0"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => void openEntry(entry)}
|
||||||
if (renameEntry !== null) return;
|
|
||||||
if (entry.type === 'folder') navigateTo(joinPath(path, entry.name));
|
|
||||||
else setMountEntry(entry);
|
|
||||||
}}
|
|
||||||
className="flex-1 flex items-center gap-3 text-left min-w-0"
|
className="flex-1 flex items-center gap-3 text-left min-w-0"
|
||||||
>
|
>
|
||||||
<EntryIcon entry={entry} />
|
<EntryIcon entry={entry} />
|
||||||
|
|
@ -927,39 +835,19 @@ export default function FileManager({ initialPath = '/', config, setConfig, onBa
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
{/* Folder: open */}
|
{/* Default open */}
|
||||||
{actionEntry?.type === 'folder' && (
|
<button
|
||||||
<button
|
onClick={() => { const e = actionEntry; setActionEntry(null); if (e) void openEntry(e); }}
|
||||||
onClick={() => { const e = actionEntry; setActionEntry(null); if (e) void openEntry(e); }}
|
className="w-full text-left px-4 py-3 rounded border border-neutral-200 hover:bg-blue-50 hover:border-blue-300 inline-flex items-center gap-3"
|
||||||
className="w-full text-left px-4 py-3 rounded border border-neutral-200 hover:bg-blue-50 hover:border-blue-300 inline-flex items-center gap-3"
|
>
|
||||||
>
|
{actionEntry?.type === 'folder'
|
||||||
<Folder className="w-4 h-4 text-blue-600" />
|
? <Folder className="w-4 h-4 text-blue-600" />
|
||||||
<span>Open folder</span>
|
: <Eye className="w-4 h-4 text-blue-600" />}
|
||||||
</button>
|
<span className="flex-1">{actionEntry?.type === 'folder' ? 'Open folder' : 'Open / View'}</span>
|
||||||
)}
|
{actionEntry?.type === 'file' && (
|
||||||
|
|
||||||
{/* File: mount (primary default) */}
|
|
||||||
{actionEntry?.type === 'file' && (
|
|
||||||
<button
|
|
||||||
onClick={() => { const e = actionEntry; setActionEntry(null); if (e) setMountEntry(e); }}
|
|
||||||
className="w-full text-left px-4 py-3 rounded border border-neutral-200 hover:bg-blue-50 hover:border-blue-300 inline-flex items-center gap-3"
|
|
||||||
>
|
|
||||||
<HardDrive className="w-4 h-4 text-amber-600" />
|
|
||||||
<span>Mount on virtual drive</span>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* File: open/view */}
|
|
||||||
{actionEntry?.type === 'file' && (
|
|
||||||
<button
|
|
||||||
onClick={() => { const e = actionEntry; setActionEntry(null); if (e) void openEntry(e); }}
|
|
||||||
className="w-full text-left px-4 py-3 rounded border border-neutral-200 hover:bg-neutral-50 inline-flex items-center gap-3"
|
|
||||||
>
|
|
||||||
<Eye className="w-4 h-4 text-blue-600" />
|
|
||||||
<span className="flex-1">Open / View</span>
|
|
||||||
<span className="text-xs text-neutral-400">{VIEWER_LABEL[defaultViewMode(actionEntry)]}</span>
|
<span className="text-xs text-neutral-400">{VIEWER_LABEL[defaultViewMode(actionEntry)]}</span>
|
||||||
</button>
|
)}
|
||||||
)}
|
</button>
|
||||||
|
|
||||||
{/* Alternate viewers */}
|
{/* Alternate viewers */}
|
||||||
{actionEntry?.type === 'file' && availableViewers(actionEntry)
|
{actionEntry?.type === 'file' && availableViewers(actionEntry)
|
||||||
|
|
@ -987,6 +875,15 @@ export default function FileManager({ initialPath = '/', config, setConfig, onBa
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{actionEntry?.type === 'file' && (
|
||||||
|
<button
|
||||||
|
onClick={() => { const e = actionEntry; setActionEntry(null); if (e) setMountEntry(e); }}
|
||||||
|
className="w-full text-left px-4 py-3 rounded border border-neutral-200 hover:bg-neutral-50 inline-flex items-center gap-3"
|
||||||
|
>
|
||||||
|
<HardDrive className="w-4 h-4 text-amber-600" /> <span>Mount on virtual drive</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="border-t border-neutral-100" />
|
<div className="border-t border-neutral-100" />
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
|
@ -1020,12 +917,12 @@ export default function FileManager({ initialPath = '/', config, setConfig, onBa
|
||||||
{/* ── File viewer overlay ── */}
|
{/* ── File viewer overlay ── */}
|
||||||
{viewEntry && (
|
{viewEntry && (
|
||||||
<div className="fixed inset-0 bg-neutral-950 z-50 flex flex-col">
|
<div className="fixed inset-0 bg-neutral-950 z-50 flex flex-col">
|
||||||
{/* Title + mode switcher */}
|
|
||||||
<div className="bg-neutral-900 flex items-center px-4 py-2 gap-3 border-b border-neutral-700 flex-shrink-0">
|
<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">
|
<button onClick={closeViewer} className="p-1.5 rounded hover:bg-neutral-700">
|
||||||
<X className="w-5 h-5 text-white" />
|
<X className="w-5 h-5 text-white" />
|
||||||
</button>
|
</button>
|
||||||
<span className="font-medium truncate flex-1 text-sm text-white">{viewEntry.name}</span>
|
<span className="font-medium truncate flex-1 text-sm text-white">{viewEntry.name}</span>
|
||||||
|
{/* Mode switcher */}
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
{viewMode && availableViewers(viewEntry).map(mode => (
|
{viewMode && availableViewers(viewEntry).map(mode => (
|
||||||
<button
|
<button
|
||||||
|
|
@ -1060,13 +957,13 @@ export default function FileManager({ initialPath = '/', config, setConfig, onBa
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!viewLoading && viewMode === 'hex' && viewHexData && (
|
{!viewLoading && viewMode === 'hex' && viewHexData && (
|
||||||
<HexEditor key={viewEntry.path} data={viewHexData} onSave={d => saveViewFile(d)} />
|
<HexEditor data={viewHexData} onSave={d => saveViewFile(d)} />
|
||||||
)}
|
)}
|
||||||
{!viewLoading && viewMode === 'markdown' && viewText !== null && (
|
{!viewLoading && viewMode === 'markdown' && viewText !== null && (
|
||||||
<MarkdownEditor key={viewEntry.path} text={viewText} onSave={s => saveViewFile(s)} />
|
<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 && (
|
||||||
<CodeEditor key={viewEntry.path} text={viewText} mode={viewMode} onSave={s => saveViewFile(s)} />
|
<CodeEditor text={viewText} mode={viewMode} onSave={s => saveViewFile(s)} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1093,7 +990,7 @@ export default function FileManager({ initialPath = '/', config, setConfig, onBa
|
||||||
{devices.map(dev => (
|
{devices.map(dev => (
|
||||||
<button
|
<button
|
||||||
key={`${dev.type}-${dev.key}`}
|
key={`${dev.type}-${dev.key}`}
|
||||||
onClick={() => void mountOnDevice(dev.type, dev.key)}
|
onClick={() => mountOnDevice(dev.type, dev.key)}
|
||||||
className="w-full text-left px-4 py-3 rounded border border-neutral-200 hover:bg-blue-50 hover:border-blue-300 inline-flex items-center gap-3"
|
className="w-full text-left px-4 py-3 rounded border border-neutral-200 hover:bg-blue-50 hover:border-blue-300 inline-flex items-center gap-3"
|
||||||
>
|
>
|
||||||
<HardDrive className={`w-5 h-5 flex-shrink-0 ${dev.enabled ? 'text-blue-500' : 'text-neutral-400'}`} />
|
<HardDrive className={`w-5 h-5 flex-shrink-0 ${dev.enabled ? 'text-blue-500' : 'text-neutral-400'}`} />
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,7 @@ 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 ROW_HEIGHT = 20; // px — matches leading-5 at Tailwind's 16px base
|
const MAX_DISPLAY = 65536;
|
||||||
const CHUNK_ROWS = 256; // virtual-scroll overscan: render prev+curr+next windows
|
|
||||||
|
|
||||||
// ── History ───────────────────────────────────────────────────────────────────
|
// ── History ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -75,48 +74,30 @@ 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);
|
||||||
|
|
||||||
// Virtual scroll
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const searchRef = useRef<HTMLInputElement>(null);
|
||||||
const searchRef = useRef<HTMLInputElement>(null);
|
|
||||||
const [scrollTop, setScrollTop] = useState(0);
|
|
||||||
|
|
||||||
const totalRows = Math.ceil(current.length / BYTES_PER_ROW);
|
const needle = query.trim() ? (searchType === 'text' ? new TextEncoder().encode(query) : parseHex(query)) : null;
|
||||||
const firstRenderRow = Math.max(0, Math.floor(scrollTop / ROW_HEIGHT) - CHUNK_ROWS);
|
const matches = needle ? matchAll(current, needle) : [];
|
||||||
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 (programmatic, works with virtual scroll)
|
// Scroll cursor row into view
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (cursor < 0) return;
|
if (cursor < 0) return;
|
||||||
const row = Math.floor(cursor / BYTES_PER_ROW);
|
containerRef.current
|
||||||
const rowTop = row * ROW_HEIGHT + 12; // 12 = top padding equivalent
|
?.querySelector(`[data-byte="${cursor}"]`)
|
||||||
const el = scrollRef.current;
|
?.scrollIntoView({ block: 'nearest' });
|
||||||
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) => {
|
||||||
|
|
@ -141,7 +122,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]);
|
||||||
scrollRef.current?.focus();
|
containerRef.current?.focus();
|
||||||
};
|
};
|
||||||
|
|
||||||
const openSearch = () => {
|
const openSearch = () => {
|
||||||
|
|
@ -151,7 +132,7 @@ export default function HexEditor({ data, readOnly = false, onSave }: HexEditorP
|
||||||
const closeSearch = () => {
|
const closeSearch = () => {
|
||||||
setSearchOpen(false);
|
setSearchOpen(false);
|
||||||
setQuery('');
|
setQuery('');
|
||||||
scrollRef.current?.focus();
|
containerRef.current?.focus();
|
||||||
};
|
};
|
||||||
|
|
||||||
const onKeyDown = (e: React.KeyboardEvent) => {
|
const onKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
|
@ -197,6 +178,9 @@ 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); }
|
||||||
|
|
@ -211,7 +195,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); scrollRef.current?.focus(); }}
|
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'}`}
|
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</>}
|
||||||
|
|
@ -236,6 +220,9 @@ 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}
|
||||||
|
|
@ -285,27 +272,21 @@ export default function HexEditor({ data, readOnly = false, onSave }: HexEditorP
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── Hex grid (virtual scroll) ── */}
|
{/* ── Hex grid ── */}
|
||||||
<div
|
<div
|
||||||
ref={scrollRef}
|
ref={containerRef}
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
onKeyDown={onKeyDown}
|
onKeyDown={onKeyDown}
|
||||||
onScroll={e => setScrollTop(e.currentTarget.scrollTop)}
|
className="flex-1 overflow-auto p-3 focus:outline-none select-none"
|
||||||
className="flex-1 overflow-auto focus:outline-none select-none"
|
onClick={() => containerRef.current?.focus()}
|
||||||
onClick={() => scrollRef.current?.focus()}
|
|
||||||
>
|
>
|
||||||
{/* top spacer — 12px mimics former p-3 top padding */}
|
{cursor < 0 && (
|
||||||
<div style={{ height: paddingTop + 12 }} />
|
<div className="text-neutral-600 text-xs mb-2 font-mono">
|
||||||
|
|
||||||
{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">
|
||||||
<div className="px-3 font-mono text-xs leading-5 whitespace-nowrap">
|
{Array.from({ length: numRows }, (_, row) => {
|
||||||
{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">
|
||||||
|
|
@ -319,24 +300,27 @@ 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 >= current.length) {
|
if (idx >= view.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 = current[idx];
|
const byte = view[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'); scrollRef.current?.focus(); }}
|
onClick={() => { setCursor(idx); setNibble(0); setPane('hex'); containerRef.current?.focus(); }}
|
||||||
>
|
>
|
||||||
{byte.toString(16).padStart(2, '0').toUpperCase()}
|
{byte.toString(16).padStart(2, '0').toUpperCase()}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -351,23 +335,25 @@ 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 >= current.length) return <span key={col} className="inline-block w-[9px]" />;
|
if (idx >= view.length) return <span key={col} className="inline-block w-[9px]" />;
|
||||||
const byte = current[idx];
|
const byte = view[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'); scrollRef.current?.focus(); }}
|
onClick={() => { setCursor(idx); setPane('ascii'); containerRef.current?.focus(); }}
|
||||||
>
|
>
|
||||||
{char}
|
{char}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -379,9 +365,6 @@ export default function HexEditor({ data, readOnly = false, onSave }: HexEditorP
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* bottom spacer */}
|
|
||||||
<div style={{ height: paddingBottom + 12 }} />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,33 +0,0 @@
|
||||||
interface MediaSetProps {
|
|
||||||
files: string[];
|
|
||||||
activeUrl: string;
|
|
||||||
onSwitch: (file: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function MediaSet({ files, activeUrl, onSwitch }: MediaSetProps) {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div className="text-sm text-neutral-500 mb-2">Media Set</div>
|
|
||||||
<div className="flex gap-2 flex-wrap">
|
|
||||||
{files.map((file, index) => {
|
|
||||||
const fileName = file.split('/').pop() || file;
|
|
||||||
const title = fileName.replace(/\.[^.]+$/, '');
|
|
||||||
const active = activeUrl === file;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={file}
|
|
||||||
onClick={() => onSwitch(file)}
|
|
||||||
className={`px-3 py-1.5 rounded-lg text-sm ${
|
|
||||||
active
|
|
||||||
? 'bg-blue-600 text-white'
|
|
||||||
: 'bg-neutral-100 text-neutral-700 hover:bg-neutral-200'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{`${index + 1}: ${title}`}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { HardDrive, Activity, Wifi, Signal, Clock, RefreshCw, FolderOpen, Map, Loader2 } from 'lucide-react';
|
import { HardDrive, Activity, Wifi, Signal, Clock, RefreshCw, FolderOpen, Map, Loader2 } from 'lucide-react';
|
||||||
import DeviceDetailOverlay from './DeviceDetailOverlay';
|
import DeviceDetailOverlay from './DeviceDetailOverlay';
|
||||||
import MediaSet from './MediaSet';
|
|
||||||
import { ImageWithFallback } from './figma/ImageWithFallback';
|
import { ImageWithFallback } from './figma/ImageWithFallback';
|
||||||
import { Dialog, DialogContent, DialogTitle, DialogDescription } from './ui/dialog';
|
import { Dialog, DialogContent, DialogTitle, DialogDescription } from './ui/dialog';
|
||||||
import DirectoryListing from './DirectoryListing';
|
import DirectoryListing from './DirectoryListing';
|
||||||
|
|
@ -35,24 +34,6 @@ export default function StatusPage({ config, setConfig }: StatusPageProps) {
|
||||||
|
|
||||||
const activeDevice = findActiveDevice();
|
const activeDevice = findActiveDevice();
|
||||||
|
|
||||||
const mediaSetFiles: string[] | null = (() => {
|
|
||||||
if (!activeDevice?.url) return null;
|
|
||||||
if (Array.isArray(activeDevice.mediaSet) && activeDevice.mediaSet.length > 0)
|
|
||||||
return activeDevice.mediaSet as string[];
|
|
||||||
const match = (activeDevice.url as string).match(/^(.+?)(\d+)(\.[^.]+)$/);
|
|
||||||
if (!match) return null;
|
|
||||||
const [, prefix, , ext] = match;
|
|
||||||
return Array.from({ length: 10 }, (_, i) => `${prefix}${i + 1}${ext}`);
|
|
||||||
})();
|
|
||||||
|
|
||||||
const switchActiveMedia = (file: string) => {
|
|
||||||
const newConfig = JSON.parse(JSON.stringify(config));
|
|
||||||
if (newConfig.iec?.devices?.drive?.[activeDevice!.number]) {
|
|
||||||
newConfig.iec.devices.drive[activeDevice!.number].url = file;
|
|
||||||
setConfig(newConfig);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Mock activity log - in a real app this would come from device monitoring
|
// Mock activity log - in a real app this would come from device monitoring
|
||||||
const activityLog = [
|
const activityLog = [
|
||||||
{ time: '14:32:15', event: 'File opened: game.d64', type: 'info' },
|
{ time: '14:32:15', event: 'File opened: game.d64', type: 'info' },
|
||||||
|
|
@ -152,12 +133,6 @@ export default function StatusPage({ config, setConfig }: StatusPageProps) {
|
||||||
<div className="w-2 h-2 rounded-full bg-green-500" />
|
<div className="w-2 h-2 rounded-full bg-green-500" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{mediaSetFiles && (
|
|
||||||
<div className="mt-2">
|
|
||||||
<MediaSet files={mediaSetFiles} activeUrl={activeDevice.url ?? ''} onSwitch={switchActiveMedia} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Directory and Disk Map buttons at bottom */}
|
{/* Directory and Disk Map buttons at bottom */}
|
||||||
|
|
||||||
{/* New device info cards */}
|
{/* New device info cards */}
|
||||||
|
|
@ -195,6 +170,47 @@ export default function StatusPage({ config, setConfig }: StatusPageProps) {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Media switch buttons if media set is detected */}
|
||||||
|
{(() => {
|
||||||
|
// Media set detection logic (copied from DeviceDetailOverlay)
|
||||||
|
const url = activeDevice.url;
|
||||||
|
if (!url) return null;
|
||||||
|
const match = url.match(/^(.+?)(\d+)(\.[^.]+)$/);
|
||||||
|
if (!match) return null;
|
||||||
|
const [, prefix, num, ext] = match;
|
||||||
|
const currentNum = parseInt(num);
|
||||||
|
const mediaSet = [];
|
||||||
|
for (let i = 1; i <= 10; i++) {
|
||||||
|
mediaSet.push(`${prefix}${i}${ext}`);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap gap-2 mt-2">
|
||||||
|
{mediaSet.map((file, idx) => {
|
||||||
|
const fileName = file.split('/').pop() || file;
|
||||||
|
const title = fileName.replace(/\.[^.]+$/, '');
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={file}
|
||||||
|
className={`px-2 py-1 rounded text-xs border ${url === file ? 'bg-blue-600 text-white border-blue-600' : 'bg-neutral-100 text-neutral-700 border-neutral-300'}`}
|
||||||
|
onClick={e => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (setConfig) {
|
||||||
|
const newConfig = JSON.parse(JSON.stringify(config));
|
||||||
|
let current = newConfig;
|
||||||
|
if (current.iec && current.iec.devices && current.iec.devices.drive && current.iec.devices.drive[num]) {
|
||||||
|
current.iec.devices.drive[num].url = file;
|
||||||
|
setConfig(newConfig);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{`${idx + 1}: ${title}`}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
<div className="flex flex-col gap-2 mt-6">
|
<div className="flex flex-col gap-2 mt-6">
|
||||||
<button
|
<button
|
||||||
className="flex items-center justify-center gap-2 px-3 py-3 rounded bg-neutral-200 text-neutral-700 hover:bg-blue-600 hover:text-white transition text-base font-medium w-full"
|
className="flex items-center justify-center gap-2 px-3 py-3 rounded bg-neutral-200 text-neutral-700 hover:bg-blue-600 hover:text-white transition text-base font-medium w-full"
|
||||||
|
|
@ -233,9 +249,9 @@ export default function StatusPage({ config, setConfig }: StatusPageProps) {
|
||||||
|
|
||||||
{/* Directory Overlay */}
|
{/* Directory Overlay */}
|
||||||
{showDirectory && (
|
{showDirectory && (
|
||||||
<>
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||||
<div className="fixed inset-0 z-40 bg-black/40 backdrop-blur-md" onClick={() => setShowDirectory(false)} />
|
<div className="absolute inset-0 bg-black/40 backdrop-blur-md" onClick={() => setShowDirectory(false)} />
|
||||||
<div className="fixed inset-0 z-50 bg-white shadow-2xl flex flex-col" onClick={(e) => e.stopPropagation()}>
|
<div className="relative w-full h-full bg-white/90 shadow-2xl overflow-auto flex flex-col" onClick={(e) => e.stopPropagation()}>
|
||||||
<div className="flex items-center justify-between p-4 border-b">
|
<div className="flex items-center justify-between p-4 border-b">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<h2 className="text-xl font-medium">Directory</h2>
|
<h2 className="text-xl font-medium">Directory</h2>
|
||||||
|
|
@ -245,7 +261,7 @@ export default function StatusPage({ config, setConfig }: StatusPageProps) {
|
||||||
</div>
|
</div>
|
||||||
<button onClick={() => setShowDirectory(false)} className="p-2 -m-2 hover:bg-neutral-100 rounded-lg"><svg xmlns='http://www.w3.org/2000/svg' className='w-6 h-6' fill='none' viewBox='0 0 24 24' stroke='currentColor'><path strokeLinecap='round' strokeLinejoin='round' strokeWidth={2} d='M6 18L18 6M6 6l12 12' /></svg></button>
|
<button onClick={() => setShowDirectory(false)} className="p-2 -m-2 hover:bg-neutral-100 rounded-lg"><svg xmlns='http://www.w3.org/2000/svg' className='w-6 h-6' fill='none' viewBox='0 0 24 24' stroke='currentColor'><path strokeLinecap='round' strokeLinejoin='round' strokeWidth={2} d='M6 18L18 6M6 6l12 12' /></svg></button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-h-0 flex flex-col">
|
<div className="flex-1 overflow-auto flex flex-col">
|
||||||
{!activeDevice?.url && (
|
{!activeDevice?.url && (
|
||||||
<div className="p-8 text-center text-neutral-500 text-sm">
|
<div className="p-8 text-center text-neutral-500 text-sm">
|
||||||
No file mounted on this device.
|
No file mounted on this device.
|
||||||
|
|
@ -277,24 +293,24 @@ export default function StatusPage({ config, setConfig }: StatusPageProps) {
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Disk Map Overlay */}
|
{/* Disk Map Overlay */}
|
||||||
{showDiskMap && (
|
{showDiskMap && (
|
||||||
<>
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||||
<div className="fixed inset-0 z-40 bg-black/40 backdrop-blur-md" onClick={() => setShowDiskMap(false)} />
|
<div className="absolute inset-0 bg-black/40 backdrop-blur-md" onClick={() => setShowDiskMap(false)} />
|
||||||
<div className="fixed inset-0 z-50 bg-white shadow-2xl flex flex-col" onClick={(e) => e.stopPropagation()}>
|
<div className="relative w-full h-full max-w-2xl sm:rounded-xl bg-white/90 shadow-2xl overflow-auto flex flex-col mx-0 sm:mx-auto my-0 sm:my-20 p-0 sm:p-0" style={{ maxHeight: '100dvh' }}>
|
||||||
<div className="flex items-center justify-between p-4 border-b">
|
<div className="flex items-center justify-between p-4 border-b">
|
||||||
<h2 className="text-xl font-medium">Disk Map</h2>
|
<h2 className="text-xl font-medium">Disk Map</h2>
|
||||||
<button onClick={() => setShowDiskMap(false)} className="p-2 -m-2 hover:bg-neutral-100 rounded-lg"><svg xmlns='http://www.w3.org/2000/svg' className='w-6 h-6' fill='none' viewBox='0 0 24 24' stroke='currentColor'><path strokeLinecap='round' strokeLinejoin='round' strokeWidth={2} d='M6 18L18 6M6 6l12 12' /></svg></button>
|
<button onClick={() => setShowDiskMap(false)} className="p-2 -m-2 hover:bg-neutral-100 rounded-lg"><svg xmlns='http://www.w3.org/2000/svg' className='w-6 h-6' fill='none' viewBox='0 0 24 24' stroke='currentColor'><path strokeLinecap='round' strokeLinejoin='round' strokeWidth={2} d='M6 18L18 6M6 6l12 12' /></svg></button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-h-0 overflow-auto flex items-center justify-center text-neutral-500 p-4">
|
<div className="flex-1 overflow-auto flex items-center justify-center text-neutral-500 p-4">
|
||||||
<span>Disk map visualization goes here.</span>
|
<span>Disk map visualization goes here.</span>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
{/* Reset Activity Modal */}
|
{/* Reset Activity Modal */}
|
||||||
<Dialog open={!!showResetModal} onOpenChange={open => !open && setShowResetModal(null)}>
|
<Dialog open={!!showResetModal} onOpenChange={open => !open && setShowResetModal(null)}>
|
||||||
|
|
|
||||||
|
|
@ -317,19 +317,6 @@ 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