mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-18 06:23:18 +00:00
9b481212f6
Stop remounting the file tree on every refresh so expanded folders and scroll position survive delete, move, rename, and save. Relist open directories in place and drop stale collapsed caches.
173 lines
6.5 KiB
TypeScript
173 lines
6.5 KiB
TypeScript
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
import { RefreshCw } from 'lucide-react';
|
|
import { SystemSheet } from '@/components/ui/system-sheet';
|
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
|
import { Skeleton } from '@/components/ui/skeleton';
|
|
import { toast } from '@/components/ui/toast-store';
|
|
import { FileTree } from '@/components/files/FileTree';
|
|
import type { FileEntry } from '@/lib/stackFilesApi';
|
|
import { listVolumeDirectory, readVolumeFile } from '@/lib/volumeApi';
|
|
import type { VolumeFileResult } from '@/lib/volumeApi';
|
|
import { formatBytes } from '@/lib/utils';
|
|
import { isAnonymousVolumeName, shortVolumeLabel } from '@/lib/volumeName';
|
|
import { VolumeNameLabel } from './VolumeNameLabel';
|
|
import { AnonymousNameBand } from './AnonymousNameBand';
|
|
|
|
interface VolumeBrowserSheetProps {
|
|
volumeName: string | null;
|
|
onClose: () => void;
|
|
}
|
|
|
|
export function VolumeBrowserSheet({ volumeName, onClose }: VolumeBrowserSheetProps) {
|
|
const [refreshKey, setRefreshKey] = useState(0);
|
|
const [selectedPath, setSelectedPath] = useState<string>('');
|
|
const [fileLoading, setFileLoading] = useState(false);
|
|
const [fileResult, setFileResult] = useState<VolumeFileResult | null>(null);
|
|
// Generation counter so a slow read for a stale (volume, path) selection
|
|
// cannot stomp the visible result after the user has moved on.
|
|
const readGenerationRef = useRef(0);
|
|
|
|
useEffect(() => {
|
|
// Cancel any in-flight read when the volume context changes.
|
|
readGenerationRef.current += 1;
|
|
}, [volumeName]);
|
|
|
|
const handleSelectFile = useCallback(async (relPath: string) => {
|
|
if (!volumeName) return;
|
|
const generation = ++readGenerationRef.current;
|
|
const targetVolume = volumeName;
|
|
setSelectedPath(relPath);
|
|
setFileLoading(true);
|
|
setFileResult(null);
|
|
try {
|
|
const result = await readVolumeFile(targetVolume, relPath);
|
|
if (readGenerationRef.current !== generation) return;
|
|
setFileResult(result);
|
|
} catch (err: unknown) {
|
|
if (readGenerationRef.current !== generation) return;
|
|
const msg = err instanceof Error ? err.message : 'Failed to read file.';
|
|
toast.error(msg);
|
|
} finally {
|
|
if (readGenerationRef.current === generation) setFileLoading(false);
|
|
}
|
|
}, [volumeName]);
|
|
|
|
const handleClose = (open: boolean) => {
|
|
if (!open) {
|
|
setSelectedPath('');
|
|
setFileResult(null);
|
|
setFileLoading(false);
|
|
onClose();
|
|
}
|
|
};
|
|
|
|
const loadDir = useCallback(
|
|
(relPath: string) => {
|
|
if (!volumeName) return Promise.resolve<FileEntry[]>([]);
|
|
return listVolumeDirectory(volumeName, relPath);
|
|
},
|
|
[volumeName]
|
|
);
|
|
|
|
const meta = selectedPath || 'No file selected';
|
|
|
|
return (
|
|
<SystemSheet
|
|
open={!!volumeName}
|
|
onOpenChange={handleClose}
|
|
crumb={['Resources', 'Volumes', volumeName ? shortVolumeLabel(volumeName) : '-']}
|
|
name={volumeName ? <VolumeNameLabel name={volumeName} /> : 'Volume'}
|
|
meta={meta}
|
|
primaryAction={{
|
|
label: 'Refresh tree',
|
|
icon: RefreshCw,
|
|
onClick: () => setRefreshKey((k) => k + 1),
|
|
}}
|
|
footerContext="File reads are recorded in the audit log."
|
|
size="lg"
|
|
noScroll
|
|
>
|
|
{volumeName && (
|
|
<>
|
|
{isAnonymousVolumeName(volumeName) && <AnonymousNameBand name={volumeName} />}
|
|
<div className="grid grid-cols-[260px_1fr] gap-3 px-6 py-5 flex-1 min-h-0 max-md:grid-cols-1 max-md:grid-rows-[40%_1fr] max-md:px-4">
|
|
<div className="rounded-md border border-card-border bg-card overflow-hidden">
|
|
<FileTree
|
|
key={volumeName}
|
|
sourceKey={volumeName}
|
|
loadDir={loadDir}
|
|
refreshKey={refreshKey}
|
|
selectedPath={selectedPath}
|
|
onSelectFile={handleSelectFile}
|
|
/>
|
|
</div>
|
|
|
|
<div className="rounded-md border border-card-border bg-card overflow-hidden flex flex-col">
|
|
{!selectedPath && (
|
|
<div className="flex-1 flex items-center justify-center p-6 text-xs text-muted-foreground italic">
|
|
Select a file to preview.
|
|
</div>
|
|
)}
|
|
{selectedPath && fileLoading && (
|
|
<div className="p-3 space-y-2">
|
|
<Skeleton className="h-4 w-1/3" />
|
|
<Skeleton className="h-4 w-full" />
|
|
<Skeleton className="h-4 w-full" />
|
|
<Skeleton className="h-4 w-3/4" />
|
|
</div>
|
|
)}
|
|
{selectedPath && !fileLoading && fileResult && (
|
|
<FileResultPanel path={selectedPath} result={fileResult} />
|
|
)}
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
</SystemSheet>
|
|
);
|
|
}
|
|
|
|
function FileResultPanel({ path, result }: { path: string; result: VolumeFileResult }) {
|
|
const decoded = result.binary ? base64ToHex(result.content) : result.content;
|
|
return (
|
|
<div className="flex-1 flex flex-col min-h-0">
|
|
<div className="flex items-center justify-between gap-3 px-3 py-2 border-b border-card-border">
|
|
<span className="font-mono text-[11px] text-muted-foreground truncate" title={path}>{path}</span>
|
|
<span className="text-[11px] text-muted-foreground tabular-nums">
|
|
{formatBytes(result.size)}{result.binary ? ' · binary' : ''}{result.truncated ? ' · truncated' : ''}
|
|
</span>
|
|
</div>
|
|
{result.truncated && (
|
|
<div className="px-3 py-1.5 text-[11px] text-warning bg-warning/10 border-b border-card-border">
|
|
Showing first {formatBytes(5 * 1024 * 1024)}. Larger files cannot be downloaded from this view.
|
|
</div>
|
|
)}
|
|
<ScrollArea className="flex-1">
|
|
<pre className="p-3 font-mono text-[11px] whitespace-pre-wrap break-all leading-relaxed">
|
|
{decoded}
|
|
</pre>
|
|
</ScrollArea>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function base64ToHex(b64: string): string {
|
|
try {
|
|
const binary = atob(b64);
|
|
const out: string[] = [];
|
|
for (let i = 0; i < binary.length; i += 16) {
|
|
const offset = i.toString(16).padStart(8, '0');
|
|
const chunk = binary.slice(i, i + 16);
|
|
const hex = Array.from(chunk).map((c) => c.charCodeAt(0).toString(16).padStart(2, '0')).join(' ');
|
|
const ascii = Array.from(chunk).map((c) => {
|
|
const code = c.charCodeAt(0);
|
|
return code >= 32 && code <= 126 ? c : '.';
|
|
}).join('');
|
|
out.push(`${offset} ${hex.padEnd(48, ' ')} ${ascii}`);
|
|
}
|
|
return out.join('\n');
|
|
} catch {
|
|
return '(unable to decode binary content)';
|
|
}
|
|
}
|