feat: move Info section below Backlinks in Inspector, add Phosphor icon
Reorders Inspector sections to: Properties → Relationships → Backlinks → Info → History. Extracts NoteInfoPanel into its own component with Info icon matching other section headers. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -93,17 +93,6 @@ describe('DynamicPropertiesPanel', () => {
|
||||
expect(screen.getByText('Note')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders word count', () => {
|
||||
render(
|
||||
<DynamicPropertiesPanel
|
||||
entry={makeEntry()}
|
||||
content="---\ntitle: Test\n---\nOne two three four"
|
||||
frontmatter={{}}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText('Words')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders status as colored pill', () => {
|
||||
render(
|
||||
<DynamicPropertiesPanel
|
||||
@@ -391,17 +380,6 @@ describe('DynamicPropertiesPanel', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('renders modified date', () => {
|
||||
render(
|
||||
<DynamicPropertiesPanel
|
||||
entry={makeEntry({ modifiedAt: 1700000000 })}
|
||||
content=""
|
||||
frontmatter={{}}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText('Modified')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('opens status dropdown on click and selects a status', () => {
|
||||
render(
|
||||
<DynamicPropertiesPanel
|
||||
@@ -536,69 +514,6 @@ describe('DynamicPropertiesPanel', () => {
|
||||
})
|
||||
|
||||
describe('editable vs read-only distinction', () => {
|
||||
it('renders Info section header', () => {
|
||||
render(
|
||||
<DynamicPropertiesPanel
|
||||
entry={makeEntry()}
|
||||
content=""
|
||||
frontmatter={{}}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText('Info')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders Modified and Words in read-only Info section', () => {
|
||||
render(
|
||||
<DynamicPropertiesPanel
|
||||
entry={makeEntry({ modifiedAt: 1700000000 })}
|
||||
content="---\ntitle: Test\n---\nOne two three"
|
||||
frontmatter={{}}
|
||||
/>
|
||||
)
|
||||
const readOnlyRows = screen.getAllByTestId('readonly-property')
|
||||
const labels = readOnlyRows.map(row => row.querySelector('span')?.textContent)
|
||||
expect(labels).toContain('Modified')
|
||||
expect(labels).toContain('Words')
|
||||
})
|
||||
|
||||
it('renders Created date in Info section', () => {
|
||||
render(
|
||||
<DynamicPropertiesPanel
|
||||
entry={makeEntry({ createdAt: 1700000000 })}
|
||||
content=""
|
||||
frontmatter={{}}
|
||||
/>
|
||||
)
|
||||
const readOnlyRows = screen.getAllByTestId('readonly-property')
|
||||
const labels = readOnlyRows.map(row => row.querySelector('span')?.textContent)
|
||||
expect(labels).toContain('Created')
|
||||
})
|
||||
|
||||
it('renders file size in Info section', () => {
|
||||
render(
|
||||
<DynamicPropertiesPanel
|
||||
entry={makeEntry({ fileSize: 4300 })}
|
||||
content=""
|
||||
frontmatter={{}}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText('Size')).toBeInTheDocument()
|
||||
expect(screen.getByText('4.2 KB')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows em dash for null timestamps in Info section', () => {
|
||||
render(
|
||||
<DynamicPropertiesPanel
|
||||
entry={makeEntry({ modifiedAt: null, createdAt: null })}
|
||||
content=""
|
||||
frontmatter={{}}
|
||||
/>
|
||||
)
|
||||
// Two em dashes for null Modified and Created
|
||||
const dashes = screen.getAllByText('\u2014')
|
||||
expect(dashes.length).toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
|
||||
it('editable properties have hover styling via data-testid', () => {
|
||||
render(
|
||||
<DynamicPropertiesPanel
|
||||
@@ -616,52 +531,6 @@ describe('DynamicPropertiesPanel', () => {
|
||||
expect(row.className).toContain('hover:bg-muted')
|
||||
})
|
||||
})
|
||||
|
||||
it('read-only rows do not have hover styling', () => {
|
||||
render(
|
||||
<DynamicPropertiesPanel
|
||||
entry={makeEntry()}
|
||||
content=""
|
||||
frontmatter={{}}
|
||||
/>
|
||||
)
|
||||
const readOnlyRows = screen.getAllByTestId('readonly-property')
|
||||
readOnlyRows.forEach(row => {
|
||||
expect(row.className).not.toContain('hover:bg-muted')
|
||||
})
|
||||
})
|
||||
|
||||
it('formats file sizes correctly', () => {
|
||||
// Small file — bytes
|
||||
const { rerender } = render(
|
||||
<DynamicPropertiesPanel
|
||||
entry={makeEntry({ fileSize: 500 })}
|
||||
content=""
|
||||
frontmatter={{}}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText('500 B')).toBeInTheDocument()
|
||||
|
||||
// KB range
|
||||
rerender(
|
||||
<DynamicPropertiesPanel
|
||||
entry={makeEntry({ fileSize: 2048 })}
|
||||
content=""
|
||||
frontmatter={{}}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText('2.0 KB')).toBeInTheDocument()
|
||||
|
||||
// MB range
|
||||
rerender(
|
||||
<DynamicPropertiesPanel
|
||||
entry={makeEntry({ fileSize: 1048576 })}
|
||||
content=""
|
||||
frontmatter={{}}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText('1.0 MB')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('property row 50/50 layout', () => {
|
||||
@@ -680,21 +549,6 @@ describe('DynamicPropertiesPanel', () => {
|
||||
expect(row.className).toContain('grid-cols-2')
|
||||
})
|
||||
})
|
||||
|
||||
it('uses CSS grid with two equal columns on read-only rows', () => {
|
||||
render(
|
||||
<DynamicPropertiesPanel
|
||||
entry={makeEntry()}
|
||||
content=""
|
||||
frontmatter={{}}
|
||||
/>
|
||||
)
|
||||
const readOnlyRows = screen.getAllByTestId('readonly-property')
|
||||
readOnlyRows.forEach(row => {
|
||||
expect(row.className).toContain('grid')
|
||||
expect(row.className).toContain('grid-cols-2')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('URL property rendering', () => {
|
||||
|
||||
@@ -6,7 +6,6 @@ import { getEffectiveDisplayMode, detectPropertyType } from '../utils/propertyTy
|
||||
import { SmartPropertyValueCell, DisplayModeSelector } from './PropertyValueCells'
|
||||
import { TypeSelector } from './TypeSelector'
|
||||
import { AddPropertyForm } from './AddPropertyForm'
|
||||
import { countWords } from '../utils/wikilinks'
|
||||
import type { PropertyDisplayMode } from '../utils/propertyTypes'
|
||||
|
||||
function toSentenceCase(key: string): string {
|
||||
@@ -22,20 +21,6 @@ export function containsWikilinks(value: FrontmatterValue): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
function formatDate(timestamp: number | null): string {
|
||||
if (!timestamp) return '\u2014'
|
||||
const d = new Date(timestamp * 1000)
|
||||
return d.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' })
|
||||
}
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
const kb = bytes / 1024
|
||||
if (kb < 1024) return `${kb.toFixed(1)} KB`
|
||||
const mb = kb / 1024
|
||||
return `${mb.toFixed(1)} MB`
|
||||
}
|
||||
|
||||
function PropertyRow({ propKey, value, editingKey, displayMode, autoMode, vaultStatuses, vaultTags, onStartEdit, onSave, onSaveList, onUpdate, onDelete, onDisplayModeChange }: {
|
||||
propKey: string; value: FrontmatterValue; editingKey: string | null
|
||||
displayMode: PropertyDisplayMode; autoMode: PropertyDisplayMode
|
||||
@@ -68,15 +53,6 @@ function PropertyRow({ propKey, value, editingKey, displayMode, autoMode, vaultS
|
||||
)
|
||||
}
|
||||
|
||||
function InfoRow({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="grid min-w-0 grid-cols-2 items-center gap-2 px-1.5" data-testid="readonly-property">
|
||||
<span className="min-w-0 truncate text-[12px] text-muted-foreground">{label}</span>
|
||||
<span className="min-w-0 truncate text-right text-[12px]" style={{ color: 'var(--text-muted)' }}>{value}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AddPropertyButton({ onClick, disabled }: { onClick: () => void; disabled: boolean }) {
|
||||
return (
|
||||
<button
|
||||
@@ -89,26 +65,12 @@ function AddPropertyButton({ onClick, disabled }: { onClick: () => void; disable
|
||||
)
|
||||
}
|
||||
|
||||
function NoteInfoSection({ entry, wordCount }: { entry: VaultEntry; wordCount: number }) {
|
||||
return (
|
||||
<div className="border-t border-border pt-3">
|
||||
<h4 className="font-mono-overline mb-2 text-muted-foreground">Info</h4>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<InfoRow label="Modified" value={formatDate(entry.modifiedAt)} />
|
||||
<InfoRow label="Created" value={formatDate(entry.createdAt)} />
|
||||
<InfoRow label="Words" value={String(wordCount)} />
|
||||
<InfoRow label="Size" value={formatFileSize(entry.fileSize)} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function DynamicPropertiesPanel({
|
||||
entry, content, frontmatter, entries,
|
||||
entry, frontmatter, entries,
|
||||
onUpdateProperty, onDeleteProperty, onAddProperty, onNavigate,
|
||||
}: {
|
||||
entry: VaultEntry
|
||||
content: string | null
|
||||
content?: string | null
|
||||
frontmatter: ParsedFrontmatter
|
||||
entries?: VaultEntry[]
|
||||
onUpdateProperty?: (key: string, value: FrontmatterValue) => void
|
||||
@@ -122,8 +84,6 @@ export function DynamicPropertiesPanel({
|
||||
handleSaveValue, handleSaveList, handleAdd, handleDisplayModeChange,
|
||||
} = usePropertyPanelState({ entries, entryIsA: entry.isA, frontmatter, onUpdateProperty, onDeleteProperty, onAddProperty })
|
||||
|
||||
const wordCount = countWords(content ?? '')
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
@@ -145,7 +105,6 @@ export function DynamicPropertiesPanel({
|
||||
? <AddPropertyForm onAdd={handleAdd} onCancel={() => setShowAddDialog(false)} vaultStatuses={vaultStatuses} />
|
||||
: <AddPropertyButton onClick={() => setShowAddDialog(true)} disabled={!onAddProperty} />
|
||||
}
|
||||
<NoteInfoSection entry={entry} wordCount={wordCount} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { SlidersHorizontal, X, Sparkle, WarningCircle, PencilSimple } from '@pho
|
||||
import { Separator } from './ui/separator'
|
||||
import { parseFrontmatter, detectFrontmatterState } from '../utils/frontmatter'
|
||||
import { DynamicPropertiesPanel } from './DynamicPropertiesPanel'
|
||||
import { DynamicRelationshipsPanel, BacklinksPanel, ReferencedByPanel, GitHistoryPanel, InstancesPanel } from './InspectorPanels'
|
||||
import { DynamicRelationshipsPanel, BacklinksPanel, ReferencedByPanel, GitHistoryPanel, InstancesPanel, NoteInfoPanel } from './InspectorPanels'
|
||||
import { wikilinkTarget } from '../utils/wikilink'
|
||||
import type { ReferencedByItem, BacklinkItem } from './InspectorPanels'
|
||||
|
||||
@@ -189,7 +189,7 @@ export function Inspector({
|
||||
{fmState === 'valid' ? (
|
||||
<>
|
||||
<DynamicPropertiesPanel
|
||||
entry={entry} content={content} frontmatter={frontmatter}
|
||||
entry={entry} frontmatter={frontmatter}
|
||||
entries={entries}
|
||||
onUpdateProperty={onUpdateFrontmatter ? handleUpdateProperty : undefined}
|
||||
onDeleteProperty={onDeleteProperty ? handleDeleteProperty : undefined}
|
||||
@@ -213,6 +213,8 @@ export function Inspector({
|
||||
)}
|
||||
{backlinks.length > 0 && <Separator />}
|
||||
<BacklinksPanel backlinks={backlinks} onNavigate={onNavigate} />
|
||||
<Separator />
|
||||
<NoteInfoPanel entry={entry} content={content} />
|
||||
{gitHistory.length > 0 && <Separator />}
|
||||
<GitHistoryPanel commits={gitHistory} onViewCommitDiff={onViewCommitDiff} />
|
||||
</>
|
||||
|
||||
@@ -5,3 +5,4 @@ export { ReferencedByPanel } from './inspector/ReferencedByPanel'
|
||||
export type { ReferencedByItem } from './inspector/ReferencedByPanel'
|
||||
export { GitHistoryPanel } from './inspector/GitHistoryPanel'
|
||||
export { InstancesPanel } from './inspector/InstancesPanel'
|
||||
export { NoteInfoPanel } from './inspector/NoteInfoPanel'
|
||||
|
||||
81
src/components/inspector/NoteInfoPanel.test.tsx
Normal file
81
src/components/inspector/NoteInfoPanel.test.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { NoteInfoPanel } from './NoteInfoPanel'
|
||||
import type { VaultEntry } from '../../types'
|
||||
|
||||
function makeEntry(overrides: Partial<VaultEntry> = {}): VaultEntry {
|
||||
return {
|
||||
path: '/vault/test.md', filename: 'test.md', title: 'Test', isA: 'Note',
|
||||
aliases: [], outgoingLinks: [], relationships: {}, tags: [],
|
||||
modifiedAt: 1700000000, createdAt: 1700000000, fileSize: 1024,
|
||||
icon: null, color: null, archived: false, trashed: false, favorite: false,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('NoteInfoPanel', () => {
|
||||
it('renders Info section header with icon', () => {
|
||||
render(<NoteInfoPanel entry={makeEntry()} content="" />)
|
||||
expect(screen.getByText('Info')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders Modified and Words in read-only Info section', () => {
|
||||
render(<NoteInfoPanel entry={makeEntry({ modifiedAt: 1700000000 })} content="---\ntitle: Test\n---\nOne two three" />)
|
||||
const readOnlyRows = screen.getAllByTestId('readonly-property')
|
||||
const labels = readOnlyRows.map(row => row.querySelector('span')?.textContent)
|
||||
expect(labels).toContain('Modified')
|
||||
expect(labels).toContain('Words')
|
||||
})
|
||||
|
||||
it('renders Created date', () => {
|
||||
render(<NoteInfoPanel entry={makeEntry({ createdAt: 1700000000 })} content="" />)
|
||||
const readOnlyRows = screen.getAllByTestId('readonly-property')
|
||||
const labels = readOnlyRows.map(row => row.querySelector('span')?.textContent)
|
||||
expect(labels).toContain('Created')
|
||||
})
|
||||
|
||||
it('renders file size', () => {
|
||||
render(<NoteInfoPanel entry={makeEntry({ fileSize: 4300 })} content="" />)
|
||||
expect(screen.getByText('Size')).toBeInTheDocument()
|
||||
expect(screen.getByText('4.2 KB')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows em dash for null timestamps', () => {
|
||||
render(<NoteInfoPanel entry={makeEntry({ modifiedAt: null, createdAt: null })} content="" />)
|
||||
const dashes = screen.getAllByText('\u2014')
|
||||
expect(dashes.length).toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
|
||||
it('read-only rows do not have hover styling', () => {
|
||||
render(<NoteInfoPanel entry={makeEntry()} content="" />)
|
||||
const readOnlyRows = screen.getAllByTestId('readonly-property')
|
||||
readOnlyRows.forEach(row => {
|
||||
expect(row.className).not.toContain('hover:bg-muted')
|
||||
})
|
||||
})
|
||||
|
||||
it('formats file sizes correctly', () => {
|
||||
const { rerender } = render(<NoteInfoPanel entry={makeEntry({ fileSize: 500 })} content="" />)
|
||||
expect(screen.getByText('500 B')).toBeInTheDocument()
|
||||
|
||||
rerender(<NoteInfoPanel entry={makeEntry({ fileSize: 2048 })} content="" />)
|
||||
expect(screen.getByText('2.0 KB')).toBeInTheDocument()
|
||||
|
||||
rerender(<NoteInfoPanel entry={makeEntry({ fileSize: 1048576 })} content="" />)
|
||||
expect(screen.getByText('1.0 MB')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('uses CSS grid with two equal columns on read-only rows', () => {
|
||||
render(<NoteInfoPanel entry={makeEntry()} content="" />)
|
||||
const readOnlyRows = screen.getAllByTestId('readonly-property')
|
||||
readOnlyRows.forEach(row => {
|
||||
expect(row.className).toContain('grid')
|
||||
expect(row.className).toContain('grid-cols-2')
|
||||
})
|
||||
})
|
||||
|
||||
it('renders word count from content', () => {
|
||||
render(<NoteInfoPanel entry={makeEntry()} content="---\ntitle: Test\n---\nOne two three four" />)
|
||||
expect(screen.getByText('Words')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
44
src/components/inspector/NoteInfoPanel.tsx
Normal file
44
src/components/inspector/NoteInfoPanel.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import type { VaultEntry } from '../../types'
|
||||
import { Info } from '@phosphor-icons/react'
|
||||
import { countWords } from '../../utils/wikilinks'
|
||||
|
||||
function formatDate(timestamp: number | null): string {
|
||||
if (!timestamp) return '\u2014'
|
||||
const d = new Date(timestamp * 1000)
|
||||
return d.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' })
|
||||
}
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
const kb = bytes / 1024
|
||||
if (kb < 1024) return `${kb.toFixed(1)} KB`
|
||||
const mb = kb / 1024
|
||||
return `${mb.toFixed(1)} MB`
|
||||
}
|
||||
|
||||
function InfoRow({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="grid min-w-0 grid-cols-2 items-center gap-2 px-1.5" data-testid="readonly-property">
|
||||
<span className="min-w-0 truncate text-[12px] text-muted-foreground">{label}</span>
|
||||
<span className="min-w-0 truncate text-right text-[12px]" style={{ color: 'var(--text-muted)' }}>{value}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function NoteInfoPanel({ entry, content }: { entry: VaultEntry; content: string | null }) {
|
||||
const wordCount = countWords(content ?? '')
|
||||
return (
|
||||
<div>
|
||||
<h4 className="font-mono-overline mb-2 flex items-center gap-1 text-muted-foreground">
|
||||
<Info size={12} className="shrink-0" />
|
||||
Info
|
||||
</h4>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<InfoRow label="Modified" value={formatDate(entry.modifiedAt)} />
|
||||
<InfoRow label="Created" value={formatDate(entry.createdAt)} />
|
||||
<InfoRow label="Words" value={String(wordCount)} />
|
||||
<InfoRow label="Size" value={formatFileSize(entry.fileSize)} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user