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
116 changes: 100 additions & 16 deletions apps/desktop/src/components/pane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@

import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import {
ChevronDown,
ChevronRight,
ChevronUp,
CornerLeftUp,
ExternalLink,
FilePlus2,
Expand All @@ -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'
Expand Down Expand Up @@ -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 (
<button
type="button"
onClick={() => onSort(column)}
aria-label={`Sort by ${label.toLowerCase()}`}
className={cn(
'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',
align === 'right' && 'justify-end',
activeColumn && 'text-dim',
)}
>
{align === 'right' ? null : <span className="truncate">{label}</span>}
<Arrow
className={cn(
'size-3 shrink-0 transition-opacity',
activeColumn ? 'opacity-100' : 'opacity-0 group-hover:opacity-40',
)}
/>
{align === 'right' ? <span className="truncate">{label}</span> : null}
</button>
)
}

/** 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(() => {
Expand Down Expand Up @@ -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<Sort>(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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -491,14 +568,21 @@ export function Pane({
</div>

<div
role="row"
className={cn(
'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',
'grid shrink-0 gap-3 border-b border-line bg-sunken/60 px-3 py-1.5 text-[10px] font-medium text-faint',
COLUMNS,
)}
>
<span>Name</span>
<span className="text-right">Size</span>
<span className="text-right">Modified</span>
<span role="columnheader" aria-sort={ariaSort(sort, 'name')} className="min-w-0">
<SortHeader label="Name" column="name" sort={sort} onSort={resort} />
</span>
<span role="columnheader" aria-sort={ariaSort(sort, 'size')} className="min-w-0">
<SortHeader label="Size" column="size" sort={sort} onSort={resort} align="right" />
</span>
<span role="columnheader" aria-sort={ariaSort(sort, 'modified')} className="min-w-0">
<SortHeader label="Modified" column="modified" sort={sort} onSort={resort} align="right" />
</span>
</div>

<ScrollArea className="min-h-0 flex-1">
Expand Down
110 changes: 109 additions & 1 deletion apps/desktop/src/lib/entries.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): 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<FileEntry> = {}) =>
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', () => {
/**
Expand Down Expand Up @@ -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'])
})
})
90 changes: 90 additions & 0 deletions apps/desktop/src/lib/entries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,93 @@ import type { FileEntry } from '@/lib/api'
export function isNavigable(entry: Pick<FileEntry, 'type' | 'targetType'>): 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))
}
15 changes: 15 additions & 0 deletions docs/desktop.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading