- 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 = {