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
58 changes: 58 additions & 0 deletions src/renderer/components/files/FileBrowser.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<FileBrowser {...baseProps} />);
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(<FileBrowser {...baseProps} />);
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(<FileBrowser {...baseProps} />);
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' } });
Expand Down
64 changes: 60 additions & 4 deletions src/renderer/components/files/FileBrowser.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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 (
<th
scope="col"
aria-sort={active ? (sort.direction === 'asc' ? 'ascending' : 'descending') : 'none'}
className={`px-3 py-1.5 font-medium ${align === 'right' ? 'text-right' : 'text-left'}`}
>
<button
type="button"
onClick={() => onSort(column)}
className={`inline-flex items-center gap-1 hover:text-slate-900 dark:hover:text-slate-100 ${
active ? 'text-slate-900 dark:text-slate-100' : ''
}`}
>
{label}
<Icon className={`h-3.5 w-3.5 ${active ? '' : 'invisible'}`} aria-hidden />
</button>
</th>
);
}

export function FileBrowser({
accountId,
Expand All @@ -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<SortState>(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);
Expand Down Expand Up @@ -78,6 +120,18 @@ export function FileBrowser({
<DropZone onDropFiles={(droppedFiles) => void uploads.upload(droppedFiles, prefix)}>
<div className="h-full overflow-auto">
<table className="w-full border-collapse text-left">
{(folders.length > 0 || files.length > 0) && (
<thead className="sticky top-0 z-10 bg-white text-xs text-slate-500 shadow-[inset_0_-1px_0_var(--color-slate-200)] dark:bg-slate-900 dark:text-slate-400 dark:shadow-[inset_0_-1px_0_var(--color-slate-700)]">
<tr>
<SortableHeader label={t('files.columnName')} column="name" sort={sort} onSort={(c) => setSort((s) => nextSort(s, c))} />
<SortableHeader label={t('files.columnSize')} column="size" sort={sort} onSort={(c) => setSort((s) => nextSort(s, c))} align="right" />
<SortableHeader label={t('files.columnLastModified')} column="lastModified" sort={sort} onSort={(c) => setSort((s) => nextSort(s, c))} />
<th scope="col" className="px-3 py-1.5">
<span className="sr-only">{t('files.columnActions')}</span>
</th>
</tr>
</thead>
)}
<tbody>
{folders.map((folder) => (
<tr
Expand All @@ -87,7 +141,8 @@ export function FileBrowser({
>
<td className="px-3 py-1.5">📁 <span>{folder.name}</span></td>
<td className="px-3 py-1.5 text-right text-slate-400 dark:text-slate-500">—</td>
<td className="px-3 py-1.5">
<td className="px-3 py-1.5" />
<td className="whitespace-nowrap px-3 py-1.5 text-right">
<button
type="button"
aria-label={t('files.renameFolderAria', { name: folder.name })}
Expand Down Expand Up @@ -135,6 +190,7 @@ export function FileBrowser({
<td className="px-3 py-1.5">📄 <span>{file.name}</span></td>
<td className="px-3 py-1.5 text-right tabular-nums">{formatBytes(file.size)}</td>
<td className="px-3 py-1.5 text-slate-500 dark:text-slate-400">{formatTimestamp(file.lastModified)}</td>
<td className="px-3 py-1.5" />
</tr>
))}
</tbody>
Expand Down
4 changes: 4 additions & 0 deletions src/renderer/i18n/locales/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 4 additions & 0 deletions src/renderer/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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}}",
Expand Down
4 changes: 4 additions & 0 deletions src/renderer/i18n/locales/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -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}}",
Expand Down
4 changes: 4 additions & 0 deletions src/renderer/i18n/locales/nl.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 4 additions & 0 deletions src/renderer/i18n/locales/pl.json
Original file line number Diff line number Diff line change
Expand Up @@ -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}}",
Expand Down
4 changes: 4 additions & 0 deletions src/renderer/i18n/locales/ro.json
Original file line number Diff line number Diff line change
Expand Up @@ -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}}",
Expand Down
70 changes: 70 additions & 0 deletions src/renderer/lib/sortEntries.test.ts
Original file line number Diff line number Diff line change
@@ -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' });
});
});
69 changes: 69 additions & 0 deletions src/renderer/lib/sortEntries.ts
Original file line number Diff line number Diff line change
@@ -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 };
}
Loading