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