Skip to content
Merged
Show file tree
Hide file tree
Changes from 15 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
12 changes: 10 additions & 2 deletions public/locales/en/files.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@
"addByCar": "From CAR",
"bulkImport": "Bulk import",
"newFolder": "New folder",
"viewList": "Show items in list",
"viewGrid": "Show items in grid",
"switchToListView": "Click to switch to list view",
"switchToGridView": "Click to switch to grid view",
"generating": "Generating…",
"dropHere": "Drop here to move",
"actions": {
Expand Down Expand Up @@ -170,6 +170,14 @@
"files": "Files",
"cidNotFileNorDir": "The current link isn't a file, nor a directory. Try to <1>inspect</1> it instead.",
"sortBy": "Sort items by {name}",
"sortFiles": "Sort files",
"sortByNameAsc": "Name (A → Z)",
"sortByNameDesc": "Name (Z → A)",
"sortBySizeAsc": "Size (smallest first)",
"sortBySizeDesc": "Size (largest first)",
"sortByPinnedFirst": "Pinned first",
"sortByUnpinnedFirst": "Unpinned first",
"sortByOriginal": "Original DAG order",
"publishModal": {
"title": "Publish to IPNS",
"cidToPublish": "CID:",
Expand Down
36 changes: 31 additions & 5 deletions src/bundles/files/consts.js
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,11 @@ export const SORTING = {
/** @type {'name'} */
BY_NAME: ('name'),
/** @type {'size'} */
BY_SIZE: ('size')
BY_SIZE: ('size'),
/** @type {'pinned'} */
BY_PINNED: ('pinned'),
/** @type {'original'} */
BY_ORIGINAL: ('original')
}

export const IGNORED_FILES = [
Expand All @@ -68,10 +72,32 @@ export const DEFAULT_STATE = {
pageContent: null,
mfsSize: -1,
pins: [],
sorting: { // TODO: cache this
by: SORTING.BY_NAME,
asc: true
},
sorting: (() => {
// Try to read from localStorage, fallback to default
try {
const saved = window.localStorage?.getItem('files.sorting')
if (saved) {
const parsed = JSON.parse(saved)
// Validate the structure and values
const validSortBy = Object.values(SORTING).includes(parsed?.by)
const validAsc = typeof parsed?.asc === 'boolean'

if (parsed && validSortBy && validAsc) {
return parsed
}
}
} catch (error) {
console.warn('Failed to read files.sorting from localStorage:', error)
// Clear corrupted data
try {
window.localStorage?.removeItem('files.sorting')
} catch {}
}
return {
by: SORTING.BY_NAME,
asc: true
}
})(),
pending: [],
finished: [],
failed: []
Expand Down
79 changes: 68 additions & 11 deletions src/bundles/files/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,39 @@ export const sorts = SORTING
* @typedef {import('./protocol').Message} Message
* @typedef {import('../task').SpawnState<any, Error, any, any>} JobState
*/

/**
* Get sorted content from pageContent
* @param {import('./protocol').DirectoryContent} pageContent
* @param {import('./protocol').Sorting} sorting
* @param {string[]} pins
* @returns {any[]}
*/
const getSortedContent = (pageContent, sorting, pins) => {
// Always sort from originalContent (preserved from ipfs.ls) or fallback to content
const sourceContent = pageContent.originalContent || pageContent.content
return sortFiles(sourceContent, sorting, pins)
}

/**
* Helper function to re-sort files when needed
* @param {Model} state
* @returns {Model}
*/
const resortContent = (state) => {
if (state.pageContent && state.pageContent.type === 'directory') {
const content = getSortedContent(state.pageContent, state.sorting, state.pins)
return {
...state,
pageContent: {
...state.pageContent,
content
}
}
}
return state
}

const createFilesBundle = () => {
return {
name: 'files',
Expand All @@ -30,9 +63,13 @@ const createFilesBundle = () => {
case ACTIONS.MOVE:
case ACTIONS.COPY:
case ACTIONS.MAKE_DIR:
case ACTIONS.PIN_ADD:
case ACTIONS.PIN_REMOVE:
return updateJob(state, action.task, action.type)
case ACTIONS.PIN_ADD:
case ACTIONS.PIN_REMOVE: {
const updatedState = updateJob(state, action.task, action.type)
// Re-sort if sorting by pinned status
return state.sorting.by === SORTING.BY_PINNED ? resortContent(updatedState) : updatedState
}
case ACTIONS.WRITE: {
return updateJob(state, action.task, action.type)
}
Expand All @@ -43,21 +80,30 @@ const createFilesBundle = () => {
? task.result.value.pins.map(String)
: state.pins

return {
const updatedState = {
...updateJob(state, task, type),
pins
}

// Re-sort if sorting by pinned status
return state.sorting.by === SORTING.BY_PINNED ? resortContent(updatedState) : updatedState
}
case ACTIONS.FETCH: {
const { task, type } = action
const result = task.status === 'Exit' && task.result.ok
? task.result.value
: null
const { pageContent } = result
? {
pageContent: result
}
: state
let pageContent = result || state.pageContent
// Apply current sorting to the fetched content
if (pageContent && pageContent.type === 'directory' && pageContent.content) {
const originalContent = pageContent.originalContent || pageContent.content // Preserve original
const sortedContent = getSortedContent({ ...pageContent, originalContent }, state.sorting, state.pins)
pageContent = {
...pageContent,
originalContent, // Store original unsorted order
content: sortedContent
}
}

return {
...updateJob(state, task, type),
Expand All @@ -79,9 +125,17 @@ const createFilesBundle = () => {
}
}
case ACTIONS.UPDATE_SORT: {
const { pageContent } = state
const { pageContent, pins } = state

// Persist sorting preference to localStorage
try {
window.localStorage?.setItem('files.sorting', JSON.stringify(action.payload))
} catch (error) {
console.error('Failed to save files.sorting to localStorage:', error)
}

if (pageContent && pageContent.type === 'directory') {
const content = sortFiles(pageContent.content, action.payload)
const content = getSortedContent(pageContent, action.payload, pins)
return {
...state,
pageContent: {
Expand All @@ -91,7 +145,10 @@ const createFilesBundle = () => {
sorting: action.payload
}
} else {
return state
return {
...state,
sorting: action.payload
}
}
}
case ACTIONS.SIZE_GET: {
Expand Down
3 changes: 2 additions & 1 deletion src/bundles/files/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ export type DirectoryContent = {
cid: CID,

content: FileStat[]
originalContent?: FileStat[] // Original unsorted content from ipfs.ls
upper: FileStat | null,
}

Expand All @@ -58,7 +59,7 @@ export type PageContent =
| FileContent
| DirectoryContent

export type SortBy = 'name' | 'size'
export type SortBy = 'name' | 'size' | 'pinned' | 'original'

export type Sorting = {
by: SortBy,
Expand Down
51 changes: 47 additions & 4 deletions src/bundles/files/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -159,18 +159,61 @@ export const send = (action) => async ({ store }) => {
*/

/**
* @template {{name:string, type:string, cumulativeSize?:number, size:number}} T
* @template {{name:string, type:string, cumulativeSize?:number, size:number, cid:import('multiformats/cid').CID}} T
* @param {T[]} files
* @param {Sorting} sorting

* @param {string[]} pins - Array of pinned CIDs as strings
* @returns {T[]}
*/
export const sortFiles = (files, sorting) => {
export const sortFiles = (files, sorting, pins = []) => {
// Early return for edge cases
if (!files || files.length <= 1) {
return files || []
}

// Return original order without sorting
if (sorting.by === SORTING.BY_ORIGINAL) {
return files
}

const sortDir = sorting.asc ? 1 : -1
const nameSort = sortByName(sortDir)
const sizeSort = sortBySize(sortDir)

return files.sort((a, b) => {
// Convert pins to Set for O(1) lookup performance
const pinSet = pins.length > 0 ? new Set(pins) : null

// Create a copy to avoid mutating the original array
return [...files].sort((a, b) => {
// Handle pinned-first sorting
if (sorting.by === SORTING.BY_PINNED && pinSet) {
const aPinned = pinSet.has(a.cid.toString())
const bPinned = pinSet.has(b.cid.toString())

// If pinned status is different, apply sort direction
if (aPinned !== bPinned) {
return aPinned ? -sortDir : sortDir
}

// If both pinned or both not pinned, sort alphabetically within each group
// For pinned items, ignore folder/file distinction and sort alphabetically
if (aPinned && bPinned) {
return nameSort(a.name, b.name)
}

// For non-pinned items, maintain current behavior (folders first, then files)
if (a.type === b.type || IS_MAC) {
return nameSort(a.name, b.name)
}

if (a.type === 'directory') {
return -1
} else {
return 1
}
}

// Original sorting logic for name and size
if (a.type === b.type || IS_MAC) {
if (sorting.by === SORTING.BY_NAME) {
return nameSort(a.name, b.name)
Expand Down
42 changes: 26 additions & 16 deletions src/files/FilesPage.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { withTranslation, Trans } from 'react-i18next'
import ReactJoyride from 'react-joyride'
// Lib
import { filesTour } from '../lib/tours.js'
import { readSetting, writeSetting } from '../bundles/local-storage.js'
// Components
import ContextMenu from './context-menu/ContextMenu.js'
import withTour from '../components/tour/withTour.js'
Expand All @@ -16,6 +17,7 @@ import FilesGrid from './files-grid/files-grid.js'
import { ViewList, ViewModule } from '../icons/stroke-icons.js'
import FileNotFound from './file-not-found/index.tsx'
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'
Expand All @@ -30,7 +32,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, doFilesCidProvide, doFilesDelete, doSetPinning, onRemotePinClick, doPublishIpnsKey,
files, filesPathInfo, pinningServices, toursEnabled, handleJoyrideCallback, isCliTutorModeEnabled, cliOptions, t
files, filesPathInfo, pinningServices, toursEnabled, handleJoyrideCallback, isCliTutorModeEnabled, cliOptions, filesSorting, t
}) => {
const { doExploreUserProvidedPath } = useExplore()
const contextMenuRef = useRef()
Expand All @@ -41,9 +43,14 @@ const FilesPage = ({
translateY: 0,
file: null
})
const [viewMode, setViewMode] = useState('list')
const [viewMode, setViewMode] = useState(() => readSetting('files.viewMode') || 'list')
const [selected, setSelected] = useState([])

const toggleViewMode = () => {
const newMode = viewMode === 'list' ? 'grid' : 'list'
setViewMode(newMode)
}

useEffect(() => {
doFetchPinningServices()
doFilesFetch()
Expand Down Expand Up @@ -73,6 +80,11 @@ const FilesPage = ({
return () => document.removeEventListener('keydown', handleKeyDown)
}, [])

// Persist view mode changes to localStorage
useEffect(() => {
writeSetting('files.viewMode', viewMode)
}, [viewMode])

/* TODO: uncomment below if we ever want automatic remote pin check
* (it was disabled for now due to https://github.com/ipfs/ipfs-desktop/issues/1954)
useEffect(() => {
Expand Down Expand Up @@ -326,25 +338,23 @@ const FilesPage = ({
handleContextMenu={(...args) => handleContextMenu(...args, true)}
>
<div className="flex items-center justify-end">
<div className="mr3">
<SortDropdown
currentSort={filesSorting || { by: 'name', asc: true }}
onSortChange={doFilesUpdateSorting}
t={t}
/>
</div>
<button
className={`pointer filelist-view ${viewMode === 'list' ? 'selected-item' : 'gray'}`}
onClick={() => setViewMode('list')}
title={t('viewList')}
style={{
height: '24px'
}}
>
<ViewList width="24" height="24" />
</button>
<button
className={`pointer filegrid-view ${viewMode === 'grid' ? 'selected-item' : 'gray'}`}
onClick={() => setViewMode('grid')}
title={t('viewGrid')}
className="pointer selected-item"
onClick={toggleViewMode}
title={viewMode === 'list' ? t('switchToGridView') : t('switchToListView')}
aria-label={viewMode === 'list' ? t('switchToGridView') : t('switchToListView')}
style={{
height: '24px'
}}
>
<ViewModule width="24" height="24" />
{viewMode === 'list' ? <ViewList width="24" height="24" /> : <ViewModule width="24" height="24" />}
</button>
</div>
</Header>
Expand Down
Loading
Loading