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:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 */ }
|
||||
|
||||
Reference in New Issue
Block a user