diff --git a/.changeset/modern-bags-exercise.md b/.changeset/modern-bags-exercise.md new file mode 100644 index 000000000..87c4b7816 --- /dev/null +++ b/.changeset/modern-bags-exercise.md @@ -0,0 +1,5 @@ +--- +'renterd': minor +--- + +Files can now be moved by dragging into or out of directories. Closes https://github.com/SiaFoundation/renterd/issues/418 diff --git a/.changeset/sour-snakes-train.md b/.changeset/sour-snakes-train.md new file mode 100644 index 000000000..3e43a7b70 --- /dev/null +++ b/.changeset/sour-snakes-train.md @@ -0,0 +1,5 @@ +--- +'@siafoundation/design-system': minor +--- + +The Table now supports drag and drop on rows. diff --git a/.changeset/strong-dingos-pay.md b/.changeset/strong-dingos-pay.md new file mode 100644 index 000000000..707d728ad --- /dev/null +++ b/.changeset/strong-dingos-pay.md @@ -0,0 +1,5 @@ +--- +'@siafoundation/react-renterd': minor +--- + +Added useObjectRename. diff --git a/.changeset/wet-mayflies-smash.md b/.changeset/wet-mayflies-smash.md new file mode 100644 index 000000000..5406d4912 --- /dev/null +++ b/.changeset/wet-mayflies-smash.md @@ -0,0 +1,5 @@ +--- +'renterd': minor +--- + +Files and directories can now be renamed via the context menu. Closes https://github.com/SiaFoundation/renterd/issues/418 diff --git a/README.md b/README.md index fa80cc683..72f3544a0 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ The Sia web libraries provide developers with convenient TypeScript SDKs for usi ### Go - [go.sia.tech/web/ui](ui) - Library for embedding NextJS applications in Go. -- [go.sia.tech/web/sdk](sdk) - SDK for encoding RPCs, computing merkle proofs, and more. Compiled with WASM for use in the TypeScript SDK. +- [go.sia.tech/web/sdk](sdk) - SDK for encoding RPCs, computing merkle roots, and more. Compiled with WASM for use in the TypeScript SDK. - [go.sia.tech/web/walletd/wasm](walletd/wasm) - Wallet library for signing transactions. Compiled with WASM for use in the `walletd` application. - [go.sia.tech/web/walletd](walletd) - HTTP handler with embedded `walletd` application. - [go.sia.tech/web/renterd](renterd) - HTTP handler with embedded `renterd` application. diff --git a/apps/renterd/components/Files/DirectoryContextMenu.tsx b/apps/renterd/components/Files/DirectoryContextMenu.tsx index b0b5de113..3d86d18e2 100644 --- a/apps/renterd/components/Files/DirectoryContextMenu.tsx +++ b/apps/renterd/components/Files/DirectoryContextMenu.tsx @@ -5,8 +5,9 @@ import { DropdownMenuLeftSlot, DropdownMenuLabel, } from '@siafoundation/design-system' -import { Delete16, FolderIcon } from '@siafoundation/react-icons' +import { Delete16, Edit16, FolderIcon } from '@siafoundation/react-icons' import { useDirectoryDelete } from './useDirectoryDelete' +import { useDialog } from '../../contexts/dialog' type Props = { path: string @@ -15,6 +16,7 @@ type Props = { export function DirectoryContextMenu({ path, size }: Props) { const directoryConfirmDelete = useDirectoryDelete() + const { openDialog } = useDialog() return ( } - contentProps={{ align: 'start' }} + contentProps={{ + align: 'start', + onClick: (e) => { + e.stopPropagation() + }, + }} > Actions + { + openDialog('fileRename', path) + }} + > + + + + Rename directory + { directoryConfirmDelete(path, size) diff --git a/apps/renterd/components/Files/FileContextMenu/index.tsx b/apps/renterd/components/Files/FileContextMenu/index.tsx index 043602d1c..38a849639 100644 --- a/apps/renterd/components/Files/FileContextMenu/index.tsx +++ b/apps/renterd/components/Files/FileContextMenu/index.tsx @@ -16,11 +16,13 @@ import { Document16, Warning16, 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 { useDialog } from '../../../contexts/dialog' type Props = { path: string @@ -29,6 +31,7 @@ type Props = { export function FileContextMenu({ path }: Props) { const { downloadFiles, getFileUrl, navigateToFile } = useFiles() const deleteFile = useFileDelete() + const { openDialog } = useDialog() return ( Download file + openDialog('fileRename', path)}> + + + + Rename file + deleteFile(path)}> diff --git a/apps/renterd/components/Files/FileRenameDialog.tsx b/apps/renterd/components/Files/FileRenameDialog.tsx new file mode 100644 index 000000000..3d6c307d9 --- /dev/null +++ b/apps/renterd/components/Files/FileRenameDialog.tsx @@ -0,0 +1,137 @@ +import { + Dialog, + triggerErrorToast, + triggerSuccessToast, + ConfigFields, + useOnInvalid, + FormSubmitButton, + FieldText, +} from '@siafoundation/design-system' +import { useCallback, useEffect, useMemo } from 'react' +import { useForm } from 'react-hook-form' +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' + +function getDefaultValues(currentName: string) { + return { + name: currentName, + } +} + +type Values = ReturnType + +function getFields({ + currentName, +}: { + currentName: string +}): ConfigFields { + return { + name: { + type: 'text', + title: 'Name', + placeholder: currentName, + validation: { + required: 'required', + validate: { + noSlash: (val) => { + if (val.includes('/')) { + return 'Name cannot contain slashes' + } + return true + }, + }, + }, + }, + } +} + +type Props = { + id: string + trigger?: React.ReactNode + open: boolean + onOpenChange: (val: boolean) => void +} + +// Renames a file or directory +export function FileRenameDialog({ + id: originalPath, + trigger, + open, + onOpenChange, +}: Props) { + const { closeDialog } = useDialog() + const { refresh } = useFiles() + + let name = getFilename(originalPath || '') + name = name.endsWith('/') ? name.slice(0, -1) : name + const defaultValues = getDefaultValues(name) + + const objectRename = useObjectRename() + const form = useForm({ + mode: 'all', + defaultValues, + }) + // Reset the form when the name changes + useEffect(() => { + form.reset(getDefaultValues(name)) + }, [form, name]) + + const onSubmit = useCallback( + async (values: Values) => { + const { bucket, to, from, mode } = getRenameFileRenameParams( + originalPath, + values.name + ) + const response = await objectRename.post({ + payload: { + bucket, + to, + from, + mode, + force: false, + }, + }) + if (response.error) { + triggerErrorToast(response.error) + } else { + refresh() + form.reset() + closeDialog() + triggerSuccessToast( + isDirectory(originalPath) ? 'Directory renamed.' : 'File renamed.' + ) + } + }, + [form, originalPath, refresh, objectRename, closeDialog] + ) + + const fields = useMemo(() => getFields({ currentName: name }), [name]) + + const onInvalid = useOnInvalid(fields) + + return ( + { + if (!val) { + form.reset(defaultValues) + } + onOpenChange(val) + }} + contentVariants={{ + className: 'w-[400px]', + }} + onSubmit={form.handleSubmit(onSubmit, onInvalid)} + > +
+ + Save +
+
+ ) +} diff --git a/apps/renterd/components/Files/FilesExplorer.tsx b/apps/renterd/components/Files/FilesExplorer.tsx index 6dcf59fd1..862633b21 100644 --- a/apps/renterd/components/Files/FilesExplorer.tsx +++ b/apps/renterd/components/Files/FilesExplorer.tsx @@ -14,6 +14,12 @@ export function FilesExplorer() { sortDirection, sortableColumns, toggleSort, + onDragEnd, + onDragOver, + onDragStart, + onDragCancel, + onDragMove, + draggingObject, } = useFiles() const canUpload = useCanUpload() return ( @@ -34,6 +40,12 @@ export function FilesExplorer() { sortDirection={sortDirection} toggleSort={toggleSort} rowSize="dense" + onDragStart={onDragStart} + onDragOver={onDragOver} + onDragEnd={onDragEnd} + onDragCancel={onDragCancel} + onDragMove={onDragMove} + draggingDatum={draggingObject} /> diff --git a/apps/renterd/contexts/dialog.tsx b/apps/renterd/contexts/dialog.tsx index 1e7dd9352..4e16d8d4e 100644 --- a/apps/renterd/contexts/dialog.tsx +++ b/apps/renterd/contexts/dialog.tsx @@ -21,6 +21,7 @@ import { HostsFilterPublicKeyDialog } from '../components/Hosts/HostsFilterPubli 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 { KeysCreateDialog } from '../components/Keys/KeysCreateDialog' export type DialogType = @@ -44,6 +45,7 @@ export type DialogType = | 'filesCreateDirectory' | 'filesBucketPolicy' | 'filesSearch' + | 'fileRename' | 'keysCreate' | 'alerts' | 'confirm' @@ -121,6 +123,7 @@ export function DialogProvider({ children }: Props) { export function Dialogs() { const { + id, dialog, openDialog, onOpenChange, @@ -180,6 +183,11 @@ export function Dialogs() { open={dialog === 'filesSearch'} onOpenChange={(val) => (val ? openDialog(dialog) : closeDialog())} /> + (val ? openDialog(dialog) : closeDialog())} + /> (val ? openDialog(dialog) : closeDialog())} diff --git a/apps/renterd/contexts/files/dataset.tsx b/apps/renterd/contexts/files/dataset.tsx index 51aa2d80f..fa5638760 100644 --- a/apps/renterd/contexts/files/dataset.tsx +++ b/apps/renterd/contexts/files/dataset.tsx @@ -9,11 +9,10 @@ import { useContracts } from '../contracts' import { ObjectData, SortField } from './types' import { bucketAndKeyParamsFromPath, - bucketAndResponseKeyToFilePath, getBucketFromPath, - getDirPath, + buildDirectoryPath, getFilename, - getFilePath, + join, isDirectory, } from './paths' import { @@ -24,6 +23,7 @@ import { useRouter } from 'next/router' import { useMemo } from 'react' type Props = { + setActiveDirectory: (func: (directory: string[]) => string[]) => void activeDirectoryPath: string uploadsList: ObjectData[] sortDirection: 'asc' | 'desc' @@ -34,6 +34,7 @@ type Props = { const defaultLimit = 50 export function useDataset({ + setActiveDirectory, activeDirectoryPath, uploadsList, sortDirection, @@ -99,7 +100,7 @@ export function useDataset({ if (!activeBucket) { buckets.data?.forEach((bucket) => { const name = bucket.name - const path = getDirPath(name, '') + const path = buildDirectoryPath(name, '') dataMap[name] = { id: path, path, @@ -107,26 +108,33 @@ export function useDataset({ 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 = bucketAndResponseKeyToFilePath(activeBucketName, key) + const path = join(activeBucketName, key) + const name = getFilename(key) dataMap[path] = { id: path, path, bucket: activeBucket, size, health, - name: getFilename(key), + name, + onClick: isDirectory(key) + ? () => { + setActiveDirectory((p) => p.concat(name.slice(0, -1))) + } + : undefined, type: isDirectory(key) ? 'directory' : 'file', } }) uploadsList - .filter( - ({ path, name }) => path === getFilePath(activeDirectoryPath, name) - ) + .filter(({ path, name }) => path === join(activeDirectoryPath, name)) .forEach((upload) => { dataMap[upload.path] = upload }) @@ -150,5 +158,6 @@ export function useDataset({ offset, response, dataset: d.data, + refresh: response.mutate, } } diff --git a/apps/renterd/contexts/files/index.tsx b/apps/renterd/contexts/files/index.tsx index 46c743c0d..e27d87833 100644 --- a/apps/renterd/contexts/files/index.tsx +++ b/apps/renterd/contexts/files/index.tsx @@ -6,7 +6,12 @@ import { import { useRouter } from 'next/router' import { createContext, useCallback, useContext, useMemo } from 'react' import { columns } from './columns' -import { defaultSortField, columnsDefaultVisible, sortOptions } from './types' +import { + defaultSortField, + columnsDefaultVisible, + sortOptions, + ObjectData, +} from './types' import { FullPath, FullPathSegments, @@ -17,6 +22,7 @@ import { import { useUploads } from './uploads' import { useDownloads } from './downloads' import { useDataset } from './dataset' +import { useMove } from './move' function useFilesMain() { const { @@ -75,15 +81,31 @@ function useFilesMain() { const { downloadFiles, downloadsList, getFileUrl, downloadCancel } = useDownloads() - const { limit, offset, response, dataset } = useDataset({ + const { limit, offset, response, refresh, dataset } = useDataset({ activeDirectoryPath, + setActiveDirectory, uploadsList, sortField, sortDirection, filters, }) - const datasetPage = useMemo(() => { + 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 } @@ -94,7 +116,10 @@ function useFilesMain() { name: '..', path: '..', type: 'directory', - }, + onClick: () => { + setActiveDirectory((p) => p.slice(0, -1)) + }, + } as ObjectData, ...dataset, ] } @@ -104,6 +129,29 @@ function useFilesMain() { // 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( @@ -148,6 +196,7 @@ function useFilesMain() { activeDirectoryPath, navigateToFile, dataState, + refresh, limit, offset, datasetPage, @@ -177,6 +226,12 @@ function useFilesMain() { sortDirection, resetDefaultColumnVisibility, getFileUrl, + onDragStart, + onDragEnd, + onDragMove, + onDragCancel, + onDragOver, + draggingObject, } } diff --git a/apps/renterd/contexts/files/move.tsx b/apps/renterd/contexts/files/move.tsx new file mode 100644 index 000000000..3894905f5 --- /dev/null +++ b/apps/renterd/contexts/files/move.tsx @@ -0,0 +1,148 @@ +import { ObjectData } from './types' +import { useCallback, useState } from 'react' +import { + DragStartEvent, + DragEndEvent, + DragOverEvent, + DragMoveEvent, + DragCancelEvent, +} from '@dnd-kit/core' +import { FullPathSegments, getDirectorySegmentsFromPath } from './paths' +import { useObjectRename } from '@siafoundation/react-renterd' +import { triggerErrorToast } from '@siafoundation/design-system' +import { getMoveFileRenameParams } from './rename' + +type Props = { + activeDirectory: FullPathSegments + setActiveDirectory: ( + func: (directory: FullPathSegments) => FullPathSegments + ) => void + dataset?: ObjectData[] + refresh: () => void +} + +const navigationDelay = 500 + +export function useMove({ + dataset, + activeDirectory, + setActiveDirectory, + refresh, +}: Props) { + const [draggingObject, setDraggingObject] = useState(null) + const [, setNavTimeout] = useState() + const rename = useObjectRename() + + const moveFiles = useCallback( + async (e: DragEndEvent) => { + const { bucket, from, to, mode } = getMoveFileRenameParams( + e, + activeDirectory + ) + if (from === to) { + return + } + const response = await rename.post({ + payload: { + force: false, + bucket, + from, + to, + mode, + }, + }) + refresh() + if (response.error) { + triggerErrorToast(response.error) + } + }, + [refresh, rename, activeDirectory] + ) + + const delayedNavigation = useCallback( + (directory?: FullPathSegments) => { + if (!directory) { + setNavTimeout((t) => { + if (t) { + clearTimeout(t) + } + return null + }) + return + } + const newTimeout = setTimeout(() => { + setActiveDirectory(() => directory) + }, navigationDelay) + setNavTimeout((t) => { + if (t) { + clearTimeout(t) + } + return newTimeout + }) + }, + [setNavTimeout, setActiveDirectory] + ) + + const scheduleNavigation = useCallback( + (e: { collisions: { id: string | number }[] }) => { + if (!e.collisions.length) { + delayedNavigation(undefined) + } else { + const path = e.collisions?.[0].id as string + if (path === '..') { + delayedNavigation(activeDirectory.slice(0, -1)) + } else { + delayedNavigation(getDirectorySegmentsFromPath(path)) + } + } + }, + [delayedNavigation, activeDirectory] + ) + + const onDragStart = useCallback( + (e: DragStartEvent) => { + setDraggingObject(dataset.find((d) => d.id === e.active.id) || null) + }, + [dataset, setDraggingObject] + ) + + const onDragOver = useCallback( + (e: DragOverEvent) => { + scheduleNavigation(e) + }, + [scheduleNavigation] + ) + + const onDragMove = useCallback( + (e: DragMoveEvent) => { + scheduleNavigation(e) + }, + [scheduleNavigation] + ) + + const onDragEnd = useCallback( + async (e: DragEndEvent) => { + delayedNavigation(undefined) + setDraggingObject(undefined) + moveFiles(e) + }, + [setDraggingObject, delayedNavigation, moveFiles] + ) + + const onDragCancel = useCallback( + async (e: DragCancelEvent) => { + delayedNavigation(undefined) + setDraggingObject(undefined) + }, + [setDraggingObject, delayedNavigation] + ) + + return { + onDragEnd, + onDragOver, + onDragCancel, + onDragMove, + onDragStart, + draggingObject, + } +} diff --git a/apps/renterd/contexts/files/paths.spec.ts b/apps/renterd/contexts/files/paths.spec.ts index 299295b52..566a742d5 100644 --- a/apps/renterd/contexts/files/paths.spec.ts +++ b/apps/renterd/contexts/files/paths.spec.ts @@ -1,30 +1,50 @@ -import { bucketAndKeyParamsFromPath, getDirPath, getFilePath } from './paths' +import { + bucketAndKeyParamsFromPath, + buildDirectoryPath, + getParentDirectoryPath, + join, +} from './paths' -describe('getFilePath', () => { +describe('join', () => { it('a', () => { - expect(getFilePath('bucket/dir/', '/path/to/file.txt')).toEqual( + expect(join('bucket/dir/', '/path/to/file.txt')).toEqual( 'bucket/dir/path/to/file.txt' ) }) it('b', () => { - expect(getFilePath('bucket/dir/', '')).toEqual('bucket/dir/') + expect(join('bucket/dir/', '')).toEqual('bucket/dir/') }) it('b', () => { - expect(getFilePath('bucket/dir/', '/')).toEqual('bucket/dir/') + expect(join('bucket/dir/', '/')).toEqual('bucket/dir/') }) }) -describe('getDirPath', () => { +describe('buildDirPath', () => { it('a', () => { - expect(getDirPath('bucket/dir/', '/path/to/dir')).toEqual( + expect(buildDirectoryPath('bucket/dir/', '/path/to/dir')).toEqual( 'bucket/dir/path/to/dir/' ) }) it('b', () => { - expect(getDirPath('bucket/dir/', '')).toEqual('bucket/dir/') + expect(buildDirectoryPath('bucket/dir/', '')).toEqual('bucket/dir/') }) it('c', () => { - expect(getDirPath('bucket/dir/', '/')).toEqual('bucket/dir/') + expect(buildDirectoryPath('bucket/dir/', '/')).toEqual('bucket/dir/') + }) +}) + +describe('getParentDirectoryPath', () => { + it('a', () => { + expect(getParentDirectoryPath('bucket/dir/')).toEqual('bucket/') + }) + it('b', () => { + expect(getParentDirectoryPath('bucket/dir')).toEqual('bucket/') + }) + it('c', () => { + expect(getParentDirectoryPath('/')).toEqual('/') + }) + it('d', () => { + expect(getParentDirectoryPath('')).toEqual('/') }) }) diff --git a/apps/renterd/contexts/files/paths.ts b/apps/renterd/contexts/files/paths.ts index 05472bb90..ecf0e3b9a 100644 --- a/apps/renterd/contexts/files/paths.ts +++ b/apps/renterd/contexts/files/paths.ts @@ -2,33 +2,27 @@ export type FullPathSegments = string[] export type FullPath = string export type KeyPath = string -export function getFilePath(dirPath: FullPath, name: string): FullPath { - const n = name.startsWith('/') ? name.slice(1) : name - return dirPath + n +export function join(a: string, b: string): FullPath { + const _a = a.endsWith('/') ? a.slice(0, -1) : a + const _b = b.startsWith('/') ? b.slice(1) : b + return `${_a}/${_b}` } -export function getDirPath(dirPath: FullPath, name: string): FullPath { - const path = getFilePath(dirPath, name) +export function buildDirectoryPath(dirPath: FullPath, name: string): FullPath { + const path = join(dirPath, name) return path.endsWith('/') ? path : path + '/' } -// response keys start with a slash, eg /path/to/file.txt -export function bucketAndResponseKeyToFilePath( - bucket: string, - key: KeyPath -): FullPath { - return `${bucket}${key}` -} - export function getBucketFromPath(path: FullPath): string { return path.split('/')[0] } -function getKeyFromPath(path: FullPath): KeyPath { +export function getKeyFromPath(path: FullPath): KeyPath { const segsWithoutBucket = path.split('/').slice(1).join('/') return `/${segsWithoutBucket}` } +// key is the path to the file or directory with a leading slash export function bucketAndKeyParamsFromPath(path: FullPath): { bucket: string key: KeyPath @@ -55,6 +49,11 @@ export function isDirectory(path: FullPath): boolean { return path.endsWith('/') } +export function getParentDirectoryPath(path: FullPath): FullPath { + const p = isDirectory(path) ? path.slice(0, -1) : path + return p.split('/').slice(0, -1).join('/').concat('/') +} + export function getDirectorySegmentsFromPath(path: FullPath): FullPathSegments { if (isDirectory(path)) { return path.slice(0, -1).split('/') @@ -65,3 +64,10 @@ export function getDirectorySegmentsFromPath(path: FullPath): FullPathSegments { export function pathSegmentsToPath(segments: FullPathSegments): FullPath { return segments.join('/') } + +export function ensureDirectory(path: FullPath): FullPath { + if (isDirectory(path)) { + return path + } + return path.concat('/') +} diff --git a/apps/renterd/contexts/files/rename.spec.ts b/apps/renterd/contexts/files/rename.spec.ts new file mode 100644 index 000000000..b452bf87b --- /dev/null +++ b/apps/renterd/contexts/files/rename.spec.ts @@ -0,0 +1,109 @@ +import { getMoveFileRenameParams, getRenameFileRenameParams } from './rename' + +describe('getMoveFileRenameParams', () => { + it('directory current', () => { + expect( + getMoveFileRenameParams( + { + active: { + id: 'default/path/a/', + }, + collisions: [], + }, + ['default', 'path', 'to'] + ) + ).toEqual({ + bucket: 'default', + from: '/path/a/', + to: '/path/to/a/', + mode: 'multi', + }) + }) + it('directory nested collision', () => { + expect( + getMoveFileRenameParams( + { + active: { + id: 'default/path/a/', + }, + collisions: [ + { + id: 'default/path/nested/', + }, + ], + }, + ['default', 'path', 'to'] + ) + ).toEqual({ + bucket: 'default', + from: '/path/a/', + to: '/path/nested/a/', + mode: 'multi', + }) + }) + it('file current', () => { + expect( + getMoveFileRenameParams( + { + active: { + id: 'default/path/a', + }, + collisions: [], + }, + ['default', 'path', 'to'] + ) + ).toEqual({ + bucket: 'default', + from: '/path/a', + to: '/path/to/a', + mode: 'single', + }) + }) + it('file nested collision', () => { + expect( + getMoveFileRenameParams( + { + active: { + id: 'default/path/a', + }, + collisions: [ + { + id: 'default/path/nested/', + }, + ], + }, + ['default', 'path', 'to'] + ) + ).toEqual({ + bucket: 'default', + from: '/path/a', + to: '/path/nested/a', + mode: 'single', + }) + }) +}) + +describe('getRenameFileRenameParams', () => { + it('directory', () => { + expect(getRenameFileRenameParams('default/path/a/', 'b')).toEqual({ + bucket: 'default', + from: '/path/a/', + to: '/path/b/', + mode: 'multi', + }) + expect(getRenameFileRenameParams('default/path/a/', 'b/')).toEqual({ + bucket: 'default', + from: '/path/a/', + to: '/path/b/', + mode: 'multi', + }) + }) + it('file', () => { + expect(getRenameFileRenameParams('default/path/a', 'b')).toEqual({ + bucket: 'default', + from: '/path/a', + to: '/path/b', + mode: 'single', + }) + }) +}) diff --git a/apps/renterd/contexts/files/rename.ts b/apps/renterd/contexts/files/rename.ts new file mode 100644 index 000000000..7fcd03acb --- /dev/null +++ b/apps/renterd/contexts/files/rename.ts @@ -0,0 +1,56 @@ +import { + FullPathSegments, + getBucketFromPath, + getFilename, + getKeyFromPath, + pathSegmentsToPath, + join, + FullPath, + getParentDirectoryPath, + isDirectory, + ensureDirectory, +} from './paths' + +type Id = string | number + +// Parameters for moving a directory or file to drag destination +export function getMoveFileRenameParams( + e: { active: { id: Id }; collisions: { id: Id }[] }, + activeDirectory: FullPathSegments +) { + const fromPath = String(e.active.id) + let toPath = pathSegmentsToPath(activeDirectory) + if (e.collisions.length) { + if (e.collisions[0].id === '..') { + toPath = pathSegmentsToPath(activeDirectory.slice(0, -1)) + } else { + toPath = String(e.collisions[0].id) + } + } + const filename = getFilename(fromPath) + const bucket = getBucketFromPath(fromPath) + const from = getKeyFromPath(fromPath) + const to = getKeyFromPath(join(toPath, filename)) + return { + bucket, + from, + to, + mode: filename.endsWith('/') ? 'multi' : 'single', + } as const +} + +// Parameters for renaming the name of a file or directory +export function getRenameFileRenameParams(path: FullPath, newName: string) { + let to = join(getParentDirectoryPath(path), newName) + const isDir = isDirectory(path) + // handle renaming directories + if (isDir) { + to = ensureDirectory(to) + } + return { + bucket: getBucketFromPath(path), + from: getKeyFromPath(path), + to: getKeyFromPath(to), + mode: isDir ? 'multi' : 'single', + } as const +} diff --git a/apps/renterd/contexts/files/types.ts b/apps/renterd/contexts/files/types.ts index 8383b9bce..e2b46665f 100644 --- a/apps/renterd/contexts/files/types.ts +++ b/apps/renterd/contexts/files/types.ts @@ -14,7 +14,10 @@ export type ObjectData = { size: number type: ObjectType isUploading?: boolean + isDraggable?: boolean + isDroppable?: boolean loaded?: number + onClick?: () => void } export type TableColumnId = diff --git a/apps/renterd/contexts/files/uploads.tsx b/apps/renterd/contexts/files/uploads.tsx index e10dd5d54..6288e7761 100644 --- a/apps/renterd/contexts/files/uploads.tsx +++ b/apps/renterd/contexts/files/uploads.tsx @@ -7,11 +7,7 @@ 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, - getFilePath, -} from './paths' +import { bucketAndKeyParamsFromPath, getBucketFromPath, join } from './paths' type UploadProgress = ObjectData & { controller: AbortController @@ -95,8 +91,7 @@ export function useUploads({ activeDirectoryPath }: Props) { // empty string in most browsers. // Try `path` otherwise fallback to flat file structure. const relativeUserFilePath = (file['path'] as string) || file.name - // TODO: check if name has /prefix - const path = getFilePath(activeDirectoryPath, relativeUserFilePath) + const path = join(activeDirectoryPath, relativeUserFilePath) const bucketName = getBucketFromPath(path) const bucket = buckets.data?.find((b) => b.name === bucketName) diff --git a/libs/design-system/package.json b/libs/design-system/package.json index 51d33ad42..1be217eee 100644 --- a/libs/design-system/package.json +++ b/libs/design-system/package.json @@ -29,6 +29,7 @@ "@visx/react-spring": "2.18.0", "@visx/glyph": "2.17.0", "@react-spring/web": "^9.7.3", + "@dnd-kit/core": "^6.1.0", "react-idle-timer": "^5.7.2", "formik": "^2.2.9", "yup": "^0.32.11", diff --git a/libs/design-system/src/components/Table.tsx b/libs/design-system/src/components/Table.tsx deleted file mode 100644 index dcf2e87ec..000000000 --- a/libs/design-system/src/components/Table.tsx +++ /dev/null @@ -1,286 +0,0 @@ -import { Tooltip } from '../core/Tooltip' -import { Panel } from '../core/Panel' -import { Text } from '../core/Text' -import { useCallback } from 'react' -import { cx } from 'class-variance-authority' -import { CaretDown16, CaretUp16 } from '@siafoundation/react-icons' -import { times } from '@technically/lodash' - -type Data = { - id: string - onClick?: () => void -} - -export type Row = { - data: Data - context?: Context -} - -export type TableColumn = { - id: Columns - label: string - icon?: React.ReactNode - tip?: string - size?: number | string - cellClassName?: string - contentClassName?: string - render: React.FC> - summary?: () => React.ReactNode -} - -type Props< - Columns extends string, - SortField extends string, - D extends Data, - Context -> = { - data?: D[] - context?: Context - columns: TableColumn[] - sortField?: SortField - sortDirection?: 'asc' | 'desc' - toggleSort?: (field: SortField) => void - sortableColumns?: SortField[] - summary?: boolean - rowSize?: 'dense' | 'default' - pageSize: number - isLoading: boolean - emptyState?: React.ReactNode - focusId?: string - focusColor?: 'green' | 'red' | 'amber' | 'blue' | 'default' -} - -export function Table< - Columns extends string, - SortField extends string, - D extends Data, - Context ->({ - columns, - data, - context, - sortField, - sortDirection, - sortableColumns, - toggleSort, - summary, - rowSize = 'default', - pageSize, - isLoading, - emptyState, - focusId, - focusColor = 'default', -}: Props) { - let show = 'emptyState' - - if (isLoading && !data?.length) { - show = 'skeleton' - } - - if (data?.length) { - show = 'currentData' - } - - const getCellClassNames = useCallback( - (i: number, className: string | undefined, rounded?: boolean) => - cx( - i === 0 ? 'pl-6' : 'pl-4', - i === columns.length - 1 ? 'pr-6' : 'pr-4', - rounded - ? [ - i === 0 ? 'rounded-tl-lg' : '', - i === columns.length - 1 ? 'rounded-tr-lg' : '', - ] - : '', - className - ), - [columns] - ) - - const getContentClassNames = useCallback( - (i: number, className?: string) => cx('flex items-center', className), - [] - ) - - return ( - - - - - {columns.map( - ( - { id, icon, label, tip, cellClassName, contentClassName }, - i - ) => { - const isSortable = - sortableColumns?.includes(id as unknown as SortField) && - !!toggleSort - const isSortActive = (sortField as string) === id - return ( - - ) - } - )} - - - - {summary && ( - - {columns.map( - ({ id, summary, contentClassName, cellClassName }, i) => ( - - ) - )} - - )} - {show === 'currentData' && - data?.map((row) => ( - - {columns.map( - ( - { - id, - render: Render, - contentClassName: className, - cellClassName, - }, - i - ) => ( - - ) - )} - - ))} - {show === 'skeleton' && - times(pageSize).map((i) => ( - - {columns.map(({ id, contentClassName, cellClassName }, i) => ( - - ))} - - ))} - -
-
-
{ - if (isSortable) { - toggleSort(id as unknown as SortField) - } - }} - className={cx( - getContentClassNames(i, contentClassName), - isSortable ? 'cursor-pointer' : '' - )} - > - - - {icon ?
{icon}
: null} - - {label} - -
-
- {isSortActive && ( - - {sortDirection === 'asc' ? ( - - ) : ( - - )} - - )} - {isSortable && !isSortActive && ( - - - - )} - {/* {tip && {tip}} */} -
-
-
-
- {summary && summary()} -
-
-
- -
-
-
-
- {show === 'emptyState' && emptyState} -
- ) -} diff --git a/libs/design-system/src/components/Table/TableRow.tsx b/libs/design-system/src/components/Table/TableRow.tsx new file mode 100644 index 000000000..527cf0a67 --- /dev/null +++ b/libs/design-system/src/components/Table/TableRow.tsx @@ -0,0 +1,227 @@ +import { CSSProperties, forwardRef, useMemo } from 'react' +import { cx } from 'class-variance-authority' +import { useDroppable, useDraggable } from '@dnd-kit/core' +import { + DraggableAttributes, + DraggableSyntheticListeners, +} from '@dnd-kit/core/dist/hooks' + +type Data = { + id: string + isDraggable?: boolean + isDroppable?: boolean + onClick?: () => void +} + +export type Row = { + data: Data + context?: Context +} + +export type TableColumn = { + id: Columns + label: string + icon?: React.ReactNode + tip?: string + size?: number | string + cellClassName?: string + contentClassName?: string + render: React.FC> +} + +type Props = { + data: Data + context?: Context + columns: TableColumn[] + rowSize?: 'dense' | 'default' + focusId?: string + focusColor?: 'green' | 'red' | 'amber' | 'blue' | 'default' + getCellClassNames: ( + i: number, + className: string | undefined, + rounded?: boolean + ) => string + getContentClassNames: (i: number, className?: string) => string +} + +export function createTableRow< + Columns extends string, + D extends Data, + Context +>() { + const TableRow = forwardRef< + HTMLTableRowElement, + Props & { + className?: string + style?: CSSProperties + attributes?: DraggableAttributes + listeners?: DraggableSyntheticListeners + } + >( + ( + { + data, + style, + attributes, + listeners, + context, + columns, + rowSize = 'default', + focusId, + focusColor = 'default', + getCellClassNames, + getContentClassNames, + className, + }, + ref + ) => { + return ( + + {columns.map( + ( + { + id, + render: Render, + contentClassName: className, + cellClassName, + }, + i + ) => ( + +
+ +
+ + ) + )} + + ) + } + ) + return TableRow +} + +export function TableRowDraggable< + Columns extends string, + D extends Data, + Context +>({ + data, + context, + columns, + rowSize = 'default', + focusId, + focusColor = 'default', + getCellClassNames, + getContentClassNames, +}: Props) { + const { attributes, listeners, setNodeRef, transform } = useDraggable({ + id: data.id, + }) + const style = transform + ? { + transform: `translate3d(${transform.x}px, ${transform.y}px, 0)`, + } + : undefined + const TableRow = useMemo(() => createTableRow(), []) + + return ( + + ) +} + +export function TableRowDroppable< + Columns extends string, + D extends Data, + Context +>({ + data, + context, + columns, + rowSize = 'default', + focusId, + focusColor = 'default', + getCellClassNames, + getContentClassNames, +}: Props) { + const { isOver, setNodeRef } = useDroppable({ + id: data.id, + }) + const TableRow = useMemo(() => createTableRow(), []) + const className = isOver ? 'bg-blue-200/20 dark:bg-blue-300/20' : '' + + return ( + + ) +} diff --git a/libs/design-system/src/components/Table/index.tsx b/libs/design-system/src/components/Table/index.tsx new file mode 100644 index 000000000..d873ad301 --- /dev/null +++ b/libs/design-system/src/components/Table/index.tsx @@ -0,0 +1,333 @@ +import { Tooltip } from '../../core/Tooltip' +import { Panel } from '../../core/Panel' +import { Text } from '../../core/Text' +import { useCallback, useMemo } from 'react' +import { cx } from 'class-variance-authority' +import { CaretDown16, CaretUp16 } from '@siafoundation/react-icons' +import { times } from '@technically/lodash' +import { + DndContext, + DragStartEvent, + DragCancelEvent, + DragEndEvent, + DragOverEvent, + DragMoveEvent, + DragOverlay, + MouseSensor, + TouchSensor, + useSensor, + useSensors, +} from '@dnd-kit/core' +import { + TableRowDraggable, + TableRowDroppable, + createTableRow, +} from './TableRow' + +type Data = { + id: string + isDraggable?: boolean + isDroppable?: boolean + onClick?: () => void +} + +export type Row = { + data: Data + context?: Context +} + +export type TableColumn = { + id: Columns + label: string + icon?: React.ReactNode + tip?: string + size?: number | string + cellClassName?: string + contentClassName?: string + render: React.FC> +} + +type Props< + Columns extends string, + SortField extends string, + D extends Data, + Context +> = { + data?: D[] + context?: Context + columns: TableColumn[] + sortField?: SortField + sortDirection?: 'asc' | 'desc' + toggleSort?: (field: SortField) => void + sortableColumns?: SortField[] + rowSize?: 'dense' | 'default' + pageSize: number + isLoading: boolean + emptyState?: React.ReactNode + focusId?: string + focusColor?: 'green' | 'red' | 'amber' | 'blue' | 'default' + onDragStart?: (e: DragStartEvent) => void + onDragOver?: (e: DragOverEvent) => void + onDragMove?: (e: DragMoveEvent) => void + onDragEnd?: (e: DragEndEvent) => void + onDragCancel?: (e: DragCancelEvent) => void + draggingDatum?: D +} + +export function Table< + Columns extends string, + SortField extends string, + D extends Data, + Context +>({ + columns, + data, + context, + sortField, + sortDirection, + sortableColumns, + toggleSort, + rowSize = 'default', + pageSize, + isLoading, + emptyState, + focusId, + focusColor = 'default', + onDragStart, + onDragOver, + onDragMove, + onDragEnd, + onDragCancel, + draggingDatum, +}: Props) { + let show = 'emptyState' + + if (isLoading && !data?.length) { + show = 'skeleton' + } + + if (data?.length) { + show = 'currentData' + } + + const getCellClassNames = useCallback( + (i: number, className: string | undefined, rounded?: boolean) => + cx( + i === 0 ? 'pl-6' : 'pl-4', + i === columns.length - 1 ? 'pr-6' : 'pr-4', + rounded + ? [ + i === 0 ? 'rounded-tl-lg' : '', + i === columns.length - 1 ? 'rounded-tr-lg' : '', + ] + : '', + className + ), + [columns] + ) + + const getContentClassNames = useCallback( + (i: number, className?: string) => cx('flex items-center', className), + [] + ) + + const TableRow = useMemo(() => createTableRow(), []) + + const mouseSensor = useSensor(MouseSensor, { + // Require the mouse to move by 10 pixels before activating + activationConstraint: { + distance: 10, + }, + }) + const touchSensor = useSensor(TouchSensor, { + // Press delay of 250ms, with tolerance of 5px of movement + activationConstraint: { + delay: 250, + tolerance: 5, + }, + }) + + const sensors = useSensors(mouseSensor, touchSensor) + + return ( + + + {draggingDatum && ( + + + +
+
+ )} +
+ + + + + {columns.map( + ( + { id, icon, label, tip, cellClassName, contentClassName }, + i + ) => { + const isSortable = + sortableColumns?.includes(id as unknown as SortField) && + !!toggleSort + const isSortActive = (sortField as string) === id + return ( + + ) + } + )} + + + + {show === 'currentData' && + data?.map((row) => { + if (draggingDatum?.id === row.id) { + return null + } + + if (row.isDraggable) { + return ( + + ) + } + + if (row.isDroppable) { + return ( + + ) + } + return ( + + ) + })} + {show === 'skeleton' && + times(pageSize).map((i) => ( + + {columns.map(({ id, contentClassName, cellClassName }, i) => ( + + ))} + + ))} + +
+
+
{ + if (isSortable) { + toggleSort(id as unknown as SortField) + } + }} + className={cx( + getContentClassNames(i, contentClassName), + isSortable ? 'cursor-pointer' : '' + )} + > + + + {icon ?
{icon}
: null} + + {label} + +
+
+ {isSortActive && ( + + {sortDirection === 'asc' ? ( + + ) : ( + + )} + + )} + {isSortable && !isSortActive && ( + + + + )} + {/* {tip && {tip}} */} +
+
+
+
+
+ {show === 'emptyState' && emptyState} +
+
+ ) +} diff --git a/libs/react-renterd/src/bus.ts b/libs/react-renterd/src/bus.ts index 4e614a4a9..23bfe55d3 100644 --- a/libs/react-renterd/src/bus.ts +++ b/libs/react-renterd/src/bus.ts @@ -591,6 +591,20 @@ export function useObjectAdd( return usePutFunc({ ...args, route: '/bus/objects/:key' }) } +export type RenameObjectRequest = { + force: boolean + bucket: string + from: string + to: string + mode: 'single' | 'multi' +} + +export function useObjectRename( + args?: HookArgsCallback +) { + return usePostFunc({ ...args, route: '/bus/objects/rename' }) +} + export function useObjectDelete( args?: HookArgsCallback< { key: string; bucket: string; batch?: boolean }, diff --git a/package-lock.json b/package-lock.json index 5040c45d9..bb1121cdf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "@carbon/icons-react": "^10.47.0", "@changesets/cli": "^2.25.0", + "@dnd-kit/core": "^6.1.0", "@ledgerhq/hw-transport-web-ble": "^6.27.19", "@ledgerhq/hw-transport-webhid": "^6.27.19", "@mdx-js/loader": "^2.1.1", @@ -3853,6 +3854,42 @@ "ms": "^2.1.1" } }, + "node_modules/@dnd-kit/accessibility": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.0.tgz", + "integrity": "sha512-ea7IkhKvlJUv9iSHJOnxinBcoOI3ppGnnL+VDJ75O45Nss6HtZd8IdN8touXPDtASfeI2T2LImb8VOZcL47wjQ==", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/core": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.1.0.tgz", + "integrity": "sha512-J3cQBClB4TVxwGo3KEjssGEXNJqGVWx17aRTZ1ob0FliR5IjYgTxl5YJbKTzA6IzrtelotH19v6y7uoIRUZPSg==", + "dependencies": { + "@dnd-kit/accessibility": "^3.1.0", + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/utilities": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", + "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/@emotion/is-prop-valid": { "version": "0.8.8", "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-0.8.8.tgz", @@ -30693,6 +30730,32 @@ } } }, + "@dnd-kit/accessibility": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.0.tgz", + "integrity": "sha512-ea7IkhKvlJUv9iSHJOnxinBcoOI3ppGnnL+VDJ75O45Nss6HtZd8IdN8touXPDtASfeI2T2LImb8VOZcL47wjQ==", + "requires": { + "tslib": "^2.0.0" + } + }, + "@dnd-kit/core": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.1.0.tgz", + "integrity": "sha512-J3cQBClB4TVxwGo3KEjssGEXNJqGVWx17aRTZ1ob0FliR5IjYgTxl5YJbKTzA6IzrtelotH19v6y7uoIRUZPSg==", + "requires": { + "@dnd-kit/accessibility": "^3.1.0", + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + } + }, + "@dnd-kit/utilities": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", + "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", + "requires": { + "tslib": "^2.0.0" + } + }, "@emotion/is-prop-valid": { "version": "0.8.8", "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-0.8.8.tgz", diff --git a/package.json b/package.json index 269976c59..2bf8a54b8 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "dependencies": { "@carbon/icons-react": "^10.47.0", "@changesets/cli": "^2.25.0", + "@dnd-kit/core": "^6.1.0", "@ledgerhq/hw-transport-web-ble": "^6.27.19", "@ledgerhq/hw-transport-webhid": "^6.27.19", "@mdx-js/loader": "^2.1.1",