From 65c31415bcd8a330aa62ca17009d2e1c1041d1cf Mon Sep 17 00:00:00 2001 From: Alex Freska Date: Tue, 13 Feb 2024 17:01:06 -0500 Subject: [PATCH] feat: renterd explorer modes --- .changeset/late-numbers-march.md | 5 + .changeset/short-clocks-fix.md | 5 + .changeset/silent-pots-carry.md | 5 + .changeset/wise-socks-dance.md | 5 + .../FilesHealthColumnContents.tsx | 6 +- .../Files/Columns/FilesHealthColumn/index.tsx | 24 +- .../FileContextMenu/CopyMetadataMenuItem.tsx | 2 +- .../Files/FileContextMenu/index.tsx | 9 +- .../components/Files/FilesBreadcrumbMenu.tsx | 66 ----- .../Files/FilesBreadcrumbMenuMode.tsx | 20 ++ .../Files/FilesCmd/FilesSearchCmd/index.tsx | 11 +- .../Files/FilesFilterDirectoryMenu/index.tsx | 12 +- .../Files/FilesSearchMenu/index.tsx | 7 +- .../components/Files/FilesStatsMenu/index.tsx | 62 ----- .../FilesStatsMenuCount.tsx | 11 +- .../FilesStatsMenuHealth.tsx | 0 .../FilesStatsMenuSize.tsx | 0 .../FilesStatsMenuWarnings.tsx | 0 .../Files/FilesStatsMenuShared/index.tsx | 32 +++ .../Files/FilesViewDropdownMenu.tsx | 6 +- apps/renterd/components/Files/index.tsx | 36 +-- .../renterd/components/Files/useCanUpload.tsx | 4 +- .../components/Files/useDirectoryDelete.tsx | 2 +- .../components/Files/useFileDelete.tsx | 2 +- .../EmptyState/StateError.tsx | 0 .../EmptyState/StateNoneMatching.tsx | 4 +- .../EmptyState/StateNoneYet.tsx | 4 +- .../EmptyState/index.tsx | 10 +- .../FilesActionsMenu.tsx | 8 +- .../FilesDirectory/FilesBreadcrumbMenu.tsx | 70 +++++ .../FilesExplorer.tsx | 16 +- .../FilesDirectory/FilesStatsMenu/index.tsx | 33 +++ .../components/FilesDirectory/index.tsx | 29 ++ .../FilesFlat/EmptyState/StateError.tsx | 15 ++ .../EmptyState/StateNoneMatching.tsx | 29 ++ .../FilesFlat/EmptyState/StateNoneYet.tsx | 28 ++ .../components/FilesFlat/EmptyState/index.tsx | 22 ++ .../components/FilesFlat/FilesActionsMenu.tsx | 17 ++ .../FilesFlat/FilesBreadcrumbMenu.tsx | 40 +++ .../components/FilesFlat/FilesExplorer.tsx | 26 ++ .../FilesFlat/FilesStatsMenu/index.tsx | 21 ++ apps/renterd/components/FilesFlat/index.tsx | 29 ++ apps/renterd/components/TransfersBar.tsx | 4 +- apps/renterd/config/providers.tsx | 28 +- apps/renterd/contexts/dialog.tsx | 12 +- apps/renterd/contexts/files/dataset.tsx | 163 ------------ apps/renterd/contexts/files/index.tsx | 250 ------------------ .../{files => filesDirectory}/columns.tsx | 23 +- .../contexts/filesDirectory/dataset.tsx | 71 +++++ .../renterd/contexts/filesDirectory/index.tsx | 135 ++++++++++ .../{files => filesDirectory}/move.tsx | 6 +- apps/renterd/contexts/filesFlat/columns.tsx | 205 ++++++++++++++ apps/renterd/contexts/filesFlat/dataset.tsx | 73 +++++ apps/renterd/contexts/filesFlat/index.tsx | 76 ++++++ .../renterd/contexts/filesManager/dataset.tsx | 105 ++++++++ .../{files => filesManager}/downloads.tsx | 2 +- apps/renterd/contexts/filesManager/index.tsx | 195 ++++++++++++++ .../contexts/{files => filesManager}/types.ts | 22 +- .../{files => filesManager}/uploads.tsx | 6 +- apps/renterd/dialogs/AlertsDialog.tsx | 6 +- .../Files => dialogs}/FileRenameDialog.tsx | 24 +- .../FilesBucketCreateDialog.tsx | 2 +- .../FilesBucketDeleteDialog.tsx | 2 +- .../FilesBucketPolicyDialog.tsx | 2 +- .../FilesCreateDirectoryDialog.tsx | 6 +- .../FilesSearchDialog/index.tsx | 2 +- .../{contexts/files => lib}/health.spec.ts | 0 .../renterd/{contexts/files => lib}/health.ts | 2 +- .../{contexts/files => lib}/paths.spec.ts | 0 apps/renterd/{contexts/files => lib}/paths.ts | 0 .../{contexts/files => lib}/rename.spec.ts | 0 .../renterd/{contexts/files => lib}/rename.ts | 0 libs/design-system/src/app/AppNavbar.tsx | 2 +- .../src/components/PaginatorMarker.tsx | 76 ++++++ .../src/hooks/useServerFilters.ts | 27 +- libs/design-system/src/index.ts | 1 + libs/next/src/index.ts | 2 + libs/react-renterd/src/bus.ts | 26 +- 78 files changed, 1592 insertions(+), 697 deletions(-) create mode 100644 .changeset/late-numbers-march.md create mode 100644 .changeset/short-clocks-fix.md create mode 100644 .changeset/silent-pots-carry.md create mode 100644 .changeset/wise-socks-dance.md delete mode 100644 apps/renterd/components/Files/FilesBreadcrumbMenu.tsx create mode 100644 apps/renterd/components/Files/FilesBreadcrumbMenuMode.tsx delete mode 100644 apps/renterd/components/Files/FilesStatsMenu/index.tsx rename apps/renterd/components/Files/{FilesStatsMenu => FilesStatsMenuShared}/FilesStatsMenuCount.tsx (75%) rename apps/renterd/components/Files/{FilesStatsMenu => FilesStatsMenuShared}/FilesStatsMenuHealth.tsx (100%) rename apps/renterd/components/Files/{FilesStatsMenu => FilesStatsMenuShared}/FilesStatsMenuSize.tsx (100%) rename apps/renterd/components/Files/{FilesStatsMenu => FilesStatsMenuShared}/FilesStatsMenuWarnings.tsx (100%) create mode 100644 apps/renterd/components/Files/FilesStatsMenuShared/index.tsx rename apps/renterd/components/{Files => FilesDirectory}/EmptyState/StateError.tsx (100%) rename apps/renterd/components/{Files => FilesDirectory}/EmptyState/StateNoneMatching.tsx (86%) rename apps/renterd/components/{Files => FilesDirectory}/EmptyState/StateNoneYet.tsx (87%) rename apps/renterd/components/{Files => FilesDirectory}/EmptyState/index.tsx (86%) rename apps/renterd/components/{Files => FilesDirectory}/FilesActionsMenu.tsx (84%) create mode 100644 apps/renterd/components/FilesDirectory/FilesBreadcrumbMenu.tsx rename apps/renterd/components/{Files => FilesDirectory}/FilesExplorer.tsx (74%) create mode 100644 apps/renterd/components/FilesDirectory/FilesStatsMenu/index.tsx create mode 100644 apps/renterd/components/FilesDirectory/index.tsx create mode 100644 apps/renterd/components/FilesFlat/EmptyState/StateError.tsx create mode 100644 apps/renterd/components/FilesFlat/EmptyState/StateNoneMatching.tsx create mode 100644 apps/renterd/components/FilesFlat/EmptyState/StateNoneYet.tsx create mode 100644 apps/renterd/components/FilesFlat/EmptyState/index.tsx create mode 100644 apps/renterd/components/FilesFlat/FilesActionsMenu.tsx create mode 100644 apps/renterd/components/FilesFlat/FilesBreadcrumbMenu.tsx create mode 100644 apps/renterd/components/FilesFlat/FilesExplorer.tsx create mode 100644 apps/renterd/components/FilesFlat/FilesStatsMenu/index.tsx create mode 100644 apps/renterd/components/FilesFlat/index.tsx delete mode 100644 apps/renterd/contexts/files/dataset.tsx delete mode 100644 apps/renterd/contexts/files/index.tsx rename apps/renterd/contexts/{files => filesDirectory}/columns.tsx (91%) create mode 100644 apps/renterd/contexts/filesDirectory/dataset.tsx create mode 100644 apps/renterd/contexts/filesDirectory/index.tsx rename apps/renterd/contexts/{files => filesDirectory}/move.tsx (96%) create mode 100644 apps/renterd/contexts/filesFlat/columns.tsx create mode 100644 apps/renterd/contexts/filesFlat/dataset.tsx create mode 100644 apps/renterd/contexts/filesFlat/index.tsx create mode 100644 apps/renterd/contexts/filesManager/dataset.tsx rename apps/renterd/contexts/{files => filesManager}/downloads.tsx (99%) create mode 100644 apps/renterd/contexts/filesManager/index.tsx rename apps/renterd/contexts/{files => filesManager}/types.ts (72%) rename apps/renterd/contexts/{files => filesManager}/uploads.tsx (97%) rename apps/renterd/{components/Files => dialogs}/FileRenameDialog.tsx (83%) rename apps/renterd/{components/Files => dialogs}/FilesBucketCreateDialog.tsx (97%) rename apps/renterd/{components/Files => dialogs}/FilesBucketDeleteDialog.tsx (98%) rename apps/renterd/{components/Files => dialogs}/FilesBucketPolicyDialog.tsx (98%) rename apps/renterd/{components/Files => dialogs}/FilesCreateDirectoryDialog.tsx (92%) rename apps/renterd/{components/Files => dialogs}/FilesSearchDialog/index.tsx (88%) rename apps/renterd/{contexts/files => lib}/health.spec.ts (100%) rename apps/renterd/{contexts/files => lib}/health.ts (97%) rename apps/renterd/{contexts/files => lib}/paths.spec.ts (100%) rename apps/renterd/{contexts/files => lib}/paths.ts (100%) rename apps/renterd/{contexts/files => lib}/rename.spec.ts (100%) rename apps/renterd/{contexts/files => lib}/rename.ts (100%) create mode 100644 libs/design-system/src/components/PaginatorMarker.tsx diff --git a/.changeset/late-numbers-march.md b/.changeset/late-numbers-march.md new file mode 100644 index 000000000..d2e320166 --- /dev/null +++ b/.changeset/late-numbers-march.md @@ -0,0 +1,5 @@ +--- +'@siafoundation/react-renterd': minor +--- + +Added useObjectList. diff --git a/.changeset/short-clocks-fix.md b/.changeset/short-clocks-fix.md new file mode 100644 index 000000000..fe25f6303 --- /dev/null +++ b/.changeset/short-clocks-fix.md @@ -0,0 +1,5 @@ +--- +'renterd': minor +--- + +The file explorer now has a global mode that shows a flat list of files from across all directories in a bucket. diff --git a/.changeset/silent-pots-carry.md b/.changeset/silent-pots-carry.md new file mode 100644 index 000000000..87af98224 --- /dev/null +++ b/.changeset/silent-pots-carry.md @@ -0,0 +1,5 @@ +--- +'renterd': minor +--- + +The file health indicator now show the percentage inline. diff --git a/.changeset/wise-socks-dance.md b/.changeset/wise-socks-dance.md new file mode 100644 index 000000000..2986877c1 --- /dev/null +++ b/.changeset/wise-socks-dance.md @@ -0,0 +1,5 @@ +--- +'renterd': minor +--- + +The file explorer global mode now allows you to sort across all files in a bucket. diff --git a/apps/renterd/components/Files/Columns/FilesHealthColumn/FilesHealthColumnContents.tsx b/apps/renterd/components/Files/Columns/FilesHealthColumn/FilesHealthColumnContents.tsx index 4ed5ed6b0..8bd1e6bf9 100644 --- a/apps/renterd/components/Files/Columns/FilesHealthColumn/FilesHealthColumnContents.tsx +++ b/apps/renterd/components/Files/Columns/FilesHealthColumn/FilesHealthColumnContents.tsx @@ -7,10 +7,10 @@ import { import { useObject } from '@siafoundation/react-renterd' import { cx } from 'class-variance-authority' import { sortBy } from '@technically/lodash' -import { computeSlabContractSetShards } from '../../../../contexts/files/health' -import { ObjectData } from '../../../../contexts/files/types' +import { computeSlabContractSetShards } from '../../../../lib/health' +import { ObjectData } from '../../../../contexts/filesManager/types' import { useHealthLabel } from '../../../../hooks/useHealthLabel' -import { bucketAndKeyParamsFromPath } from '../../../../contexts/files/paths' +import { bucketAndKeyParamsFromPath } from '../../../../lib/paths' export function FilesHealthColumnContents({ path, diff --git a/apps/renterd/components/Files/Columns/FilesHealthColumn/index.tsx b/apps/renterd/components/Files/Columns/FilesHealthColumn/index.tsx index 3e5cc19b5..5c2ccf332 100644 --- a/apps/renterd/components/Files/Columns/FilesHealthColumn/index.tsx +++ b/apps/renterd/components/Files/Columns/FilesHealthColumn/index.tsx @@ -1,5 +1,5 @@ import { HoverCard, LoadingDots, Text } from '@siafoundation/design-system' -import { ObjectData } from '../../../../contexts/files/types' +import { ObjectData } from '../../../../contexts/filesManager/types' import { useHealthLabel } from '../../../../hooks/useHealthLabel' import { FilesHealthColumnContents } from './FilesHealthColumnContents' @@ -11,7 +11,7 @@ export function FilesHealthColumn(props: ObjectData) { size, isDirectory, }) - + const displayPercent = `${(displayHealth * 100).toFixed(0)}%` if (isDirectory) { if (name === '..') { return null @@ -22,15 +22,18 @@ export function FilesHealthColumn(props: ObjectData) { openDelay: 100, }} trigger={ - - {icon} - +
+ {icon} + + {displayPercent} + +
} >
{label} - {(displayHealth * 100).toFixed(0)}% + {displayPercent}
@@ -47,9 +50,12 @@ export function FilesHealthColumn(props: ObjectData) { openDelay: 100, }} trigger={ - - {icon} - +
+ {icon} + + {displayPercent} + +
} > {/* important to separate contents so that each row does not trigger 1+n diff --git a/apps/renterd/components/Files/FileContextMenu/CopyMetadataMenuItem.tsx b/apps/renterd/components/Files/FileContextMenu/CopyMetadataMenuItem.tsx index 38e06d64f..cf87d3c97 100644 --- a/apps/renterd/components/Files/FileContextMenu/CopyMetadataMenuItem.tsx +++ b/apps/renterd/components/Files/FileContextMenu/CopyMetadataMenuItem.tsx @@ -5,7 +5,7 @@ import { } from '@siafoundation/design-system' import { Copy16 } from '@siafoundation/react-icons' import { useObject } from '@siafoundation/react-renterd' -import { bucketAndKeyParamsFromPath } from '../../../contexts/files/paths' +import { bucketAndKeyParamsFromPath } from '../../../lib/paths' type Props = { path: string diff --git a/apps/renterd/components/Files/FileContextMenu/index.tsx b/apps/renterd/components/Files/FileContextMenu/index.tsx index 38a849639..16c3f65b2 100644 --- a/apps/renterd/components/Files/FileContextMenu/index.tsx +++ b/apps/renterd/components/Files/FileContextMenu/index.tsx @@ -18,18 +18,19 @@ import { Filter16, Edit16, } from '@siafoundation/react-icons' -import { useFiles } from '../../../contexts/files' import { useFileDelete } from '../useFileDelete' import { CopyMetadataMenuItem } from './CopyMetadataMenuItem' -import { getFilename } from '../../../contexts/files/paths' +import { getFilename } from '../../../lib/paths' import { useDialog } from '../../../contexts/dialog' +import { useFilesManager } from '../../../contexts/filesManager' type Props = { path: string } export function FileContextMenu({ path }: Props) { - const { downloadFiles, getFileUrl, navigateToFile } = useFiles() + const { downloadFiles, getFileUrl, navigateToFileDirectory } = + useFilesManager() const deleteFile = useFileDelete() const { openDialog } = useDialog() @@ -68,7 +69,7 @@ export function FileContextMenu({ path }: Props) { Filter { - navigateToFile(path) + navigateToFileDirectory(path) }} > diff --git a/apps/renterd/components/Files/FilesBreadcrumbMenu.tsx b/apps/renterd/components/Files/FilesBreadcrumbMenu.tsx deleted file mode 100644 index eb38458b1..000000000 --- a/apps/renterd/components/Files/FilesBreadcrumbMenu.tsx +++ /dev/null @@ -1,66 +0,0 @@ -import { Fragment, useEffect, useRef } from 'react' -import { Text, ScrollArea } from '@siafoundation/design-system' -import { ChevronRight16 } from '@siafoundation/react-icons' -import { useFiles } from '../../contexts/files' - -export function FilesBreadcrumbMenu() { - const { activeDirectory, setActiveDirectory } = useFiles() - const ref = useRef(null) - - useEffect(() => { - const t = setTimeout(() => { - ref.current?.scrollIntoView({ behavior: 'smooth' }) - }, 100) - return () => { - clearTimeout(t) - } - }, [activeDirectory]) - - return ( - -
- setActiveDirectory(() => [])} - size="18" - weight="semibold" - className="flex items-center cursor-pointer" - noWrap - > - Files - - {activeDirectory.length > 0 && ( - - - - )} - {activeDirectory.map((part, i) => { - return ( - - {i > 0 && ( - - - - )} - - setActiveDirectory((dirs) => dirs.slice(0, i + 1)) - } - size="18" - weight="semibold" - className="flex items-center cursor-pointer" - noWrap - > - {part} - - - ) - })} -
-
- - ) -} diff --git a/apps/renterd/components/Files/FilesBreadcrumbMenuMode.tsx b/apps/renterd/components/Files/FilesBreadcrumbMenuMode.tsx new file mode 100644 index 000000000..c503046bb --- /dev/null +++ b/apps/renterd/components/Files/FilesBreadcrumbMenuMode.tsx @@ -0,0 +1,20 @@ +import { LinkButton } from '@siafoundation/design-system' +import { Earth16, Folder16 } from '@siafoundation/react-icons' +import { useFilesManager } from '../../contexts/filesManager' + +export function FilesBreadcrumbMenuMode() { + const { switchViewModeUrl, activeViewMode } = useFilesManager() + + return ( + + {activeViewMode === 'directory' ? : } + + ) +} diff --git a/apps/renterd/components/Files/FilesCmd/FilesSearchCmd/index.tsx b/apps/renterd/components/Files/FilesCmd/FilesSearchCmd/index.tsx index 71514824d..2aa8a89e8 100644 --- a/apps/renterd/components/Files/FilesCmd/FilesSearchCmd/index.tsx +++ b/apps/renterd/components/Files/FilesCmd/FilesSearchCmd/index.tsx @@ -1,11 +1,11 @@ import { CommandGroup, CommandItemSearch } from '../../../CmdRoot/Item' import { Page } from '../../../CmdRoot/types' import { useObjectSearch } from '@siafoundation/react-renterd' -import { isDirectory } from '../../../../contexts/files/paths' -import { useFiles } from '../../../../contexts/files' +import { isDirectory } from '../../../../lib/paths' import { Text } from '@siafoundation/design-system' import { Document16, FolderIcon } from '@siafoundation/react-icons' import { FileSearchEmpty } from './FileSearchEmpty' +import { useFilesManager } from '../../../../contexts/filesManager' export const filesSearchPage = { namespace: 'files/search', @@ -27,14 +27,15 @@ export function FilesSearchCmd({ beforeSelect?: () => void afterSelect?: () => void }) { - const { activeBucket, navigateToFile } = useFiles() + const { activeBucketName: activeBucket, navigateToFileDirectory } = + useFilesManager() const onSearchPage = currentPage?.namespace === filesSearchPage.namespace const results = useObjectSearch({ disabled: !onSearchPage, params: { bucket: activeBucket || 'default', key: debouncedSearch, - skip: 0, + offset: 0, limit: 10, }, config: { @@ -61,7 +62,7 @@ export function FilesSearchCmd({ key={path} onSelect={() => { beforeSelect() - navigateToFile(path) + navigateToFileDirectory(path) afterSelect() }} value={path} diff --git a/apps/renterd/components/Files/FilesFilterDirectoryMenu/index.tsx b/apps/renterd/components/Files/FilesFilterDirectoryMenu/index.tsx index eac979399..86aeef850 100644 --- a/apps/renterd/components/Files/FilesFilterDirectoryMenu/index.tsx +++ b/apps/renterd/components/Files/FilesFilterDirectoryMenu/index.tsx @@ -1,11 +1,15 @@ import { Button, Separator, TextField } from '@siafoundation/design-system' import { useEffect, useState } from 'react' -import { useFiles } from '../../../contexts/files' import { useDebounce } from 'use-debounce' import { Close16 } from '@siafoundation/react-icons' +import { useFilesManager } from '../../../contexts/filesManager' -export function FilesFilterDirectoryMenu() { - const { filters, setFilter, removeFilter } = useFiles() +type Props = { + placeholder?: string +} + +export function FilesFilterDirectoryMenu({ placeholder }: Props) { + const { filters, setFilter, removeFilter } = useFilesManager() const [search, setSearch] = useState('') const [debouncedSearch] = useDebounce(search, 500) @@ -36,7 +40,7 @@ export function FilesFilterDirectoryMenu() { setSearch(e.currentTarget.value)} className="w-full !pl-0" diff --git a/apps/renterd/components/Files/FilesSearchMenu/index.tsx b/apps/renterd/components/Files/FilesSearchMenu/index.tsx index 2fcd81501..e1439c2ea 100644 --- a/apps/renterd/components/Files/FilesSearchMenu/index.tsx +++ b/apps/renterd/components/Files/FilesSearchMenu/index.tsx @@ -11,9 +11,12 @@ import { useDialog } from '../../../contexts/dialog' import { useRouter } from 'next/router' import { routes } from '../../../config/routes' import { useContracts } from '../../../contexts/contracts' -import { FilesSearchCmd, filesSearchPage } from '../FilesCmd/FilesSearchCmd' +import { + FilesSearchCmd, + filesSearchPage, +} from '../../Files/FilesCmd/FilesSearchCmd' import { useDebounce } from 'use-debounce' -import { FileSearchEmpty } from '../FilesCmd/FilesSearchCmd/FileSearchEmpty' +import { FileSearchEmpty } from '../../Files/FilesCmd/FilesSearchCmd/FileSearchEmpty' type Props = { panel?: boolean diff --git a/apps/renterd/components/Files/FilesStatsMenu/index.tsx b/apps/renterd/components/Files/FilesStatsMenu/index.tsx deleted file mode 100644 index 47a81a5ca..000000000 --- a/apps/renterd/components/Files/FilesStatsMenu/index.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import { - PaginatorUnknownTotal, - Separator, - Text, - Tooltip, -} from '@siafoundation/design-system' -import { Filter16, Wikis16 } from '@siafoundation/react-icons' -import { FilesStatsMenuSize } from './FilesStatsMenuSize' -import { FilesStatsMenuHealth } from './FilesStatsMenuHealth' -import { FilesStatsMenuWarnings } from './FilesStatsMenuWarnings' -import { FilesStatsMenuCount } from './FilesStatsMenuCount' -import { useFiles } from '../../../contexts/files' -import { FilesFilterDirectoryMenu } from '../FilesFilterDirectoryMenu' - -export function FilesStatsMenu() { - const { - limit, - offset, - pageCount, - dataState, - isViewingABucket, - isViewingBuckets, - } = useFiles() - return ( -
- {isViewingBuckets ? ( -
- ) : ( - - )} -
- -
- - - - - - -
- -
- - - - - - - -
-
- {isViewingABucket && ( - - )} -
- ) -} diff --git a/apps/renterd/components/Files/FilesStatsMenu/FilesStatsMenuCount.tsx b/apps/renterd/components/Files/FilesStatsMenuShared/FilesStatsMenuCount.tsx similarity index 75% rename from apps/renterd/components/Files/FilesStatsMenu/FilesStatsMenuCount.tsx rename to apps/renterd/components/Files/FilesStatsMenuShared/FilesStatsMenuCount.tsx index 4b938dbf0..e8fe37a47 100644 --- a/apps/renterd/components/Files/FilesStatsMenu/FilesStatsMenuCount.tsx +++ b/apps/renterd/components/Files/FilesStatsMenuShared/FilesStatsMenuCount.tsx @@ -1,9 +1,16 @@ import { LoadingDots, Text, Tooltip } from '@siafoundation/design-system' -import { useFiles } from '../../../contexts/files' import { useObjectStats } from '@siafoundation/react-renterd' +import { useFilesManager } from '../../../contexts/filesManager' +import { useFilesDirectory } from '../../../contexts/filesDirectory' +import { useFilesFlat } from '../../../contexts/filesFlat' export function FilesStatsMenuCount() { - const { isViewingABucket, pageCount, uploadsList } = useFiles() + const { isViewingABucket, uploadsList, activeViewMode } = useFilesManager() + const { pageCount: directoryPageCount } = useFilesDirectory() + const { pageCount: flatPageCount } = useFilesFlat() + const pageCount = + activeViewMode === 'flat' ? flatPageCount : directoryPageCount + const stats = useObjectStats({ config: { swr: { diff --git a/apps/renterd/components/Files/FilesStatsMenu/FilesStatsMenuHealth.tsx b/apps/renterd/components/Files/FilesStatsMenuShared/FilesStatsMenuHealth.tsx similarity index 100% rename from apps/renterd/components/Files/FilesStatsMenu/FilesStatsMenuHealth.tsx rename to apps/renterd/components/Files/FilesStatsMenuShared/FilesStatsMenuHealth.tsx diff --git a/apps/renterd/components/Files/FilesStatsMenu/FilesStatsMenuSize.tsx b/apps/renterd/components/Files/FilesStatsMenuShared/FilesStatsMenuSize.tsx similarity index 100% rename from apps/renterd/components/Files/FilesStatsMenu/FilesStatsMenuSize.tsx rename to apps/renterd/components/Files/FilesStatsMenuShared/FilesStatsMenuSize.tsx diff --git a/apps/renterd/components/Files/FilesStatsMenu/FilesStatsMenuWarnings.tsx b/apps/renterd/components/Files/FilesStatsMenuShared/FilesStatsMenuWarnings.tsx similarity index 100% rename from apps/renterd/components/Files/FilesStatsMenu/FilesStatsMenuWarnings.tsx rename to apps/renterd/components/Files/FilesStatsMenuShared/FilesStatsMenuWarnings.tsx diff --git a/apps/renterd/components/Files/FilesStatsMenuShared/index.tsx b/apps/renterd/components/Files/FilesStatsMenuShared/index.tsx new file mode 100644 index 000000000..7155df76f --- /dev/null +++ b/apps/renterd/components/Files/FilesStatsMenuShared/index.tsx @@ -0,0 +1,32 @@ +import { Separator, Text, Tooltip } from '@siafoundation/design-system' +import { Filter16, Wikis16 } from '@siafoundation/react-icons' +import { FilesStatsMenuSize } from './FilesStatsMenuSize' +import { FilesStatsMenuHealth } from './FilesStatsMenuHealth' +import { FilesStatsMenuWarnings } from './FilesStatsMenuWarnings' +import { FilesStatsMenuCount } from './FilesStatsMenuCount' + +export function FilesStatsMenuShared() { + return ( +
+ +
+ + + + + + +
+ +
+ + + + + + + +
+
+ ) +} diff --git a/apps/renterd/components/Files/FilesViewDropdownMenu.tsx b/apps/renterd/components/Files/FilesViewDropdownMenu.tsx index 65e9892a4..5ab6b36f0 100644 --- a/apps/renterd/components/Files/FilesViewDropdownMenu.tsx +++ b/apps/renterd/components/Files/FilesViewDropdownMenu.tsx @@ -10,8 +10,8 @@ import { Option, } from '@siafoundation/design-system' import { CaretDown16, SettingsAdjust16 } from '@siafoundation/react-icons' -import { sortOptions, SortField } from '../../contexts/files/types' -import { useFiles } from '../../contexts/files' +import { sortOptions, SortField } from '../../contexts/filesManager/types' +import { useFilesManager } from '../../contexts/filesManager' import { groupBy } from '@technically/lodash' export function FilesViewDropdownMenu() { @@ -24,7 +24,7 @@ export function FilesViewDropdownMenu() { sortDirection, setSortDirection, enabledColumns, - } = useFiles() + } = useFilesManager() return ( } - nav={} - stats={} - actions={} - openSettings={() => openDialog('settings')} - > -
- -
- - ) + if (params.get('view') === 'flat' && !isViewingBuckets) { + return + } + + return } diff --git a/apps/renterd/components/Files/useCanUpload.tsx b/apps/renterd/components/Files/useCanUpload.tsx index 7811a3eec..d150a27d3 100644 --- a/apps/renterd/components/Files/useCanUpload.tsx +++ b/apps/renterd/components/Files/useCanUpload.tsx @@ -1,10 +1,10 @@ import { useSyncStatus } from '../../hooks/useSyncStatus' -import { useFiles } from '../../contexts/files' +import { useFilesManager } from '../../contexts/filesManager' import { useAutopilotNotConfigured } from './checks/useAutopilotNotConfigured' import { useNotEnoughContracts } from './checks/useNotEnoughContracts' export function useCanUpload() { - const { isViewingABucket } = useFiles() + const { isViewingABucket } = useFilesManager() const syncStatus = useSyncStatus() const autopilotNotConfigured = useAutopilotNotConfigured() const notEnoughContracts = useNotEnoughContracts() diff --git a/apps/renterd/components/Files/useDirectoryDelete.tsx b/apps/renterd/components/Files/useDirectoryDelete.tsx index 4ca6e3992..c3d233c76 100644 --- a/apps/renterd/components/Files/useDirectoryDelete.tsx +++ b/apps/renterd/components/Files/useDirectoryDelete.tsx @@ -8,7 +8,7 @@ import { useDialog } from '../../contexts/dialog' import { useCallback } from 'react' import { useObjectDelete } from '@siafoundation/react-renterd' import { humanBytes } from '@siafoundation/units' -import { bucketAndKeyParamsFromPath } from '../../contexts/files/paths' +import { bucketAndKeyParamsFromPath } from '../../lib/paths' export function useDirectoryDelete() { const { openConfirmDialog } = useDialog() diff --git a/apps/renterd/components/Files/useFileDelete.tsx b/apps/renterd/components/Files/useFileDelete.tsx index a6d58ac80..51ad0e360 100644 --- a/apps/renterd/components/Files/useFileDelete.tsx +++ b/apps/renterd/components/Files/useFileDelete.tsx @@ -7,7 +7,7 @@ import { Delete16 } from '@siafoundation/react-icons' import { useDialog } from '../../contexts/dialog' import { useCallback } from 'react' import { useObjectDelete } from '@siafoundation/react-renterd' -import { bucketAndKeyParamsFromPath } from '../../contexts/files/paths' +import { bucketAndKeyParamsFromPath } from '../../lib/paths' export function useFileDelete() { const { openConfirmDialog } = useDialog() diff --git a/apps/renterd/components/Files/EmptyState/StateError.tsx b/apps/renterd/components/FilesDirectory/EmptyState/StateError.tsx similarity index 100% rename from apps/renterd/components/Files/EmptyState/StateError.tsx rename to apps/renterd/components/FilesDirectory/EmptyState/StateError.tsx diff --git a/apps/renterd/components/Files/EmptyState/StateNoneMatching.tsx b/apps/renterd/components/FilesDirectory/EmptyState/StateNoneMatching.tsx similarity index 86% rename from apps/renterd/components/Files/EmptyState/StateNoneMatching.tsx rename to apps/renterd/components/FilesDirectory/EmptyState/StateNoneMatching.tsx index e691bb3e4..116532114 100644 --- a/apps/renterd/components/Files/EmptyState/StateNoneMatching.tsx +++ b/apps/renterd/components/FilesDirectory/EmptyState/StateNoneMatching.tsx @@ -1,9 +1,9 @@ import { Button, Text } from '@siafoundation/design-system' import { Filter32 } from '@siafoundation/react-icons' -import { useFiles } from '../../../contexts/files' +import { useFilesManager } from '../../../contexts/filesManager' export function StateNoneMatching() { - const { filters, resetFilters } = useFiles() + const { filters, resetFilters } = useFilesManager() return (
diff --git a/apps/renterd/components/Files/EmptyState/StateNoneYet.tsx b/apps/renterd/components/FilesDirectory/EmptyState/StateNoneYet.tsx similarity index 87% rename from apps/renterd/components/Files/EmptyState/StateNoneYet.tsx rename to apps/renterd/components/FilesDirectory/EmptyState/StateNoneYet.tsx index 173f493eb..4cab099c4 100644 --- a/apps/renterd/components/Files/EmptyState/StateNoneYet.tsx +++ b/apps/renterd/components/FilesDirectory/EmptyState/StateNoneYet.tsx @@ -1,10 +1,10 @@ import { Code, LinkButton, Text } from '@siafoundation/design-system' import { CloudUpload32 } from '@siafoundation/react-icons' import { routes } from '../../../config/routes' -import { useFiles } from '../../../contexts/files' +import { useFilesManager } from '../../../contexts/filesManager' export function StateNoneYet() { - const { activeBucket } = useFiles() + const { activeBucketName: activeBucket } = useFilesManager() return (
diff --git a/apps/renterd/components/Files/EmptyState/index.tsx b/apps/renterd/components/FilesDirectory/EmptyState/index.tsx similarity index 86% rename from apps/renterd/components/Files/EmptyState/index.tsx rename to apps/renterd/components/FilesDirectory/EmptyState/index.tsx index 6ef9378ed..036428039 100644 --- a/apps/renterd/components/Files/EmptyState/index.tsx +++ b/apps/renterd/components/FilesDirectory/EmptyState/index.tsx @@ -1,15 +1,17 @@ import { Code, LinkButton, Text } from '@siafoundation/design-system' import { CloudUpload32 } from '@siafoundation/react-icons' import { routes } from '../../../config/routes' -import { useFiles } from '../../../contexts/files' -import { useAutopilotNotConfigured } from '../checks/useAutopilotNotConfigured' -import { useNotEnoughContracts } from '../checks/useNotEnoughContracts' +import { useFilesDirectory } from '../../../contexts/filesDirectory' +import { useAutopilotNotConfigured } from '../../Files/checks/useAutopilotNotConfigured' +import { useNotEnoughContracts } from '../../Files/checks/useNotEnoughContracts' import { StateError } from './StateError' import { StateNoneMatching } from './StateNoneMatching' import { StateNoneYet } from './StateNoneYet' +import { useFilesManager } from '../../../contexts/filesManager' export function EmptyState() { - const { dataState, isViewingRootOfABucket } = useFiles() + const { isViewingRootOfABucket } = useFilesManager() + const { dataState } = useFilesDirectory() const autopilotNotConfigured = useAutopilotNotConfigured() const notEnoughContracts = useNotEnoughContracts() diff --git a/apps/renterd/components/Files/FilesActionsMenu.tsx b/apps/renterd/components/FilesDirectory/FilesActionsMenu.tsx similarity index 84% rename from apps/renterd/components/Files/FilesActionsMenu.tsx rename to apps/renterd/components/FilesDirectory/FilesActionsMenu.tsx index 6d80dbbf9..ac509f008 100644 --- a/apps/renterd/components/Files/FilesActionsMenu.tsx +++ b/apps/renterd/components/FilesDirectory/FilesActionsMenu.tsx @@ -5,17 +5,17 @@ import { FolderAdd16, Search16, } from '@siafoundation/react-icons' -import { useFiles } from '../../contexts/files' import * as reactDropzone from 'react-dropzone' -import { FilesViewDropdownMenu } from './FilesViewDropdownMenu' +import { useFilesManager } from '../../contexts/filesManager' +import { FilesViewDropdownMenu } from '../Files/FilesViewDropdownMenu' import { useDialog } from '../../contexts/dialog' -import { useCanUpload } from './useCanUpload' +import { useCanUpload } from '../Files/useCanUpload' // esm compat const { useDropzone } = reactDropzone export function FilesActionsMenu() { const { openDialog } = useDialog() - const { uploadFiles, isViewingBuckets } = useFiles() + const { uploadFiles, isViewingBuckets } = useFilesManager() const canUpload = useCanUpload() diff --git a/apps/renterd/components/FilesDirectory/FilesBreadcrumbMenu.tsx b/apps/renterd/components/FilesDirectory/FilesBreadcrumbMenu.tsx new file mode 100644 index 000000000..200c5efb9 --- /dev/null +++ b/apps/renterd/components/FilesDirectory/FilesBreadcrumbMenu.tsx @@ -0,0 +1,70 @@ +import { Fragment, useEffect, useRef } from 'react' +import { Text, ScrollArea } from '@siafoundation/design-system' +import { ChevronRight16 } from '@siafoundation/react-icons' +import { useFilesManager } from '../../contexts/filesManager' +import { FilesBreadcrumbMenuMode } from '../Files/FilesBreadcrumbMenuMode' + +export function FilesBreadcrumbMenu() { + const { activeDirectory, setActiveDirectory } = useFilesManager() + const ref = useRef(null) + + useEffect(() => { + const t = setTimeout(() => { + ref.current?.scrollIntoView({ behavior: 'smooth' }) + }, 100) + return () => { + clearTimeout(t) + } + }, [activeDirectory]) + + return ( +
+ + +
+ setActiveDirectory(() => [])} + size="18" + weight="semibold" + className="flex items-center cursor-pointer" + noWrap + > + Files + + {activeDirectory.length > 0 && ( + + + + )} + {activeDirectory.map((part, i) => { + return ( + + {i > 0 && ( + + + + )} + + setActiveDirectory((dirs) => dirs.slice(0, i + 1)) + } + size="18" + weight="semibold" + className="flex items-center cursor-pointer" + noWrap + > + {part} + + + ) + })} +
+
+ +
+ ) +} diff --git a/apps/renterd/components/Files/FilesExplorer.tsx b/apps/renterd/components/FilesDirectory/FilesExplorer.tsx similarity index 74% rename from apps/renterd/components/Files/FilesExplorer.tsx rename to apps/renterd/components/FilesDirectory/FilesExplorer.tsx index 862633b21..df2eebcac 100644 --- a/apps/renterd/components/Files/FilesExplorer.tsx +++ b/apps/renterd/components/FilesDirectory/FilesExplorer.tsx @@ -1,26 +1,24 @@ import { Table, Dropzone } from '@siafoundation/design-system' -import { useFiles } from '../../contexts/files' +import { useFilesDirectory } from '../../contexts/filesDirectory' import { EmptyState } from './EmptyState' -import { useCanUpload } from './useCanUpload' +import { useCanUpload } from '../Files/useCanUpload' +import { useFilesManager } from '../../contexts/filesManager' +import { columns } from '../../contexts/filesDirectory/columns' export function FilesExplorer() { + const { uploadFiles, sortField, sortDirection, sortableColumns, toggleSort } = + useFilesManager() const { - uploadFiles, datasetPage, pageCount, dataState, - columns, - sortField, - sortDirection, - sortableColumns, - toggleSort, onDragEnd, onDragOver, onDragStart, onDragCancel, onDragMove, draggingObject, - } = useFiles() + } = useFilesDirectory() const canUpload = useCanUpload() return (
diff --git a/apps/renterd/components/FilesDirectory/FilesStatsMenu/index.tsx b/apps/renterd/components/FilesDirectory/FilesStatsMenu/index.tsx new file mode 100644 index 000000000..217e8f7b2 --- /dev/null +++ b/apps/renterd/components/FilesDirectory/FilesStatsMenu/index.tsx @@ -0,0 +1,33 @@ +import { PaginatorUnknownTotal } from '@siafoundation/design-system' +import { useFilesDirectory } from '../../../contexts/filesDirectory' +import { FilesStatsMenuShared } from '../../Files/FilesStatsMenuShared' +import { FilesFilterDirectoryMenu } from '../../Files/FilesFilterDirectoryMenu' + +export function FilesStatsMenu() { + const { + limit, + offset, + pageCount, + dataState, + isViewingABucket, + isViewingBuckets, + } = useFilesDirectory() + return ( +
+ {isViewingBuckets ? ( +
+ ) : ( + + )} + + {isViewingABucket && ( + + )} +
+ ) +} diff --git a/apps/renterd/components/FilesDirectory/index.tsx b/apps/renterd/components/FilesDirectory/index.tsx new file mode 100644 index 000000000..f7feb0a1c --- /dev/null +++ b/apps/renterd/components/FilesDirectory/index.tsx @@ -0,0 +1,29 @@ +import { RenterdSidenav } from '../RenterdSidenav' +import { routes } from '../../config/routes' +import { useDialog } from '../../contexts/dialog' +import { FilesBreadcrumbMenu } from './FilesBreadcrumbMenu' +import { RenterdAuthedLayout } from '../RenterdAuthedLayout' +import { FilesActionsMenu } from './FilesActionsMenu' +import { FilesStatsMenu } from './FilesStatsMenu' +import { FilesExplorer } from './FilesExplorer' + +export function FilesDirectory() { + const { openDialog } = useDialog() + + return ( + } + nav={} + stats={} + actions={} + openSettings={() => openDialog('settings')} + > +
+ +
+
+ ) +} diff --git a/apps/renterd/components/FilesFlat/EmptyState/StateError.tsx b/apps/renterd/components/FilesFlat/EmptyState/StateError.tsx new file mode 100644 index 000000000..280e10cd2 --- /dev/null +++ b/apps/renterd/components/FilesFlat/EmptyState/StateError.tsx @@ -0,0 +1,15 @@ +import { Text } from '@siafoundation/design-system' +import { MisuseOutline32 } from '@siafoundation/react-icons' + +export function StateError() { + return ( +
+ + + + + Error fetching files. + +
+ ) +} diff --git a/apps/renterd/components/FilesFlat/EmptyState/StateNoneMatching.tsx b/apps/renterd/components/FilesFlat/EmptyState/StateNoneMatching.tsx new file mode 100644 index 000000000..116532114 --- /dev/null +++ b/apps/renterd/components/FilesFlat/EmptyState/StateNoneMatching.tsx @@ -0,0 +1,29 @@ +import { Button, Text } from '@siafoundation/design-system' +import { Filter32 } from '@siafoundation/react-icons' +import { useFilesManager } from '../../../contexts/filesManager' + +export function StateNoneMatching() { + const { filters, resetFilters } = useFilesManager() + return ( +
+ + + +
+ + No files matching filters. + + {!!filters.length && ( + + )} +
+
+ ) +} diff --git a/apps/renterd/components/FilesFlat/EmptyState/StateNoneYet.tsx b/apps/renterd/components/FilesFlat/EmptyState/StateNoneYet.tsx new file mode 100644 index 000000000..5b4cc504e --- /dev/null +++ b/apps/renterd/components/FilesFlat/EmptyState/StateNoneYet.tsx @@ -0,0 +1,28 @@ +import { Code, LinkButton, Text } from '@siafoundation/design-system' +import { CloudUpload32 } from '@siafoundation/react-icons' +import { routes } from '../../../config/routes' +import { useFilesManager } from '../../../contexts/filesManager' + +export function StateNoneYet() { + const { activeBucketName: activeBucket } = useFilesManager() + return ( +
+ + + +
+ + The {activeBucket} bucket does not contain any files. + + { + e.stopPropagation() + }} + > + View buckets list + +
+
+ ) +} diff --git a/apps/renterd/components/FilesFlat/EmptyState/index.tsx b/apps/renterd/components/FilesFlat/EmptyState/index.tsx new file mode 100644 index 000000000..21789cf4e --- /dev/null +++ b/apps/renterd/components/FilesFlat/EmptyState/index.tsx @@ -0,0 +1,22 @@ +import { StateError } from './StateError' +import { StateNoneMatching } from './StateNoneMatching' +import { StateNoneYet } from './StateNoneYet' +import { useFilesFlat } from '../../../contexts/filesFlat' + +export function EmptyState() { + const { dataState } = useFilesFlat() + + if (dataState === 'noneMatchingFilters') { + return + } + + if (dataState === 'error') { + return + } + + if (dataState === 'noneYet') { + return + } + + return null +} diff --git a/apps/renterd/components/FilesFlat/FilesActionsMenu.tsx b/apps/renterd/components/FilesFlat/FilesActionsMenu.tsx new file mode 100644 index 000000000..1d11b48e9 --- /dev/null +++ b/apps/renterd/components/FilesFlat/FilesActionsMenu.tsx @@ -0,0 +1,17 @@ +import { Button } from '@siafoundation/design-system' +import { Search16 } from '@siafoundation/react-icons' +import { useDialog } from '../../contexts/dialog' +import { FilesViewDropdownMenu } from '../Files/FilesViewDropdownMenu' + +export function FilesActionsMenu() { + const { openDialog } = useDialog() + + return ( +
+ + +
+ ) +} diff --git a/apps/renterd/components/FilesFlat/FilesBreadcrumbMenu.tsx b/apps/renterd/components/FilesFlat/FilesBreadcrumbMenu.tsx new file mode 100644 index 000000000..19bb02fe5 --- /dev/null +++ b/apps/renterd/components/FilesFlat/FilesBreadcrumbMenu.tsx @@ -0,0 +1,40 @@ +import { Text, ScrollArea } from '@siafoundation/design-system' +import { ChevronRight16 } from '@siafoundation/react-icons' +import { useFilesManager } from '../../contexts/filesManager' +import { FilesBreadcrumbMenuMode } from '../Files/FilesBreadcrumbMenuMode' + +export function FilesBreadcrumbMenu() { + const { activeBucketName: activeBucket, setActiveDirectory } = + useFilesManager() + + return ( +
+ + +
+ setActiveDirectory(() => [])} + size="18" + weight="semibold" + className="flex items-center cursor-pointer" + noWrap + > + Files + + + + + null} + size="18" + weight="semibold" + className="flex items-center cursor-pointer" + noWrap + > + {activeBucket} + +
+
+
+ ) +} diff --git a/apps/renterd/components/FilesFlat/FilesExplorer.tsx b/apps/renterd/components/FilesFlat/FilesExplorer.tsx new file mode 100644 index 000000000..39e1e7fc7 --- /dev/null +++ b/apps/renterd/components/FilesFlat/FilesExplorer.tsx @@ -0,0 +1,26 @@ +import { Table } from '@siafoundation/design-system' +import { EmptyState } from './EmptyState' +import { useFilesFlat } from '../../contexts/filesFlat' +import { useFilesManager } from '../../contexts/filesManager' +import { columns } from '../../contexts/filesFlat/columns' + +export function FilesExplorer() { + const { sortableColumns, toggleSort } = useFilesManager() + const { datasetPage, dataState, sortField, sortDirection } = useFilesFlat() + return ( +
+ } + pageSize={10} + data={datasetPage} + columns={columns} + sortableColumns={sortableColumns} + sortField={sortField} + sortDirection={sortDirection} + toggleSort={toggleSort} + rowSize="dense" + /> + + ) +} diff --git a/apps/renterd/components/FilesFlat/FilesStatsMenu/index.tsx b/apps/renterd/components/FilesFlat/FilesStatsMenu/index.tsx new file mode 100644 index 000000000..ae573d840 --- /dev/null +++ b/apps/renterd/components/FilesFlat/FilesStatsMenu/index.tsx @@ -0,0 +1,21 @@ +import { PaginatorMarker } from '@siafoundation/design-system' +import { useFilesFlat } from '../../../contexts/filesFlat' +import { FilesFilterDirectoryMenu } from '../../Files/FilesFilterDirectoryMenu' +import { FilesStatsMenuShared } from '../../Files/FilesStatsMenuShared' + +export function FilesStatsMenu() { + const { limit, pageCount, dataState, nextMarker, isMore } = useFilesFlat() + return ( +
+ + + +
+ ) +} diff --git a/apps/renterd/components/FilesFlat/index.tsx b/apps/renterd/components/FilesFlat/index.tsx new file mode 100644 index 000000000..6bcbfba48 --- /dev/null +++ b/apps/renterd/components/FilesFlat/index.tsx @@ -0,0 +1,29 @@ +import { RenterdSidenav } from '../RenterdSidenav' +import { routes } from '../../config/routes' +import { useDialog } from '../../contexts/dialog' +import { FilesBreadcrumbMenu } from './FilesBreadcrumbMenu' +import { RenterdAuthedLayout } from '../RenterdAuthedLayout' +import { FilesActionsMenu } from './FilesActionsMenu' +import { FilesStatsMenu } from './FilesStatsMenu' +import { FilesExplorer } from './FilesExplorer' + +export function FilesFlat() { + const { openDialog } = useDialog() + + return ( + } + nav={} + stats={} + actions={} + openSettings={() => openDialog('settings')} + > +
+ +
+
+ ) +} diff --git a/apps/renterd/components/TransfersBar.tsx b/apps/renterd/components/TransfersBar.tsx index 021e85b10..153b3ac45 100644 --- a/apps/renterd/components/TransfersBar.tsx +++ b/apps/renterd/components/TransfersBar.tsx @@ -13,7 +13,7 @@ import { Upload16, } from '@siafoundation/react-icons' import { useState } from 'react' -import { useFiles } from '../contexts/files' +import { useFilesManager } from '../contexts/filesManager' import { useAppSettings } from '@siafoundation/react-core' function getProgress(transfer: { loaded?: number; size?: number }) { @@ -23,7 +23,7 @@ function getProgress(transfer: { loaded?: number; size?: number }) { export function TransfersBar() { const { isUnlockedAndAuthedRoute } = useAppSettings() const { uploadsList, uploadCancel, downloadsList, downloadCancel } = - useFiles() + useFilesManager() const [maximized, setMaximized] = useState(true) const uploadCount = uploadsList.length diff --git a/apps/renterd/config/providers.tsx b/apps/renterd/config/providers.tsx index de7da45fa..0747e8da5 100644 --- a/apps/renterd/config/providers.tsx +++ b/apps/renterd/config/providers.tsx @@ -1,14 +1,16 @@ +import React from 'react' import { DialogProvider, Dialogs } from '../contexts/dialog' -import { FilesProvider } from '../contexts/files' import { ContractsProvider } from '../contexts/contracts' import { HostsProvider } from '../contexts/hosts' -import React from 'react' import { AppProvider } from '../contexts/app' import { ConfigProvider } from '../contexts/config' import { OnboardingBar } from '../components/OnboardingBar' import { TransfersBar } from '../components/TransfersBar' import { TransactionsProvider } from '../contexts/transactions' import { KeysProvider } from '../contexts/keys' +import { FilesFlatProvider } from '../contexts/filesFlat' +import { FilesManagerProvider } from '../contexts/filesManager' +import { FilesDirectoryProvider } from '../contexts/filesDirectory' type Props = { children: React.ReactNode @@ -22,16 +24,20 @@ export function Providers({ children }: Props) { - - - {/* this is here so that dialogs can use all the other providers, + + + + + {/* this is here so that dialogs can use all the other providers, and the other providers can trigger dialogs */} - - - - {children} - - + + + + {children} + + + + diff --git a/apps/renterd/contexts/dialog.tsx b/apps/renterd/contexts/dialog.tsx index 4e16d8d4e..7c73f35ac 100644 --- a/apps/renterd/contexts/dialog.tsx +++ b/apps/renterd/contexts/dialog.tsx @@ -6,22 +6,22 @@ import { WalletSingleAddressDetailsDialog, } from '@siafoundation/design-system' import { CmdKDialog } from '../components/CmdKDialog' -import { FilesCreateDirectoryDialog } from '../components/Files/FilesCreateDirectoryDialog' +import { FilesCreateDirectoryDialog } from '../dialogs/FilesCreateDirectoryDialog' import { HostsAllowBlockDialog } from '../components/Hosts/HostsAllowBlockDialog' import { HostsFilterAddressDialog } from '../components/Hosts/HostsFilterAddressDialog' import { ContractsFilterAddressDialog } from '../components/Contracts/ContractsFilterAddressDialog' import { ContractsFilterPublicKeyDialog } from '../components/Contracts/ContractsFilterPublicKeyDialog' import { ContractsFilterContractSetDialog } from '../components/Contracts/ContractsFilterContractSetDialog' -import { FilesSearchDialog } from '../components/Files/FilesSearchDialog' +import { FilesSearchDialog } from '../dialogs/FilesSearchDialog' import { useSyncerConnect, useWallet } from '@siafoundation/react-renterd' import { RenterdSendSiacoinDialog } from '../dialogs/RenterdSendSiacoinDialog' import { RenterdTransactionDetailsDialog } from '../dialogs/RenterdTransactionDetailsDialog' import { AlertsDialog } from '../dialogs/AlertsDialog' import { HostsFilterPublicKeyDialog } from '../components/Hosts/HostsFilterPublicKeyDialog' -import { FilesBucketDeleteDialog } from '../components/Files/FilesBucketDeleteDialog' -import { FilesBucketPolicyDialog } from '../components/Files/FilesBucketPolicyDialog' -import { FilesBucketCreateDialog } from '../components/Files/FilesBucketCreateDialog' -import { FileRenameDialog } from '../components/Files/FileRenameDialog' +import { FilesBucketDeleteDialog } from '../dialogs/FilesBucketDeleteDialog' +import { FilesBucketPolicyDialog } from '../dialogs/FilesBucketPolicyDialog' +import { FilesBucketCreateDialog } from '../dialogs/FilesBucketCreateDialog' +import { FileRenameDialog } from '../dialogs/FileRenameDialog' import { KeysCreateDialog } from '../components/Keys/KeysCreateDialog' export type DialogType = diff --git a/apps/renterd/contexts/files/dataset.tsx b/apps/renterd/contexts/files/dataset.tsx deleted file mode 100644 index fa5638760..000000000 --- a/apps/renterd/contexts/files/dataset.tsx +++ /dev/null @@ -1,163 +0,0 @@ -import { - ObjectDirectoryParams, - useBuckets, - useObjectDirectory, -} from '@siafoundation/react-renterd' -import { sortBy, toPairs } from '@technically/lodash' -import useSWR from 'swr' -import { useContracts } from '../contracts' -import { ObjectData, SortField } from './types' -import { - bucketAndKeyParamsFromPath, - getBucketFromPath, - buildDirectoryPath, - getFilename, - join, - isDirectory, -} from './paths' -import { - ServerFilterItem, - minutesInMilliseconds, -} from '@siafoundation/design-system' -import { useRouter } from 'next/router' -import { useMemo } from 'react' - -type Props = { - setActiveDirectory: (func: (directory: string[]) => string[]) => void - activeDirectoryPath: string - uploadsList: ObjectData[] - sortDirection: 'asc' | 'desc' - sortField: SortField - filters: ServerFilterItem[] -} - -const defaultLimit = 50 - -export function useDataset({ - setActiveDirectory, - activeDirectoryPath, - uploadsList, - sortDirection, - sortField, - filters, -}: Props) { - const buckets = useBuckets() - - const router = useRouter() - const limit = Number(router.query.limit || defaultLimit) - const offset = Number(router.query.offset || 0) - const activeBucketName = getBucketFromPath(activeDirectoryPath) - const activeBucket = buckets.data?.find((b) => b.name === activeBucketName) - const fileNamePrefix = - filters.find((f) => f.id === 'fileNamePrefix')?.value || '' - - const params = useMemo(() => { - const p: ObjectDirectoryParams = { - ...bucketAndKeyParamsFromPath(activeDirectoryPath), - sortBy: sortField, - sortDir: sortDirection, - offset, - limit, - } - if (fileNamePrefix) { - p.prefix = fileNamePrefix - } - return p - }, [ - activeDirectoryPath, - fileNamePrefix, - sortField, - sortDirection, - offset, - limit, - ]) - - const response = useObjectDirectory({ - disabled: !activeBucketName, - params, - config: { - swr: { - refreshInterval: minutesInMilliseconds(1), - }, - }, - }) - - const { dataset: allContracts } = useContracts() - - const d = useSWR( - response.isValidating || buckets.isValidating - ? null - : [ - response.data, - uploadsList, - allContracts, - buckets.data, - activeBucketName, - activeDirectoryPath, - ], - () => { - const dataMap: Record = {} - if (!activeBucket) { - buckets.data?.forEach((bucket) => { - const name = bucket.name - const path = buildDirectoryPath(name, '') - dataMap[name] = { - id: path, - path, - bucket, - size: 0, - health: 0, - name, - onClick: () => { - setActiveDirectory((p) => p.concat(name)) - }, - type: 'bucket', - } - }) - } else if (response.data) { - response.data.entries?.forEach(({ name: key, size, health }) => { - const path = join(activeBucketName, key) - const name = getFilename(key) - dataMap[path] = { - id: path, - path, - bucket: activeBucket, - size, - health, - name, - onClick: isDirectory(key) - ? () => { - setActiveDirectory((p) => p.concat(name.slice(0, -1))) - } - : undefined, - type: isDirectory(key) ? 'directory' : 'file', - } - }) - uploadsList - .filter(({ path, name }) => path === join(activeDirectoryPath, name)) - .forEach((upload) => { - dataMap[upload.path] = upload - }) - } - const all = sortBy( - toPairs(dataMap).map((p) => p[1]), - sortField as keyof ObjectData - ) - if (sortDirection === 'desc') { - all.reverse() - } - return all - }, - { - keepPreviousData: true, - } - ) - - return { - limit, - offset, - response, - dataset: d.data, - refresh: response.mutate, - } -} diff --git a/apps/renterd/contexts/files/index.tsx b/apps/renterd/contexts/files/index.tsx deleted file mode 100644 index e27d87833..000000000 --- a/apps/renterd/contexts/files/index.tsx +++ /dev/null @@ -1,250 +0,0 @@ -import { - useDatasetEmptyState, - useServerFilters, - useTableState, -} from '@siafoundation/design-system' -import { useRouter } from 'next/router' -import { createContext, useCallback, useContext, useMemo } from 'react' -import { columns } from './columns' -import { - defaultSortField, - columnsDefaultVisible, - sortOptions, - ObjectData, -} from './types' -import { - FullPath, - FullPathSegments, - getDirectorySegmentsFromPath, - getFilename, - pathSegmentsToPath, -} from './paths' -import { useUploads } from './uploads' -import { useDownloads } from './downloads' -import { useDataset } from './dataset' -import { useMove } from './move' - -function useFilesMain() { - const { - configurableColumns, - enabledColumns, - sortableColumns, - toggleColumnVisibility, - setColumnsVisible, - setColumnsHidden, - toggleSort, - setSortDirection, - setSortField, - sortField, - sortDirection, - resetDefaultColumnVisibility, - } = useTableState('renterd/v0/objects', { - columns, - columnsDefaultVisible, - sortOptions, - defaultSortField, - }) - const router = useRouter() - const { filters, setFilter, removeFilter, removeLastFilter, resetFilters } = - useServerFilters() - - // [bucket, key, directory] - const activeDirectory = useMemo( - () => - ((router.query.path || []) as FullPathSegments).map(decodeURIComponent), - [router.query.path] - ) - - // bucket - const activeBucket = useMemo(() => { - return activeDirectory[0] - }, [activeDirectory]) - - // bucket/key/directory/ - const activeDirectoryPath = useMemo(() => { - return pathSegmentsToPath(activeDirectory) + '/' - }, [activeDirectory]) - - const setActiveDirectory = useCallback( - (fn: (activeDirectory: FullPathSegments) => FullPathSegments) => { - const nextActiveDirectory = fn(activeDirectory) - router.push( - '/files/' + nextActiveDirectory.map(encodeURIComponent).join('/') - ) - }, - [router, activeDirectory] - ) - - const { uploadFiles, uploadsList, uploadCancel } = useUploads({ - activeDirectoryPath, - }) - const { downloadFiles, downloadsList, getFileUrl, downloadCancel } = - useDownloads() - - const { limit, offset, response, refresh, dataset } = useDataset({ - activeDirectoryPath, - setActiveDirectory, - uploadsList, - sortField, - sortDirection, - filters, - }) - - const { - onDragEnd, - onDragOver, - onDragCancel, - onDragMove, - onDragStart, - draggingObject, - } = useMove({ - dataset, - activeDirectory, - setActiveDirectory, - refresh, - }) - - // Add parent directory to the dataset - const _datasetPage = useMemo(() => { - if (!dataset) { - return null - } - if (activeDirectory.length > 0 && dataset.length > 0) { - return [ - { - id: '..', - name: '..', - path: '..', - type: 'directory', - onClick: () => { - setActiveDirectory((p) => p.slice(0, -1)) - }, - } as ObjectData, - ...dataset, - ] - } - return dataset - // Purposely do not include activeDirectory - we only want to update - // when new data fetching is complete. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [dataset]) - - // Add drag and drop properties to the dataset - const datasetPage = useMemo(() => { - if (!_datasetPage) { - return null - } - return _datasetPage.map((d) => { - if ( - draggingObject && - draggingObject.id !== d.id && - d.type === 'directory' - ) { - return { - ...d, - isDroppable: true, - } - } - return { - ...d, - isDraggable: d.type !== 'bucket' && !d.isUploading, - } - }) - }, [_datasetPage, draggingObject]) - - const filteredTableColumns = useMemo( - () => - columns.filter( - (column) => column.fixed || enabledColumns.includes(column.id) - ), - [enabledColumns] - ) - - const dataState = useDatasetEmptyState( - dataset, - response.isValidating, - response.error, - filters - ) - - const isViewingBuckets = activeDirectory.length === 0 - const isViewingRootOfABucket = activeDirectory.length === 1 - const isViewingABucket = activeDirectory.length > 0 - - const navigateToFile = useCallback( - (path: string) => { - setActiveDirectory(() => [ - activeBucket, - ...getDirectorySegmentsFromPath(path), - ]) - setFilter({ - id: 'fileNamePrefix', - label: '', - value: getFilename(path), - }) - }, - [activeBucket, setActiveDirectory, setFilter] - ) - - return { - isViewingBuckets, - isViewingABucket, - isViewingRootOfABucket, - activeBucket, - activeDirectory, - setActiveDirectory, - activeDirectoryPath, - navigateToFile, - dataState, - refresh, - limit, - offset, - datasetPage, - pageCount: dataset?.length || 0, - columns: filteredTableColumns, - uploadFiles, - uploadsList, - uploadCancel, - downloadFiles, - downloadsList, - downloadCancel, - configurableColumns, - enabledColumns, - sortableColumns, - toggleColumnVisibility, - setColumnsVisible, - setColumnsHidden, - toggleSort, - setSortDirection, - setSortField, - sortField, - filters, - setFilter, - removeFilter, - removeLastFilter, - resetFilters, - sortDirection, - resetDefaultColumnVisibility, - getFileUrl, - onDragStart, - onDragEnd, - onDragMove, - onDragCancel, - onDragOver, - draggingObject, - } -} - -type State = ReturnType - -const FilesContext = createContext({} as State) -export const useFiles = () => useContext(FilesContext) - -type Props = { - children: React.ReactNode -} - -export function FilesProvider({ children }: Props) { - const state = useFilesMain() - return {children} -} diff --git a/apps/renterd/contexts/files/columns.tsx b/apps/renterd/contexts/filesDirectory/columns.tsx similarity index 91% rename from apps/renterd/contexts/files/columns.tsx rename to apps/renterd/contexts/filesDirectory/columns.tsx index 8dac5b739..b269b1882 100644 --- a/apps/renterd/contexts/files/columns.tsx +++ b/apps/renterd/contexts/filesDirectory/columns.tsx @@ -1,7 +1,6 @@ import { Button, LoadingDots, - TableColumn, Text, Tooltip, ValueNum, @@ -16,23 +15,10 @@ import { humanBytes } from '@siafoundation/units' import { FileContextMenu } from '../../components/Files/FileContextMenu' import { DirectoryContextMenu } from '../../components/Files/DirectoryContextMenu' import BigNumber from 'bignumber.js' -import { useFiles } from '.' -import { ObjectData, TableColumnId } from './types' import { FilesHealthColumn } from '../../components/Files/Columns/FilesHealthColumn' import { BucketContextMenu } from '../../components/Files/BucketContextMenu' - -type Context = { - currentHeight: number - contractsTimeRange: { - startHeight: number - endHeight: number - } -} - -type FilesTableColumn = TableColumn & { - fixed?: boolean - category?: string -} +import { FilesTableColumn } from '../filesManager/types' +import { useFilesManager } from '../filesManager' export const columns: FilesTableColumn[] = [ { @@ -43,7 +29,7 @@ export const columns: FilesTableColumn[] = [ render: function TypeColumn({ data: { isUploading, type, name, path, size }, }) { - const { setActiveDirectory } = useFiles() + const { setActiveDirectory } = useFilesManager() if (isUploading) { return ( + ) + } + if (name === '..') { + return ( + + ) + } + return type === 'bucket' ? ( + + ) : type === 'directory' ? ( + + ) : ( + + ) + }, + }, + { + id: 'name', + label: 'name', + category: 'general', + // contentClassName: 'max-w-[600px]', + render: function NameColumn({ data: { path, name, type } }) { + const { setActiveDirectory } = useFilesManager() + const key = getKeyFromPath(path).slice(1) + if (type === 'bucket') { + return ( + { + e.stopPropagation() + setActiveDirectory(() => [name]) + }} + > + {name} + + ) + } + if (type === 'directory') { + if (name === '..') { + return ( + { + e.stopPropagation() + setActiveDirectory((p) => p.slice(0, -1)) + }} + > + {key} + + ) + } + return ( + { + e.stopPropagation() + setActiveDirectory((p) => p.concat(name.slice(0, -1))) + }} + > + {key} + + ) + } + return ( + { + e.stopPropagation() + setActiveDirectory(() => getDirectorySegmentsFromPath(path)) + }} + > + {key} + + ) + }, + }, + { + id: 'readAccess', + label: 'public read access', + contentClassName: 'justify-center', + render: function ReadAccessColumn({ data }) { + if (data.name === '..') { + return null + } + const isPublic = data.bucket?.policy?.publicReadAccess + return ( + +
+ +
+
+ ) + }, + }, + { + id: 'size', + label: 'size', + contentClassName: 'justify-end', + render: function SizeColumn({ data: { type, name, size, isUploading } }) { + if (type === 'bucket') { + return null + } + if (isUploading) { + return + } + + if (name === '..') { + return null + } + + return ( + humanBytes(v.toNumber())} + /> + ) + }, + }, + { + id: 'health', + label: 'health', + contentClassName: 'justify-center', + render: function HealthColumn({ data }) { + if (data.type === 'bucket') { + return null + } + return + }, + }, +] diff --git a/apps/renterd/contexts/filesFlat/dataset.tsx b/apps/renterd/contexts/filesFlat/dataset.tsx new file mode 100644 index 000000000..4acb136c2 --- /dev/null +++ b/apps/renterd/contexts/filesFlat/dataset.tsx @@ -0,0 +1,73 @@ +import { ObjectListParams, useObjectList } from '@siafoundation/react-renterd' +import { SortField } from '../filesManager/types' +import { useDataset as useDatasetGeneric } from '../filesManager/dataset' +import { + ServerFilterItem, + minutesInMilliseconds, +} from '@siafoundation/design-system' +import { useRouter } from 'next/router' +import { useMemo } from 'react' +import { useFilesManager } from '../filesManager' + +type Props = { + setActiveDirectory: (func: (directory: string[]) => string[]) => void + activeDirectoryPath: string + sortDirection: 'asc' | 'desc' + sortField: SortField + filters: ServerFilterItem[] +} + +const defaultLimit = 50 + +export function useDataset({ sortDirection, sortField, filters }: Props) { + const { activeBucketName, fileNamePrefix } = useFilesManager() + const router = useRouter() + const limit = Number(router.query.limit || defaultLimit) + const marker = router.query.marker as string + + const params = useMemo(() => { + const p: ObjectListParams = { + bucket: activeBucketName, + sortBy: sortField, + sortDir: sortDirection, + marker, + limit, + } + if (fileNamePrefix) { + p.prefix = fileNamePrefix + } + return p + }, [ + activeBucketName, + fileNamePrefix, + sortField, + sortDirection, + marker, + limit, + ]) + + const response = useObjectList({ + disabled: !activeBucketName, + payload: params, + config: { + swr: { + refreshInterval: minutesInMilliseconds(1), + }, + }, + }) + + const d = useDatasetGeneric({ + objects: { + isValidating: response.isValidating, + data: response.data?.objects, + }, + }) + + return { + limit, + marker, + response, + dataset: d.data, + refresh: response.mutate, + } +} diff --git a/apps/renterd/contexts/filesFlat/index.tsx b/apps/renterd/contexts/filesFlat/index.tsx new file mode 100644 index 000000000..567611eba --- /dev/null +++ b/apps/renterd/contexts/filesFlat/index.tsx @@ -0,0 +1,76 @@ +import { useDatasetEmptyState } from '@siafoundation/design-system' +import { createContext, useContext, useMemo } from 'react' +import { useDataset } from './dataset' +import { useFilesManager } from '../filesManager' +import { columns } from './columns' + +function useFilesFlatMain() { + const { + activeDirectoryPath, + setActiveDirectory, + sortDirection, + sortField, + filters, + enabledColumns, + } = useFilesManager() + const { limit, response, refresh, dataset } = useDataset({ + activeDirectoryPath, + setActiveDirectory, + sortField, + sortDirection, + filters, + }) + const nextMarker = response.data?.nextMarker + const isMore = response.data?.hasMore + + const datasetPage = useMemo(() => { + return dataset + }, [dataset]) + + const dataState = useDatasetEmptyState( + dataset, + response.isValidating, + response.error, + filters + ) + + const filteredTableColumns = useMemo( + () => + columns.filter( + (column) => column.fixed || enabledColumns.includes(column.id) + ), + [enabledColumns] + ) + + return { + dataState, + refresh, + limit, + datasetPage, + columns: filteredTableColumns, + nextMarker, + isMore, + pageCount: dataset?.length || 0, + sortField, + filters, + sortDirection, + } +} + +type State = ReturnType + +const FilesFlatContext = createContext({} as State) +export const useFilesFlat = () => useContext(FilesFlatContext) + +type Props = { + children: React.ReactNode +} + +export function FilesFlatProvider({ children }: Props) { + const state = useFilesFlatMain() + return ( + + {children} + + ) +} diff --git a/apps/renterd/contexts/filesManager/dataset.tsx b/apps/renterd/contexts/filesManager/dataset.tsx new file mode 100644 index 000000000..565fe7b40 --- /dev/null +++ b/apps/renterd/contexts/filesManager/dataset.tsx @@ -0,0 +1,105 @@ +import { ObjEntry } from '@siafoundation/react-renterd' +import { sortBy, toPairs } from '@technically/lodash' +import useSWR from 'swr' +import { useContracts } from '../contracts' +import { ObjectData } from './types' +import { + buildDirectoryPath, + getFilename, + join, + isDirectory, +} from '../../lib/paths' +import { useFilesManager } from '.' + +type Props = { + objects: { + isValidating: boolean + data?: ObjEntry[] + } +} + +export function useDataset({ objects }: Props) { + const { + activeBucket, + activeBucketName, + fileNamePrefix, + uploadsList, + sortDirection, + sortField, + activeDirectoryPath, + buckets, + setActiveDirectory, + } = useFilesManager() + const { dataset: allContracts } = useContracts() + return useSWR( + objects.isValidating || buckets.isValidating + ? null + : [ + objects.data, + uploadsList, + allContracts, + buckets.data, + activeBucketName, + activeDirectoryPath, + ], + () => { + const dataMap: Record = {} + if (!activeBucket) { + buckets.data?.forEach((bucket) => { + const name = bucket.name + const path = buildDirectoryPath(name, '') + dataMap[name] = { + id: path, + path, + bucket, + size: 0, + health: 0, + name, + onClick: () => { + setActiveDirectory((p) => p.concat(name)) + }, + type: 'bucket', + } + }) + } else if (objects.data) { + objects.data?.forEach(({ name: key, size, health }) => { + const path = join(activeBucketName, key) + const name = getFilename(key) + dataMap[path] = { + id: path, + path, + bucket: activeBucket, + size, + health, + name, + onClick: isDirectory(key) + ? () => { + setActiveDirectory((p) => p.concat(name.slice(0, -1))) + } + : undefined, + type: isDirectory(key) ? 'directory' : 'file', + } + }) + uploadsList + .filter(({ path, name }) => path === join(activeDirectoryPath, name)) + .filter(({ path }) => + path.startsWith(join(activeBucketName, fileNamePrefix)) + ) + .forEach((upload) => { + dataMap[upload.path] = upload + }) + } + const all = sortBy( + toPairs(dataMap).map((p) => p[1]), + sortField as keyof ObjectData + ) + if (sortDirection === 'desc') { + all.reverse() + } + return all + }, + { + keepPreviousData: true, + } + ) +} diff --git a/apps/renterd/contexts/files/downloads.tsx b/apps/renterd/contexts/filesManager/downloads.tsx similarity index 99% rename from apps/renterd/contexts/files/downloads.tsx rename to apps/renterd/contexts/filesManager/downloads.tsx index 642cc58ab..e8f6f2a42 100644 --- a/apps/renterd/contexts/files/downloads.tsx +++ b/apps/renterd/contexts/filesManager/downloads.tsx @@ -8,7 +8,7 @@ import { bucketAndKeyParamsFromPath, getBucketFromPath, getFilename, -} from './paths' +} from '../../lib/paths' import { ObjectData } from './types' type DownloadProgress = ObjectData & { diff --git a/apps/renterd/contexts/filesManager/index.tsx b/apps/renterd/contexts/filesManager/index.tsx new file mode 100644 index 000000000..3ce0dd439 --- /dev/null +++ b/apps/renterd/contexts/filesManager/index.tsx @@ -0,0 +1,195 @@ +'use client' + +import { useServerFilters, useTableState } from '@siafoundation/design-system' +import { useParams, useAppRouter } from '@siafoundation/next' +import { createContext, useCallback, useContext, useMemo } from 'react' +import { columns } from '../filesDirectory/columns' +import { + defaultSortField, + columnsDefaultVisible, + sortOptions, + ExplorerMode, +} from './types' +import { + FullPath, + FullPathSegments, + getDirectorySegmentsFromPath, + getFilename, + pathSegmentsToPath, +} from '../../lib/paths' +import { useUploads } from './uploads' +import { useDownloads } from './downloads' +import { usePathname, useSearchParams } from '@siafoundation/next' +import { useBuckets } from '@siafoundation/react-renterd' +import { routes } from '../../config/routes' + +function useFilesManagerMain() { + const { + configurableColumns, + enabledColumns, + sortableColumns, + toggleColumnVisibility, + setColumnsVisible, + setColumnsHidden, + toggleSort, + setSortDirection, + setSortField, + sortField, + sortDirection, + resetDefaultColumnVisibility, + } = useTableState('renterd/v0/objects', { + columns, + columnsDefaultVisible, + sortOptions, + defaultSortField, + }) + const router = useAppRouter() + const params = useParams<{ path: FullPathSegments }>() + const { filters, setFilter, removeFilter, removeLastFilter, resetFilters } = + useServerFilters() + const fileNamePrefix = useMemo(() => { + const prefix = filters.find((f) => f.id === 'fileNamePrefix')?.value + if (prefix) { + return prefix.startsWith('/') ? prefix : '/' + prefix + } + return '' + }, [filters]) + + // [bucket, key, directory] + const activeDirectory = useMemo( + () => (params?.path || []).map(decodeURIComponent), + [params?.path] + ) + + // bucket + const activeBucketName = useMemo(() => { + return activeDirectory[0] + }, [activeDirectory]) + const buckets = useBuckets() + const activeBucket = buckets.data?.find((b) => b.name === activeBucketName) + + // bucket/key/directory/ + const activeDirectoryPath = useMemo(() => { + return pathSegmentsToPath(activeDirectory) + '/' + }, [activeDirectory]) + + const setActiveDirectory = useCallback( + ( + fn: (activeDirectory: FullPathSegments) => FullPathSegments, + explorerMode?: ExplorerMode + ) => { + const nextActiveDirectory = fn(activeDirectory) + let route = + routes.files.index + + '/' + + nextActiveDirectory.map(encodeURIComponent).join('/') + if (explorerMode === 'flat') { + route += `?view=flat` + } + router.push(route) + }, + [router, activeDirectory] + ) + + const { uploadFiles, uploadsList, uploadCancel } = useUploads({ + activeDirectoryPath, + }) + const { downloadFiles, downloadsList, getFileUrl, downloadCancel } = + useDownloads() + + const isViewingBuckets = activeDirectory.length === 0 + const isViewingRootOfABucket = activeDirectory.length === 1 + const isViewingABucket = activeDirectory.length > 0 + + const pathname = usePathname() + const activeParams = useSearchParams() + const activeViewMode: ExplorerMode = + activeParams.get('view') === 'flat' ? 'flat' : 'directory' + const toggleViewModeParams = useMemo(() => { + const switchParams = new URLSearchParams(activeParams) + if (switchParams.get('view') === 'flat') { + switchParams.delete('view') + } else { + switchParams.set('view', 'flat') + } + const str = switchParams.toString() + return str ? `?${str}` : str + }, [activeParams]) + const switchViewModeUrl = `${pathname}${toggleViewModeParams}` + + const navigateToFileDirectory = useCallback( + (path: string) => { + setFilter({ + id: 'fileNamePrefix', + label: '', + value: getFilename(path), + }) + setActiveDirectory( + () => [ + activeBucketName, + ...getDirectorySegmentsFromPath(path.slice(1)), + ], + 'directory' + ) + }, + [activeBucketName, setActiveDirectory, setFilter] + ) + + return { + isViewingBuckets, + isViewingABucket, + isViewingRootOfABucket, + buckets, + activeBucket, + activeBucketName, + activeDirectory, + setActiveDirectory, + activeDirectoryPath, + navigateToFileDirectory, + uploadFiles, + uploadsList, + uploadCancel, + downloadFiles, + downloadsList, + downloadCancel, + configurableColumns, + enabledColumns, + sortableColumns, + toggleColumnVisibility, + setColumnsVisible, + setColumnsHidden, + toggleSort, + setSortDirection, + setSortField, + sortField, + filters, + fileNamePrefix, + setFilter, + removeFilter, + removeLastFilter, + resetFilters, + sortDirection, + resetDefaultColumnVisibility, + getFileUrl, + activeViewMode, + switchViewModeUrl, + } +} + +type State = ReturnType + +const FilesManagerContext = createContext({} as State) +export const useFilesManager = () => useContext(FilesManagerContext) + +type Props = { + children: React.ReactNode +} + +export function FilesManagerProvider({ children }: Props) { + const state = useFilesManagerMain() + return ( + + {children} + + ) +} diff --git a/apps/renterd/contexts/files/types.ts b/apps/renterd/contexts/filesManager/types.ts similarity index 72% rename from apps/renterd/contexts/files/types.ts rename to apps/renterd/contexts/filesManager/types.ts index e2b46665f..a723e9181 100644 --- a/apps/renterd/contexts/files/types.ts +++ b/apps/renterd/contexts/filesManager/types.ts @@ -1,5 +1,6 @@ import { Bucket } from '@siafoundation/react-renterd' -import { FullPath } from './paths' +import { FullPath } from '../../lib/paths' +import { TableColumn } from '@siafoundation/design-system' export type ObjectType = 'bucket' | 'directory' | 'file' @@ -28,6 +29,23 @@ export type TableColumnId = | 'size' | 'health' +export type FilesTableContext = { + currentHeight: number + contractsTimeRange: { + startHeight: number + endHeight: number + } +} + +export type FilesTableColumn = TableColumn< + TableColumnId, + ObjectData, + FilesTableContext +> & { + fixed?: boolean + category?: string +} + export const columnsDefaultVisible: TableColumnId[] = [ 'type', 'name', @@ -58,3 +76,5 @@ export const sortOptions: { id: SortField; label: string; category: string }[] = category: 'general', }, ] + +export type ExplorerMode = 'directory' | 'flat' diff --git a/apps/renterd/contexts/files/uploads.tsx b/apps/renterd/contexts/filesManager/uploads.tsx similarity index 97% rename from apps/renterd/contexts/files/uploads.tsx rename to apps/renterd/contexts/filesManager/uploads.tsx index 6288e7761..8316ee91c 100644 --- a/apps/renterd/contexts/files/uploads.tsx +++ b/apps/renterd/contexts/filesManager/uploads.tsx @@ -7,7 +7,11 @@ import { useBuckets, useObjectUpload } from '@siafoundation/react-renterd' import { throttle } from '@technically/lodash' import { useCallback, useMemo, useState } from 'react' import { ObjectData } from './types' -import { bucketAndKeyParamsFromPath, getBucketFromPath, join } from './paths' +import { + bucketAndKeyParamsFromPath, + getBucketFromPath, + join, +} from '../../lib/paths' type UploadProgress = ObjectData & { controller: AbortController diff --git a/apps/renterd/dialogs/AlertsDialog.tsx b/apps/renterd/dialogs/AlertsDialog.tsx index 7d3d7bde9..e524c89fe 100644 --- a/apps/renterd/dialogs/AlertsDialog.tsx +++ b/apps/renterd/dialogs/AlertsDialog.tsx @@ -26,8 +26,8 @@ import { useCallback } from 'react' import { ContractContextMenuFromId } from '../components/Contracts/ContractContextMenuFromId' import { HostContextMenu } from '../components/Hosts/HostContextMenu' import { useDialog } from '../contexts/dialog' -import { useFiles } from '../contexts/files' -import { getDirectorySegmentsFromPath } from '../contexts/files/paths' +import { useFilesManager } from '../contexts/filesManager' +import { getDirectorySegmentsFromPath } from '../lib/paths' type Props = { open: boolean @@ -177,7 +177,7 @@ const dataFields: Record< }, slabKey: { render: function SlabField({ value }: { value: string }) { - const { setActiveDirectory } = useFiles() + const { setActiveDirectory } = useFilesManager() const { closeDialog } = useDialog() const objects = useSlabObjects({ params: { diff --git a/apps/renterd/components/Files/FileRenameDialog.tsx b/apps/renterd/dialogs/FileRenameDialog.tsx similarity index 83% rename from apps/renterd/components/Files/FileRenameDialog.tsx rename to apps/renterd/dialogs/FileRenameDialog.tsx index 3d6c307d9..5a8680c8e 100644 --- a/apps/renterd/components/Files/FileRenameDialog.tsx +++ b/apps/renterd/dialogs/FileRenameDialog.tsx @@ -9,11 +9,12 @@ import { } from '@siafoundation/design-system' import { useCallback, useEffect, useMemo } from 'react' import { useForm } from 'react-hook-form' -import { useDialog } from '../../contexts/dialog' +import { useDialog } from '../contexts/dialog' import { useObjectRename } from '@siafoundation/react-renterd' -import { getFilename, isDirectory } from '../../contexts/files/paths' -import { getRenameFileRenameParams } from '../../contexts/files/rename' -import { useFiles } from '../../contexts/files' +import { getFilename, isDirectory } from '../lib/paths' +import { getRenameFileRenameParams } from '../lib/rename' +import { useFilesDirectory } from '../contexts/filesDirectory' +import { useFilesFlat } from '../contexts/filesFlat' function getDefaultValues(currentName: string) { return { @@ -63,7 +64,8 @@ export function FileRenameDialog({ onOpenChange, }: Props) { const { closeDialog } = useDialog() - const { refresh } = useFiles() + const { refresh: refreshDirectory } = useFilesDirectory() + const { refresh: refreshFlat } = useFilesFlat() let name = getFilename(originalPath || '') name = name.endsWith('/') ? name.slice(0, -1) : name @@ -97,7 +99,8 @@ export function FileRenameDialog({ if (response.error) { triggerErrorToast(response.error) } else { - refresh() + refreshDirectory() + refreshFlat() form.reset() closeDialog() triggerSuccessToast( @@ -105,7 +108,14 @@ export function FileRenameDialog({ ) } }, - [form, originalPath, refresh, objectRename, closeDialog] + [ + form, + originalPath, + refreshDirectory, + refreshFlat, + objectRename, + closeDialog, + ] ) const fields = useMemo(() => getFields({ currentName: name }), [name]) diff --git a/apps/renterd/components/Files/FilesBucketCreateDialog.tsx b/apps/renterd/dialogs/FilesBucketCreateDialog.tsx similarity index 97% rename from apps/renterd/components/Files/FilesBucketCreateDialog.tsx rename to apps/renterd/dialogs/FilesBucketCreateDialog.tsx index 9d18799f4..64e386674 100644 --- a/apps/renterd/components/Files/FilesBucketCreateDialog.tsx +++ b/apps/renterd/dialogs/FilesBucketCreateDialog.tsx @@ -10,7 +10,7 @@ import { } from '@siafoundation/design-system' import { useCallback, useMemo } from 'react' import { useForm } from 'react-hook-form' -import { useDialog } from '../../contexts/dialog' +import { useDialog } from '../contexts/dialog' import { useBucketCreate } from '@siafoundation/react-renterd' const defaultValues = { diff --git a/apps/renterd/components/Files/FilesBucketDeleteDialog.tsx b/apps/renterd/dialogs/FilesBucketDeleteDialog.tsx similarity index 98% rename from apps/renterd/components/Files/FilesBucketDeleteDialog.tsx rename to apps/renterd/dialogs/FilesBucketDeleteDialog.tsx index 0b1416578..9ef02be42 100644 --- a/apps/renterd/components/Files/FilesBucketDeleteDialog.tsx +++ b/apps/renterd/dialogs/FilesBucketDeleteDialog.tsx @@ -11,7 +11,7 @@ import { } from '@siafoundation/design-system' import { useCallback, useMemo } from 'react' import { useForm } from 'react-hook-form' -import { useDialog } from '../../contexts/dialog' +import { useDialog } from '../contexts/dialog' import { useBucketDelete } from '@siafoundation/react-renterd' const defaultValues = { diff --git a/apps/renterd/components/Files/FilesBucketPolicyDialog.tsx b/apps/renterd/dialogs/FilesBucketPolicyDialog.tsx similarity index 98% rename from apps/renterd/components/Files/FilesBucketPolicyDialog.tsx rename to apps/renterd/dialogs/FilesBucketPolicyDialog.tsx index 9aa3d8fc3..3e7d76529 100644 --- a/apps/renterd/components/Files/FilesBucketPolicyDialog.tsx +++ b/apps/renterd/dialogs/FilesBucketPolicyDialog.tsx @@ -10,7 +10,7 @@ import { } from '@siafoundation/design-system' import { useCallback, useEffect, useMemo } from 'react' import { useForm } from 'react-hook-form' -import { useDialog } from '../../contexts/dialog' +import { useDialog } from '../contexts/dialog' import { useBucket, useBucketPolicyUpdate } from '@siafoundation/react-renterd' const defaultValues = { diff --git a/apps/renterd/components/Files/FilesCreateDirectoryDialog.tsx b/apps/renterd/dialogs/FilesCreateDirectoryDialog.tsx similarity index 92% rename from apps/renterd/components/Files/FilesCreateDirectoryDialog.tsx rename to apps/renterd/dialogs/FilesCreateDirectoryDialog.tsx index 2fb12f322..76d6b7952 100644 --- a/apps/renterd/components/Files/FilesCreateDirectoryDialog.tsx +++ b/apps/renterd/dialogs/FilesCreateDirectoryDialog.tsx @@ -6,10 +6,10 @@ import { triggerToast, } from '@siafoundation/design-system' import { useObjectUpload } from '@siafoundation/react-renterd' -import { useFiles } from '../../contexts/files' +import { useFilesManager } from '../contexts/filesManager' import { useFormik } from 'formik' import * as Yup from 'yup' -import { bucketAndKeyParamsFromPath } from '../../contexts/files/paths' +import { bucketAndKeyParamsFromPath } from '../lib/paths' const initialValues = { name: '', @@ -30,7 +30,7 @@ export function FilesCreateDirectoryDialog({ open, onOpenChange, }: Props) { - const { activeDirectoryPath } = useFiles() + const { activeDirectoryPath } = useFilesManager() const upload = useObjectUpload() const formik = useFormik({ diff --git a/apps/renterd/components/Files/FilesSearchDialog/index.tsx b/apps/renterd/dialogs/FilesSearchDialog/index.tsx similarity index 88% rename from apps/renterd/components/Files/FilesSearchDialog/index.tsx rename to apps/renterd/dialogs/FilesSearchDialog/index.tsx index 970c0a86e..3d5fa88d9 100644 --- a/apps/renterd/components/Files/FilesSearchDialog/index.tsx +++ b/apps/renterd/dialogs/FilesSearchDialog/index.tsx @@ -1,5 +1,5 @@ import { Dialog } from '@siafoundation/design-system' -import { FilesSearchMenu } from '../FilesSearchMenu' +import { FilesSearchMenu } from '../../components/Files/FilesSearchMenu' type Props = { open: boolean diff --git a/apps/renterd/contexts/files/health.spec.ts b/apps/renterd/lib/health.spec.ts similarity index 100% rename from apps/renterd/contexts/files/health.spec.ts rename to apps/renterd/lib/health.spec.ts diff --git a/apps/renterd/contexts/files/health.ts b/apps/renterd/lib/health.ts similarity index 97% rename from apps/renterd/contexts/files/health.ts rename to apps/renterd/lib/health.ts index 1e566324f..a73b20ab4 100644 --- a/apps/renterd/contexts/files/health.ts +++ b/apps/renterd/lib/health.ts @@ -1,6 +1,6 @@ import { Obj, SlabSlice } from '@siafoundation/react-renterd' import { min } from '@technically/lodash' -import { ContractData } from '../contracts/types' +import { ContractData } from '../contexts/contracts/types' export function getObjectHealth( obj: Obj, diff --git a/apps/renterd/contexts/files/paths.spec.ts b/apps/renterd/lib/paths.spec.ts similarity index 100% rename from apps/renterd/contexts/files/paths.spec.ts rename to apps/renterd/lib/paths.spec.ts diff --git a/apps/renterd/contexts/files/paths.ts b/apps/renterd/lib/paths.ts similarity index 100% rename from apps/renterd/contexts/files/paths.ts rename to apps/renterd/lib/paths.ts diff --git a/apps/renterd/contexts/files/rename.spec.ts b/apps/renterd/lib/rename.spec.ts similarity index 100% rename from apps/renterd/contexts/files/rename.spec.ts rename to apps/renterd/lib/rename.spec.ts diff --git a/apps/renterd/contexts/files/rename.ts b/apps/renterd/lib/rename.ts similarity index 100% rename from apps/renterd/contexts/files/rename.ts rename to apps/renterd/lib/rename.ts diff --git a/libs/design-system/src/app/AppNavbar.tsx b/libs/design-system/src/app/AppNavbar.tsx index c2a48dcfc..a2feabf25 100644 --- a/libs/design-system/src/app/AppNavbar.tsx +++ b/libs/design-system/src/app/AppNavbar.tsx @@ -28,7 +28,7 @@ export function AppNavbar({ title, nav, stats, actions }: Props) { ) ) : null}
-
+
{nav}
{actions}
diff --git a/libs/design-system/src/components/PaginatorMarker.tsx b/libs/design-system/src/components/PaginatorMarker.tsx new file mode 100644 index 000000000..cd26409c4 --- /dev/null +++ b/libs/design-system/src/components/PaginatorMarker.tsx @@ -0,0 +1,76 @@ +'use client' + +import { Button } from '../core/Button' +import { ControlGroup } from '../core/ControlGroup' +import { CaretRight16, PageFirst16 } from '@siafoundation/react-icons' +import { usePagesRouter } from '@siafoundation/next' +import { LoadingDots } from './LoadingDots' + +type Props = { + marker?: string + isMore: boolean + limit: number + pageTotal: number + isLoading: boolean +} + +export function PaginatorMarker({ + marker, + isMore, + pageTotal, + isLoading, +}: Props) { + const router = usePagesRouter() + return ( + + + {isLoading ? ( + + ) : pageTotal ? ( + + ) : ( + + )} + + + ) +} diff --git a/libs/design-system/src/hooks/useServerFilters.ts b/libs/design-system/src/hooks/useServerFilters.ts index b47dc73fb..e3c72a3e4 100644 --- a/libs/design-system/src/hooks/useServerFilters.ts +++ b/libs/design-system/src/hooks/useServerFilters.ts @@ -1,6 +1,6 @@ 'use client' -import { usePagesRouter } from '@siafoundation/next' +import { useAppRouter, usePathname, useSearchParams } from '@siafoundation/next' import { useCallback, useState } from 'react' export type ServerFilterItem = { @@ -12,18 +12,27 @@ export type ServerFilterItem = { } export function useServerFilters() { - const router = usePagesRouter() + const router = useAppRouter() + const pathname = usePathname() + const searchParams = useSearchParams() const [filters, _setFilters] = useState([]) const removePagination = useCallback(() => { + // These can be undefined when the page is still initializing + if (!router || !pathname) { + return + } // remove any limit and offset - const query = { ...router.query } - delete query['limit'] - delete query['offset'] - router.replace({ - query, - }) - }, [router]) + const query = new URLSearchParams(searchParams) + query.delete('limit') + query.delete('offset') + const str = query.toString() + if (str) { + router.replace(`${pathname}?${str}`) + } else { + router.replace(pathname) + } + }, [router, searchParams, pathname]) const setFilter = useCallback( (item: ServerFilterItem) => { diff --git a/libs/design-system/src/index.ts b/libs/design-system/src/index.ts index 9b5bb303e..0d7f9a757 100644 --- a/libs/design-system/src/index.ts +++ b/libs/design-system/src/index.ts @@ -74,6 +74,7 @@ export * from './components/SectionHeading' export * from './components/LoadingDots' export * from './components/PaginatorKnownTotal' export * from './components/PaginatorUnknownTotal' +export * from './components/PaginatorMarker' // app export * from './app/AppPublicLayout' diff --git a/libs/next/src/index.ts b/libs/next/src/index.ts index 2ae4b5ea1..6dc116bb1 100644 --- a/libs/next/src/index.ts +++ b/libs/next/src/index.ts @@ -3,6 +3,7 @@ import Head from 'next/head.js' import Image from 'next/image.js' import { useParams, + useSearchParams, usePathname, useRouter as useAppRouter, } from 'next/navigation.js' @@ -18,6 +19,7 @@ export { Head, Image, useParams, + useSearchParams, usePathname, useAppRouter, usePagesRouter, diff --git a/libs/react-renterd/src/bus.ts b/libs/react-renterd/src/bus.ts index 23bfe55d3..df98b9432 100644 --- a/libs/react-renterd/src/bus.ts +++ b/libs/react-renterd/src/bus.ts @@ -556,11 +556,33 @@ export type ObjectDirectoryParams = { } export function useObjectDirectory( - args: HookArgsSwr + args: HookArgsSwr< + ObjectDirectoryParams, + { hasMore: boolean; entries: ObjEntry[] } + > ) { return useGetSwr({ ...args, route: '/bus/objects/:key' }) } +export type ObjectListParams = { + bucket: string + limit?: number + prefix?: string + marker?: string + sortBy?: 'name' | 'health' | 'size' + sortDir?: 'asc' | 'desc' +} + +export function useObjectList( + args: HookArgsWithPayloadSwr< + void, + ObjectListParams, + { hasMore: boolean; nextMarker: string; objects: ObjEntry[] } + > +) { + return usePostSwr({ ...args, route: '/bus/objects/list' }) +} + export function useObject( args: HookArgsSwr<{ key: string; bucket: string }, { object: Obj }> ) { @@ -569,7 +591,7 @@ export function useObject( export function useObjectSearch( args: HookArgsSwr< - { key: string; bucket: string; skip: number; limit: number }, + { key: string; bucket: string; offset: number; limit: number }, ObjEntry[] > ) {