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:
,
+ label: t('actions.share'),
+ className: '',
+ disabled: false,
+ extras: {}
+ },
+ {
+ action: download,
+ icon:
,
+ label: t('app:actions.download'),
+ className: '',
+ disabled: false,
+ extras: {}
+ },
+ {
+ action: remove,
+ icon:
,
+ label: t('app:actions.remove'),
+ className: classes.action(isMfs),
+ disabled: !isMfs,
+ extras: {}
+ },
+ {
+ action: move,
+ icon:
,
+ label: t('app:actions.move'),
+ className: classes.action(isMfs),
+ disabled: !isMfs,
+ extras: {}
+ },
+ {
+ action: setPinning,
+ icon:
,
+ label: t('app:actions.setPinning'),
+ className: classes.action(isSingle),
+ disabled: !isSingle,
+ extras: {}
+ },
+ {
+ action: inspect,
+ icon:
,
+ label: t('app:actions.inspect'),
+ className: classes.action(isSingle),
+ disabled: !isSingle,
+ extras: singleFileTooltip
+ },
+ {
+ action: rename,
+ icon:
,
+ label: t('app:actions.rename'),
+ className: classes.action(isSingle && isMfs),
+ disabled: !(isSingle && isMfs),
+ extras: singleFileTooltip
+ }
+ ]
+
+ // max widths breakpoints
+ const breakpoints = () => {
+ if (windowWidth <= 400) {
+ return 1
+ } else if (windowWidth <= 768) {
+ return 2
+ } else if (windowWidth <= 1045) {
+ return 3
+ } else if (windowWidth <= 1250) {
+ return 4
+ }
+
+ return items.length
+ }
+
return (
@@ -92,37 +182,35 @@ class SelectedActions extends React.Component {
{count}
-
+
{t('filesSelected', { count })}
{t('totalSize', { size: humanSize(size) })}
-
-
-
-
-
-
-
+
+
+ {items.slice(0, breakpoints()).map((item, i) => (
+
+ ))}
+ {(breakpoints() < (items.length) &&
)}
+ {showMoreMenu && (
+
+ {items.slice(breakpoints(), items.length).map((item, i) => (
+
+ ))}
+
+ )}
+