diff --git a/app/client/components/directory-browser.tsx b/app/client/components/directory-browser.tsx deleted file mode 100644 index bfea00b3..00000000 --- a/app/client/components/directory-browser.tsx +++ /dev/null @@ -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 ( -
- -
Loading directories...
-
-
- ); - } - - if (fileBrowser.isEmpty) { - return ( -
- -
No subdirectories found
-
-
- ); - } - - return ( -
- - - - - {selectedPath && ( -
-
Selected path:
-
{selectedPath}
-
- )} -
- ); -}; diff --git a/app/client/components/file-browsers/directory-browser.tsx b/app/client/components/file-browsers/directory-browser.tsx new file mode 100644 index 00000000..2b77f921 --- /dev/null +++ b/app/client/components/file-browsers/directory-browser.tsx @@ -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 ( + + ); +}; diff --git a/app/client/components/file-browsers/file-browser.tsx b/app/client/components/file-browsers/file-browser.tsx new file mode 100644 index 00000000..771a1e7d --- /dev/null +++ b/app/client/components/file-browsers/file-browser.tsx @@ -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; + onSelectionChange?: (paths: Set) => 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; + loadingFolders: Set; + onFolderExpand: (folderPath: string) => void | Promise; + onFolderHover: (folderPath: string) => void; + onLoadMore: (folderPath: string) => void | Promise; + 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 ? : emptyIcon; + + let body: ReactNode; + + if (isLoading) { + body = ( +
+

{loadingMessage}

+
+ ); + } else if (errorMessage) { + body = ( +
+

{errorMessage}

+
+ ); + } else if (isEmpty) { + body = ( +
+ {resolvedEmptyIcon} +

{emptyMessage}

+ {emptyDescription &&

{emptyDescription}

} +
+ ); + } else { + body = ( + + ); + } + + const bodyWithScroll = useScrollArea ? {body} : body; + const wrappedBody = treeContainerClassName ? ( +
{bodyWithScroll}
+ ) : ( + bodyWithScroll + ); + + return ( +
+ {wrappedBody} + {showSelectedPathFooter && resolvedSelectedPath && ( +
+
{selectedPathLabel}
+
{resolvedSelectedPath}
+
+ )} +
+ ); +}; diff --git a/app/client/components/file-browsers/local-file-browser.tsx b/app/client/components/file-browsers/local-file-browser.tsx new file mode 100644 index 00000000..2e55e13f --- /dev/null +++ b/app/client/components/file-browsers/local-file-browser.tsx @@ -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 ( + + ); +}; diff --git a/app/client/components/file-browsers/snapshot-tree-browser.tsx b/app/client/components/file-browsers/snapshot-tree-browser.tsx new file mode 100644 index 00000000..3eb93026 --- /dev/null +++ b/app/client/components/file-browsers/snapshot-tree-browser.tsx @@ -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(); + for (const fullPath of selectedPaths) { + displayPaths.add(stripBasePath(fullPath)); + } + + return displayPaths; + }, [selectedPaths, stripBasePath]); + + const handleSelectionChange = useCallback( + (nextDisplayPaths: Set) => { + if (!onSelectionChange) return; + + const nextFullPaths = new Set(); + 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 ( + + ); +}; diff --git a/app/client/components/file-browsers/volume-file-browser.tsx b/app/client/components/file-browsers/volume-file-browser.tsx new file mode 100644 index 00000000..1a9de6ff --- /dev/null +++ b/app/client/components/file-browsers/volume-file-browser.tsx @@ -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 => { + 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 ( + + ); +}; diff --git a/app/client/components/path-selector.tsx b/app/client/components/path-selector.tsx index 5d8132bc..8ad41779 100644 --- a/app/client/components/path-selector.tsx +++ b/app/client/components/path-selector.tsx @@ -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 (
{ onChange(path); setShowBrowser(false); }} - selectedPath={value} />
diff --git a/app/client/components/volume-file-browser.tsx b/app/client/components/volume-file-browser.tsx deleted file mode 100644 index 3ed3a156..00000000 --- a/app/client/components/volume-file-browser.tsx +++ /dev/null @@ -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; - onSelectionChange?: (paths: Set) => 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 => { - 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 ( -
-

Loading files...

-
- ); - } - - if (error) { - return ( -
-

Failed to load files: {parseError(error)?.message}

-
- ); - } - - if (fileBrowser.isEmpty) { - return ( -
- -

{emptyMessage}

- {emptyDescription &&

{emptyDescription}

} -
- ); - } - - return ( -
- -
- ); -}; diff --git a/app/client/hooks/use-file-browser.ts b/app/client/hooks/use-file-browser.ts index bdb8103d..e51baad1 100644 --- a/app/client/hooks/use-file-browser.ts +++ b/app/client/hooks/use-file-browser.ts @@ -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, }; }; diff --git a/app/client/hooks/use-scroll-to-form-error.ts b/app/client/hooks/use-scroll-to-form-error.ts new file mode 100644 index 00000000..2e5ebc67 --- /dev/null +++ b/app/client/hooks/use-scroll-to-form-error.ts @@ -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); + }, []); +} diff --git a/app/client/modules/backups/components/create-schedule-form/index.tsx b/app/client/modules/backups/components/create-schedule-form/index.tsx index 21007ca7..a760323c 100644 --- a/app/client/modules/backups/components/create-schedule-form/index.tsx +++ b/app/client/modules/backups/components/create-schedule-form/index.tsx @@ -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 (
diff --git a/app/client/modules/backups/components/create-schedule-form/paths-section.tsx b/app/client/modules/backups/components/create-schedule-form/paths-section.tsx index 97320658..d5720634 100644 --- a/app/client/modules/backups/components/create-schedule-form/paths-section.tsx +++ b/app/client/modules/backups/components/create-schedule-form/paths-section.tsx @@ -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 && (
diff --git a/app/client/modules/backups/components/snapshot-file-browser.tsx b/app/client/modules/backups/components/snapshot-file-browser.tsx index 2fd534bc..078a126d 100644 --- a/app/client/modules/backups/components/snapshot-file-browser.tsx +++ b/app/client/modules/backups/components/snapshot-file-browser.tsx @@ -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 (
@@ -126,33 +65,18 @@ export const SnapshotFileBrowser = (props: Props) => {
- {fileBrowser.isLoading && ( -
-

Loading files...

-
- )} - - {fileBrowser.isEmpty && ( -
- -

No files in this snapshot

-
- )} - - {!fileBrowser.isLoading && !fileBrowser.isEmpty && ( -
- -
- )} +
diff --git a/app/client/modules/notifications/components/create-notification-form.tsx b/app/client/modules/notifications/components/create-notification-form.tsx index f4473125..d166f858 100644 --- a/app/client/modules/notifications/components/create-notification-form.tsx +++ b/app/client/modules/notifications/components/create-notification-form.tsx @@ -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 ( - + ("default"); const { capabilities } = useSystemInfo(); + const scrollToFirstError = useScrollToFormError(); return ( - + {
{ - field.onChange(path); - }} + onSelectPath={field.onChange} selectedPath={field.value || constants.REPOSITORY_BASE} />
diff --git a/app/client/modules/repositories/routes/snapshot-details.tsx b/app/client/modules/repositories/routes/snapshot-details.tsx index 96298539..c423f852 100644 --- a/app/client/modules/repositories/routes/snapshot-details.tsx +++ b/app/client/modules/repositories/routes/snapshot-details.tsx @@ -24,11 +24,7 @@ export const SnapshotError = () => {

This snapshot does not exist in this repository

It may have been deleted manually outside of Zerobyte.

- ({ tab: "snapshots" })} - params={{ repositoryId }} - > + ({ tab: "snapshots" })} params={{ repositoryId }}>
diff --git a/app/client/modules/volumes/components/create-volume-form.tsx b/app/client/modules/volumes/components/create-volume-form.tsx index c239528e..1497b5ea 100644 --- a/app/client/modules/volumes/components/create-volume-form.tsx +++ b/app/client/modules/volumes/components/create-volume-form.tsx @@ -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 ( - + { ) : ( - field.onChange(path)} selectedPath={field.value} /> + )} Browse and select a directory on the host filesystem to track. diff --git a/app/client/modules/volumes/tabs/files.tsx b/app/client/modules/volumes/tabs/files.tsx index b9124f0a..e4cee5cc 100644 --- a/app/client/modules/volumes/tabs/files.tsx +++ b/app/client/modules/volumes/tabs/files.tsx @@ -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"; diff --git a/app/utils/common-ancestor.ts b/app/utils/common-ancestor.ts new file mode 100644 index 00000000..5263c5de --- /dev/null +++ b/app/utils/common-ancestor.ts @@ -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("/"); +};