diff --git a/package-lock.json b/package-lock.json index e03d6a413..c63b56171 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9455,9 +9455,9 @@ "dev": true }, "dnd-core": { - "version": "10.0.2", - "resolved": "https://registry.npmjs.org/dnd-core/-/dnd-core-10.0.2.tgz", - "integrity": "sha512-PrxEjxF0+6Y1n1n1Z9hSWZ1tvnDXv9syL+BccV1r1RC08uWNsyetf8AnWmUF3NgYPwy0HKQJwTqGkZK+1NlaFA==", + "version": "11.1.3", + "resolved": "https://registry.npmjs.org/dnd-core/-/dnd-core-11.1.3.tgz", + "integrity": "sha512-QugF55dNW+h+vzxVJ/LSJeTeUw9MCJ2cllhmVThVPEtF16ooBkxj0WBE5RB+AceFxMFo1rO6bJKXtqKl+JNnyA==", "requires": { "@react-dnd/asap": "^4.0.0", "@react-dnd/invariant": "^2.0.0", @@ -16075,8 +16075,7 @@ "it-first": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/it-first/-/it-first-1.0.2.tgz", - "integrity": "sha512-hU5ObR14987PR7l0J7dfWAgKYiWoKbXcoXKqhQDGgHSZML6UPmHSS9ILBGucZkoA2B152kEqEOllS4tVQq11fg==", - "dev": true + "integrity": "sha512-hU5ObR14987PR7l0J7dfWAgKYiWoKbXcoXKqhQDGgHSZML6UPmHSS9ILBGucZkoA2B152kEqEOllS4tVQq11fg==" }, "it-glob": { "version": "0.0.8", @@ -22774,22 +22773,22 @@ } }, "react-dnd": { - "version": "10.0.2", - "resolved": "https://registry.npmjs.org/react-dnd/-/react-dnd-10.0.2.tgz", - "integrity": "sha512-SC2Ymvntynhoqtf5zaFhZscm9xenCoMofilxPdlwUlaelAzmbl9fw82C4ZJ//+lNm3kWAKXjGDZg2/aWjKEAtg==", + "version": "11.1.3", + "resolved": "https://registry.npmjs.org/react-dnd/-/react-dnd-11.1.3.tgz", + "integrity": "sha512-8rtzzT8iwHgdSC89VktwhqdKKtfXaAyC4wiqp0SywpHG12TTLvfOoL6xNEIUWXwIEWu+CFfDn4GZJyynCEuHIQ==", "requires": { "@react-dnd/shallowequal": "^2.0.0", "@types/hoist-non-react-statics": "^3.3.1", - "dnd-core": "^10.0.2", + "dnd-core": "^11.1.3", "hoist-non-react-statics": "^3.3.0" } }, "react-dnd-html5-backend": { - "version": "10.0.2", - "resolved": "https://registry.npmjs.org/react-dnd-html5-backend/-/react-dnd-html5-backend-10.0.2.tgz", - "integrity": "sha512-ny17gUdInZ6PIGXdzfwPhoztRdNVVvjoJMdG80hkDBamJBeUPuNF2Wv4D3uoQJLjXssX1+i9PhBqc7EpogClwQ==", + "version": "11.1.3", + "resolved": "https://registry.npmjs.org/react-dnd-html5-backend/-/react-dnd-html5-backend-11.1.3.tgz", + "integrity": "sha512-/1FjNlJbW/ivkUxlxQd7o3trA5DE33QiRZgxent3zKme8DwF4Nbw3OFVhTRFGaYhHFNL1rZt6Rdj1D78BjnNLw==", "requires": { - "dnd-core": "^10.0.2" + "dnd-core": "^11.1.3" } }, "react-docgen": { diff --git a/package.json b/package.json index f5a7d2439..11835faed 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,7 @@ "is-binary": "^0.1.0", "is-ipfs": "^1.0.3", "it-all": "^1.0.2", + "it-first": "^1.0.2", "it-last": "^1.0.2", "it-map": "^1.0.2", "milliseconds": "^1.0.3", @@ -68,8 +69,8 @@ "react-copy-to-clipboard": "^5.0.2", "react-country-flag": "^1.1.0", "react-debounce-render": "^5.0.0", - "react-dnd": "^10.0.2", - "react-dnd-html5-backend": "^10.0.2", + "react-dnd": "^11.1.3", + "react-dnd-html5-backend": "^11.1.3", "react-dom": "^16.13.1", "react-faux-dom": "^4.5.0", "react-helmet": "^5.2.1", diff --git a/public/locales/en/files.json b/public/locales/en/files.json index 5e0c6a60f..dcedc86b9 100644 --- a/public/locales/en/files.json +++ b/public/locales/en/files.json @@ -91,7 +91,10 @@ }, "step2": { "title": "Breadcrumbs", - "paragraph1": "The current work directory, you can navigate through the folder hierarchy by clicking on them." + "paragraph1": "These breadcrumbs display the path to your current directory. You can…", + "bullet1": "Click an individual breadcrumb to go to that directory", + "bullet2": "Right-click a breadcrumb to get the actions menu for that directory", + "bullet3": "Drag files onto a breadcrumb to move or import them" }, "step3": { "title": "Add files", diff --git a/public/locales/en/notify.json b/public/locales/en/notify.json index 49a6e90e2..5f1e943a6 100644 --- a/public/locales/en/notify.json +++ b/public/locales/en/notify.json @@ -4,7 +4,7 @@ "ipfsApiRequestFailed": "Could not connect. Please check if your daemon is running.", "windowIpfsRequestFailed": "IPFS request failed. Please check your IPFS Companion settings.", "ipfsIsBack": "Normal IPFS service has resumed. Enjoy!", - "folderExists": "A folder with that name already exists. Please choose another.", + "folderExists": "An item with that name already exists. Please choose another.", "filesFetchFailed": "Failed to get those files. Please check the path and try again.", "filesRenameFailed": "Failed to rename the files. Please try again.", "filesMakeDirFailed": "Failed to create directory. Please try again.", diff --git a/src/bundles/ipfs-provider.js b/src/bundles/ipfs-provider.js index a673f5fed..1a0532154 100644 --- a/src/bundles/ipfs-provider.js +++ b/src/bundles/ipfs-provider.js @@ -3,6 +3,7 @@ import multiaddr from 'multiaddr' import HttpClient from 'ipfs-http-client' import { getIpfs, providers } from 'ipfs-provider' +import first from 'it-first' import last from 'it-last' /** @@ -169,9 +170,12 @@ const writeSetting = (id, value) => { /** * @typedef {Object} IPFSAPI * @property {(callback?:Function) => Promise} stop + * @property {Object} files + * @property {(path:string) => Promise} files.stat + * @property {Object} pin + * @property {(options:Object) => Iterator} pin.ls */ - -/** @type {IPFSAPI|void} */ +/** @type {IPFSAPI|null} */ let ipfs = null /** @@ -232,6 +236,17 @@ const bundle = { doDismissIpfsInvalidAddress: () => (store) => { store.dispatch({ type: 'IPFS_API_ADDRESS_INVALID_DISMISS' }) + }, + + doGetPathInfo: (path) => async () => { + return await ipfs.files.stat(path) + }, + + doCheckIfPinned: (cid) => async () => { + try { + const value = await first(ipfs.pin.ls({ paths: [cid] })) + return !!value + } catch (_) { return false } } } diff --git a/src/files/FilesPage.js b/src/files/FilesPage.js index ad25a0295..5e439af52 100644 --- a/src/files/FilesPage.js +++ b/src/files/FilesPage.js @@ -225,7 +225,7 @@ class FilesPage extends React.Component { translateX={contextMenu.translateX} translateY={contextMenu.translateY} handleClick={this.handleContextMenu} - isUpperDir={contextMenu.file && contextMenu.file.name === '..'} + isDirectory={contextMenu.file && contextMenu.file.type === 'directory'} isMfs={filesPathInfo ? filesPathInfo.isMfs : false} isUnknown={!!(contextMenu.file && contextMenu.file.type === 'unknown')} pinned={contextMenu.file && contextMenu.file.pinned} @@ -246,6 +246,7 @@ class FilesPage extends React.Component { files={files} onNavigate={this.props.doFilesNavigateTo} onAddFiles={this.onAddFiles} + onMove={this.props.doFilesMove} onAddByPath={(files) => this.showModal(ADD_BY_PATH, files)} onNewFolder={(files) => this.showModal(NEW_FOLDER, files)} onCliTutorMode={() => this.showModal(CLI_TUTOR_MODE)} diff --git a/src/files/breadcrumbs/Breadcrumbs.css b/src/files/breadcrumbs/Breadcrumbs.css new file mode 100644 index 000000000..dfc8b752e --- /dev/null +++ b/src/files/breadcrumbs/Breadcrumbs.css @@ -0,0 +1,23 @@ +.BreadcrumbsButton::after { + content: ''; + position: absolute; + left: 0; + right: 0; + bottom: -4px; + height: 2px; + background: currentColor; + transition: opacity 0.2s ease-in-out; + opacity: 0; +} + +.BreadcrumbsButton.white::after { + background: inherit; +} + +.BreadcrumbsButton.dragging::after { + opacity: 1; +} + +.BreadcrumbsButton.no-events { + pointer-events: none; +} diff --git a/src/files/breadcrumbs/Breadcrumbs.js b/src/files/breadcrumbs/Breadcrumbs.js index 559ba2639..6db259019 100644 --- a/src/files/breadcrumbs/Breadcrumbs.js +++ b/src/files/breadcrumbs/Breadcrumbs.js @@ -1,18 +1,123 @@ -import React from 'react' +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' +import { NativeTypes } from 'react-dnd-html5-backend' + +import { normalizeFiles } from '../../lib/files' + +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) { + (async () => { + const files = await filesPromise + onAddFiles(await 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) } + } + }, + collect: (monitor) => ({ + isOver: monitor.isOver() + }) + }) + + const buttonRef = useRef() + + const handleOnContextMenuHandle = async (ev) => { + ev.preventDefault() + + const { path } = link + const sanitizedPath = path.substring(path.indexOf('/', 1), path.length) + const { cid, type } = await getPathInfo(sanitizedPath) + const pinned = await checkIfPinned(cid) + + onContextMenuHandle(undefined, buttonRef.current, { + ...link, + type, + cid, + pinned + }) + } + + return ( + + + + ) +} + +const Breadcrumbs = ({ t, tReady, path, onClick, className, onContextMenuHandle, onAddFiles, onMove, doGetPathInfo, doCheckIfPinned, ...props }) => { + const [overflows, setOverflows] = useState(false) + const [isImmutable, setImmutable] = useState(false) + const anchors = useRef() + + useEffect(() => { + const a = anchors.current + + const newOverflows = a ? (a.offsetHeight < a.scrollHeight || a.offsetWidth < a.scrollWidth) : false + if (newOverflows !== overflows) { + setOverflows(newOverflows) + } + }, [overflows]) + + const bread = useMemo(() => + makeBread(path, t, isImmutable, setImmutable) + , [isImmutable, path, t]) + + return ( + + ) +} -function makeBread (root) { +Breadcrumbs.propTypes = { + path: PropTypes.string.isRequired, + onClick: PropTypes.func.isRequired, + t: PropTypes.func.isRequired, + onContextMenuHandle: PropTypes.func +} + +function makeBread (root, t, isImmutable, setImmutable) { if (root.endsWith('/')) { root = root.substring(0, root.length - 1) } - const parts = root.split('/').map(part => { - return { - name: part, - path: part - } - }) + const parts = root.split('/').map(part => ({ + name: part, + path: part + })) for (let i = 1; i < parts.length; i++) { const name = parts[i].name @@ -29,83 +134,19 @@ function makeBread (root) { } parts.shift() - parts[0].disabled = true - parts[parts.length - 1].last = true - return parts -} - -class Breadcrumbs extends React.Component { - state = { - overflows: false + if (parts[0].name === 'ipfs' || parts[0].name === 'ipns') { + !isImmutable && setImmutable(true) } - componentDidUpdate (_, prevState) { - const a = this.anchors - const overflows = a ? (a.offsetHeight < a.scrollHeight || a.offsetWidth < a.scrollWidth) : false - - if (prevState.overflows !== overflows) { - this.setState({ overflows }) - } - } + parts[0].name = t(parts[0].name) - render () { - const { t, tReady, path, onClick, className = '', ...props } = this.props - - const cls = `Breadcrumbs flex items-center sans-serif overflow-hidden ${className}` - const bread = makeBread(path) - const root = bread[0] - - if (root.name === 'files' || root.name === 'pins') { - bread.shift() - } - - const res = bread.map((link, index) => ([ - - { link.disabled - ? {link.name} - : - } - , - /* eslint-disable-next-line jsx-a11y/anchor-is-valid */ - / - ])) - - if (res.length === 0) { - /* eslint-disable-next-line jsx-a11y/anchor-is-valid */ - res.push(/) - } - - res.reverse() - - return ( - - ) - } -} - -Breadcrumbs.propTypes = { - path: PropTypes.string.isRequired, - onClick: PropTypes.func.isRequired, - t: PropTypes.func.isRequired, - tReady: PropTypes.bool.isRequired + parts[parts.length - 1].last = true + return parts } -export default withTranslation('files')(Breadcrumbs) +export default connect( + 'doGetPathInfo', + 'doCheckIfPinned', + withTranslation('files')(Breadcrumbs) +) diff --git a/src/files/context-menu/ContextMenu.js b/src/files/context-menu/ContextMenu.js index e3bf67295..f7131ba74 100644 --- a/src/files/context-menu/ContextMenu.js +++ b/src/files/context-menu/ContextMenu.js @@ -45,7 +45,7 @@ class ContextMenu extends React.Component { const { t, onRename, onDelete, onDownload, onInspect, onShare, translateX, translateY, className, - isUpperDir, isMfs, isUnknown, pinned, isCliTutorModeEnabled + isDirectory, isMfs, isUnknown, pinned, isCliTutorModeEnabled } = this.props return ( @@ -58,7 +58,7 @@ class ContextMenu extends React.Component { translateY={-translateY} open={this.props.isOpen} onDismiss={this.props.handleClick}> - { !isUpperDir && onShare && + { !isDirectory && onShare && - { !isUpperDir && !isUnknown && onDownload && + { !isDirectory && !isUnknown && onDownload && } - { !isUpperDir && !isUnknown && isMfs && onRename && + { !isDirectory && !isUnknown && isMfs && onRename && } - { !isUpperDir && !isUnknown && isMfs && onDelete && + { !isDirectory && !isUnknown && isMfs && onDelete &&