Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions public/locales/en/files.json
Original file line number Diff line number Diff line change
Expand Up @@ -77,9 +77,9 @@
},
"addByPathModal": {
"title": "Import from IPFS",
"description": "Insert an IPFS path (CID) to import.",
"preloadNote": "NOTE: Imported path is lazy-loaded by default, and will be retrieved on-demand as you browse it. To proactively prefetch imported path to the local Datastore, choose ”Download” from the context menu.",
"importPathPlaceholder": "Path or CID (required)",
"description": "Insert an IPFS or IPNS path, CID, or protocol URL to import.",
"preloadNote": "NOTE: Imported path is lazy-loaded by default, and will be retrieved on-demand as you browse it. To proactively prefetch imported path to the local Datastore, choose \u201cDownload\u201d from the context menu.",
"importPathPlaceholder": "Path, CID, or URL (required)",
"namePlaceholder": "Name (optional)",
"examples": "Examples:"
},
Expand Down Expand Up @@ -110,6 +110,7 @@
"filesImportStatus": {
"imported": "{count, plural, one {Imported 1 item} other {Imported {count} items}}",
"importing": "{count, plural, one {Importing 1 item} other {Importing {count} items}}",
"failed": "{count, plural, one {Failed to import 1 item} other {Failed to import {count} items}}",
"toggleDropdown": "Toggle dropdown",
"closeDropdown": "Close dropdown",
"count": "{count} of {count}"
Expand Down
34 changes: 25 additions & 9 deletions src/bundles/files/actions.js
Original file line number Diff line number Diff line change
Expand Up @@ -441,25 +441,41 @@ const actions = () => ({
* @param {string} src
* @param {string} name
*/
doFilesAddPath: (root, src, name = '') => perform(ACTIONS.ADD_BY_PATH, async (ipfs, { store }) => {
doFilesAddPath: (root, src, name = '') => spawn(ACTIONS.ADD_BY_PATH, async function * (ipfs, { store }) {
ensureMFS(store)

const path = realMfsPath(src)
const cid = /** @type {string} */(path.split('/').pop())
let srcPath = src

if (!name) {
name = cid
// Resolve /ipns/ paths to /ipfs/ first
if (src.startsWith('/ipns/')) {
const [ipnsName, ...subpathParts] = src.slice('/ipns/'.length).split('/')
const resolved = await last(ipfs.name.resolve(`/ipns/${ipnsName}`))
const subpath = subpathParts.length ? '/' + subpathParts.join('/') : ''
srcPath = resolved + subpath
} else if (!src.startsWith('/')) {
srcPath = `/ipfs/${src}`
}

const dst = realMfsPath(join(root, name))
const srcPath = src.startsWith('/') ? src : `/ipfs/${cid}`
// Strip trailing slashes to get proper filename
while (srcPath.endsWith('/')) {
srcPath = srcPath.slice(0, -1)
}

const fileName = name || /** @type {string} */(srcPath.split('/').pop())
const entries = [{ path: fileName, size: 0 }]

yield { entries, progress: 0 }

const dst = realMfsPath(join(root, fileName))

try {
return await ipfs.files.cp(srcPath, dst)
await ipfs.files.cp(srcPath, dst)
yield { entries, progress: 100 }
return entries
} finally {
await store.doFilesFetch()
}
}),
}, src),

/**
* Adds CAR file. On completion will trigger `doFilesFetch` to update the state.
Expand Down
4 changes: 2 additions & 2 deletions src/bundles/files/selectors.js
Original file line number Diff line number Diff line change
Expand Up @@ -66,13 +66,13 @@ const selectors = () => ({
* @returns {PendingJob<void, {progress: number, entries: {size:number, path: string}[]}>[]}
*/
selectFilesPending: (state) =>
state.files.pending.filter(s => s.type === ACTIONS.WRITE && s.message != null),
state.files.pending.filter(s => (s.type === ACTIONS.WRITE || s.type === ACTIONS.ADD_BY_PATH) && s.message != null),

/**
* @param {Model} state
*/
selectFilesFinished: (state) =>
state.files.finished.filter(s => s.type === ACTIONS.WRITE),
state.files.finished.filter(s => s.type === ACTIONS.WRITE || s.type === ACTIONS.ADD_BY_PATH),

/**
* @param {Model} state
Expand Down
3 changes: 2 additions & 1 deletion src/files/FilesPage.js
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,8 @@ const FilesPage = ({
doUpdateHash(files?.parentPath)
}, [files?.parentPath, doUpdateHash])

const onAddByPath = (path, name) => doFilesAddPath(files.path, path, name)
// errors are tracked in redux via filesErrors, catch here to prevent unhandled rejection crash
const onAddByPath = (path, name) => doFilesAddPath(files.path, path, name).catch(() => {})
/**
*
* @param {File} file
Expand Down
27 changes: 2 additions & 25 deletions src/files/explore-form/files-explore-form.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import React, { useCallback, useMemo, useState } from 'react'
import * as isIPFS from 'is-ipfs'
import { useTranslation } from 'react-i18next'
import { isValidIpfsPath, normalizeToPath } from '../../lib/ipfs-path.js'
import StrokeFolder from '../../icons/StrokeFolder.js'
import StrokeIpld from '../../icons/StrokeIpld.js'
import Button from '../../components/button/button'
Expand All @@ -15,22 +16,6 @@ interface FilesExploreFormProps {
onBrowse: ({ path, cid }: {path: string, cid?: string}) => void
}

/**
* Normalize input to a canonical path format:
* - ipfs://CID -> /ipfs/CID
* - ipns://name -> /ipns/name
* - Existing paths pass through unchanged
*/
const normalizeToPath = (input: string): string => {
if (input.startsWith('ipfs://')) {
return '/ipfs/' + input.slice(7)
}
if (input.startsWith('ipns://')) {
return '/ipns/' + input.slice(7)
}
return input
}

const FilesExploreForm: React.FC<FilesExploreFormProps> = ({ onBrowse: onBrowseProp }) => {
const [path, setPath] = useState('')
const [isResolving, setIsResolving] = useState(false)
Expand All @@ -43,15 +28,7 @@ const FilesExploreForm: React.FC<FilesExploreFormProps> = ({ onBrowse: onBrowseP
return path.trim()
}, [path])

const isValid = useMemo(() => {
if (trimmedPath === '') return false
// Accept native protocol URLs
if (trimmedPath.startsWith('ipfs://') || trimmedPath.startsWith('ipns://')) {
const asPath = normalizeToPath(trimmedPath)
return isIPFS.path(asPath)
}
return isIPFS.cid(trimmedPath) || isIPFS.path(trimmedPath)
}, [trimmedPath])
const isValid = useMemo(() => isValidIpfsPath(trimmedPath), [trimmedPath])

const inputClass = useMemo(() => {
if (trimmedPath === '') {
Expand Down
14 changes: 14 additions & 0 deletions src/files/file-import-status/FileImportStatus.css
Original file line number Diff line number Diff line change
Expand Up @@ -54,4 +54,18 @@

.fileImportStatusName {
flex: 1 1;
}

.fileImportStatusError {
background: #fff5f5 !important;
border-color: #feb2b2 !important;
}

.fileImportStatusError .fileImportStatusButton {
background: #fff5f5 !important;
color: #c53030 !important;
}

.fileImportStatusIconError {
fill: #fc8181;
}
75 changes: 64 additions & 11 deletions src/files/file-import-status/FileImportStatus.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,15 @@ import PropTypes from 'prop-types'
import { connect } from 'redux-bundler-react'
import { withTranslation } from 'react-i18next'
// Icons
import DocumentIcon from '../../icons/GlyphDocGeneric.js'
import FolderIcon from '../../icons/GlyphFolder.js'
import DocumentIcon from '../../icons/GlyphDocGeneric.tsx'
import FolderIcon from '../../icons/GlyphFolder.tsx'
import './FileImportStatus.css'
import GlyphSmallArrows from '../../icons/GlyphSmallArrow.js'
import GlyphTick from '../../icons/GlyphTick.js'
import GlyphCancel from '../../icons/GlyphCancel.js'
import GlyphSmallCancel from '../../icons/GlyphSmallCancel.js'
import GlyphSmallArrows from '../../icons/GlyphSmallArrow.tsx'
import GlyphTick from '../../icons/GlyphTick.tsx'
import GlyphCancel from '../../icons/GlyphCancel.tsx'
import GlyphSmallCancel from '../../icons/GlyphSmallCancel.tsx'
import ProgressBar from '../../components/progress-bar/ProgressBar.js'
import { ACTIONS } from '../../bundles/files/consts.js'

const Import = (job, t) =>
[...groupByPath(job?.message?.entries || new Map()).values()].map(item => (
Expand All @@ -26,6 +27,28 @@ const Import = (job, t) =>
</li>
))

/**
* Renders a failed import job with its error message.
* Used for jobs like ADD_BY_PATH that don't have message.entries structure.
*/
const FailedImport = (job, t) => {
const path = job.init || t('filesImportStatus.unknownPath', 'Unknown path')
const errorMessage = job.error?.message || t('filesImportStatus.unknownError', 'Unknown error')

return (
<li className="flex flex-column w-100 bb b--light-gray f6 charcoal" key={job.id?.toString() || path}>
<div className="flex items-center">
<DocumentIcon className="fileImportStatusIcon fileImportStatusIconError pa1" />
<span className="fileImportStatusName truncate">{path}</span>
<GlyphCancel className="dark-red w2 ph1 ml-auto" fill="currentColor"/>
</div>
<div className="f7 dark-red mt1 truncate" style={{ marginLeft: '36px' }} title={errorMessage}>
{errorMessage}
</div>
</li>
)
}

const viewIcon = (entry) =>
entry.type === 'directory'
? <FolderIcon className="fileImportStatusIcon fill-aqua pa1" />
Expand Down Expand Up @@ -108,13 +131,30 @@ export const FileImportStatus = ({ filesFinished, filesPending, filesErrors, doF
setExpanded(!expanded)
}

const numberOfImportedItems = !filesFinished.length ? 0 : filesFinished.reduce((prev, finishedFile) => prev + finishedFile.message.entries.length, 0)
const numberOfPendingItems = filesPending.reduce((total, pending) => total + groupByPath(pending.message.entries).size, 0)
// Separate WRITE errors (have message.entries) from ADD_BY_PATH errors (no message.entries)
const writeErrors = filesErrors.filter(e => e.type === ACTIONS.WRITE)
const importPathErrors = filesErrors.filter(e => e.type === ACTIONS.ADD_BY_PATH)

const numberOfImportedItems = !filesFinished.length ? 0 : filesFinished.reduce((prev, finishedFile) => prev + (finishedFile.message?.entries?.length ?? 1), 0)
const numberOfFailedItems = writeErrors.reduce((prev, failedFile) => prev + (failedFile.message?.entries?.length ?? 1), 0) + importPathErrors.length
const numberOfPendingItems = filesPending.reduce((total, pending) => total + groupByPath(pending.message?.entries || []).size, 0)
const progress = Math.floor(filesPending.reduce((total, { message: { progress } }) => total + progress, 0) / filesPending.length)

// Find the most recent operation by comparing timestamps
const lastFinished = filesFinished.length > 0
? Math.max(...filesFinished.map(f => f.start))
: 0
const lastFailed = filesErrors.length > 0
? Math.max(...filesErrors.map(f => f.start))
: 0

// Show error styling only if the most recent operation was a failure
const hasErrors = lastFailed > lastFinished && !filesPending.length
const containerClass = hasErrors ? 'fileImportStatusError' : ''

return (
<div className='fileImportStatus fixed bottom-1 w-100 flex justify-center' style={{ zIndex: 14, pointerEvents: 'none' }}>
<div className="relative br1 dark-gray w-40 center ba b--light-gray bg-white" style={{ pointerEvents: 'auto' }}>
<div className={`relative br1 dark-gray w-40 center ba b--light-gray bg-white ${containerClass}`} style={{ pointerEvents: 'auto' }}>
<div
tabIndex="0"
onClick={() => setExpanded(!expanded)}
Expand All @@ -127,7 +167,9 @@ export const FileImportStatus = ({ filesFinished, filesPending, filesErrors, doF
<span>
{ filesPending.length
? `${t('filesImportStatus.importing', { count: numberOfPendingItems })} (${progress}%)`
: t('filesImportStatus.imported', { count: numberOfImportedItems })
: hasErrors
? t('filesImportStatus.failed', { count: numberOfFailedItems })
: t('filesImportStatus.imported', { count: numberOfImportedItems })
}
</span>
<div className="flex items-center">
Expand All @@ -141,8 +183,19 @@ export const FileImportStatus = ({ filesFinished, filesPending, filesErrors, doF
</div>
<ul className='fileImportStatusRow pa0 ma0' aria-hidden={!expanded}>
{ filesPending.map(file => Import(file, t)) }
{ hasErrors && (
<>
{ writeErrors.map(file => Import(file, t)) }
{ importPathErrors.map(file => FailedImport(file, t)) }
</>
)}
{ sortedFilesFinished.map(file => Import(file, t)) }
{ filesErrors.map(file => Import(file, t)) }
{ !hasErrors && (
<>
{ writeErrors.map(file => Import(file, t)) }
{ importPathErrors.map(file => FailedImport(file, t)) }
</>
)}
</ul>
{
filesPending.length
Expand Down
23 changes: 9 additions & 14 deletions src/files/modals/add-by-path-modal/AddByPathModal.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import PropTypes from 'prop-types'
import Button from '../../../components/button/button.tsx'
import { Modal, ModalActions, ModalBody } from '../../../components/modal/modal'
import { withTranslation } from 'react-i18next'
import * as isIPFS from 'is-ipfs'
import { isValidIpfsPath, normalizeToPath } from '../../../lib/ipfs-path.js'
import Icon from '../../../icons/StrokeDecentralization.js'

class AddByPathModal extends React.Component {
Expand All @@ -22,14 +22,6 @@ class AddByPathModal extends React.Component {
name: ''
}

validatePath = (p) => {
if (!p.startsWith('/ipfs/')) {
p = `/ipfs/${p}`
}

return isIPFS.ipfsPath(p)
}

onChange = (event) => {
const target = event.target
const value = target.value
Expand All @@ -39,7 +31,9 @@ class AddByPathModal extends React.Component {

onSubmit = () => {
let { path, name } = this.state
if (this.validatePath(path)) {
if (isValidIpfsPath(path)) {
// normalize URLs to paths
path = normalizeToPath(path)
// avoid issues with paths by forcing a flat filename without leading/trailing spaces
name = name.replaceAll('/', '_').trim()
this.props.onSubmit(path, name)
Expand All @@ -57,7 +51,7 @@ class AddByPathModal extends React.Component {
return ''
}

if (this.validatePath(this.state.path)) {
if (isValidIpfsPath(this.state.path)) {
return 'b--green-muted focus-outline-green'
}

Expand All @@ -69,7 +63,7 @@ class AddByPathModal extends React.Component {
return true
}

return !this.validatePath(this.state.path)
return !isValidIpfsPath(this.state.path)
}

render () {
Expand All @@ -84,8 +78,9 @@ class AddByPathModal extends React.Component {
<ModalBody title={t('addByPathModal.title')} Icon={Icon}>
<div className='mb3 flex flex-column items-center'>
<p className='mt0 charcoal tl w-90'>{t('addByPathModal.description') + ' ' + t('addByPathModal.examples')}</p>
<code className={codeClass}>/ipfs/QmZTR5bcpQD7cFgTorqxZDYaew1Wqgfbd2ud9QqGPAkK2V</code>
<code className={codeClass}>QmPZ9gcCEpqKTo6aq61g2nXGUhM4iCL3ewB6LDXZCtioEB</code>
<code className={codeClass}>QmbWqxBEKC3P8tqsKc98xmWNzrzDtRLMiMPL8wBuTGsMnR</code>
<code className={codeClass}>/ipfs/bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi</code>
<code className={codeClass}>ipfs://bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi</code>
</div>

<input
Expand Down
41 changes: 41 additions & 0 deletions src/lib/ipfs-path.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import * as isIPFS from 'is-ipfs'

/**
* Normalize input to a canonical path format:
* - ipfs://CID/path -> /ipfs/CID/path
* - ipns://name/path -> /ipns/name/path
* - Bare CID -> /ipfs/CID
* - Existing paths pass through unchanged
* @param {string} input
* @returns {string}
*/
export const normalizeToPath = (input) => {
const trimmed = input.trim()
if (trimmed.startsWith('ipfs://')) {
return '/ipfs/' + trimmed.slice(7)
}
if (trimmed.startsWith('ipns://')) {
return '/ipns/' + trimmed.slice(7)
}
if (isIPFS.cid(trimmed)) {
return '/ipfs/' + trimmed
}
return trimmed
}

/**
* Check if input is a valid IPFS/IPNS path, CID, or protocol URL.
* @param {string} input
* @returns {boolean}
*/
export const isValidIpfsPath = (input) => {
const trimmed = input.trim()
if (trimmed === '') return false

if (trimmed.startsWith('ipfs://') || trimmed.startsWith('ipns://')) {
const asPath = normalizeToPath(trimmed)
return isIPFS.path(asPath)
}

return isIPFS.cid(trimmed) || isIPFS.path(trimmed)
}
Loading