mono/packages/ui/src/modules/storage/hooks/useVfsAdapter.ts
2026-04-05 12:38:16 +02:00

170 lines
5.9 KiB
TypeScript

import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import { INode, SortKey } from '@/modules/storage/types';
import { getMimeCategory, globToRegex, sortNodes, vfsUrl } from '@/modules/storage/helpers';
import { fetchVfsDirectory, fetchVfsSearch } from '@/modules/storage/client-vfs';
export interface UseVfsAdapterProps {
mount: string;
pathProp?: string;
glob?: string;
searchQuery?: string;
showFolders?: boolean;
accessToken?: string;
index?: boolean;
jail?: boolean;
jailPath?: string;
sortBy?: SortKey;
sortAsc?: boolean;
includeSize?: boolean;
onPathChange?: (path: string) => void;
onMountChange?: (mount: string) => void;
onFetched?: (nodes: INode[], isSearchMode: boolean) => void;
}
export function useVfsAdapter({
mount,
pathProp,
glob,
searchQuery,
showFolders = true,
accessToken,
index = true,
jail = false,
jailPath = '/',
sortBy = 'name',
sortAsc = true,
includeSize = false,
onPathChange,
onMountChange,
onFetched
}: UseVfsAdapterProps) {
const currentGlob = glob || '*.*';
const isControlled = pathProp !== undefined;
const [internalPath, setInternalPath] = useState(pathProp || '/');
const currentPath = isControlled ? pathProp : internalPath;
const [nodes, setNodes] = useState<INode[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const isSearchMode = !!searchQuery && searchQuery.trim().length >= 2;
const tokenParam = accessToken ? `token=${encodeURIComponent(accessToken)}` : '';
const onFetchedRef = useRef(onFetched);
useEffect(() => {
onFetchedRef.current = onFetched;
}, [onFetched]);
// Root-jail resolution wrapper
const jailRoot = useMemo(() => {
return (jailPath || '/').replace(/\/+$/, '') || '/';
}, [jailPath]);
const fetchDir = useCallback(async (dirPath: string) => {
setLoading(true);
setError(null);
try {
const clean = dirPath.replace(/^\/+/, '');
if (isSearchMode) {
const data = await fetchVfsSearch(mount, clean, searchQuery, { accessToken });
setNodes(data.results || []);
if (onFetchedRef.current) onFetchedRef.current(data.results || [], true);
} else {
const data = await fetchVfsDirectory(mount, clean, { accessToken, includeSize });
setNodes(data);
if (onFetchedRef.current) onFetchedRef.current(data, false);
}
} catch (e: any) {
setError(e.message || 'Failed to load directory');
setNodes([]);
} finally {
setLoading(false);
}
}, [mount, accessToken, index, tokenParam, includeSize, isSearchMode, searchQuery]);
const updatePath = useCallback((p: string) => {
if (p === currentPath) {
fetchDir(p);
return;
}
if (!isControlled) setInternalPath(p);
if (onPathChange) onPathChange(p);
// Clear nodes immediately so UI doesn't render the new path with the old folder's contents
// causing layout effects (like focus restoration) to fail against stale data.
setNodes([]);
setLoading(true);
}, [isControlled, onPathChange, currentPath, fetchDir]);
const updateMount = useCallback((m: string) => {
if (onMountChange) onMountChange(m);
// Only internal state changes if path not controlled:
if (!isControlled) setInternalPath('/');
if (onPathChange) onPathChange('/');
}, [isControlled, onMountChange, onPathChange]);
useEffect(() => { fetchDir(currentPath); }, [currentPath, fetchDir]);
useEffect(() => {
if (!isControlled && pathProp !== undefined) setInternalPath(pathProp);
}, [pathProp, isControlled]);
const canGoUp = useMemo(() => {
if (currentPath === '/' || currentPath === '') return false;
if (jail) {
const normalized = currentPath.replace(/\/+$/, '') || '/';
return normalized !== jailRoot && normalized !== jailRoot.replace(/\/+$/, '');
}
return true;
}, [currentPath, jail, jailRoot]);
const filteredNodes = useMemo(() => {
const regex = globToRegex(currentGlob);
return nodes.filter(n => {
const isDir = n.type === 'dir' || n.mime === 'inode/directory';
if (isDir) return showFolders;
return regex.test(n.name);
});
}, [nodes, currentGlob, showFolders]);
const sorted = useMemo(() => sortNodes(filteredNodes, sortBy, sortAsc), [filteredNodes, sortBy, sortAsc]);
const goUp = useCallback(() => {
if (!canGoUp) return;
const parts = currentPath.replace(/^\/+/, '').split('/').filter(Boolean);
parts.pop();
updatePath(parts.length ? parts.join('/') : '/');
}, [currentPath, canGoUp, updatePath]);
const breadcrumbs = useMemo(() => {
const parts = currentPath.replace(/^\/+/, '').split('/').filter(Boolean);
const crumbs = [{ label: '/', path: '/' }];
let acc = '';
for (const p of parts) { acc += (acc ? '/' : '') + p; crumbs.push({ label: p, path: acc }); }
if (jail) {
const root = jailRoot === '/' ? '/' : jailRoot;
const rootParts = root === '/' ? 0 : root.split('/').filter(Boolean).length;
return crumbs.slice(rootParts);
}
return crumbs;
}, [currentPath, mount, jail, jailRoot]);
return {
nodes,
sorted,
loading,
error,
currentPath,
currentGlob,
updatePath,
updateMount,
fetchDir,
canGoUp,
goUp,
breadcrumbs,
jailRoot,
isSearchMode
};
}