From 9e9d2bcb37ca608ab7381893cdf1d067f27f6276 Mon Sep 17 00:00:00 2001 From: Alex Freska Date: Thu, 24 Oct 2024 16:11:56 -0400 Subject: [PATCH] feat(renterd): directory mode files multiselect and batch delete --- .changeset/bright-camels-confess.md | 5 + .changeset/early-toys-know.md | 5 + .changeset/lazy-pandas-report.md | 5 + .changeset/olive-cougars-divide.md | 5 + .changeset/two-seas-shake.md | 5 + apps/renterd-e2e/src/fixtures/files.ts | 97 +++++++++++++- .../sample-files}/sample.txt | 0 .../src/{specs => fixtures}/sample.txt | 0 apps/renterd-e2e/src/specs/files.spec.ts | 118 ++++++++++++++---- .../components/Files/BucketContextMenu.tsx | 2 +- .../components/Files/DirectoryContextMenu.tsx | 1 + .../Files/FileContextMenu/index.tsx | 15 ++- .../Files/FilesExplorerModeContextMenu.tsx | 1 + apps/renterd/components/Files/Layout.tsx | 32 +++-- .../Files/batchActions/FilesBatchDelete.tsx | 80 ++++++++++++ ...u.tsx => FilesDirectoryBreadcrumbMenu.tsx} | 2 +- .../FilesDirectoryBatchMenu.tsx | 13 ++ .../FilesDirectoryDockedControls/index.tsx | 5 + .../FilesDirectory/FilesExplorer.tsx | 2 + .../components/FilesFlat/FilesExplorer.tsx | 5 +- .../FilesFlatBatchMenu.tsx | 13 ++ .../FilesFlatDockedControls/index.tsx | 5 + .../contexts/filesDirectory/columns.tsx | 31 ++++- .../renterd/contexts/filesDirectory/index.tsx | 79 +++++++++--- apps/renterd/contexts/filesFlat/columns.tsx | 42 +++++-- apps/renterd/contexts/filesFlat/index.tsx | 61 ++++++++- .../renterd/contexts/filesManager/dataset.tsx | 13 +- .../contexts/filesManager/downloads.tsx | 17 ++- apps/renterd/contexts/filesManager/types.ts | 24 +++- .../renterd/contexts/filesManager/uploads.tsx | 21 ++-- apps/renterd/contexts/uploads/index.tsx | 4 +- 31 files changed, 606 insertions(+), 102 deletions(-) create mode 100644 .changeset/bright-camels-confess.md create mode 100644 .changeset/early-toys-know.md create mode 100644 .changeset/lazy-pandas-report.md create mode 100644 .changeset/olive-cougars-divide.md create mode 100644 .changeset/two-seas-shake.md rename apps/renterd-e2e/src/{specs/nested-sample => fixtures/sample-files}/sample.txt (100%) rename apps/renterd-e2e/src/{specs => fixtures}/sample.txt (100%) create mode 100644 apps/renterd/components/Files/batchActions/FilesBatchDelete.tsx rename apps/renterd/components/FilesDirectory/{FilesBreadcrumbMenu.tsx => FilesDirectoryBreadcrumbMenu.tsx} (97%) create mode 100644 apps/renterd/components/FilesDirectory/FilesDirectoryDockedControls/FilesDirectoryBatchMenu.tsx create mode 100644 apps/renterd/components/FilesDirectory/FilesDirectoryDockedControls/index.tsx create mode 100644 apps/renterd/components/FilesFlat/FilesFlatDockedControls/FilesFlatBatchMenu.tsx create mode 100644 apps/renterd/components/FilesFlat/FilesFlatDockedControls/index.tsx diff --git a/.changeset/bright-camels-confess.md b/.changeset/bright-camels-confess.md new file mode 100644 index 000000000..032d578eb --- /dev/null +++ b/.changeset/bright-camels-confess.md @@ -0,0 +1,5 @@ +--- +'renterd': minor +--- + +Navigating into a directory in the file explorer is now by clicking on the directory name rather than anywhere on the row. diff --git a/.changeset/early-toys-know.md b/.changeset/early-toys-know.md new file mode 100644 index 000000000..a898926cc --- /dev/null +++ b/.changeset/early-toys-know.md @@ -0,0 +1,5 @@ +--- +'renterd': minor +--- + +The directory-based file explorer now supports multiselect across any files and directories. diff --git a/.changeset/lazy-pandas-report.md b/.changeset/lazy-pandas-report.md new file mode 100644 index 000000000..1a8063131 --- /dev/null +++ b/.changeset/lazy-pandas-report.md @@ -0,0 +1,5 @@ +--- +'renterd': minor +--- + +The "all files" file explorer now supports multiselect across any files. diff --git a/.changeset/olive-cougars-divide.md b/.changeset/olive-cougars-divide.md new file mode 100644 index 000000000..4631b044b --- /dev/null +++ b/.changeset/olive-cougars-divide.md @@ -0,0 +1,5 @@ +--- +'renterd': minor +--- + +The "all files" file explorer multiselect menu now supports batch deletion of selected files. diff --git a/.changeset/two-seas-shake.md b/.changeset/two-seas-shake.md new file mode 100644 index 000000000..235e8a8bc --- /dev/null +++ b/.changeset/two-seas-shake.md @@ -0,0 +1,5 @@ +--- +'renterd': minor +--- + +The directory-based file explorer multiselect menu now supports batch deletion of selected files and directories. diff --git a/apps/renterd-e2e/src/fixtures/files.ts b/apps/renterd-e2e/src/fixtures/files.ts index 3f036901e..756edc244 100644 --- a/apps/renterd-e2e/src/fixtures/files.ts +++ b/apps/renterd-e2e/src/fixtures/files.ts @@ -1,6 +1,9 @@ import { Page, expect } from '@playwright/test' import { readFileSync } from 'fs' import { fillTextInputByName } from '@siafoundation/e2e' +import { navigateToBuckets } from './navigate' +import { openBucket } from './buckets' +import { join } from 'path' export async function deleteFile(page: Page, path: string) { await openFileContextMenu(page, path) @@ -62,12 +65,26 @@ export async function openFileContextMenu(page: Page, path: string) { } export async function openDirectory(page: Page, path: string) { - await page.getByTestId('filesTable').getByTestId(path).click() + const parts = path.split('/') + const name = parts[parts.length - 2] + '/' + await page.getByTestId('filesTable').getByTestId(path).getByText(name).click() for (const dir of path.split('/').slice(0, -1)) { await expect(page.getByTestId('navbar').getByText(dir)).toBeVisible() } } +export async function openDirectoryFromAnywhere(page: Page, path: string) { + const bucket = path.split('/')[0] + const dirParts = path.split('/').slice(1) + await navigateToBuckets({ page }) + await openBucket(page, path.split('/')[0]) + let currentPath = bucket + '/' + for (const dir of dirParts) { + currentPath += dir + '/' + await openDirectory(page, currentPath) + } +} + export async function navigateToParentDirectory(page: Page) { const isEmpty = await page .getByText('The current directory does not contain any files yet') @@ -107,11 +124,11 @@ export async function fileNotInList(page: Page, path: string) { await expect(page.getByTestId('filesTable').getByTestId(path)).toBeHidden() } -export async function getFileRowById(page: Page, id: string) { +export function getFileRowById(page: Page, id: string) { return page.getByTestId('filesTable').getByTestId(id) } -export async function dragAndDropFile( +async function simulateDragAndDropFile( page: Page, selector: string, filePath: string, @@ -139,3 +156,77 @@ export async function dragAndDropFile( await page.dispatchEvent(selector, 'drop', { dataTransfer }) } + +export async function dragAndDropFileFromSystem( + page: Page, + systemFilePath: string, + localFileName?: string +) { + await simulateDragAndDropFile( + page, + `[data-testid=filesDropzone]`, + join(__dirname, 'sample-files', systemFilePath), + '/' + (localFileName || systemFilePath) + ) +} + +export interface FileMap { + [key: string]: string | FileMap +} + +// Iterate through the file map and create files/directories. +export async function createFilesMap( + page: Page, + bucketName: string, + map: FileMap +) { + const create = async (map: FileMap, stack: string[]) => { + for (const name in map) { + await openDirectoryFromAnywhere(page, stack.join('/')) + const currentDirPath = stack.join('/') + const path = `${currentDirPath}/${name}` + if (!!map[name] && typeof map[name] === 'object') { + await createDirectory(page, name) + await fileInList(page, path + '/') + await create(map[name] as FileMap, stack.concat(name)) + } else { + await dragAndDropFileFromSystem(page, 'sample.txt', name) + await fileInList(page, path) + } + } + } + await create(map, [bucketName]) + await navigateToBuckets({ page }) + await openBucket(page, bucketName) +} + +interface FileExpectMap { + [key: string]: 'visible' | 'hidden' | FileExpectMap +} + +// Check each file and directory in the map exists. +export async function expectFilesMap( + page: Page, + bucketName: string, + map: FileExpectMap +) { + const check = async (map: FileMap, stack: string[]) => { + for (const name in map) { + await openDirectoryFromAnywhere(page, stack.join('/')) + const currentDirPath = stack.join('/') + const path = `${currentDirPath}/${name}` + if (typeof map[name] === 'string') { + const state = map[name] as 'visible' | 'hidden' + if (state === 'visible') { + await fileInList(page, path) + } else { + await fileNotInList(page, path) + } + } else { + await fileInList(page, path + '/') + await check(map[name] as FileMap, stack.concat(name)) + } + } + } + await check(map, [bucketName]) +} diff --git a/apps/renterd-e2e/src/specs/nested-sample/sample.txt b/apps/renterd-e2e/src/fixtures/sample-files/sample.txt similarity index 100% rename from apps/renterd-e2e/src/specs/nested-sample/sample.txt rename to apps/renterd-e2e/src/fixtures/sample-files/sample.txt diff --git a/apps/renterd-e2e/src/specs/sample.txt b/apps/renterd-e2e/src/fixtures/sample.txt similarity index 100% rename from apps/renterd-e2e/src/specs/sample.txt rename to apps/renterd-e2e/src/fixtures/sample.txt diff --git a/apps/renterd-e2e/src/specs/files.spec.ts b/apps/renterd-e2e/src/specs/files.spec.ts index 5acc452a1..6c46dc796 100644 --- a/apps/renterd-e2e/src/specs/files.spec.ts +++ b/apps/renterd-e2e/src/specs/files.spec.ts @@ -1,11 +1,9 @@ import { test, expect } from '@playwright/test' import { navigateToBuckets } from '../fixtures/navigate' import { createBucket, deleteBucket, openBucket } from '../fixtures/buckets' -import path from 'path' import { deleteDirectory, deleteFile, - dragAndDropFile, fileInList, fileNotInList, getFileRowById, @@ -13,6 +11,9 @@ import { openDirectory, openFileContextMenu, createDirectory, + dragAndDropFileFromSystem, + createFilesMap, + expectFilesMap, } from '../fixtures/files' import { afterTest, beforeTest } from '../fixtures/beforeTest' import { clearToasts, fillTextInputByName } from '@siafoundation/e2e' @@ -81,12 +82,7 @@ test('can create directory, upload file, rename file, navigate, delete a file, d await clearToasts({ page }) // Upload. - await dragAndDropFile( - page, - `[data-testid=filesDropzone]`, - path.join(__dirname, originalFileName), - originalFileName - ) + await dragAndDropFileFromSystem(page, originalFileName) await expect(page.getByText('100%')).toBeVisible() await fileInList(page, originalFilePath) @@ -104,12 +100,7 @@ test('can create directory, upload file, rename file, navigate, delete a file, d await clearToasts({ page }) // Upload the file again. - await dragAndDropFile( - page, - `[data-testid=filesDropzone]`, - path.join(__dirname, originalFileName), - originalFileName - ) + await dragAndDropFileFromSystem(page, originalFileName) await expect(page.getByText('100%')).toBeVisible() await fileInList(page, originalFilePath) @@ -131,7 +122,7 @@ test('shows a new intermediate directory when uploading nested files', async ({ const bucketName = 'files-test' const containerDir = 'test-dir' const containerDirPath = `${bucketName}/${containerDir}/` - const systemDir = 'nested-sample' + const systemDir = 'sample-files' const systemFile = 'sample.txt' const systemFilePath = `${systemDir}/${systemFile}` const dirPath = `${bucketName}/${containerDir}/${systemDir}/` @@ -154,12 +145,7 @@ test('shows a new intermediate directory when uploading nested files', async ({ await clearToasts({ page }) // Upload a nested file. - await dragAndDropFile( - page, - `[data-testid=filesDropzone]`, - path.join(__dirname, systemFilePath), - '/' + systemFilePath - ) + await dragAndDropFileFromSystem(page, systemFile, systemFilePath) await fileInList(page, dirPath) const dirRow = await getFileRowById(page, dirPath) // The intermediate directory should show up before the file is finished uploading. @@ -188,3 +174,93 @@ test('shows a new intermediate directory when uploading nested files', async ({ await navigateToBuckets({ page }) await deleteBucket(page, bucketName) }) + +test('batch delete across nested directories', async ({ page }) => { + test.setTimeout(120_000) + const bucketName = 'bucket1' + await navigateToBuckets({ page }) + await createBucket(page, bucketName) + await createFilesMap(page, bucketName, { + dir1: { + 'file1.txt': null, + 'file2.txt': null, + }, + dir2: { + 'file3.txt': null, + 'file4.txt': null, + 'file5.txt': null, + }, + }) + await navigateToBuckets({ page }) + await openBucket(page, bucketName) + + // Select entire dir1. + await getFileRowById(page, 'bucket1/dir1/').click() + await openDirectory(page, 'bucket1/dir2/') + + // Select file3 and file4. + await getFileRowById(page, 'bucket1/dir2/file3.txt').click() + await getFileRowById(page, 'bucket1/dir2/file4.txt').click() + const menu = page.getByLabel('file multiselect menu') + + // Delete selected files. + await menu.getByLabel('delete selected files').click() + const dialog = page.getByRole('dialog') + await dialog.getByRole('button', { name: 'Delete' }).click() + + await expectFilesMap(page, bucketName, { + 'dir1/': 'hidden', + dir2: { + 'file3.txt': 'hidden', + 'file4.txt': 'hidden', + 'file5.txt': 'visible', + }, + }) +}) + +test('batch delete using the all files explorer mode', async ({ page }) => { + test.setTimeout(120_000) + const bucketName = 'bucket1' + await navigateToBuckets({ page }) + await createBucket(page, bucketName) + await createFilesMap(page, bucketName, { + dir1: { + 'file1.txt': null, + 'file2.txt': null, + }, + dir2: { + 'file3.txt': null, + 'file4.txt': null, + 'file5.txt': null, + }, + }) + await navigateToBuckets({ page }) + await openBucket(page, bucketName) + await page.getByLabel('change explorer mode').click() + await page.getByRole('menuitem', { name: 'All files' }).click() + + // Select entire dir1. + await getFileRowById(page, 'bucket1/dir1/').click() + // Select file3 and file4. + await getFileRowById(page, 'bucket1/dir2/file3.txt').click() + await getFileRowById(page, 'bucket1/dir2/file4.txt').click() + const menu = page.getByLabel('file multiselect menu') + + // Delete selected files. + await menu.getByLabel('delete selected files').click() + const dialog = page.getByRole('dialog') + await dialog.getByRole('button', { name: 'Delete' }).click() + + // Change back to directory mode to validate. + await page.getByLabel('change explorer mode').click() + await page.getByRole('menuitem', { name: 'Directory' }).click() + + await expectFilesMap(page, bucketName, { + 'dir1/': 'hidden', + dir2: { + 'file3.txt': 'hidden', + 'file4.txt': 'hidden', + 'file5.txt': 'visible', + }, + }) +}) diff --git a/apps/renterd/components/Files/BucketContextMenu.tsx b/apps/renterd/components/Files/BucketContextMenu.tsx index b2c49d579..1b2f0cc3f 100644 --- a/apps/renterd/components/Files/BucketContextMenu.tsx +++ b/apps/renterd/components/Files/BucketContextMenu.tsx @@ -17,7 +17,7 @@ export function BucketContextMenu({ name }: Props) { return ( + } diff --git a/apps/renterd/components/Files/DirectoryContextMenu.tsx b/apps/renterd/components/Files/DirectoryContextMenu.tsx index 8106875e7..19e632cda 100644 --- a/apps/renterd/components/Files/DirectoryContextMenu.tsx +++ b/apps/renterd/components/Files/DirectoryContextMenu.tsx @@ -23,6 +23,7 @@ export function DirectoryContextMenu({ path, size }: Props) { trigger={ ) } - contentProps={{ align: 'start', ...contentProps }} + contentProps={{ + align: 'start', + ...contentProps, + onClick: (e) => { + e.stopPropagation() + }, + }} > Actions , + openSettings: () => openDialog('settings'), + nav: , + stats: , + actions: , + dockedControls: , + } + } + return { title: 'Files', navTitle: null, routes, sidenav: , openSettings: () => openDialog('settings'), - nav: - activeMode === 'directory' ? ( - - ) : ( - - ), + nav: , stats: , actions: , + dockedControls: , } } diff --git a/apps/renterd/components/Files/batchActions/FilesBatchDelete.tsx b/apps/renterd/components/Files/batchActions/FilesBatchDelete.tsx new file mode 100644 index 000000000..a0a8def17 --- /dev/null +++ b/apps/renterd/components/Files/batchActions/FilesBatchDelete.tsx @@ -0,0 +1,80 @@ +import { + Button, + Paragraph, + triggerSuccessToast, + triggerErrorToast, + MultiSelect, +} from '@siafoundation/design-system' +import { Delete16 } from '@siafoundation/react-icons' +import { useCallback, useMemo } from 'react' +import { useDialog } from '../../../contexts/dialog' +import { useObjectsRemove } from '@siafoundation/renterd-react' +import { ObjectData } from '../../../contexts/filesManager/types' + +export function FilesBatchDelete({ + multiSelect, +}: { + multiSelect: MultiSelect +}) { + const filesToDelete = useMemo( + () => + Object.entries(multiSelect.selectionMap).map(([_, item]) => ({ + bucket: item.bucket.name, + prefix: item.key, + })), + [multiSelect.selectionMap] + ) + const { openConfirmDialog } = useDialog() + const objectsRemove = useObjectsRemove() + const deleteFiles = useCallback(async () => { + const totalCount = filesToDelete.length + let errorCount = 0 + for (const { bucket, prefix } of filesToDelete) { + const response = await objectsRemove.post({ + payload: { + bucket, + prefix, + }, + }) + if (response.error) { + errorCount++ + } + } + if (errorCount > 0) { + triggerErrorToast({ + title: `${totalCount - errorCount} files deleted`, + body: `Error deleting ${errorCount}/${totalCount} total files.`, + }) + } else { + triggerSuccessToast({ title: `${totalCount} files deleted` }) + } + multiSelect.deselectAll() + }, [multiSelect, filesToDelete, objectsRemove]) + + return ( + + ) +} diff --git a/apps/renterd/components/FilesDirectory/FilesBreadcrumbMenu.tsx b/apps/renterd/components/FilesDirectory/FilesDirectoryBreadcrumbMenu.tsx similarity index 97% rename from apps/renterd/components/FilesDirectory/FilesBreadcrumbMenu.tsx rename to apps/renterd/components/FilesDirectory/FilesDirectoryBreadcrumbMenu.tsx index b6d251a12..05c06a3a2 100644 --- a/apps/renterd/components/FilesDirectory/FilesBreadcrumbMenu.tsx +++ b/apps/renterd/components/FilesDirectory/FilesDirectoryBreadcrumbMenu.tsx @@ -4,7 +4,7 @@ import { ChevronRight16 } from '@siafoundation/react-icons' import { useFilesManager } from '../../contexts/filesManager' import { FilesExplorerModeButton } from '../Files/FilesExplorerModeButton' -export function FilesBreadcrumbMenu() { +export function FilesDirectoryBreadcrumbMenu() { const { activeDirectory, setActiveDirectory } = useFilesManager() const ref = useRef(null) diff --git a/apps/renterd/components/FilesDirectory/FilesDirectoryDockedControls/FilesDirectoryBatchMenu.tsx b/apps/renterd/components/FilesDirectory/FilesDirectoryDockedControls/FilesDirectoryBatchMenu.tsx new file mode 100644 index 000000000..f8c8d0ffb --- /dev/null +++ b/apps/renterd/components/FilesDirectory/FilesDirectoryDockedControls/FilesDirectoryBatchMenu.tsx @@ -0,0 +1,13 @@ +import { MultiSelectionMenu } from '@siafoundation/design-system' +import { FilesBatchDelete } from '../../Files/batchActions/FilesBatchDelete' +import { useFilesDirectory } from '../../../contexts/filesDirectory' + +export function FilesDirectoryBatchMenu() { + const { multiSelect } = useFilesDirectory() + + return ( + + + + ) +} diff --git a/apps/renterd/components/FilesDirectory/FilesDirectoryDockedControls/index.tsx b/apps/renterd/components/FilesDirectory/FilesDirectoryDockedControls/index.tsx new file mode 100644 index 000000000..77feb42ce --- /dev/null +++ b/apps/renterd/components/FilesDirectory/FilesDirectoryDockedControls/index.tsx @@ -0,0 +1,5 @@ +import { FilesDirectoryBatchMenu } from './FilesDirectoryBatchMenu' + +export function FilesDirectoryDockedControls() { + return +} diff --git a/apps/renterd/components/FilesDirectory/FilesExplorer.tsx b/apps/renterd/components/FilesDirectory/FilesExplorer.tsx index e4085279a..f08d79a56 100644 --- a/apps/renterd/components/FilesDirectory/FilesExplorer.tsx +++ b/apps/renterd/components/FilesDirectory/FilesExplorer.tsx @@ -18,6 +18,7 @@ export function FilesExplorer() { datasetPage, pageCount, dataState, + cellContext, onDragEnd, onDragOver, onDragStart, @@ -40,6 +41,7 @@ export function FilesExplorer() { emptyState={} pageSize={10} data={datasetPage} + context={cellContext} columns={columns} sortableColumns={sortableColumns} sortField={sortField} diff --git a/apps/renterd/components/FilesFlat/FilesExplorer.tsx b/apps/renterd/components/FilesFlat/FilesExplorer.tsx index 39e1e7fc7..06053f043 100644 --- a/apps/renterd/components/FilesFlat/FilesExplorer.tsx +++ b/apps/renterd/components/FilesFlat/FilesExplorer.tsx @@ -6,14 +6,17 @@ import { columns } from '../../contexts/filesFlat/columns' export function FilesExplorer() { const { sortableColumns, toggleSort } = useFilesManager() - const { datasetPage, dataState, sortField, sortDirection } = useFilesFlat() + const { datasetPage, dataState, cellContext, sortField, sortDirection } = + useFilesFlat() return (
} pageSize={10} data={datasetPage} + context={cellContext} columns={columns} sortableColumns={sortableColumns} sortField={sortField} diff --git a/apps/renterd/components/FilesFlat/FilesFlatDockedControls/FilesFlatBatchMenu.tsx b/apps/renterd/components/FilesFlat/FilesFlatDockedControls/FilesFlatBatchMenu.tsx new file mode 100644 index 000000000..7354079b1 --- /dev/null +++ b/apps/renterd/components/FilesFlat/FilesFlatDockedControls/FilesFlatBatchMenu.tsx @@ -0,0 +1,13 @@ +import { MultiSelectionMenu } from '@siafoundation/design-system' +import { FilesBatchDelete } from '../../Files/batchActions/FilesBatchDelete' +import { useFilesFlat } from '../../../contexts/filesFlat' + +export function FilesFlatBatchMenu() { + const { multiSelect } = useFilesFlat() + + return ( + + + + ) +} diff --git a/apps/renterd/components/FilesFlat/FilesFlatDockedControls/index.tsx b/apps/renterd/components/FilesFlat/FilesFlatDockedControls/index.tsx new file mode 100644 index 000000000..48a9fb6e7 --- /dev/null +++ b/apps/renterd/components/FilesFlat/FilesFlatDockedControls/index.tsx @@ -0,0 +1,5 @@ +import { FilesFlatBatchMenu } from './FilesFlatBatchMenu' + +export function FilesFlatDockedControls() { + return +} diff --git a/apps/renterd/contexts/filesDirectory/columns.tsx b/apps/renterd/contexts/filesDirectory/columns.tsx index b901d8132..089d7f227 100644 --- a/apps/renterd/contexts/filesDirectory/columns.tsx +++ b/apps/renterd/contexts/filesDirectory/columns.tsx @@ -1,4 +1,11 @@ -import { Button, Text, Tooltip, ValueNum } from '@siafoundation/design-system' +import { + Button, + Checkbox, + ControlGroup, + Text, + Tooltip, + ValueNum, +} from '@siafoundation/design-system' import { Document16, Earth16, @@ -20,14 +27,28 @@ export const columns: FilesTableColumn[] = [ id: 'type', label: '', fixed: true, - cellClassName: 'w-[50px] !pl-2 !pr-2 [&+*]:!pl-0', + contentClassName: '!pl-3 !pr-4', + cellClassName: 'w-[20px] !pl-0 !pr-0', + heading: ({ context: { isViewingBuckets, multiSelect } }) => { + if (isViewingBuckets) { + return null + } + return ( + + + + ) + }, render: function TypeColumn({ data: { isUploading, type, name, path, size }, }) { const { setActiveDirectory } = useFilesManager() if (isUploading) { return ( - ) @@ -35,6 +56,7 @@ export const columns: FilesTableColumn[] = [ if (name === '..') { return ( ) @@ -48,16 +69,9 @@ export const columns: FilesTableColumn[] = [ const { setFileNamePrefixFilter } = useFilesManager() const key = getKeyFromPath(path).slice(1) if (type === 'bucket') { - return ( - - {name} - - ) + // This should never be possible because the global file browser is + // only rendered inside a specific bucket. + return null } if (type === 'directory') { return ( @@ -65,6 +79,7 @@ export const columns: FilesTableColumn[] = [ ellipsis color="accent" weight="semibold" + underline="hover" className="cursor-pointer" onClick={(e) => { e.stopPropagation() @@ -79,6 +94,7 @@ export const columns: FilesTableColumn[] = [ { e.stopPropagation() diff --git a/apps/renterd/contexts/filesFlat/index.tsx b/apps/renterd/contexts/filesFlat/index.tsx index b9d28bde2..eea515d48 100644 --- a/apps/renterd/contexts/filesFlat/index.tsx +++ b/apps/renterd/contexts/filesFlat/index.tsx @@ -1,19 +1,35 @@ -import { useDatasetEmptyState } from '@siafoundation/design-system' -import { createContext, useContext, useMemo } from 'react' +import { + useDatasetEmptyState, + useMultiSelect, +} from '@siafoundation/design-system' +import { + createContext, + MouseEvent, + useContext, + useEffect, + useMemo, +} from 'react' import { useDataset } from './dataset' import { useFilesManager } from '../filesManager' import { columns } from './columns' +import { CellContext } from '../filesManager/types' function useFilesFlatMain() { - const { sortDirection, sortField, filters, enabledColumns } = - useFilesManager() + const { + activeBucket, + sortDirection, + sortField, + filters, + enabledColumns, + isViewingBuckets, + } = useFilesManager() const { limit, response, isMore, refresh, dataset } = useDataset({ sortField, sortDirection, }) const nextMarker = response.data?.nextMarker - const datasetPage = useMemo(() => { + const _datasetPage = useMemo(() => { return dataset }, [dataset]) @@ -32,8 +48,43 @@ function useFilesFlatMain() { [enabledColumns] ) + const multiSelect = useMultiSelect(dataset) + + // If the active bucket changes, clear the multi-select. + useEffect(() => { + if (activeBucket) { + multiSelect.deselectAll() + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [activeBucket]) + + const datasetPage = useMemo(() => { + if (!_datasetPage) { + return undefined + } + return _datasetPage.map((datum) => { + return { + ...datum, + isSelected: !!multiSelect.selectionMap[datum.id], + onClick: (e: MouseEvent) => + multiSelect.onSelect(datum.id, e), + } + }) + }, [_datasetPage, multiSelect]) + + const cellContext = useMemo( + () => + ({ + isViewingBuckets, + multiSelect, + } as CellContext), + [multiSelect, isViewingBuckets] + ) + return { dataState, + multiSelect, + cellContext, refresh, limit, datasetPage, diff --git a/apps/renterd/contexts/filesManager/dataset.tsx b/apps/renterd/contexts/filesManager/dataset.tsx index 66d883b6c..fec8a14c3 100644 --- a/apps/renterd/contexts/filesManager/dataset.tsx +++ b/apps/renterd/contexts/filesManager/dataset.tsx @@ -8,6 +8,7 @@ import { getFilename, join, isDirectory, + getKeyFromPath, } from '../../lib/paths' import { useFilesManager } from '.' import { useEffect } from 'react' @@ -43,10 +44,12 @@ export function useDataset({ id, objects }: Props) { buckets.data?.forEach((bucket) => { const name = bucket.name const path = buildDirectoryPath(name, '') + const key = getKeyFromPath(path) dataMap[name] = { id: path, path, bucket, + key, size: 0, health: 0, name, @@ -64,14 +67,10 @@ export function useDataset({ id, objects }: Props) { id: path, path, bucket: activeBucket, + key, size, health, name, - onClick: isDirectory(key) - ? () => { - setActiveDirectory((p) => p.concat(name.slice(0, -1))) - } - : undefined, type: isDirectory(key) ? 'directory' : 'file', } }) @@ -99,12 +98,10 @@ export function useDataset({ id, objects }: Props) { id: newDirPath, path: newDirPath, bucket: activeBucket, + key: getKeyFromPath(newDirPath), size: 0, health: 0, name: newDirName + '/', - onClick: () => { - setActiveDirectory((p) => p.concat(newDirName)) - }, type: 'directory', } } diff --git a/apps/renterd/contexts/filesManager/downloads.tsx b/apps/renterd/contexts/filesManager/downloads.tsx index 49a68b214..314e5e808 100644 --- a/apps/renterd/contexts/filesManager/downloads.tsx +++ b/apps/renterd/contexts/filesManager/downloads.tsx @@ -8,6 +8,7 @@ import { bucketAndKeyParamsFromPath, getBucketFromPath, getFilename, + getKeyFromPath, } from '../../lib/paths' import { ObjectData } from './types' @@ -26,11 +27,11 @@ export function useDownloads() { const initDownloadProgress = useCallback( (obj: DownloadProgressParams) => { - setDownloadsMap((map) => ({ - ...map, - [obj.path]: { + setDownloadsMap((map) => { + const downloadProgress: DownloadProgress = { id: obj.path, path: obj.path, + key: obj.key, bucket: obj.bucket, name: obj.name, size: obj.size, @@ -38,8 +39,12 @@ export function useDownloads() { isUploading: false, controller: obj.controller, type: 'file', - }, - })) + } + return { + ...map, + [obj.path]: downloadProgress, + } + }) }, [setDownloadsMap] ) @@ -84,6 +89,7 @@ export function useDownloads() { files.forEach(async (path) => { let isDone = false const bucketName = getBucketFromPath(path) + const key = getKeyFromPath(path) const bucket = buckets.data?.find((b) => b.name === bucketName) if (!bucket) { triggerErrorToast({ title: 'Bucket not found', body: bucketName }) @@ -109,6 +115,7 @@ export function useDownloads() { }, 2000) initDownloadProgress({ path, + key, name, bucket, loaded: 0, diff --git a/apps/renterd/contexts/filesManager/types.ts b/apps/renterd/contexts/filesManager/types.ts index 8bae61497..40853b02f 100644 --- a/apps/renterd/contexts/filesManager/types.ts +++ b/apps/renterd/contexts/filesManager/types.ts @@ -1,16 +1,20 @@ import { Bucket } from '@siafoundation/renterd-types' import { FullPath } from '../../lib/paths' -import { TableColumn } from '@siafoundation/design-system' +import { MultiSelect, TableColumn } from '@siafoundation/design-system' import { MultipartUpload } from '../../lib/multipartUpload' +import { MouseEvent } from 'react' export type ObjectType = 'bucket' | 'directory' | 'file' export type ObjectData = { id: FullPath - // path is exacty bucket + returned key + bucket: Bucket + // path is exacty bucket + returned key. // eg: default + /path/to/file.txt = default/path/to/file.txt path: FullPath - bucket: Bucket + // key is the path without the bucket. + key: string + // name is the last segment of the path. name: string health?: number size: number @@ -19,7 +23,13 @@ export type ObjectData = { isDraggable?: boolean isDroppable?: boolean loaded?: number - onClick?: () => void + onClick?: (e: MouseEvent) => void + isSelected?: boolean +} + +export type CellContext = { + isViewingBuckets: boolean + multiSelect: MultiSelect } export type TableColumnId = @@ -30,7 +40,11 @@ export type TableColumnId = | 'size' | 'health' -export type FilesTableColumn = TableColumn & { +export type FilesTableColumn = TableColumn< + TableColumnId, + ObjectData, + CellContext +> & { fixed?: boolean category?: string } diff --git a/apps/renterd/contexts/filesManager/uploads.tsx b/apps/renterd/contexts/filesManager/uploads.tsx index 7c4141582..51555a13e 100644 --- a/apps/renterd/contexts/filesManager/uploads.tsx +++ b/apps/renterd/contexts/filesManager/uploads.tsx @@ -175,13 +175,14 @@ export function useUploads({ activeDirectoryPath }: Props) { return } const { uploadId, multipartUpload } = upload - setUploadsMap((map) => ({ - ...map, - [uploadId]: { + setUploadsMap((map) => { + const key = getKeyFromPath(path) + const uploadItem: ObjectUploadData = { id: uploadId, - path: path, - bucket: bucket, - name: name, + path, + key, + bucket, + name, size: uploadFile.size, loaded: 0, isUploading: true, @@ -194,8 +195,12 @@ export function useUploads({ activeDirectoryPath }: Props) { ref.current.removeUpload(uploadId) }, type: 'file', - }, - })) + } + return { + ...map, + [uploadId]: uploadItem, + } + }) }, [setUploadsMap, createMultipartUpload] ) diff --git a/apps/renterd/contexts/uploads/index.tsx b/apps/renterd/contexts/uploads/index.tsx index 73466f877..2edf51281 100644 --- a/apps/renterd/contexts/uploads/index.tsx +++ b/apps/renterd/contexts/uploads/index.tsx @@ -75,7 +75,8 @@ function useUploadsMain() { } return response.data.uploads.map((upload) => { const id = upload.uploadID - const name = getFilename(upload.key) + const key = upload.key + const name = getFilename(key) const fullPath = join(activeBucket.name, upload.key) const localUpload = uploadsMap[id] if (localUpload) { @@ -84,6 +85,7 @@ function useUploadsMain() { return { id, path: fullPath, + key, bucket: activeBucket, name, size: 1,