From 8d0c5eaba4a6e71f3e3930fd55dcd10f875e37c6 Mon Sep 17 00:00:00 2001 From: Alex Freska Date: Tue, 20 Feb 2024 10:35:00 -0500 Subject: [PATCH] feat: renterd refine file explorer modes --- .changeset/gentle-rockets-fry.md | 5 + .changeset/ten-yaks-relate.md | 5 + .changeset/thick-ears-refuse.md | 5 + .changeset/wicked-dragons-doubt.md | 5 + .../Files/FileContextMenu/index.tsx | 4 +- .../Files/FilesBreadcrumbMenuMode.tsx | 20 --- .../Files/FilesCmd/FilesSearchCmd/index.tsx | 7 +- .../Files/FilesExplorerModeButton.tsx | 33 +++++ .../Files/FilesFilterDirectoryMenu/index.tsx | 17 ++- .../Files/FilesSearchMenu/index.tsx | 10 +- .../FilesStatsMenuCount.tsx | 5 +- apps/renterd/components/Files/index.tsx | 10 +- .../FilesDirectory/FilesBreadcrumbMenu.tsx | 6 +- .../FilesFlat/FilesBreadcrumbMenu.tsx | 6 +- .../contexts/filesDirectory/columns.tsx | 5 +- .../contexts/filesDirectory/dataset.tsx | 10 +- apps/renterd/contexts/filesFlat/columns.tsx | 51 +------ apps/renterd/contexts/filesFlat/dataset.tsx | 14 +- .../renterd/contexts/filesManager/dataset.tsx | 4 +- .../contexts/filesManager/index.spec.tsx | 127 ++++++++++++++++++ apps/renterd/contexts/filesManager/index.tsx | 125 ++++++++++------- 21 files changed, 312 insertions(+), 162 deletions(-) create mode 100644 .changeset/gentle-rockets-fry.md create mode 100644 .changeset/ten-yaks-relate.md create mode 100644 .changeset/thick-ears-refuse.md create mode 100644 .changeset/wicked-dragons-doubt.md delete mode 100644 apps/renterd/components/Files/FilesBreadcrumbMenuMode.tsx create mode 100644 apps/renterd/components/Files/FilesExplorerModeButton.tsx create mode 100644 apps/renterd/contexts/filesManager/index.spec.tsx diff --git a/.changeset/gentle-rockets-fry.md b/.changeset/gentle-rockets-fry.md new file mode 100644 index 000000000..bfbdda853 --- /dev/null +++ b/.changeset/gentle-rockets-fry.md @@ -0,0 +1,5 @@ +--- +'renterd': patch +--- + +The file breadcrumb nav now shows the root as "Buckets". diff --git a/.changeset/ten-yaks-relate.md b/.changeset/ten-yaks-relate.md new file mode 100644 index 000000000..ddbd961a0 --- /dev/null +++ b/.changeset/ten-yaks-relate.md @@ -0,0 +1,5 @@ +--- +'renterd': patch +--- + +The explorer mode switcher button is now disabled when viewing buckets. Closes https://github.com/SiaFoundation/renterd/issues/973 diff --git a/.changeset/thick-ears-refuse.md b/.changeset/thick-ears-refuse.md new file mode 100644 index 000000000..7a44e3eb0 --- /dev/null +++ b/.changeset/thick-ears-refuse.md @@ -0,0 +1,5 @@ +--- +'renterd': minor +--- + +File explorer navigation actions now retain the active explorer mode. diff --git a/.changeset/wicked-dragons-doubt.md b/.changeset/wicked-dragons-doubt.md new file mode 100644 index 000000000..6bb9f81d0 --- /dev/null +++ b/.changeset/wicked-dragons-doubt.md @@ -0,0 +1,5 @@ +--- +'renterd': minor +--- + +The selected file explorer mode is now persisted between sessions. diff --git a/apps/renterd/components/Files/FileContextMenu/index.tsx b/apps/renterd/components/Files/FileContextMenu/index.tsx index 16c3f65b2..82f09dda7 100644 --- a/apps/renterd/components/Files/FileContextMenu/index.tsx +++ b/apps/renterd/components/Files/FileContextMenu/index.tsx @@ -29,7 +29,7 @@ type Props = { } export function FileContextMenu({ path }: Props) { - const { downloadFiles, getFileUrl, navigateToFileDirectory } = + const { downloadFiles, getFileUrl, navigateToModeSpecificFiltering } = useFilesManager() const deleteFile = useFileDelete() const { openDialog } = useDialog() @@ -69,7 +69,7 @@ export function FileContextMenu({ path }: Props) { Filter { - navigateToFileDirectory(path) + navigateToModeSpecificFiltering(path) }} > diff --git a/apps/renterd/components/Files/FilesBreadcrumbMenuMode.tsx b/apps/renterd/components/Files/FilesBreadcrumbMenuMode.tsx deleted file mode 100644 index c503046bb..000000000 --- a/apps/renterd/components/Files/FilesBreadcrumbMenuMode.tsx +++ /dev/null @@ -1,20 +0,0 @@ -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 2aa8a89e8..da02b2707 100644 --- a/apps/renterd/components/Files/FilesCmd/FilesSearchCmd/index.tsx +++ b/apps/renterd/components/Files/FilesCmd/FilesSearchCmd/index.tsx @@ -27,13 +27,14 @@ export function FilesSearchCmd({ beforeSelect?: () => void afterSelect?: () => void }) { - const { activeBucketName: activeBucket, navigateToFileDirectory } = + const { activeBucketName: activeBucket, navigateToModeSpecificFiltering } = useFilesManager() const onSearchPage = currentPage?.namespace === filesSearchPage.namespace + const searchBucket = activeBucket || 'default' const results = useObjectSearch({ disabled: !onSearchPage, params: { - bucket: activeBucket || 'default', + bucket: searchBucket, key: debouncedSearch, offset: 0, limit: 10, @@ -62,7 +63,7 @@ export function FilesSearchCmd({ key={path} onSelect={() => { beforeSelect() - navigateToFileDirectory(path) + navigateToModeSpecificFiltering(searchBucket + path) afterSelect() }} value={path} diff --git a/apps/renterd/components/Files/FilesExplorerModeButton.tsx b/apps/renterd/components/Files/FilesExplorerModeButton.tsx new file mode 100644 index 000000000..7030c89d0 --- /dev/null +++ b/apps/renterd/components/Files/FilesExplorerModeButton.tsx @@ -0,0 +1,33 @@ +import { Button, Tooltip } from '@siafoundation/design-system' +import { BucketIcon, Earth16, Folder16 } from '@siafoundation/react-icons' +import { useFilesManager } from '../../contexts/filesManager' + +export function FilesExplorerModeButton() { + const { isViewingBuckets, toggleExplorerMode, activeExplorerMode } = + useFilesManager() + + if (isViewingBuckets) { + return ( + +
+ +
+
+ ) + } + + return ( + + ) +} diff --git a/apps/renterd/components/Files/FilesFilterDirectoryMenu/index.tsx b/apps/renterd/components/Files/FilesFilterDirectoryMenu/index.tsx index 86aeef850..44b434849 100644 --- a/apps/renterd/components/Files/FilesFilterDirectoryMenu/index.tsx +++ b/apps/renterd/components/Files/FilesFilterDirectoryMenu/index.tsx @@ -9,20 +9,23 @@ type Props = { } export function FilesFilterDirectoryMenu({ placeholder }: Props) { - const { filters, setFilter, removeFilter } = useFilesManager() - const [search, setSearch] = useState('') + const { setFilter, removeFilter, fileNamePrefixFilter } = useFilesManager() + const [search, setSearch] = useState(fileNamePrefixFilter) const [debouncedSearch] = useDebounce(search, 500) + // Update search value directly when fileNamePrefixFilter changes useEffect(() => { - const fileNamePrefixFilter = filters.find((f) => f.id === 'fileNamePrefix') - const fileNamePrefix = fileNamePrefixFilter?.value || '' - if (fileNamePrefix !== search) { - setSearch(fileNamePrefix) + if (fileNamePrefixFilter !== search) { + setSearch(fileNamePrefixFilter) } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [setSearch, filters]) + }, [fileNamePrefixFilter]) + // Update and trigger the server filter if the debounced search value changes useEffect(() => { + if (fileNamePrefixFilter === debouncedSearch) { + return + } if (debouncedSearch.length) { setFilter({ id: 'fileNamePrefix', diff --git a/apps/renterd/components/Files/FilesSearchMenu/index.tsx b/apps/renterd/components/Files/FilesSearchMenu/index.tsx index e1439c2ea..b0936f11a 100644 --- a/apps/renterd/components/Files/FilesSearchMenu/index.tsx +++ b/apps/renterd/components/Files/FilesSearchMenu/index.tsx @@ -8,9 +8,8 @@ import { import { Command } from 'cmdk' import { useCallback, useState } from 'react' import { useDialog } from '../../../contexts/dialog' -import { useRouter } from 'next/router' +import { useAppRouter, usePathname } from '@siafoundation/next' import { routes } from '../../../config/routes' -import { useContracts } from '../../../contexts/contracts' import { FilesSearchCmd, filesSearchPage, @@ -23,9 +22,9 @@ type Props = { } export function FilesSearchMenu({ panel }: Props) { - const { resetFilters } = useContracts() const { closeDialog } = useDialog() - const router = useRouter() + const router = useAppRouter() + const pathname = usePathname() const [search, setSearch] = useState('') const [debouncedSearch] = useDebounce(search, 500) @@ -61,10 +60,9 @@ export function FilesSearchMenu({ panel }: Props) { currentPage={filesSearchPage} beforeSelect={() => { beforeSelect() - resetFilters() }} afterSelect={() => { - if (!router.pathname.startsWith(routes.files.index)) { + if (!pathname.startsWith(routes.files.index)) { router.push(routes.files.index) } }} diff --git a/apps/renterd/components/Files/FilesStatsMenuShared/FilesStatsMenuCount.tsx b/apps/renterd/components/Files/FilesStatsMenuShared/FilesStatsMenuCount.tsx index ab2601ded..6f8b0f83f 100644 --- a/apps/renterd/components/Files/FilesStatsMenuShared/FilesStatsMenuCount.tsx +++ b/apps/renterd/components/Files/FilesStatsMenuShared/FilesStatsMenuCount.tsx @@ -5,11 +5,12 @@ import { useFilesDirectory } from '../../../contexts/filesDirectory' import { useFilesFlat } from '../../../contexts/filesFlat' export function FilesStatsMenuCount() { - const { isViewingABucket, uploadsList, activeViewMode } = useFilesManager() + const { isViewingABucket, uploadsList, activeExplorerMode } = + useFilesManager() const { pageCount: directoryPageCount } = useFilesDirectory() const { pageCount: flatPageCount } = useFilesFlat() const pageCount = - activeViewMode === 'flat' ? flatPageCount : directoryPageCount + activeExplorerMode === 'flat' ? flatPageCount : directoryPageCount const stats = useObjectStats({ config: { diff --git a/apps/renterd/components/Files/index.tsx b/apps/renterd/components/Files/index.tsx index 073c991ff..db8fa41fd 100644 --- a/apps/renterd/components/Files/index.tsx +++ b/apps/renterd/components/Files/index.tsx @@ -1,15 +1,13 @@ import { FilesDirectory } from '../FilesDirectory' -import { useSearchParams } from '@siafoundation/next' import { FilesFlat } from '../FilesFlat' import { useFilesManager } from '../../contexts/filesManager' export function Files() { - const { isViewingBuckets } = useFilesManager() - const params = useSearchParams() + const { isViewingBuckets, activeExplorerMode } = useFilesManager() - if (params.get('view') === 'flat' && !isViewingBuckets) { - return + if (activeExplorerMode === 'directory' || isViewingBuckets) { + return } - return + return } diff --git a/apps/renterd/components/FilesDirectory/FilesBreadcrumbMenu.tsx b/apps/renterd/components/FilesDirectory/FilesBreadcrumbMenu.tsx index 200c5efb9..b6d251a12 100644 --- a/apps/renterd/components/FilesDirectory/FilesBreadcrumbMenu.tsx +++ b/apps/renterd/components/FilesDirectory/FilesBreadcrumbMenu.tsx @@ -2,7 +2,7 @@ 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' +import { FilesExplorerModeButton } from '../Files/FilesExplorerModeButton' export function FilesBreadcrumbMenu() { const { activeDirectory, setActiveDirectory } = useFilesManager() @@ -19,7 +19,7 @@ export function FilesBreadcrumbMenu() { return (
- +
- Files + Buckets {activeDirectory.length > 0 && ( diff --git a/apps/renterd/components/FilesFlat/FilesBreadcrumbMenu.tsx b/apps/renterd/components/FilesFlat/FilesBreadcrumbMenu.tsx index 19bb02fe5..3185b3687 100644 --- a/apps/renterd/components/FilesFlat/FilesBreadcrumbMenu.tsx +++ b/apps/renterd/components/FilesFlat/FilesBreadcrumbMenu.tsx @@ -1,7 +1,7 @@ import { Text, ScrollArea } from '@siafoundation/design-system' import { ChevronRight16 } from '@siafoundation/react-icons' import { useFilesManager } from '../../contexts/filesManager' -import { FilesBreadcrumbMenuMode } from '../Files/FilesBreadcrumbMenuMode' +import { FilesExplorerModeButton } from '../Files/FilesExplorerModeButton' export function FilesBreadcrumbMenu() { const { activeBucketName: activeBucket, setActiveDirectory } = @@ -9,7 +9,7 @@ export function FilesBreadcrumbMenu() { return (
- +
- Files + Buckets diff --git a/apps/renterd/contexts/filesDirectory/columns.tsx b/apps/renterd/contexts/filesDirectory/columns.tsx index b269b1882..d4e2d63ed 100644 --- a/apps/renterd/contexts/filesDirectory/columns.tsx +++ b/apps/renterd/contexts/filesDirectory/columns.tsx @@ -66,7 +66,8 @@ export const columns: FilesTableColumn[] = [ category: 'general', contentClassName: 'max-w-[600px]', render: function NameColumn({ data: { name, type } }) { - const { setActiveDirectory } = useFilesManager() + const { setActiveDirectoryAndFileNamePrefix, setActiveDirectory } = + useFilesManager() if (type === 'bucket') { return ( { e.stopPropagation() - setActiveDirectory(() => [name]) + setActiveDirectoryAndFileNamePrefix([name], '') }} > {name} diff --git a/apps/renterd/contexts/filesDirectory/dataset.tsx b/apps/renterd/contexts/filesDirectory/dataset.tsx index ada6a5954..7976c8b5c 100644 --- a/apps/renterd/contexts/filesDirectory/dataset.tsx +++ b/apps/renterd/contexts/filesDirectory/dataset.tsx @@ -15,7 +15,7 @@ export function useDataset() { const { activeBucketName, activeDirectoryPath, - fileNamePrefix, + fileNamePrefixFilter, sortDirection, sortField, } = useFilesManager() @@ -31,13 +31,15 @@ export function useDataset() { offset, limit, } - if (fileNamePrefix) { - p.prefix = fileNamePrefix.slice(1) + if (fileNamePrefixFilter) { + p.prefix = fileNamePrefixFilter.startsWith('/') + ? fileNamePrefixFilter.slice(1) + : fileNamePrefixFilter } return p }, [ activeDirectoryPath, - fileNamePrefix, + fileNamePrefixFilter, sortField, sortDirection, offset, diff --git a/apps/renterd/contexts/filesFlat/columns.tsx b/apps/renterd/contexts/filesFlat/columns.tsx index 281aece40..e1a43ac9c 100644 --- a/apps/renterd/contexts/filesFlat/columns.tsx +++ b/apps/renterd/contexts/filesFlat/columns.tsx @@ -5,12 +5,7 @@ import { Tooltip, ValueNum, } from '@siafoundation/design-system' -import { - Document16, - Earth16, - FolderIcon, - Locked16, -} from '@siafoundation/react-icons' +import { Document16, Earth16, Locked16 } from '@siafoundation/react-icons' import { humanBytes } from '@siafoundation/units' import { FileContextMenu } from '../../components/Files/FileContextMenu' import { DirectoryContextMenu } from '../../components/Files/DirectoryContextMenu' @@ -19,7 +14,7 @@ import { FilesHealthColumn } from '../../components/Files/Columns/FilesHealthCol import { BucketContextMenu } from '../../components/Files/BucketContextMenu' import { FilesTableColumn } from '../filesManager/types' import { useFilesManager } from '../filesManager' -import { getDirectorySegmentsFromPath, getKeyFromPath } from '../../lib/paths' +import { getKeyFromPath, getParentDirectoryPath } from '../../lib/paths' export const columns: FilesTableColumn[] = [ { @@ -30,7 +25,6 @@ export const columns: FilesTableColumn[] = [ render: function TypeColumn({ data: { isUploading, type, name, path, size }, }) { - const { setActiveDirectory } = useFilesManager() if (isUploading) { return ( ) } - if (name === '..') { - return ( - - ) - } return type === 'bucket' ? ( ) : type === 'directory' ? ( @@ -65,9 +45,8 @@ export const columns: FilesTableColumn[] = [ id: 'name', label: 'name', category: 'general', - // contentClassName: 'max-w-[600px]', render: function NameColumn({ data: { path, name, type } }) { - const { setActiveDirectory } = useFilesManager() + const { setFileNamePrefixFilter } = useFilesManager() const key = getKeyFromPath(path).slice(1) if (type === 'bucket') { return ( @@ -76,32 +55,12 @@ export const columns: FilesTableColumn[] = [ color="accent" weight="semibold" className="cursor-pointer" - onClick={(e) => { - 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))) + setFileNamePrefixFilter(getParentDirectoryPath(key)) }} > {key} @@ -124,7 +83,7 @@ export const columns: FilesTableColumn[] = [ className="cursor-pointer" onClick={(e) => { e.stopPropagation() - setActiveDirectory(() => getDirectorySegmentsFromPath(path)) + setFileNamePrefixFilter(getParentDirectoryPath(key)) }} > {key} diff --git a/apps/renterd/contexts/filesFlat/dataset.tsx b/apps/renterd/contexts/filesFlat/dataset.tsx index 22be5fb38..c697c4285 100644 --- a/apps/renterd/contexts/filesFlat/dataset.tsx +++ b/apps/renterd/contexts/filesFlat/dataset.tsx @@ -1,7 +1,6 @@ import { ObjectListParams, useObjectList } from '@siafoundation/react-renterd' import { SortField } from '../filesManager/types' import { useDataset as useDatasetGeneric } from '../filesManager/dataset' -import { ServerFilterItem } from '@siafoundation/design-system' import { useRouter } from 'next/router' import { useMemo } from 'react' import { useFilesManager } from '../filesManager' @@ -12,13 +11,12 @@ type Props = { activeDirectoryPath: string sortDirection: 'asc' | 'desc' sortField: SortField - filters: ServerFilterItem[] } const defaultLimit = 50 -export function useDataset({ sortDirection, sortField, filters }: Props) { - const { activeBucketName, fileNamePrefix } = useFilesManager() +export function useDataset({ sortDirection, sortField }: Props) { + const { activeBucketName, fileNamePrefixFilter } = useFilesManager() const router = useRouter() const limit = Number(router.query.limit || defaultLimit) const marker = router.query.marker as string @@ -31,13 +29,15 @@ export function useDataset({ sortDirection, sortField, filters }: Props) { marker, limit, } - if (fileNamePrefix) { - p.prefix = fileNamePrefix + if (fileNamePrefixFilter) { + p.prefix = fileNamePrefixFilter.startsWith('/') + ? fileNamePrefixFilter + : '/' + fileNamePrefixFilter } return p }, [ activeBucketName, - fileNamePrefix, + fileNamePrefixFilter, sortField, sortDirection, marker, diff --git a/apps/renterd/contexts/filesManager/dataset.tsx b/apps/renterd/contexts/filesManager/dataset.tsx index 565fe7b40..c0b7a71c5 100644 --- a/apps/renterd/contexts/filesManager/dataset.tsx +++ b/apps/renterd/contexts/filesManager/dataset.tsx @@ -22,7 +22,7 @@ export function useDataset({ objects }: Props) { const { activeBucket, activeBucketName, - fileNamePrefix, + fileNamePrefixFilter, uploadsList, sortDirection, sortField, @@ -83,7 +83,7 @@ export function useDataset({ objects }: Props) { uploadsList .filter(({ path, name }) => path === join(activeDirectoryPath, name)) .filter(({ path }) => - path.startsWith(join(activeBucketName, fileNamePrefix)) + path.startsWith(join(activeBucketName, fileNamePrefixFilter)) ) .forEach((upload) => { dataMap[upload.path] = upload diff --git a/apps/renterd/contexts/filesManager/index.spec.tsx b/apps/renterd/contexts/filesManager/index.spec.tsx new file mode 100644 index 000000000..0da4d27d3 --- /dev/null +++ b/apps/renterd/contexts/filesManager/index.spec.tsx @@ -0,0 +1,127 @@ +import { render, act, waitFor } from '@testing-library/react' +import { setupServer } from 'msw/node' +import { NextAppCsr } from '@siafoundation/design-system' +import { FilesManagerProvider, useFilesManager, FilesManagerState } from '.' +import { useEffect } from 'react' +import { mockApiBusBuckets, mockMatchMedia } from '../../mock/mock' +// eslint-disable-next-line @typescript-eslint/no-var-requires +const { usePathname, useAppRouter } = require('@siafoundation/next') + +// NOTE: Update to use a functional test router after migrating to vitest. +jest.mock('@siafoundation/next', () => { + const push = jest.fn() + return { + useAppRouter: jest.fn().mockReturnValue({ + query: {}, + push, + }), + useParams: jest.fn().mockReturnValue({ path: [] }), + usePathname: jest.fn().mockReturnValue('/files'), + useSearchParams: jest.fn().mockReturnValue({}), + } +}) + +const server = setupServer() +beforeAll(() => { + mockMatchMedia() + mockApiBusBuckets(server) + server.listen() +}) +beforeEach(() => { + localStorage.clear() +}) +afterEach(() => server.resetHandlers()) +afterAll(() => server.close()) + +describe('filesManager', () => { + it('directory mode navigates to directory with file filter', async () => { + useAppRouter.mockClear() + usePathname.mockReturnValue('/files/default/foo/bar/baz') + const context = mountProvider() + const { + activeExplorerMode, + fileNamePrefixFilter, + navigateToModeSpecificFiltering, + } = context.state + expect(activeExplorerMode).toBe('directory') + act(() => { + navigateToModeSpecificFiltering('/default/photos/cats') + }) + waitFor(() => { + const lastPushCallParams = getLastRouterPushCallParams() + expect(lastPushCallParams[0]).toBe('/files/default/photos') + expect(fileNamePrefixFilter).toBe('cats') + }) + }) + it('toggling to flat explorer mode applies the active directory as a filter', async () => { + useAppRouter.mockClear() + usePathname.mockReturnValue('/files/default/foo/bar/baz') + const context = mountProvider() + expect(context.state.activeExplorerMode).toBe('directory') + act(() => { + context.state.toggleExplorerMode() + }) + waitFor(() => { + expect(context.state.activeExplorerMode).toBe('flat') + const lastPushCallParams = getLastRouterPushCallParams() + expect(lastPushCallParams[0]).toBe('/files/default') + expect(context.state.fileNamePrefixFilter).toBe('/foo/bar/baz') + }) + }) + it('toggling from flat mode to directory clears the file filter', async () => { + useAppRouter.mockClear() + usePathname.mockReturnValue('/files/foo/bar/baz') + const context = mountProvider() + act(() => { + context.state.toggleExplorerMode() + }) + waitFor(() => { + expect(context.state.activeExplorerMode).toBe('flat') + expect(context.state.fileNamePrefixFilter).toBe('/foo/bar/baz') + }) + act(() => { + context.state.toggleExplorerMode() + }) + waitFor(() => { + expect(context.state.activeExplorerMode).toBe('directory') + const lastPushCallParams = getLastRouterPushCallParams() + expect(lastPushCallParams[0]).toBe('/files/default') + expect(context.state.fileNamePrefixFilter).toBe('cats') + }) + }) +}) + +function ContextConsumer({ + onContext, +}: { + onContext: (context: FilesManagerState) => void +}) { + const contextValues = useFilesManager() + + useEffect(() => { + onContext(contextValues) + }, [contextValues, onContext]) + + return null +} + +function mountProvider() { + const context: { state?: FilesManagerState } = {} + const onContext = (c) => { + context.state = c + } + render( + + + + + + ) + return context +} + +function getLastRouterPushCallParams() { + const mockPush = useAppRouter.mock.results.slice(-1)[0] + const lastPushCallParams = mockPush?.value.push.mock.calls.slice(-1)[0] + return lastPushCallParams +} diff --git a/apps/renterd/contexts/filesManager/index.tsx b/apps/renterd/contexts/filesManager/index.tsx index 3ce0dd439..d821cf6a4 100644 --- a/apps/renterd/contexts/filesManager/index.tsx +++ b/apps/renterd/contexts/filesManager/index.tsx @@ -15,13 +15,14 @@ import { FullPathSegments, getDirectorySegmentsFromPath, getFilename, + getKeyFromPath, 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' +import useLocalStorageState from 'use-local-storage-state' function useFilesManagerMain() { const { @@ -47,12 +48,9 @@ function useFilesManagerMain() { const params = useParams<{ path: FullPathSegments }>() const { filters, setFilter, removeFilter, removeLastFilter, resetFilters } = useServerFilters() - const fileNamePrefix = useMemo(() => { + const fileNamePrefixFilter = useMemo(() => { const prefix = filters.find((f) => f.id === 'fileNamePrefix')?.value - if (prefix) { - return prefix.startsWith('/') ? prefix : '/' + prefix - } - return '' + return prefix || '' }, [filters]) // [bucket, key, directory] @@ -73,19 +71,17 @@ function useFilesManagerMain() { return pathSegmentsToPath(activeDirectory) + '/' }, [activeDirectory]) + const [activeExplorerMode, setActiveExplorerMode] = + useLocalStorageState('renterd/v0/explorerMode', { + defaultValue: 'directory', + }) + const setActiveDirectory = useCallback( - ( - fn: (activeDirectory: FullPathSegments) => FullPathSegments, - explorerMode?: ExplorerMode - ) => { + (fn: (activeDirectory: FullPathSegments) => FullPathSegments) => { const nextActiveDirectory = fn(activeDirectory) - let route = - routes.files.index + - '/' + - nextActiveDirectory.map(encodeURIComponent).join('/') - if (explorerMode === 'flat') { - route += `?view=flat` - } + const route = `${routes.files.index}/${nextActiveDirectory + .map(encodeURIComponent) + .join('/')}` router.push(route) }, [router, activeDirectory] @@ -101,40 +97,69 @@ function useFilesManagerMain() { 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) => { + const setFileNamePrefixFilter = useCallback( + (value: string) => { setFilter({ id: 'fileNamePrefix', label: '', - value: getFilename(path), + value, }) - setActiveDirectory( - () => [ - activeBucketName, - ...getDirectorySegmentsFromPath(path.slice(1)), - ], - 'directory' + }, + [setFilter] + ) + + const setActiveDirectoryAndFileNamePrefix = useCallback( + (activeDirectory: string[], prefix: string) => { + setFileNamePrefixFilter(prefix) + setActiveDirectory(() => activeDirectory) + }, + [setActiveDirectory, setFileNamePrefixFilter] + ) + + const navigateToFileInFilteredDirectory = useCallback( + (path: FullPath) => { + setActiveDirectoryAndFileNamePrefix( + getDirectorySegmentsFromPath(path), + getFilename(path) ) }, - [activeBucketName, setActiveDirectory, setFilter] + [setActiveDirectoryAndFileNamePrefix] + ) + + const navigateToModeSpecificFiltering = useCallback( + (path: string) => { + if (activeExplorerMode === 'directory') { + navigateToFileInFilteredDirectory(path) + } else { + setFileNamePrefixFilter(getKeyFromPath(path).slice(1)) + } + }, + [ + activeExplorerMode, + navigateToFileInFilteredDirectory, + setFileNamePrefixFilter, + ] ) + const toggleExplorerMode = useCallback(async () => { + const nextMode = activeExplorerMode === 'directory' ? 'flat' : 'directory' + if (nextMode === 'flat') { + setActiveDirectoryAndFileNamePrefix( + [activeBucketName], + getKeyFromPath(activeDirectoryPath).slice(1) + ) + } else { + setActiveDirectoryAndFileNamePrefix([activeBucketName], '') + } + setActiveExplorerMode(nextMode) + }, [ + activeBucketName, + activeDirectoryPath, + activeExplorerMode, + setActiveExplorerMode, + setActiveDirectoryAndFileNamePrefix, + ]) + return { isViewingBuckets, isViewingABucket, @@ -144,8 +169,9 @@ function useFilesManagerMain() { activeBucketName, activeDirectory, setActiveDirectory, + setActiveDirectoryAndFileNamePrefix, activeDirectoryPath, - navigateToFileDirectory, + navigateToModeSpecificFiltering, uploadFiles, uploadsList, uploadCancel, @@ -163,7 +189,8 @@ function useFilesManagerMain() { setSortField, sortField, filters, - fileNamePrefix, + fileNamePrefixFilter, + setFileNamePrefixFilter, setFilter, removeFilter, removeLastFilter, @@ -171,14 +198,14 @@ function useFilesManagerMain() { sortDirection, resetDefaultColumnVisibility, getFileUrl, - activeViewMode, - switchViewModeUrl, + activeExplorerMode, + toggleExplorerMode, } } -type State = ReturnType +export type FilesManagerState = ReturnType -const FilesManagerContext = createContext({} as State) +const FilesManagerContext = createContext({} as FilesManagerState) export const useFilesManager = () => useContext(FilesManagerContext) type Props = {