(DEFAULT_SORT)
// The row the keyboard is on. Distinct from selection: you can walk the list
// without changing what is selected, the way every file manager behaves.
const [cursor, setCursor] = useState(0)
@@ -266,17 +331,29 @@ export function Pane({
}, [state.path])
const visible = useMemo(
- () =>
- state.entries
- .filter((entry) => showHidden || !entry.name.startsWith('.'))
- .filter((entry) => filter === '' || entry.name.toLowerCase().includes(filter.toLowerCase()))
- .sort((a, b) => {
- // Directories first, then by name: the order every file manager uses.
- // A link to a directory sorts as one, because that is what it opens as.
- if (isNavigable(a) !== isNavigable(b)) return isNavigable(a) ? -1 : 1
- return a.name.localeCompare(b.name)
- }),
- [state.entries, filter, showHidden],
+ () => visibleEntries(state.entries, { filter, showHidden, sort }),
+ [state.entries, filter, showHidden, sort],
+ )
+
+ /**
+ * Re-orders the rows and keeps the keyboard cursor on the row it was on.
+ *
+ * The cursor is an index into `visible`, so re-sorting without this leaves it
+ * pointing at whatever row slid into that position — arrow-down after a
+ * header click would jump somewhere unrelated. Selection needs no such care:
+ * it is held by name.
+ */
+ const resort = useCallback(
+ (key: SortKey) => {
+ const next = nextSort(sort, key)
+ const focused = visible[cursor]?.name
+ const reordered = visibleEntries(state.entries, { filter, showHidden, sort: next })
+ const index = focused === undefined ? -1 : reordered.findIndex((entry) => entry.name === focused)
+ setSort(next)
+ setCursor(index === -1 ? 0 : index)
+ anchor.current = null
+ },
+ [cursor, filter, showHidden, sort, state.entries, visible],
)
const selectedSize = visible
@@ -491,14 +568,21 @@ export function Pane({
- Name
- Size
- Modified
+
+
+
+
+
+
+
+
+
diff --git a/apps/desktop/src/lib/entries.test.ts b/apps/desktop/src/lib/entries.test.ts
index 50086e5..fa1b1bd 100644
--- a/apps/desktop/src/lib/entries.test.ts
+++ b/apps/desktop/src/lib/entries.test.ts
@@ -1,5 +1,29 @@
import { describe, expect, it } from 'vitest'
-import { isNavigable } from './entries.js'
+import type { FileEntry } from '@/lib/api'
+import { DEFAULT_SORT, isNavigable, nextSort, visibleEntries, type Sort } from './entries.js'
+
+function entry(name: string, overrides: Partial = {}): FileEntry {
+ return {
+ name,
+ path: `/x/${name}`,
+ type: 'file',
+ size: 0,
+ modifiedAt: '2026-01-01T00:00:00.000Z',
+ mode: 0o644,
+ ...overrides,
+ }
+}
+
+const directory = (name: string, overrides: Partial = {}) =>
+ entry(name, { type: 'directory', size: 4096, ...overrides })
+
+function order(entries: FileEntry[], sort: Sort, options: { filter?: string; showHidden?: boolean } = {}) {
+ return visibleEntries(entries, {
+ filter: options.filter ?? '',
+ showHidden: options.showHidden ?? true,
+ sort,
+ }).map((item) => item.name)
+}
describe('isNavigable', () => {
/**
@@ -30,3 +54,87 @@ describe('isNavigable', () => {
expect(isNavigable({ type: 'file', targetType: 'directory' })).toBe(false)
})
})
+
+describe('nextSort', () => {
+ it('opens size and modified on the answer you clicked for', () => {
+ // Nobody clicks Size to find the smallest file.
+ expect(nextSort(DEFAULT_SORT, 'size')).toEqual({ key: 'size', direction: 'desc' })
+ expect(nextSort(DEFAULT_SORT, 'modified')).toEqual({ key: 'modified', direction: 'desc' })
+ })
+
+ it('opens name forwards', () => {
+ expect(nextSort({ key: 'size', direction: 'desc' }, 'name')).toEqual({ key: 'name', direction: 'asc' })
+ })
+
+ it('flips the column already being sorted on', () => {
+ expect(nextSort({ key: 'name', direction: 'asc' }, 'name')).toEqual({ key: 'name', direction: 'desc' })
+ expect(nextSort({ key: 'name', direction: 'desc' }, 'name')).toEqual({ key: 'name', direction: 'asc' })
+ })
+})
+
+describe('visibleEntries', () => {
+ it('sorts names the way a person reads them, not by code point', () => {
+ // Plain localeCompare puts file10 before file2, and a capitalised name
+ // into a block of its own above every lowercase one.
+ const names = order([entry('file10'), entry('file2'), entry('Photos.txt'), entry('apps.txt')], DEFAULT_SORT)
+ expect(names).toEqual(['apps.txt', 'file2', 'file10', 'Photos.txt'])
+ })
+
+ it('keeps directories above files in both directions', () => {
+ const listing = [entry('a.txt'), directory('zoo'), entry('z.txt'), directory('apps')]
+ expect(order(listing, { key: 'name', direction: 'asc' })).toEqual(['apps', 'zoo', 'a.txt', 'z.txt'])
+ expect(order(listing, { key: 'name', direction: 'desc' })).toEqual(['zoo', 'apps', 'z.txt', 'a.txt'])
+ })
+
+ it('groups a link to a directory with the directories', () => {
+ const listing = [entry('a.txt'), entry('data', { type: 'symlink', targetType: 'directory' })]
+ expect(order(listing, DEFAULT_SORT)).toEqual(['data', 'a.txt'])
+ })
+
+ it('sorts by size, largest first, without reordering the folders', () => {
+ // Directories report their own inode size, which the pane draws as an em
+ // dash: ordering identical-looking rows by an invisible number reads as a bug.
+ const listing = [
+ directory('zoo', { size: 4096 }),
+ directory('apps', { size: 40960 }),
+ entry('small.txt', { size: 10 }),
+ entry('big.bin', { size: 9_000_000 }),
+ ]
+ expect(order(listing, { key: 'size', direction: 'desc' })).toEqual(['apps', 'zoo', 'big.bin', 'small.txt'])
+ expect(order(listing, { key: 'size', direction: 'asc' })).toEqual(['apps', 'zoo', 'small.txt', 'big.bin'])
+ })
+
+ it('sorts by modified time, newest first', () => {
+ const listing = [
+ entry('old.txt', { modifiedAt: '2020-06-01T00:00:00.000Z' }),
+ entry('new.txt', { modifiedAt: '2026-09-01T00:00:00.000Z' }),
+ entry('middle.txt', { modifiedAt: '2024-01-01T00:00:00.000Z' }),
+ ]
+ expect(order(listing, { key: 'modified', direction: 'desc' })).toEqual(['new.txt', 'middle.txt', 'old.txt'])
+ })
+
+ it('sorts an unreadable mtime as the epoch instead of poisoning the comparison', () => {
+ // A NaN comparison returns NaN for every pair, which leaves the whole
+ // listing in arrival order and looks like sorting silently stopped working.
+ const listing = [entry('b.txt', { modifiedAt: '' }), entry('a.txt', { modifiedAt: '2026-01-01T00:00:00.000Z' })]
+ expect(order(listing, { key: 'modified', direction: 'desc' })).toEqual(['a.txt', 'b.txt'])
+ })
+
+ it('breaks ties by name, so both panes draw an equal pair the same way', () => {
+ const listing = [entry('b.txt', { size: 10 }), entry('a.txt', { size: 10 })]
+ expect(order(listing, { key: 'size', direction: 'desc' })).toEqual(['a.txt', 'b.txt'])
+ expect(order(listing, { key: 'modified', direction: 'desc' })).toEqual(['a.txt', 'b.txt'])
+ })
+
+ it('still hides dotfiles and honours the filter', () => {
+ const listing = [entry('.hidden'), entry('notes.txt'), entry('other.md')]
+ expect(order(listing, DEFAULT_SORT, { showHidden: false })).toEqual(['notes.txt', 'other.md'])
+ expect(order(listing, DEFAULT_SORT, { showHidden: true, filter: 'HID' })).toEqual(['.hidden'])
+ })
+
+ it('leaves the array it was given alone', () => {
+ const listing = [entry('b.txt'), entry('a.txt')]
+ order(listing, DEFAULT_SORT)
+ expect(listing.map((item) => item.name)).toEqual(['b.txt', 'a.txt'])
+ })
+})
diff --git a/apps/desktop/src/lib/entries.ts b/apps/desktop/src/lib/entries.ts
index 106ae0a..b9729b9 100644
--- a/apps/desktop/src/lib/entries.ts
+++ b/apps/desktop/src/lib/entries.ts
@@ -12,3 +12,93 @@ import type { FileEntry } from '@/lib/api'
export function isNavigable(entry: Pick): boolean {
return entry.type === 'directory' || (entry.type === 'symlink' && entry.targetType === 'directory')
}
+
+export type SortKey = 'name' | 'size' | 'modified'
+export type SortDirection = 'asc' | 'desc'
+export type Sort = { key: SortKey; direction: SortDirection }
+
+/** What a pane sorts by until someone clicks a header: the file-manager default. */
+export const DEFAULT_SORT: Sort = { key: 'name', direction: 'asc' }
+
+/**
+ * The direction a column starts in the first time it is clicked.
+ *
+ * Name reads forwards, but nobody clicks Size to find the smallest file or
+ * Modified to find the oldest, so those open on the answer you came for.
+ */
+export function initialDirection(key: SortKey): SortDirection {
+ return key === 'name' ? 'asc' : 'desc'
+}
+
+/** Clicking the active column flips it; clicking another one switches to it. */
+export function nextSort(current: Sort, key: SortKey): Sort {
+ if (current.key !== key) return { key, direction: initialDirection(key) }
+ return { key, direction: current.direction === 'asc' ? 'desc' : 'asc' }
+}
+
+/**
+ * Names in the order a person reads them: `file10` after `file2`, and case
+ * ignored, so `Photos` does not sort into a block of its own above `apps`.
+ *
+ * The exact-string fallback is not decoration. A collator told to ignore case
+ * calls `README` and `readme` equal, and two entries that compare equal are
+ * left in whatever order the listing arrived in — stable within one sort, but
+ * different between the local lister and SFTP, so the same folder would draw
+ * one way on the left and another on the right.
+ */
+function byName(a: FileEntry, b: FileEntry): number {
+ const collated = a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' })
+ if (collated !== 0) return collated
+ return a.name < b.name ? -1 : a.name > b.name ? 1 : 0
+}
+
+/** A missing or unparseable mtime sorts as the epoch, rather than poisoning the comparator with NaN. */
+function modifiedAt(entry: FileEntry): number {
+ const parsed = Date.parse(entry.modifiedAt)
+ return Number.isNaN(parsed) ? 0 : parsed
+}
+
+/**
+ * The row order for a pane.
+ *
+ * Directories stay above files in every direction, the way every file manager
+ * behaves: reversing Size should not shuffle folders into the middle of the
+ * list. A link to a directory is grouped as one, because that is what it opens
+ * as.
+ *
+ * Sorting by size leaves that folder block on name. Directories report their
+ * own inode size, not the size of their contents, and the pane already draws
+ * that as an em dash — ordering visibly identical rows by a number nobody can
+ * see reads as a bug.
+ */
+export function compareEntries(sort: Sort): (a: FileEntry, b: FileEntry) => number {
+ const sign = sort.direction === 'asc' ? 1 : -1
+ return (a, b) => {
+ if (isNavigable(a) !== isNavigable(b)) return isNavigable(a) ? -1 : 1
+ if (sort.key === 'size') {
+ if (isNavigable(a)) return byName(a, b)
+ return a.size === b.size ? byName(a, b) : sign * (a.size - b.size)
+ }
+ if (sort.key === 'modified') {
+ const difference = modifiedAt(a) - modifiedAt(b)
+ return difference === 0 ? byName(a, b) : sign * difference
+ }
+ return sign * byName(a, b)
+ }
+}
+
+/**
+ * What a pane actually draws: hidden files, the filter box and the sort in one
+ * place, so the header click that keeps the keyboard cursor on its row can ask
+ * for the next order instead of predicting it.
+ */
+export function visibleEntries(
+ entries: readonly FileEntry[],
+ options: { filter: string; showHidden: boolean; sort: Sort },
+): FileEntry[] {
+ const needle = options.filter.toLowerCase()
+ return entries
+ .filter((entry) => options.showHidden || !entry.name.startsWith('.'))
+ .filter((entry) => needle === '' || entry.name.toLowerCase().includes(needle))
+ .sort(compareEntries(options.sort))
+}
diff --git a/docs/desktop.md b/docs/desktop.md
index e540847..291b62c 100644
--- a/docs/desktop.md
+++ b/docs/desktop.md
@@ -24,6 +24,21 @@
Either pane can be the local machine or any saved server, which is what makes
`Server A → Server B` an ordinary case rather than a special mode.
+## Sorting
+
+Click **Name**, **Size** or **Modified** to sort a listing; click the same
+column again to reverse it. Size and Modified open on their largest and newest,
+because that is what you clicked them to find.
+
+Each pane sorts on its own, a server pane exactly like a local one. The rows
+are already in the app, so this is instant and re-reads nothing: no second
+listing, no SSH round trip, no rsync.
+
+Two rules hold in every direction. Directories stay above files, so reversing
+Size does not scatter folders through the list. And sorting by size leaves the
+folders on name — a directory reports its own inode size rather than the size
+of its contents, which is why the column shows an em dash for them.
+
## Defaults
Dragging between panes uses the safe preset. No dialog appears first. The