2026-02-22 10:18:05 +01:00
|
|
|
import { useState, useMemo, useCallback, memo } from 'react'
|
2026-02-15 12:56:28 +01:00
|
|
|
import type { VaultEntry, SidebarSelection, ModifiedFile } from '../types'
|
2026-02-16 16:56:44 +01:00
|
|
|
import { Input } from '@/components/ui/input'
|
2026-02-17 18:45:34 +01:00
|
|
|
import {
|
2026-02-22 10:18:05 +01:00
|
|
|
MagnifyingGlass, Plus, CaretDown, CaretRight, Warning,
|
2026-02-17 18:45:34 +01:00
|
|
|
} from '@phosphor-icons/react'
|
2026-02-17 18:17:21 +01:00
|
|
|
import { getTypeColor, getTypeLightColor } from '../utils/typeColors'
|
2026-02-22 10:18:05 +01:00
|
|
|
import { NoteItem, getTypeIcon } from './NoteItem'
|
|
|
|
|
import { SortDropdown } from './SortDropdown'
|
|
|
|
|
import {
|
|
|
|
|
type SortOption, type RelationshipGroup,
|
|
|
|
|
getSortComparator,
|
|
|
|
|
buildRelationshipGroups, filterEntries,
|
|
|
|
|
sortByModified, relativeDate, getDisplayDate,
|
|
|
|
|
loadSortPreferences, saveSortPreferences,
|
|
|
|
|
} from '../utils/noteListHelpers'
|
2026-02-17 18:45:34 +01:00
|
|
|
|
2026-02-22 10:18:05 +01:00
|
|
|
// Re-export for consumers
|
|
|
|
|
export { sortByModified, filterEntries, buildRelationshipGroups, getSortComparator }
|
|
|
|
|
export type { SortOption }
|
2026-02-17 18:45:34 +01:00
|
|
|
|
2026-02-14 18:22:42 +01:00
|
|
|
interface NoteListProps {
|
|
|
|
|
entries: VaultEntry[]
|
2026-02-14 19:44:39 +01:00
|
|
|
selection: SidebarSelection
|
2026-02-14 20:07:23 +01:00
|
|
|
selectedNote: VaultEntry | null
|
2026-02-14 21:22:54 +01:00
|
|
|
allContent: Record<string, string>
|
2026-02-15 12:56:28 +01:00
|
|
|
modifiedFiles?: ModifiedFile[]
|
2026-02-14 20:07:23 +01:00
|
|
|
onSelectNote: (entry: VaultEntry) => void
|
2026-02-14 21:14:16 +01:00
|
|
|
onCreateNote: () => void
|
2026-02-14 19:44:39 +01:00
|
|
|
}
|
|
|
|
|
|
2026-02-22 10:18:05 +01:00
|
|
|
function PinnedCard({ entry, typeEntryMap, onSelectNote, showDate }: {
|
|
|
|
|
entry: VaultEntry
|
|
|
|
|
typeEntryMap: Record<string, VaultEntry>
|
|
|
|
|
onSelectNote: (entry: VaultEntry) => void
|
|
|
|
|
showDate?: boolean
|
|
|
|
|
}) {
|
|
|
|
|
const te = typeEntryMap[entry.isA ?? '']
|
|
|
|
|
const color = getTypeColor(entry.isA ?? '', te?.color)
|
|
|
|
|
const bgColor = getTypeLightColor(entry.isA ?? '', te?.color)
|
|
|
|
|
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)}>
|
|
|
|
|
<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>
|
|
|
|
|
{showDate && <div className="mt-1 text-[11px] opacity-60" style={{ color }}>{relativeDate(getDisplayDate(entry))}</div>}
|
|
|
|
|
</div>
|
|
|
|
|
)
|
2026-02-21 17:09:29 +01:00
|
|
|
}
|
|
|
|
|
|
2026-02-22 10:18:05 +01:00
|
|
|
function RelationshipGroupSection({ group, isCollapsed, sortPrefs, onToggle, handleSortChange, renderItem }: {
|
|
|
|
|
group: RelationshipGroup
|
|
|
|
|
isCollapsed: boolean
|
|
|
|
|
sortPrefs: Record<string, SortOption>
|
|
|
|
|
onToggle: () => void
|
|
|
|
|
handleSortChange: (groupLabel: string, option: SortOption) => void
|
|
|
|
|
renderItem: (entry: VaultEntry) => React.ReactNode
|
|
|
|
|
}) {
|
|
|
|
|
const groupSort = sortPrefs[group.label] ?? 'modified'
|
|
|
|
|
const sortedEntries = [...group.entries].sort(getSortComparator(groupSort))
|
|
|
|
|
return (
|
|
|
|
|
<div>
|
|
|
|
|
<div className="flex w-full items-center justify-between bg-muted" style={{ height: 32, padding: '0 16px' }}>
|
|
|
|
|
<button className="flex flex-1 items-center gap-1.5 border-none bg-transparent cursor-pointer p-0" onClick={onToggle}>
|
|
|
|
|
<span className="font-mono-label text-muted-foreground">{group.label}</span>
|
|
|
|
|
<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} />
|
|
|
|
|
<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>
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
{!isCollapsed && sortedEntries.map((entry) => renderItem(entry))}
|
|
|
|
|
</div>
|
|
|
|
|
)
|
feat: implement context view in NoteList
When a note is selected from the sidebar (entity selection), NoteList
now shows a context view instead of a flat list:
- Prominent top card with type-colored background, bold title, snippet,
timestamp, and type icon
- Grouped relationship sections (Children, Events, Referenced By,
Belongs To, Related To, Backlinks) with collapse/expand chevrons
- Backlinks detected by scanning allContent for wikilink references
- Groups show ALL-CAPS headers with count and CaretDown/CaretRight toggle
- Normal flat list shown when sidebar selection is "All Notes"
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 18:47:38 +01:00
|
|
|
}
|
|
|
|
|
|
2026-02-22 10:18:05 +01:00
|
|
|
function TrashWarningBanner({ expiredCount }: { expiredCount: number }) {
|
|
|
|
|
if (expiredCount === 0) return null
|
|
|
|
|
return (
|
|
|
|
|
<div className="flex items-start gap-2 border-b border-[var(--border)]" style={{ padding: '10px 12px', background: 'color-mix(in srgb, var(--destructive) 6%, transparent)' }}>
|
|
|
|
|
<Warning size={16} className="shrink-0" style={{ color: 'var(--destructive)', marginTop: 1 }} />
|
|
|
|
|
<div>
|
|
|
|
|
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--destructive)' }}>Notes in trash for 30+ days will be permanently deleted</div>
|
|
|
|
|
<div className="text-muted-foreground" style={{ fontSize: 11 }}>{expiredCount} {expiredCount === 1 ? 'note is' : 'notes are'} past the 30-day retention period</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)
|
2026-02-17 19:11:01 +01:00
|
|
|
}
|
|
|
|
|
|
2026-02-22 10:18:05 +01:00
|
|
|
function EmptyMessage({ text }: { text: string }) {
|
|
|
|
|
return <div className="px-4 py-8 text-center text-[13px] text-muted-foreground">{text}</div>
|
2026-02-15 10:30:13 +01:00
|
|
|
}
|
|
|
|
|
|
2026-02-22 10:18:05 +01:00
|
|
|
function resolveHeaderTitle(selection: SidebarSelection, typeDocument: VaultEntry | null): string {
|
|
|
|
|
if (selection.kind === 'entity') return selection.entry.title
|
|
|
|
|
if (typeDocument) return typeDocument.title
|
|
|
|
|
if (selection.kind === 'filter' && selection.filter === 'archived') return 'Archive'
|
|
|
|
|
if (selection.kind === 'filter' && selection.filter === 'trash') return 'Trash'
|
|
|
|
|
return 'Notes'
|
2026-02-14 19:44:39 +01:00
|
|
|
}
|
|
|
|
|
|
2026-02-22 10:18:05 +01:00
|
|
|
function useTypeEntryMap(entries: VaultEntry[]) {
|
|
|
|
|
return useMemo(() => {
|
|
|
|
|
const map: Record<string, VaultEntry> = {}
|
|
|
|
|
for (const e of entries) {
|
|
|
|
|
if (e.isA === 'Type') map[e.title] = e
|
2026-02-21 17:09:29 +01:00
|
|
|
}
|
2026-02-22 10:18:05 +01:00
|
|
|
return map
|
|
|
|
|
}, [entries])
|
2026-02-21 17:09:29 +01:00
|
|
|
}
|
|
|
|
|
|
2026-02-22 10:48:13 +01:00
|
|
|
// --- View sub-components ---
|
2026-02-14 19:46:44 +01:00
|
|
|
|
2026-02-22 10:48:13 +01:00
|
|
|
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
|
|
|
|
|
renderItem: (entry: VaultEntry) => React.ReactNode
|
|
|
|
|
typeEntryMap: Record<string, VaultEntry>; onSelectNote: (entry: VaultEntry) => void
|
|
|
|
|
}) {
|
|
|
|
|
return (
|
|
|
|
|
<div className="h-full overflow-y-auto">
|
|
|
|
|
<PinnedCard entry={entity} typeEntryMap={typeEntryMap} onSelectNote={onSelectNote} showDate />
|
|
|
|
|
{groups.length === 0
|
|
|
|
|
? <EmptyMessage text={query ? 'No matching items' : 'No related items'} />
|
|
|
|
|
: groups.map((group) => (
|
|
|
|
|
<RelationshipGroupSection key={group.label} group={group} isCollapsed={collapsedGroups.has(group.label)} sortPrefs={sortPrefs} onToggle={() => onToggleGroup(group.label)} handleSortChange={onSortChange} renderItem={renderItem} />
|
|
|
|
|
))
|
|
|
|
|
}
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|
2026-02-15 12:56:28 +01:00
|
|
|
|
2026-02-22 10:48:13 +01:00
|
|
|
function ListView({ typeDocument, isTrashView, expiredTrashCount, searched, query, renderItem, typeEntryMap, onSelectNote }: {
|
|
|
|
|
typeDocument: VaultEntry | null; isTrashView: boolean; expiredTrashCount: number
|
|
|
|
|
searched: VaultEntry[]; query: string
|
|
|
|
|
renderItem: (entry: VaultEntry) => React.ReactNode
|
|
|
|
|
typeEntryMap: Record<string, VaultEntry>; onSelectNote: (entry: VaultEntry) => void
|
|
|
|
|
}) {
|
|
|
|
|
const emptyText = isTrashView ? 'Trash is empty' : (query ? 'No matching notes' : 'No notes found')
|
|
|
|
|
return (
|
|
|
|
|
<div className="h-full overflow-y-auto">
|
|
|
|
|
{typeDocument && <PinnedCard entry={typeDocument} typeEntryMap={typeEntryMap} onSelectNote={onSelectNote} />}
|
|
|
|
|
<TrashWarningBanner expiredCount={isTrashView ? expiredTrashCount : 0} />
|
|
|
|
|
{searched.length === 0
|
|
|
|
|
? <EmptyMessage text={emptyText} />
|
|
|
|
|
: searched.map((entry) => renderItem(entry))
|
|
|
|
|
}
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|
2026-02-21 17:09:29 +01:00
|
|
|
|
2026-02-22 10:48:13 +01:00
|
|
|
// --- Pure helpers ---
|
feat: implement context view in NoteList
When a note is selected from the sidebar (entity selection), NoteList
now shows a context view instead of a flat list:
- Prominent top card with type-colored background, bold title, snippet,
timestamp, and type icon
- Grouped relationship sections (Children, Events, Referenced By,
Belongs To, Related To, Backlinks) with collapse/expand chevrons
- Backlinks detected by scanning allContent for wikilink references
- Groups show ALL-CAPS headers with count and CaretDown/CaretRight toggle
- Normal flat list shown when sidebar selection is "All Notes"
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 18:47:38 +01:00
|
|
|
|
2026-02-22 10:48:13 +01:00
|
|
|
function filterByQuery<T extends { title: string }>(items: T[], query: string): T[] {
|
|
|
|
|
return query ? items.filter((e) => e.title.toLowerCase().includes(query)) : items
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function filterGroupsByQuery(groups: RelationshipGroup[], query: string): RelationshipGroup[] {
|
|
|
|
|
if (!query) return groups
|
|
|
|
|
return groups.map((g) => ({ ...g, entries: filterByQuery(g.entries, query) })).filter((g) => g.entries.length > 0)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function countExpiredTrash(entries: VaultEntry[]): number {
|
|
|
|
|
const now = Date.now() / 1000
|
|
|
|
|
return entries.filter((e) => e.trashedAt && (now - e.trashedAt) >= 86400 * 30).length
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// --- Data hooks ---
|
|
|
|
|
|
|
|
|
|
interface NoteListDataParams {
|
|
|
|
|
entries: VaultEntry[]; selection: SidebarSelection; allContent: Record<string, string>
|
|
|
|
|
query: string; listSort: SortOption; modifiedFiles?: ModifiedFile[]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function useNoteListData({ entries, selection, allContent, query, listSort, modifiedFiles }: NoteListDataParams) {
|
|
|
|
|
const isEntityView = selection.kind === 'entity'
|
|
|
|
|
const isTrashView = selection.kind === 'filter' && selection.filter === 'trash'
|
2026-02-21 13:27:33 +01:00
|
|
|
|
2026-02-20 21:47:42 +01:00
|
|
|
const typeDocument = useMemo(() => {
|
2026-02-22 10:18:05 +01:00
|
|
|
if (selection.kind !== 'sectionGroup') return null
|
|
|
|
|
return entries.find((e) => e.isA === 'Type' && e.title === selection.type) ?? null
|
|
|
|
|
}, [selection, entries])
|
2026-02-15 10:30:13 +01:00
|
|
|
|
2026-02-22 10:18:05 +01:00
|
|
|
const searched = useMemo(() => {
|
|
|
|
|
if (isEntityView) return []
|
2026-02-22 10:48:13 +01:00
|
|
|
const sorted = [...filterEntries(entries, selection, modifiedFiles)].sort(getSortComparator(listSort))
|
|
|
|
|
return filterByQuery(sorted, query)
|
2026-02-22 10:18:05 +01:00
|
|
|
}, [entries, selection, modifiedFiles, isEntityView, listSort, query])
|
2026-02-17 17:14:36 +01:00
|
|
|
|
2026-02-22 10:18:05 +01:00
|
|
|
const searchedGroups = useMemo(() => {
|
|
|
|
|
if (!isEntityView) return []
|
|
|
|
|
const groups = buildRelationshipGroups(selection.entry, entries, allContent)
|
2026-02-22 10:48:13 +01:00
|
|
|
return filterGroupsByQuery(groups, query)
|
2026-02-22 10:18:05 +01:00
|
|
|
}, [isEntityView, selection, entries, allContent, query])
|
2026-02-14 19:48:59 +01:00
|
|
|
|
2026-02-22 10:48:13 +01:00
|
|
|
const expiredTrashCount = useMemo(
|
|
|
|
|
() => isTrashView ? countExpiredTrash(searched) : 0,
|
|
|
|
|
[isTrashView, searched],
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
return { isEntityView, isTrashView, typeDocument, searched, searchedGroups, expiredTrashCount }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// --- Main component ---
|
|
|
|
|
|
|
|
|
|
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 handleSortChange = useCallback((groupLabel: string, option: SortOption) => {
|
|
|
|
|
setSortPrefs((prev) => { const next = { ...prev, [groupLabel]: option }; saveSortPreferences(next); return next })
|
|
|
|
|
}, [])
|
|
|
|
|
|
|
|
|
|
const toggleGroup = useCallback((label: string) => {
|
|
|
|
|
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, modifiedFiles })
|
2026-02-21 16:54:59 +01:00
|
|
|
|
2026-02-22 10:18:05 +01:00
|
|
|
const renderItem = useCallback((entry: VaultEntry) => (
|
|
|
|
|
<NoteItem key={entry.path} entry={entry} isSelected={selectedNote?.path === entry.path} typeEntryMap={typeEntryMap} onSelectNote={onSelectNote} />
|
|
|
|
|
), [selectedNote?.path, onSelectNote, typeEntryMap])
|
2026-02-20 21:47:42 +01:00
|
|
|
|
2026-02-14 18:22:42 +01:00
|
|
|
return (
|
2026-02-17 17:14:36 +01:00
|
|
|
<div className="flex flex-col overflow-hidden border-r border-border bg-card text-foreground" style={{ height: '100%' }}>
|
2026-02-18 10:52:01 +01:00
|
|
|
<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}>
|
2026-02-22 10:18:05 +01:00
|
|
|
<h3 className="m-0 min-w-0 flex-1 truncate text-[14px] font-semibold">{resolveHeaderTitle(selection, typeDocument)}</h3>
|
2026-02-17 11:03:23 +01:00
|
|
|
<div className="flex items-center gap-3" style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}>
|
2026-02-22 10:18:05 +01:00
|
|
|
{!isEntityView && <SortDropdown groupLabel="__list__" current={listSort} onChange={handleSortChange} />}
|
|
|
|
|
<button className="flex items-center text-muted-foreground transition-colors hover:text-foreground" onClick={() => { setSearchVisible(!searchVisible); if (searchVisible) setSearch('') }} title="Search notes">
|
2026-02-17 11:03:23 +01:00
|
|
|
<MagnifyingGlass size={16} />
|
|
|
|
|
</button>
|
2026-02-22 10:18:05 +01:00
|
|
|
<button className="flex items-center text-muted-foreground transition-colors hover:text-foreground" onClick={onCreateNote} title="Create new note">
|
2026-02-17 11:03:23 +01:00
|
|
|
<Plus size={16} />
|
|
|
|
|
</button>
|
2026-02-14 21:14:16 +01:00
|
|
|
</div>
|
2026-02-14 19:46:44 +01:00
|
|
|
</div>
|
2026-02-16 16:56:44 +01:00
|
|
|
|
2026-02-17 11:03:23 +01:00
|
|
|
{searchVisible && (
|
|
|
|
|
<div className="border-b border-border px-3 py-2">
|
2026-02-22 10:18:05 +01:00
|
|
|
<Input placeholder="Search notes..." value={search} onChange={(e) => setSearch(e.target.value)} className="h-8 text-[13px]" autoFocus />
|
2026-02-17 11:03:23 +01:00
|
|
|
</div>
|
|
|
|
|
)}
|
2026-02-16 16:56:44 +01:00
|
|
|
|
2026-02-17 17:14:36 +01:00
|
|
|
<div className="flex-1 overflow-hidden" style={{ minHeight: 0 }}>
|
2026-02-22 11:00:07 +01:00
|
|
|
{isEntityView && selection.kind === 'entity' ? (
|
2026-02-22 10:48:13 +01:00
|
|
|
<EntityView entity={selection.entry} groups={searchedGroups} query={query} collapsedGroups={collapsedGroups} sortPrefs={sortPrefs} onToggleGroup={toggleGroup} onSortChange={handleSortChange} renderItem={renderItem} typeEntryMap={typeEntryMap} onSelectNote={onSelectNote} />
|
2026-02-22 10:18:05 +01:00
|
|
|
) : (
|
2026-02-22 10:48:13 +01:00
|
|
|
<ListView typeDocument={typeDocument} isTrashView={isTrashView} expiredTrashCount={expiredTrashCount} searched={searched} query={query} renderItem={renderItem} typeEntryMap={typeEntryMap} onSelectNote={onSelectNote} />
|
2026-02-14 18:22:42 +01:00
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|
2026-02-17 17:14:36 +01:00
|
|
|
|
|
|
|
|
export const NoteList = memo(NoteListInner)
|