Skip to content

Commit d1312c0

Browse files
ralyodioclaude
andauthored
desktop: sortable file list headers, on both panes (#34)
Click Name, Size or Modified to sort a listing; click the same column again to reverse it. Size and Modified open descending, because nobody clicks Size to find the smallest file. Both panes get it from the one component, and each keeps its own sort: a server pane sorts exactly like a local one. It is a client-side reorder of rows the app already holds, so it costs no second listing, no SSH round trip and no rsync. Directories stay above files in every direction, so reversing Size does not scatter folders through the list, and sorting by size leaves the folder block on name -- a directory reports its own inode size, not the size of its contents, which is why that column already draws an em dash for them. Two details that would otherwise bite: - Names now collate naturally and case-insensitively, so file10 sorts after file2 and Photos does not form a block above every lowercase name. Every comparison falls back to the exact string, because a collator that ignores case calls README and readme equal, and equal entries keep arrival order -- which differs between the local lister and SFTP, so the same folder would draw one way on the left and another on the right. - The keyboard cursor is an index into the visible rows, so re-sorting without moving it leaves it pointing at whatever row slid into that position. It now follows the row it was on. Verified in headless Chromium under the window's real CSP with a mocked bridge: all three columns in both directions, both themes, and at the 960x600 minimum window. Claude-Session: https://claude.ai/code/session_012Q2bt449mEJSHoEZzaemCn Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 5240efe commit d1312c0

4 files changed

Lines changed: 314 additions & 17 deletions

File tree

‎apps/desktop/src/components/pane.tsx‎

Lines changed: 100 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@
22

33
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
44
import {
5+
ChevronDown,
56
ChevronRight,
7+
ChevronUp,
68
CornerLeftUp,
79
ExternalLink,
810
FilePlus2,
@@ -19,7 +21,14 @@ import {
1921
Trash2,
2022
} from 'lucide-react'
2123
import { api, unwrap, type Connection, type FileEntry } from '@/lib/api'
22-
import { isNavigable } from '@/lib/entries'
24+
import {
25+
DEFAULT_SORT,
26+
isNavigable,
27+
nextSort,
28+
visibleEntries,
29+
type Sort,
30+
type SortKey,
31+
} from '@/lib/entries'
2332
import { formatBytes, formatDate, formatMode, joinPath, parentPath } from '@/lib/format'
2433
import { EndpointSelect, type PaneEndpoint } from '@/components/endpoint-select'
2534
import { DeleteDialog, NameDialog } from '@/components/entry-dialogs'
@@ -93,6 +102,57 @@ function IconAction({
93102
)
94103
}
95104

105+
/** What a screen reader is told about a column: only one of the three is ever sorted. */
106+
function ariaSort(sort: Sort, column: SortKey): 'ascending' | 'descending' | 'none' {
107+
if (sort.key !== column) return 'none'
108+
return sort.direction === 'asc' ? 'ascending' : 'descending'
109+
}
110+
111+
/**
112+
* One column header: a button, not a label.
113+
*
114+
* The arrow is only drawn for the column being sorted on, and faintly on hover
115+
* for the others — a header that looks identical whether or not it does
116+
* anything is a control nobody finds.
117+
*/
118+
function SortHeader({
119+
label,
120+
column,
121+
sort,
122+
onSort,
123+
align = 'left',
124+
}: {
125+
label: string
126+
column: SortKey
127+
sort: Sort
128+
onSort: (key: SortKey) => void
129+
align?: 'left' | 'right'
130+
}) {
131+
const activeColumn = sort.key === column
132+
const Arrow = activeColumn && sort.direction === 'desc' ? ChevronDown : ChevronUp
133+
return (
134+
<button
135+
type="button"
136+
onClick={() => onSort(column)}
137+
aria-label={`Sort by ${label.toLowerCase()}`}
138+
className={cn(
139+
'focus-ring group -mx-1 flex w-[calc(100%+0.5rem)] min-w-0 items-center gap-1 rounded px-1 uppercase tracking-[0.08em] transition-colors hover:text-dim',
140+
align === 'right' && 'justify-end',
141+
activeColumn && 'text-dim',
142+
)}
143+
>
144+
{align === 'right' ? null : <span className="truncate">{label}</span>}
145+
<Arrow
146+
className={cn(
147+
'size-3 shrink-0 transition-opacity',
148+
activeColumn ? 'opacity-100' : 'opacity-0 group-hover:opacity-40',
149+
)}
150+
/>
151+
{align === 'right' ? <span className="truncate">{label}</span> : null}
152+
</button>
153+
)
154+
}
155+
96156
/** Breadcrumbs without a library: the path is the only source of truth. */
97157
function Breadcrumbs({ path, onNavigate }: { path: string; onNavigate: (path: string) => void }) {
98158
const parts = useMemo(() => {
@@ -227,6 +287,11 @@ export function Pane({
227287
}) {
228288
const [filter, setFilter] = useState('')
229289
const [showHidden, setShowHidden] = useState(false)
290+
// Per pane, and deliberately not reset when the path changes: a sort you
291+
// picked is a way of looking at files, not a property of one folder. The two
292+
// panes keep their own, because the point of them is comparing a listing
293+
// against a differently-ordered one.
294+
const [sort, setSort] = useState<Sort>(DEFAULT_SORT)
230295
// The row the keyboard is on. Distinct from selection: you can walk the list
231296
// without changing what is selected, the way every file manager behaves.
232297
const [cursor, setCursor] = useState(0)
@@ -266,17 +331,29 @@ export function Pane({
266331
}, [state.path])
267332

268333
const visible = useMemo(
269-
() =>
270-
state.entries
271-
.filter((entry) => showHidden || !entry.name.startsWith('.'))
272-
.filter((entry) => filter === '' || entry.name.toLowerCase().includes(filter.toLowerCase()))
273-
.sort((a, b) => {
274-
// Directories first, then by name: the order every file manager uses.
275-
// A link to a directory sorts as one, because that is what it opens as.
276-
if (isNavigable(a) !== isNavigable(b)) return isNavigable(a) ? -1 : 1
277-
return a.name.localeCompare(b.name)
278-
}),
279-
[state.entries, filter, showHidden],
334+
() => visibleEntries(state.entries, { filter, showHidden, sort }),
335+
[state.entries, filter, showHidden, sort],
336+
)
337+
338+
/**
339+
* Re-orders the rows and keeps the keyboard cursor on the row it was on.
340+
*
341+
* The cursor is an index into `visible`, so re-sorting without this leaves it
342+
* pointing at whatever row slid into that position — arrow-down after a
343+
* header click would jump somewhere unrelated. Selection needs no such care:
344+
* it is held by name.
345+
*/
346+
const resort = useCallback(
347+
(key: SortKey) => {
348+
const next = nextSort(sort, key)
349+
const focused = visible[cursor]?.name
350+
const reordered = visibleEntries(state.entries, { filter, showHidden, sort: next })
351+
const index = focused === undefined ? -1 : reordered.findIndex((entry) => entry.name === focused)
352+
setSort(next)
353+
setCursor(index === -1 ? 0 : index)
354+
anchor.current = null
355+
},
356+
[cursor, filter, showHidden, sort, state.entries, visible],
280357
)
281358

282359
const selectedSize = visible
@@ -491,14 +568,21 @@ export function Pane({
491568
</div>
492569

493570
<div
571+
role="row"
494572
className={cn(
495-
'grid shrink-0 gap-3 border-b border-line bg-sunken/60 px-3 py-1.5 text-[10px] font-medium uppercase tracking-[0.08em] text-faint',
573+
'grid shrink-0 gap-3 border-b border-line bg-sunken/60 px-3 py-1.5 text-[10px] font-medium text-faint',
496574
COLUMNS,
497575
)}
498576
>
499-
<span>Name</span>
500-
<span className="text-right">Size</span>
501-
<span className="text-right">Modified</span>
577+
<span role="columnheader" aria-sort={ariaSort(sort, 'name')} className="min-w-0">
578+
<SortHeader label="Name" column="name" sort={sort} onSort={resort} />
579+
</span>
580+
<span role="columnheader" aria-sort={ariaSort(sort, 'size')} className="min-w-0">
581+
<SortHeader label="Size" column="size" sort={sort} onSort={resort} align="right" />
582+
</span>
583+
<span role="columnheader" aria-sort={ariaSort(sort, 'modified')} className="min-w-0">
584+
<SortHeader label="Modified" column="modified" sort={sort} onSort={resort} align="right" />
585+
</span>
502586
</div>
503587

504588
<ScrollArea className="min-h-0 flex-1">

‎apps/desktop/src/lib/entries.test.ts‎

Lines changed: 109 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,29 @@
11
import { describe, expect, it } from 'vitest'
2-
import { isNavigable } from './entries.js'
2+
import type { FileEntry } from '@/lib/api'
3+
import { DEFAULT_SORT, isNavigable, nextSort, visibleEntries, type Sort } from './entries.js'
4+
5+
function entry(name: string, overrides: Partial<FileEntry> = {}): FileEntry {
6+
return {
7+
name,
8+
path: `/x/${name}`,
9+
type: 'file',
10+
size: 0,
11+
modifiedAt: '2026-01-01T00:00:00.000Z',
12+
mode: 0o644,
13+
...overrides,
14+
}
15+
}
16+
17+
const directory = (name: string, overrides: Partial<FileEntry> = {}) =>
18+
entry(name, { type: 'directory', size: 4096, ...overrides })
19+
20+
function order(entries: FileEntry[], sort: Sort, options: { filter?: string; showHidden?: boolean } = {}) {
21+
return visibleEntries(entries, {
22+
filter: options.filter ?? '',
23+
showHidden: options.showHidden ?? true,
24+
sort,
25+
}).map((item) => item.name)
26+
}
327

428
describe('isNavigable', () => {
529
/**
@@ -30,3 +54,87 @@ describe('isNavigable', () => {
3054
expect(isNavigable({ type: 'file', targetType: 'directory' })).toBe(false)
3155
})
3256
})
57+
58+
describe('nextSort', () => {
59+
it('opens size and modified on the answer you clicked for', () => {
60+
// Nobody clicks Size to find the smallest file.
61+
expect(nextSort(DEFAULT_SORT, 'size')).toEqual({ key: 'size', direction: 'desc' })
62+
expect(nextSort(DEFAULT_SORT, 'modified')).toEqual({ key: 'modified', direction: 'desc' })
63+
})
64+
65+
it('opens name forwards', () => {
66+
expect(nextSort({ key: 'size', direction: 'desc' }, 'name')).toEqual({ key: 'name', direction: 'asc' })
67+
})
68+
69+
it('flips the column already being sorted on', () => {
70+
expect(nextSort({ key: 'name', direction: 'asc' }, 'name')).toEqual({ key: 'name', direction: 'desc' })
71+
expect(nextSort({ key: 'name', direction: 'desc' }, 'name')).toEqual({ key: 'name', direction: 'asc' })
72+
})
73+
})
74+
75+
describe('visibleEntries', () => {
76+
it('sorts names the way a person reads them, not by code point', () => {
77+
// Plain localeCompare puts file10 before file2, and a capitalised name
78+
// into a block of its own above every lowercase one.
79+
const names = order([entry('file10'), entry('file2'), entry('Photos.txt'), entry('apps.txt')], DEFAULT_SORT)
80+
expect(names).toEqual(['apps.txt', 'file2', 'file10', 'Photos.txt'])
81+
})
82+
83+
it('keeps directories above files in both directions', () => {
84+
const listing = [entry('a.txt'), directory('zoo'), entry('z.txt'), directory('apps')]
85+
expect(order(listing, { key: 'name', direction: 'asc' })).toEqual(['apps', 'zoo', 'a.txt', 'z.txt'])
86+
expect(order(listing, { key: 'name', direction: 'desc' })).toEqual(['zoo', 'apps', 'z.txt', 'a.txt'])
87+
})
88+
89+
it('groups a link to a directory with the directories', () => {
90+
const listing = [entry('a.txt'), entry('data', { type: 'symlink', targetType: 'directory' })]
91+
expect(order(listing, DEFAULT_SORT)).toEqual(['data', 'a.txt'])
92+
})
93+
94+
it('sorts by size, largest first, without reordering the folders', () => {
95+
// Directories report their own inode size, which the pane draws as an em
96+
// dash: ordering identical-looking rows by an invisible number reads as a bug.
97+
const listing = [
98+
directory('zoo', { size: 4096 }),
99+
directory('apps', { size: 40960 }),
100+
entry('small.txt', { size: 10 }),
101+
entry('big.bin', { size: 9_000_000 }),
102+
]
103+
expect(order(listing, { key: 'size', direction: 'desc' })).toEqual(['apps', 'zoo', 'big.bin', 'small.txt'])
104+
expect(order(listing, { key: 'size', direction: 'asc' })).toEqual(['apps', 'zoo', 'small.txt', 'big.bin'])
105+
})
106+
107+
it('sorts by modified time, newest first', () => {
108+
const listing = [
109+
entry('old.txt', { modifiedAt: '2020-06-01T00:00:00.000Z' }),
110+
entry('new.txt', { modifiedAt: '2026-09-01T00:00:00.000Z' }),
111+
entry('middle.txt', { modifiedAt: '2024-01-01T00:00:00.000Z' }),
112+
]
113+
expect(order(listing, { key: 'modified', direction: 'desc' })).toEqual(['new.txt', 'middle.txt', 'old.txt'])
114+
})
115+
116+
it('sorts an unreadable mtime as the epoch instead of poisoning the comparison', () => {
117+
// A NaN comparison returns NaN for every pair, which leaves the whole
118+
// listing in arrival order and looks like sorting silently stopped working.
119+
const listing = [entry('b.txt', { modifiedAt: '' }), entry('a.txt', { modifiedAt: '2026-01-01T00:00:00.000Z' })]
120+
expect(order(listing, { key: 'modified', direction: 'desc' })).toEqual(['a.txt', 'b.txt'])
121+
})
122+
123+
it('breaks ties by name, so both panes draw an equal pair the same way', () => {
124+
const listing = [entry('b.txt', { size: 10 }), entry('a.txt', { size: 10 })]
125+
expect(order(listing, { key: 'size', direction: 'desc' })).toEqual(['a.txt', 'b.txt'])
126+
expect(order(listing, { key: 'modified', direction: 'desc' })).toEqual(['a.txt', 'b.txt'])
127+
})
128+
129+
it('still hides dotfiles and honours the filter', () => {
130+
const listing = [entry('.hidden'), entry('notes.txt'), entry('other.md')]
131+
expect(order(listing, DEFAULT_SORT, { showHidden: false })).toEqual(['notes.txt', 'other.md'])
132+
expect(order(listing, DEFAULT_SORT, { showHidden: true, filter: 'HID' })).toEqual(['.hidden'])
133+
})
134+
135+
it('leaves the array it was given alone', () => {
136+
const listing = [entry('b.txt'), entry('a.txt')]
137+
order(listing, DEFAULT_SORT)
138+
expect(listing.map((item) => item.name)).toEqual(['b.txt', 'a.txt'])
139+
})
140+
})

‎apps/desktop/src/lib/entries.ts‎

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,93 @@ import type { FileEntry } from '@/lib/api'
1212
export function isNavigable(entry: Pick<FileEntry, 'type' | 'targetType'>): boolean {
1313
return entry.type === 'directory' || (entry.type === 'symlink' && entry.targetType === 'directory')
1414
}
15+
16+
export type SortKey = 'name' | 'size' | 'modified'
17+
export type SortDirection = 'asc' | 'desc'
18+
export type Sort = { key: SortKey; direction: SortDirection }
19+
20+
/** What a pane sorts by until someone clicks a header: the file-manager default. */
21+
export const DEFAULT_SORT: Sort = { key: 'name', direction: 'asc' }
22+
23+
/**
24+
* The direction a column starts in the first time it is clicked.
25+
*
26+
* Name reads forwards, but nobody clicks Size to find the smallest file or
27+
* Modified to find the oldest, so those open on the answer you came for.
28+
*/
29+
export function initialDirection(key: SortKey): SortDirection {
30+
return key === 'name' ? 'asc' : 'desc'
31+
}
32+
33+
/** Clicking the active column flips it; clicking another one switches to it. */
34+
export function nextSort(current: Sort, key: SortKey): Sort {
35+
if (current.key !== key) return { key, direction: initialDirection(key) }
36+
return { key, direction: current.direction === 'asc' ? 'desc' : 'asc' }
37+
}
38+
39+
/**
40+
* Names in the order a person reads them: `file10` after `file2`, and case
41+
* ignored, so `Photos` does not sort into a block of its own above `apps`.
42+
*
43+
* The exact-string fallback is not decoration. A collator told to ignore case
44+
* calls `README` and `readme` equal, and two entries that compare equal are
45+
* left in whatever order the listing arrived in — stable within one sort, but
46+
* different between the local lister and SFTP, so the same folder would draw
47+
* one way on the left and another on the right.
48+
*/
49+
function byName(a: FileEntry, b: FileEntry): number {
50+
const collated = a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' })
51+
if (collated !== 0) return collated
52+
return a.name < b.name ? -1 : a.name > b.name ? 1 : 0
53+
}
54+
55+
/** A missing or unparseable mtime sorts as the epoch, rather than poisoning the comparator with NaN. */
56+
function modifiedAt(entry: FileEntry): number {
57+
const parsed = Date.parse(entry.modifiedAt)
58+
return Number.isNaN(parsed) ? 0 : parsed
59+
}
60+
61+
/**
62+
* The row order for a pane.
63+
*
64+
* Directories stay above files in every direction, the way every file manager
65+
* behaves: reversing Size should not shuffle folders into the middle of the
66+
* list. A link to a directory is grouped as one, because that is what it opens
67+
* as.
68+
*
69+
* Sorting by size leaves that folder block on name. Directories report their
70+
* own inode size, not the size of their contents, and the pane already draws
71+
* that as an em dash — ordering visibly identical rows by a number nobody can
72+
* see reads as a bug.
73+
*/
74+
export function compareEntries(sort: Sort): (a: FileEntry, b: FileEntry) => number {
75+
const sign = sort.direction === 'asc' ? 1 : -1
76+
return (a, b) => {
77+
if (isNavigable(a) !== isNavigable(b)) return isNavigable(a) ? -1 : 1
78+
if (sort.key === 'size') {
79+
if (isNavigable(a)) return byName(a, b)
80+
return a.size === b.size ? byName(a, b) : sign * (a.size - b.size)
81+
}
82+
if (sort.key === 'modified') {
83+
const difference = modifiedAt(a) - modifiedAt(b)
84+
return difference === 0 ? byName(a, b) : sign * difference
85+
}
86+
return sign * byName(a, b)
87+
}
88+
}
89+
90+
/**
91+
* What a pane actually draws: hidden files, the filter box and the sort in one
92+
* place, so the header click that keeps the keyboard cursor on its row can ask
93+
* for the next order instead of predicting it.
94+
*/
95+
export function visibleEntries(
96+
entries: readonly FileEntry[],
97+
options: { filter: string; showHidden: boolean; sort: Sort },
98+
): FileEntry[] {
99+
const needle = options.filter.toLowerCase()
100+
return entries
101+
.filter((entry) => options.showHidden || !entry.name.startsWith('.'))
102+
.filter((entry) => needle === '' || entry.name.toLowerCase().includes(needle))
103+
.sort(compareEntries(options.sort))
104+
}

‎docs/desktop.md‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,21 @@
2424
Either pane can be the local machine or any saved server, which is what makes
2525
`Server A → Server B` an ordinary case rather than a special mode.
2626

27+
## Sorting
28+
29+
Click **Name**, **Size** or **Modified** to sort a listing; click the same
30+
column again to reverse it. Size and Modified open on their largest and newest,
31+
because that is what you clicked them to find.
32+
33+
Each pane sorts on its own, a server pane exactly like a local one. The rows
34+
are already in the app, so this is instant and re-reads nothing: no second
35+
listing, no SSH round trip, no rsync.
36+
37+
Two rules hold in every direction. Directories stay above files, so reversing
38+
Size does not scatter folders through the list. And sorting by size leaves the
39+
folders on name — a directory reports its own inode size rather than the size
40+
of its contents, which is why the column shows an em dash for them.
41+
2742
## Defaults
2843

2944
Dragging between panes uses the safe preset. No dialog appears first. The

0 commit comments

Comments
 (0)