diff --git a/public/locales/ar/app.json b/public/locales/ar/app.json index af449ea69..da010e6ce 100644 --- a/public/locales/ar/app.json +++ b/public/locales/ar/app.json @@ -9,6 +9,7 @@ "close": "أغلق", "copy": "نسخ", "create": "إنشاء ", + "move": "تحريك", "remove": "إزالة", "download": "تحميل", "edit": "تعديل", diff --git a/public/locales/ca/app.json b/public/locales/ca/app.json index 2e9ca0eb1..5b743da99 100644 --- a/public/locales/ca/app.json +++ b/public/locales/ca/app.json @@ -10,6 +10,7 @@ "copy": "Copia", "create": "Crea", "remove": "Treu", + "move": "Mou", "download": "Descarrega", "edit": "Edita", "import": "Importa", diff --git a/public/locales/cs/app.json b/public/locales/cs/app.json index b745ebf68..ccacbba2e 100644 --- a/public/locales/cs/app.json +++ b/public/locales/cs/app.json @@ -9,6 +9,7 @@ "close": "Zavřít", "copy": "Zkopírovat", "create": "Vytvořit", + "move": "Přesunout", "remove": "Odstranit", "download": "Stáhnout", "edit": "Upravit", diff --git a/public/locales/de/app.json b/public/locales/de/app.json index 03f37f9ea..746be37ff 100644 --- a/public/locales/de/app.json +++ b/public/locales/de/app.json @@ -7,6 +7,7 @@ "change": "Ändern", "clear": "Löschen", "close": "Schließen", + "move": "Verschieben", "copy": "Kopieren", "create": "Erstellen", "remove": "Entfernen", diff --git a/public/locales/en/app.json b/public/locales/en/app.json index dd2918a97..fea5cb16e 100644 --- a/public/locales/en/app.json +++ b/public/locales/en/app.json @@ -10,6 +10,7 @@ "copy": "Copy", "create": "Create", "remove": "Remove", + "move": "Move", "download": "Download", "edit": "Edit", "import": "Import", diff --git a/public/locales/en/files.json b/public/locales/en/files.json index 20db42bf5..2a3b76e7e 100644 --- a/public/locales/en/files.json +++ b/public/locales/en/files.json @@ -35,6 +35,13 @@ "descriptionFile": "Choose a new name for this file.", "descriptionFolder": "Choose a new name for this folder." }, + "moveModal": { + "titleFiles": "{count, plural, one {Move file} other {Move {count} files}}", + "titleFolder": "Move folder", + "descriptionFiles": "{count, plural, one {Choose a new location for this file.} other {Choose a new location for these {count} files.}}", + "descriptionFolder": "Choose a new location for this folder.", + "directoryCreationInfo": "If the directory does not exist, it will be created automatically" + }, "removeModal": { "titleItem": "{count, plural, one {Remove item? {name}} other {Remove {count} items?}}", "titleFolder": "{count, plural, one {Remove folder? {name}} other {Remove {count} folders?}}", diff --git a/public/locales/es/app.json b/public/locales/es/app.json index 9d8fa4e5d..8309d6cf0 100644 --- a/public/locales/es/app.json +++ b/public/locales/es/app.json @@ -7,6 +7,7 @@ "change": "Cambio", "clear": "Limpiar", "close": "Cerrar", + "move": "Mover", "copy": "Copiar", "create": "Crear", "remove": "Eliminar", diff --git a/public/locales/fi/app.json b/public/locales/fi/app.json index 5effedbf0..d089f6b51 100644 --- a/public/locales/fi/app.json +++ b/public/locales/fi/app.json @@ -9,6 +9,7 @@ "close": "Sulje", "copy": "Kopioi", "create": "Luo", + "move": "Siirrä", "remove": "Poista", "download": "Lataa", "edit": "Muokkaa", diff --git a/public/locales/fr/app.json b/public/locales/fr/app.json index 1c460d29c..e4931e942 100644 --- a/public/locales/fr/app.json +++ b/public/locales/fr/app.json @@ -6,6 +6,7 @@ "cancel": "Annuler", "change": "Modifier", "clear": "Effacer", + "move": "Déplacer", "close": "Fermer", "copy": "Copier", "create": "Créer", diff --git a/public/locales/hu/app.json b/public/locales/hu/app.json index bac9733fe..0afe25a5c 100644 --- a/public/locales/hu/app.json +++ b/public/locales/hu/app.json @@ -8,6 +8,7 @@ "clear": "Törlés", "close": "Bezárás", "copy": "Másolás", + "move": "Áthelyezés", "create": "Létrehozás", "remove": "Remove", "download": "Letölt", diff --git a/public/locales/id/app.json b/public/locales/id/app.json index 5a7032041..565aebb6a 100644 --- a/public/locales/id/app.json +++ b/public/locales/id/app.json @@ -7,6 +7,7 @@ "change": "Mengubah", "clear": "Bersihkan", "close": "Tutup", + "move": "Pindahkan", "copy": "Salin", "create": "Buat", "remove": "Hapus", diff --git a/public/locales/it/app.json b/public/locales/it/app.json index ba47cf78c..ce5a8f800 100644 --- a/public/locales/it/app.json +++ b/public/locales/it/app.json @@ -6,6 +6,7 @@ "cancel": "Annulla", "change": "Cambia", "clear": "Svuota", + "move": "Sposta", "close": "Chiudi", "copy": "Copia", "create": "Crea", diff --git a/public/locales/ja-JP/app.json b/public/locales/ja-JP/app.json index 385988add..b01362f66 100644 --- a/public/locales/ja-JP/app.json +++ b/public/locales/ja-JP/app.json @@ -7,6 +7,7 @@ "change": "変更", "clear": "クリア", "close": "閉じる", + "move": "移動", "copy": "コピー", "create": "作成", "remove": "削除", diff --git a/public/locales/ko-KR/app.json b/public/locales/ko-KR/app.json index 5d70a469c..bd436ccb7 100644 --- a/public/locales/ko-KR/app.json +++ b/public/locales/ko-KR/app.json @@ -10,6 +10,7 @@ "copy": "Copy", "create": "Create", "remove": "Remove", + "move": "Move", "download": "Download", "edit": "Edit", "import": "Import", diff --git a/public/locales/pl/app.json b/public/locales/pl/app.json index 7b938097c..a082c6319 100644 --- a/public/locales/pl/app.json +++ b/public/locales/pl/app.json @@ -11,6 +11,7 @@ "create": "Utwórz", "remove": "Usuń", "download": "Pobierz", + "move": "Przenieś", "edit": "Edytuj", "import": "Import", "inspect": "Analizuj", diff --git a/public/locales/ru/app.json b/public/locales/ru/app.json index 8142a4fa2..37667baea 100644 --- a/public/locales/ru/app.json +++ b/public/locales/ru/app.json @@ -11,6 +11,7 @@ "create": "Создать", "remove": "Удалить", "download": "Загрузить", + "move": "Переместить", "edit": "Изменить", "import": "Импорт", "inspect": "Проверить", diff --git a/public/locales/tr/app.json b/public/locales/tr/app.json index 7142ed861..a49f3efef 100644 --- a/public/locales/tr/app.json +++ b/public/locales/tr/app.json @@ -11,6 +11,7 @@ "create": "Oluştur", "remove": "Kaldır", "download": "İndir", + "move": "Taşı", "edit": "Düzenle", "import": "İçe aktar", "inspect": "İncele", diff --git a/public/locales/ur/app.json b/public/locales/ur/app.json index 4871f06f8..70dc3c2ca 100644 --- a/public/locales/ur/app.json +++ b/public/locales/ur/app.json @@ -10,6 +10,7 @@ "copy": "نقل کریں", "create": "تشکیل دیں", "remove": "Remove", + "move": "پیچھے کریں", "download": "ڈاؤنلوڈ کریں", "edit": "ترمیم کریں", "import": "درآمد کریں", diff --git a/public/locales/zh-CN/app.json b/public/locales/zh-CN/app.json index b5c046299..33aed448f 100644 --- a/public/locales/zh-CN/app.json +++ b/public/locales/zh-CN/app.json @@ -10,6 +10,7 @@ "copy": "复制", "create": "创建", "remove": "移除", + "move": "移动", "download": "下载", "edit": "编辑", "import": "导入", diff --git a/public/locales/zh-TW/app.json b/public/locales/zh-TW/app.json index e7a049c79..f89cf7b26 100644 --- a/public/locales/zh-TW/app.json +++ b/public/locales/zh-TW/app.json @@ -12,6 +12,7 @@ "remove": "移除", "download": "下載", "edit": "編輯", + "move": "移動", "import": "匯入", "inspect": "檢查", "more": "更多", diff --git a/src/bundles/files/actions.js b/src/bundles/files/actions.js index 7381ff92b..f84507f96 100644 --- a/src/bundles/files/actions.js +++ b/src/bundles/files/actions.js @@ -185,6 +185,57 @@ const actions = () => ({ } }, + /** + * Fetches directory contents with optimized performance + * @param {string} path - Directory path to fetch + */ + doFetchDirectory: (path) => perform(ACTIONS.DIRECTORY_FETCH, async (ipfs) => { + if (!ipfs) { + throw new Error('IPFS is not available') + } + + try { + const resolvedPath = path.startsWith('/ipns') ? await last(ipfs.name.resolve(path)) : path + + const stats = await stat(ipfs, resolvedPath) + + if (stats.type !== 'directory') { + return { + path, + type: stats.type, + content: [] + } + } + + const entries = [] + for await (const entry of ipfs.ls(stats.cid)) { + if (entry.type === 'dir') { + entries.push({ + name: entry.name, + type: entry.type, + path: entry.path, + size: entry.size, + cid: entry.cid + }) + } + } + + entries.sort((a, b) => a.name.localeCompare(b.name)) + + return { + path, + type: 'directory', + content: entries + } + } catch (err) { + return { + path, + type: 'unknown', + content: [] + } + } + }), + /** * Fetches conten for the currently selected path. And updates * `state.pageContent` on succesful completion. diff --git a/src/bundles/files/consts.js b/src/bundles/files/consts.js index 67383694a..10ce0a8ff 100644 --- a/src/bundles/files/consts.js +++ b/src/bundles/files/consts.js @@ -40,7 +40,9 @@ export const ACTIONS = { /** @type {'FILES_WRITE_UPDATED'} */ WRITE_UPDATED: ('FILES_WRITE_UPDATED'), /** @type {'FILES_UPDATE_SORT'} */ - UPDATE_SORT: ('FILES_UPDATE_SORT') + UPDATE_SORT: ('FILES_UPDATE_SORT'), + /** @type {'FILES_DIRECTORY_FETCH'} */ + DIRECTORY_FETCH: ('FILES_DIRECTORY_FETCH') } export const SORTING = { diff --git a/src/files/FilesPage.js b/src/files/FilesPage.js index b61678f16..338ea370b 100644 --- a/src/files/FilesPage.js +++ b/src/files/FilesPage.js @@ -15,7 +15,7 @@ import FilesList from './files-list/FilesList.js' import { getJoyrideLocales } from '../helpers/i8n.js' // Icons -import Modals, { DELETE, NEW_FOLDER, SHARE, RENAME, ADD_BY_PATH, BULK_CID_IMPORT, CLI_TUTOR_MODE, PINNING, PUBLISH } from './modals/Modals.js' +import Modals, { DELETE, NEW_FOLDER, SHARE, RENAME, ADD_BY_PATH, BULK_CID_IMPORT, CLI_TUTOR_MODE, PINNING, PUBLISH, MOVE } from './modals/Modals.js' import Header from './header/Header.js' import FileImportStatus from './file-import-status/FileImportStatus.js' import { useExplore } from 'ipld-explorer-components/providers' @@ -24,7 +24,7 @@ const FilesPage = ({ doFetchPinningServices, doFilesFetch, doPinsFetch, doFilesSizeGet, doFilesDownloadLink, doFilesDownloadCarLink, doFilesWrite, doFilesBulkCidImport, doFilesAddPath, doUpdateHash, doFilesUpdateSorting, doFilesNavigateTo, doFilesMove, doSetCliOptions, doFetchRemotePins, remotePins, pendingPins, failedPins, ipfsProvider, ipfsConnected, doFilesMakeDir, doFilesShareLink, doFilesDelete, doSetPinning, onRemotePinClick, doPublishIpnsKey, - files, filesPathInfo, pinningServices, toursEnabled, handleJoyrideCallback, isCliTutorModeEnabled, cliOptions, t + files, filesPathInfo, doFetchDirectory, pinningServices, toursEnabled, handleJoyrideCallback, isCliTutorModeEnabled, cliOptions, t }) => { const { doExploreUserProvidedPath } = useExplore() const contextMenuRef = useRef() @@ -150,13 +150,13 @@ const FilesPage = ({ onShare={(files) => showModal(SHARE, files)} onRename={(files) => showModal(RENAME, files)} onRemove={(files) => showModal(DELETE, files)} + onMove={(files) => showModal(MOVE, files)} onSetPinning={(files) => showModal(PINNING, files)} onInspect={onInspect} onRemotePinClick={onRemotePinClick} onDownload={onDownload} onAddFiles={onAddFiles} onNavigate={doFilesNavigateTo} - onMove={doFilesMove} handleContextMenuClick={handleContextMenu} /> ) } @@ -227,6 +227,7 @@ const FilesPage = ({ @@ -293,6 +295,7 @@ export default connect( 'selectIsCliTutorModalOpen', 'doOpenCliTutorModal', 'doSetCliOptions', + 'doFetchDirectory', 'selectCliOptions', 'doSetPinning', 'doPublishIpnsKey', diff --git a/src/files/breadcrumbs/Breadcrumbs.js b/src/files/breadcrumbs/Breadcrumbs.js index 630ecd81b..1b34d83f4 100644 --- a/src/files/breadcrumbs/Breadcrumbs.js +++ b/src/files/breadcrumbs/Breadcrumbs.js @@ -1,7 +1,6 @@ import React, { useEffect, useState, useRef, useMemo } from 'react' import classNames from 'classnames' import PropTypes from 'prop-types' -import { basename, join } from 'path' import { connect } from 'redux-bundler-react' import { withTranslation } from 'react-i18next' import { useDrop } from 'react-dnd' @@ -14,21 +13,57 @@ import './Breadcrumbs.css' const DropableBreadcrumb = ({ index, link, immutable, onAddFiles, onMove, onClick, onContextMenuHandle, getPathInfo, checkIfPinned }) => { const [{ isOver }, drop] = useDrop({ accept: [NativeTypes.FILE, 'FILE'], - drop: async ({ files, filesPromise, path: filePath }) => { - if (files) { + drop: async (_, monitor) => { + const item = monitor.getItem() + + if (item.files) { (async () => { - const files = await filesPromise - onAddFiles(await normalizeFiles(files), link.path) + const files = await item.filesPromise + onAddFiles(normalizeFiles(files), link.path) })() } else { - const src = filePath - const dst = join(link.path, basename(filePath)) - - try { await onMove(src, dst) } catch (e) { console.error(e) } + const src = item.path + + try { + const selectedFiles = Array.isArray(item.selectedFiles) ? item.selectedFiles : [] + const isDraggedFileSelected = selectedFiles.length > 0 && selectedFiles.some(file => file.path === src) + + if (isDraggedFileSelected) { + const moveOperations = selectedFiles.map(file => { + const fileName = file.path.split('/').pop() + const destinationPath = `${link.path}/${fileName}` + return [file.path, destinationPath] + }) + for (const [src, dst] of moveOperations) { + try { + await onMove(src, dst) + } catch (err) { + console.error('Failed to move file:', { src, dst, error: err }) + } + } + } else { + const fileName = src.split('/').pop() + const destinationPath = `${link.path}/${fileName}` + await onMove(src, destinationPath) + } + } catch (err) { + console.error('Error during file move operation:', err) + } } }, + canDrop: (_, monitor) => { + const item = monitor.getItem() + if (!item) return false + + if (item.path === link.path) return false + + if (item.parentPath === link.path) return false + + return true + }, collect: (monitor) => ({ - isOver: monitor.isOver() + isOver: monitor.isOver(), + canDrop: monitor.canDrop() }) }) diff --git a/src/files/file/File.js b/src/files/file/File.js index 2c37bab15..5b8640c56 100644 --- a/src/files/file/File.js +++ b/src/files/file/File.js @@ -1,6 +1,5 @@ import React, { useRef } from 'react' import PropTypes from 'prop-types' -import { join, basename } from 'path' import { withTranslation } from 'react-i18next' import classnames from 'classnames' import { normalizeFiles, humanSize } from '../../lib/files.js' @@ -31,8 +30,20 @@ const File = ({ } const [, drag, preview] = useDrag({ - item: { name, size, cid, path, pinned, type: 'FILE' }, - canDrag: !cantDrag && isMfs + item: { + name, + size, + cid, + path, + pinned, + type: 'FILE', + parentPath: path.substring(0, path.lastIndexOf('/')), + selectedFiles: selected ? (window.__selectedFiles || []) : [] + }, + canDrag: !cantDrag && isMfs, + collect: (monitor) => ({ + isDragging: monitor.isDragging() + }) }) const checkIfDir = (monitor) => { @@ -40,13 +51,18 @@ const File = ({ const item = monitor.getItem() if (!item) return false - if (item.name) { - return type === 'directory' && - name !== item.name && - !selected + if (type !== 'directory') return false + + if (item.path) { + if (item.path === path) return false + + const itemParentPath = item.path.substring(0, item.path.lastIndexOf('/')) + if (itemParentPath === path) return false + + return true } - return type === 'directory' + return true } const [{ isOver, canDrop }, drop] = useDrop({ @@ -61,12 +77,26 @@ const File = ({ })() } else { const src = item.path - const dst = join(path, basename(item.path)) - - onMove(src, dst) + const selectedFiles = Array.isArray(item.selectedFiles) ? item.selectedFiles : [] + const isDraggedFileSelected = selectedFiles.length > 0 && selectedFiles.some(file => file.path === src) + + if (isDraggedFileSelected) { + selectedFiles.forEach(file => { + const fileName = file.path.split('/').pop() + const destinationPath = `${path}/${fileName}` + onMove(file.path, destinationPath) + }) + } else { + const fileName = src.split('/').pop() + const destinationPath = `${path}/${fileName}` + onMove(src, destinationPath) + } } }, - canDrop: (_, monitor) => checkIfDir(monitor), + canDrop: (_, monitor) => { + const canDrop = checkIfDir(monitor) + return canDrop + }, collect: (monitor) => ({ canDrop: checkIfDir(monitor), isOver: monitor.isOver() diff --git a/src/files/files-list/FilesList.js b/src/files/files-list/FilesList.js index 7e4673dfa..4056cbcc9 100644 --- a/src/files/files-list/FilesList.js +++ b/src/files/files-list/FilesList.js @@ -4,7 +4,7 @@ import PropTypes from 'prop-types' import { connect } from 'redux-bundler-react' import { Trans, withTranslation } from 'react-i18next' import classnames from 'classnames' -import { join } from 'path' +import { join, basename } from 'path' import { sorts } from '../../bundles/files/index.js' import { normalizeFiles } from '../../lib/files.js' import { List, WindowScroller, AutoSizer } from 'react-virtualized' @@ -52,7 +52,7 @@ const mergeRemotePinsIntoFiles = (files, remotePins = [], pendingPins = [], fail export const FilesList = ({ className, files, pins, pinningServices, remotePins, pendingPins, failedPins, filesSorting, updateSorting, filesIsFetching, filesPathInfo, showLoadingAnimation, - onShare, onSetPinning, onInspect, onDownload, onRemove, onRename, onNavigate, onRemotePinClick, onAddFiles, onMove, doFetchRemotePins, doDismissFailedPin, handleContextMenuClick, t + onShare, onSetPinning, onInspect, doFilesMove, onDownload, onRemove, onRename, onNavigate, onAddFiles, onMove, doFetchRemotePins, doDismissFailedPin, handleContextMenuClick, t }) => { const [selected, setSelected] = useState([]) const [focused, setFocused] = useState(null) @@ -80,15 +80,24 @@ export const FilesList = ({ canDrop: _ => filesPathInfo.isMfs }) - const selectedFiles = useMemo(() => - selected - .map(name => allFiles.find(el => el.name === name)) + const selectedFiles = useMemo(() => { + const files = selected + .map(name => { + const file = allFiles.find(el => el.name === name) + if (!file) return null + return { + ...file, + // Ensure we have the complete path + path: file.path || join(filesPathInfo.path, file.name), + pinned: pins.map(p => p.toString()).includes(file.cid.toString()) + } + }) .filter(n => n) - .map(file => ({ - ...file, - pinned: pins.map(p => p.toString()).includes(file.cid.toString()) - })) - , [allFiles, pins, selected]) + + // decided to make selected files global for drag operations with breadcrumbs + window.__selectedFiles = files + return files + }, [allFiles, pins, selected, filesPathInfo]) const keyHandler = (e) => { const focusedFile = files.find(el => el.name === focused) @@ -98,30 +107,30 @@ export const FilesList = ({ return } - if (e.key === 'Escape') { + if (e.key === 'Escape' || e.keyCode === 27) { setSelected([]) setFocused(null) return listRef.current.forceUpdateGrid() } - if (e.key === 'F2' && focused !== null) { + if ((e.key === 'F2' || e.keyCode === 113) && focused !== null) { return onRename([focusedFile]) } - if (e.key === 'Delete' && selected.length > 0) { + if ((e.key === 'Delete' || e.key === 'Backspace' || e.keyCode === 8 || e.keyCode === 46) && selected.length > 0) { return onRemove(selectedFiles) } - if (e.key === ' ' && focused !== null) { + if ((e.key === ' ' || e.keyCode === 32) && focused !== null) { e.preventDefault() return toggleOne(focused, true) } - if ((e.key === 'Enter' || (e.key === 'ArrowRight' && e.metaKey)) && focused !== null) { + if (((e.key === 'Enter' || e.keyCode === 13) && focused !== null) || ((e.key === 'ArrowRight' && e.metaKey) || (e.key === 'ArrowRight' && e.keyCode === 39))) { return onNavigate({ path: focusedFile.path, cid: focusedFile.cid }) } - if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { + if ((e.key === 'ArrowDown' || e.key === 'ArrowUp' || e.keyCode === 40 || e.keyCode === 38) && focused !== null) { e.preventDefault() let index = 0 @@ -195,29 +204,33 @@ export const FilesList = ({ }, [selected]) const move = (src, dst) => { + if (Array.isArray(src)) { + onMove(src) + return + } + if (selectedFiles.length > 0) { - const parts = dst.split('/') - parts.pop() - let basepath = parts.join('/') + const isDraggedFileSelected = selectedFiles.some(file => file.path === src) - if (basepath === '') { - basepath = '/' - } + const filesToMove = isDraggedFileSelected + ? selectedFiles + : [{ path: src, name: basename(src) }] - const toMove = selectedFiles.map(({ name, path }) => ([ - path, - join(basepath, name) - ])) + const toMove = filesToMove.map(file => { + const sourcePath = file.path + const fileName = basename(sourcePath) + const destinationPath = dst.endsWith(fileName) ? dst : join(dst, fileName) - const res = toMove.find(a => a[0] === src) - if (!res) { - toMove.push([src, dst]) - } + return [sourcePath, destinationPath] + }) toggleAll(false) - toMove.forEach(op => onMove(...op)) + toMove.forEach(op => doFilesMove(...op)) } else { - onMove(src, dst) + const fileName = basename(src) + const dstPath = dst.endsWith(fileName) ? dst : join(dst, fileName) + + doFilesMove(src, dstPath) } } @@ -349,6 +362,7 @@ export const FilesList = ({ unselect={() => toggleAll(false)} remove={() => onRemove(selectedFiles)} rename={() => onRename(selectedFiles)} + move={() => move(selectedFiles)} share={() => onShare(selectedFiles)} setPinning={() => onSetPinning(selectedFiles)} download={() => onDownload(selectedFiles)} @@ -407,5 +421,13 @@ export default connect( 'selectFilesPathInfo', 'selectShowLoadingAnimation', 'doDismissFailedPin', + 'doFilesMove', + 'doFilesWrite', + 'doFilesDelete', + 'doFilesAddByPath', + 'doFilesShareLink', + 'doFilesDownloadLink', + 'doFilesMakeDir', + 'doFilesUpdateSorting', withTranslation('files')(FilesList) ) diff --git a/src/files/modals/Modals.js b/src/files/modals/Modals.js index 5ccebac50..691f0393e 100644 --- a/src/files/modals/Modals.js +++ b/src/files/modals/Modals.js @@ -7,6 +7,7 @@ import Overlay from '../../components/overlay/Overlay.js' import NewFolderModal from './new-folder-modal/NewFolderModal.js' import ShareModal from './share-modal/ShareModal.js' import RenameModal from './rename-modal/RenameModal.js' +import MoveModal from './move-modal/MoveModal.js' import PinningModal from './pinning-modal/PinningModal.js' import RemoveModal from './remove-modal/RemoveModal.js' import AddByPathModal from './add-by-path-modal/AddByPathModal.js' @@ -25,6 +26,7 @@ const BULK_CID_IMPORT = 'bulk_cid_import' const CLI_TUTOR_MODE = 'cli_tutor_mode' const PINNING = 'pinning' const PUBLISH = 'publish' +const MOVE = 'move' export { NEW_FOLDER, @@ -35,7 +37,8 @@ export { BULK_CID_IMPORT, CLI_TUTOR_MODE, PINNING, - PUBLISH + PUBLISH, + MOVE } class Modals extends React.Component { @@ -46,6 +49,12 @@ class Modals extends React.Component { path: '', filename: '' }, + move: { + files: [], + source: '', + destination: '', + folder: false + }, pinning: { file: null }, @@ -87,6 +96,44 @@ class Modals extends React.Component { this.leave() } + move = async (destination) => { + const { files } = this.state.move + const { onMove, onMakeDir } = this.props + + if (destination !== '') { + const normalizedDestination = destination.startsWith('/files') ? destination : `/files${destination}` + + const segments = normalizedDestination.split('/').filter(Boolean) + let currentPath = '' + + const startIndex = segments[0] === 'files' ? 1 : 0 + + for (let i = startIndex; i < segments.length; i++) { + currentPath = `/${segments.slice(0, i + 1).join('/')}` + try { + await onMakeDir(currentPath) + } catch (err) { + // ignoring error if folder exists + if (!err.message.includes('file already exists')) { + throw err + } + } + } + + for (const file of files) { + const fileName = file.path.split('/').pop() + + const destPath = normalizedDestination.endsWith(fileName) + ? normalizedDestination + : join(normalizedDestination, fileName) + + await onMove(file.path, destPath) + } + } + + this.leave() + } + delete = (args) => { const { files } = this.state.delete @@ -140,6 +187,23 @@ class Modals extends React.Component { }) break } + case MOVE: { + const isFolder = files.some(f => f.type === 'directory') + const sourcePath = files[0].path + const sourceDir = sourcePath.substring(0, sourcePath.lastIndexOf('/')) + + this.setState({ + readyToShow: true, + move: { + files, + source: sourceDir, + destination: sourceDir, + folder: isFolder, + count: files.length + } + }) + break + } case DELETE: { let filesCount = 0 let foldersCount = 0 @@ -185,7 +249,7 @@ class Modals extends React.Component { }) } default: - // do nothing + // do nothing for now } } @@ -224,8 +288,9 @@ class Modals extends React.Component { } render () { - const { show, t } = this.props - const { readyToShow, link, rename, command } = this.state + const { show, t, mainFiles, onFetchDirectory, files } = this.props + const { readyToShow, link, rename, move, pinning, publish, delete: deleteFiles, command } = this.state + return (
@@ -250,10 +315,21 @@ class Modals extends React.Component { onSubmit={this.rename} /> + + + + @@ -278,16 +354,16 @@ class Modals extends React.Component { @@ -299,13 +375,18 @@ class Modals extends React.Component { Modals.propTypes = { t: PropTypes.func.isRequired, show: PropTypes.string, - files: PropTypes.array, + files: PropTypes.oneOfType([ + PropTypes.array, + PropTypes.object + ]), onAddByPath: PropTypes.func.isRequired, onMove: PropTypes.func.isRequired, onMakeDir: PropTypes.func.isRequired, onShareLink: PropTypes.func.isRequired, onRemove: PropTypes.func.isRequired, - onPublish: PropTypes.func.isRequired + onPublish: PropTypes.func.isRequired, + done: PropTypes.func.isRequired, + root: PropTypes.string } export default withTranslation('files')(Modals) diff --git a/src/files/modals/move-modal/MoveModal.js b/src/files/modals/move-modal/MoveModal.js new file mode 100644 index 000000000..e591136a2 --- /dev/null +++ b/src/files/modals/move-modal/MoveModal.js @@ -0,0 +1,290 @@ +import React, { useState, useEffect, useRef } from 'react' +import PropTypes from 'prop-types' +import { Modal, ModalActions, ModalBody } from '../../../components/modal/Modal.js' +import Button from '../../../components/button/button.tsx' +import StrokeMove from '../../../icons/StrokeMove.js' +import { withTranslation } from 'react-i18next' +import StrokeInfo from '../../../icons/StrokeInfo.js' + +const DirectoryInput = ({ value, onChange, suggestions, onKeyPress, className, t }) => { + const [showSuggestions, setShowSuggestions] = useState(false) + const [activeSuggestion, setActiveSuggestion] = useState(-1) + const [showTooltip, setShowTooltip] = useState(false) + const inputRef = useRef(null) + const suggestionsRef = useRef(null) + const listboxId = 'directory-suggestions-listbox' + + useEffect(() => { + const handleClickOutside = (event) => { + if (suggestionsRef.current && !suggestionsRef.current.contains(event.target)) { + setShowSuggestions(false) + } + } + document.addEventListener('mousedown', handleClickOutside) + return () => document.removeEventListener('mousedown', handleClickOutside) + }, []) + + const handleKeyDown = (e) => { + if (!suggestions.length) return + + if (e.keyCode === 40 || e.key === 'ArrowDown') { + e.preventDefault() + setActiveSuggestion(prev => Math.min(prev + 1, suggestions.length - 1)) + } else if (e.keyCode === 38 || e.key === 'ArrowUp') { + e.preventDefault() + setActiveSuggestion(prev => Math.max(prev - 1, -1)) + } else if ((e.keyCode === 13 || e.key === 'Enter') && activeSuggestion >= 0) { + e.preventDefault() + onChange(suggestions[activeSuggestion]) + setShowSuggestions(false) + setActiveSuggestion(-1) + } + } + + const selectSuggestion = (suggestion) => { + onChange(suggestion) + setShowSuggestions(false) + inputRef.current?.focus() + } + + return ( +
+
+ { + onChange(e.target.value) + setShowSuggestions(true) + setActiveSuggestion(-1) + }} + onKeyPress={onKeyPress} + onKeyDown={handleKeyDown} + onFocus={() => setShowSuggestions(true)} + className={`input-reset charcoal ba b--black-20 br1 pa2 mb2 db flex-auto focus-outline ${className}`} + placeholder="/files/path/to/directory" + aria-label="Directory path" + aria-expanded={showSuggestions} + aria-controls={listboxId} + aria-autocomplete="list" + role="combobox" + /> +
+
+ + {showTooltip && ( +
+ {t('moveModal.directoryCreationInfo')} +
+
+ )} +
+
+
+ {showSuggestions && suggestions.length > 0 && ( +
+ {suggestions.map((suggestion, index) => ( + + ))} +
+ )} +
+ ) +} + +function MoveModal ({ t, tReady, mainFiles, files, onCancel, onSubmit, source, destination, folder, count, className, onFetchDirectory: doFetchDirectory, ...props }) { + const [value, setValue] = useState(destination) + const [suggestions, setSuggestions] = useState([]) + const fetchTimeoutRef = useRef(null) + const formatPath = (path) => { + const normalizedPath = path.startsWith('/files') ? path.substring(6) : path + return normalizedPath.split('/').filter(e => !!e).join('/') + } + + const disabledPath = files.filter(e => e.type === 'directory').map(e => formatPath(e.path)).includes(formatPath(value)) + + useEffect(() => { + let isMounted = true + + const fetchSuggestions = async () => { + if (!value || typeof value !== 'string') { + setSuggestions([]) + return + } + + try { + const parts = value.toString().split('/') + const currentInput = parts[parts.length - 1] || '' + + let entries = [] + try { + const normalizedValue = value.replace(/^\/files/, '') + const result = formatPath(value) === formatPath(mainFiles.path) + ? mainFiles.content.filter(entry => entry.type === 'directory').map(entry => ({ path: entry.path, name: entry.name })) + : ((await doFetchDirectory(normalizedValue))?.content || [])?.map(entry => ({ + ...entry, + path: `/files${normalizedValue}/${entry.name}`.replace(/\/+/g, '/') + })) + + if (result && Array.isArray(result)) { + entries = result.map(entry => entry.path) + } + } catch (err) { + console.error('Error fetching directory entries:', err) + } + + const ignorePath = files.filter(e => e.type === 'directory').map(e => formatPath(e.path)) + + const filteredSuggestions = entries.filter(path => + path.toLowerCase().includes((currentInput || '').toLowerCase()) && + path !== value && !ignorePath.includes(formatPath(path)) + ) + + if (isMounted) { + setSuggestions([...new Set(filteredSuggestions)]) + } + } catch (err) { + console.error('Error fetching directory suggestions:', err) + if (isMounted) { + setSuggestions([]) + } + } + } + + // a little bit of debouncing, to avoid too many requests + if (fetchTimeoutRef.current) { + clearTimeout(fetchTimeoutRef.current) + } + + fetchTimeoutRef.current = setTimeout(() => { + fetchSuggestions() + }, 350) + + return () => { + isMounted = false + if (fetchTimeoutRef.current) { + clearTimeout(fetchTimeoutRef.current) + } + setSuggestions([]) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [value, source, doFetchDirectory]) + + const context = folder ? 'Folder' : 'Files' + const title = t(`moveModal.title${context}`, { count }) + const description = t(`moveModal.description${context}`, { source, count }) + + const onKeyPress = (event) => { + if (event.key === 'Enter' && !suggestions.length) { + const normalizedValue = value.startsWith('/files') ? value : `/files${value}` + onSubmit(normalizedValue) + } + } + + return ( + + + {description &&

{description}

} + + +
+ + + + + +
+ ) +} + +MoveModal.propTypes = { + onCancel: PropTypes.func.isRequired, + onSubmit: PropTypes.func.isRequired, + source: PropTypes.string.isRequired, + destination: PropTypes.string.isRequired, + folder: PropTypes.bool, + count: PropTypes.number.isRequired, + t: PropTypes.func.isRequired, + tReady: PropTypes.bool.isRequired, + mainFiles: PropTypes.object.isRequired +} + +MoveModal.defaultProps = { + className: '', + folder: false +} + +export default withTranslation('files')(MoveModal) diff --git a/src/files/selected-actions/SelectedActions.css b/src/files/selected-actions/SelectedActions.css index e7ceaa047..9fd640f2f 100644 --- a/src/files/selected-actions/SelectedActions.css +++ b/src/files/selected-actions/SelectedActions.css @@ -2,6 +2,16 @@ width: 100%; } +.fs-count { + display: none; +} + +@media only screen and (min-width: 35em) { + .fs-count { + display: block; + } +} + @media only screen and (min-width: 60em) { .selectedActions { width: calc(100% - 148px) @@ -16,3 +26,63 @@ from { transform: translateY(100%) } to { transform: translateY(0%) } } + +.more-menu { + position: absolute; + right: 0; + bottom: 100%; + margin-bottom: 0.5rem; + background-color: white; + border-radius: 4px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + border: 1px solid #edf0f4; + min-width: 200px; + z-index: 10; +} + +.more-menu-item { + display: flex; + align-items: center; + padding: 0.75rem 1rem; + width: 100%; + border-bottom: 1px solid #edf0f4; + transition: background-color 0.2s ease; + cursor: pointer; +} + +.more-menu-item:last-child { + border-bottom: none; +} + +.more-menu-item:hover { + background-color: #f0f6fa; +} + +.more-menu-item.disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.more-menu-item.disabled:hover { + background-color: transparent; +} + +.more-menu-item svg { + width: 1rem; + height: 1rem; + margin-right: 0.75rem; + flex-shrink: 0; +} + +.more-menu-item span { + font-size: 0.875rem; + color: #4A4A4A; +} + +.more-menu-item:hover svg { + fill: #0b3a53; +} + +.more-menu-item:hover span { + color: #0b3a53; +} diff --git a/src/files/selected-actions/SelectedActions.js b/src/files/selected-actions/SelectedActions.js index 3451517a3..0f389dab7 100644 --- a/src/files/selected-actions/SelectedActions.js +++ b/src/files/selected-actions/SelectedActions.js @@ -10,6 +10,8 @@ import StrokePencil from '../../icons/StrokePencil.js' import StrokeIpld from '../../icons/StrokeIpld.js' import StrokeTrash from '../../icons/StrokeTrash.js' import StrokeDownload from '../../icons/StrokeDownload.js' +import StrokeMove from '../../icons/StrokeMove.js' +import StrokeMore from '../../icons/StrokeMore.js' import './SelectedActions.css' const styles = { @@ -43,6 +45,11 @@ class SelectedActions extends React.Component { constructor (props) { super(props) this.containerRef = React.createRef() + this.state = { + force100: false, + showMoreMenu: false, + windowWidth: window.innerWidth + } } static propTypes = { @@ -50,6 +57,7 @@ class SelectedActions extends React.Component { size: PropTypes.number.isRequired, unselect: PropTypes.func.isRequired, remove: PropTypes.func.isRequired, + move: PropTypes.func.isRequired, setPinning: PropTypes.func.isRequired, share: PropTypes.func.isRequired, download: PropTypes.func.isRequired, @@ -65,25 +73,107 @@ class SelectedActions extends React.Component { className: '' } - state = { - force100: false + updateWidth = () => { + this.setState({ windowWidth: window.innerWidth }) } componentDidMount () { this.containerRef.current && this.containerRef.current.focus() + window.addEventListener('resize', this.updateWidth) } - render () { - const { t, tReady, animateOnStart, count, size, unselect, remove, share, setPinning, download, rename, inspect, className, style, isMfs, ...props } = this.props + componentWillUnmount () { + window.removeEventListener('resize', this.updateWidth) + } + + toggleMoreMenu = () => { + this.setState(state => ({ showMoreMenu: !state.showMoreMenu })) + } + render () { + const { t, tReady, animateOnStart, count, size, unselect, remove, move, share, setPinning, download, rename, inspect, className, style, isMfs, ...props } = this.props + const { showMoreMenu, windowWidth } = this.state const isSingle = count === 1 let singleFileTooltip = { title: t('individualFilesOnly') } - if (count === 1) { singleFileTooltip = {} } + const items = [ + { + action: share, + icon: