From 6059aaab9755178d36f2642ee82f23dea78d691b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carsten=20H=C3=BClsmann?= Date: Wed, 14 Oct 2026 07:02:48 +0200 Subject: [PATCH] feat(ui): add sortable column headers to the file list Add a sticky header row (Name, Size, Last modified) to the file browser. Clicking a header sorts by that column; clicking it again flips the direction. Folders stay above files and keep name order, names sort naturally and case-insensitively, and undated files stay last. Folder actions move into their own column so they no longer sit under "Last modified". Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01RR6wV54o4jV23tysVcT8HK --- .../components/files/FileBrowser.test.tsx | 58 +++++++++++++++ src/renderer/components/files/FileBrowser.tsx | 64 +++++++++++++++-- src/renderer/i18n/locales/de.json | 4 ++ src/renderer/i18n/locales/en.json | 4 ++ src/renderer/i18n/locales/fr.json | 4 ++ src/renderer/i18n/locales/nl.json | 4 ++ src/renderer/i18n/locales/pl.json | 4 ++ src/renderer/i18n/locales/ro.json | 4 ++ src/renderer/lib/sortEntries.test.ts | 70 +++++++++++++++++++ src/renderer/lib/sortEntries.ts | 69 ++++++++++++++++++ 10 files changed, 281 insertions(+), 4 deletions(-) create mode 100644 src/renderer/lib/sortEntries.test.ts create mode 100644 src/renderer/lib/sortEntries.ts diff --git a/src/renderer/components/files/FileBrowser.test.tsx b/src/renderer/components/files/FileBrowser.test.tsx index da13ff8..301e1c3 100644 --- a/src/renderer/components/files/FileBrowser.test.tsx +++ b/src/renderer/components/files/FileBrowser.test.tsx @@ -69,6 +69,64 @@ describe('FileBrowser', () => { }); }); +describe('FileBrowser sorting', () => { + beforeEach(() => { + (window as unknown as { s3: unknown }).s3 = { + listObjects: vi.fn().mockResolvedValue({ + ok: true, + data: { + folders: [{ name: 'thumbs', prefix: 'images/thumbs/' }], + files: [ + { name: 'b.png', key: 'images/b.png', size: 100, lastModified: '2024-01-03T00:00:00.000Z', storageClass: null, etag: null }, + { name: 'a.png', key: 'images/a.png', size: 300, lastModified: '2024-01-01T00:00:00.000Z', storageClass: null, etag: null }, + { name: 'c.png', key: 'images/c.png', size: 200, lastModified: '2024-01-02T00:00:00.000Z', storageClass: null, etag: null }, + ], + nextToken: null, + }, + }), + getDropPath: vi.fn(), + uploadObject: vi.fn().mockResolvedValue({ ok: true, data: {} }), + onUploadProgress: vi.fn(() => () => {}), + }; + }); + + const rowNames = () => + screen + .getAllByRole('row') + .slice(1) // header row + .map((row) => row.querySelector('td span')?.textContent); + + it('sorts by name ascending by default, folders first', async () => { + wrap(); + await screen.findByText('a.png'); + expect(rowNames()).toEqual(['thumbs', 'a.png', 'b.png', 'c.png']); + expect(screen.getByRole('columnheader', { name: 'Name' })).toHaveAttribute('aria-sort', 'ascending'); + }); + + it('toggles name direction when the active header is clicked again', async () => { + wrap(); + await screen.findByText('a.png'); + await userEvent.click(screen.getByRole('button', { name: 'Name' })); + expect(rowNames()).toEqual(['thumbs', 'c.png', 'b.png', 'a.png']); + expect(screen.getByRole('columnheader', { name: 'Name' })).toHaveAttribute('aria-sort', 'descending'); + }); + + it('sorts files by size and by last modified', async () => { + wrap(); + await screen.findByText('a.png'); + + await userEvent.click(screen.getByRole('button', { name: 'Size' })); + expect(rowNames()).toEqual(['thumbs', 'b.png', 'c.png', 'a.png']); + expect(screen.getByRole('columnheader', { name: 'Size' })).toHaveAttribute('aria-sort', 'ascending'); + expect(screen.getByRole('columnheader', { name: 'Name' })).toHaveAttribute('aria-sort', 'none'); + + await userEvent.click(screen.getByRole('button', { name: 'Last modified' })); + await userEvent.click(screen.getByRole('button', { name: 'Last modified' })); + expect(rowNames()).toEqual(['thumbs', 'b.png', 'c.png', 'a.png']); + expect(screen.getByRole('columnheader', { name: 'Last modified' })).toHaveAttribute('aria-sort', 'descending'); + }); +}); + describe('FileBrowser operations', () => { it('uploads dropped files to the current prefix', async () => { const uploadObject = vi.fn().mockResolvedValue({ ok: true, data: { key: 'images/a.txt' } }); diff --git a/src/renderer/components/files/FileBrowser.tsx b/src/renderer/components/files/FileBrowser.tsx index 6e76efa..e25f1fd 100644 --- a/src/renderer/components/files/FileBrowser.tsx +++ b/src/renderer/components/files/FileBrowser.tsx @@ -1,6 +1,6 @@ -import { useState } from 'react'; +import { useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { FiEdit3, FiMove, FiTrash2 } from 'react-icons/fi'; +import { FiChevronDown, FiChevronUp, FiEdit3, FiMove, FiTrash2 } from 'react-icons/fi'; import { useObjects } from '../../hooks/useObjects'; import { formatBytes, formatTimestamp } from '../../lib/format'; import { Breadcrumb } from './Breadcrumb'; @@ -15,6 +15,42 @@ import { NameDialog } from '../transfer/NameDialog'; import { MoveDialog, type MoveItem } from '../transfer/MoveDialog'; import { useToast } from '../ui/ToastProvider'; import { UploadLinkDialog } from './UploadLinkDialog'; +import { DEFAULT_SORT, nextSort, sortEntries, type SortKey, type SortState } from '../../lib/sortEntries'; + +function SortableHeader({ + label, + column, + sort, + onSort, + align = 'left', +}: { + label: string; + column: SortKey; + sort: SortState; + onSort: (column: SortKey) => void; + align?: 'left' | 'right'; +}) { + const active = sort.key === column; + const Icon = sort.direction === 'asc' ? FiChevronUp : FiChevronDown; + return ( + + + + ); +} export function FileBrowser({ accountId, @@ -32,7 +68,13 @@ export function FileBrowser({ onSelectFile: (key: string) => void; }) { const { t } = useTranslation(); - const { query, folders, files } = useObjects(accountId, bucket, prefix); + const objects = useObjects(accountId, bucket, prefix); + const { query } = objects; + const [sort, setSort] = useState(DEFAULT_SORT); + const { folders, files } = useMemo( + () => sortEntries(objects.folders, objects.files, sort), + [objects.folders, objects.files, sort], + ); const uploads = useUploads(accountId, bucket); const actions = useObjectActions(accountId ?? '', bucket ?? ''); const [folderToDelete, setFolderToDelete] = useState<{ name: string; prefix: string } | null>(null); @@ -78,6 +120,18 @@ export function FileBrowser({ void uploads.upload(droppedFiles, prefix)}>
+ {(folders.length > 0 || files.length > 0) && ( + + + setSort((s) => nextSort(s, c))} /> + setSort((s) => nextSort(s, c))} align="right" /> + setSort((s) => nextSort(s, c))} /> + + + + )} {folders.map((folder) => ( - + ))} diff --git a/src/renderer/i18n/locales/de.json b/src/renderer/i18n/locales/de.json index 6cdd2eb..4558440 100644 --- a/src/renderer/i18n/locales/de.json +++ b/src/renderer/i18n/locales/de.json @@ -116,6 +116,10 @@ "newFolder": "Neuer Ordner", "folderEmpty": "Dieser Ordner ist leer", "loadMore": "Mehr laden", + "columnName": "Name", + "columnSize": "Größe", + "columnLastModified": "Zuletzt geändert", + "columnActions": "Aktionen", "renameFolderAria": "Ordner {{name}} umbenennen", "moveFolderAria": "Ordner {{name}} verschieben", "deleteFolderAria": "Ordner {{name}} löschen", diff --git a/src/renderer/i18n/locales/en.json b/src/renderer/i18n/locales/en.json index 096d878..ad20e02 100644 --- a/src/renderer/i18n/locales/en.json +++ b/src/renderer/i18n/locales/en.json @@ -116,6 +116,10 @@ "newFolder": "New folder", "folderEmpty": "This folder is empty", "loadMore": "Load more", + "columnName": "Name", + "columnSize": "Size", + "columnLastModified": "Last modified", + "columnActions": "Actions", "renameFolderAria": "Rename folder {{name}}", "moveFolderAria": "Move folder {{name}}", "deleteFolderAria": "Delete folder {{name}}", diff --git a/src/renderer/i18n/locales/fr.json b/src/renderer/i18n/locales/fr.json index 1e4f493..ec8a224 100644 --- a/src/renderer/i18n/locales/fr.json +++ b/src/renderer/i18n/locales/fr.json @@ -116,6 +116,10 @@ "newFolder": "Nouveau dossier", "folderEmpty": "Ce dossier est vide", "loadMore": "Charger plus", + "columnName": "Nom", + "columnSize": "Taille", + "columnLastModified": "Dernière modification", + "columnActions": "Actions", "renameFolderAria": "Renommer le dossier {{name}}", "moveFolderAria": "Déplacer le dossier {{name}}", "deleteFolderAria": "Supprimer le dossier {{name}}", diff --git a/src/renderer/i18n/locales/nl.json b/src/renderer/i18n/locales/nl.json index 8fac87e..06c2c60 100644 --- a/src/renderer/i18n/locales/nl.json +++ b/src/renderer/i18n/locales/nl.json @@ -116,6 +116,10 @@ "newFolder": "Nieuwe map", "folderEmpty": "Deze map is leeg", "loadMore": "Meer laden", + "columnName": "Naam", + "columnSize": "Grootte", + "columnLastModified": "Laatst gewijzigd", + "columnActions": "Acties", "renameFolderAria": "Map {{name}} hernoemen", "moveFolderAria": "Map {{name}} verplaatsen", "deleteFolderAria": "Map {{name}} verwijderen", diff --git a/src/renderer/i18n/locales/pl.json b/src/renderer/i18n/locales/pl.json index 3d973d0..09270c0 100644 --- a/src/renderer/i18n/locales/pl.json +++ b/src/renderer/i18n/locales/pl.json @@ -120,6 +120,10 @@ "newFolder": "Nowy folder", "folderEmpty": "Ten folder jest pusty", "loadMore": "Załaduj więcej", + "columnName": "Nazwa", + "columnSize": "Rozmiar", + "columnLastModified": "Ostatnia modyfikacja", + "columnActions": "Akcje", "renameFolderAria": "Zmień nazwę folderu {{name}}", "moveFolderAria": "Przenieś folder {{name}}", "deleteFolderAria": "Usuń folder {{name}}", diff --git a/src/renderer/i18n/locales/ro.json b/src/renderer/i18n/locales/ro.json index c70f835..51a74d1 100644 --- a/src/renderer/i18n/locales/ro.json +++ b/src/renderer/i18n/locales/ro.json @@ -118,6 +118,10 @@ "newFolder": "Folder nou", "folderEmpty": "Acest folder este gol", "loadMore": "Încarcă mai multe", + "columnName": "Nume", + "columnSize": "Dimensiune", + "columnLastModified": "Ultima modificare", + "columnActions": "Acțiuni", "renameFolderAria": "Redenumește folderul {{name}}", "moveFolderAria": "Mută folderul {{name}}", "deleteFolderAria": "Șterge folderul {{name}}", diff --git a/src/renderer/lib/sortEntries.test.ts b/src/renderer/lib/sortEntries.test.ts new file mode 100644 index 0000000..8d4ca03 --- /dev/null +++ b/src/renderer/lib/sortEntries.test.ts @@ -0,0 +1,70 @@ +import { describe, it, expect } from 'vitest'; +import { sortEntries, nextSort, DEFAULT_SORT } from './sortEntries'; +import type { FileEntry, FolderEntry } from '../../main/s3/listTransform'; + +function file(name: string, size: number, lastModified: string | null): FileEntry { + return { name, key: `p/${name}`, size, lastModified, storageClass: null, etag: null }; +} + +function folder(name: string): FolderEntry { + return { name, prefix: `p/${name}/` }; +} + +const files = [ + file('file10.txt', 300, '2024-01-03T00:00:00.000Z'), + file('File2.txt', 100, '2024-01-01T00:00:00.000Z'), + file('a.txt', 200, null), + file('b.txt', 100, '2024-01-02T00:00:00.000Z'), +]; +const folders = [folder('zeta'), folder('Alpha'), folder('beta')]; + +const names = (xs: { name: string }[]) => xs.map((x) => x.name); + +describe('sortEntries', () => { + it('sorts by name naturally and case-insensitively', () => { + const r = sortEntries(folders, files, { key: 'name', direction: 'asc' }); + expect(names(r.folders)).toEqual(['Alpha', 'beta', 'zeta']); + expect(names(r.files)).toEqual(['a.txt', 'b.txt', 'File2.txt', 'file10.txt']); + }); + + it('reverses folders and files when sorting by name descending', () => { + const r = sortEntries(folders, files, { key: 'name', direction: 'desc' }); + expect(names(r.folders)).toEqual(['zeta', 'beta', 'Alpha']); + expect(names(r.files)).toEqual(['file10.txt', 'File2.txt', 'b.txt', 'a.txt']); + }); + + it('sorts files by size with name as tie-breaker, folders stay by name', () => { + const asc = sortEntries(folders, files, { key: 'size', direction: 'asc' }); + expect(names(asc.files)).toEqual(['b.txt', 'File2.txt', 'a.txt', 'file10.txt']); + expect(names(asc.folders)).toEqual(['Alpha', 'beta', 'zeta']); + + const desc = sortEntries(folders, files, { key: 'size', direction: 'desc' }); + expect(names(desc.files)).toEqual(['file10.txt', 'a.txt', 'b.txt', 'File2.txt']); + expect(names(desc.folders)).toEqual(['Alpha', 'beta', 'zeta']); + }); + + it('sorts files by last modified, keeping undated entries last', () => { + const asc = sortEntries([], files, { key: 'lastModified', direction: 'asc' }); + expect(names(asc.files)).toEqual(['File2.txt', 'b.txt', 'file10.txt', 'a.txt']); + + const desc = sortEntries([], files, { key: 'lastModified', direction: 'desc' }); + expect(names(desc.files)).toEqual(['file10.txt', 'b.txt', 'File2.txt', 'a.txt']); + }); + + it('does not mutate its inputs', () => { + const input = [...files]; + sortEntries([], input, { key: 'size', direction: 'desc' }); + expect(input).toEqual(files); + }); +}); + +describe('nextSort', () => { + it('toggles direction on the active column', () => { + expect(nextSort(DEFAULT_SORT, 'name')).toEqual({ key: 'name', direction: 'desc' }); + expect(nextSort({ key: 'name', direction: 'desc' }, 'name')).toEqual({ key: 'name', direction: 'asc' }); + }); + + it('starts ascending on a new column', () => { + expect(nextSort({ key: 'name', direction: 'desc' }, 'size')).toEqual({ key: 'size', direction: 'asc' }); + }); +}); diff --git a/src/renderer/lib/sortEntries.ts b/src/renderer/lib/sortEntries.ts new file mode 100644 index 0000000..9d0ddb9 --- /dev/null +++ b/src/renderer/lib/sortEntries.ts @@ -0,0 +1,69 @@ +import type { FolderEntry, FileEntry } from '../../main/s3/listTransform'; + +export type SortKey = 'name' | 'size' | 'lastModified'; +export type SortDirection = 'asc' | 'desc'; + +export interface SortState { + key: SortKey; + direction: SortDirection; +} + +export const DEFAULT_SORT: SortState = { key: 'name', direction: 'asc' }; + +const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' }); + +/** Natural, case-insensitive name order ("file2" before "file10"). */ +function compareNames(a: string, b: string): number { + return collator.compare(a, b) || (a < b ? -1 : a > b ? 1 : 0); +} + +function timestamp(value: string | null): number | null { + if (value === null) return null; + const ms = Date.parse(value); + return Number.isNaN(ms) ? null : ms; +} + +function compareFiles(a: FileEntry, b: FileEntry, key: SortKey): number { + if (key === 'size') return a.size - b.size; + if (key === 'lastModified') { + const ta = timestamp(a.lastModified); + const tb = timestamp(b.lastModified); + if (ta === tb) return 0; + if (ta === null) return 1; + if (tb === null) return -1; + return ta - tb; + } + return compareNames(a.name, b.name); +} + +/** The next sort state after clicking a column header: the active column flips + * direction, a different column starts ascending. */ +export function nextSort(current: SortState, key: SortKey): SortState { + if (current.key === key) return { key, direction: current.direction === 'asc' ? 'desc' : 'asc' }; + return { key, direction: 'asc' }; +} + +/** Sort a listing for display. Folders stay above files; they have no size or + * date, so they are always ordered by name (following the direction only when + * sorting by name). Ties fall back to name order so the result is stable. */ +export function sortEntries( + folders: FolderEntry[], + files: FileEntry[], + sort: SortState, +): { folders: FolderEntry[]; files: FileEntry[] } { + const sign = sort.direction === 'asc' ? 1 : -1; + const folderSign = sort.key === 'name' ? sign : 1; + + const sortedFolders = [...folders].sort((a, b) => folderSign * compareNames(a.name, b.name)); + const sortedFiles = [...files].sort((a, b) => { + const primary = compareFiles(a, b, sort.key); + if (primary !== 0) { + // Entries without a date stay at the bottom regardless of direction. + if (sort.key === 'lastModified' && (a.lastModified === null || b.lastModified === null)) return primary; + return sign * primary; + } + return compareNames(a.name, b.name); + }); + + return { folders: sortedFolders, files: sortedFiles }; +}
+ {t('files.columnActions')} +
📁 {folder.name} — + + {formatBytes(file.size)} {formatTimestamp(file.lastModified)}