feat: add sort direction (asc/desc) to note list sorting

- Add SortDirection type and SortConfig interface to noteListHelpers
- getSortComparator now accepts optional direction parameter
- SortDropdown shows ↑/↓ arrow buttons per option in the menu
- Button label shows current direction arrow (ArrowUp/ArrowDown)
- Persistence updated to store {option, direction} with backward compat
  for old string-only preferences
- Default directions: modified/created=desc, title/status=asc
- Status sort tiebreaker always uses newest-first regardless of direction

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
lucaronin
2026-02-22 12:08:53 +01:00
parent 25e03ee2d6
commit b91aded1ba
3 changed files with 109 additions and 53 deletions

View File

@@ -8,14 +8,14 @@ import { getTypeColor, getTypeLightColor } from '../utils/typeColors'
import { NoteItem, getTypeIcon } from './NoteItem'
import { SortDropdown } from './SortDropdown'
import {
type SortOption, type RelationshipGroup,
getSortComparator,
type SortOption, type SortDirection, type SortConfig, type RelationshipGroup,
DEFAULT_DIRECTIONS, getSortComparator,
buildRelationshipGroups, filterEntries,
sortByModified, relativeDate, getDisplayDate,
loadSortPreferences, saveSortPreferences,
} from '../utils/noteListHelpers'
// eslint-disable-next-line react-refresh/only-export-components -- re-exports for consumers
// Re-export for consumers
export { sortByModified, filterEntries, buildRelationshipGroups, getSortComparator }
export type { SortOption }
@@ -38,10 +38,9 @@ function PinnedCard({ entry, typeEntryMap, onSelectNote, showDate }: {
const te = typeEntryMap[entry.isA ?? '']
const color = getTypeColor(entry.isA ?? '', te?.color)
const bgColor = getTypeLightColor(entry.isA ?? '', te?.color)
const Icon = useMemo(() => getTypeIcon(entry.isA, te?.icon), [entry.isA, te?.icon])
const Icon = getTypeIcon(entry.isA, te?.icon)
return (
<div className="relative cursor-pointer border-b border-[var(--border)]" style={{ backgroundColor: bgColor, padding: '14px 16px' }} onClick={() => onSelectNote(entry)}>
{/* eslint-disable-next-line react-hooks/static-components -- icon lookup from static map, no internal state */}
<Icon width={16} height={16} className="absolute right-3 top-3.5" style={{ color }} data-testid="type-icon" />
<div className="pr-6 text-[14px] font-bold" style={{ color }}>{entry.title}</div>
<div className="mt-1 text-[12px] leading-[1.5] opacity-80" style={{ color, display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>{entry.snippet}</div>
@@ -53,13 +52,13 @@ function PinnedCard({ entry, typeEntryMap, onSelectNote, showDate }: {
function RelationshipGroupSection({ group, isCollapsed, sortPrefs, onToggle, handleSortChange, renderItem }: {
group: RelationshipGroup
isCollapsed: boolean
sortPrefs: Record<string, SortOption>
sortPrefs: Record<string, SortConfig>
onToggle: () => void
handleSortChange: (groupLabel: string, option: SortOption) => void
handleSortChange: (groupLabel: string, option: SortOption, direction: SortDirection) => void
renderItem: (entry: VaultEntry) => React.ReactNode
}) {
const groupSort = sortPrefs[group.label] ?? 'modified'
const sortedEntries = [...group.entries].sort(getSortComparator(groupSort))
const groupConfig = sortPrefs[group.label] ?? { option: 'modified' as SortOption, direction: 'desc' as SortDirection }
const sortedEntries = [...group.entries].sort(getSortComparator(groupConfig.option, groupConfig.direction))
return (
<div>
<div className="flex w-full items-center justify-between bg-muted" style={{ height: 32, padding: '0 16px' }}>
@@ -68,7 +67,7 @@ function RelationshipGroupSection({ group, isCollapsed, sortPrefs, onToggle, han
<span className="font-mono-label text-muted-foreground" style={{ fontWeight: 400 }}>{group.entries.length}</span>
</button>
<span className="flex items-center gap-1.5">
<SortDropdown groupLabel={group.label} current={groupSort} onChange={handleSortChange} />
<SortDropdown groupLabel={group.label} current={groupConfig.option} direction={groupConfig.direction} onChange={handleSortChange} />
<button className="flex items-center border-none bg-transparent cursor-pointer p-0 text-muted-foreground" onClick={onToggle}>
{isCollapsed ? <CaretRight size={12} /> : <CaretDown size={12} />}
</button>
@@ -118,8 +117,8 @@ function useTypeEntryMap(entries: VaultEntry[]) {
function EntityView({ entity, groups, query, collapsedGroups, sortPrefs, onToggleGroup, onSortChange, renderItem, typeEntryMap, onSelectNote }: {
entity: VaultEntry; groups: RelationshipGroup[]; query: string
collapsedGroups: Set<string>; sortPrefs: Record<string, SortOption>
onToggleGroup: (label: string) => void; onSortChange: (label: string, opt: SortOption) => void
collapsedGroups: Set<string>; sortPrefs: Record<string, SortConfig>
onToggleGroup: (label: string) => void; onSortChange: (label: string, opt: SortOption, dir: SortDirection) => void
renderItem: (entry: VaultEntry) => React.ReactNode
typeEntryMap: Record<string, VaultEntry>; onSelectNote: (entry: VaultEntry) => void
}) {
@@ -175,10 +174,10 @@ function countExpiredTrash(entries: VaultEntry[]): number {
interface NoteListDataParams {
entries: VaultEntry[]; selection: SidebarSelection; allContent: Record<string, string>
query: string; listSort: SortOption
query: string; listSort: SortOption; listDirection: SortDirection; modifiedFiles?: ModifiedFile[]
}
function useNoteListData({ entries, selection, allContent, query, listSort }: NoteListDataParams) {
function useNoteListData({ entries, selection, allContent, query, listSort, listDirection, modifiedFiles }: NoteListDataParams) {
const isEntityView = selection.kind === 'entity'
const isTrashView = selection.kind === 'filter' && selection.filter === 'trash'
@@ -189,9 +188,9 @@ function useNoteListData({ entries, selection, allContent, query, listSort }: No
const searched = useMemo(() => {
if (isEntityView) return []
const sorted = [...filterEntries(entries, selection)].sort(getSortComparator(listSort))
const sorted = [...filterEntries(entries, selection, modifiedFiles)].sort(getSortComparator(listSort, listDirection))
return filterByQuery(sorted, query)
}, [entries, selection, isEntityView, listSort, query])
}, [entries, selection, modifiedFiles, isEntityView, listSort, listDirection, query])
const searchedGroups = useMemo(() => {
if (!isEntityView) return []
@@ -209,24 +208,26 @@ function useNoteListData({ entries, selection, allContent, query, listSort }: No
// --- Main component ---
function NoteListInner({ entries, selection, selectedNote, allContent, onSelectNote, onCreateNote }: NoteListProps) {
function NoteListInner({ entries, selection, selectedNote, allContent, modifiedFiles, onSelectNote, onCreateNote }: NoteListProps) {
const [search, setSearch] = useState('')
const [searchVisible, setSearchVisible] = useState(false)
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set())
const [sortPrefs, setSortPrefs] = useState<Record<string, SortOption>>(loadSortPreferences)
const [sortPrefs, setSortPrefs] = useState<Record<string, SortConfig>>(loadSortPreferences)
const handleSortChange = useCallback((groupLabel: string, option: SortOption) => {
setSortPrefs((prev) => { const next = { ...prev, [groupLabel]: option }; saveSortPreferences(next); return next })
const handleSortChange = useCallback((groupLabel: string, option: SortOption, direction: SortDirection) => {
setSortPrefs((prev) => { const next = { ...prev, [groupLabel]: { option, direction } }; saveSortPreferences(next); return next })
}, [])
const toggleGroup = useCallback((label: string) => {
setCollapsedGroups((prev) => { const next = new Set(prev); if (next.has(label)) next.delete(label); else next.add(label); return next })
setCollapsedGroups((prev) => { const next = new Set(prev); next.has(label) ? next.delete(label) : next.add(label); return next })
}, [])
const typeEntryMap = useTypeEntryMap(entries)
const query = search.trim().toLowerCase()
const listSort = sortPrefs['__list__'] ?? 'modified'
const { isEntityView, isTrashView, typeDocument, searched, searchedGroups, expiredTrashCount } = useNoteListData({ entries, selection, allContent, query, listSort })
const listConfig = sortPrefs['__list__'] ?? { option: 'modified' as SortOption, direction: 'desc' as SortDirection }
const listSort = listConfig.option
const listDirection = listConfig.direction
const { isEntityView, isTrashView, typeDocument, searched, searchedGroups, expiredTrashCount } = useNoteListData({ entries, selection, allContent, query, listSort, listDirection, modifiedFiles })
const renderItem = useCallback((entry: VaultEntry) => (
<NoteItem key={entry.path} entry={entry} isSelected={selectedNote?.path === entry.path} typeEntryMap={typeEntryMap} onSelectNote={onSelectNote} />
@@ -237,7 +238,7 @@ function NoteListInner({ entries, selection, selectedNote, allContent, onSelectN
<div className="flex h-[45px] shrink-0 items-center justify-between border-b border-border px-4" data-tauri-drag-region style={{ WebkitAppRegion: 'drag' } as React.CSSProperties}>
<h3 className="m-0 min-w-0 flex-1 truncate text-[14px] font-semibold">{resolveHeaderTitle(selection, typeDocument)}</h3>
<div className="flex items-center gap-3" style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}>
{!isEntityView && <SortDropdown groupLabel="__list__" current={listSort} onChange={handleSortChange} />}
{!isEntityView && <SortDropdown groupLabel="__list__" current={listSort} direction={listDirection} onChange={handleSortChange} />}
<button className="flex items-center text-muted-foreground transition-colors hover:text-foreground" onClick={() => { setSearchVisible(!searchVisible); if (searchVisible) setSearch('') }} title="Search notes">
<MagnifyingGlass size={16} />
</button>

View File

@@ -1,12 +1,13 @@
import { useState, useEffect, useRef } from 'react'
import { cn } from '@/lib/utils'
import { ArrowsDownUp, Check } from '@phosphor-icons/react'
import { type SortOption, SORT_OPTIONS } from '../utils/noteListHelpers'
import { ArrowUp, ArrowDown } from '@phosphor-icons/react'
import { type SortOption, type SortDirection, DEFAULT_DIRECTIONS, SORT_OPTIONS } from '../utils/noteListHelpers'
export function SortDropdown({ groupLabel, current, onChange }: {
export function SortDropdown({ groupLabel, current, direction, onChange }: {
groupLabel: string
current: SortOption
onChange: (groupLabel: string, option: SortOption) => void
direction: SortDirection
onChange: (groupLabel: string, option: SortOption, direction: SortDirection) => void
}) {
const [open, setOpen] = useState(false)
const ref = useRef<HTMLDivElement>(null)
@@ -20,11 +21,13 @@ export function SortDropdown({ groupLabel, current, onChange }: {
return () => document.removeEventListener('mousedown', handleClick)
}, [open])
const handleSelect = (opt: SortOption) => {
onChange(groupLabel, opt)
const handleSelect = (opt: SortOption, dir: SortDirection) => {
onChange(groupLabel, opt, dir)
setOpen(false)
}
const DirectionIcon = direction === 'asc' ? ArrowUp : ArrowDown
return (
<div ref={ref} className="relative" style={{ zIndex: open ? 10 : 0 }}>
<button
@@ -33,23 +36,47 @@ export function SortDropdown({ groupLabel, current, onChange }: {
title={`Sort by ${current}`}
data-testid={`sort-button-${groupLabel}`}
>
<ArrowsDownUp size={12} />
<DirectionIcon size={12} data-testid={`sort-direction-icon-${groupLabel}`} />
<span className="text-[10px] font-medium">{SORT_OPTIONS.find((o) => o.value === current)?.label}</span>
</button>
{open && (
<div className="absolute right-0 top-full mt-1 rounded-md border border-border bg-popover shadow-md" style={{ width: 130, padding: 4 }} data-testid={`sort-menu-${groupLabel}`}>
{SORT_OPTIONS.map((opt) => (
<button
key={opt.value}
className={cn("flex w-full items-center gap-1.5 rounded px-2 text-[12px] text-popover-foreground hover:bg-accent", opt.value === current && "bg-accent font-medium")}
style={{ height: 28, border: 'none', cursor: 'pointer', background: opt.value === current ? 'var(--accent)' : 'transparent' }}
onClick={(e) => { e.stopPropagation(); handleSelect(opt.value) }}
data-testid={`sort-option-${opt.value}`}
>
{opt.value === current ? <Check size={12} /> : <span style={{ width: 12, height: 12, display: 'inline-block' }} />}
{opt.label}
</button>
))}
<div className="absolute right-0 top-full mt-1 rounded-md border border-border bg-popover shadow-md" style={{ width: 150, padding: 4 }} data-testid={`sort-menu-${groupLabel}`}>
{SORT_OPTIONS.map((opt) => {
const isActive = opt.value === current
return (
<div
key={opt.value}
className={cn("flex w-full items-center justify-between rounded px-2 text-[12px] text-popover-foreground hover:bg-accent", isActive && "bg-accent font-medium")}
style={{ height: 28, cursor: 'pointer', background: isActive ? 'var(--accent)' : 'transparent' }}
data-testid={`sort-option-${opt.value}`}
onClick={(e) => { e.stopPropagation(); handleSelect(opt.value, isActive ? direction : DEFAULT_DIRECTIONS[opt.value]) }}
>
<span className="flex flex-1 items-center gap-1.5 text-inherit">
{opt.label}
</span>
<span className="flex items-center gap-0.5 ml-1">
<button
className={cn("flex items-center border-none bg-transparent cursor-pointer p-0 rounded hover:bg-background", isActive && direction === 'asc' ? 'text-foreground' : 'text-muted-foreground opacity-40')}
style={{ padding: 2 }}
onClick={(e) => { e.stopPropagation(); handleSelect(opt.value, 'asc') }}
data-testid={`sort-dir-asc-${opt.value}`}
title="Ascending"
>
<ArrowUp size={12} />
</button>
<button
className={cn("flex items-center border-none bg-transparent cursor-pointer p-0 rounded hover:bg-background", isActive && direction === 'desc' ? 'text-foreground' : 'text-muted-foreground opacity-40')}
style={{ padding: 2 }}
onClick={(e) => { e.stopPropagation(); handleSelect(opt.value, 'desc') }}
data-testid={`sort-dir-desc-${opt.value}`}
title="Descending"
>
<ArrowDown size={12} />
</button>
</span>
</div>
)
})}
</div>
)}
</div>

View File

@@ -53,6 +53,19 @@ export function sortByModified(a: VaultEntry, b: VaultEntry): number {
}
export type SortOption = 'modified' | 'created' | 'title' | 'status'
export type SortDirection = 'asc' | 'desc'
export interface SortConfig {
option: SortOption
direction: SortDirection
}
export const DEFAULT_DIRECTIONS: Record<SortOption, SortDirection> = {
modified: 'desc',
created: 'desc',
title: 'asc',
status: 'asc',
}
export const SORT_OPTIONS: { value: SortOption; label: string }[] = [
{ value: 'modified', label: 'Modified' },
@@ -65,36 +78,51 @@ const STATUS_ORDER: Record<string, number> = {
Active: 0, Paused: 1, Done: 2, Finished: 3,
}
export function getSortComparator(option: SortOption): (a: VaultEntry, b: VaultEntry) => number {
export function getSortComparator(option: SortOption, direction?: SortDirection): (a: VaultEntry, b: VaultEntry) => number {
const dir = direction ?? DEFAULT_DIRECTIONS[option]
const flip = dir === 'asc' ? 1 : -1
switch (option) {
case 'modified':
return sortByModified
return (a, b) => flip * ((getDisplayDate(a) ?? 0) - (getDisplayDate(b) ?? 0))
case 'created':
return (a, b) => (b.createdAt ?? b.modifiedAt ?? 0) - (a.createdAt ?? a.modifiedAt ?? 0)
return (a, b) => flip * ((a.createdAt ?? a.modifiedAt ?? 0) - (b.createdAt ?? b.modifiedAt ?? 0))
case 'title':
return (a, b) => a.title.localeCompare(b.title)
return (a, b) => flip * a.title.localeCompare(b.title)
case 'status':
return (a, b) => {
const sa = STATUS_ORDER[a.status ?? ''] ?? 999
const sb = STATUS_ORDER[b.status ?? ''] ?? 999
if (sa !== sb) return sa - sb
return sortByModified(a, b)
if (sa !== sb) return flip * (sa - sb)
// Tiebreaker: always newest first regardless of direction
return (getDisplayDate(b) ?? 0) - (getDisplayDate(a) ?? 0)
}
}
}
const SORT_STORAGE_KEY = 'laputa-sort-preferences'
export function loadSortPreferences(): Record<string, SortOption> {
export function loadSortPreferences(): Record<string, SortConfig> {
try {
const raw = localStorage.getItem(SORT_STORAGE_KEY)
return raw ? JSON.parse(raw) : {}
if (!raw) return {}
const parsed = JSON.parse(raw)
const result: Record<string, SortConfig> = {}
for (const [key, value] of Object.entries(parsed)) {
if (typeof value === 'string') {
// Migrate old format: bare SortOption string → SortConfig
const opt = value as SortOption
result[key] = { option: opt, direction: DEFAULT_DIRECTIONS[opt] }
} else {
result[key] = value as SortConfig
}
}
return result
} catch {
return {}
}
}
export function saveSortPreferences(prefs: Record<string, SortOption>) {
export function saveSortPreferences(prefs: Record<string, SortConfig>) {
try {
localStorage.setItem(SORT_STORAGE_KEY, JSON.stringify(prefs))
} catch { /* ignore */ }