feat: show type instances in inspector Properties panel

When viewing a Type note (e.g. "Project"), the Properties panel now shows
an Instances section listing all notes of that type, sorted by modified_at
descending. Trashed instances are excluded, archived instances are dimmed.
Display capped at 50 with count badge for large collections.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
lucaronin
2026-03-08 18:08:30 +01:00
parent bb42ebbdc1
commit 87090cebc3
6 changed files with 218 additions and 3 deletions

View File

@@ -89,7 +89,7 @@ Laputa is a personal knowledge and life management desktop app. It reads a vault
- **Sidebar** (150-400px, resizable): Top-level filters (All Notes, Favorites, Changes, Pulse) and collapsible type-based section groups. Each type can have a custom icon, color, sort, and visibility set via its type document in `type/`.
- **Note List / Pulse View** (200-500px, resizable): When a section group or filter is selected, shows filtered notes with snippets, modified dates, and status indicators. When Pulse filter is active, shows `PulseView` — a chronological git activity feed grouped by day.
- **Editor** (flex, fills remaining space): Tab bar with modified dots, breadcrumb bar with word count, BlockNote rich text editor with wikilink support. Can toggle to diff view (modified files) or raw CodeMirror view. Decomposed into `Editor` (orchestrator), `EditorContent`, `EditorRightPanel`, `SingleEditorView`, with hooks `useDiffMode`, `useEditorFocus`, `useEditorSave`, `useRawMode`.
- **Inspector / AI Chat / AI Agent** (200-500px or 40px collapsed): Toggles between Inspector (frontmatter, relationships, backlinks, git history), AI Chat panel (API-based), and AI Agent panel (Claude CLI subprocess with tool execution). The Sparkle icon in the breadcrumb bar toggles between them.
- **Inspector / AI Chat / AI Agent** (200-500px or 40px collapsed): Toggles between Inspector (frontmatter, relationships, instances, backlinks, git history), AI Chat panel (API-based), and AI Agent panel (Claude CLI subprocess with tool execution). The Sparkle icon in the breadcrumb bar toggles between them. When viewing a Type note, the Inspector shows an **Instances** section listing all notes of that type (sorted by modified_at desc, capped at 50).
Panels are separated by `ResizeHandle` components that support drag-to-resize.

View File

@@ -5,7 +5,7 @@ import { cn } from '@/lib/utils'
import { SlidersHorizontal, X } from '@phosphor-icons/react'
import { parseFrontmatter } from '../utils/frontmatter'
import { DynamicPropertiesPanel } from './DynamicPropertiesPanel'
import { DynamicRelationshipsPanel, BacklinksPanel, ReferencedByPanel, GitHistoryPanel } from './InspectorPanels'
import { DynamicRelationshipsPanel, BacklinksPanel, ReferencedByPanel, GitHistoryPanel, InstancesPanel } from './InspectorPanels'
import { wikilinkTarget } from '../utils/wikilink'
import { extractBacklinkContext } from '../utils/wikilinks'
import type { ReferencedByItem, BacklinkItem } from './InspectorPanels'
@@ -163,6 +163,7 @@ export function Inspector({
onUpdateProperty={onUpdateFrontmatter ? handleUpdateProperty : undefined}
onDeleteProperty={onDeleteProperty ? handleDeleteProperty : undefined}
/>
<InstancesPanel entry={entry} entries={entries} typeEntryMap={typeEntryMap} onNavigate={onNavigate} />
<ReferencedByPanel items={referencedBy} typeEntryMap={typeEntryMap} onNavigate={onNavigate} />
<BacklinksPanel backlinks={backlinks} typeEntryMap={typeEntryMap} onNavigate={onNavigate} />
<GitHistoryPanel commits={gitHistory} onViewCommitDiff={onViewCommitDiff} />

View File

@@ -1,6 +1,6 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { DynamicRelationshipsPanel, BacklinksPanel, ReferencedByPanel, GitHistoryPanel } from './InspectorPanels'
import { DynamicRelationshipsPanel, BacklinksPanel, ReferencedByPanel, GitHistoryPanel, InstancesPanel } from './InspectorPanels'
import type { ReferencedByItem } from './InspectorPanels'
import type { VaultEntry, GitCommit } from '../types'
@@ -568,3 +568,112 @@ describe('GitHistoryPanel', () => {
expect(screen.getByText('10d ago')).toBeInTheDocument()
})
})
describe('InstancesPanel', () => {
const onNavigate = vi.fn()
const quarterType = makeEntry({
path: '/vault/type/quarter.md', filename: 'quarter.md', title: 'Quarter',
isA: 'Type', color: 'blue', icon: 'calendar',
})
const typeEntryMap: Record<string, VaultEntry> = { Quarter: quarterType }
beforeEach(() => {
vi.clearAllMocks()
})
it('renders nothing when entry is not a Type', () => {
const entry = makeEntry({ title: 'Random Note', isA: 'Note' })
const { container } = render(
<InstancesPanel entry={entry} entries={[]} typeEntryMap={{}} onNavigate={onNavigate} />
)
expect(container.innerHTML).toBe('')
})
it('renders nothing when Type has zero instances', () => {
const { container } = render(
<InstancesPanel entry={quarterType} entries={[]} typeEntryMap={typeEntryMap} onNavigate={onNavigate} />
)
expect(container.innerHTML).toBe('')
})
it('renders instances of a Type sorted by modifiedAt descending', () => {
const instances = [
makeEntry({ path: '/vault/quarter/q1.md', title: 'Q1 2026', isA: 'Quarter', modifiedAt: 1000 }),
makeEntry({ path: '/vault/quarter/q2.md', title: 'Q2 2026', isA: 'Quarter', modifiedAt: 3000 }),
makeEntry({ path: '/vault/quarter/q3.md', title: 'Q3 2026', isA: 'Quarter', modifiedAt: 2000 }),
]
render(
<InstancesPanel entry={quarterType} entries={instances} typeEntryMap={typeEntryMap} onNavigate={onNavigate} />
)
expect(screen.getByText('Instances (3)')).toBeInTheDocument()
const buttons = screen.getAllByRole('button').filter(b => ['Q1 2026', 'Q2 2026', 'Q3 2026'].includes(b.textContent?.replace(/\s*\(.*\)/, '') ?? ''))
// Q2 (3000) should come before Q3 (2000) before Q1 (1000)
expect(buttons[0].textContent).toContain('Q2 2026')
expect(buttons[1].textContent).toContain('Q3 2026')
expect(buttons[2].textContent).toContain('Q1 2026')
})
it('excludes trashed instances', () => {
const instances = [
makeEntry({ path: '/vault/quarter/q1.md', title: 'Q1 2026', isA: 'Quarter', modifiedAt: 2000 }),
makeEntry({ path: '/vault/quarter/q2.md', title: 'Q2 Trashed', isA: 'Quarter', trashed: true, modifiedAt: 3000 }),
]
render(
<InstancesPanel entry={quarterType} entries={instances} typeEntryMap={typeEntryMap} onNavigate={onNavigate} />
)
expect(screen.getByText('Q1 2026')).toBeInTheDocument()
expect(screen.queryByText('Q2 Trashed')).not.toBeInTheDocument()
})
it('dims archived instances', () => {
const instances = [
makeEntry({ path: '/vault/quarter/old.md', title: 'Q4 2024', isA: 'Quarter', archived: true, modifiedAt: 1000 }),
]
render(
<InstancesPanel entry={quarterType} entries={instances} typeEntryMap={typeEntryMap} onNavigate={onNavigate} />
)
expect(screen.getByTitle('Archived')).toBeInTheDocument()
})
it('navigates when clicking an instance', () => {
const instances = [
makeEntry({ path: '/vault/quarter/q1.md', title: 'Q1 2026', isA: 'Quarter', modifiedAt: 1000 }),
]
render(
<InstancesPanel entry={quarterType} entries={instances} typeEntryMap={typeEntryMap} onNavigate={onNavigate} />
)
fireEvent.click(screen.getByText('Q1 2026'))
expect(onNavigate).toHaveBeenCalledWith('Q1 2026')
})
it('caps display at 50 instances and shows count badge', () => {
const instances = Array.from({ length: 80 }, (_, i) =>
makeEntry({
path: `/vault/quarter/q${i}.md`,
title: `Instance ${i}`,
isA: 'Quarter',
modifiedAt: 80 - i,
})
)
render(
<InstancesPanel entry={quarterType} entries={instances} typeEntryMap={typeEntryMap} onNavigate={onNavigate} />
)
expect(screen.getByText('Instances (80)')).toBeInTheDocument()
// Only 50 link buttons rendered
const allButtons = screen.getAllByRole('button')
const instanceButtons = allButtons.filter(b => b.textContent?.startsWith('Instance'))
expect(instanceButtons.length).toBe(50)
expect(screen.getByText('showing 50 of 80')).toBeInTheDocument()
})
it('does not show Instances section for non-Type note even if title matches a type name', () => {
const notAType = makeEntry({ title: 'Quarter', isA: 'Project' })
const instances = [
makeEntry({ path: '/vault/quarter/q1.md', title: 'Q1 2026', isA: 'Quarter', modifiedAt: 1000 }),
]
const { container } = render(
<InstancesPanel entry={notAType} entries={instances} typeEntryMap={typeEntryMap} onNavigate={onNavigate} />
)
expect(container.innerHTML).toBe('')
})
})

View File

@@ -4,3 +4,4 @@ export type { BacklinkItem } from './inspector/BacklinksPanel'
export { ReferencedByPanel } from './inspector/ReferencedByPanel'
export type { ReferencedByItem } from './inspector/ReferencedByPanel'
export { GitHistoryPanel } from './inspector/GitHistoryPanel'
export { InstancesPanel } from './inspector/InstancesPanel'

View File

@@ -0,0 +1,57 @@
import { useMemo } from 'react'
import type { VaultEntry } from '../../types'
import { getTypeColor } from '../../utils/typeColors'
import { getTypeIcon } from '../NoteItem'
import { LinkButton } from './LinkButton'
import { entryStatusTitle } from './shared'
const MAX_DISPLAY = 50
export function InstancesPanel({ entry, entries, typeEntryMap, onNavigate }: {
entry: VaultEntry
entries: VaultEntry[]
typeEntryMap: Record<string, VaultEntry>
onNavigate: (target: string) => void
}) {
const instances = useMemo(() => {
if (entry.isA !== 'Type') return []
return entries
.filter((e) => e.isA === entry.title && !e.trashed)
.sort((a, b) => (b.modifiedAt ?? 0) - (a.modifiedAt ?? 0))
}, [entry, entries])
if (instances.length === 0) return null
const displayed = instances.slice(0, MAX_DISPLAY)
const total = instances.length
return (
<div>
<span className="font-mono-overline mb-1 block text-muted-foreground">
Instances ({total})
</span>
<div className="flex flex-col gap-0.5">
{displayed.map((e) => {
const te = typeEntryMap[e.isA ?? '']
return (
<LinkButton
key={e.path}
label={e.title}
typeColor={getTypeColor(e.isA, te?.color)}
isArchived={e.archived}
isTrashed={false}
onClick={() => onNavigate(e.title)}
title={entryStatusTitle(e)}
TypeIcon={getTypeIcon(e.isA, te?.icon)}
/>
)
})}
</div>
{total > MAX_DISPLAY && (
<span className="mt-1 block text-[11px] text-muted-foreground">
showing {MAX_DISPLAY} of {total}
</span>
)}
</div>
)
}

View File

@@ -0,0 +1,47 @@
import { test, expect } from '@playwright/test'
import { sendShortcut } from './helpers'
async function openNoteViaQuickOpen(page: import('@playwright/test').Page, query: string) {
await page.locator('body').click()
await sendShortcut(page, 'p', ['Control'])
const searchInput = page.locator('input[placeholder="Search notes..."]')
await expect(searchInput).toBeVisible()
await searchInput.fill(query)
await page.waitForTimeout(500)
await page.keyboard.press('Enter')
await page.waitForTimeout(500)
}
test.describe('Type instances in inspector', () => {
test.beforeEach(async ({ page }) => {
await page.setViewportSize({ width: 1600, height: 900 })
await page.goto('/')
await page.waitForLoadState('networkidle')
})
test('shows Instances section when viewing a Type note', async ({ page }) => {
await openNoteViaQuickOpen(page, 'Project')
// The inspector starts open by default. Look for the "Properties" header.
// If it's collapsed, click the SlidersHorizontal icon to expand.
const propertiesHeader = page.getByText('Properties', { exact: true })
if (!(await propertiesHeader.isVisible())) {
// Inspector might be collapsed — click the toggle button
const toggleBtn = page.locator('button').filter({ has: page.locator('svg') }).last()
await toggleBtn.click()
await page.waitForTimeout(300)
}
// The Instances section should be visible with a count
const instancesLabel = page.getByText(/Instances \(\d+\)/)
await expect(instancesLabel).toBeVisible({ timeout: 5000 })
})
test('does not show Instances section for non-Type note', async ({ page }) => {
await openNoteViaQuickOpen(page, 'Build Laputa App')
// No Instances section should appear for a non-Type note
const instancesLabel = page.getByText(/Instances \(\d+\)/)
await expect(instancesLabel).not.toBeVisible()
})
})