Merge pull request #17 from refactoringhq/task/properties-inspector
feat: Properties inspector — editable vs read-only distinction
This commit is contained in:
76
.claude-done
76
.claude-done
@@ -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
|
||||
|
||||
550
design/properties-inspector.pen
Normal file
550
design/properties-inspector.pen
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user