diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index c5f172e4..4dacb765 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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. diff --git a/src/components/Inspector.tsx b/src/components/Inspector.tsx index 8c2442c3..5fc3fe1b 100644 --- a/src/components/Inspector.tsx +++ b/src/components/Inspector.tsx @@ -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} /> + diff --git a/src/components/InspectorPanels.test.tsx b/src/components/InspectorPanels.test.tsx index f23e04bd..185e681c 100644 --- a/src/components/InspectorPanels.test.tsx +++ b/src/components/InspectorPanels.test.tsx @@ -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 = { 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( + + ) + expect(container.innerHTML).toBe('') + }) + + it('renders nothing when Type has zero instances', () => { + const { container } = render( + + ) + 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( + + ) + 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( + + ) + 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( + + ) + 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( + + ) + 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( + + ) + 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( + + ) + expect(container.innerHTML).toBe('') + }) +}) diff --git a/src/components/InspectorPanels.tsx b/src/components/InspectorPanels.tsx index 439b4d5d..38d4c8fd 100644 --- a/src/components/InspectorPanels.tsx +++ b/src/components/InspectorPanels.tsx @@ -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' diff --git a/src/components/inspector/InstancesPanel.tsx b/src/components/inspector/InstancesPanel.tsx new file mode 100644 index 00000000..9358e297 --- /dev/null +++ b/src/components/inspector/InstancesPanel.tsx @@ -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 + 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 ( +
+ + Instances ({total}) + +
+ {displayed.map((e) => { + const te = typeEntryMap[e.isA ?? ''] + return ( + onNavigate(e.title)} + title={entryStatusTitle(e)} + TypeIcon={getTypeIcon(e.isA, te?.icon)} + /> + ) + })} +
+ {total > MAX_DISPLAY && ( + + showing {MAX_DISPLAY} of {total} + + )} +
+ ) +} diff --git a/tests/smoke/show-type-instances-inspector.spec.ts b/tests/smoke/show-type-instances-inspector.spec.ts new file mode 100644 index 00000000..6b6cc214 --- /dev/null +++ b/tests/smoke/show-type-instances-inspector.spec.ts @@ -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() + }) +})