refactor(browsers): create hook for common operations

This commit is contained in:
Nicolas Meienberger
2025-11-14 20:50:01 +01:00
parent 18f863cbac
commit 00916a1fd2
5 changed files with 218 additions and 261 deletions

View File

@@ -1,8 +1,8 @@
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useCallback, useMemo, useState } from "react"; import { FileTree } from "./file-tree";
import { FileTree, type FileEntry } from "./file-tree";
import { ScrollArea } from "./ui/scroll-area"; import { ScrollArea } from "./ui/scroll-area";
import { browseFilesystemOptions } from "../api-client/@tanstack/react-query.gen"; import { browseFilesystemOptions } from "../api-client/@tanstack/react-query.gen";
import { useFileBrowser } from "../hooks/use-file-browser";
type Props = { type Props = {
onSelectPath: (path: string) => void; onSelectPath: (path: string) => void;
@@ -11,82 +11,23 @@ type Props = {
export const DirectoryBrowser = ({ onSelectPath, selectedPath }: Props) => { export const DirectoryBrowser = ({ onSelectPath, selectedPath }: Props) => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
const [fetchedFolders, setFetchedFolders] = useState<Set<string>>(new Set(["/"]));
const [loadingFolders, setLoadingFolders] = useState<Set<string>>(new Set());
const [allFiles, setAllFiles] = useState<Map<string, FileEntry>>(new Map());
const { data, isLoading } = useQuery({ const { data, isLoading } = useQuery({
...browseFilesystemOptions({ query: { path: "/" } }), ...browseFilesystemOptions({ query: { path: "/" } }),
}); });
useMemo(() => { const fileBrowser = useFileBrowser({
if (data?.directories) { initialData: data,
setAllFiles((prev) => { isLoading,
const next = new Map(prev); fetchFolder: async (path) => {
for (const dir of data.directories) { return await queryClient.ensureQueryData(browseFilesystemOptions({ query: { path } }));
next.set(dir.path, { name: dir.name, path: dir.path, type: "folder" });
}
return next;
});
}
}, [data]);
const fileArray = useMemo(() => Array.from(allFiles.values()), [allFiles]);
const handleFolderExpand = useCallback(
async (folderPath: string) => {
setExpandedFolders((prev) => {
const next = new Set(prev);
next.add(folderPath);
return next;
});
if (!fetchedFolders.has(folderPath)) {
setLoadingFolders((prev) => new Set(prev).add(folderPath));
try {
const result = await queryClient.ensureQueryData(
browseFilesystemOptions({
query: { path: folderPath },
}),
);
if (result.directories) {
setAllFiles((prev) => {
const next = new Map(prev);
for (const dir of result.directories) {
next.set(dir.path, { name: dir.name, path: dir.path, type: "folder" });
}
return next;
});
setFetchedFolders((prev) => new Set(prev).add(folderPath));
}
} catch (error) {
console.error("Failed to fetch folder contents:", error);
} finally {
setLoadingFolders((prev) => {
const next = new Set(prev);
next.delete(folderPath);
return next;
});
}
}
}, },
[fetchedFolders, queryClient], prefetchFolder: (path) => {
); queryClient.prefetchQuery(browseFilesystemOptions({ query: { path } }));
const handleFolderHover = useCallback(
(folderPath: string) => {
if (!fetchedFolders.has(folderPath) && !loadingFolders.has(folderPath)) {
queryClient.prefetchQuery(browseFilesystemOptions({ query: { path: folderPath } }));
}
}, },
[fetchedFolders, loadingFolders, queryClient], });
);
if (isLoading && fileArray.length === 0) { if (fileBrowser.isLoading) {
return ( return (
<div className="border rounded-lg overflow-hidden"> <div className="border rounded-lg overflow-hidden">
<ScrollArea className="h-64"> <ScrollArea className="h-64">
@@ -96,7 +37,7 @@ export const DirectoryBrowser = ({ onSelectPath, selectedPath }: Props) => {
); );
} }
if (fileArray.length === 0) { if (fileBrowser.isEmpty) {
return ( return (
<div className="border rounded-lg overflow-hidden"> <div className="border rounded-lg overflow-hidden">
<ScrollArea className="h-64"> <ScrollArea className="h-64">
@@ -110,11 +51,11 @@ export const DirectoryBrowser = ({ onSelectPath, selectedPath }: Props) => {
<div className="border rounded-lg overflow-hidden"> <div className="border rounded-lg overflow-hidden">
<ScrollArea className="h-64"> <ScrollArea className="h-64">
<FileTree <FileTree
files={fileArray} files={fileBrowser.fileArray}
onFolderExpand={handleFolderExpand} onFolderExpand={fileBrowser.handleFolderExpand}
onFolderHover={handleFolderHover} onFolderHover={fileBrowser.handleFolderHover}
expandedFolders={expandedFolders} expandedFolders={fileBrowser.expandedFolders}
loadingFolders={loadingFolders} loadingFolders={fileBrowser.loadingFolders}
foldersOnly foldersOnly
selectableFolders selectableFolders
selectedFolder={selectedPath} selectedFolder={selectedPath}

View File

@@ -1,16 +1,8 @@
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { FolderOpen } from "lucide-react"; import { FolderOpen } from "lucide-react";
import { useCallback, useMemo, useState } from "react";
import { FileTree } from "~/client/components/file-tree"; import { FileTree } from "~/client/components/file-tree";
import { listFilesOptions } from "../api-client/@tanstack/react-query.gen"; import { listFilesOptions } from "../api-client/@tanstack/react-query.gen";
import { useFileBrowser } from "../hooks/use-file-browser";
interface FileEntry {
name: string;
path: string;
type: "file" | "directory";
size?: number;
modifiedAt?: number;
}
type VolumeFileBrowserProps = { type VolumeFileBrowserProps = {
volumeName: string; volumeName: string;
@@ -36,89 +28,34 @@ export const VolumeFileBrowser = ({
emptyDescription, emptyDescription,
}: VolumeFileBrowserProps) => { }: VolumeFileBrowserProps) => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
const [fetchedFolders, setFetchedFolders] = useState<Set<string>>(new Set(["/"]));
const [loadingFolders, setLoadingFolders] = useState<Set<string>>(new Set());
const [allFiles, setAllFiles] = useState<Map<string, FileEntry>>(new Map());
const { data, isLoading, error } = useQuery({ const { data, isLoading, error } = useQuery({
...listFilesOptions({ path: { name: volumeName } }), ...listFilesOptions({ path: { name: volumeName } }),
enabled, enabled,
}); });
useMemo(() => { const fileBrowser = useFileBrowser({
if (data?.files) { initialData: data,
setAllFiles((prev) => { isLoading,
const next = new Map(prev); fetchFolder: async (path) => {
for (const file of data.files) { return await queryClient.ensureQueryData(
next.set(file.path, file); listFilesOptions({
} path: { name: volumeName },
return next; query: { path },
}); }),
} );
}, [data]);
const fileArray = useMemo(() => Array.from(allFiles.values()), [allFiles]);
const handleFolderExpand = useCallback(
async (folderPath: string) => {
setExpandedFolders((prev) => {
const next = new Set(prev);
next.add(folderPath);
return next;
});
if (!fetchedFolders.has(folderPath)) {
setLoadingFolders((prev) => new Set(prev).add(folderPath));
try {
const result = await queryClient.ensureQueryData(
listFilesOptions({
path: { name: volumeName },
query: { path: folderPath },
}),
);
if (result.files) {
setAllFiles((prev) => {
const next = new Map(prev);
for (const file of result.files) {
next.set(file.path, file);
}
return next;
});
setFetchedFolders((prev) => new Set(prev).add(folderPath));
}
} catch (error) {
console.error("Failed to fetch folder contents:", error);
} finally {
setLoadingFolders((prev) => {
const next = new Set(prev);
next.delete(folderPath);
return next;
});
}
}
}, },
[volumeName, fetchedFolders, queryClient.ensureQueryData], prefetchFolder: (path) => {
); queryClient.prefetchQuery(
listFilesOptions({
const handleFolderHover = useCallback( path: { name: volumeName },
(folderPath: string) => { query: { path },
if (!fetchedFolders.has(folderPath) && !loadingFolders.has(folderPath)) { }),
queryClient.prefetchQuery( );
listFilesOptions({
path: { name: volumeName },
query: { path: folderPath },
}),
);
}
}, },
[volumeName, fetchedFolders, loadingFolders, queryClient], });
);
if (isLoading && fileArray.length === 0) { if (fileBrowser.isLoading) {
return ( return (
<div className="flex items-center justify-center h-full min-h-[200px]"> <div className="flex items-center justify-center h-full min-h-[200px]">
<p className="text-muted-foreground">Loading files...</p> <p className="text-muted-foreground">Loading files...</p>
@@ -134,7 +71,7 @@ export const VolumeFileBrowser = ({
); );
} }
if (fileArray.length === 0) { if (fileBrowser.isEmpty) {
return ( return (
<div className="flex flex-col items-center justify-center h-full text-center p-8 min-h-[200px]"> <div className="flex flex-col items-center justify-center h-full text-center p-8 min-h-[200px]">
<FolderOpen className="mb-4 h-12 w-12 text-muted-foreground" /> <FolderOpen className="mb-4 h-12 w-12 text-muted-foreground" />
@@ -147,11 +84,11 @@ export const VolumeFileBrowser = ({
return ( return (
<div className={className}> <div className={className}>
<FileTree <FileTree
files={fileArray} files={fileBrowser.fileArray}
onFolderExpand={handleFolderExpand} onFolderExpand={fileBrowser.handleFolderExpand}
onFolderHover={handleFolderHover} onFolderHover={fileBrowser.handleFolderHover}
expandedFolders={expandedFolders} expandedFolders={fileBrowser.expandedFolders}
loadingFolders={loadingFolders} loadingFolders={fileBrowser.loadingFolders}
withCheckboxes={withCheckboxes} withCheckboxes={withCheckboxes}
selectedPaths={selectedPaths} selectedPaths={selectedPaths}
onSelectionChange={onSelectionChange} onSelectionChange={onSelectionChange}

View File

@@ -0,0 +1,141 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import type { FileEntry } from "../components/file-tree";
type FetchFolderFn = (
path: string,
) => Promise<{ files?: FileEntry[]; directories?: Array<{ name: string; path: string }> }>;
type PathTransformFns = {
strip?: (path: string) => string;
add?: (path: string) => string;
};
type UseFileBrowserOptions = {
initialData?: { files?: FileEntry[]; directories?: Array<{ name: string; path: string }> };
isLoading?: boolean;
fetchFolder: FetchFolderFn;
prefetchFolder?: (path: string) => void;
pathTransform?: PathTransformFns;
rootPath?: string;
};
export const useFileBrowser = ({
initialData,
isLoading = false,
fetchFolder,
prefetchFolder,
pathTransform,
rootPath = "/",
}: UseFileBrowserOptions) => {
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
const [fetchedFolders, setFetchedFolders] = useState<Set<string>>(new Set([rootPath]));
const [loadingFolders, setLoadingFolders] = useState<Set<string>>(new Set());
const [allFiles, setAllFiles] = useState<Map<string, FileEntry>>(new Map());
const stripPath = pathTransform?.strip;
const addPath = pathTransform?.add;
useMemo(() => {
if (initialData?.files) {
const files = initialData.files;
setAllFiles((prev) => {
const next = new Map(prev);
for (const file of files) {
const path = stripPath ? stripPath(file.path) : file.path;
if (path !== rootPath) {
next.set(path, { ...file, path });
}
}
return next;
});
if (rootPath) {
setFetchedFolders((prev) => new Set(prev).add(rootPath));
}
} else if (initialData?.directories) {
const directories = initialData.directories;
setAllFiles((prev) => {
const next = new Map(prev);
for (const dir of directories) {
next.set(dir.path, { name: dir.name, path: dir.path, type: "folder" });
}
return next;
});
}
}, [initialData, stripPath, rootPath]);
const fileArray = useMemo(() => Array.from(allFiles.values()), [allFiles]);
const handleFolderExpand = useCallback(
async (folderPath: string) => {
setExpandedFolders((prev) => {
const next = new Set(prev);
next.add(folderPath);
return next;
});
if (!fetchedFolders.has(folderPath)) {
setLoadingFolders((prev) => new Set(prev).add(folderPath));
try {
const pathToFetch = addPath ? addPath(folderPath) : folderPath;
const result = await fetchFolder(pathToFetch);
if (result.files) {
const files = result.files;
setAllFiles((prev) => {
const next = new Map(prev);
for (const file of files) {
const strippedPath = stripPath ? stripPath(file.path) : file.path;
// Skip the directory itself
if (strippedPath !== folderPath) {
next.set(strippedPath, { ...file, path: strippedPath });
}
}
return next;
});
} else if (result.directories) {
const directories = result.directories;
setAllFiles((prev) => {
const next = new Map(prev);
for (const dir of directories) {
next.set(dir.path, { name: dir.name, path: dir.path, type: "folder" });
}
return next;
});
}
setFetchedFolders((prev) => new Set(prev).add(folderPath));
} catch (error) {
console.error("Failed to fetch folder contents:", error);
} finally {
setLoadingFolders((prev) => {
const next = new Set(prev);
next.delete(folderPath);
return next;
});
}
}
},
[fetchedFolders, fetchFolder, stripPath, addPath],
);
const handleFolderHover = useCallback(
(folderPath: string) => {
if (!fetchedFolders.has(folderPath) && !loadingFolders.has(folderPath) && prefetchFolder) {
const pathToPrefetch = addPath ? addPath(folderPath) : folderPath;
prefetchFolder(pathToPrefetch);
}
},
[fetchedFolders, loadingFolders, prefetchFolder, addPath],
);
return {
fileArray,
expandedFolders,
loadingFolders,
handleFolderExpand,
handleFolderHover,
isLoading: isLoading && fileArray.length === 0,
isEmpty: fileArray.length === 0 && !isLoading,
};
};

View File

@@ -1,7 +1,7 @@
import { useCallback, useMemo, useState } from "react"; import { useCallback, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { FileIcon } from "lucide-react"; import { FileIcon } from "lucide-react";
import { FileTree, type FileEntry } from "~/client/components/file-tree"; import { FileTree } from "~/client/components/file-tree";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card";
import { Button } from "~/client/components/ui/button"; import { Button } from "~/client/components/ui/button";
import { Checkbox } from "~/client/components/ui/checkbox"; import { Checkbox } from "~/client/components/ui/checkbox";
@@ -20,6 +20,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "~/client/components/ui/
import type { Snapshot, Volume } from "~/client/lib/types"; import type { Snapshot, Volume } from "~/client/lib/types";
import { toast } from "sonner"; import { toast } from "sonner";
import { listSnapshotFilesOptions, restoreSnapshotMutation } from "~/client/api-client/@tanstack/react-query.gen"; import { listSnapshotFilesOptions, restoreSnapshotMutation } from "~/client/api-client/@tanstack/react-query.gen";
import { useFileBrowser } from "~/client/hooks/use-file-browser";
interface Props { interface Props {
snapshot: Snapshot; snapshot: Snapshot;
@@ -33,10 +34,6 @@ export const SnapshotFileBrowser = (props: Props) => {
const isReadOnly = volume?.config && "readOnly" in volume.config && volume.config.readOnly === true; const isReadOnly = volume?.config && "readOnly" in volume.config && volume.config.readOnly === true;
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
const [fetchedFolders, setFetchedFolders] = useState<Set<string>>(new Set());
const [loadingFolders, setLoadingFolders] = useState<Set<string>>(new Set());
const [allFiles, setAllFiles] = useState<Map<string, FileEntry>>(new Map());
const [selectedPaths, setSelectedPaths] = useState<Set<string>>(new Set()); const [selectedPaths, setSelectedPaths] = useState<Set<string>>(new Set());
const [showRestoreDialog, setShowRestoreDialog] = useState(false); const [showRestoreDialog, setShowRestoreDialog] = useState(false);
const [deleteExtraFiles, setDeleteExtraFiles] = useState(false); const [deleteExtraFiles, setDeleteExtraFiles] = useState(false);
@@ -72,89 +69,30 @@ export const SnapshotFileBrowser = (props: Props) => {
[volumeBasePath], [volumeBasePath],
); );
useMemo(() => { const fileBrowser = useFileBrowser({
if (filesData?.files) { initialData: filesData,
setAllFiles((prev) => { isLoading: filesLoading,
const next = new Map(prev); fetchFolder: async (path) => {
for (const file of filesData.files) { return await queryClient.ensureQueryData(
const strippedPath = stripBasePath(file.path); listSnapshotFilesOptions({
if (strippedPath !== "/") { path: { name: repositoryName, snapshotId: snapshot.short_id },
next.set(strippedPath, { ...file, path: strippedPath }); query: { path },
} }),
} );
return next;
});
setFetchedFolders((prev) => new Set(prev).add("/"));
}
}, [filesData, stripBasePath]);
const fileArray = useMemo(() => Array.from(allFiles.values()), [allFiles]);
const handleFolderExpand = useCallback(
async (folderPath: string) => {
setExpandedFolders((prev) => {
const next = new Set(prev);
next.add(folderPath);
return next;
});
if (!fetchedFolders.has(folderPath)) {
setLoadingFolders((prev) => new Set(prev).add(folderPath));
try {
const fullPath = addBasePath(folderPath);
const result = await queryClient.ensureQueryData(
listSnapshotFilesOptions({
path: { name: repositoryName, snapshotId: snapshot.short_id },
query: { path: fullPath },
}),
);
if (result.files) {
setAllFiles((prev) => {
const next = new Map(prev);
for (const file of result.files) {
const strippedPath = stripBasePath(file.path);
// Skip the directory itself
if (strippedPath !== folderPath) {
next.set(strippedPath, { ...file, path: strippedPath });
}
}
return next;
});
setFetchedFolders((prev) => new Set(prev).add(folderPath));
}
} catch (error) {
console.error("Failed to fetch folder contents:", error);
} finally {
setLoadingFolders((prev) => {
const next = new Set(prev);
next.delete(folderPath);
return next;
});
}
}
}, },
[repositoryName, snapshot, fetchedFolders, queryClient, stripBasePath, addBasePath], prefetchFolder: (path) => {
); queryClient.prefetchQuery(
listSnapshotFilesOptions({
const handleFolderHover = useCallback( path: { name: repositoryName, snapshotId: snapshot.short_id },
(folderPath: string) => { query: { path },
if (!fetchedFolders.has(folderPath) && !loadingFolders.has(folderPath)) { }),
const fullPath = addBasePath(folderPath); );
queryClient.prefetchQuery({
...listSnapshotFilesOptions({
path: { name: repositoryName, snapshotId: snapshot.short_id },
query: { path: fullPath },
}),
});
}
}, },
[repositoryName, snapshot, fetchedFolders, loadingFolders, queryClient, addBasePath], pathTransform: {
); strip: stripBasePath,
add: addBasePath,
},
});
const { mutate: restoreSnapshot, isPending: isRestoring } = useMutation({ const { mutate: restoreSnapshot, isPending: isRestoring } = useMutation({
...restoreSnapshotMutation(), ...restoreSnapshotMutation(),
@@ -225,27 +163,27 @@ export const SnapshotFileBrowser = (props: Props) => {
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="flex-1 overflow-hidden flex flex-col p-0"> <CardContent className="flex-1 overflow-hidden flex flex-col p-0">
{filesLoading && fileArray.length === 0 && ( {fileBrowser.isLoading && (
<div className="flex items-center justify-center flex-1"> <div className="flex items-center justify-center flex-1">
<p className="text-muted-foreground">Loading files...</p> <p className="text-muted-foreground">Loading files...</p>
</div> </div>
)} )}
{fileArray.length === 0 && !filesLoading && ( {fileBrowser.isEmpty && (
<div className="flex flex-col items-center justify-center flex-1 text-center p-8"> <div className="flex flex-col items-center justify-center flex-1 text-center p-8">
<FileIcon className="w-12 h-12 text-muted-foreground/50 mb-4" /> <FileIcon className="w-12 h-12 text-muted-foreground/50 mb-4" />
<p className="text-muted-foreground">No files in this snapshot</p> <p className="text-muted-foreground">No files in this snapshot</p>
</div> </div>
)} )}
{fileArray.length > 0 && ( {!fileBrowser.isLoading && !fileBrowser.isEmpty && (
<div className="overflow-auto flex-1 border border-border rounded-md bg-card m-4"> <div className="overflow-auto flex-1 border border-border rounded-md bg-card m-4">
<FileTree <FileTree
files={fileArray} files={fileBrowser.fileArray}
onFolderExpand={handleFolderExpand} onFolderExpand={fileBrowser.handleFolderExpand}
onFolderHover={handleFolderHover} onFolderHover={fileBrowser.handleFolderHover}
expandedFolders={expandedFolders} expandedFolders={fileBrowser.expandedFolders}
loadingFolders={loadingFolders} loadingFolders={fileBrowser.loadingFolders}
className="px-2 py-2" className="px-2 py-2"
withCheckboxes={true} withCheckboxes={true}
selectedPaths={selectedPaths} selectedPaths={selectedPaths}

View File

@@ -18,7 +18,7 @@ services:
- /var/lib/ironmount:/var/lib/ironmount - /var/lib/ironmount:/var/lib/ironmount
- ./app:/app/app - ./app:/app/app
# - ~/.config/rclone:/root/.config/rclone - ~/.config/rclone:/root/.config/rclone
ironmount-prod: ironmount-prod:
build: build: