diff --git a/app/client/components/directory-browser.tsx b/app/client/components/directory-browser.tsx index dab1d4d..002b8a4 100644 --- a/app/client/components/directory-browser.tsx +++ b/app/client/components/directory-browser.tsx @@ -1,8 +1,8 @@ import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { useCallback, useMemo, useState } from "react"; -import { FileTree, type FileEntry } from "./file-tree"; +import { FileTree } from "./file-tree"; import { ScrollArea } from "./ui/scroll-area"; import { browseFilesystemOptions } from "../api-client/@tanstack/react-query.gen"; +import { useFileBrowser } from "../hooks/use-file-browser"; type Props = { onSelectPath: (path: string) => void; @@ -11,82 +11,23 @@ type Props = { export const DirectoryBrowser = ({ onSelectPath, selectedPath }: Props) => { const queryClient = useQueryClient(); - const [expandedFolders, setExpandedFolders] = useState>(new Set()); - const [fetchedFolders, setFetchedFolders] = useState>(new Set(["/"])); - const [loadingFolders, setLoadingFolders] = useState>(new Set()); - const [allFiles, setAllFiles] = useState>(new Map()); const { data, isLoading } = useQuery({ ...browseFilesystemOptions({ query: { path: "/" } }), }); - useMemo(() => { - if (data?.directories) { - setAllFiles((prev) => { - const next = new Map(prev); - for (const dir of data.directories) { - 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; - }); - } - } + const fileBrowser = useFileBrowser({ + initialData: data, + isLoading, + fetchFolder: async (path) => { + return await queryClient.ensureQueryData(browseFilesystemOptions({ query: { path } })); }, - [fetchedFolders, queryClient], - ); - - const handleFolderHover = useCallback( - (folderPath: string) => { - if (!fetchedFolders.has(folderPath) && !loadingFolders.has(folderPath)) { - queryClient.prefetchQuery(browseFilesystemOptions({ query: { path: folderPath } })); - } + prefetchFolder: (path) => { + queryClient.prefetchQuery(browseFilesystemOptions({ query: { path } })); }, - [fetchedFolders, loadingFolders, queryClient], - ); + }); - if (isLoading && fileArray.length === 0) { + if (fileBrowser.isLoading) { return (
@@ -96,7 +37,7 @@ export const DirectoryBrowser = ({ onSelectPath, selectedPath }: Props) => { ); } - if (fileArray.length === 0) { + if (fileBrowser.isEmpty) { return (
@@ -110,11 +51,11 @@ export const DirectoryBrowser = ({ onSelectPath, selectedPath }: Props) => {
{ const queryClient = useQueryClient(); - const [expandedFolders, setExpandedFolders] = useState>(new Set()); - const [fetchedFolders, setFetchedFolders] = useState>(new Set(["/"])); - const [loadingFolders, setLoadingFolders] = useState>(new Set()); - const [allFiles, setAllFiles] = useState>(new Map()); const { data, isLoading, error } = useQuery({ ...listFilesOptions({ path: { name: volumeName } }), enabled, }); - useMemo(() => { - if (data?.files) { - setAllFiles((prev) => { - const next = new Map(prev); - for (const file of data.files) { - next.set(file.path, file); - } - 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( - 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; - }); - } - } + const fileBrowser = useFileBrowser({ + initialData: data, + isLoading, + fetchFolder: async (path) => { + return await queryClient.ensureQueryData( + listFilesOptions({ + path: { name: volumeName }, + query: { path }, + }), + ); }, - [volumeName, fetchedFolders, queryClient.ensureQueryData], - ); - - const handleFolderHover = useCallback( - (folderPath: string) => { - if (!fetchedFolders.has(folderPath) && !loadingFolders.has(folderPath)) { - queryClient.prefetchQuery( - listFilesOptions({ - path: { name: volumeName }, - query: { path: folderPath }, - }), - ); - } + prefetchFolder: (path) => { + queryClient.prefetchQuery( + listFilesOptions({ + path: { name: volumeName }, + query: { path }, + }), + ); }, - [volumeName, fetchedFolders, loadingFolders, queryClient], - ); + }); - if (isLoading && fileArray.length === 0) { + if (fileBrowser.isLoading) { return (

Loading files...

@@ -134,7 +71,7 @@ export const VolumeFileBrowser = ({ ); } - if (fileArray.length === 0) { + if (fileBrowser.isEmpty) { return (
@@ -147,11 +84,11 @@ export const VolumeFileBrowser = ({ return (
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>(new Set()); + const [fetchedFolders, setFetchedFolders] = useState>(new Set([rootPath])); + const [loadingFolders, setLoadingFolders] = useState>(new Set()); + const [allFiles, setAllFiles] = useState>(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, + }; +}; diff --git a/app/client/modules/backups/components/snapshot-file-browser.tsx b/app/client/modules/backups/components/snapshot-file-browser.tsx index a26ecb3..3ccca73 100644 --- a/app/client/modules/backups/components/snapshot-file-browser.tsx +++ b/app/client/modules/backups/components/snapshot-file-browser.tsx @@ -1,7 +1,7 @@ -import { useCallback, useMemo, useState } from "react"; +import { useCallback, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; 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 { Button } from "~/client/components/ui/button"; 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 { toast } from "sonner"; import { listSnapshotFilesOptions, restoreSnapshotMutation } from "~/client/api-client/@tanstack/react-query.gen"; +import { useFileBrowser } from "~/client/hooks/use-file-browser"; interface Props { snapshot: Snapshot; @@ -33,10 +34,6 @@ export const SnapshotFileBrowser = (props: Props) => { const isReadOnly = volume?.config && "readOnly" in volume.config && volume.config.readOnly === true; const queryClient = useQueryClient(); - const [expandedFolders, setExpandedFolders] = useState>(new Set()); - const [fetchedFolders, setFetchedFolders] = useState>(new Set()); - const [loadingFolders, setLoadingFolders] = useState>(new Set()); - const [allFiles, setAllFiles] = useState>(new Map()); const [selectedPaths, setSelectedPaths] = useState>(new Set()); const [showRestoreDialog, setShowRestoreDialog] = useState(false); const [deleteExtraFiles, setDeleteExtraFiles] = useState(false); @@ -72,89 +69,30 @@ export const SnapshotFileBrowser = (props: Props) => { [volumeBasePath], ); - useMemo(() => { - if (filesData?.files) { - setAllFiles((prev) => { - const next = new Map(prev); - for (const file of filesData.files) { - const strippedPath = stripBasePath(file.path); - if (strippedPath !== "/") { - next.set(strippedPath, { ...file, path: strippedPath }); - } - } - 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; - }); - } - } + const fileBrowser = useFileBrowser({ + initialData: filesData, + isLoading: filesLoading, + fetchFolder: async (path) => { + return await queryClient.ensureQueryData( + listSnapshotFilesOptions({ + path: { name: repositoryName, snapshotId: snapshot.short_id }, + query: { path }, + }), + ); }, - [repositoryName, snapshot, fetchedFolders, queryClient, stripBasePath, addBasePath], - ); - - const handleFolderHover = useCallback( - (folderPath: string) => { - if (!fetchedFolders.has(folderPath) && !loadingFolders.has(folderPath)) { - const fullPath = addBasePath(folderPath); - - queryClient.prefetchQuery({ - ...listSnapshotFilesOptions({ - path: { name: repositoryName, snapshotId: snapshot.short_id }, - query: { path: fullPath }, - }), - }); - } + prefetchFolder: (path) => { + queryClient.prefetchQuery( + listSnapshotFilesOptions({ + path: { name: repositoryName, snapshotId: snapshot.short_id }, + query: { path }, + }), + ); }, - [repositoryName, snapshot, fetchedFolders, loadingFolders, queryClient, addBasePath], - ); + pathTransform: { + strip: stripBasePath, + add: addBasePath, + }, + }); const { mutate: restoreSnapshot, isPending: isRestoring } = useMutation({ ...restoreSnapshotMutation(), @@ -225,27 +163,27 @@ export const SnapshotFileBrowser = (props: Props) => {
- {filesLoading && fileArray.length === 0 && ( + {fileBrowser.isLoading && (

Loading files...

)} - {fileArray.length === 0 && !filesLoading && ( + {fileBrowser.isEmpty && (

No files in this snapshot

)} - {fileArray.length > 0 && ( + {!fileBrowser.isLoading && !fileBrowser.isEmpty && (