2026-02-17 17:14:36 +01:00
|
|
|
import { useState, useMemo, useCallback, memo } from 'react'
|
2026-02-20 22:02:04 +01:00
|
|
|
// Virtuoso removed — flat list rendering used instead
|
2026-02-15 12:56:28 +01:00
|
|
|
import type { VaultEntry, SidebarSelection, ModifiedFile } from '../types'
|
2026-02-16 16:56:44 +01:00
|
|
|
import { cn } from '@/lib/utils'
|
|
|
|
|
import { Input } from '@/components/ui/input'
|
2026-02-17 18:45:34 +01:00
|
|
|
import {
|
|
|
|
|
MagnifyingGlass, Plus, Wrench, Flask, Target, ArrowsClockwise,
|
2026-02-20 21:45:54 +01:00
|
|
|
Users, CalendarBlank, Tag, FileText, CaretDown, CaretRight, StackSimple,
|
2026-02-17 18:45:34 +01:00
|
|
|
} from '@phosphor-icons/react'
|
|
|
|
|
import type { ComponentType, SVGAttributes } from 'react'
|
2026-02-17 18:17:21 +01:00
|
|
|
import { getTypeColor, getTypeLightColor } from '../utils/typeColors'
|
2026-02-14 18:22:42 +01:00
|
|
|
|
2026-02-17 18:45:34 +01:00
|
|
|
const TYPE_ICON_MAP: Record<string, ComponentType<SVGAttributes<SVGSVGElement>>> = {
|
|
|
|
|
Project: Wrench,
|
|
|
|
|
Experiment: Flask,
|
|
|
|
|
Responsibility: Target,
|
|
|
|
|
Procedure: ArrowsClockwise,
|
|
|
|
|
Person: Users,
|
|
|
|
|
Event: CalendarBlank,
|
|
|
|
|
Topic: Tag,
|
2026-02-20 21:45:54 +01:00
|
|
|
Type: StackSimple,
|
2026-02-17 18:45:34 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getTypeIcon(isA: string | null): ComponentType<SVGAttributes<SVGSVGElement>> {
|
|
|
|
|
return (isA && TYPE_ICON_MAP[isA]) || FileText
|
|
|
|
|
}
|
|
|
|
|
|
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-15 10:30:13 +01:00
|
|
|
interface RelationshipGroup {
|
|
|
|
|
label: string
|
|
|
|
|
entries: VaultEntry[]
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-14 21:22:54 +01:00
|
|
|
function relativeDate(ts: number | null): string {
|
|
|
|
|
if (!ts) return ''
|
|
|
|
|
const now = Math.floor(Date.now() / 1000)
|
|
|
|
|
const diff = now - ts
|
2026-02-15 10:00:29 +01:00
|
|
|
if (diff < 0) {
|
|
|
|
|
const date = new Date(ts * 1000)
|
|
|
|
|
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
|
|
|
|
|
}
|
2026-02-14 21:22:54 +01:00
|
|
|
if (diff < 60) return 'just now'
|
|
|
|
|
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`
|
|
|
|
|
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`
|
|
|
|
|
if (diff < 604800) return `${Math.floor(diff / 86400)}d ago`
|
|
|
|
|
const date = new Date(ts * 1000)
|
|
|
|
|
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-15 10:00:29 +01:00
|
|
|
function getDisplayDate(entry: VaultEntry): number | null {
|
|
|
|
|
return entry.modifiedAt ?? entry.createdAt
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-14 19:44:39 +01:00
|
|
|
function refsMatch(refs: string[], entry: VaultEntry): boolean {
|
|
|
|
|
const stem = entry.path.replace(/^.*\/Laputa\//, '').replace(/\.md$/, '')
|
2026-02-17 19:16:19 +01:00
|
|
|
const fileStem = entry.filename.replace(/\.md$/, '')
|
2026-02-14 19:44:39 +01:00
|
|
|
return refs.some((ref) => {
|
2026-02-17 19:16:19 +01:00
|
|
|
const raw = ref.replace(/^\[\[/, '').replace(/\]\]$/, '')
|
|
|
|
|
const inner = raw.split('|')[0]
|
|
|
|
|
return inner === stem || inner.split('/').pop() === fileStem
|
2026-02-14 19:44:39 +01:00
|
|
|
})
|
2026-02-14 18:22:42 +01:00
|
|
|
}
|
|
|
|
|
|
2026-02-15 10:30:13 +01:00
|
|
|
function resolveRefs(refs: string[], entries: VaultEntry[]): VaultEntry[] {
|
|
|
|
|
return refs
|
|
|
|
|
.map((ref) => {
|
2026-02-17 19:16:19 +01:00
|
|
|
// Strip [[ ]] and remove alias (|display text) if present
|
|
|
|
|
const raw = ref.replace(/^\[\[/, '').replace(/\]\]$/, '')
|
|
|
|
|
const inner = raw.split('|')[0]
|
2026-02-15 10:30:13 +01:00
|
|
|
return entries.find((e) => {
|
|
|
|
|
const stem = e.path.replace(/^.*\/Laputa\//, '').replace(/\.md$/, '')
|
|
|
|
|
if (stem === inner) return true
|
|
|
|
|
const fileStem = e.filename.replace(/\.md$/, '')
|
|
|
|
|
if (fileStem === inner.split('/').pop()) return true
|
|
|
|
|
return false
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
.filter((e): e is VaultEntry => e !== undefined)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function sortByModified(a: VaultEntry, b: VaultEntry): number {
|
|
|
|
|
return (getDisplayDate(b) ?? 0) - (getDisplayDate(a) ?? 0)
|
|
|
|
|
}
|
|
|
|
|
|
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
|
|
|
function findBacklinks(entity: VaultEntry, allEntries: VaultEntry[], allContent: Record<string, string>): VaultEntry[] {
|
|
|
|
|
const stem = entity.filename.replace(/\.md$/, '')
|
|
|
|
|
const pathStem = entity.path.replace(/^.*\/Laputa\//, '').replace(/\.md$/, '')
|
|
|
|
|
const targets = [entity.title, ...entity.aliases]
|
|
|
|
|
|
|
|
|
|
return allEntries.filter((e) => {
|
|
|
|
|
if (e.path === entity.path) return false
|
|
|
|
|
const content = allContent[e.path]
|
|
|
|
|
if (!content) return false
|
|
|
|
|
for (const t of targets) {
|
|
|
|
|
if (content.includes(`[[${t}]]`)) return true
|
|
|
|
|
}
|
|
|
|
|
if (content.includes(`[[${stem}]]`)) return true
|
|
|
|
|
if (content.includes(`[[${pathStem}]]`)) return true
|
|
|
|
|
if (content.includes(`[[${pathStem}|`)) return true
|
|
|
|
|
return false
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-17 19:11:01 +01:00
|
|
|
function addGroup(
|
|
|
|
|
groups: RelationshipGroup[],
|
|
|
|
|
label: string,
|
|
|
|
|
entries: VaultEntry[],
|
|
|
|
|
seen: Set<string>,
|
|
|
|
|
) {
|
|
|
|
|
const unseen = entries.filter((e) => !seen.has(e.path))
|
|
|
|
|
if (unseen.length > 0) {
|
|
|
|
|
groups.push({ label, entries: unseen })
|
|
|
|
|
unseen.forEach((e) => seen.add(e.path))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
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
|
|
|
function buildRelationshipGroups(
|
|
|
|
|
entity: VaultEntry,
|
|
|
|
|
allEntries: VaultEntry[],
|
|
|
|
|
allContent: Record<string, string>,
|
|
|
|
|
): RelationshipGroup[] {
|
2026-02-15 10:30:13 +01:00
|
|
|
const groups: RelationshipGroup[] = []
|
|
|
|
|
const seen = new Set<string>([entity.path])
|
2026-02-17 19:11:01 +01:00
|
|
|
const rels = entity.relationships ?? {}
|
2026-02-15 10:30:13 +01:00
|
|
|
|
2026-02-20 21:48:15 +01:00
|
|
|
// 0. "Instances" — for type documents, show all entries of this type
|
|
|
|
|
if (entity.isA === 'Type') {
|
|
|
|
|
const instances = allEntries
|
|
|
|
|
.filter((e) => e.isA === entity.title && !seen.has(e.path))
|
|
|
|
|
.sort(sortByModified)
|
|
|
|
|
addGroup(groups, 'Instances', instances, seen)
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-17 19:11:01 +01:00
|
|
|
// 1. "Has" — from the entity's own relationships map
|
|
|
|
|
const hasRefs = rels['Has'] ?? []
|
|
|
|
|
if (hasRefs.length > 0) {
|
|
|
|
|
addGroup(groups, 'Has', resolveRefs(hasRefs, allEntries).sort(sortByModified), seen)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 2. Children — entries whose belongsTo points to this entity (reverse lookup, excluding events)
|
2026-02-15 10:30:13 +01:00
|
|
|
const children = allEntries
|
|
|
|
|
.filter((e) => !seen.has(e.path) && e.isA !== 'Event' && refsMatch(e.belongsTo, entity))
|
|
|
|
|
.sort(sortByModified)
|
2026-02-17 19:11:01 +01:00
|
|
|
addGroup(groups, 'Children', children, seen)
|
2026-02-15 10:30:13 +01:00
|
|
|
|
2026-02-17 19:11:01 +01:00
|
|
|
// 3. Events — entities of type Event that reference this entity via belongsTo/relatedTo
|
2026-02-15 10:30:13 +01:00
|
|
|
const events = allEntries
|
|
|
|
|
.filter(
|
|
|
|
|
(e) =>
|
|
|
|
|
!seen.has(e.path) &&
|
|
|
|
|
e.isA === 'Event' &&
|
|
|
|
|
(refsMatch(e.belongsTo, entity) || refsMatch(e.relatedTo, entity))
|
|
|
|
|
)
|
|
|
|
|
.sort(sortByModified)
|
2026-02-17 19:11:01 +01:00
|
|
|
addGroup(groups, 'Events', events, seen)
|
2026-02-15 10:30:13 +01:00
|
|
|
|
2026-02-17 19:11:01 +01:00
|
|
|
// 4. "Topics" — from the entity's own relationships map
|
|
|
|
|
const topicRefs = rels['Topics'] ?? []
|
|
|
|
|
if (topicRefs.length > 0) {
|
|
|
|
|
addGroup(groups, 'Topics', resolveRefs(topicRefs, allEntries).sort(sortByModified), seen)
|
2026-02-15 10:30:13 +01:00
|
|
|
}
|
|
|
|
|
|
2026-02-17 19:11:01 +01:00
|
|
|
// 5. All other generic relationship fields (alphabetically)
|
|
|
|
|
const handledKeys = new Set(['Has', 'Topics'])
|
|
|
|
|
const otherKeys = Object.keys(rels)
|
2026-02-20 23:43:39 +01:00
|
|
|
.filter((k) => !handledKeys.has(k) && k.toLowerCase() !== 'type')
|
2026-02-17 19:11:01 +01:00
|
|
|
.sort((a, b) => a.localeCompare(b))
|
|
|
|
|
for (const key of otherKeys) {
|
|
|
|
|
const refs = rels[key]
|
|
|
|
|
if (refs && refs.length > 0) {
|
|
|
|
|
addGroup(groups, key, resolveRefs(refs, allEntries).sort(sortByModified), seen)
|
|
|
|
|
}
|
2026-02-15 10:30:13 +01:00
|
|
|
}
|
|
|
|
|
|
2026-02-17 19:11:01 +01:00
|
|
|
// 6. Referenced By — entries that reference this entity via relatedTo (reverse lookup)
|
|
|
|
|
const referencedBy = allEntries
|
|
|
|
|
.filter((e) => !seen.has(e.path) && e.isA !== 'Event' && refsMatch(e.relatedTo, entity))
|
|
|
|
|
.sort(sortByModified)
|
|
|
|
|
addGroup(groups, 'Referenced By', referencedBy, seen)
|
2026-02-15 10:30:13 +01:00
|
|
|
|
2026-02-17 19:11:01 +01:00
|
|
|
// 7. Backlinks — always last
|
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
|
|
|
const backlinks = findBacklinks(entity, allEntries, allContent)
|
|
|
|
|
.filter((e) => !seen.has(e.path))
|
|
|
|
|
.sort(sortByModified)
|
2026-02-17 19:17:03 +01:00
|
|
|
addGroup(groups, 'Backlinks', backlinks, seen)
|
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-15 10:30:13 +01:00
|
|
|
return groups
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-17 11:03:23 +01:00
|
|
|
function filterEntries(entries: VaultEntry[], selection: SidebarSelection, _modifiedFiles?: ModifiedFile[]): VaultEntry[] {
|
2026-02-14 19:44:39 +01:00
|
|
|
switch (selection.kind) {
|
|
|
|
|
case 'filter':
|
|
|
|
|
switch (selection.filter) {
|
|
|
|
|
case 'all':
|
|
|
|
|
return entries
|
|
|
|
|
case 'favorites':
|
|
|
|
|
return []
|
|
|
|
|
}
|
|
|
|
|
break
|
|
|
|
|
case 'sectionGroup':
|
|
|
|
|
return entries.filter((e) => e.isA === selection.type)
|
2026-02-15 10:30:13 +01:00
|
|
|
case 'entity':
|
|
|
|
|
return []
|
2026-02-14 19:44:39 +01:00
|
|
|
case 'topic': {
|
|
|
|
|
const topic = selection.entry
|
|
|
|
|
return entries.filter((e) => refsMatch(e.relatedTo, topic))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
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
|
|
|
function NoteListInner({ entries, selection, selectedNote, allContent, modifiedFiles, onSelectNote, onCreateNote }: NoteListProps) {
|
2026-02-14 19:46:44 +01:00
|
|
|
const [search, setSearch] = useState('')
|
2026-02-17 11:03:23 +01:00
|
|
|
const [searchVisible, setSearchVisible] = useState(false)
|
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
|
|
|
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set())
|
2026-02-14 19:46:44 +01:00
|
|
|
|
2026-02-15 10:30:13 +01:00
|
|
|
const isEntityView = selection.kind === 'entity'
|
2026-02-20 21:47:42 +01:00
|
|
|
const isSectionGroup = selection.kind === 'sectionGroup'
|
2026-02-15 12:56:28 +01:00
|
|
|
|
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
|
|
|
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
|
|
|
|
|
})
|
|
|
|
|
}, [])
|
|
|
|
|
|
2026-02-20 21:47:42 +01:00
|
|
|
// Find the type document for this section group (e.g., type/project.md for "Project")
|
|
|
|
|
const typeDocument = useMemo(() => {
|
|
|
|
|
if (!isSectionGroup) return null
|
|
|
|
|
const typeName = (selection as { kind: 'sectionGroup'; type: string }).type
|
|
|
|
|
return entries.find((e) => e.isA === 'Type' && e.title === typeName) ?? null
|
|
|
|
|
}, [isSectionGroup, selection, entries])
|
|
|
|
|
|
2026-02-17 17:14:36 +01:00
|
|
|
const entityGroups = useMemo(
|
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
|
|
|
() => isEntityView ? buildRelationshipGroups(selection.entry, entries, allContent) : [],
|
|
|
|
|
[isEntityView, selection, entries, allContent]
|
2026-02-17 17:14:36 +01:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
const filtered = useMemo(
|
|
|
|
|
() => isEntityView ? [] : filterEntries(entries, selection, modifiedFiles),
|
|
|
|
|
[entries, selection, modifiedFiles, isEntityView]
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
const sorted = useMemo(
|
|
|
|
|
() => isEntityView ? [] : [...filtered].sort(sortByModified),
|
|
|
|
|
[filtered, isEntityView]
|
|
|
|
|
)
|
2026-02-15 10:30:13 +01:00
|
|
|
|
2026-02-14 19:46:44 +01:00
|
|
|
const query = search.trim().toLowerCase()
|
2026-02-15 10:30:13 +01:00
|
|
|
|
2026-02-17 17:14:36 +01:00
|
|
|
const searched = useMemo(
|
|
|
|
|
() => query ? sorted.filter((e) => e.title.toLowerCase().includes(query)) : sorted,
|
|
|
|
|
[sorted, query]
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
const searchedGroups = useMemo(
|
|
|
|
|
() => query
|
|
|
|
|
? entityGroups
|
|
|
|
|
.map((g) => ({
|
|
|
|
|
...g,
|
|
|
|
|
entries: g.entries.filter((e) => e.title.toLowerCase().includes(query)),
|
|
|
|
|
}))
|
|
|
|
|
.filter((g) => g.entries.length > 0)
|
|
|
|
|
: entityGroups,
|
|
|
|
|
[entityGroups, query]
|
|
|
|
|
)
|
|
|
|
|
|
2026-02-14 19:48:59 +01:00
|
|
|
|
2026-02-17 17:14:36 +01:00
|
|
|
const renderItem = useCallback((entry: VaultEntry, isPinned = false) => {
|
2026-02-17 11:03:23 +01:00
|
|
|
const isSelected = selectedNote?.path === entry.path && !isPinned
|
2026-02-20 23:07:10 +01:00
|
|
|
const typeColor = getTypeColor(entry.isA ?? 'Note')
|
|
|
|
|
const typeLightColor = getTypeLightColor(entry.isA ?? 'Note')
|
2026-02-17 18:45:34 +01:00
|
|
|
const TypeIcon = getTypeIcon(entry.isA)
|
2026-02-15 12:56:28 +01:00
|
|
|
return (
|
|
|
|
|
<div
|
|
|
|
|
key={entry.path}
|
2026-02-16 16:56:44 +01:00
|
|
|
className={cn(
|
2026-02-17 18:45:34 +01:00
|
|
|
"relative cursor-pointer border-b border-[var(--border)] transition-colors",
|
2026-02-17 11:03:23 +01:00
|
|
|
isPinned && "border-l-[3px] border-l-[var(--accent-green)] bg-muted",
|
2026-02-17 18:17:21 +01:00
|
|
|
isSelected && "border-l-[3px]",
|
2026-02-17 11:03:23 +01:00
|
|
|
!isPinned && !isSelected && "hover:bg-muted"
|
2026-02-16 16:56:44 +01:00
|
|
|
)}
|
2026-02-17 11:03:23 +01:00
|
|
|
style={{
|
2026-02-18 10:52:01 +01:00
|
|
|
padding: isPinned || isSelected ? '14px 16px 14px 13px' : '14px 16px',
|
2026-02-17 18:17:21 +01:00
|
|
|
...(isSelected && {
|
|
|
|
|
borderLeftColor: typeColor,
|
|
|
|
|
backgroundColor: typeLightColor,
|
|
|
|
|
}),
|
2026-02-17 11:03:23 +01:00
|
|
|
}}
|
2026-02-15 12:56:28 +01:00
|
|
|
onClick={() => onSelectNote(entry)}
|
|
|
|
|
>
|
2026-02-17 18:45:34 +01:00
|
|
|
<TypeIcon
|
|
|
|
|
width={14}
|
|
|
|
|
height={14}
|
|
|
|
|
className="absolute right-3 top-2.5"
|
|
|
|
|
style={{ color: typeColor }}
|
|
|
|
|
data-testid="type-icon"
|
|
|
|
|
/>
|
2026-02-17 19:56:41 +01:00
|
|
|
<div className="pr-5">
|
2026-02-17 11:03:23 +01:00
|
|
|
<div className={cn(
|
2026-02-17 19:56:41 +01:00
|
|
|
"truncate text-[13px] text-foreground",
|
2026-02-17 11:03:23 +01:00
|
|
|
isSelected ? "font-semibold" : "font-medium"
|
|
|
|
|
)}>
|
2026-02-17 19:56:41 +01:00
|
|
|
{entry.title}
|
2026-02-15 12:56:28 +01:00
|
|
|
</div>
|
2026-02-16 16:56:44 +01:00
|
|
|
</div>
|
2026-02-17 11:03:23 +01:00
|
|
|
<div className="mt-0.5 text-[12px] leading-[1.5] text-muted-foreground" style={{ display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>
|
2026-02-17 17:14:36 +01:00
|
|
|
{entry.snippet}
|
2026-02-15 12:56:28 +01:00
|
|
|
</div>
|
2026-02-18 10:52:01 +01:00
|
|
|
<div className="mt-0.5 text-[10px] text-muted-foreground">
|
|
|
|
|
{relativeDate(getDisplayDate(entry))}
|
|
|
|
|
</div>
|
2026-02-15 10:30:13 +01:00
|
|
|
</div>
|
2026-02-15 12:56:28 +01:00
|
|
|
)
|
2026-02-17 17:14:36 +01:00
|
|
|
}, [selectedNote?.path, onSelectNote])
|
2026-02-15 10:30:13 +01:00
|
|
|
|
2026-02-20 21:47:42 +01:00
|
|
|
const renderPinnedView = useCallback((entity: VaultEntry, groups: RelationshipGroup[]) => {
|
2026-02-20 22:02:04 +01:00
|
|
|
const entityTypeColor = getTypeColor(entity.isA ?? '')
|
|
|
|
|
const entityLightColor = getTypeLightColor(entity.isA ?? '')
|
2026-02-20 21:47:42 +01:00
|
|
|
const EntityIcon = getTypeIcon(entity.isA)
|
|
|
|
|
return (
|
|
|
|
|
<div className="h-full overflow-y-auto">
|
|
|
|
|
{/* Prominent card */}
|
|
|
|
|
<div
|
|
|
|
|
className="relative cursor-pointer border-b border-[var(--border)]"
|
|
|
|
|
style={{ backgroundColor: entityLightColor, padding: '14px 16px' }}
|
|
|
|
|
onClick={() => onSelectNote(entity)}
|
|
|
|
|
>
|
|
|
|
|
<EntityIcon
|
|
|
|
|
width={16}
|
|
|
|
|
height={16}
|
|
|
|
|
className="absolute right-3 top-3.5"
|
|
|
|
|
style={{ color: entityTypeColor }}
|
|
|
|
|
data-testid="type-icon"
|
|
|
|
|
/>
|
|
|
|
|
<div className="pr-6 text-[14px] font-bold" style={{ color: entityTypeColor }}>
|
|
|
|
|
{entity.title}
|
|
|
|
|
</div>
|
|
|
|
|
<div className="mt-1 text-[12px] leading-[1.5] opacity-80" style={{ color: entityTypeColor, display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>
|
|
|
|
|
{entity.snippet}
|
|
|
|
|
</div>
|
|
|
|
|
<div className="mt-1 text-[11px] opacity-60" style={{ color: entityTypeColor }}>
|
|
|
|
|
{relativeDate(getDisplayDate(entity))}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Relationship groups */}
|
|
|
|
|
{groups.length === 0 ? (
|
|
|
|
|
<div className="px-4 py-8 text-center text-[13px] text-muted-foreground">
|
|
|
|
|
{query ? 'No matching items' : 'No related items'}
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
groups.map((group) => {
|
|
|
|
|
const isGroupCollapsed = collapsedGroups.has(group.label)
|
|
|
|
|
return (
|
|
|
|
|
<div key={group.label}>
|
|
|
|
|
<button
|
|
|
|
|
className="flex w-full items-center justify-between border-none bg-muted cursor-pointer"
|
|
|
|
|
style={{ height: 32, padding: '0 16px' }}
|
|
|
|
|
onClick={() => toggleGroup(group.label)}
|
|
|
|
|
>
|
|
|
|
|
<span className="flex items-center gap-1.5">
|
|
|
|
|
<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>
|
|
|
|
|
</span>
|
|
|
|
|
{isGroupCollapsed
|
|
|
|
|
? <CaretRight size={12} className="text-muted-foreground" />
|
|
|
|
|
: <CaretDown size={12} className="text-muted-foreground" />
|
|
|
|
|
}
|
|
|
|
|
</button>
|
|
|
|
|
{!isGroupCollapsed && group.entries.map((groupEntry) => renderItem(groupEntry))}
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
})
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}, [onSelectNote, query, collapsedGroups, toggleGroup, renderItem])
|
|
|
|
|
|
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-16 16:56:44 +01:00
|
|
|
{/* Header */}
|
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-17 11:03:23 +01:00
|
|
|
<h3 className="m-0 min-w-0 flex-1 truncate text-[14px] font-semibold">
|
2026-02-20 21:47:42 +01:00
|
|
|
{isEntityView ? selection.entry.title : (typeDocument ? typeDocument.title : 'Notes')}
|
2026-02-16 16:56:44 +01:00
|
|
|
</h3>
|
2026-02-17 11:03:23 +01:00
|
|
|
<div className="flex items-center gap-3" style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}>
|
|
|
|
|
<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>
|
|
|
|
|
<button
|
|
|
|
|
className="flex items-center text-muted-foreground transition-colors hover:text-foreground"
|
|
|
|
|
onClick={onCreateNote}
|
|
|
|
|
title="Create new note"
|
|
|
|
|
>
|
|
|
|
|
<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
|
|
|
{/* Search (toggle on icon click) */}
|
|
|
|
|
{searchVisible && (
|
|
|
|
|
<div className="border-b border-border px-3 py-2">
|
|
|
|
|
<Input
|
|
|
|
|
placeholder="Search notes..."
|
|
|
|
|
value={search}
|
|
|
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
|
|
|
className="h-8 text-[13px]"
|
|
|
|
|
autoFocus
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2026-02-16 16:56:44 +01:00
|
|
|
|
|
|
|
|
{/* Items */}
|
2026-02-17 17:14:36 +01:00
|
|
|
<div className="flex-1 overflow-hidden" style={{ minHeight: 0 }}>
|
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
|
|
|
{isEntityView ? (() => {
|
|
|
|
|
const entity = selection.entry
|
2026-02-20 21:47:42 +01:00
|
|
|
return renderPinnedView(entity, searchedGroups)
|
|
|
|
|
})() : (
|
|
|
|
|
<div className="h-full overflow-y-auto">
|
|
|
|
|
{/* Type document pinned card (for sectionGroup view) */}
|
2026-02-20 22:02:04 +01:00
|
|
|
{typeDocument && (() => {
|
|
|
|
|
const tdColor = getTypeColor(typeDocument.isA ?? 'Type')
|
|
|
|
|
const tdLightColor = getTypeLightColor(typeDocument.isA ?? 'Type')
|
|
|
|
|
const TDIcon = getTypeIcon(typeDocument.isA)
|
|
|
|
|
return (
|
|
|
|
|
<div
|
|
|
|
|
className="relative cursor-pointer border-b border-[var(--border)]"
|
|
|
|
|
style={{ backgroundColor: tdLightColor, padding: '14px 16px' }}
|
|
|
|
|
onClick={() => onSelectNote(typeDocument)}
|
|
|
|
|
>
|
2026-02-20 21:47:42 +01:00
|
|
|
<TDIcon
|
|
|
|
|
width={16}
|
|
|
|
|
height={16}
|
|
|
|
|
className="absolute right-3 top-3.5"
|
2026-02-20 22:02:04 +01:00
|
|
|
style={{ color: tdColor }}
|
2026-02-20 21:47:42 +01:00
|
|
|
data-testid="type-icon"
|
|
|
|
|
/>
|
2026-02-20 22:02:04 +01:00
|
|
|
<div className="pr-6 text-[14px] font-bold" style={{ color: tdColor }}>
|
|
|
|
|
{typeDocument.title}
|
|
|
|
|
</div>
|
|
|
|
|
<div className="mt-1 text-[12px] leading-[1.5] opacity-80" style={{ color: tdColor, display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>
|
|
|
|
|
{typeDocument.snippet}
|
|
|
|
|
</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
|
|
|
</div>
|
2026-02-20 22:02:04 +01:00
|
|
|
)
|
|
|
|
|
})()}
|
2026-02-20 21:47:42 +01:00
|
|
|
{searched.length === 0 ? (
|
|
|
|
|
<div className="px-4 py-8 text-center text-[13px] text-muted-foreground">No notes found</div>
|
|
|
|
|
) : (
|
|
|
|
|
searched.map((entry) => renderItem(entry))
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
2026-02-14 18:22:42 +01:00
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|
2026-02-17 17:14:36 +01:00
|
|
|
|
|
|
|
|
export const NoteList = memo(NoteListInner)
|