From e3a5929e88c89007f370f98d76c93cb6ed14f97b Mon Sep 17 00:00:00 2001 From: Alex Freska Date: Tue, 13 Feb 2024 10:48:11 -0500 Subject: [PATCH] feat: renterd rename files --- .changeset/modern-bags-exercise.md | 2 +- .changeset/wet-mayflies-smash.md | 5 + .../components/Files/DirectoryContextMenu.tsx | 21 ++- .../Files/FileContextMenu/index.tsx | 9 ++ .../components/Files/FileRenameDialog.tsx | 137 ++++++++++++++++++ apps/renterd/contexts/dialog.tsx | 8 + apps/renterd/contexts/files/dataset.tsx | 14 +- apps/renterd/contexts/files/index.tsx | 5 +- apps/renterd/contexts/files/move.tsx | 19 ++- apps/renterd/contexts/files/paths.spec.ts | 38 +++-- apps/renterd/contexts/files/paths.ts | 30 ++-- apps/renterd/contexts/files/rename.spec.ts | 59 +++++++- apps/renterd/contexts/files/rename.ts | 23 ++- apps/renterd/contexts/files/uploads.tsx | 9 +- .../src/components/Table/TableRow.tsx | 14 +- 15 files changed, 322 insertions(+), 71 deletions(-) create mode 100644 .changeset/wet-mayflies-smash.md create mode 100644 apps/renterd/components/Files/FileRenameDialog.tsx diff --git a/.changeset/modern-bags-exercise.md b/.changeset/modern-bags-exercise.md index 01911e456..87c4b7816 100644 --- a/.changeset/modern-bags-exercise.md +++ b/.changeset/modern-bags-exercise.md @@ -2,4 +2,4 @@ 'renterd': minor --- -Files can now be moved by dragging into or out of directories. +Files can now be moved by dragging into or out of directories. Closes https://github.com/SiaFoundation/renterd/issues/418 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/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/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 63952ab1b..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 { @@ -101,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, @@ -117,7 +116,7 @@ export function useDataset({ }) } 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, @@ -135,9 +134,7 @@ export function useDataset({ } }) uploadsList - .filter( - ({ path, name }) => path === getFilePath(activeDirectoryPath, name) - ) + .filter(({ path, name }) => path === join(activeDirectoryPath, name)) .forEach((upload) => { dataMap[upload.path] = upload }) @@ -161,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 e1dfd5f1d..e27d87833 100644 --- a/apps/renterd/contexts/files/index.tsx +++ b/apps/renterd/contexts/files/index.tsx @@ -81,7 +81,7 @@ function useFilesMain() { const { downloadFiles, downloadsList, getFileUrl, downloadCancel } = useDownloads() - const { limit, offset, response, dataset } = useDataset({ + const { limit, offset, response, refresh, dataset } = useDataset({ activeDirectoryPath, setActiveDirectory, uploadsList, @@ -101,7 +101,7 @@ function useFilesMain() { dataset, activeDirectory, setActiveDirectory, - mutate: response.mutate, + refresh, }) // Add parent directory to the dataset @@ -196,6 +196,7 @@ function useFilesMain() { activeDirectoryPath, navigateToFile, dataState, + refresh, limit, offset, datasetPage, diff --git a/apps/renterd/contexts/files/move.tsx b/apps/renterd/contexts/files/move.tsx index 2fd8e133a..3894905f5 100644 --- a/apps/renterd/contexts/files/move.tsx +++ b/apps/renterd/contexts/files/move.tsx @@ -10,7 +10,7 @@ import { import { FullPathSegments, getDirectorySegmentsFromPath } from './paths' import { useObjectRename } from '@siafoundation/react-renterd' import { triggerErrorToast } from '@siafoundation/design-system' -import { getRenameParams } from './rename' +import { getMoveFileRenameParams } from './rename' type Props = { activeDirectory: FullPathSegments @@ -18,14 +18,16 @@ type Props = { func: (directory: FullPathSegments) => FullPathSegments ) => void dataset?: ObjectData[] - mutate: () => void + refresh: () => void } +const navigationDelay = 500 + export function useMove({ dataset, activeDirectory, setActiveDirectory, - mutate, + refresh, }: Props) { const [draggingObject, setDraggingObject] = useState(null) const [, setNavTimeout] = useState() @@ -33,7 +35,10 @@ export function useMove({ const moveFiles = useCallback( async (e: DragEndEvent) => { - const { bucket, from, to, mode } = getRenameParams(e, activeDirectory) + const { bucket, from, to, mode } = getMoveFileRenameParams( + e, + activeDirectory + ) if (from === to) { return } @@ -46,12 +51,12 @@ export function useMove({ mode, }, }) - mutate() + refresh() if (response.error) { triggerErrorToast(response.error) } }, - [mutate, rename, activeDirectory] + [refresh, rename, activeDirectory] ) const delayedNavigation = useCallback( @@ -67,7 +72,7 @@ export function useMove({ } const newTimeout = setTimeout(() => { setActiveDirectory(() => directory) - }, 1000) + }, navigationDelay) setNavTimeout((t) => { if (t) { clearTimeout(t) 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 70fc55ac8..ecf0e3b9a 100644 --- a/apps/renterd/contexts/files/paths.ts +++ b/apps/renterd/contexts/files/paths.ts @@ -8,24 +8,11 @@ export function join(a: string, b: string): FullPath { return `${_a}/${_b}` } -export function getFilePath(dirPath: FullPath, name: string): FullPath { - const n = name.startsWith('/') ? name.slice(1) : name - return dirPath + n -} - -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] } @@ -35,6 +22,7 @@ export function getKeyFromPath(path: FullPath): KeyPath { return `/${segsWithoutBucket}` } +// key is the path to the file or directory with a leading slash export function bucketAndKeyParamsFromPath(path: FullPath): { bucket: string key: KeyPath @@ -61,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('/') @@ -71,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 index a7d6b8e69..b452bf87b 100644 --- a/apps/renterd/contexts/files/rename.spec.ts +++ b/apps/renterd/contexts/files/rename.spec.ts @@ -1,9 +1,9 @@ -import { getRenameParams } from './rename' +import { getMoveFileRenameParams, getRenameFileRenameParams } from './rename' -describe('rename', () => { - it('directory', () => { +describe('getMoveFileRenameParams', () => { + it('directory current', () => { expect( - getRenameParams( + getMoveFileRenameParams( { active: { id: 'default/path/a/', @@ -19,9 +19,9 @@ describe('rename', () => { mode: 'multi', }) }) - it('directory specific', () => { + it('directory nested collision', () => { expect( - getRenameParams( + getMoveFileRenameParams( { active: { id: 'default/path/a/', @@ -41,9 +41,27 @@ describe('rename', () => { mode: 'multi', }) }) - it('file', () => { + 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( - getRenameParams( + getMoveFileRenameParams( { active: { id: 'default/path/a', @@ -64,3 +82,28 @@ describe('rename', () => { }) }) }) + +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 index 6096a3c12..7fcd03acb 100644 --- a/apps/renterd/contexts/files/rename.ts +++ b/apps/renterd/contexts/files/rename.ts @@ -5,11 +5,16 @@ import { getKeyFromPath, pathSegmentsToPath, join, + FullPath, + getParentDirectoryPath, + isDirectory, + ensureDirectory, } from './paths' type Id = string | number -export function getRenameParams( +// Parameters for moving a directory or file to drag destination +export function getMoveFileRenameParams( e: { active: { id: Id }; collisions: { id: Id }[] }, activeDirectory: FullPathSegments ) { @@ -33,3 +38,19 @@ export function getRenameParams( 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/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/src/components/Table/TableRow.tsx b/libs/design-system/src/components/Table/TableRow.tsx index 6d3861570..527cf0a67 100644 --- a/libs/design-system/src/components/Table/TableRow.tsx +++ b/libs/design-system/src/components/Table/TableRow.tsx @@ -162,10 +162,9 @@ export function TableRowDraggable< getCellClassNames, getContentClassNames, }: Props) { - const { isDragging, attributes, listeners, setNodeRef, transform } = - useDraggable({ - id: data.id, - }) + const { attributes, listeners, setNodeRef, transform } = useDraggable({ + id: data.id, + }) const style = transform ? { transform: `translate3d(${transform.x}px, ${transform.y}px, 0)`, @@ -173,17 +172,10 @@ export function TableRowDraggable< : undefined const TableRow = useMemo(() => createTableRow(), []) - const className = isDragging - ? // ? // ? 'bg-gray-50 dark:bg-graydark-50 rounded ring ring-blue-200 dark:ring-blue-300' - // 'ring ring-blue-200 dark:ring-blue-300' - '' - : '' - return (