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))} />
+ |
+ {t('files.columnActions')}
+ |
+
+
+ )}
{folders.map((folder) => (
| 📁 {folder.name} |
— |
-
+ | |
+
|
{formatBytes(file.size)} |
{formatTimestamp(file.lastModified)} |
+ |
))}
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 };
+}