From d9f346292f4d72a81e41f389e0c0c3638ceef2d8 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sun, 6 Sep 2026 14:28:34 +0000 Subject: [PATCH] desktop: sortable file list headers, on both panes 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. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012Q2bt449mEJSHoEZzaemCn --- apps/desktop/src/components/pane.tsx | 116 +++++++++++++++++++++++---- apps/desktop/src/lib/entries.test.ts | 110 ++++++++++++++++++++++++- apps/desktop/src/lib/entries.ts | 90 +++++++++++++++++++++ docs/desktop.md | 15 ++++ 4 files changed, 314 insertions(+), 17 deletions(-) diff --git a/apps/desktop/src/components/pane.tsx b/apps/desktop/src/components/pane.tsx index 72c66b9..77bf9d2 100644 --- a/apps/desktop/src/components/pane.tsx +++ b/apps/desktop/src/components/pane.tsx @@ -2,7 +2,9 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { + ChevronDown, ChevronRight, + ChevronUp, CornerLeftUp, ExternalLink, FilePlus2, @@ -19,7 +21,14 @@ import { Trash2, } from 'lucide-react' import { api, unwrap, type Connection, type FileEntry } from '@/lib/api' -import { isNavigable } from '@/lib/entries' +import { + DEFAULT_SORT, + isNavigable, + nextSort, + visibleEntries, + type Sort, + type SortKey, +} from '@/lib/entries' import { formatBytes, formatDate, formatMode, joinPath, parentPath } from '@/lib/format' import { EndpointSelect, type PaneEndpoint } from '@/components/endpoint-select' import { DeleteDialog, NameDialog } from '@/components/entry-dialogs' @@ -93,6 +102,57 @@ function IconAction({ ) } +/** What a screen reader is told about a column: only one of the three is ever sorted. */ +function ariaSort(sort: Sort, column: SortKey): 'ascending' | 'descending' | 'none' { + if (sort.key !== column) return 'none' + return sort.direction === 'asc' ? 'ascending' : 'descending' +} + +/** + * One column header: a button, not a label. + * + * The arrow is only drawn for the column being sorted on, and faintly on hover + * for the others — a header that looks identical whether or not it does + * anything is a control nobody finds. + */ +function SortHeader({ + label, + column, + sort, + onSort, + align = 'left', +}: { + label: string + column: SortKey + sort: Sort + onSort: (key: SortKey) => void + align?: 'left' | 'right' +}) { + const activeColumn = sort.key === column + const Arrow = activeColumn && sort.direction === 'desc' ? ChevronDown : ChevronUp + return ( + + ) +} + /** Breadcrumbs without a library: the path is the only source of truth. */ function Breadcrumbs({ path, onNavigate }: { path: string; onNavigate: (path: string) => void }) { const parts = useMemo(() => { @@ -227,6 +287,11 @@ export function Pane({ }) { const [filter, setFilter] = useState('') const [showHidden, setShowHidden] = useState(false) + // Per pane, and deliberately not reset when the path changes: a sort you + // picked is a way of looking at files, not a property of one folder. The two + // panes keep their own, because the point of them is comparing a listing + // against a differently-ordered one. + const [sort, setSort] = useState(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