From 1cb1f76a449ac5d0355e62c63c1efd1e44978d89 Mon Sep 17 00:00:00 2001 From: Suchitra Swain Date: Sun, 19 Oct 2025 12:17:40 +0200 Subject: [PATCH 1/2] feat: Add Sync from Pins feature to Files view - Add new 'Sync from Pins' modal to import pinned files into MFS - Implement pin selection with checkboxes and select all functionality - Add doSyncFromPins action to copy pinned content to Files view - Update FileInput component with new sync option in Import dropdown - Add proper error handling and loading states - Include translations for all new UI elements - Maintain existing pin status while copying to MFS Resolves #2444 --- public/locales/en/files.json | 10 + src/bundles/files/actions.js | 48 +++++ src/bundles/files/consts.js | 2 + src/bundles/files/index.js | 1 + src/bundles/files/protocol.ts | 1 + src/bundles/files/selectors.js | 12 ++ src/files/FilesPage.js | 13 +- src/files/file-input/FileInput.js | 13 +- src/files/header/Header.js | 1 + src/files/modals/Modals.js | 25 ++- .../sync-from-pins-modal/SyncFromPinsModal.js | 173 ++++++++++++++++++ 11 files changed, 294 insertions(+), 5 deletions(-) create mode 100644 src/files/modals/sync-from-pins-modal/SyncFromPinsModal.js diff --git a/public/locales/en/files.json b/public/locales/en/files.json index a50098709..8b301ac31 100644 --- a/public/locales/en/files.json +++ b/public/locales/en/files.json @@ -186,6 +186,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 bc59e465c..54381f0cf 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 a087a737b..da9b3e101 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 fd45fa221..ad0b8600c 100644 --- a/src/bundles/files/index.js +++ b/src/bundles/files/index.js @@ -32,6 +32,7 @@ const createFilesBundle = () => { case ACTIONS.MAKE_DIR: case ACTIONS.PIN_ADD: case ACTIONS.PIN_REMOVE: + case ACTIONS.SYNC_FROM_PINS: return updateJob(state, action.task, action.type) case ACTIONS.WRITE: { return updateJob(state, action.task, action.type) diff --git a/src/bundles/files/protocol.ts b/src/bundles/files/protocol.ts index 72a0454a3..2d79339f6 100644 --- a/src/bundles/files/protocol.ts +++ b/src/bundles/files/protocol.ts @@ -95,6 +95,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 ede996fd1..3c29adf25 100644 --- a/src/files/FilesPage.js +++ b/src/files/FilesPage.js @@ -18,7 +18,7 @@ import FileNotFound from './file-not-found/index.tsx' import { getJoyrideLocales } from '../helpers/i8n.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' @@ -30,7 +30,7 @@ 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, doFilesCopyCidProvide, doFilesDelete, doSetPinning, onRemotePinClick, doPublishIpnsKey, - files, filesPathInfo, pinningServices, toursEnabled, handleJoyrideCallback, isCliTutorModeEnabled, cliOptions, t + files, filesPathInfo, pinningServices, toursEnabled, handleJoyrideCallback, isCliTutorModeEnabled, cliOptions, pins, doSyncFromPins, t }) => { const { doExploreUserProvidedPath } = useExplore() const contextMenuRef = useRef() @@ -118,6 +118,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 showModal = (modal, files = null) => setModals({ show: modal, files }) const hideModal = () => setModals({}) @@ -316,6 +320,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)} @@ -384,7 +389,9 @@ const FilesPage = ({ onAddByPath={onAddByPath} onAddByCar={onAddByCar} onBulkCidImport={onBulkCidImport} + onSyncFromPins={onSyncFromPins} onPinningSet={doSetPinning} + pins={pins} onPublish={doPublishIpnsKey} cliOptions={cliOptions} { ...modals } /> @@ -448,5 +455,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) From 9a4a32ffe751a3a750245f7664332e440b394505 Mon Sep 17 00:00:00 2001 From: Suchitra Swain Date: Sun, 19 Oct 2025 12:22:17 +0200 Subject: [PATCH 2/2] conflict fixed --- src/bundles/files/index.js | 2 -- src/files/FilesPage.js | 4 ++-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/src/bundles/files/index.js b/src/bundles/files/index.js index aeef655e8..17aa1b8a2 100644 --- a/src/bundles/files/index.js +++ b/src/bundles/files/index.js @@ -63,8 +63,6 @@ const createFilesBundle = () => { case ACTIONS.MOVE: case ACTIONS.COPY: case ACTIONS.MAKE_DIR: - case ACTIONS.PIN_ADD: - case ACTIONS.PIN_REMOVE: case ACTIONS.SYNC_FROM_PINS: return updateJob(state, action.task, action.type) case ACTIONS.PIN_ADD: diff --git a/src/files/FilesPage.js b/src/files/FilesPage.js index 7cdfce4f6..f42a380d2 100644 --- a/src/files/FilesPage.js +++ b/src/files/FilesPage.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, doFilesCopyCidProvide, doFilesDelete, doSetPinning, onRemotePinClick, doPublishIpnsKey, - files, filesPathInfo, pinningServices, toursEnabled, handleJoyrideCallback, isCliTutorModeEnabled, cliOptions, pins, doSyncFromPins, 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()