feat: separate Backlinks and History with horizontal dividers in Inspector

Add visual separation between properties, Backlinks, and History sections
using Separator components. Restyle Backlinks with arrow-up-right icon header
and blue clickable links. Restyle History with counter-clockwise icon header,
combined hash+message blue links, and muted date below. Both sections now
hide entirely when empty instead of showing placeholder text.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
lucaronin
2026-03-29 16:04:28 +02:00
parent 9df41c3287
commit 730b56ecbd
5 changed files with 71 additions and 123 deletions

View File

@@ -188,10 +188,7 @@ This is a test note with some words to count.
entries={[mockEntry, referrerEntry]}
/>
)
// Backlinks section is collapsed by default, but header with count is visible
expect(screen.getByText('Backlinks (1)')).toBeInTheDocument()
// Expand to see the backlink entry
fireEvent.click(screen.getByTestId('backlinks-toggle'))
expect(screen.getByText('Backlinks')).toBeInTheDocument()
expect(screen.getByText('Referrer Note')).toBeInTheDocument()
})
@@ -204,10 +201,8 @@ This is a test note with some words to count.
entries={[mockEntry, { ...referrerEntry, outgoingLinks: [] }]}
/>
)
// Initially no backlinks — section is hidden entirely
expect(screen.queryByTestId('backlinks-toggle')).not.toBeInTheDocument()
expect(screen.queryByText('Backlinks')).not.toBeInTheDocument()
// Rerender with updated outgoingLinks (simulates adding [[Test Project]] to referrer)
rerender(
<Inspector
{...defaultProps}
@@ -216,8 +211,7 @@ This is a test note with some words to count.
entries={[mockEntry, { ...referrerEntry, outgoingLinks: ['Test Project'] }]}
/>
)
expect(screen.getByText('Backlinks (1)')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('backlinks-toggle'))
expect(screen.getByText('Backlinks')).toBeInTheDocument()
expect(screen.getByText('Referrer Note')).toBeInTheDocument()
})
@@ -230,7 +224,7 @@ This is a test note with some words to count.
entries={[mockEntry]}
/>
)
expect(screen.queryByTestId('backlinks-toggle')).not.toBeInTheDocument()
expect(screen.queryByText('Backlinks')).not.toBeInTheDocument()
})
it('navigates when a backlink is clicked', () => {
@@ -244,7 +238,6 @@ This is a test note with some words to count.
onNavigate={onNavigate}
/>
)
fireEvent.click(screen.getByTestId('backlinks-toggle'))
fireEvent.click(screen.getByText('Referrer Note'))
expect(onNavigate).toHaveBeenCalledWith('Referrer Note')
})
@@ -260,12 +253,11 @@ This is a test note with some words to count.
)
expect(screen.getByText('History')).toBeInTheDocument()
expect(screen.getByText('a1b2c3d')).toBeInTheDocument()
expect(screen.getByText('Update test with latest changes')).toBeInTheDocument()
expect(screen.getByText('e4f5g6h')).toBeInTheDocument()
expect(screen.getByText('i7j8k9l')).toBeInTheDocument()
})
it('renders commit hashes as clickable buttons', () => {
it('renders commit entries as clickable buttons', () => {
const onViewCommitDiff = vi.fn()
render(
<Inspector
@@ -277,25 +269,13 @@ This is a test note with some words to count.
/>
)
const hashBtn = screen.getByText('a1b2c3d')
expect(hashBtn.tagName).toBe('BUTTON')
hashBtn.click()
const button = hashBtn.closest('button')!
expect(button.tagName).toBe('BUTTON')
button.click()
expect(onViewCommitDiff).toHaveBeenCalledWith('a1b2c3d4e5f6a7b8')
})
it('shows author name in commit rows', () => {
render(
<Inspector
{...defaultProps}
entry={mockEntry}
content={mockContent}
gitHistory={mockGitHistory}
/>
)
const authors = screen.getAllByText('Luca Rossi')
expect(authors.length).toBeGreaterThan(0)
})
it('shows "No revision history" when no commits', () => {
it('hides history section when no commits', () => {
render(
<Inspector
{...defaultProps}
@@ -304,7 +284,7 @@ This is a test note with some words to count.
gitHistory={[]}
/>
)
expect(screen.getByText('No revision history')).toBeInTheDocument()
expect(screen.queryByText('History')).not.toBeInTheDocument()
})
it('shows separate Info section with read-only metadata', () => {

View File

@@ -3,6 +3,7 @@ import { useDragRegion } from '../hooks/useDragRegion'
import type { VaultEntry, GitCommit } from '../types'
import { cn } from '@/lib/utils'
import { SlidersHorizontal, X } from '@phosphor-icons/react'
import { Separator } from './ui/separator'
import { parseFrontmatter } from '../utils/frontmatter'
import { DynamicPropertiesPanel } from './DynamicPropertiesPanel'
import { DynamicRelationshipsPanel, BacklinksPanel, ReferencedByPanel, GitHistoryPanel, InstancesPanel } from './InspectorPanels'
@@ -162,7 +163,9 @@ export function Inspector({
/>
<InstancesPanel entry={entry} entries={entries} typeEntryMap={typeEntryMap} onNavigate={onNavigate} />
<ReferencedByPanel items={referencedBy} typeEntryMap={typeEntryMap} onNavigate={onNavigate} />
<BacklinksPanel backlinks={backlinks} typeEntryMap={typeEntryMap} onNavigate={onNavigate} />
{backlinks.length > 0 && <Separator />}
<BacklinksPanel backlinks={backlinks} onNavigate={onNavigate} />
{gitHistory.length > 0 && <Separator />}
<GitHistoryPanel commits={gitHistory} onViewCommitDiff={onViewCommitDiff} />
</>
) : (

View File

@@ -632,7 +632,7 @@ describe('BacklinksPanel', () => {
})
it('renders nothing when empty', () => {
const { container } = render(<BacklinksPanel typeEntryMap={{}} backlinks={[]} onNavigate={onNavigate} />)
const { container } = render(<BacklinksPanel backlinks={[]} onNavigate={onNavigate} />)
expect(container.innerHTML).toBe('')
})
@@ -641,23 +641,16 @@ describe('BacklinksPanel', () => {
{ entry: makeEntry({ title: 'Another Note', isA: 'Project', path: '/vault/project/another.md' }), context: null },
]
it('renders collapsed by default with count badge', () => {
render(<BacklinksPanel typeEntryMap={{}} backlinks={twoBacklinks} onNavigate={onNavigate} />)
expect(screen.getByText('Backlinks (2)')).toBeInTheDocument()
expect(screen.queryByText('Referencing Note')).not.toBeInTheDocument()
})
it('expands to show backlink entries when toggle clicked', () => {
render(<BacklinksPanel typeEntryMap={{}} backlinks={twoBacklinks} onNavigate={onNavigate} />)
fireEvent.click(screen.getByTestId('backlinks-toggle'))
it('renders header and all backlinks immediately (no collapse)', () => {
render(<BacklinksPanel backlinks={twoBacklinks} onNavigate={onNavigate} />)
expect(screen.getByText('Backlinks')).toBeInTheDocument()
expect(screen.getByText('Referencing Note')).toBeInTheDocument()
expect(screen.getByText('Another Note')).toBeInTheDocument()
})
it('navigates when clicking backlink', () => {
const backlinks = [{ entry: makeEntry({ title: 'Reference' }), context: null }]
render(<BacklinksPanel typeEntryMap={{}} backlinks={backlinks} onNavigate={onNavigate} />)
fireEvent.click(screen.getByTestId('backlinks-toggle'))
render(<BacklinksPanel backlinks={backlinks} onNavigate={onNavigate} />)
fireEvent.click(screen.getByText('Reference'))
expect(onNavigate).toHaveBeenCalledWith('Reference')
})
@@ -666,39 +659,26 @@ describe('BacklinksPanel', () => {
const backlinks = [
{ entry: makeEntry({ title: 'Referencing Note' }), context: 'This references [[My Note]] in context.' },
]
render(<BacklinksPanel typeEntryMap={{}} backlinks={backlinks} onNavigate={onNavigate} />)
fireEvent.click(screen.getByTestId('backlinks-toggle'))
render(<BacklinksPanel backlinks={backlinks} onNavigate={onNavigate} />)
expect(screen.getByText('This references [[My Note]] in context.')).toBeInTheDocument()
})
it('collapses when toggle clicked twice', () => {
const backlinks = [{ entry: makeEntry({ title: 'Note A' }), context: null }]
render(<BacklinksPanel typeEntryMap={{}} backlinks={backlinks} onNavigate={onNavigate} />)
fireEvent.click(screen.getByTestId('backlinks-toggle'))
expect(screen.getByText('Note A')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('backlinks-toggle'))
expect(screen.queryByText('Note A')).not.toBeInTheDocument()
})
it('shows emoji icon before backlink title when entry has an emoji', () => {
const backlinks = [{
entry: makeEntry({ title: 'Starred Note', icon: '⭐' }),
context: null,
}]
render(<BacklinksPanel typeEntryMap={{}} backlinks={backlinks} onNavigate={onNavigate} />)
fireEvent.click(screen.getByTestId('backlinks-toggle'))
render(<BacklinksPanel backlinks={backlinks} onNavigate={onNavigate} />)
expect(screen.getByText('⭐')).toBeInTheDocument()
expect(screen.getByText('Starred Note')).toBeInTheDocument()
})
it('does not show emoji when backlink entry has no icon', () => {
const backlinks = [{ entry: makeEntry({ title: 'Plain Note' }), context: null }]
render(<BacklinksPanel typeEntryMap={{}} backlinks={backlinks} onNavigate={onNavigate} />)
fireEvent.click(screen.getByTestId('backlinks-toggle'))
render(<BacklinksPanel backlinks={backlinks} onNavigate={onNavigate} />)
expect(screen.getByText('Plain Note')).toBeInTheDocument()
const btn = screen.getByText('Plain Note').closest('button')
const spans = btn?.querySelectorAll('span.shrink-0')
// No emoji span should exist (only possible shrink-0 spans are trash icon, not emoji)
const emojiSpans = Array.from(spans ?? []).filter(s => /[\p{Emoji_Presentation}]/u.test(s.textContent ?? ''))
expect(emojiSpans).toHaveLength(0)
})
@@ -764,12 +744,12 @@ describe('GitHistoryPanel', () => {
vi.clearAllMocks()
})
it('shows "No revision history" when empty', () => {
render(<GitHistoryPanel commits={[]} />)
expect(screen.getByText('No revision history')).toBeInTheDocument()
it('renders nothing when empty', () => {
const { container } = render(<GitHistoryPanel commits={[]} />)
expect(container.innerHTML).toBe('')
})
it('renders commit entries', () => {
it('renders commit entries with hash and message', () => {
const commits: GitCommit[] = [
{ hash: 'abc1234567890', shortHash: 'abc1234', message: 'Initial commit', author: 'luca', date: Math.floor(Date.now() / 1000) - 3600 },
{ hash: 'def4567890123', shortHash: 'def4567', message: 'Fix bug', author: 'jane', date: Math.floor(Date.now() / 1000) - 86400 * 2 },
@@ -777,13 +757,9 @@ describe('GitHistoryPanel', () => {
render(<GitHistoryPanel commits={commits} onViewCommitDiff={onViewCommitDiff} />)
expect(screen.getByText('abc1234')).toBeInTheDocument()
expect(screen.getByText('def4567')).toBeInTheDocument()
expect(screen.getByText('Initial commit')).toBeInTheDocument()
expect(screen.getByText('Fix bug')).toBeInTheDocument()
expect(screen.getByText('luca')).toBeInTheDocument()
expect(screen.getByText('jane')).toBeInTheDocument()
})
it('calls onViewCommitDiff when clicking commit hash', () => {
it('calls onViewCommitDiff when clicking commit entry', () => {
const commits: GitCommit[] = [
{ hash: 'abc1234567890', shortHash: 'abc1234', message: 'test', author: '', date: Math.floor(Date.now() / 1000) },
]

View File

@@ -1,7 +1,5 @@
import { useState } from 'react'
import type { VaultEntry } from '../../types'
import { CaretRight, Trash } from '@phosphor-icons/react'
import { getTypeColor } from '../../utils/typeColors'
import { ArrowUpRight, Trash } from '@phosphor-icons/react'
import { isEmoji } from '../../utils/emoji'
import { entryStatusTitle } from './shared'
import { StatusSuffix } from './LinkButton'
@@ -11,23 +9,21 @@ export interface BacklinkItem {
context: string | null
}
function BacklinkEntry({ entry, context, typeEntryMap, onNavigate }: {
function BacklinkEntry({ entry, context, onNavigate }: {
entry: VaultEntry
context: string | null
typeEntryMap: Record<string, VaultEntry>
onNavigate: (target: string) => void
}) {
const te = typeEntryMap[entry.isA ?? '']
const isDimmed = entry.archived || entry.trashed
return (
<button
className="flex w-full cursor-pointer flex-col items-start gap-0.5 border-none bg-transparent p-0 text-left hover:opacity-80"
className="flex w-full cursor-pointer flex-col items-start gap-0.5 border-none bg-transparent p-0 text-left hover:underline"
onClick={() => onNavigate(entry.title)}
title={entryStatusTitle(entry)}
>
<span
className="flex items-center gap-1 text-xs font-medium"
style={{ color: isDimmed ? 'var(--muted-foreground)' : getTypeColor(entry.isA, te?.color) }}
className="flex items-center gap-1 text-xs text-primary"
style={isDimmed ? { color: 'var(--muted-foreground)' } : undefined}
>
{entry.trashed && <Trash size={12} className="shrink-0" />}
{entry.icon && isEmoji(entry.icon) && <span className="shrink-0">{entry.icon}</span>}
@@ -43,42 +39,28 @@ function BacklinkEntry({ entry, context, typeEntryMap, onNavigate }: {
)
}
export function BacklinksPanel({ backlinks, typeEntryMap, onNavigate }: {
export function BacklinksPanel({ backlinks, onNavigate }: {
backlinks: BacklinkItem[]
typeEntryMap: Record<string, VaultEntry>
onNavigate: (target: string) => void
}) {
const [expanded, setExpanded] = useState(false)
if (backlinks.length === 0) return null
return (
<div>
<button
className="font-mono-overline mb-2 flex w-full cursor-pointer items-center gap-1 border-none bg-transparent p-0 text-left text-muted-foreground hover:text-foreground"
onClick={() => setExpanded((v) => !v)}
data-testid="backlinks-toggle"
>
<CaretRight
size={12}
className="shrink-0 transition-transform"
style={{ transform: expanded ? 'rotate(90deg)' : undefined }}
/>
Backlinks ({backlinks.length})
</button>
{expanded && (
<div className="flex flex-col gap-1.5" data-testid="backlinks-list">
{backlinks.map(({ entry, context }) => (
<BacklinkEntry
key={entry.path}
entry={entry}
context={context}
typeEntryMap={typeEntryMap}
onNavigate={onNavigate}
/>
))}
</div>
)}
<h4 className="font-mono-overline mb-2 flex items-center gap-1 text-muted-foreground">
<ArrowUpRight size={12} className="shrink-0" />
Backlinks
</h4>
<div className="flex flex-col gap-1.5" data-testid="backlinks-list">
{backlinks.map(({ entry, context }) => (
<BacklinkEntry
key={entry.path}
entry={entry}
context={context}
onNavigate={onNavigate}
/>
))}
</div>
</div>
)
}

View File

@@ -1,3 +1,4 @@
import { ArrowCounterClockwise } from '@phosphor-icons/react'
import type { GitCommit } from '../../types'
function formatRelativeDate(timestamp: number): string {
@@ -11,26 +12,32 @@ function formatRelativeDate(timestamp: number): string {
}
export function GitHistoryPanel({ commits, onViewCommitDiff }: { commits: GitCommit[]; onViewCommitDiff?: (commitHash: string) => void }) {
if (commits.length === 0) return null
return (
<div>
<h4 className="font-mono-overline mb-2 text-muted-foreground">History</h4>
{commits.length === 0
? <p className="m-0 text-[13px] text-muted-foreground">No revision history</p>
: (
<div className="flex flex-col gap-2.5">
{commits.map((c) => (
<div key={c.hash} style={{ borderLeft: '2px solid var(--border)', paddingLeft: 10 }}>
<div className="mb-0.5 flex items-center justify-between">
<button className="border-none bg-transparent p-0 font-mono text-primary cursor-pointer hover:underline" style={{ fontSize: 11 }} onClick={() => onViewCommitDiff?.(c.hash)} title={`View diff for ${c.shortHash}`}>{c.shortHash}</button>
<span className="text-muted-foreground" style={{ fontSize: 10 }}>{formatRelativeDate(c.date)}</span>
</div>
<div className="truncate text-xs text-secondary-foreground">{c.message}</div>
{c.author && <div className="truncate text-muted-foreground" style={{ fontSize: 10, marginTop: 1 }}>{c.author}</div>}
</div>
))}
<h4 className="font-mono-overline mb-2 flex items-center gap-1 text-muted-foreground">
<ArrowCounterClockwise size={12} className="shrink-0" />
History
</h4>
<div className="flex flex-col gap-2.5">
{commits.map((c) => (
<div key={c.hash} style={{ borderLeft: '2px solid var(--border)', paddingLeft: 10 }}>
<button
className="mb-0.5 w-full cursor-pointer truncate border-none bg-transparent p-0 text-left text-xs text-primary hover:underline"
onClick={() => onViewCommitDiff?.(c.hash)}
title={`View diff for ${c.shortHash}`}
>
<span className="font-mono" style={{ fontSize: 11 }}>{c.shortHash}</span>
{' · '}
{c.message}
</button>
<div className="text-muted-foreground" style={{ fontSize: 10 }}>
{formatRelativeDate(c.date)}
</div>
</div>
)
}
))}
</div>
</div>
)
}