diff --git a/public/locales/en/files.json b/public/locales/en/files.json index c44340915..2e6f6c4ab 100644 --- a/public/locales/en/files.json +++ b/public/locales/en/files.json @@ -191,6 +191,16 @@ "noPinsInProgress": "All done, no remote pins in progress.", "remotePinningInProgress": "Remote pinning in progress:", "selectAllEntries": "Select all entries", + "syncFromPins": { + "title": "Sync from Pins", + "description": "Import your pinned files into the Files view so you can see and manage them alongside your other files.", + "note": "This will copy pinned files to MFS (Mutable File System) so they appear in your Files view. The original pins will remain unchanged.", + "syncSelected": "Sync {{count}} files" + }, + "loadingPinDetails": "Loading pin details...", + "syncing": "Syncing...", + "pins": "pins", + "selectAll": "Select all", "previewNotFound": { "title": "IPFS can't find this item", "helpTitle": "These are common troubleshooting steps might help:", diff --git a/src/bundles/files/actions.js b/src/bundles/files/actions.js index 91e991067..c506e3a8f 100644 --- a/src/bundles/files/actions.js +++ b/src/bundles/files/actions.js @@ -476,6 +476,54 @@ const actions = () => ({ } }), + /** + * Syncs selected pinned files to MFS at the given root path. + * @param {string[]} selectedPins - Array of CID strings to sync + * @param {string} root - Destination directory in MFS + */ + doSyncFromPins: (selectedPins, root) => perform(ACTIONS.SYNC_FROM_PINS, async (/** @type {IPFSService} */ ipfs, { store }) => { + ensureMFS(store) + + const results = [] + const errors = [] + + for (const pinCid of selectedPins) { + try { + const cid = CID.parse(pinCid) + const src = `/ipfs/${cid}` + const dst = realMfsPath(join(root || '/files', `pinned-${cid.toString().substring(0, 8)}`)) + + // Check if destination already exists + let dstExists = false + try { + await ipfs.files.stat(dst) + dstExists = true + } catch { + // Destination doesn't exist, we can proceed + } + + if (dstExists) { + // Try with a different name + const timestamp = Date.now() + const newDst = realMfsPath(join(root || '/files', `pinned-${cid.toString().substring(0, 8)}-${timestamp}`)) + await ipfs.files.cp(src, newDst) + results.push({ cid: pinCid, path: newDst, success: true }) + } else { + await ipfs.files.cp(src, dst) + results.push({ cid: pinCid, path: dst, success: true }) + } + } catch (error) { + console.error(`Error syncing pin ${pinCid}:`, error) + errors.push({ cid: pinCid, error: error instanceof Error ? error.message : String(error) }) + } + } + + // Refresh the files view + await store.doFilesFetch() + + return { results, errors } + }), + /** * Reads a text file containing CIDs and adds each one to IPFS at the given root path. * @param {FileStream[]} source - The text file containing CIDs diff --git a/src/bundles/files/consts.js b/src/bundles/files/consts.js index 7d91a787b..857c08d58 100644 --- a/src/bundles/files/consts.js +++ b/src/bundles/files/consts.js @@ -27,6 +27,8 @@ export const ACTIONS = { ADD_CAR_FILE: ('FILES_ADD_CAR'), /** @type {'FILES_BULK_CID_IMPORT'} */ BULK_CID_IMPORT: ('FILES_BULK_CID_IMPORT'), + /** @type {'FILES_SYNC_FROM_PINS'} */ + SYNC_FROM_PINS: ('FILES_SYNC_FROM_PINS'), /** @type {'FILES_PIN_ADD'} */ PIN_ADD: ('FILES_PIN_ADD'), /** @type {'FILES_PIN_REMOVE'} */ diff --git a/src/bundles/files/index.js b/src/bundles/files/index.js index 6d073c472..17aa1b8a2 100644 --- a/src/bundles/files/index.js +++ b/src/bundles/files/index.js @@ -63,6 +63,7 @@ const createFilesBundle = () => { case ACTIONS.MOVE: case ACTIONS.COPY: case ACTIONS.MAKE_DIR: + case ACTIONS.SYNC_FROM_PINS: return updateJob(state, action.task, action.type) case ACTIONS.PIN_ADD: case ACTIONS.PIN_REMOVE: { diff --git a/src/bundles/files/protocol.ts b/src/bundles/files/protocol.ts index 90c9f450b..15d430a49 100644 --- a/src/bundles/files/protocol.ts +++ b/src/bundles/files/protocol.ts @@ -96,6 +96,7 @@ export type Message = | Perform<'FILES_PIN_ADD', Error, Pin[], void> | Perform<'FILES_PIN_REMOVE', Error, Pin[], void> | Perform<'FILES_PIN_LIST', Error, { pins: CID[] }, void> + | Perform<'FILES_SYNC_FROM_PINS', Error, { results: Array<{ cid: string, path: string, success: boolean }>, errors: Array<{ cid: string, error: string }> }, void> | Perform<'FILES_SIZE_GET', Error, { size: number }, void> | Perform<'FILES_PINS_SIZE_GET', Error, { pinsSize: number, numberOfPins: number }, void> diff --git a/src/bundles/files/selectors.js b/src/bundles/files/selectors.js index 2806c6384..2c7ad4540 100644 --- a/src/bundles/files/selectors.js +++ b/src/bundles/files/selectors.js @@ -79,6 +79,18 @@ const selectors = () => ({ */ selectFilesHasError: (state) => state.files.failed.length > 0, + /** + * @param {Model} state + */ + selectSyncFromPinsPending: (state) => + state.files.pending.filter(s => s.type === ACTIONS.SYNC_FROM_PINS), + + /** + * @param {Model} state + */ + selectSyncFromPinsFinished: (state) => + state.files.finished.filter(s => s.type === ACTIONS.SYNC_FROM_PINS), + /** * @param {Model} state */ diff --git a/src/files/FilesPage.js b/src/files/FilesPage.js index 64d70973c..f42a380d2 100644 --- a/src/files/FilesPage.js +++ b/src/files/FilesPage.js @@ -20,7 +20,7 @@ import { getJoyrideLocales } from '../helpers/i8n.js' import SortDropdown from './sort-dropdown/SortDropdown.js' // Icons -import Modals, { DELETE, NEW_FOLDER, SHARE, ADD_BY_CAR, RENAME, ADD_BY_PATH, BULK_CID_IMPORT, SHORTCUTS, CLI_TUTOR_MODE, PINNING, PUBLISH } from './modals/Modals.js' +import Modals, { DELETE, NEW_FOLDER, SHARE, ADD_BY_CAR, RENAME, ADD_BY_PATH, BULK_CID_IMPORT, SHORTCUTS, CLI_TUTOR_MODE, PINNING, PUBLISH, SYNC_FROM_PINS } from './modals/Modals.js' import Header from './header/Header.js' import FileImportStatus from './file-import-status/FileImportStatus.js' @@ -31,8 +31,8 @@ import Checkbox from '../components/checkbox/Checkbox.js' const FilesPage = ({ doFetchPinningServices, doFilesFetch, doPinsFetch, doFilesSizeGet, doFilesDownloadLink, doFilesDownloadCarLink, doFilesWrite, doAddCarFile, doFilesBulkCidImport, doFilesAddPath, doUpdateHash, doFilesUpdateSorting, doFilesNavigateTo, doFilesMove, doSetCliOptions, doFetchRemotePins, remotePins, pendingPins, failedPins, - ipfsProvider, ipfsConnected, doFilesMakeDir, doFilesShareLink, doFilesCidProvide, doFilesDelete, doSetPinning, onRemotePinClick, doPublishIpnsKey, - files, filesPathInfo, filesIsFetching, pinningServices, toursEnabled, handleJoyrideCallback, isCliTutorModeEnabled, cliOptions, filesSorting, t + ipfsProvider, ipfsConnected, doFilesMakeDir, doFilesShareLink, doFilesCopyCidProvide, doFilesCidProvide, doFilesDelete, doSetPinning, onRemotePinClick, doPublishIpnsKey, + files, filesPathInfo, filesSorting, filesIsFetching, pinningServices, toursEnabled, handleJoyrideCallback, isCliTutorModeEnabled, cliOptions, pins, doSyncFromPins, t }) => { const { doExploreUserProvidedPath } = useExplore() const contextMenuRef = useRef() @@ -130,6 +130,10 @@ const FilesPage = ({ const onAddByCar = (file, name) => { doAddCarFile(files.path, file, name) } + + const onSyncFromPins = (selectedPins) => { + doSyncFromPins(selectedPins, files.path) + } const onInspect = (cid) => doUpdateHash(`/explore/${cid}`) const onCheckRetrieval = (cid) => { doFilesCidProvide(cid) // Trigger background provide @@ -338,6 +342,7 @@ const FilesPage = ({ onAddByPath={(files) => showModal(ADD_BY_PATH, files)} onAddByCar={(files) => showModal(ADD_BY_CAR, files)} onBulkCidImport={(files) => showModal(BULK_CID_IMPORT, files)} + onSyncFromPins={() => showModal(SYNC_FROM_PINS)} onNewFolder={(files) => showModal(NEW_FOLDER, files)} onCliTutorMode={() => showModal(CLI_TUTOR_MODE)} handleContextMenu={(...args) => handleContextMenu(...args, true)} @@ -405,7 +410,9 @@ const FilesPage = ({ onAddByPath={onAddByPath} onAddByCar={onAddByCar} onBulkCidImport={onBulkCidImport} + onSyncFromPins={onSyncFromPins} onPinningSet={doSetPinning} + pins={pins} onPublish={doPublishIpnsKey} cliOptions={cliOptions} { ...modals } /> @@ -470,5 +477,7 @@ export default connect( 'selectCliOptions', 'doSetPinning', 'doPublishIpnsKey', + 'selectPins', + 'doSyncFromPins', withTour(withTranslation('files')(FilesPage)) ) diff --git a/src/files/file-input/FileInput.js b/src/files/file-input/FileInput.js index 5f654740a..9e43248b5 100644 --- a/src/files/file-input/FileInput.js +++ b/src/files/file-input/FileInput.js @@ -9,6 +9,7 @@ import FolderIcon from '../../icons/StrokeFolder.js' import NewFolderIcon from '../../icons/StrokeNewFolder.js' import DecentralizationIcon from '../../icons/StrokeDecentralization.js' import DataIcon from '../../icons/StrokeData.js' +import GlyphPinCloud from '../../icons/GlyphPinCloud.js' // Components import { Dropdown, DropdownMenu, Option } from '../dropdown/Dropdown.js' import Button from '../../components/button/button.tsx' @@ -61,6 +62,11 @@ class FileInput extends React.Component { this.toggleDropdown() } + onSyncFromPins = () => { + this.props.onSyncFromPins() + this.toggleDropdown() + } + onNewFolder = () => { this.props.onNewFolder() this.toggleDropdown() @@ -112,6 +118,10 @@ class FileInput extends React.Component { {t('bulkImport')} + @@ -142,7 +152,8 @@ FileInput.propTypes = { onAddByPath: PropTypes.func.isRequired, onAddByCar: PropTypes.func.isRequired, onBulkCidImport: PropTypes.func.isRequired, - onNewFolder: PropTypes.func.isRequired + onNewFolder: PropTypes.func.isRequired, + onSyncFromPins: PropTypes.func.isRequired } export default connect( diff --git a/src/files/header/Header.js b/src/files/header/Header.js index 53af1417c..47e5986bd 100644 --- a/src/files/header/Header.js +++ b/src/files/header/Header.js @@ -98,6 +98,7 @@ class Header extends React.Component { onAddByPath={this.props.onAddByPath} onAddByCar={this.props.onAddByCar} onBulkCidImport={this.props.onBulkCidImport} + onSyncFromPins={this.props.onSyncFromPins} onCliTutorMode={this.props.onCliTutorMode} /> :
{ this.dotsWrapper = el }}> diff --git a/src/files/modals/Modals.js b/src/files/modals/Modals.js index 0a8744e92..786c996e9 100644 --- a/src/files/modals/Modals.js +++ b/src/files/modals/Modals.js @@ -17,6 +17,7 @@ import CliTutorMode from '../../components/cli-tutor-mode/CliTutorMode.js' import { cliCommandList, cliCmdKeys } from '../../bundles/files/consts.js' import { realMfsPath } from '../../bundles/files/actions.js' import AddByCarModal from './add-by-car-modal/AddByCarModal.js' +import SyncFromPinsModal from './sync-from-pins-modal/SyncFromPinsModal.js' // Constants const NEW_FOLDER = 'new_folder' const SHARE = 'share' @@ -29,6 +30,7 @@ const CLI_TUTOR_MODE = 'cli_tutor_mode' const PINNING = 'pinning' const PUBLISH = 'publish' const SHORTCUTS = 'shortcuts' +const SYNC_FROM_PINS = 'sync_from_pins' export { NEW_FOLDER, @@ -41,7 +43,8 @@ export { CLI_TUTOR_MODE, PINNING, PUBLISH, - SHORTCUTS + SHORTCUTS, + SYNC_FROM_PINS } class Modals extends React.Component { @@ -82,6 +85,11 @@ class Modals extends React.Component { this.leave() } + onSyncFromPins = (selectedPins) => { + this.props.onSyncFromPins(selectedPins) + this.leave() + } + makeDir = (path) => { this.props.onMakeDir(join(this.props.root, path)) this.leave() @@ -199,6 +207,9 @@ class Modals extends React.Component { case SHORTCUTS: this.setState({ readyToShow: true }) break + case SYNC_FROM_PINS: + this.setState({ readyToShow: true }) + break default: // do nothing } @@ -317,6 +328,14 @@ class Modals extends React.Component { className='outline-0' onLeave={this.leave} /> + + + +
) } @@ -326,13 +345,15 @@ Modals.propTypes = { t: PropTypes.func.isRequired, show: PropTypes.string, files: PropTypes.array, + pins: PropTypes.array, onAddByPath: PropTypes.func.isRequired, onAddByCar: 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, + onSyncFromPins: PropTypes.func.isRequired } export default withTranslation('files')(Modals) diff --git a/src/files/modals/sync-from-pins-modal/SyncFromPinsModal.js b/src/files/modals/sync-from-pins-modal/SyncFromPinsModal.js new file mode 100644 index 000000000..a4d4f129f --- /dev/null +++ b/src/files/modals/sync-from-pins-modal/SyncFromPinsModal.js @@ -0,0 +1,173 @@ +import React, { useState, useEffect, useCallback } from 'react' +import PropTypes from 'prop-types' +import { withTranslation } from 'react-i18next' +import { CID } from 'multiformats/cid' + +import Checkbox from '../../../components/checkbox/Checkbox.js' +import Button from '../../../components/button/button.tsx' +import LoadingAnimation from '../../../components/loading-animation/LoadingAnimation.js' +import { Modal, ModalActions, ModalBody } from '../../../components/modal/modal' +import GlyphPinCloud from '../../../icons/GlyphPinCloud.js' +import { humanSize } from '../../../lib/files.js' + +const SyncFromPinsModal = ({ t, tReady, onCancel, onSync, pins, className, ...props }) => { + const [selectedPins, setSelectedPins] = useState([]) + const [isLoading, setIsLoading] = useState(false) + const [pinDetails, setPinDetails] = useState({}) + const [loadingDetails, setLoadingDetails] = useState(false) + + const loadPinDetails = useCallback(async () => { + try { + // This would need to be implemented in the actions to get pin details + // For now, we'll use basic info + const details = {} + for (const pin of pins) { + details[pin.toString()] = { + cid: pin, + name: `Pinned File ${pin.toString().substring(0, 8)}...`, + size: 'Unknown', + type: 'file' + } + } + setPinDetails(details) + } catch (error) { + console.error('Error loading pin details:', error) + } finally { + setLoadingDetails(false) + } + }, [pins]) + + // Load pin details when component mounts + useEffect(() => { + if (pins && pins.length > 0) { + setLoadingDetails(true) + loadPinDetails() + } + }, [pins, loadPinDetails]) + + const handleSelectAll = (checked) => { + if (checked) { + setSelectedPins(pins.map(pin => pin.toString())) + } else { + setSelectedPins([]) + } + } + + const handleSelectPin = (pinCid, checked) => { + if (checked) { + setSelectedPins(prev => [...prev, pinCid]) + } else { + setSelectedPins(prev => prev.filter(cid => cid !== pinCid)) + } + } + + const handleSync = async () => { + if (selectedPins.length === 0) return + + setIsLoading(true) + try { + await onSync(selectedPins) + } catch (error) { + console.error('Error syncing pins:', error) + } finally { + setIsLoading(false) + } + } + + const allSelected = selectedPins.length === pins.length && pins.length > 0 + const someSelected = selectedPins.length > 0 && selectedPins.length < pins.length + + if (!tReady) { + return + } + + return ( + + +
+

{t('syncFromPins.description')}

+

{t('syncFromPins.note')}

+
+ + {loadingDetails + ? ( +
+ + {t('loadingPinDetails')} +
+ ) + : ( +
+
+ + +
+ +
+ {pins.map((pin, index) => { + const pinCid = pin.toString() + const details = pinDetails[pinCid] + const isSelected = selectedPins.includes(pinCid) + + return ( +
+ handleSelectPin(pinCid, checked)} + className='mr3' + /> +
+
+ {details?.name || `Pinned File ${pinCid.substring(0, 8)}...`} +
+
+ {details?.size && details.size !== 'Unknown' && ( + {humanSize(details.size)} + )} + {pinCid.substring(0, 12)}... +
+
+
+ ) + })} +
+
+ )} +
+ + + + + +
+ ) +} + +SyncFromPinsModal.propTypes = { + t: PropTypes.func.isRequired, + tReady: PropTypes.bool, + onCancel: PropTypes.func.isRequired, + onSync: PropTypes.func.isRequired, + pins: PropTypes.arrayOf(PropTypes.instanceOf(CID)).isRequired, + className: PropTypes.string +} + +export default withTranslation('files')(SyncFromPinsModal)