Merge pull request #17 from refactoringhq/task/properties-inspector

feat: Properties inspector — editable vs read-only distinction
This commit is contained in:
Luca Rossi
2026-02-23 19:42:03 +01:00
committed by GitHub
6 changed files with 798 additions and 59 deletions

View File

@@ -1,56 +1,34 @@
# Vault from GitHub — Implementation Summary
# Properties Inspector: Editable vs Read-Only Distinction
## What was built
## What was implemented
A "Vault from GitHub" feature that lets users create or clone a vault from a GitHub repository, directly from within the Laputa app.
Split the Properties panel into two visually distinct sections:
## Architecture
### 1. Editable Properties (top section)
- Frontmatter properties the user can modify
- Interactive styling: `hover:bg-muted` background on row hover, cursor pointer, click-to-edit
- Includes: Type badge, Status pill, boolean toggles, array tag pills, text fields
- "Add property" button for user-defined properties
### Rust Backend (`src-tauri/src/github.rs`)
- `github_list_repos` — Paginated GitHub API call to list authenticated user's repos (up to 1000)
- `github_create_repo` — Creates a new GitHub repo via API with auto_init
- `clone_repo` — Clones a repo locally using HTTPS + OAuth token injection, configures remote auth
- Helper functions: `inject_token_into_url`, `configure_remote_auth`
- All three exposed as Tauri commands in `lib.rs`
### 2. Info Section (bottom section, separated by border)
- Read-only derived metadata with "Info" header
- Muted styling: `--text-muted` color for both labels and values
- No hover states, no click interaction
- Fields: Modified date, Created date, Word count, File size (formatted as B/KB/MB)
### Frontend (`src/components/GitHubVaultModal.tsx`)
- Two-tab modal: "Clone Existing" and "Create New"
- Clone tab: searchable repo list with Private/Public badges, auto-fill clone path
- Create tab: repo name, Private/Public toggle, auto-fill clone path
- Loading and cloning progress states
- No-token state redirects to Settings
- Integrated into `App.tsx` via StatusBar vault menu
### Other changes
- Added `is_a` and `Is A` to SKIP_KEYS to prevent duplication (already shown via TypeRow)
- Added `formatFileSize()` helper for human-readable file sizes
- Created `design/properties-inspector.pen` with wireframe frames
- Updated `docs/ABSTRACTIONS.md` with new Inspector section documentation
### Settings Extension
- Added `github_token` field to Settings (Rust + TypeScript)
- GitHub Token input field in SettingsPanel
- Refactored `settings.rs` to use testable internal functions (`get_settings_at`, `save_settings_at`)
## Commits on this branch
1. `design: properties-inspector wireframes` — 2 frames showing editable vs read-only distinction
2. `feat: distinguish editable from read-only properties in inspector` — main implementation + 12 new tests
3. `fix: hide is_a/Is A from editable properties` — prevent TypeRow duplication
4. `docs: update abstractions for properties inspector Info section`
### Mock Data (`src/mock-tauri.ts`)
- 5 realistic mock repos for browser-mode testing
- Mock handlers for all 3 GitHub commands
## Design Decisions
1. **HTTPS+token auth over SSH**: Simpler for users (no SSH key setup), token injected into remote URL
2. **Auto-fill clone path**: `~/Vaults/<repo-name>` — sensible default, editable
3. **Private by default** for new repos — safer default for personal vaults
4. **No OAuth flow**: GitHub token entered manually in Settings — ships faster, no server dependency
5. **Extra vaults tracked in state**: Cloned vaults added to vault switcher dynamically (not persisted across sessions yet)
## Test Coverage
- **Frontend**: 80.79% statements, 83.34% lines (threshold: 70%)
- **Rust**: 86.20% lines (threshold: 85%)
- 12 GitHubVaultModal component tests
- 12 Rust github.rs unit tests
- 9 Rust settings.rs unit tests (refactored for coverage)
- E2E Playwright verification: vault menu, modal tabs, repo list, search filter, repo selection
## Commits (6 total)
1. `design: vault-from-github wireframes`
2. `feat: add Rust backend for GitHub vault operations`
3. `feat: GitHub vault modal with clone/create flows`
4. `test: add GitHubVaultModal component tests`
5. `test: improve Rust test coverage for settings and github modules`
6. `fix: remove unused GithubRepo import in mock-tauri.ts`
## Test coverage
- 469 tests passing (12 new tests for this feature)
- CodeScene quality gates: passed on all commits
- `pnpm build`: succeeds

View File

@@ -0,0 +1,550 @@
{
"version": "2.8",
"themes": { "Mode": ["Dark"] },
"variables": {},
"fonts": [],
"children": [
{
"type": "frame",
"id": "pi01",
"name": "Properties Inspector — Editable Properties",
"width": 320,
"height": 520,
"layout": "vertical",
"fill": "$--background",
"theme": { "Mode": "Light" },
"children": [
{
"type": "text",
"id": "pi02",
"name": "frameLabel",
"content": "Editable properties section: interactive hover states, cursor pointer, click-to-edit inputs. Properties from frontmatter YAML that the user can modify.",
"fontFamily": "Inter",
"fontSize": 11,
"fontWeight": "normal",
"fill": "$--muted-foreground",
"padding": [8, 12]
},
{
"type": "frame",
"id": "pi03",
"name": "Inspector Header",
"width": "fill_container",
"height": 45,
"fill": "$--background",
"stroke": { "align": "inside", "thickness": { "bottom": 1 }, "fill": "$--border" },
"gap": 8,
"padding": [0, 12],
"alignItems": "center",
"children": [
{
"type": "icon_font",
"id": "pi04",
"width": 16,
"height": 16,
"iconFontName": "sliders-horizontal",
"iconFontFamily": "lucide",
"fill": "$--muted-foreground"
},
{
"type": "text",
"id": "pi05",
"content": "Properties",
"fontFamily": "Inter",
"fontSize": 13,
"fontWeight": "600",
"fill": "$--muted-foreground",
"width": "fill_container"
},
{
"type": "icon_font",
"id": "pi06",
"width": 16,
"height": 16,
"iconFontName": "x",
"iconFontFamily": "lucide",
"fill": "$--muted-foreground"
}
]
},
{
"type": "frame",
"id": "pi07",
"name": "Properties Section",
"width": "fill_container",
"layout": "vertical",
"padding": [12, 12],
"gap": 10,
"children": [
{
"type": "frame",
"id": "pi08",
"name": "Type Row",
"width": "fill_container",
"justifyContent": "space-between",
"alignItems": "center",
"children": [
{
"type": "text",
"id": "pi09",
"content": "TYPE",
"fontFamily": "IBM Plex Mono",
"fontSize": 10,
"fontWeight": "500",
"letterSpacing": 1.2,
"fill": "$--muted-foreground"
},
{
"type": "frame",
"id": "pi10",
"name": "Type Badge",
"fill": "$--accent-blue-light",
"cornerRadius": 6,
"padding": [2, 8],
"children": [
{
"type": "text",
"id": "pi11",
"content": "Project",
"fontFamily": "Inter",
"fontSize": 12,
"fontWeight": "500",
"fill": "$--accent-blue"
}
]
}
]
},
{
"type": "frame",
"id": "pi12",
"name": "Editable Row — Status (hover state)",
"width": "fill_container",
"justifyContent": "space-between",
"alignItems": "center",
"cornerRadius": 4,
"fill": "$--bg-hover-subtle",
"padding": [4, 6],
"children": [
{
"type": "text",
"id": "pi13",
"content": "STATUS",
"fontFamily": "IBM Plex Mono",
"fontSize": 10,
"fontWeight": "500",
"letterSpacing": 1.2,
"fill": "$--muted-foreground"
},
{
"type": "frame",
"id": "pi14",
"name": "Status Badge",
"fill": "$--accent-green-light",
"cornerRadius": 16,
"padding": [1, 6],
"children": [
{
"type": "text",
"id": "pi15",
"content": "ACTIVE",
"fontFamily": "IBM Plex Mono",
"fontSize": 10,
"fontWeight": "600",
"letterSpacing": 1.2,
"fill": "$--accent-green"
}
]
}
]
},
{
"type": "frame",
"id": "pi16",
"name": "Editable Row — Owner (normal state)",
"width": "fill_container",
"justifyContent": "space-between",
"alignItems": "center",
"cornerRadius": 4,
"padding": [4, 6],
"children": [
{
"type": "text",
"id": "pi17",
"content": "OWNER",
"fontFamily": "IBM Plex Mono",
"fontSize": 10,
"fontWeight": "500",
"letterSpacing": 1.2,
"fill": "$--muted-foreground"
},
{
"type": "text",
"id": "pi18",
"content": "Luca Rossi",
"fontFamily": "Inter",
"fontSize": 13,
"fontWeight": "normal",
"fill": "$--foreground"
}
]
},
{
"type": "frame",
"id": "pi19",
"name": "Editable Row — Tags (normal state)",
"width": "fill_container",
"justifyContent": "space-between",
"alignItems": "center",
"cornerRadius": 4,
"padding": [4, 6],
"children": [
{
"type": "text",
"id": "pi20",
"content": "TAGS",
"fontFamily": "IBM Plex Mono",
"fontSize": 10,
"fontWeight": "500",
"letterSpacing": 1.2,
"fill": "$--muted-foreground"
},
{
"type": "frame",
"id": "pi21",
"name": "Tag Pills",
"gap": 4,
"children": [
{
"type": "frame",
"id": "pi22",
"fill": "$--accent-blue-light",
"cornerRadius": 12,
"padding": [2, 8],
"children": [
{ "type": "text", "id": "pi23", "content": "React", "fontFamily": "Inter", "fontSize": 11, "fontWeight": "500", "fill": "$--accent-blue" }
]
},
{
"type": "frame",
"id": "pi24",
"fill": "$--accent-blue-light",
"cornerRadius": 12,
"padding": [2, 8],
"children": [
{ "type": "text", "id": "pi25", "content": "TypeScript", "fontFamily": "Inter", "fontSize": 11, "fontWeight": "500", "fill": "$--accent-blue" }
]
}
]
}
]
},
{
"type": "frame",
"id": "pi26",
"name": "Add Property Button",
"width": "fill_container",
"height": 32,
"stroke": { "align": "inside", "thickness": 1, "fill": "$--border" },
"cornerRadius": 6,
"justifyContent": "center",
"alignItems": "center",
"children": [
{
"type": "text",
"id": "pi27",
"content": "+ Add property",
"fontFamily": "Inter",
"fontSize": 12,
"fill": "$--muted-foreground"
}
]
}
]
},
{
"type": "frame",
"id": "pi28",
"name": "Separator",
"width": "fill_container",
"height": 1,
"fill": "$--border"
},
{
"type": "frame",
"id": "pi29",
"name": "Info Section — Read-only Metadata",
"width": "fill_container",
"layout": "vertical",
"padding": [12, 12],
"gap": 6,
"children": [
{
"type": "text",
"id": "pi30",
"content": "INFO",
"fontFamily": "IBM Plex Mono",
"fontSize": 10,
"fontWeight": "500",
"letterSpacing": 1.2,
"fill": "$--muted-foreground",
"padding": [0, 0, 4, 0]
},
{
"type": "frame",
"id": "pi31",
"name": "Info Row — Modified (read-only, muted)",
"width": "fill_container",
"justifyContent": "space-between",
"alignItems": "center",
"children": [
{
"type": "text",
"id": "pi32",
"content": "MODIFIED",
"fontFamily": "IBM Plex Mono",
"fontSize": 10,
"fontWeight": "500",
"letterSpacing": 1.2,
"fill": "$--text-muted"
},
{
"type": "text",
"id": "pi33",
"content": "Feb 14, 2026",
"fontFamily": "Inter",
"fontSize": 12,
"fontWeight": "normal",
"fill": "$--text-muted"
}
]
},
{
"type": "frame",
"id": "pi34",
"name": "Info Row — Created (read-only, muted)",
"width": "fill_container",
"justifyContent": "space-between",
"alignItems": "center",
"children": [
{
"type": "text",
"id": "pi35",
"content": "CREATED",
"fontFamily": "IBM Plex Mono",
"fontSize": 10,
"fontWeight": "500",
"letterSpacing": 1.2,
"fill": "$--text-muted"
},
{
"type": "text",
"id": "pi36",
"content": "Jan 5, 2026",
"fontFamily": "Inter",
"fontSize": 12,
"fontWeight": "normal",
"fill": "$--text-muted"
}
]
},
{
"type": "frame",
"id": "pi37",
"name": "Info Row — Words (read-only, muted)",
"width": "fill_container",
"justifyContent": "space-between",
"alignItems": "center",
"children": [
{
"type": "text",
"id": "pi38",
"content": "WORDS",
"fontFamily": "IBM Plex Mono",
"fontSize": 10,
"fontWeight": "500",
"letterSpacing": 1.2,
"fill": "$--text-muted"
},
{
"type": "text",
"id": "pi39",
"content": "1,247",
"fontFamily": "Inter",
"fontSize": 12,
"fontWeight": "normal",
"fill": "$--text-muted"
}
]
},
{
"type": "frame",
"id": "pi40",
"name": "Info Row — File Size (read-only, muted)",
"width": "fill_container",
"justifyContent": "space-between",
"alignItems": "center",
"children": [
{
"type": "text",
"id": "pi41",
"content": "SIZE",
"fontFamily": "IBM Plex Mono",
"fontSize": 10,
"fontWeight": "500",
"letterSpacing": 1.2,
"fill": "$--text-muted"
},
{
"type": "text",
"id": "pi42",
"content": "4.2 KB",
"fontFamily": "Inter",
"fontSize": 12,
"fontWeight": "normal",
"fill": "$--text-muted"
}
]
}
]
}
]
},
{
"type": "frame",
"id": "pi50",
"name": "Properties Inspector — Info Section Detail",
"width": 320,
"height": 400,
"layout": "vertical",
"fill": "$--background",
"theme": { "Mode": "Light" },
"children": [
{
"type": "text",
"id": "pi51",
"name": "frameLabel",
"content": "Info section for read-only derived metadata. Labels and values use --text-muted color. No hover states, no cursor pointer, no click interaction. Visually distinct from editable properties above.",
"fontFamily": "Inter",
"fontSize": 11,
"fontWeight": "normal",
"fill": "$--muted-foreground",
"padding": [8, 12]
},
{
"type": "frame",
"id": "pi52",
"name": "Comparison Side by Side",
"width": "fill_container",
"layout": "vertical",
"padding": [12, 12],
"gap": 16,
"children": [
{
"type": "frame",
"id": "pi53",
"name": "Editable Property Example",
"width": "fill_container",
"layout": "vertical",
"gap": 4,
"children": [
{
"type": "text",
"id": "pi54",
"content": "EDITABLE (interactive)",
"fontFamily": "IBM Plex Mono",
"fontSize": 9,
"fontWeight": "600",
"letterSpacing": 1.5,
"fill": "$--accent-green"
},
{
"type": "frame",
"id": "pi55",
"name": "Row — cursor:pointer, hover:bg-muted, rounded",
"width": "fill_container",
"justifyContent": "space-between",
"alignItems": "center",
"cornerRadius": 4,
"fill": "$--bg-hover-subtle",
"padding": [4, 6],
"children": [
{
"type": "text",
"id": "pi56",
"content": "OWNER",
"fontFamily": "IBM Plex Mono",
"fontSize": 10,
"fontWeight": "500",
"letterSpacing": 1.2,
"fill": "$--muted-foreground"
},
{
"type": "text",
"id": "pi57",
"content": "Luca Rossi",
"fontFamily": "Inter",
"fontSize": 13,
"fontWeight": "normal",
"fill": "$--foreground"
}
]
}
]
},
{
"type": "frame",
"id": "pi58",
"name": "Read-only Property Example",
"width": "fill_container",
"layout": "vertical",
"gap": 4,
"children": [
{
"type": "text",
"id": "pi59",
"content": "READ-ONLY (muted, no interaction)",
"fontFamily": "IBM Plex Mono",
"fontSize": 9,
"fontWeight": "600",
"letterSpacing": 1.5,
"fill": "$--text-muted"
},
{
"type": "frame",
"id": "pi60",
"name": "Row — cursor:default, no hover, muted color",
"width": "fill_container",
"justifyContent": "space-between",
"alignItems": "center",
"padding": [4, 6],
"children": [
{
"type": "text",
"id": "pi61",
"content": "MODIFIED",
"fontFamily": "IBM Plex Mono",
"fontSize": 10,
"fontWeight": "500",
"letterSpacing": 1.2,
"fill": "$--text-muted"
},
{
"type": "text",
"id": "pi62",
"content": "Feb 14, 2026",
"fontFamily": "Inter",
"fontSize": 12,
"fontWeight": "normal",
"fill": "$--text-muted"
}
]
}
]
}
]
}
]
}
]
}

View File

@@ -259,7 +259,10 @@ In brief: `src/theme.json` defines editor typography and styling as nested JSON.
The Inspector panel (`src/components/Inspector.tsx`) is composed of four sub-panels:
1. **DynamicPropertiesPanel** (`src/components/DynamicPropertiesPanel.tsx`): Renders frontmatter as editable key-value pairs. Uses `EditableValue` for inline editing.
1. **DynamicPropertiesPanel** (`src/components/DynamicPropertiesPanel.tsx`): Renders frontmatter as editable key-value pairs with two distinct sections:
- **Editable properties** (top): frontmatter fields the user can modify — shown with interactive hover styling (`hover:bg-muted`), cursor pointer, and click-to-edit. Includes Type badge, Status pill, boolean toggles, array tag pills, and text fields.
- **Info section** (bottom, separated by border): read-only derived metadata — Modified, Created, Words, File Size. Uses muted text color (`--text-muted`) with no hover states or click interaction. These fields are computed from file metadata and content, not from frontmatter.
- Keys in `SKIP_KEYS` (`aliases`, `notion_id`, `workspace`, `is_a`, `Is A`) are hidden from the editable section since they are either internal or already displayed elsewhere (e.g., `is_a` is shown via the TypeRow badge).
2. **Relationships**: Shows `belongs_to` and `related_to` wikilinks as clickable chips.
3. **Backlinks**: Scans `allContent` for notes that reference the current note via `[[title]]` or `[[path]]`.
4. **Git History**: Shows the last few commits from `gitHistory` state.

View File

@@ -128,6 +128,23 @@ describe('DynamicPropertiesPanel', () => {
expect(screen.getByText('cadence')).toBeInTheDocument()
})
it('skips is_a key since it is shown via TypeRow', () => {
render(
<DynamicPropertiesPanel
entry={makeEntry({ isA: 'Project' })}
content=""
frontmatter={{ is_a: 'Project', Status: 'Active' }}
/>
)
// Type is shown via TypeRow, is_a should NOT appear as a separate editable property
const editableRows = screen.getAllByTestId('editable-property')
const editableLabels = editableRows.map(row => row.querySelector('span')?.textContent?.replace('×', '').trim())
expect(editableLabels).not.toContain('is_a')
// But Type row should still show
expect(screen.getByText('Type')).toBeInTheDocument()
expect(screen.getByText('Project')).toBeInTheDocument()
})
it('renders boolean property as toggle', () => {
render(
<DynamicPropertiesPanel
@@ -356,4 +373,133 @@ describe('DynamicPropertiesPanel', () => {
fireEvent.click(screen.getByText('Cancel'))
expect(screen.getByText('+ Add property')).toBeInTheDocument()
})
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
entry={makeEntry()}
content=""
frontmatter={{ cadence: 'Weekly', owner: 'Luca' }}
onUpdateProperty={onUpdateProperty}
onDeleteProperty={onDeleteProperty}
/>
)
const editableRows = screen.getAllByTestId('editable-property')
expect(editableRows.length).toBe(2)
// Editable rows have hover:bg-muted class for interactivity
editableRows.forEach(row => {
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()
})
})
})

View File

@@ -28,7 +28,7 @@ export const RELATIONSHIP_KEYS = new Set([
])
// Keys to skip showing in Properties
const SKIP_KEYS = new Set(['aliases', 'notion_id', 'workspace'])
const SKIP_KEYS = new Set(['aliases', 'notion_id', 'workspace', 'is_a', 'Is A'])
// eslint-disable-next-line react-refresh/only-export-components -- utility co-located with component
export function containsWikilinks(value: FrontmatterValue): boolean {
@@ -63,6 +63,14 @@ function parseNewValue(rawValue: string): FrontmatterValue {
return items.length === 1 ? items[0] : items
}
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 isStatusKey(key: string): boolean {
return key === 'Status' || key.includes('Status')
}
@@ -165,7 +173,7 @@ function PropertyRow({ propKey, value, editingKey, onStartEdit, onSave, onSaveLi
onUpdate?: (key: string, value: FrontmatterValue) => void; onDelete?: (key: string) => void
}) {
return (
<div className="group/prop flex items-center justify-between">
<div className="group/prop flex items-center justify-between rounded px-1.5 py-0.5 transition-colors hover:bg-muted" data-testid="editable-property">
<span className="font-mono-overline flex shrink-0 items-center gap-1 text-muted-foreground">
{propKey}
{onDelete && (
@@ -191,11 +199,11 @@ function PropertyValueCell({ propKey, value, isEditing, onStartEdit, onSave, onS
return <EditableValue {...editProps} />
}
function StaticRow({ label, value }: { label: string; value: string }) {
function InfoRow({ label, value }: { label: string; value: string }) {
return (
<div className="flex items-center justify-between">
<span className="font-mono-overline shrink-0 text-muted-foreground">{label}</span>
<span className="text-right text-[12px] text-secondary-foreground">{value}</span>
<div className="flex items-center justify-between px-1.5" data-testid="readonly-property">
<span className="font-mono-overline shrink-0" style={{ color: 'var(--text-muted)' }}>{label}</span>
<span className="text-right text-[12px]" style={{ color: 'var(--text-muted)' }}>{value}</span>
</div>
)
}
@@ -256,19 +264,28 @@ export function DynamicPropertiesPanel({
}, [onAddProperty])
return (
<div>
<div className="flex flex-col gap-3">
{/* Editable properties section */}
<div className="flex flex-col gap-2">
<TypeRow isA={entry.isA} onNavigate={onNavigate} />
{propertyEntries.map(([key, value]) => (
<PropertyRow key={key} propKey={key} value={value} editingKey={editingKey} onStartEdit={setEditingKey} onSave={handleSaveValue} onSaveList={handleSaveList} onUpdate={onUpdateProperty} onDelete={onDeleteProperty} />
))}
<StaticRow label="Modified" value={formatDate(entry.modifiedAt)} />
<StaticRow label="Words" value={String(wordCount)} />
</div>
{showAddDialog
? <AddPropertyForm onAdd={handleAdd} onCancel={() => setShowAddDialog(false)} />
: <AddPropertyButton onClick={() => setShowAddDialog(true)} disabled={!onAddProperty} />
}
{/* Read-only Info section */}
<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>
</div>
)
}

View File

@@ -279,4 +279,49 @@ This is a test note with some words to count.
)
expect(screen.getByText('No revision history')).toBeInTheDocument()
})
it('shows separate Info section with read-only metadata', () => {
render(
<Inspector
{...defaultProps}
entry={mockEntry}
content={mockContent}
/>
)
expect(screen.getByText('Info')).toBeInTheDocument()
expect(screen.getByText('Modified')).toBeInTheDocument()
expect(screen.getByText('Created')).toBeInTheDocument()
expect(screen.getByText('Size')).toBeInTheDocument()
})
it('renders editable properties with interactive styling', () => {
render(
<Inspector
{...defaultProps}
entry={mockEntry}
content={mockContent}
/>
)
const editableRows = screen.getAllByTestId('editable-property')
expect(editableRows.length).toBeGreaterThan(0)
editableRows.forEach(row => {
expect(row.className).toContain('hover:bg-muted')
})
})
it('renders read-only properties with muted non-interactive styling', () => {
render(
<Inspector
{...defaultProps}
entry={mockEntry}
content={mockContent}
/>
)
const readOnlyRows = screen.getAllByTestId('readonly-property')
expect(readOnlyRows.length).toBe(4) // Modified, Created, Words, Size
readOnlyRows.forEach(row => {
expect(row.className).not.toContain('hover:bg-muted')
expect(row.className).not.toContain('cursor-pointer')
})
})
})