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:
@@ -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.
|
||||
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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('')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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'
|
||||
|
||||
57
src/components/inspector/InstancesPanel.tsx
Normal file
57
src/components/inspector/InstancesPanel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
47
tests/smoke/show-type-instances-inspector.spec.ts
Normal file
47
tests/smoke/show-type-instances-inspector.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user