refactor: split file browsers into dedicated components with base
This commit is contained in:
parent
d34bd53275
commit
bb102b0619
17 changed files with 525 additions and 368 deletions
|
|
@ -1,74 +0,0 @@
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
||||||
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;
|
|
||||||
selectedPath?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const DirectoryBrowser = ({ onSelectPath, selectedPath }: Props) => {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
const { data, isLoading } = useQuery({
|
|
||||||
...browseFilesystemOptions({ query: { path: "/" } }),
|
|
||||||
});
|
|
||||||
|
|
||||||
const fileBrowser = useFileBrowser({
|
|
||||||
initialData: data,
|
|
||||||
isLoading,
|
|
||||||
fetchFolder: async (path) => {
|
|
||||||
return await queryClient.ensureQueryData(browseFilesystemOptions({ query: { path } }));
|
|
||||||
},
|
|
||||||
prefetchFolder: (path) => {
|
|
||||||
void queryClient.prefetchQuery(browseFilesystemOptions({ query: { path } }));
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (fileBrowser.isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="border rounded-lg overflow-hidden">
|
|
||||||
<ScrollArea className="h-64">
|
|
||||||
<div className="text-sm text-gray-500 p-4">Loading directories...</div>
|
|
||||||
</ScrollArea>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (fileBrowser.isEmpty) {
|
|
||||||
return (
|
|
||||||
<div className="border rounded-lg overflow-hidden">
|
|
||||||
<ScrollArea className="h-64">
|
|
||||||
<div className="text-sm text-gray-500 p-4">No subdirectories found</div>
|
|
||||||
</ScrollArea>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="border rounded-lg overflow-hidden">
|
|
||||||
<ScrollArea className="h-64">
|
|
||||||
<FileTree
|
|
||||||
files={fileBrowser.fileArray}
|
|
||||||
onFolderExpand={fileBrowser.handleFolderExpand}
|
|
||||||
onFolderHover={fileBrowser.handleFolderHover}
|
|
||||||
expandedFolders={fileBrowser.expandedFolders}
|
|
||||||
loadingFolders={fileBrowser.loadingFolders}
|
|
||||||
foldersOnly
|
|
||||||
selectableFolders
|
|
||||||
selectedFolder={selectedPath}
|
|
||||||
onFolderSelect={onSelectPath}
|
|
||||||
/>
|
|
||||||
</ScrollArea>
|
|
||||||
|
|
||||||
{selectedPath && (
|
|
||||||
<div className="bg-muted/50 border-t p-2 text-sm">
|
|
||||||
<div className="font-medium text-muted-foreground">Selected path:</div>
|
|
||||||
<div className="font-mono text-xs break-all">{selectedPath}</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
24
app/client/components/file-browsers/directory-browser.tsx
Normal file
24
app/client/components/file-browsers/directory-browser.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
import { LocalFileBrowser } from "./local-file-browser";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
onSelectPath: (path: string) => void;
|
||||||
|
selectedPath?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DirectoryBrowser = ({ onSelectPath, selectedPath }: Props) => {
|
||||||
|
return (
|
||||||
|
<LocalFileBrowser
|
||||||
|
className="border rounded-lg overflow-hidden"
|
||||||
|
useScrollArea
|
||||||
|
scrollAreaClassName="h-64"
|
||||||
|
foldersOnly
|
||||||
|
selectableFolders
|
||||||
|
selectedFolder={selectedPath}
|
||||||
|
onFolderSelect={onSelectPath}
|
||||||
|
showSelectedPathFooter
|
||||||
|
selectedPath={selectedPath}
|
||||||
|
loadingMessage="Loading directories..."
|
||||||
|
emptyMessage="No subdirectories found"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
154
app/client/components/file-browsers/file-browser.tsx
Normal file
154
app/client/components/file-browsers/file-browser.tsx
Normal file
|
|
@ -0,0 +1,154 @@
|
||||||
|
import { type ReactNode } from "react";
|
||||||
|
import { FolderOpen } from "lucide-react";
|
||||||
|
import { FileTree, type FileEntry } from "~/client/components/file-tree";
|
||||||
|
import { ScrollArea } from "~/client/components/ui/scroll-area";
|
||||||
|
import { cn } from "~/client/lib/utils";
|
||||||
|
|
||||||
|
type PaginationState = {
|
||||||
|
hasMore: boolean;
|
||||||
|
isLoadingMore: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FileBrowserUiProps = {
|
||||||
|
className?: string;
|
||||||
|
treeContainerClassName?: string;
|
||||||
|
treeClassName?: string;
|
||||||
|
useScrollArea?: boolean;
|
||||||
|
scrollAreaClassName?: string;
|
||||||
|
stateClassName?: string;
|
||||||
|
loadingMessage?: string;
|
||||||
|
emptyMessage?: string;
|
||||||
|
emptyDescription?: string;
|
||||||
|
emptyIcon?: ReactNode;
|
||||||
|
withCheckboxes?: boolean;
|
||||||
|
selectedPaths?: Set<string>;
|
||||||
|
onSelectionChange?: (paths: Set<string>) => void;
|
||||||
|
foldersOnly?: boolean;
|
||||||
|
selectableFolders?: boolean;
|
||||||
|
onFolderSelect?: (folderPath: string) => void;
|
||||||
|
selectedFolder?: string;
|
||||||
|
onFileSelect?: (filePath: string) => void;
|
||||||
|
selectedFile?: string;
|
||||||
|
showSelectedPathFooter?: boolean;
|
||||||
|
selectedPath?: string;
|
||||||
|
selectedPathLabel?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type FileBrowserProps = FileBrowserUiProps & {
|
||||||
|
isLoading: boolean;
|
||||||
|
isEmpty: boolean;
|
||||||
|
errorMessage?: string;
|
||||||
|
fileArray: FileEntry[];
|
||||||
|
expandedFolders: Set<string>;
|
||||||
|
loadingFolders: Set<string>;
|
||||||
|
onFolderExpand: (folderPath: string) => void | Promise<void>;
|
||||||
|
onFolderHover: (folderPath: string) => void;
|
||||||
|
onLoadMore: (folderPath: string) => void | Promise<void>;
|
||||||
|
getFolderPagination: (folderPath: string) => PaginationState;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const FileBrowser = (props: FileBrowserProps) => {
|
||||||
|
const {
|
||||||
|
className,
|
||||||
|
treeContainerClassName,
|
||||||
|
treeClassName,
|
||||||
|
useScrollArea = false,
|
||||||
|
scrollAreaClassName,
|
||||||
|
stateClassName,
|
||||||
|
loadingMessage = "Loading files...",
|
||||||
|
emptyMessage = "No files found.",
|
||||||
|
emptyDescription,
|
||||||
|
emptyIcon,
|
||||||
|
withCheckboxes = false,
|
||||||
|
selectedPaths,
|
||||||
|
onSelectionChange,
|
||||||
|
foldersOnly = false,
|
||||||
|
selectableFolders = false,
|
||||||
|
onFolderSelect,
|
||||||
|
selectedFolder,
|
||||||
|
onFileSelect,
|
||||||
|
selectedFile,
|
||||||
|
showSelectedPathFooter = false,
|
||||||
|
selectedPath,
|
||||||
|
selectedPathLabel = "Selected path:",
|
||||||
|
isLoading,
|
||||||
|
isEmpty,
|
||||||
|
errorMessage,
|
||||||
|
fileArray,
|
||||||
|
expandedFolders,
|
||||||
|
loadingFolders,
|
||||||
|
onFolderExpand,
|
||||||
|
onFolderHover,
|
||||||
|
onLoadMore,
|
||||||
|
getFolderPagination,
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
const resolvedSelectedPath = selectedPath ?? selectedFolder;
|
||||||
|
const resolvedEmptyIcon =
|
||||||
|
emptyIcon === undefined ? <FolderOpen className="mb-2 h-12 w-12 text-muted-foreground" /> : emptyIcon;
|
||||||
|
|
||||||
|
let body: ReactNode;
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
body = (
|
||||||
|
<div className={cn("flex min-h-50 flex-col items-center justify-center p-6 text-center", stateClassName)}>
|
||||||
|
<p className="text-muted-foreground">{loadingMessage}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} else if (errorMessage) {
|
||||||
|
body = (
|
||||||
|
<div className={cn("flex min-h-50 flex-col items-center justify-center p-6 text-center", stateClassName)}>
|
||||||
|
<p className="text-destructive">{errorMessage}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} else if (isEmpty) {
|
||||||
|
body = (
|
||||||
|
<div className={cn("flex min-h-50 flex-col items-center justify-center p-6 text-center", stateClassName)}>
|
||||||
|
{resolvedEmptyIcon}
|
||||||
|
<p className="text-muted-foreground">{emptyMessage}</p>
|
||||||
|
{emptyDescription && <p className="mt-2 text-sm text-muted-foreground">{emptyDescription}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
body = (
|
||||||
|
<FileTree
|
||||||
|
files={fileArray}
|
||||||
|
onFolderExpand={onFolderExpand}
|
||||||
|
onFolderHover={onFolderHover}
|
||||||
|
onLoadMore={onLoadMore}
|
||||||
|
getFolderPagination={getFolderPagination}
|
||||||
|
expandedFolders={expandedFolders}
|
||||||
|
loadingFolders={loadingFolders}
|
||||||
|
className={treeClassName}
|
||||||
|
withCheckboxes={withCheckboxes}
|
||||||
|
selectedPaths={selectedPaths}
|
||||||
|
onSelectionChange={onSelectionChange}
|
||||||
|
foldersOnly={foldersOnly}
|
||||||
|
selectableFolders={selectableFolders}
|
||||||
|
onFolderSelect={onFolderSelect}
|
||||||
|
selectedFolder={selectedFolder}
|
||||||
|
onFileSelect={onFileSelect}
|
||||||
|
selectedFile={selectedFile}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const bodyWithScroll = useScrollArea ? <ScrollArea className={scrollAreaClassName}>{body}</ScrollArea> : body;
|
||||||
|
const wrappedBody = treeContainerClassName ? (
|
||||||
|
<div className={treeContainerClassName}>{bodyWithScroll}</div>
|
||||||
|
) : (
|
||||||
|
bodyWithScroll
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={className}>
|
||||||
|
{wrappedBody}
|
||||||
|
{showSelectedPathFooter && resolvedSelectedPath && (
|
||||||
|
<div className="bg-muted/50 border-t p-2 text-sm">
|
||||||
|
<div className="font-medium text-muted-foreground">{selectedPathLabel}</div>
|
||||||
|
<div className="font-mono text-xs break-all">{resolvedSelectedPath}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
63
app/client/components/file-browsers/local-file-browser.tsx
Normal file
63
app/client/components/file-browsers/local-file-browser.tsx
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { browseFilesystemOptions } from "~/client/api-client/@tanstack/react-query.gen";
|
||||||
|
import { FileBrowser, type FileBrowserUiProps } from "~/client/components/file-browsers/file-browser";
|
||||||
|
import { useFileBrowser } from "~/client/hooks/use-file-browser";
|
||||||
|
import { parseError } from "~/client/lib/errors";
|
||||||
|
|
||||||
|
const normalizeAbsolutePath = (path?: string): string => {
|
||||||
|
if (!path) return "/";
|
||||||
|
const withLeadingSlash = path.startsWith("/") ? path : `/${path}`;
|
||||||
|
const trimmed = withLeadingSlash.replace(/\/+$/, "");
|
||||||
|
return trimmed || "/";
|
||||||
|
};
|
||||||
|
|
||||||
|
type LocalFileBrowserProps = FileBrowserUiProps & {
|
||||||
|
initialPath?: string;
|
||||||
|
enabled?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const LocalFileBrowser = ({ initialPath = "/", enabled = true, ...uiProps }: LocalFileBrowserProps) => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const normalizedInitialPath = normalizeAbsolutePath(initialPath);
|
||||||
|
|
||||||
|
const { data, isLoading, error } = useQuery({
|
||||||
|
...browseFilesystemOptions({ query: { path: normalizedInitialPath } }),
|
||||||
|
enabled,
|
||||||
|
});
|
||||||
|
|
||||||
|
const fileBrowser = useFileBrowser({
|
||||||
|
initialData: data,
|
||||||
|
isLoading,
|
||||||
|
fetchFolder: async (path) => {
|
||||||
|
return await queryClient.ensureQueryData(browseFilesystemOptions({ query: { path } }));
|
||||||
|
},
|
||||||
|
prefetchFolder: (path) => {
|
||||||
|
void queryClient.prefetchQuery(browseFilesystemOptions({ query: { path } }));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const errorDetails = parseError(error)?.message;
|
||||||
|
const errorMessage = errorDetails
|
||||||
|
? `Failed to load directories: ${errorDetails}`
|
||||||
|
: error
|
||||||
|
? "Failed to load directories"
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FileBrowser
|
||||||
|
{...uiProps}
|
||||||
|
fileArray={fileBrowser.fileArray}
|
||||||
|
expandedFolders={fileBrowser.expandedFolders}
|
||||||
|
loadingFolders={fileBrowser.loadingFolders}
|
||||||
|
onFolderExpand={fileBrowser.handleFolderExpand}
|
||||||
|
onFolderHover={fileBrowser.handleFolderHover}
|
||||||
|
onLoadMore={fileBrowser.handleLoadMore}
|
||||||
|
getFolderPagination={fileBrowser.getFolderPagination}
|
||||||
|
isLoading={fileBrowser.isLoading}
|
||||||
|
isEmpty={fileBrowser.isEmpty}
|
||||||
|
errorMessage={errorMessage}
|
||||||
|
loadingMessage={uiProps.loadingMessage ?? "Loading directories..."}
|
||||||
|
emptyMessage={uiProps.emptyMessage ?? "No subdirectories found"}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
119
app/client/components/file-browsers/snapshot-tree-browser.tsx
Normal file
119
app/client/components/file-browsers/snapshot-tree-browser.tsx
Normal file
|
|
@ -0,0 +1,119 @@
|
||||||
|
import { useCallback } from "react";
|
||||||
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { listSnapshotFilesOptions } from "~/client/api-client/@tanstack/react-query.gen";
|
||||||
|
import { FileBrowser, type FileBrowserUiProps } from "~/client/components/file-browsers/file-browser";
|
||||||
|
import { useFileBrowser } from "~/client/hooks/use-file-browser";
|
||||||
|
import { parseError } from "~/client/lib/errors";
|
||||||
|
|
||||||
|
const normalizeAbsolutePath = (path?: string): string => {
|
||||||
|
if (!path) return "/";
|
||||||
|
const withLeadingSlash = path.startsWith("/") ? path : `/${path}`;
|
||||||
|
const trimmed = withLeadingSlash.replace(/\/+$/, "");
|
||||||
|
return trimmed || "/";
|
||||||
|
};
|
||||||
|
|
||||||
|
type SnapshotTreeBrowserProps = FileBrowserUiProps & {
|
||||||
|
repositoryId: string;
|
||||||
|
snapshotId: string;
|
||||||
|
basePath?: string;
|
||||||
|
pageSize?: number;
|
||||||
|
enabled?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SnapshotTreeBrowser = ({
|
||||||
|
repositoryId,
|
||||||
|
snapshotId,
|
||||||
|
basePath = "/",
|
||||||
|
pageSize = 500,
|
||||||
|
enabled = true,
|
||||||
|
...uiProps
|
||||||
|
}: SnapshotTreeBrowserProps) => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const normalizedBasePath = normalizeAbsolutePath(basePath);
|
||||||
|
|
||||||
|
const { data, isLoading, error } = useQuery({
|
||||||
|
...listSnapshotFilesOptions({
|
||||||
|
path: { id: repositoryId, snapshotId },
|
||||||
|
query: { path: normalizedBasePath },
|
||||||
|
}),
|
||||||
|
enabled,
|
||||||
|
});
|
||||||
|
|
||||||
|
const stripBasePath = useCallback(
|
||||||
|
(path: string): string => {
|
||||||
|
if (normalizedBasePath === "/") return path;
|
||||||
|
if (path === normalizedBasePath) return "/";
|
||||||
|
if (path.startsWith(`${normalizedBasePath}/`)) {
|
||||||
|
return path.slice(normalizedBasePath.length);
|
||||||
|
}
|
||||||
|
return path;
|
||||||
|
},
|
||||||
|
[normalizedBasePath],
|
||||||
|
);
|
||||||
|
|
||||||
|
const addBasePath = useCallback(
|
||||||
|
(displayPath: string): string => {
|
||||||
|
if (normalizedBasePath === "/") return displayPath;
|
||||||
|
if (displayPath === "/") return normalizedBasePath;
|
||||||
|
return `${normalizedBasePath}${displayPath}`;
|
||||||
|
},
|
||||||
|
[normalizedBasePath],
|
||||||
|
);
|
||||||
|
|
||||||
|
const fileBrowser = useFileBrowser({
|
||||||
|
initialData: data,
|
||||||
|
isLoading,
|
||||||
|
fetchFolder: async (path, offset = 0) => {
|
||||||
|
return await queryClient.ensureQueryData(
|
||||||
|
listSnapshotFilesOptions({
|
||||||
|
path: { id: repositoryId, snapshotId },
|
||||||
|
query: {
|
||||||
|
path,
|
||||||
|
offset: offset.toString(),
|
||||||
|
limit: pageSize.toString(),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
prefetchFolder: (path) => {
|
||||||
|
void queryClient.prefetchQuery(
|
||||||
|
listSnapshotFilesOptions({
|
||||||
|
path: { id: repositoryId, snapshotId },
|
||||||
|
query: {
|
||||||
|
path,
|
||||||
|
offset: "0",
|
||||||
|
limit: pageSize.toString(),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
pathTransform: {
|
||||||
|
strip: stripBasePath,
|
||||||
|
add: addBasePath,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const errorDetails = parseError(error)?.message;
|
||||||
|
const errorMessage = errorDetails
|
||||||
|
? `Failed to load files: ${errorDetails}`
|
||||||
|
: error
|
||||||
|
? "Failed to load files"
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FileBrowser
|
||||||
|
{...uiProps}
|
||||||
|
fileArray={fileBrowser.fileArray}
|
||||||
|
expandedFolders={fileBrowser.expandedFolders}
|
||||||
|
loadingFolders={fileBrowser.loadingFolders}
|
||||||
|
onFolderExpand={fileBrowser.handleFolderExpand}
|
||||||
|
onFolderHover={fileBrowser.handleFolderHover}
|
||||||
|
onLoadMore={fileBrowser.handleLoadMore}
|
||||||
|
getFolderPagination={fileBrowser.getFolderPagination}
|
||||||
|
isLoading={fileBrowser.isLoading}
|
||||||
|
isEmpty={fileBrowser.isEmpty}
|
||||||
|
errorMessage={errorMessage}
|
||||||
|
loadingMessage={uiProps.loadingMessage ?? "Loading files..."}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
65
app/client/components/file-browsers/volume-file-browser.tsx
Normal file
65
app/client/components/file-browsers/volume-file-browser.tsx
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { listFilesOptions } from "~/client/api-client/@tanstack/react-query.gen";
|
||||||
|
import { FileBrowser, type FileBrowserUiProps } from "~/client/components/file-browsers/file-browser";
|
||||||
|
import { useFileBrowser, type FetchFolderResult } from "~/client/hooks/use-file-browser";
|
||||||
|
import { parseError } from "~/client/lib/errors";
|
||||||
|
|
||||||
|
type VolumeFileBrowserProps = FileBrowserUiProps & {
|
||||||
|
volumeId: string;
|
||||||
|
enabled?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const VolumeFileBrowser = ({ volumeId, enabled = true, ...uiProps }: VolumeFileBrowserProps) => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const { data, isLoading, error } = useQuery({
|
||||||
|
...listFilesOptions({ path: { id: volumeId } }),
|
||||||
|
enabled,
|
||||||
|
});
|
||||||
|
|
||||||
|
const fileBrowser = useFileBrowser({
|
||||||
|
initialData: data,
|
||||||
|
isLoading,
|
||||||
|
fetchFolder: async (path, offset): Promise<FetchFolderResult> => {
|
||||||
|
return await queryClient.ensureQueryData(
|
||||||
|
listFilesOptions({
|
||||||
|
path: { id: volumeId },
|
||||||
|
query: { path, offset: offset?.toString() },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
prefetchFolder: (path) => {
|
||||||
|
void queryClient.prefetchQuery(
|
||||||
|
listFilesOptions({
|
||||||
|
path: { id: volumeId },
|
||||||
|
query: { path },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const errorDetails = parseError(error)?.message;
|
||||||
|
const errorMessage = errorDetails
|
||||||
|
? `Failed to load files: ${errorDetails}`
|
||||||
|
: error
|
||||||
|
? "Failed to load files"
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FileBrowser
|
||||||
|
{...uiProps}
|
||||||
|
fileArray={fileBrowser.fileArray}
|
||||||
|
expandedFolders={fileBrowser.expandedFolders}
|
||||||
|
loadingFolders={fileBrowser.loadingFolders}
|
||||||
|
onFolderExpand={fileBrowser.handleFolderExpand}
|
||||||
|
onFolderHover={fileBrowser.handleFolderHover}
|
||||||
|
onLoadMore={fileBrowser.handleLoadMore}
|
||||||
|
getFolderPagination={fileBrowser.getFolderPagination}
|
||||||
|
isLoading={fileBrowser.isLoading}
|
||||||
|
isEmpty={fileBrowser.isEmpty}
|
||||||
|
errorMessage={errorMessage}
|
||||||
|
loadingMessage={uiProps.loadingMessage ?? "Loading files..."}
|
||||||
|
emptyMessage={uiProps.emptyMessage ?? "This volume appears to be empty."}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { DirectoryBrowser } from "./directory-browser";
|
import { LocalFileBrowser } from "./file-browsers/local-file-browser";
|
||||||
import { Button } from "./ui/button";
|
import { Button } from "./ui/button";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
|
|
@ -14,12 +14,21 @@ export const PathSelector = ({ value, onChange }: Props) => {
|
||||||
if (showBrowser) {
|
if (showBrowser) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<DirectoryBrowser
|
<LocalFileBrowser
|
||||||
onSelectPath={(path) => {
|
className="border rounded-lg overflow-hidden"
|
||||||
|
useScrollArea
|
||||||
|
scrollAreaClassName="h-64"
|
||||||
|
foldersOnly
|
||||||
|
selectableFolders
|
||||||
|
selectedFolder={value}
|
||||||
|
onFolderSelect={(path) => {
|
||||||
onChange(path);
|
onChange(path);
|
||||||
setShowBrowser(false);
|
setShowBrowser(false);
|
||||||
}}
|
}}
|
||||||
|
showSelectedPathFooter
|
||||||
selectedPath={value}
|
selectedPath={value}
|
||||||
|
loadingMessage="Loading directories..."
|
||||||
|
emptyMessage="No subdirectories found"
|
||||||
/>
|
/>
|
||||||
<Button type="button" variant="ghost" size="sm" onClick={() => setShowBrowser(false)}>
|
<Button type="button" variant="ghost" size="sm" onClick={() => setShowBrowser(false)}>
|
||||||
Cancel
|
Cancel
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation } from "@tanstack/react-query";
|
||||||
import { ChevronDown, FileIcon, FolderOpen, RotateCcw } from "lucide-react";
|
import { ChevronDown, FolderOpen, RotateCcw } from "lucide-react";
|
||||||
import { Button } from "~/client/components/ui/button";
|
import { Button } from "~/client/components/ui/button";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card";
|
||||||
import { Input } from "~/client/components/ui/input";
|
import { Input } from "~/client/components/ui/input";
|
||||||
|
|
@ -16,15 +16,15 @@ import {
|
||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
} from "~/client/components/ui/alert-dialog";
|
} from "~/client/components/ui/alert-dialog";
|
||||||
import { PathSelector } from "~/client/components/path-selector";
|
import { PathSelector } from "~/client/components/path-selector";
|
||||||
import { FileTree } from "~/client/components/file-tree";
|
import { SnapshotTreeBrowser } from "~/client/components/file-browsers/snapshot-tree-browser";
|
||||||
import { RestoreProgress } from "~/client/components/restore-progress";
|
import { RestoreProgress } from "~/client/components/restore-progress";
|
||||||
import { listSnapshotFilesOptions, restoreSnapshotMutation } from "~/client/api-client/@tanstack/react-query.gen";
|
import { restoreSnapshotMutation } from "~/client/api-client/@tanstack/react-query.gen";
|
||||||
import { useFileBrowser } from "~/client/hooks/use-file-browser";
|
|
||||||
import { type RestoreCompletedEvent, useServerEvents } from "~/client/hooks/use-server-events";
|
import { type RestoreCompletedEvent, useServerEvents } from "~/client/hooks/use-server-events";
|
||||||
import { OVERWRITE_MODES, type OverwriteMode } from "~/schemas/restic";
|
import { OVERWRITE_MODES, type OverwriteMode } from "~/schemas/restic";
|
||||||
import type { Repository, Snapshot } from "~/client/lib/types";
|
import type { Repository, Snapshot } from "~/client/lib/types";
|
||||||
import { handleRepositoryError } from "~/client/lib/errors";
|
import { handleRepositoryError } from "~/client/lib/errors";
|
||||||
import { useNavigate } from "@tanstack/react-router";
|
import { useNavigate } from "@tanstack/react-router";
|
||||||
|
import { findCommonAncestor } from "~/utils/common-ancestor";
|
||||||
|
|
||||||
type RestoreLocation = "original" | "custom";
|
type RestoreLocation = "original" | "custom";
|
||||||
|
|
||||||
|
|
@ -38,9 +38,8 @@ interface RestoreFormProps {
|
||||||
export function RestoreForm({ snapshot, repository, snapshotId, returnPath }: RestoreFormProps) {
|
export function RestoreForm({ snapshot, repository, snapshotId, returnPath }: RestoreFormProps) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { addEventListener } = useServerEvents();
|
const { addEventListener } = useServerEvents();
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
const volumeBasePath = snapshot.paths[0]?.match(/^(.*?_data)(\/|$)/)?.[1] || "/";
|
const volumeBasePath = findCommonAncestor(snapshot.paths);
|
||||||
|
|
||||||
const [restoreLocation, setRestoreLocation] = useState<RestoreLocation>("original");
|
const [restoreLocation, setRestoreLocation] = useState<RestoreLocation>("original");
|
||||||
const [customTargetPath, setCustomTargetPath] = useState("");
|
const [customTargetPath, setCustomTargetPath] = useState("");
|
||||||
|
|
@ -89,26 +88,6 @@ export function RestoreForm({ snapshot, repository, snapshotId, returnPath }: Re
|
||||||
};
|
};
|
||||||
}, [addEventListener, repository.id, snapshotId]);
|
}, [addEventListener, repository.id, snapshotId]);
|
||||||
|
|
||||||
const { data: filesData, isLoading: filesLoading } = useQuery({
|
|
||||||
...listSnapshotFilesOptions({
|
|
||||||
path: { id: repository.id, snapshotId },
|
|
||||||
query: { path: volumeBasePath },
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
const stripBasePath = useCallback(
|
|
||||||
(path: string): string => {
|
|
||||||
if (!volumeBasePath) return path;
|
|
||||||
if (path === volumeBasePath) return "/";
|
|
||||||
if (path.startsWith(`${volumeBasePath}/`)) {
|
|
||||||
const stripped = path.slice(volumeBasePath.length);
|
|
||||||
return stripped;
|
|
||||||
}
|
|
||||||
return path;
|
|
||||||
},
|
|
||||||
[volumeBasePath],
|
|
||||||
);
|
|
||||||
|
|
||||||
const addBasePath = useCallback(
|
const addBasePath = useCallback(
|
||||||
(displayPath: string): string => {
|
(displayPath: string): string => {
|
||||||
const vbp = volumeBasePath === "/" ? "" : volumeBasePath;
|
const vbp = volumeBasePath === "/" ? "" : volumeBasePath;
|
||||||
|
|
@ -120,31 +99,6 @@ export function RestoreForm({ snapshot, repository, snapshotId, returnPath }: Re
|
||||||
[volumeBasePath],
|
[volumeBasePath],
|
||||||
);
|
);
|
||||||
|
|
||||||
const fileBrowser = useFileBrowser({
|
|
||||||
initialData: filesData,
|
|
||||||
isLoading: filesLoading,
|
|
||||||
fetchFolder: async (path, offset = 0) => {
|
|
||||||
return await queryClient.ensureQueryData(
|
|
||||||
listSnapshotFilesOptions({
|
|
||||||
path: { id: repository.id, snapshotId },
|
|
||||||
query: { path, offset: offset.toString(), limit: "500" },
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
prefetchFolder: (path) => {
|
|
||||||
void queryClient.prefetchQuery(
|
|
||||||
listSnapshotFilesOptions({
|
|
||||||
path: { id: repository.id, snapshotId },
|
|
||||||
query: { path, offset: "0", limit: "500" },
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
pathTransform: {
|
|
||||||
strip: stripBasePath,
|
|
||||||
add: addBasePath,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const { mutate: restoreSnapshot, isPending: isRestoring } = useMutation({
|
const { mutate: restoreSnapshot, isPending: isRestoring } = useMutation({
|
||||||
...restoreSnapshotMutation(),
|
...restoreSnapshotMutation(),
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
|
|
@ -339,36 +293,21 @@ export function RestoreForm({ snapshot, repository, snapshotId, returnPath }: Re
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex-1 overflow-hidden flex flex-col p-0">
|
<CardContent className="flex-1 overflow-hidden flex flex-col p-0">
|
||||||
{fileBrowser.isLoading && (
|
<SnapshotTreeBrowser
|
||||||
<div className="flex items-center justify-center flex-1">
|
repositoryId={repository.id}
|
||||||
<p className="text-muted-foreground">Loading files...</p>
|
snapshotId={snapshotId}
|
||||||
</div>
|
basePath={volumeBasePath}
|
||||||
)}
|
pageSize={500}
|
||||||
|
className="flex flex-1 min-h-0 flex-col"
|
||||||
{fileBrowser.isEmpty && (
|
treeContainerClassName="overflow-auto flex-1 min-h-0 border border-border rounded-md bg-card m-4"
|
||||||
<div className="flex flex-col items-center justify-center flex-1 text-center p-8">
|
treeClassName="px-2 py-2"
|
||||||
<FileIcon className="w-12 h-12 text-muted-foreground/50 mb-4" />
|
loadingMessage="Loading files..."
|
||||||
<p className="text-muted-foreground">No files in this snapshot</p>
|
emptyMessage="No files in this snapshot"
|
||||||
</div>
|
withCheckboxes
|
||||||
)}
|
|
||||||
|
|
||||||
{!fileBrowser.isLoading && !fileBrowser.isEmpty && (
|
|
||||||
<div className="overflow-auto flex-1 border border-border rounded-md bg-card m-4">
|
|
||||||
<FileTree
|
|
||||||
files={fileBrowser.fileArray}
|
|
||||||
onFolderExpand={fileBrowser.handleFolderExpand}
|
|
||||||
onFolderHover={fileBrowser.handleFolderHover}
|
|
||||||
expandedFolders={fileBrowser.expandedFolders}
|
|
||||||
loadingFolders={fileBrowser.loadingFolders}
|
|
||||||
onLoadMore={fileBrowser.handleLoadMore}
|
|
||||||
getFolderPagination={fileBrowser.getFolderPagination}
|
|
||||||
className="px-2 py-2"
|
|
||||||
withCheckboxes={true}
|
|
||||||
selectedPaths={selectedPaths}
|
selectedPaths={selectedPaths}
|
||||||
onSelectionChange={setSelectedPaths}
|
onSelectionChange={setSelectedPaths}
|
||||||
|
stateClassName="flex-1 min-h-0"
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,102 +0,0 @@
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
||||||
import { FolderOpen } from "lucide-react";
|
|
||||||
import { FileTree } from "~/client/components/file-tree";
|
|
||||||
import { listFilesOptions } from "../api-client/@tanstack/react-query.gen";
|
|
||||||
import { useFileBrowser, type FetchFolderResult } from "../hooks/use-file-browser";
|
|
||||||
import { parseError } from "../lib/errors";
|
|
||||||
|
|
||||||
type VolumeFileBrowserProps = {
|
|
||||||
volumeId: string;
|
|
||||||
enabled?: boolean;
|
|
||||||
withCheckboxes?: boolean;
|
|
||||||
selectedPaths?: Set<string>;
|
|
||||||
onSelectionChange?: (paths: Set<string>) => void;
|
|
||||||
foldersOnly?: boolean;
|
|
||||||
className?: string;
|
|
||||||
emptyMessage?: string;
|
|
||||||
emptyDescription?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const VolumeFileBrowser = ({
|
|
||||||
volumeId,
|
|
||||||
enabled = true,
|
|
||||||
withCheckboxes = false,
|
|
||||||
selectedPaths,
|
|
||||||
onSelectionChange,
|
|
||||||
foldersOnly = false,
|
|
||||||
className,
|
|
||||||
emptyMessage = "This volume appears to be empty.",
|
|
||||||
emptyDescription,
|
|
||||||
}: VolumeFileBrowserProps) => {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
const { data, isLoading, error } = useQuery({
|
|
||||||
...listFilesOptions({ path: { id: volumeId } }),
|
|
||||||
enabled,
|
|
||||||
});
|
|
||||||
|
|
||||||
const fileBrowser = useFileBrowser({
|
|
||||||
initialData: data,
|
|
||||||
isLoading,
|
|
||||||
fetchFolder: async (path, offset): Promise<FetchFolderResult> => {
|
|
||||||
return await queryClient.ensureQueryData(
|
|
||||||
listFilesOptions({
|
|
||||||
path: { id: volumeId },
|
|
||||||
query: { path, offset: offset?.toString() },
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
prefetchFolder: (path) => {
|
|
||||||
void queryClient.prefetchQuery(
|
|
||||||
listFilesOptions({
|
|
||||||
path: { id: volumeId },
|
|
||||||
query: { path },
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (fileBrowser.isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-center h-full min-h-50">
|
|
||||||
<p className="text-muted-foreground">Loading files...</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-center h-full min-h-50">
|
|
||||||
<p className="text-destructive">Failed to load files: {parseError(error)?.message}</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (fileBrowser.isEmpty) {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col items-center justify-center h-full text-center p-8 min-h-50">
|
|
||||||
<FolderOpen className="mb-4 h-12 w-12 text-muted-foreground" />
|
|
||||||
<p className="text-muted-foreground">{emptyMessage}</p>
|
|
||||||
{emptyDescription && <p className="text-sm text-muted-foreground mt-2">{emptyDescription}</p>}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={className}>
|
|
||||||
<FileTree
|
|
||||||
files={fileBrowser.fileArray}
|
|
||||||
onFolderExpand={fileBrowser.handleFolderExpand}
|
|
||||||
onFolderHover={fileBrowser.handleFolderHover}
|
|
||||||
onLoadMore={fileBrowser.handleLoadMore}
|
|
||||||
getFolderPagination={fileBrowser.getFolderPagination}
|
|
||||||
expandedFolders={fileBrowser.expandedFolders}
|
|
||||||
loadingFolders={fileBrowser.loadingFolders}
|
|
||||||
withCheckboxes={withCheckboxes}
|
|
||||||
selectedPaths={selectedPaths}
|
|
||||||
onSelectionChange={onSelectionChange}
|
|
||||||
foldersOnly={foldersOnly}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -226,7 +226,7 @@ export const useFileBrowser = (props: UseFileBrowserOptions) => {
|
||||||
handleFolderHover,
|
handleFolderHover,
|
||||||
handleLoadMore,
|
handleLoadMore,
|
||||||
getFolderPagination,
|
getFolderPagination,
|
||||||
isLoading: isLoading && fileArray.length === 0,
|
isLoading: Boolean(isLoading) && fileArray.length === 0,
|
||||||
isEmpty: fileArray.length === 0 && !isLoading,
|
isEmpty: fileArray.length === 0 && !isLoading,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { X } from "lucide-react";
|
import { X } from "lucide-react";
|
||||||
import { VolumeFileBrowser } from "~/client/components/volume-file-browser";
|
import { VolumeFileBrowser } from "~/client/components/file-browsers/volume-file-browser";
|
||||||
import { FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "~/client/components/ui/form";
|
import { FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "~/client/components/ui/form";
|
||||||
import { Textarea } from "~/client/components/ui/textarea";
|
import { Textarea } from "~/client/components/ui/textarea";
|
||||||
import type { Volume } from "~/client/lib/types";
|
import type { Volume } from "~/client/lib/types";
|
||||||
|
|
@ -32,9 +32,9 @@ export const PathsSection = ({
|
||||||
volumeId={volume.shortId}
|
volumeId={volume.shortId}
|
||||||
selectedPaths={selectedPaths}
|
selectedPaths={selectedPaths}
|
||||||
onSelectionChange={onSelectionChange}
|
onSelectionChange={onSelectionChange}
|
||||||
withCheckboxes={true}
|
withCheckboxes
|
||||||
foldersOnly={false}
|
|
||||||
className="relative border rounded-md bg-card p-2 h-100 overflow-y-auto"
|
className="relative border rounded-md bg-card p-2 h-100 overflow-y-auto"
|
||||||
|
emptyMessage="This volume appears to be empty."
|
||||||
/>
|
/>
|
||||||
{selectedPaths.size > 0 && (
|
{selectedPaths.size > 0 && (
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,12 @@
|
||||||
import { useCallback } from "react";
|
import { RotateCcw, Trash2 } from "lucide-react";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
||||||
import { FileIcon, RotateCcw, Trash2 } from "lucide-react";
|
|
||||||
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, buttonVariants } from "~/client/components/ui/button";
|
import { Button, buttonVariants } from "~/client/components/ui/button";
|
||||||
import type { Snapshot } from "~/client/lib/types";
|
import type { Snapshot } from "~/client/lib/types";
|
||||||
import { listSnapshotFilesOptions } from "~/client/api-client/@tanstack/react-query.gen";
|
|
||||||
import { formatDateTime } from "~/client/lib/datetime";
|
import { formatDateTime } from "~/client/lib/datetime";
|
||||||
import { useFileBrowser } from "~/client/hooks/use-file-browser";
|
|
||||||
import { cn } from "~/client/lib/utils";
|
import { cn } from "~/client/lib/utils";
|
||||||
import { Link } from "@tanstack/react-router";
|
import { Link } from "@tanstack/react-router";
|
||||||
|
import { SnapshotTreeBrowser } from "~/client/components/file-browsers/snapshot-tree-browser";
|
||||||
|
import { findCommonAncestor } from "~/utils/common-ancestor";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
snapshot: Snapshot;
|
snapshot: Snapshot;
|
||||||
|
|
@ -22,65 +19,7 @@ interface Props {
|
||||||
export const SnapshotFileBrowser = (props: Props) => {
|
export const SnapshotFileBrowser = (props: Props) => {
|
||||||
const { snapshot, repositoryId, backupId, onDeleteSnapshot, isDeletingSnapshot } = props;
|
const { snapshot, repositoryId, backupId, onDeleteSnapshot, isDeletingSnapshot } = props;
|
||||||
|
|
||||||
const queryClient = useQueryClient();
|
const volumeBasePath = findCommonAncestor(snapshot.paths);
|
||||||
|
|
||||||
const volumeBasePath = snapshot.paths[0]?.match(/^(.*?_data)(\/|$)/)?.[1] || "/";
|
|
||||||
|
|
||||||
const { data: filesData, isLoading: filesLoading } = useQuery({
|
|
||||||
...listSnapshotFilesOptions({
|
|
||||||
path: { id: repositoryId, snapshotId: snapshot.short_id },
|
|
||||||
query: { path: volumeBasePath },
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
const stripBasePath = useCallback(
|
|
||||||
(path: string): string => {
|
|
||||||
if (!volumeBasePath) return path;
|
|
||||||
if (path === volumeBasePath) return "/";
|
|
||||||
if (path.startsWith(`${volumeBasePath}/`)) {
|
|
||||||
const stripped = path.slice(volumeBasePath.length);
|
|
||||||
return stripped;
|
|
||||||
}
|
|
||||||
return path;
|
|
||||||
},
|
|
||||||
[volumeBasePath],
|
|
||||||
);
|
|
||||||
|
|
||||||
const addBasePath = useCallback(
|
|
||||||
(displayPath: string): string => {
|
|
||||||
const vbp = volumeBasePath === "/" ? "" : volumeBasePath;
|
|
||||||
|
|
||||||
if (!vbp) return displayPath;
|
|
||||||
if (displayPath === "/") return vbp;
|
|
||||||
return `${vbp}${displayPath}`;
|
|
||||||
},
|
|
||||||
[volumeBasePath],
|
|
||||||
);
|
|
||||||
|
|
||||||
const fileBrowser = useFileBrowser({
|
|
||||||
initialData: filesData,
|
|
||||||
isLoading: filesLoading,
|
|
||||||
fetchFolder: async (path, offset = 0) => {
|
|
||||||
return await queryClient.ensureQueryData(
|
|
||||||
listSnapshotFilesOptions({
|
|
||||||
path: { id: repositoryId, snapshotId: snapshot.short_id },
|
|
||||||
query: { path, offset: offset.toString(), limit: "500" },
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
prefetchFolder: (path) => {
|
|
||||||
void queryClient.prefetchQuery(
|
|
||||||
listSnapshotFilesOptions({
|
|
||||||
path: { id: repositoryId, snapshotId: snapshot.short_id },
|
|
||||||
query: { path, offset: "0", limit: "500" },
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
pathTransform: {
|
|
||||||
strip: stripBasePath,
|
|
||||||
add: addBasePath,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
|
@ -126,33 +65,18 @@ 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">
|
||||||
{fileBrowser.isLoading && (
|
<SnapshotTreeBrowser
|
||||||
<div className="flex items-center justify-center flex-1">
|
repositoryId={repositoryId}
|
||||||
<p className="text-muted-foreground">Loading files...</p>
|
snapshotId={snapshot.short_id}
|
||||||
</div>
|
basePath={volumeBasePath}
|
||||||
)}
|
pageSize={500}
|
||||||
|
className="flex flex-1 min-h-0 flex-col"
|
||||||
{fileBrowser.isEmpty && (
|
treeContainerClassName="overflow-auto flex-1 min-h-0 border border-border rounded-md bg-card m-4"
|
||||||
<div className="flex flex-col items-center justify-center flex-1 text-center p-8">
|
treeClassName="px-2 py-2"
|
||||||
<FileIcon className="w-12 h-12 text-muted-foreground/50 mb-4" />
|
loadingMessage="Loading files..."
|
||||||
<p className="text-muted-foreground">No files in this snapshot</p>
|
emptyMessage="No files in this snapshot"
|
||||||
</div>
|
stateClassName="flex-1 min-h-0"
|
||||||
)}
|
|
||||||
|
|
||||||
{!fileBrowser.isLoading && !fileBrowser.isEmpty && (
|
|
||||||
<div className="overflow-auto flex-1 border border-border rounded-md bg-card m-4">
|
|
||||||
<FileTree
|
|
||||||
files={fileBrowser.fileArray}
|
|
||||||
onFolderExpand={fileBrowser.handleFolderExpand}
|
|
||||||
onFolderHover={fileBrowser.handleFolderHover}
|
|
||||||
expandedFolders={fileBrowser.expandedFolders}
|
|
||||||
loadingFolders={fileBrowser.loadingFolders}
|
|
||||||
onLoadMore={fileBrowser.handleLoadMore}
|
|
||||||
getFolderPagination={fileBrowser.getFolderPagination}
|
|
||||||
className="px-2 py-2"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import type { UseFormReturn } from "react-hook-form";
|
||||||
import { Check, Pencil, X, AlertTriangle } from "lucide-react";
|
import { Check, Pencil, X, AlertTriangle } from "lucide-react";
|
||||||
import { Button } from "../../../../components/ui/button";
|
import { Button } from "../../../../components/ui/button";
|
||||||
import { FormItem, FormLabel, FormDescription, FormField, FormControl } from "../../../../components/ui/form";
|
import { FormItem, FormLabel, FormDescription, FormField, FormControl } from "../../../../components/ui/form";
|
||||||
import { DirectoryBrowser } from "../../../../components/directory-browser";
|
import { LocalFileBrowser } from "../../../../components/file-browsers/local-file-browser";
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
AlertDialogAction,
|
AlertDialogAction,
|
||||||
|
|
@ -96,11 +96,20 @@ export const LocalRepositoryForm = ({ form }: Props) => {
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<div className="py-4">
|
<div className="py-4">
|
||||||
<DirectoryBrowser
|
<LocalFileBrowser
|
||||||
onSelectPath={(path) => {
|
className="border rounded-lg overflow-hidden"
|
||||||
|
useScrollArea
|
||||||
|
scrollAreaClassName="h-64"
|
||||||
|
foldersOnly
|
||||||
|
selectableFolders
|
||||||
|
onFolderSelect={(path) => {
|
||||||
field.onChange(path);
|
field.onChange(path);
|
||||||
}}
|
}}
|
||||||
|
selectedFolder={field.value || constants.REPOSITORY_BASE}
|
||||||
|
showSelectedPathFooter
|
||||||
selectedPath={field.value || constants.REPOSITORY_BASE}
|
selectedPath={field.value || constants.REPOSITORY_BASE}
|
||||||
|
loadingMessage="Loading directories..."
|
||||||
|
emptyMessage="No subdirectories found"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
|
|
|
||||||
|
|
@ -24,11 +24,7 @@ export const SnapshotError = () => {
|
||||||
<p className="text-sm text-muted-foreground mt-2">This snapshot does not exist in this repository</p>
|
<p className="text-sm text-muted-foreground mt-2">This snapshot does not exist in this repository</p>
|
||||||
<p className="text-sm text-muted-foreground mt-1">It may have been deleted manually outside of Zerobyte.</p>
|
<p className="text-sm text-muted-foreground mt-1">It may have been deleted manually outside of Zerobyte.</p>
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<Link
|
<Link to={`/repositories/$repositoryId`} search={() => ({ tab: "snapshots" })} params={{ repositoryId }}>
|
||||||
to={`/repositories/$repositoryId`}
|
|
||||||
search={() => ({ tab: "snapshots" })}
|
|
||||||
params={{ repositoryId }}
|
|
||||||
>
|
|
||||||
<Button variant="outline">Back to repository</Button>
|
<Button variant="outline">Back to repository</Button>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { Pencil } from "lucide-react";
|
import { Pencil } from "lucide-react";
|
||||||
import type { UseFormReturn } from "react-hook-form";
|
import type { UseFormReturn } from "react-hook-form";
|
||||||
import type { FormValues } from "../create-volume-form";
|
import type { FormValues } from "../create-volume-form";
|
||||||
import { DirectoryBrowser } from "../../../../components/directory-browser";
|
import { LocalFileBrowser } from "../../../../components/file-browsers/local-file-browser";
|
||||||
import { Button } from "../../../../components/ui/button";
|
import { Button } from "../../../../components/ui/button";
|
||||||
import {
|
import {
|
||||||
FormControl,
|
FormControl,
|
||||||
|
|
@ -38,7 +38,19 @@ export const DirectoryForm = ({ form }: Props) => {
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<DirectoryBrowser onSelectPath={(path) => field.onChange(path)} selectedPath={field.value} />
|
<LocalFileBrowser
|
||||||
|
className="border rounded-lg overflow-hidden"
|
||||||
|
useScrollArea
|
||||||
|
scrollAreaClassName="h-64"
|
||||||
|
foldersOnly
|
||||||
|
selectableFolders
|
||||||
|
onFolderSelect={(path) => field.onChange(path)}
|
||||||
|
selectedFolder={field.value}
|
||||||
|
showSelectedPathFooter
|
||||||
|
selectedPath={field.value}
|
||||||
|
loadingMessage="Loading directories..."
|
||||||
|
emptyMessage="No subdirectories found"
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription>Browse and select a directory on the host filesystem to track.</FormDescription>
|
<FormDescription>Browse and select a directory on the host filesystem to track.</FormDescription>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { FolderOpen } from "lucide-react";
|
import { FolderOpen } from "lucide-react";
|
||||||
import { VolumeFileBrowser } from "~/client/components/volume-file-browser";
|
import { VolumeFileBrowser } from "~/client/components/file-browsers/volume-file-browser";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card";
|
||||||
import type { Volume } from "~/client/lib/types";
|
import type { Volume } from "~/client/lib/types";
|
||||||
|
|
||||||
|
|
|
||||||
19
app/utils/common-ancestor.ts
Normal file
19
app/utils/common-ancestor.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
export const findCommonAncestor = (paths: string[]): string => {
|
||||||
|
if (paths.length === 0) return "/";
|
||||||
|
if (paths.length === 1) return paths[0];
|
||||||
|
|
||||||
|
const splitPaths = paths.map((path) => path.split("/").filter(Boolean));
|
||||||
|
const minLength = Math.min(...splitPaths.map((parts) => parts.length));
|
||||||
|
|
||||||
|
let commonParts: string[] = [];
|
||||||
|
for (let i = 0; i < minLength; i++) {
|
||||||
|
const partSet = new Set(splitPaths.map((parts) => parts[i]));
|
||||||
|
if (partSet.size === 1) {
|
||||||
|
commonParts.push(splitPaths[0][i]);
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "/" + commonParts.join("/");
|
||||||
|
};
|
||||||
Loading…
Reference in a new issue