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 { DirectoryBrowser } from "./directory-browser";
|
||||
import { DirectoryBrowser } from "./file-browsers/directory-browser";
|
||||
import { Button } from "./ui/button";
|
||||
|
||||
type Props = {
|
||||
|
|
@ -15,11 +15,11 @@ export const PathSelector = ({ value, onChange }: Props) => {
|
|||
return (
|
||||
<div className="space-y-2">
|
||||
<DirectoryBrowser
|
||||
selectedPath={value}
|
||||
onSelectPath={(path) => {
|
||||
onChange(path);
|
||||
setShowBrowser(false);
|
||||
}}
|
||||
selectedPath={value}
|
||||
/>
|
||||
<Button type="button" variant="ghost" size="sm" onClick={() => setShowBrowser(false)}>
|
||||
Cancel
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { ChevronDown, FileIcon, FolderOpen, RotateCcw } from "lucide-react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { ChevronDown, FolderOpen, RotateCcw } from "lucide-react";
|
||||
import { Button } from "~/client/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card";
|
||||
import { Input } from "~/client/components/ui/input";
|
||||
|
|
@ -16,15 +16,15 @@ import {
|
|||
AlertDialogTitle,
|
||||
} from "~/client/components/ui/alert-dialog";
|
||||
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 { listSnapshotFilesOptions, restoreSnapshotMutation } from "~/client/api-client/@tanstack/react-query.gen";
|
||||
import { useFileBrowser } from "~/client/hooks/use-file-browser";
|
||||
import { restoreSnapshotMutation } from "~/client/api-client/@tanstack/react-query.gen";
|
||||
import { type RestoreCompletedEvent, useServerEvents } from "~/client/hooks/use-server-events";
|
||||
import { OVERWRITE_MODES, type OverwriteMode } from "~/schemas/restic";
|
||||
import type { Repository, Snapshot } from "~/client/lib/types";
|
||||
import { handleRepositoryError } from "~/client/lib/errors";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { findCommonAncestor } from "~/utils/common-ancestor";
|
||||
|
||||
type RestoreLocation = "original" | "custom";
|
||||
|
||||
|
|
@ -38,9 +38,8 @@ interface RestoreFormProps {
|
|||
export function RestoreForm({ snapshot, repository, snapshotId, returnPath }: RestoreFormProps) {
|
||||
const navigate = useNavigate();
|
||||
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 [customTargetPath, setCustomTargetPath] = useState("");
|
||||
|
|
@ -89,62 +88,6 @@ export function RestoreForm({ snapshot, repository, snapshotId, returnPath }: Re
|
|||
};
|
||||
}, [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({
|
||||
...restoreSnapshotMutation(),
|
||||
onError: (error) => {
|
||||
|
|
@ -163,8 +106,7 @@ export function RestoreForm({ snapshot, repository, snapshotId, returnPath }: Re
|
|||
const isCustomLocation = restoreLocation === "custom";
|
||||
const targetPath = isCustomLocation && customTargetPath.trim() ? customTargetPath.trim() : undefined;
|
||||
|
||||
const pathsArray = Array.from(selectedPaths);
|
||||
const includePaths = pathsArray.map((path) => addBasePath(path));
|
||||
const includePaths = Array.from(selectedPaths);
|
||||
|
||||
restoreCompletedRef.current = false;
|
||||
setIsRestoreActive(true);
|
||||
|
|
@ -188,7 +130,6 @@ export function RestoreForm({ snapshot, repository, snapshotId, returnPath }: Re
|
|||
restoreLocation,
|
||||
customTargetPath,
|
||||
selectedPaths,
|
||||
addBasePath,
|
||||
overwriteMode,
|
||||
restoreSnapshot,
|
||||
]);
|
||||
|
|
@ -339,36 +280,21 @@ export function RestoreForm({ snapshot, repository, snapshotId, returnPath }: Re
|
|||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 overflow-hidden flex flex-col p-0">
|
||||
{fileBrowser.isLoading && (
|
||||
<div className="flex items-center justify-center flex-1">
|
||||
<p className="text-muted-foreground">Loading files...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{fileBrowser.isEmpty && (
|
||||
<div className="flex flex-col items-center justify-center flex-1 text-center p-8">
|
||||
<FileIcon className="w-12 h-12 text-muted-foreground/50 mb-4" />
|
||||
<p className="text-muted-foreground">No files in this snapshot</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!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}
|
||||
onSelectionChange={setSelectedPaths}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<SnapshotTreeBrowser
|
||||
repositoryId={repository.id}
|
||||
snapshotId={snapshotId}
|
||||
basePath={volumeBasePath}
|
||||
pageSize={500}
|
||||
className="flex flex-1 min-h-0 flex-col"
|
||||
treeContainerClassName="overflow-auto flex-1 min-h-0 border border-border rounded-md bg-card m-4"
|
||||
treeClassName="px-2 py-2"
|
||||
loadingMessage="Loading files..."
|
||||
emptyMessage="No files in this snapshot"
|
||||
withCheckboxes
|
||||
selectedPaths={selectedPaths}
|
||||
onSelectionChange={setSelectedPaths}
|
||||
stateClassName="flex-1 min-h-0"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</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,
|
||||
handleLoadMore,
|
||||
getFolderPagination,
|
||||
isLoading: isLoading && fileArray.length === 0,
|
||||
isLoading: Boolean(isLoading) && fileArray.length === 0,
|
||||
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 { useCallback, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useScrollToFormError } from "~/client/hooks/use-scroll-to-form-error";
|
||||
import { Form } from "~/client/components/ui/form";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card";
|
||||
import type { BackupSchedule, Volume } from "~/client/lib/types";
|
||||
|
|
@ -30,6 +31,8 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }:
|
|||
defaultValues: backupScheduleToFormValues(initialValues),
|
||||
});
|
||||
|
||||
const scrollToFirstError = useScrollToFormError();
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(data: InternalFormValues) => {
|
||||
const {
|
||||
|
|
@ -100,7 +103,7 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }:
|
|||
return (
|
||||
<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)]"
|
||||
id={formId}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
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 { Textarea } from "~/client/components/ui/textarea";
|
||||
import type { Volume } from "~/client/lib/types";
|
||||
|
|
@ -32,9 +32,9 @@ export const PathsSection = ({
|
|||
volumeId={volume.shortId}
|
||||
selectedPaths={selectedPaths}
|
||||
onSelectionChange={onSelectionChange}
|
||||
withCheckboxes={true}
|
||||
foldersOnly={false}
|
||||
withCheckboxes
|
||||
className="relative border rounded-md bg-card p-2 h-100 overflow-y-auto"
|
||||
emptyMessage="This volume appears to be empty."
|
||||
/>
|
||||
{selectedPaths.size > 0 && (
|
||||
<div className="mt-4">
|
||||
|
|
|
|||
|
|
@ -1,15 +1,12 @@
|
|||
import { useCallback } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { FileIcon, RotateCcw, Trash2 } from "lucide-react";
|
||||
import { FileTree } from "~/client/components/file-tree";
|
||||
import { RotateCcw, Trash2 } from "lucide-react";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card";
|
||||
import { Button, buttonVariants } from "~/client/components/ui/button";
|
||||
import type { Snapshot } from "~/client/lib/types";
|
||||
import { listSnapshotFilesOptions } from "~/client/api-client/@tanstack/react-query.gen";
|
||||
import { formatDateTime } from "~/client/lib/datetime";
|
||||
import { useFileBrowser } from "~/client/hooks/use-file-browser";
|
||||
import { cn } from "~/client/lib/utils";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { SnapshotTreeBrowser } from "~/client/components/file-browsers/snapshot-tree-browser";
|
||||
import { findCommonAncestor } from "~/utils/common-ancestor";
|
||||
|
||||
interface Props {
|
||||
snapshot: Snapshot;
|
||||
|
|
@ -22,65 +19,7 @@ interface Props {
|
|||
export const SnapshotFileBrowser = (props: Props) => {
|
||||
const { snapshot, repositoryId, backupId, onDeleteSnapshot, isDeletingSnapshot } = props;
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
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,
|
||||
},
|
||||
});
|
||||
const volumeBasePath = findCommonAncestor(snapshot.paths);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
|
|
@ -126,33 +65,18 @@ export const SnapshotFileBrowser = (props: Props) => {
|
|||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 overflow-hidden flex flex-col p-0">
|
||||
{fileBrowser.isLoading && (
|
||||
<div className="flex items-center justify-center flex-1">
|
||||
<p className="text-muted-foreground">Loading files...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{fileBrowser.isEmpty && (
|
||||
<div className="flex flex-col items-center justify-center flex-1 text-center p-8">
|
||||
<FileIcon className="w-12 h-12 text-muted-foreground/50 mb-4" />
|
||||
<p className="text-muted-foreground">No files in this snapshot</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!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>
|
||||
)}
|
||||
<SnapshotTreeBrowser
|
||||
repositoryId={repositoryId}
|
||||
snapshotId={snapshot.short_id}
|
||||
basePath={volumeBasePath}
|
||||
pageSize={500}
|
||||
className="flex flex-1 min-h-0 flex-col"
|
||||
treeContainerClassName="overflow-auto flex-1 min-h-0 border border-border rounded-md bg-card m-4"
|
||||
treeClassName="px-2 py-2"
|
||||
loadingMessage="Loading files..."
|
||||
emptyMessage="No files in this snapshot"
|
||||
stateClassName="flex-1 min-h-0"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import {
|
|||
import { Input } from "~/client/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select";
|
||||
import { notificationConfigSchemaBase } from "~/schemas/notifications";
|
||||
import { useScrollToFormError } from "~/client/hooks/use-scroll-to-form-error";
|
||||
import {
|
||||
CustomForm,
|
||||
DiscordForm,
|
||||
|
|
@ -116,11 +117,16 @@ export const CreateNotificationForm = ({ onSubmit, mode = "create", initialValue
|
|||
});
|
||||
|
||||
const { watch } = form;
|
||||
const scrollToFirstError = useScrollToFormError();
|
||||
const watchedType = watch("type");
|
||||
|
||||
return (
|
||||
<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
|
||||
control={form.control}
|
||||
name="name"
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import { SecretInput } from "../../../components/ui/secret-input";
|
|||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../../components/ui/select";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "../../../components/ui/tooltip";
|
||||
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 { Checkbox } from "../../../components/ui/checkbox";
|
||||
import {
|
||||
|
|
@ -96,10 +97,15 @@ export const CreateRepositoryForm = ({
|
|||
const [passwordMode, setPasswordMode] = useState<"default" | "custom">("default");
|
||||
|
||||
const { capabilities } = useSystemInfo();
|
||||
const scrollToFirstError = useScrollToFormError();
|
||||
|
||||
return (
|
||||
<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
|
||||
control={form.control}
|
||||
name="name"
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import type { UseFormReturn } from "react-hook-form";
|
|||
import { Check, Pencil, X, AlertTriangle } from "lucide-react";
|
||||
import { Button } from "../../../../components/ui/button";
|
||||
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 {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
|
|
@ -97,9 +97,7 @@ export const LocalRepositoryForm = ({ form }: Props) => {
|
|||
</AlertDialogHeader>
|
||||
<div className="py-4">
|
||||
<DirectoryBrowser
|
||||
onSelectPath={(path) => {
|
||||
field.onChange(path);
|
||||
}}
|
||||
onSelectPath={field.onChange}
|
||||
selectedPath={field.value || constants.REPOSITORY_BASE}
|
||||
/>
|
||||
</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-1">It may have been deleted manually outside of Zerobyte.</p>
|
||||
<div className="mt-4">
|
||||
<Link
|
||||
to={`/repositories/$repositoryId`}
|
||||
search={() => ({ tab: "snapshots" })}
|
||||
params={{ repositoryId }}
|
||||
>
|
||||
<Link to={`/repositories/$repositoryId`} search={() => ({ tab: "snapshots" })} params={{ repositoryId }}>
|
||||
<Button variant="outline">Back to repository</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import { volumeConfigSchemaBase } from "~/schemas/volumes";
|
|||
import { testConnectionMutation } from "../../../api-client/@tanstack/react-query.gen";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "../../../components/ui/tooltip";
|
||||
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";
|
||||
|
||||
export const formSchema = type({
|
||||
|
|
@ -65,6 +66,7 @@ export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, for
|
|||
const { getValues, watch } = form;
|
||||
|
||||
const { capabilities } = useSystemInfo();
|
||||
const scrollToFirstError = useScrollToFormError();
|
||||
const watchedBackend = watch("backend");
|
||||
|
||||
const [testMessage, setTestMessage] = useState<{ success: boolean; message: string } | null>(null);
|
||||
|
|
@ -102,7 +104,11 @@ export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, for
|
|||
|
||||
return (
|
||||
<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
|
||||
control={form.control}
|
||||
name="name"
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { Pencil } from "lucide-react";
|
||||
import type { UseFormReturn } from "react-hook-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 {
|
||||
FormControl,
|
||||
|
|
@ -38,7 +38,7 @@ export const DirectoryForm = ({ form }: Props) => {
|
|||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<DirectoryBrowser onSelectPath={(path) => field.onChange(path)} selectedPath={field.value} />
|
||||
<DirectoryBrowser onSelectPath={field.onChange} selectedPath={field.value} />
|
||||
)}
|
||||
</FormControl>
|
||||
<FormDescription>Browse and select a directory on the host filesystem to track.</FormDescription>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
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 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