ui: scroll to error (#541)
* refactor: scroll to first error when submitting a form * refactor: split file browsers into dedicated components with base * chore: pr feedbacks
This commit is contained in:
parent
505f60a8a1
commit
ca8248b2a0
22 changed files with 557 additions and 386 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"}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
147
app/client/components/file-browsers/snapshot-tree-browser.tsx
Normal file
147
app/client/components/file-browsers/snapshot-tree-browser.tsx
Normal file
|
|
@ -0,0 +1,147 @@
|
||||||
|
import { useCallback, useMemo } 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 { selectedPaths, onSelectionChange, ...fileBrowserUiProps } = uiProps;
|
||||||
|
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 displaySelectedPaths = useMemo(() => {
|
||||||
|
if (!selectedPaths) return undefined;
|
||||||
|
|
||||||
|
const displayPaths = new Set<string>();
|
||||||
|
for (const fullPath of selectedPaths) {
|
||||||
|
displayPaths.add(stripBasePath(fullPath));
|
||||||
|
}
|
||||||
|
|
||||||
|
return displayPaths;
|
||||||
|
}, [selectedPaths, stripBasePath]);
|
||||||
|
|
||||||
|
const handleSelectionChange = useCallback(
|
||||||
|
(nextDisplayPaths: Set<string>) => {
|
||||||
|
if (!onSelectionChange) return;
|
||||||
|
|
||||||
|
const nextFullPaths = new Set<string>();
|
||||||
|
for (const displayPath of nextDisplayPaths) {
|
||||||
|
nextFullPaths.add(addBasePath(displayPath));
|
||||||
|
}
|
||||||
|
|
||||||
|
onSelectionChange(nextFullPaths);
|
||||||
|
},
|
||||||
|
[onSelectionChange, addBasePath],
|
||||||
|
);
|
||||||
|
|
||||||
|
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
|
||||||
|
{...fileBrowserUiProps}
|
||||||
|
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={fileBrowserUiProps.loadingMessage ?? "Loading files..."}
|
||||||
|
selectedPaths={displaySelectedPaths}
|
||||||
|
onSelectionChange={onSelectionChange ? handleSelectionChange : undefined}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
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 { DirectoryBrowser } from "./file-browsers/directory-browser";
|
||||||
import { Button } from "./ui/button";
|
import { Button } from "./ui/button";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
|
|
@ -15,11 +15,11 @@ export const PathSelector = ({ value, onChange }: Props) => {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<DirectoryBrowser
|
<DirectoryBrowser
|
||||||
|
selectedPath={value}
|
||||||
onSelectPath={(path) => {
|
onSelectPath={(path) => {
|
||||||
onChange(path);
|
onChange(path);
|
||||||
setShowBrowser(false);
|
setShowBrowser(false);
|
||||||
}}
|
}}
|
||||||
selectedPath={value}
|
|
||||||
/>
|
/>
|
||||||
<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,62 +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(
|
|
||||||
(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: 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) => {
|
||||||
|
|
@ -163,8 +106,7 @@ export function RestoreForm({ snapshot, repository, snapshotId, returnPath }: Re
|
||||||
const isCustomLocation = restoreLocation === "custom";
|
const isCustomLocation = restoreLocation === "custom";
|
||||||
const targetPath = isCustomLocation && customTargetPath.trim() ? customTargetPath.trim() : undefined;
|
const targetPath = isCustomLocation && customTargetPath.trim() ? customTargetPath.trim() : undefined;
|
||||||
|
|
||||||
const pathsArray = Array.from(selectedPaths);
|
const includePaths = Array.from(selectedPaths);
|
||||||
const includePaths = pathsArray.map((path) => addBasePath(path));
|
|
||||||
|
|
||||||
restoreCompletedRef.current = false;
|
restoreCompletedRef.current = false;
|
||||||
setIsRestoreActive(true);
|
setIsRestoreActive(true);
|
||||||
|
|
@ -188,7 +130,6 @@ export function RestoreForm({ snapshot, repository, snapshotId, returnPath }: Re
|
||||||
restoreLocation,
|
restoreLocation,
|
||||||
customTargetPath,
|
customTargetPath,
|
||||||
selectedPaths,
|
selectedPaths,
|
||||||
addBasePath,
|
|
||||||
overwriteMode,
|
overwriteMode,
|
||||||
restoreSnapshot,
|
restoreSnapshot,
|
||||||
]);
|
]);
|
||||||
|
|
@ -339,36 +280,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
|
||||||
)}
|
selectedPaths={selectedPaths}
|
||||||
|
onSelectionChange={setSelectedPaths}
|
||||||
{!fileBrowser.isLoading && !fileBrowser.isEmpty && (
|
stateClassName="flex-1 min-h-0"
|
||||||
<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}
|
|
||||||
onSelectionChange={setSelectedPaths}
|
|
||||||
/>
|
|
||||||
</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,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
|
||||||
10
app/client/hooks/use-scroll-to-form-error.ts
Normal file
10
app/client/hooks/use-scroll-to-form-error.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
import { useCallback } from "react";
|
||||||
|
|
||||||
|
export function useScrollToFormError() {
|
||||||
|
return useCallback(() => {
|
||||||
|
setTimeout(() => {
|
||||||
|
const firstError = document.querySelector("[data-slot='form-message']");
|
||||||
|
firstError?.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||||
|
}, 50);
|
||||||
|
}, []);
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { arktypeResolver } from "@hookform/resolvers/arktype";
|
import { arktypeResolver } from "@hookform/resolvers/arktype";
|
||||||
import { useCallback, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
|
import { useScrollToFormError } from "~/client/hooks/use-scroll-to-form-error";
|
||||||
import { Form } from "~/client/components/ui/form";
|
import { Form } from "~/client/components/ui/form";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card";
|
||||||
import type { BackupSchedule, Volume } from "~/client/lib/types";
|
import type { BackupSchedule, Volume } from "~/client/lib/types";
|
||||||
|
|
@ -30,6 +31,8 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }:
|
||||||
defaultValues: backupScheduleToFormValues(initialValues),
|
defaultValues: backupScheduleToFormValues(initialValues),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const scrollToFirstError = useScrollToFormError();
|
||||||
|
|
||||||
const handleSubmit = useCallback(
|
const handleSubmit = useCallback(
|
||||||
(data: InternalFormValues) => {
|
(data: InternalFormValues) => {
|
||||||
const {
|
const {
|
||||||
|
|
@ -100,7 +103,7 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }:
|
||||||
return (
|
return (
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form
|
<form
|
||||||
onSubmit={form.handleSubmit(handleSubmit)}
|
onSubmit={form.handleSubmit(handleSubmit, scrollToFirstError)}
|
||||||
className="grid gap-4 xl:grid-cols-[minmax(0,2.3fr)_minmax(320px,1fr)]"
|
className="grid gap-4 xl:grid-cols-[minmax(0,2.3fr)_minmax(320px,1fr)]"
|
||||||
id={formId}
|
id={formId}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import {
|
||||||
import { Input } from "~/client/components/ui/input";
|
import { Input } from "~/client/components/ui/input";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select";
|
||||||
import { notificationConfigSchemaBase } from "~/schemas/notifications";
|
import { notificationConfigSchemaBase } from "~/schemas/notifications";
|
||||||
|
import { useScrollToFormError } from "~/client/hooks/use-scroll-to-form-error";
|
||||||
import {
|
import {
|
||||||
CustomForm,
|
CustomForm,
|
||||||
DiscordForm,
|
DiscordForm,
|
||||||
|
|
@ -116,11 +117,16 @@ export const CreateNotificationForm = ({ onSubmit, mode = "create", initialValue
|
||||||
});
|
});
|
||||||
|
|
||||||
const { watch } = form;
|
const { watch } = form;
|
||||||
|
const scrollToFirstError = useScrollToFormError();
|
||||||
const watchedType = watch("type");
|
const watchedType = watch("type");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form id={formId} onSubmit={form.handleSubmit(onSubmit)} className={cn("space-y-4", className)}>
|
<form
|
||||||
|
id={formId}
|
||||||
|
onSubmit={form.handleSubmit(onSubmit, scrollToFirstError)}
|
||||||
|
className={cn("space-y-4", className)}
|
||||||
|
>
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="name"
|
name="name"
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ import { SecretInput } from "../../../components/ui/secret-input";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../../components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../../components/ui/select";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "../../../components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "../../../components/ui/tooltip";
|
||||||
import { useSystemInfo } from "~/client/hooks/use-system-info";
|
import { useSystemInfo } from "~/client/hooks/use-system-info";
|
||||||
|
import { useScrollToFormError } from "~/client/hooks/use-scroll-to-form-error";
|
||||||
import { COMPRESSION_MODES, repositoryConfigSchemaBase } from "~/schemas/restic";
|
import { COMPRESSION_MODES, repositoryConfigSchemaBase } from "~/schemas/restic";
|
||||||
import { Checkbox } from "../../../components/ui/checkbox";
|
import { Checkbox } from "../../../components/ui/checkbox";
|
||||||
import {
|
import {
|
||||||
|
|
@ -96,10 +97,15 @@ export const CreateRepositoryForm = ({
|
||||||
const [passwordMode, setPasswordMode] = useState<"default" | "custom">("default");
|
const [passwordMode, setPasswordMode] = useState<"default" | "custom">("default");
|
||||||
|
|
||||||
const { capabilities } = useSystemInfo();
|
const { capabilities } = useSystemInfo();
|
||||||
|
const scrollToFirstError = useScrollToFormError();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form id={formId} onSubmit={form.handleSubmit(onSubmit)} className={cn("space-y-4", className)}>
|
<form
|
||||||
|
id={formId}
|
||||||
|
onSubmit={form.handleSubmit(onSubmit, scrollToFirstError)}
|
||||||
|
className={cn("space-y-4", className)}
|
||||||
|
>
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="name"
|
name="name"
|
||||||
|
|
|
||||||
|
|
@ -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 { DirectoryBrowser } from "../../../../components/file-browsers/directory-browser";
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
AlertDialogAction,
|
AlertDialogAction,
|
||||||
|
|
@ -97,9 +97,7 @@ export const LocalRepositoryForm = ({ form }: Props) => {
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<div className="py-4">
|
<div className="py-4">
|
||||||
<DirectoryBrowser
|
<DirectoryBrowser
|
||||||
onSelectPath={(path) => {
|
onSelectPath={field.onChange}
|
||||||
field.onChange(path);
|
|
||||||
}}
|
|
||||||
selectedPath={field.value || constants.REPOSITORY_BASE}
|
selectedPath={field.value || constants.REPOSITORY_BASE}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ import { volumeConfigSchemaBase } from "~/schemas/volumes";
|
||||||
import { testConnectionMutation } from "../../../api-client/@tanstack/react-query.gen";
|
import { testConnectionMutation } from "../../../api-client/@tanstack/react-query.gen";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "../../../components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "../../../components/ui/tooltip";
|
||||||
import { useSystemInfo } from "~/client/hooks/use-system-info";
|
import { useSystemInfo } from "~/client/hooks/use-system-info";
|
||||||
|
import { useScrollToFormError } from "~/client/hooks/use-scroll-to-form-error";
|
||||||
import { DirectoryForm, NFSForm, SMBForm, WebDAVForm, RcloneForm, SFTPForm } from "./volume-forms";
|
import { DirectoryForm, NFSForm, SMBForm, WebDAVForm, RcloneForm, SFTPForm } from "./volume-forms";
|
||||||
|
|
||||||
export const formSchema = type({
|
export const formSchema = type({
|
||||||
|
|
@ -65,6 +66,7 @@ export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, for
|
||||||
const { getValues, watch } = form;
|
const { getValues, watch } = form;
|
||||||
|
|
||||||
const { capabilities } = useSystemInfo();
|
const { capabilities } = useSystemInfo();
|
||||||
|
const scrollToFirstError = useScrollToFormError();
|
||||||
const watchedBackend = watch("backend");
|
const watchedBackend = watch("backend");
|
||||||
|
|
||||||
const [testMessage, setTestMessage] = useState<{ success: boolean; message: string } | null>(null);
|
const [testMessage, setTestMessage] = useState<{ success: boolean; message: string } | null>(null);
|
||||||
|
|
@ -102,7 +104,11 @@ export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, for
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form id={formId} onSubmit={form.handleSubmit(onSubmit)} className={cn("space-y-4", className)}>
|
<form
|
||||||
|
id={formId}
|
||||||
|
onSubmit={form.handleSubmit(onSubmit, scrollToFirstError)}
|
||||||
|
className={cn("space-y-4", className)}
|
||||||
|
>
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="name"
|
name="name"
|
||||||
|
|
|
||||||
|
|
@ -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 { DirectoryBrowser } from "../../../../components/file-browsers/directory-browser";
|
||||||
import { Button } from "../../../../components/ui/button";
|
import { Button } from "../../../../components/ui/button";
|
||||||
import {
|
import {
|
||||||
FormControl,
|
FormControl,
|
||||||
|
|
@ -38,7 +38,7 @@ export const DirectoryForm = ({ form }: Props) => {
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<DirectoryBrowser onSelectPath={(path) => field.onChange(path)} selectedPath={field.value} />
|
<DirectoryBrowser onSelectPath={field.onChange} selectedPath={field.value} />
|
||||||
)}
|
)}
|
||||||
</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));
|
||||||
|
|
||||||
|
const 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