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
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
36 changes: 25 additions & 11 deletions src/diagnostics/logs-screen/golog-level-autocomplete.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React from 'react'
import { render, screen, waitFor, cleanup } from '@testing-library/react'
import { render, screen, waitFor, cleanup, act } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { jest, describe, it, expect, beforeEach, afterEach } from '@jest/globals'
import '@testing-library/jest-dom'
Expand Down Expand Up @@ -556,7 +556,7 @@ describe('GologLevelAutocomplete', () => {
const mockOnValidityChange = jest.fn()
const mockOnErrorChange = jest.fn()

render(
const { unmount } = render(
<GologLevelAutocomplete
{...defaultProps}
value="info"
Expand All @@ -567,28 +567,35 @@ describe('GologLevelAutocomplete', () => {
)

const input = screen.getByRole('textbox')
await userEvent.type(input, '{enter}')

// Wait for the async submission to complete and all state updates to finish
// Use act to ensure all state updates are handled properly
await act(async () => {
await userEvent.type(input, '{enter}')
})

// Wait for all async operations and state updates to complete
await waitFor(() => {
expect(mockOnSubmit).toHaveBeenCalled()
})

// Wait a bit for async state updates to complete
// Ensure all state updates have been processed
await waitFor(() => {
// The component should remain interactive after error
expect(input).not.toBeDisabled()
})
}, { timeout: 1000 })

// Verify that the component remains interactive after error
expect(input).toHaveValue('info')

// Clean up properly
unmount()
})

it('should handle async form submission with unknown error', async () => {
const mockOnSubmit = jest.fn<(event?: React.FormEvent) => Promise<void>>().mockImplementation(() => Promise.reject(new Error('Unknown error')))
const mockOnErrorChange = jest.fn()

render(
const { unmount } = render(
<GologLevelAutocomplete
{...defaultProps}
value="info"
Expand All @@ -598,21 +605,28 @@ describe('GologLevelAutocomplete', () => {
)

const input = screen.getByRole('textbox')
await userEvent.type(input, '{enter}')

// Wait for the async submission to complete and all state updates to finish
// Use act to ensure all state updates are handled properly
await act(async () => {
await userEvent.type(input, '{enter}')
})

// Wait for all async operations and state updates to complete
await waitFor(() => {
expect(mockOnSubmit).toHaveBeenCalled()
})

// Wait a bit for async state updates to complete
// Ensure all state updates have been processed
await waitFor(() => {
// The component should remain interactive after error
expect(input).not.toBeDisabled()
})
}, { timeout: 1000 })

// Verify that the component remains interactive after error
expect(input).toHaveValue('info')

// Clean up properly
unmount()
})

it('should handle cursor positioning after suggestion selection', async () => {
Expand Down
Loading