Compare commits

...

12 Commits

Author SHA1 Message Date
Test
14b5c34b94 docs: add ADR-0029, ADR-0030; update README index (guard — from commits 1ae1377, a59640) 2026-03-30 08:01:55 +02:00
Test
a7a61d9751 fix: resize handle fills full panel height via self-stretch
The ResizeHandle div was not stretching to the full height of its flex
row parent, making it only interactive in the top portion. Adding
self-stretch (align-self: stretch) ensures it fills the complete cross-axis
height of the container, so the user can drag at any vertical position.

Fixes: Properties panel resize handle active area.
2026-03-30 07:48:48 +02:00
Test
68066b857f fix: make breadcrumb bar draggable as window drag region
Add data-tauri-drag-region to the BreadcrumbBar container so users can
drag the window by clicking empty space in the breadcrumb bar. Tauri
automatically excludes interactive children (buttons, links) from the
drag region.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 05:59:37 +02:00
Test
858468aec6 fix: remove broken category nav strip from emoji picker
The row of group icons between the search bar and emoji grid looked like
selectable emoji but only scrolled to groups — confusing users who expected
clicking them to select an emoji. Remove the strip entirely so the picker
opens directly with search + categorized grid.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 03:56:40 +02:00
Test
67ac8db888 fix: shorten Pulse view time filter labels to save space
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 02:26:30 +02:00
Test
1ae1377b2d refactor: split useCommandRegistry into domain command builders
Extract command definitions into focused domain modules under
src/hooks/commands/ (navigation, note, git, view, settings, type, filter).
useCommandRegistry becomes a thin assembler. Fixes CodeScene cc=39 brain
method — all new files score 9.58–10.0.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 22:59:54 +02:00
Test
adfceb3c70 fix: Properties panel — hash-based tag colors, single-item tags, chip consistency
- Single-item tag arrays (e.g. Tags: [Has Pic]) now render as pills instead
  of plain text: detectPropertyType checks key patterns before value type
- Tags get deterministic hash-based colors (one color per tag name) instead
  of all-blue default — manual overrides still take precedence
- All chips (Type, Status, Date, Tags) share consistent sizing: 12px font,
  rounded-md, matching padding
- Type label font-size matches other property labels (was 10px, now 12px)
- Tag add button is solid muted pill instead of dashed circle

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 22:04:37 +02:00
Test
46856b4dc2 fix: title H1 layout — no emoji gap, add-icon above title, larger font
- Remove reserved left space when note has no emoji icon (conditional render)
- Move "Add icon" button above the title row (Notion-style hover reveal)
- Increase title font size to 32px and top padding to 32px for proper H1 weight
- Update smoke test to validate no-emoji layout and new hover target

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 19:28:58 +02:00
Test
2746fb88ad fix: replace monospaced ALL CAPS labels with Inter sentence case
Remove IBM Plex Mono + text-transform: uppercase from all UI labels.
CSS classes .font-mono-label and .font-mono-overline now use Inter
with no text-transform. Inline monospace+uppercase styles in
StatusDropdown, TagsDropdown, ReferencedByPanel, AiActionCard replaced.
Tailwind uppercase removed from Sidebar, CommandPalette, PulseView,
CreateNoteDialog, CreateTypeDialog. Hardcoded ALL CAPS text (COLOR,
ICON, TEMPLATE) converted to sentence case. Code blocks and commit
hashes remain monospaced.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 17:00:04 +02:00
Test
52d66048d6 test: add Playwright smoke test for Properties panel visual style
Verifies sentence-case labels, status dot indicator, date chip,
boolean checkbox, no horizontal overflow, subtle Add Property button.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 16:39:48 +02:00
Test
1a90679f62 fix: align Properties panel visual style to design (pills, truncation, labels)
Sentence-case labels, colored tag pills, date chips, checkbox booleans,
blue URL links, status dot indicator, subtle Add Property button.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 16:36:31 +02:00
Test
59773725e1 feat: separate Backlinks and History with horizontal dividers in Inspector
Add visual separation between properties, Backlinks, and History sections
using Separator components. Restyle Backlinks with arrow-up-right icon header
and blue clickable links. Restyle History with counter-clockwise icon header,
combined hash+message blue links, and muted date below. Both sections now
hide entirely when empty instead of showing placeholder text.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 16:04:28 +02:00
50 changed files with 807 additions and 476 deletions

View File

@@ -0,0 +1,29 @@
---
type: ADR
id: "0029"
title: "Domain command builder pattern for useCommandRegistry"
status: active
date: 2026-03-30
---
## Context
`useCommandRegistry` was a 224-line "brain method" (CodeScene hotspot) that defined all command palette commands inline: navigation, note actions, git operations, view toggles, settings, type management, and filter controls. This monolithic structure scored 39 on CodeScene's complexity scale (target: ≤9.5 for hotspots), making it increasingly hard to add new commands without touching the central file.
## Decision
**Split command definitions into focused domain modules under `src/hooks/commands/`, each exporting a `build*Commands(config)` factory function. `useCommandRegistry` becomes a thin assembler that calls each builder and merges the results.** Domain modules: `navigationCommands`, `noteCommands`, `gitCommands`, `viewCommands`, `settingsCommands`, `typeCommands`, `filterCommands`. Shared types live in `commands/types.ts`; public API re-exported from `commands/index.ts`.
## Options considered
- **Option A** (chosen): Domain builder modules — each module owns its command shape and receives typed config. `useCommandRegistry` is pure assembly. All new files score 9.5810.0. Downside: more files to navigate.
- **Option B**: Split by file but keep one large hook calling sub-hooks — sub-hooks still need shared state passed down, similar coupling. No real complexity win.
- **Option C**: Register commands imperatively via a global registry — decouples callers entirely. Downside: harder to trace, no TypeScript inference at the registration site, over-engineering for current scale.
## Consequences
- Adding a new command means editing the relevant domain module (e.g. `noteCommands.ts`) only, not touching the assembler.
- Each domain module receives only the config it needs — explicit, typed interface, no hook dependency.
- `useCommandRegistry` reduced from 224 lines to a thin assembler.
- Pattern is consistent with the Rust commands/ module split (ADR-0030).
- Re-evaluation trigger: if command count grows to the point where the assembler itself becomes a complexity hotspot.

View File

@@ -0,0 +1,29 @@
---
type: ADR
id: "0030"
title: "Rust commands/ module split by domain"
status: active
date: 2026-03-30
---
## Context
`src-tauri/src/commands.rs` grew to 937 lines as Tauri command handlers accumulated for vault CRUD, git/GitHub sync, AI, system, and window operations. All commands shared a single file with no domain separation, making it hard to navigate, review, and extend. The file was a CodeScene hotspot dragging down overall code health.
## Decision
**Replace `commands.rs` with a `commands/` module split by domain: `vault.rs`, `git.rs`, `github.rs`, `ai.rs`, `system.rs`, and `mod.rs` (shared utilities + re-exports).** Each file owns the Tauri command handlers for its domain and the `#[cfg(desktop)]` / `#[cfg(mobile)]` stubs for platform-conditional availability. `mod.rs` is kept thin (≤100 lines) with no command logic — only re-exports and shared helpers (`expand_tilde`, `parse_build_label`).
## Options considered
- **Option A** (chosen): Domain-based module split — mirrors the TypeScript `hooks/commands/` pattern (ADR-0029). Each file is independently reviewable and scores well on code health. Downside: more files to navigate.
- **Option B**: Split by platform (`desktop.rs`, `mobile.rs`) — aligns with `#[cfg(...)]` guards but mixes domain concerns. Harder to find a specific command.
- **Option C**: Keep monolith but add section comments — zero file-count cost, but doesn't solve complexity or reviewability.
## Consequences
- `github.rs` separates GitHub OAuth/API commands from git sync commands (`git.rs`), matching the underlying Rust module split (`github/` vs `git/`).
- Platform stubs (`#[cfg(mobile)]` error returns) live alongside the desktop implementation in the same domain file.
- `mod.rs` re-exports all command functions so `lib.rs` `invoke_handler!` registration is unchanged.
- New Tauri commands go into the appropriate domain file; if no domain fits, create a new one rather than putting it in `mod.rs`.
- Re-evaluation trigger: if a single domain file (e.g. `vault.rs`) itself grows beyond ~300 lines and becomes a hotspot.

View File

@@ -82,4 +82,7 @@ proposed → active → superseded
| [0024](0024-cache-outside-vault.md) | Vault cache stored outside vault directory | active |
| [0025](0025-type-field-canonical.md) | type: as canonical field (replacing Is A:) | active |
| [0026](0026-props-down-no-global-state.md) | Props-down callbacks-up (no global state) | active |
| [0027](0027-dual-ai-architecture.md) | Dual AI architecture (API chat + CLI agent) | active |
| [0027](0027-dual-ai-architecture.md) | Dual AI architecture (API chat + CLI agent) | superseded |
| [0028](0028-cli-agent-only-no-api-key.md) | CLI agent only — no direct Anthropic API key | active |
| [0029](0029-domain-command-builder-pattern.md) | Domain command builder pattern for useCommandRegistry | active |
| [0030](0030-rust-commands-module-split.md) | Rust commands/ module split by domain | active |

View File

@@ -74,7 +74,7 @@ function DetailBlock({ label, content, isError }: {
<div style={{ marginTop: 6 }}>
<div
className="text-muted-foreground"
style={{ fontSize: 10, fontWeight: 600, textTransform: 'uppercase', marginBottom: 2 }}
style={{ fontSize: 10, fontWeight: 600, marginBottom: 2 }}
>
{label}
</div>

View File

@@ -48,6 +48,14 @@ const defaultProps = {
onToggleDiff: vi.fn(),
}
describe('BreadcrumbBar — drag region', () => {
it('has data-tauri-drag-region on the container', () => {
const { container } = render(<BreadcrumbBar entry={baseEntry} {...defaultProps} />)
const bar = container.firstElementChild as HTMLElement
expect(bar.dataset.tauriDragRegion).toBeDefined()
})
})
describe('BreadcrumbBar — trash/restore', () => {
it('shows trash button for non-trashed note', () => {
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} onTrash={vi.fn()} onRestore={vi.fn()} />)

View File

@@ -168,6 +168,7 @@ export const BreadcrumbBar = memo(function BreadcrumbBar({
}: BreadcrumbBarProps) {
return (
<div
data-tauri-drag-region
className="flex shrink-0 items-center justify-between"
style={{
height: 45,

View File

@@ -208,7 +208,8 @@ describe('CommandPalette', () => {
const groupHeaders = screen.getAllByText(
(_content, el) =>
el?.tagName === 'DIV' &&
el.classList.contains('uppercase') &&
el.classList.contains('text-[11px]') &&
el.classList.contains('font-medium') &&
!!el.textContent,
).map(el => el.textContent)

View File

@@ -140,7 +140,7 @@ export function CommandPalette({ open, commands, onClose }: CommandPaletteProps)
runningIndex += items.length
return (
<div key={group}>
<div className="px-4 pb-1 pt-2 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
<div className="px-4 pb-1 pt-2 text-[11px] font-medium text-muted-foreground">
{group}
</div>
{items.map((cmd, i) => {

View File

@@ -55,7 +55,7 @@ export function CreateNoteDialog({ open, onClose, onCreate, defaultType, customT
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-1.5">
<label className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
<label className="text-xs font-medium text-muted-foreground">
Title
</label>
<Input
@@ -66,7 +66,7 @@ export function CreateNoteDialog({ open, onClose, onCreate, defaultType, customT
/>
</div>
<div className="space-y-1.5">
<label className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
<label className="text-xs font-medium text-muted-foreground">
Type
</label>
<div className="flex flex-wrap gap-1.5">

View File

@@ -36,7 +36,7 @@ export function CreateTypeDialog({ open, onClose, onCreate }: CreateTypeDialogPr
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-1.5">
<label className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
<label className="text-xs font-medium text-muted-foreground">
Type Name
</label>
<Input

View File

@@ -112,8 +112,8 @@ describe('DynamicPropertiesPanel', () => {
frontmatter={{ Status: 'Active' }}
/>
)
// Status rendered with CSS text-transform: uppercase, DOM text is still "Active"
expect(screen.getByTitle('Active')).toBeInTheDocument()
// Status rendered as sentence case
expect(screen.getByTestId('status-badge')).toBeInTheDocument()
})
it('renders properties from frontmatter', () => {
@@ -124,9 +124,9 @@ describe('DynamicPropertiesPanel', () => {
frontmatter={{ cadence: 'Weekly', owner: 'Luca' }}
/>
)
expect(screen.getByText('cadence')).toBeInTheDocument()
expect(screen.getByText('Cadence')).toBeInTheDocument()
expect(screen.getByText('Weekly')).toBeInTheDocument()
expect(screen.getByText('owner')).toBeInTheDocument()
expect(screen.getByText('Owner')).toBeInTheDocument()
expect(screen.getByText('Luca')).toBeInTheDocument()
})
@@ -162,7 +162,7 @@ describe('DynamicPropertiesPanel', () => {
frontmatter={{ notion_id: 'abc-123-def' }}
/>
)
expect(screen.getByText('notion_id')).toBeInTheDocument()
expect(screen.getByText('Notion id')).toBeInTheDocument()
expect(screen.getByText('abc-123-def')).toBeInTheDocument()
})
@@ -177,7 +177,7 @@ describe('DynamicPropertiesPanel', () => {
// aliases skipped (in SKIP_KEYS); 'Belongs to' skipped (has wikilinks)
expect(screen.queryByText('aliases')).not.toBeInTheDocument()
expect(screen.queryByText('Belongs to')).not.toBeInTheDocument()
expect(screen.getByText('cadence')).toBeInTheDocument()
expect(screen.getByText('Cadence')).toBeInTheDocument()
})
it('shows former relationship key with plain text value in Properties', () => {
@@ -219,7 +219,7 @@ describe('DynamicPropertiesPanel', () => {
const typeLabels = screen.getAllByText('Type')
// Only the TypeRow label should exist, not a property row
expect(typeLabels).toHaveLength(1)
expect(screen.getByTitle('Active')).toBeInTheDocument()
expect(screen.getByTestId('status-badge')).toBeInTheDocument()
})
it('renders boolean property as toggle', () => {
@@ -232,7 +232,7 @@ describe('DynamicPropertiesPanel', () => {
/>
)
// Boolean should show as Yes/No toggle
const toggleBtn = screen.getByText('\u2717 No')
const toggleBtn = screen.getByText('No')
fireEvent.click(toggleBtn)
expect(onUpdateProperty).toHaveBeenCalledWith('published', true)
})
@@ -260,7 +260,7 @@ describe('DynamicPropertiesPanel', () => {
onAddProperty={onAddProperty}
/>
)
expect(screen.getByText('+ Add property')).toBeInTheDocument()
expect(screen.getByText('Add property')).toBeInTheDocument()
})
it('opens add property form when button clicked', () => {
@@ -272,7 +272,7 @@ describe('DynamicPropertiesPanel', () => {
onAddProperty={onAddProperty}
/>
)
fireEvent.click(screen.getByText('+ Add property'))
fireEvent.click(screen.getByText('Add property'))
expect(screen.getByPlaceholderText('Property name')).toBeInTheDocument()
expect(screen.getByPlaceholderText('Value')).toBeInTheDocument()
expect(screen.getByTestId('add-property-type-trigger')).toBeInTheDocument()
@@ -287,7 +287,7 @@ describe('DynamicPropertiesPanel', () => {
onAddProperty={onAddProperty}
/>
)
fireEvent.click(screen.getByText('+ Add property'))
fireEvent.click(screen.getByText('Add property'))
const keyInput = screen.getByPlaceholderText('Property name')
const valueInput = screen.getByPlaceholderText('Value')
fireEvent.change(keyInput, { target: { value: 'priority' } })
@@ -412,7 +412,7 @@ describe('DynamicPropertiesPanel', () => {
/>
)
// Click status pill to open dropdown
fireEvent.click(screen.getByTitle('Active'))
fireEvent.click(screen.getByTestId('status-badge'))
// Should show dropdown with search input
expect(screen.getByTestId('status-dropdown')).toBeInTheDocument()
expect(screen.getByTestId('status-search-input')).toBeInTheDocument()
@@ -478,11 +478,11 @@ describe('DynamicPropertiesPanel', () => {
onAddProperty={onAddProperty}
/>
)
fireEvent.click(screen.getByText('+ Add property'))
fireEvent.click(screen.getByText('Add property'))
const keyInput = screen.getByPlaceholderText('Property name')
fireEvent.keyDown(keyInput, { key: 'Escape' })
// Form should be hidden, button should reappear
expect(screen.getByText('+ Add property')).toBeInTheDocument()
expect(screen.getByText('Add property')).toBeInTheDocument()
})
it('adds property on Enter in form', () => {
@@ -494,7 +494,7 @@ describe('DynamicPropertiesPanel', () => {
onAddProperty={onAddProperty}
/>
)
fireEvent.click(screen.getByText('+ Add property'))
fireEvent.click(screen.getByText('Add property'))
const keyInput = screen.getByPlaceholderText('Property name')
const valueInput = screen.getByPlaceholderText('Value')
fireEvent.change(keyInput, { target: { value: 'key' } })
@@ -512,7 +512,7 @@ describe('DynamicPropertiesPanel', () => {
onAddProperty={onAddProperty}
/>
)
fireEvent.click(screen.getByText('+ Add property'))
fireEvent.click(screen.getByText('Add property'))
const keyInput = screen.getByPlaceholderText('Property name')
const valueInput = screen.getByPlaceholderText('Value')
fireEvent.change(keyInput, { target: { value: 'tags' } })
@@ -530,9 +530,9 @@ describe('DynamicPropertiesPanel', () => {
onAddProperty={onAddProperty}
/>
)
fireEvent.click(screen.getByText('+ Add property'))
fireEvent.click(screen.getByText('Add property'))
fireEvent.click(screen.getByTestId('add-property-cancel'))
expect(screen.getByText('+ Add property')).toBeInTheDocument()
expect(screen.getByText('Add property')).toBeInTheDocument()
})
describe('editable vs read-only distinction', () => {
@@ -775,7 +775,7 @@ describe('DynamicPropertiesPanel', () => {
expect(screen.getByText('Mar 31, 2026')).toBeInTheDocument()
})
it('renders calendar icon in date trigger button', () => {
it('renders date trigger button', () => {
render(
<DynamicPropertiesPanel
entry={makeEntry()}
@@ -786,7 +786,6 @@ describe('DynamicPropertiesPanel', () => {
)
const trigger = screen.getByTestId('date-display')
expect(trigger.tagName).toBe('BUTTON')
expect(trigger.querySelector('svg')).toBeInTheDocument()
})
it('opens calendar popover when date button clicked', () => {
@@ -841,7 +840,7 @@ describe('DynamicPropertiesPanel', () => {
onUpdateProperty={onUpdateProperty}
/>
)
fireEvent.click(screen.getByTitle('Active'))
fireEvent.click(screen.getByTestId('status-badge'))
expect(screen.getByTestId('status-dropdown')).toBeInTheDocument()
fireEvent.keyDown(screen.getByTestId('status-search-input'), { key: 'Escape' })
expect(screen.queryByTestId('status-dropdown')).not.toBeInTheDocument()
@@ -857,7 +856,7 @@ describe('DynamicPropertiesPanel', () => {
onUpdateProperty={onUpdateProperty}
/>
)
fireEvent.click(screen.getByTitle('Active'))
fireEvent.click(screen.getByTestId('status-badge'))
fireEvent.click(screen.getByTestId('status-dropdown-backdrop'))
expect(screen.queryByTestId('status-dropdown')).not.toBeInTheDocument()
expect(onUpdateProperty).not.toHaveBeenCalled()
@@ -872,7 +871,7 @@ describe('DynamicPropertiesPanel', () => {
onUpdateProperty={onUpdateProperty}
/>
)
fireEvent.click(screen.getByTitle('Active'))
fireEvent.click(screen.getByTestId('status-badge'))
const input = screen.getByTestId('status-search-input')
fireEvent.change(input, { target: { value: 'Needs Review' } })
fireEvent.keyDown(input, { key: 'Enter' })
@@ -893,7 +892,7 @@ describe('DynamicPropertiesPanel', () => {
onUpdateProperty={onUpdateProperty}
/>
)
fireEvent.click(screen.getByTitle('Active'))
fireEvent.click(screen.getByTestId('status-badge'))
expect(screen.getByTestId('status-option-Reviewing')).toBeInTheDocument()
expect(screen.getByTestId('status-option-Shipped')).toBeInTheDocument()
})
@@ -910,7 +909,7 @@ describe('DynamicPropertiesPanel', () => {
/>
)
expect(screen.getByTestId('boolean-toggle')).toBeInTheDocument()
expect(screen.getByText('\u2713 Yes')).toBeInTheDocument()
expect(screen.getByText('Yes')).toBeInTheDocument()
})
it('renders boolean toggle for false values', () => {
@@ -923,7 +922,7 @@ describe('DynamicPropertiesPanel', () => {
/>
)
expect(screen.getByTestId('boolean-toggle')).toBeInTheDocument()
expect(screen.getByText('\u2717 No')).toBeInTheDocument()
expect(screen.getByText('No')).toBeInTheDocument()
})
})
@@ -937,13 +936,13 @@ describe('DynamicPropertiesPanel', () => {
onUpdateProperty={onUpdateProperty}
/>
)
expect(screen.queryByText('trashed')).not.toBeInTheDocument()
expect(screen.queryByText('trashed_at')).not.toBeInTheDocument()
expect(screen.queryByText('archived')).not.toBeInTheDocument()
expect(screen.queryByText('archived_at')).not.toBeInTheDocument()
expect(screen.queryByText('icon')).not.toBeInTheDocument()
expect(screen.queryByText('Trashed')).not.toBeInTheDocument()
expect(screen.queryByText('Trashed at')).not.toBeInTheDocument()
expect(screen.queryByText('Archived')).not.toBeInTheDocument()
expect(screen.queryByText('Archived at')).not.toBeInTheDocument()
expect(screen.queryByText('Icon')).not.toBeInTheDocument()
// Custom property still visible
expect(screen.getByText('cadence')).toBeInTheDocument()
expect(screen.getByText('Cadence')).toBeInTheDocument()
})
it('filters system properties case-insensitively', () => {
@@ -958,7 +957,7 @@ describe('DynamicPropertiesPanel', () => {
expect(screen.queryByText('Trashed')).not.toBeInTheDocument()
expect(screen.queryByText('Archived')).not.toBeInTheDocument()
expect(screen.queryByText('Icon')).not.toBeInTheDocument()
expect(screen.getByText('cadence')).toBeInTheDocument()
expect(screen.getByText('Cadence')).toBeInTheDocument()
})
it('does not filter similar but non-matching property names', () => {
@@ -971,7 +970,7 @@ describe('DynamicPropertiesPanel', () => {
/>
)
expect(screen.getByText('Is Trashed')).toBeInTheDocument()
expect(screen.getByText('archive_date')).toBeInTheDocument()
expect(screen.getByText('Archive date')).toBeInTheDocument()
})
})
@@ -1055,7 +1054,7 @@ describe('DynamicPropertiesPanel', () => {
/>
)
expect(screen.getByTestId('boolean-toggle')).toBeInTheDocument()
expect(screen.getByText('\u2713 Yes')).toBeInTheDocument()
expect(screen.getByText('Yes')).toBeInTheDocument()
})
it('renders boolean toggle for string "false" when boolean mode overridden', () => {
@@ -1069,7 +1068,7 @@ describe('DynamicPropertiesPanel', () => {
/>
)
expect(screen.getByTestId('boolean-toggle')).toBeInTheDocument()
expect(screen.getByText('\u2717 No')).toBeInTheDocument()
expect(screen.getByText('No')).toBeInTheDocument()
})
it('toggles string boolean from false to true', () => {
@@ -1124,7 +1123,7 @@ describe('DynamicPropertiesPanel', () => {
onAddProperty={onAddProperty}
/>
)
fireEvent.click(screen.getByText('+ Add property'))
fireEvent.click(screen.getByText('Add property'))
// Switch type to boolean
fireEvent.pointerDown(screen.getByTestId('add-property-type-trigger'), { button: 0, pointerType: 'mouse' })
fireEvent.click(screen.getByRole('option', { name: /Boolean/ }))
@@ -1141,7 +1140,7 @@ describe('DynamicPropertiesPanel', () => {
onAddProperty={onAddProperty}
/>
)
fireEvent.click(screen.getByText('+ Add property'))
fireEvent.click(screen.getByText('Add property'))
fireEvent.pointerDown(screen.getByTestId('add-property-type-trigger'), { button: 0, pointerType: 'mouse' })
fireEvent.click(screen.getByRole('option', { name: /Boolean/ }))
// Toggle from No to Yes
@@ -1158,7 +1157,7 @@ describe('DynamicPropertiesPanel', () => {
onAddProperty={onAddProperty}
/>
)
fireEvent.click(screen.getByText('+ Add property'))
fireEvent.click(screen.getByText('Add property'))
const keyInput = screen.getByPlaceholderText('Property name')
fireEvent.change(keyInput, { target: { value: 'published' } })
// Switch to boolean type
@@ -1180,7 +1179,7 @@ describe('DynamicPropertiesPanel', () => {
onAddProperty={onAddProperty}
/>
)
fireEvent.click(screen.getByText('+ Add property'))
fireEvent.click(screen.getByText('Add property'))
fireEvent.pointerDown(screen.getByTestId('add-property-type-trigger'), { button: 0, pointerType: 'mouse' })
fireEvent.click(screen.getByRole('option', { name: /Date/ }))
expect(screen.getByTestId('add-property-date-trigger')).toBeInTheDocument()
@@ -1196,7 +1195,7 @@ describe('DynamicPropertiesPanel', () => {
onAddProperty={onAddProperty}
/>
)
fireEvent.click(screen.getByText('+ Add property'))
fireEvent.click(screen.getByText('Add property'))
fireEvent.pointerDown(screen.getByTestId('add-property-type-trigger'), { button: 0, pointerType: 'mouse' })
fireEvent.click(screen.getByRole('option', { name: /Status/ }))
expect(screen.getByTestId('add-property-status-trigger')).toBeInTheDocument()
@@ -1211,7 +1210,7 @@ describe('DynamicPropertiesPanel', () => {
onAddProperty={onAddProperty}
/>
)
fireEvent.click(screen.getByText('+ Add property'))
fireEvent.click(screen.getByText('Add property'))
// Default mode is text
expect(screen.getByPlaceholderText('Value')).toBeInTheDocument()
})

View File

@@ -9,6 +9,12 @@ import { AddPropertyForm } from './AddPropertyForm'
import { countWords } from '../utils/wikilinks'
import type { PropertyDisplayMode } from '../utils/propertyTypes'
function toSentenceCase(key: string): string {
const spaced = key.replace(/[_-]/g, ' ')
if (!spaced) return spaced
return spaced.charAt(0).toUpperCase() + spaced.slice(1)
}
// eslint-disable-next-line react-refresh/only-export-components -- utility co-located with component
export function containsWikilinks(value: FrontmatterValue): boolean {
if (typeof value === 'string') return /^\[\[.*\]\]$/.test(value)
@@ -48,8 +54,8 @@ function PropertyRow({ propKey, value, editingKey, displayMode, autoMode, vaultS
return (
<div className="group/prop grid min-w-0 grid-cols-2 items-center gap-2 rounded px-1.5 outline-none transition-colors hover:bg-muted focus:bg-muted focus:ring-1 focus:ring-primary" tabIndex={0} onKeyDown={handleKeyDown} data-testid="editable-property">
<span className="font-mono-overline flex min-w-0 items-center gap-1 text-muted-foreground">
<span className="truncate">{propKey}</span>
<span className="flex min-w-0 items-center gap-1 text-[12px] text-muted-foreground">
<span className="truncate">{toSentenceCase(propKey)}</span>
{onDelete && (
<button className="border-none bg-transparent p-0 text-sm leading-none text-muted-foreground opacity-0 transition-all hover:text-destructive group-hover/prop:opacity-100" onClick={() => onDelete(propKey)} title="Delete property">&times;</button>
)}
@@ -65,7 +71,7 @@ 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="font-mono-overline min-w-0 truncate" style={{ color: 'var(--text-muted)' }}>{label}</span>
<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>
)
@@ -74,10 +80,12 @@ function InfoRow({ label, value }: { label: string; value: string }) {
function AddPropertyButton({ onClick, disabled }: { onClick: () => void; disabled: boolean }) {
return (
<button
className="mt-3 w-full cursor-pointer border border-border bg-transparent text-center text-muted-foreground transition-colors hover:border-primary hover:text-primary disabled:cursor-not-allowed disabled:opacity-50"
style={{ borderRadius: 6, padding: '6px 12px', fontSize: 12 }}
className="mt-1 flex w-full cursor-pointer items-center gap-1 border-none bg-transparent px-1.5 text-[12px] text-muted-foreground opacity-50 transition-opacity hover:opacity-100 disabled:cursor-not-allowed"
onClick={onClick} disabled={disabled}
>+ Add property</button>
>
<span className="text-[12px] leading-none">+</span>
Add property
</button>
)
}

View File

@@ -1,5 +1,6 @@
import { useState, useCallback, useRef } from 'react'
import { normalizeUrl, openExternalUrl } from '../utils/url'
import { getTagStyle } from '../utils/tagStyles'
export function UrlValue({
value,
@@ -65,7 +66,7 @@ export function UrlValue({
return (
<span className="group/url inline-flex min-w-0 items-center gap-1">
<span
className="min-w-0 cursor-pointer truncate rounded px-1 py-0.5 text-right text-[12px] text-secondary-foreground transition-colors hover:text-primary hover:underline"
className="min-w-0 cursor-pointer truncate rounded px-1 py-0.5 text-right text-[12px] text-[var(--accent-blue)] underline decoration-[var(--accent-blue)]/40 transition-colors hover:decoration-[var(--accent-blue)]"
onClick={handleOpen}
title={value}
data-testid="url-link"
@@ -211,11 +212,12 @@ export function TagPillList({
) : (
<span
key={idx}
className="group/pill relative inline-flex cursor-pointer items-center rounded-full px-2 py-0.5 transition-colors"
className="group/pill relative inline-flex cursor-pointer items-center rounded-md transition-colors"
style={{
backgroundColor: 'var(--accent-blue-light)',
color: 'var(--accent-blue)',
fontSize: 11,
...getTagStyle(item),
backgroundColor: getTagStyle(item).bg,
padding: '2px 8px',
fontSize: 12,
fontWeight: 500,
}}
onClick={() => handleStartEdit(idx)}
@@ -223,8 +225,8 @@ export function TagPillList({
>
{item}
<button
className="absolute right-0.5 top-1/2 flex h-3.5 w-3.5 -translate-y-1/2 items-center justify-center rounded-full border-none p-0 text-[10px] leading-none opacity-0 shadow-[-6px_0_4px_-2px_var(--accent-blue-light)] transition-all hover:bg-[var(--accent-red-light)] hover:text-[var(--accent-red)] group-hover/pill:opacity-100"
style={{ color: 'var(--accent-blue)', backgroundColor: 'var(--accent-blue-light)' }}
className="absolute right-0.5 top-1/2 flex h-3.5 w-3.5 -translate-y-1/2 items-center justify-center rounded-full border-none p-0 text-[10px] leading-none opacity-0 transition-all hover:bg-[var(--accent-red-light)] hover:text-[var(--accent-red)] group-hover/pill:opacity-100"
style={{ color: getTagStyle(item).color, backgroundColor: getTagStyle(item).bg }}
onClick={(e) => {
e.stopPropagation()
handleDeleteItem(idx)
@@ -253,7 +255,8 @@ export function TagPillList({
/>
) : (
<button
className="inline-flex h-5 w-5 items-center justify-center rounded-full border border-dashed border-[var(--accent-blue)] bg-transparent p-0 text-[12px] leading-none text-[var(--accent-blue)] transition-colors hover:bg-[var(--accent-blue-light)]"
className="inline-flex items-center justify-center rounded-md border-none bg-muted px-2 text-[12px] font-medium leading-none text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
style={{ padding: '2px 8px' }}
onClick={() => setIsAddingNew(true)}
title={`Add ${label.toLowerCase()}`}
>

View File

@@ -183,7 +183,7 @@
display: flex;
flex-direction: row;
align-items: flex-start;
padding-top: 16px;
padding-top: 32px;
}
.title-section__separator {
@@ -209,10 +209,10 @@
}
.note-icon-button--active {
font-size: 32px;
line-height: 1.2;
font-size: 36px;
line-height: 1;
transition: transform 0.1s;
margin-right: 8px;
margin-right: 10px;
}
.note-icon-button--active:hover:not(:disabled) {
@@ -227,9 +227,10 @@
font-size: 13px;
opacity: 0;
transition: opacity 0.15s;
margin-top: 16px;
}
.note-icon-area:hover .note-icon-button--add,
.title-section:hover .note-icon-button--add,
.note-icon-button--add:focus-visible {
opacity: 1;
}
@@ -290,7 +291,7 @@
border: none;
outline: none;
background: transparent;
font-size: var(--headings-h1-font-size, 28px);
font-size: var(--headings-h1-font-size, 32px);
font-weight: var(--headings-h1-font-weight, 700);
line-height: var(--headings-h1-line-height, 1.2);
letter-spacing: var(--headings-h1-letter-spacing, -0.015em);

View File

@@ -203,13 +203,23 @@ export function EditorContent({
{showEditor && activeTab && (
<div className="editor-scroll-area">
<div className="title-section">
<div className="title-section__row">
{!emojiIcon && (
<NoteIcon
icon={emojiIcon}
icon={null}
editable={!isTrashed}
onSetIcon={handleSetIcon}
onRemoveIcon={handleRemoveIcon}
/>
)}
<div className="title-section__row">
{emojiIcon && (
<NoteIcon
icon={emojiIcon}
editable={!isTrashed}
onSetIcon={handleSetIcon}
onRemoveIcon={handleRemoveIcon}
/>
)}
<TitleField
title={activeTab.entry.title}
filename={activeTab.entry.filename}

View File

@@ -1,5 +1,5 @@
import { useState, useRef, useEffect, useCallback } from 'react'
import { EMOJI_GROUPS, EMOJIS_BY_GROUP, GROUP_ICONS, GROUP_SHORT_LABELS, searchEmojis } from '../utils/emoji'
import { EMOJI_GROUPS, EMOJIS_BY_GROUP, GROUP_SHORT_LABELS, searchEmojis } from '../utils/emoji'
interface EmojiPickerProps {
onSelect: (emoji: string) => void
@@ -11,7 +11,6 @@ export function EmojiPicker({ onSelect, onClose }: EmojiPickerProps) {
const inputRef = useRef<HTMLInputElement>(null)
const containerRef = useRef<HTMLDivElement>(null)
const scrollRef = useRef<HTMLDivElement>(null)
const groupRefs = useRef<Map<string, HTMLDivElement>>(new Map())
useEffect(() => {
setTimeout(() => inputRef.current?.focus(), 50)
@@ -45,13 +44,6 @@ export function EmojiPicker({ onSelect, onClose }: EmojiPickerProps) {
onClose()
}, [onSelect, onClose])
const scrollToGroup = useCallback((group: string) => {
const el = groupRefs.current.get(group)
if (el && scrollRef.current) {
el.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
}, [])
const searchResults = search.trim() ? searchEmojis(search) : null
const isSearching = searchResults !== null
@@ -73,20 +65,6 @@ export function EmojiPicker({ onSelect, onClose }: EmojiPickerProps) {
data-testid="emoji-picker-search"
/>
</div>
{!isSearching && (
<div className="flex gap-0.5 border-b border-border px-2 py-1.5 overflow-x-auto">
{EMOJI_GROUPS.map(group => (
<button
key={group}
className="shrink-0 rounded px-1.5 py-1 text-base transition-colors hover:bg-secondary"
onClick={() => scrollToGroup(group)}
title={GROUP_SHORT_LABELS[group]}
>
{GROUP_ICONS[group]}
</button>
))}
</div>
)}
<div ref={scrollRef} className="max-h-[300px] overflow-y-auto p-2" data-testid="emoji-picker-grid">
{isSearching ? (
searchResults.length > 0 ? (
@@ -113,10 +91,7 @@ export function EmojiPicker({ onSelect, onClose }: EmojiPickerProps) {
const emojis = EMOJIS_BY_GROUP.get(group)
if (!emojis?.length) return null
return (
<div
key={group}
ref={el => { if (el) groupRefs.current.set(group, el) }}
>
<div key={group}>
<div className="sticky top-0 z-10 bg-popover px-1 pb-1 pt-2 text-[11px] font-medium text-muted-foreground">
{GROUP_SHORT_LABELS[group]}
</div>

View File

@@ -122,11 +122,11 @@ describe('Inspector', () => {
expect(screen.getByText('Words')).toBeInTheDocument()
})
it('renders status as a colored pill', () => {
it('renders status as a colored badge with dot indicator', () => {
render(<Inspector {...defaultProps} entry={mockEntry} content={mockContent} />)
const pill = screen.getByText('Active')
// Status is rendered as an inline pill with CSS variable-based styles
expect(pill).toHaveStyle({ borderRadius: '16px' })
const badge = screen.getByTestId('status-badge')
expect(badge).toHaveTextContent('Active')
expect(badge.className).toContain('rounded')
})
it('computes word count from content minus frontmatter and title', () => {
@@ -137,7 +137,7 @@ describe('Inspector', () => {
it('shows "Add property" button as disabled placeholder', () => {
render(<Inspector {...defaultProps} entry={mockEntry} content={mockContent} />)
const btn = screen.getByText('+ Add property')
const btn = screen.getByText('Add property')
expect(btn).toBeDisabled()
})
@@ -188,10 +188,7 @@ This is a test note with some words to count.
entries={[mockEntry, referrerEntry]}
/>
)
// Backlinks section is collapsed by default, but header with count is visible
expect(screen.getByText('Backlinks (1)')).toBeInTheDocument()
// Expand to see the backlink entry
fireEvent.click(screen.getByTestId('backlinks-toggle'))
expect(screen.getByText('Backlinks')).toBeInTheDocument()
expect(screen.getByText('Referrer Note')).toBeInTheDocument()
})
@@ -204,10 +201,8 @@ This is a test note with some words to count.
entries={[mockEntry, { ...referrerEntry, outgoingLinks: [] }]}
/>
)
// Initially no backlinks — section is hidden entirely
expect(screen.queryByTestId('backlinks-toggle')).not.toBeInTheDocument()
expect(screen.queryByText('Backlinks')).not.toBeInTheDocument()
// Rerender with updated outgoingLinks (simulates adding [[Test Project]] to referrer)
rerender(
<Inspector
{...defaultProps}
@@ -216,8 +211,7 @@ This is a test note with some words to count.
entries={[mockEntry, { ...referrerEntry, outgoingLinks: ['Test Project'] }]}
/>
)
expect(screen.getByText('Backlinks (1)')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('backlinks-toggle'))
expect(screen.getByText('Backlinks')).toBeInTheDocument()
expect(screen.getByText('Referrer Note')).toBeInTheDocument()
})
@@ -230,7 +224,7 @@ This is a test note with some words to count.
entries={[mockEntry]}
/>
)
expect(screen.queryByTestId('backlinks-toggle')).not.toBeInTheDocument()
expect(screen.queryByText('Backlinks')).not.toBeInTheDocument()
})
it('navigates when a backlink is clicked', () => {
@@ -244,7 +238,6 @@ This is a test note with some words to count.
onNavigate={onNavigate}
/>
)
fireEvent.click(screen.getByTestId('backlinks-toggle'))
fireEvent.click(screen.getByText('Referrer Note'))
expect(onNavigate).toHaveBeenCalledWith('Referrer Note')
})
@@ -260,12 +253,11 @@ This is a test note with some words to count.
)
expect(screen.getByText('History')).toBeInTheDocument()
expect(screen.getByText('a1b2c3d')).toBeInTheDocument()
expect(screen.getByText('Update test with latest changes')).toBeInTheDocument()
expect(screen.getByText('e4f5g6h')).toBeInTheDocument()
expect(screen.getByText('i7j8k9l')).toBeInTheDocument()
})
it('renders commit hashes as clickable buttons', () => {
it('renders commit entries as clickable buttons', () => {
const onViewCommitDiff = vi.fn()
render(
<Inspector
@@ -277,25 +269,13 @@ This is a test note with some words to count.
/>
)
const hashBtn = screen.getByText('a1b2c3d')
expect(hashBtn.tagName).toBe('BUTTON')
hashBtn.click()
const button = hashBtn.closest('button')!
expect(button.tagName).toBe('BUTTON')
button.click()
expect(onViewCommitDiff).toHaveBeenCalledWith('a1b2c3d4e5f6a7b8')
})
it('shows author name in commit rows', () => {
render(
<Inspector
{...defaultProps}
entry={mockEntry}
content={mockContent}
gitHistory={mockGitHistory}
/>
)
const authors = screen.getAllByText('Luca Rossi')
expect(authors.length).toBeGreaterThan(0)
})
it('shows "No revision history" when no commits', () => {
it('hides history section when no commits', () => {
render(
<Inspector
{...defaultProps}
@@ -304,7 +284,7 @@ This is a test note with some words to count.
gitHistory={[]}
/>
)
expect(screen.getByText('No revision history')).toBeInTheDocument()
expect(screen.queryByText('History')).not.toBeInTheDocument()
})
it('shows separate Info section with read-only metadata', () => {

View File

@@ -3,6 +3,7 @@ import { useDragRegion } from '../hooks/useDragRegion'
import type { VaultEntry, GitCommit } from '../types'
import { cn } from '@/lib/utils'
import { SlidersHorizontal, X } from '@phosphor-icons/react'
import { Separator } from './ui/separator'
import { parseFrontmatter } from '../utils/frontmatter'
import { DynamicPropertiesPanel } from './DynamicPropertiesPanel'
import { DynamicRelationshipsPanel, BacklinksPanel, ReferencedByPanel, GitHistoryPanel, InstancesPanel } from './InspectorPanels'
@@ -162,7 +163,9 @@ export function Inspector({
/>
<InstancesPanel entry={entry} entries={entries} typeEntryMap={typeEntryMap} onNavigate={onNavigate} />
<ReferencedByPanel items={referencedBy} typeEntryMap={typeEntryMap} onNavigate={onNavigate} />
<BacklinksPanel backlinks={backlinks} typeEntryMap={typeEntryMap} onNavigate={onNavigate} />
{backlinks.length > 0 && <Separator />}
<BacklinksPanel backlinks={backlinks} onNavigate={onNavigate} />
{gitHistory.length > 0 && <Separator />}
<GitHistoryPanel commits={gitHistory} onViewCommitDiff={onViewCommitDiff} />
</>
) : (

View File

@@ -632,7 +632,7 @@ describe('BacklinksPanel', () => {
})
it('renders nothing when empty', () => {
const { container } = render(<BacklinksPanel typeEntryMap={{}} backlinks={[]} onNavigate={onNavigate} />)
const { container } = render(<BacklinksPanel backlinks={[]} onNavigate={onNavigate} />)
expect(container.innerHTML).toBe('')
})
@@ -641,23 +641,16 @@ describe('BacklinksPanel', () => {
{ entry: makeEntry({ title: 'Another Note', isA: 'Project', path: '/vault/project/another.md' }), context: null },
]
it('renders collapsed by default with count badge', () => {
render(<BacklinksPanel typeEntryMap={{}} backlinks={twoBacklinks} onNavigate={onNavigate} />)
expect(screen.getByText('Backlinks (2)')).toBeInTheDocument()
expect(screen.queryByText('Referencing Note')).not.toBeInTheDocument()
})
it('expands to show backlink entries when toggle clicked', () => {
render(<BacklinksPanel typeEntryMap={{}} backlinks={twoBacklinks} onNavigate={onNavigate} />)
fireEvent.click(screen.getByTestId('backlinks-toggle'))
it('renders header and all backlinks immediately (no collapse)', () => {
render(<BacklinksPanel backlinks={twoBacklinks} onNavigate={onNavigate} />)
expect(screen.getByText('Backlinks')).toBeInTheDocument()
expect(screen.getByText('Referencing Note')).toBeInTheDocument()
expect(screen.getByText('Another Note')).toBeInTheDocument()
})
it('navigates when clicking backlink', () => {
const backlinks = [{ entry: makeEntry({ title: 'Reference' }), context: null }]
render(<BacklinksPanel typeEntryMap={{}} backlinks={backlinks} onNavigate={onNavigate} />)
fireEvent.click(screen.getByTestId('backlinks-toggle'))
render(<BacklinksPanel backlinks={backlinks} onNavigate={onNavigate} />)
fireEvent.click(screen.getByText('Reference'))
expect(onNavigate).toHaveBeenCalledWith('Reference')
})
@@ -666,39 +659,26 @@ describe('BacklinksPanel', () => {
const backlinks = [
{ entry: makeEntry({ title: 'Referencing Note' }), context: 'This references [[My Note]] in context.' },
]
render(<BacklinksPanel typeEntryMap={{}} backlinks={backlinks} onNavigate={onNavigate} />)
fireEvent.click(screen.getByTestId('backlinks-toggle'))
render(<BacklinksPanel backlinks={backlinks} onNavigate={onNavigate} />)
expect(screen.getByText('This references [[My Note]] in context.')).toBeInTheDocument()
})
it('collapses when toggle clicked twice', () => {
const backlinks = [{ entry: makeEntry({ title: 'Note A' }), context: null }]
render(<BacklinksPanel typeEntryMap={{}} backlinks={backlinks} onNavigate={onNavigate} />)
fireEvent.click(screen.getByTestId('backlinks-toggle'))
expect(screen.getByText('Note A')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('backlinks-toggle'))
expect(screen.queryByText('Note A')).not.toBeInTheDocument()
})
it('shows emoji icon before backlink title when entry has an emoji', () => {
const backlinks = [{
entry: makeEntry({ title: 'Starred Note', icon: '⭐' }),
context: null,
}]
render(<BacklinksPanel typeEntryMap={{}} backlinks={backlinks} onNavigate={onNavigate} />)
fireEvent.click(screen.getByTestId('backlinks-toggle'))
render(<BacklinksPanel backlinks={backlinks} onNavigate={onNavigate} />)
expect(screen.getByText('⭐')).toBeInTheDocument()
expect(screen.getByText('Starred Note')).toBeInTheDocument()
})
it('does not show emoji when backlink entry has no icon', () => {
const backlinks = [{ entry: makeEntry({ title: 'Plain Note' }), context: null }]
render(<BacklinksPanel typeEntryMap={{}} backlinks={backlinks} onNavigate={onNavigate} />)
fireEvent.click(screen.getByTestId('backlinks-toggle'))
render(<BacklinksPanel backlinks={backlinks} onNavigate={onNavigate} />)
expect(screen.getByText('Plain Note')).toBeInTheDocument()
const btn = screen.getByText('Plain Note').closest('button')
const spans = btn?.querySelectorAll('span.shrink-0')
// No emoji span should exist (only possible shrink-0 spans are trash icon, not emoji)
const emojiSpans = Array.from(spans ?? []).filter(s => /[\p{Emoji_Presentation}]/u.test(s.textContent ?? ''))
expect(emojiSpans).toHaveLength(0)
})
@@ -764,12 +744,12 @@ describe('GitHistoryPanel', () => {
vi.clearAllMocks()
})
it('shows "No revision history" when empty', () => {
render(<GitHistoryPanel commits={[]} />)
expect(screen.getByText('No revision history')).toBeInTheDocument()
it('renders nothing when empty', () => {
const { container } = render(<GitHistoryPanel commits={[]} />)
expect(container.innerHTML).toBe('')
})
it('renders commit entries', () => {
it('renders commit entries with hash and message', () => {
const commits: GitCommit[] = [
{ hash: 'abc1234567890', shortHash: 'abc1234', message: 'Initial commit', author: 'luca', date: Math.floor(Date.now() / 1000) - 3600 },
{ hash: 'def4567890123', shortHash: 'def4567', message: 'Fix bug', author: 'jane', date: Math.floor(Date.now() / 1000) - 86400 * 2 },
@@ -777,13 +757,9 @@ describe('GitHistoryPanel', () => {
render(<GitHistoryPanel commits={commits} onViewCommitDiff={onViewCommitDiff} />)
expect(screen.getByText('abc1234')).toBeInTheDocument()
expect(screen.getByText('def4567')).toBeInTheDocument()
expect(screen.getByText('Initial commit')).toBeInTheDocument()
expect(screen.getByText('Fix bug')).toBeInTheDocument()
expect(screen.getByText('luca')).toBeInTheDocument()
expect(screen.getByText('jane')).toBeInTheDocument()
})
it('calls onViewCommitDiff when clicking commit hash', () => {
it('calls onViewCommitDiff when clicking commit entry', () => {
const commits: GitCommit[] = [
{ hash: 'abc1234567890', shortHash: 'abc1234', message: 'test', author: '', date: Math.floor(Date.now() / 1000) },
]

View File

@@ -5,7 +5,7 @@ import { EditableValue, TagPillList, UrlValue } from './EditableValue'
import { isUrlValue } from '../utils/url'
import { Calendar } from '@/components/ui/calendar'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { CalendarIcon, XIcon } from 'lucide-react'
import { XIcon } from 'lucide-react'
import { isValidCssColor } from '../utils/colorUtils'
import {
type PropertyDisplayMode,
@@ -14,7 +14,8 @@ import {
DISPLAY_MODE_OPTIONS,
DISPLAY_MODE_ICONS,
} from '../utils/propertyTypes'
import { StatusPill, StatusDropdown } from './StatusDropdown'
import { StatusDropdown } from './StatusDropdown'
import { getStatusStyle } from '../utils/statusStyles'
import { TagsDropdown } from './TagsDropdown'
import { getTagStyle } from '../utils/tagStyles'
import { ColorEditableValue } from './ColorInput'
@@ -37,14 +38,17 @@ function StatusValue({ propKey, value, isEditing, vaultStatuses, onSave, onStart
onSave: (key: string, value: string) => void; onStartEdit: (key: string | null) => void
}) {
const statusStr = String(value)
const style = getStatusStyle(statusStr)
return (
<span className="relative inline-flex min-w-0 items-center">
<span
className="cursor-pointer transition-opacity hover:opacity-80"
className="inline-flex cursor-pointer items-center gap-1.5 rounded-md px-2 py-1 text-[12px] font-medium transition-opacity hover:opacity-80"
style={{ backgroundColor: style.bg, color: style.color }}
onClick={() => onStartEdit(propKey)}
data-testid="status-badge"
>
<StatusPill status={statusStr} />
<span className="inline-block size-1.5 shrink-0 rounded-full" style={{ backgroundColor: style.color }} />
{statusStr}
</span>
{isEditing && (
<StatusDropdown
@@ -79,18 +83,15 @@ function TagsValue({ propKey, value, isEditing, vaultTags, onSave, onStartEdit }
return (
<span
key={tag}
className="group/tag relative inline-flex items-center overflow-hidden rounded-full"
style={{ backgroundColor: style.bg, padding: '1px 6px', maxWidth: 120 }}
className="group/tag relative inline-flex items-center overflow-hidden rounded-md"
style={{ backgroundColor: style.bg, padding: '2px 8px', maxWidth: 120 }}
>
<span
className="transition-[max-width] duration-150 group-hover/tag:[mask-image:linear-gradient(to_right,black_60%,transparent_100%)]"
style={{
color: style.color,
fontFamily: "'IBM Plex Mono', monospace",
fontSize: 10,
fontWeight: 600,
letterSpacing: '0',
textTransform: 'uppercase' as const,
fontSize: 12,
fontWeight: 500,
overflow: 'hidden',
whiteSpace: 'nowrap' as const,
}}
@@ -99,7 +100,7 @@ function TagsValue({ propKey, value, isEditing, vaultTags, onSave, onStartEdit }
</span>
<button
className="ml-0.5 max-w-0 overflow-hidden border-none bg-transparent p-0 leading-none opacity-0 transition-all duration-150 group-hover/tag:max-w-[14px] group-hover/tag:opacity-100"
style={{ color: style.color, fontSize: 10, flexShrink: 0 }}
style={{ color: style.color, fontSize: 11, flexShrink: 0 }}
onClick={() => handleRemove(tag)}
title={`Remove ${tag}`}
>
@@ -109,7 +110,8 @@ function TagsValue({ propKey, value, isEditing, vaultTags, onSave, onStartEdit }
)
})}
<button
className="inline-flex size-5 shrink-0 items-center justify-center rounded-full border border-dashed border-muted-foreground bg-transparent text-[10px] text-muted-foreground transition-colors hover:border-foreground hover:text-foreground"
className="inline-flex shrink-0 items-center justify-center rounded-md border-none bg-muted text-[12px] font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
style={{ padding: '2px 8px' }}
onClick={() => onStartEdit(propKey)}
title="Add tag"
data-testid="tags-add-button"
@@ -128,13 +130,15 @@ function TagsValue({ propKey, value, isEditing, vaultTags, onSave, onStartEdit }
function BooleanToggle({ value, onToggle }: { value: boolean; onToggle: () => void }) {
return (
<button
className="rounded border border-border bg-transparent px-2 py-0.5 text-xs text-secondary-foreground transition-colors hover:bg-muted"
onClick={onToggle}
data-testid="boolean-toggle"
>
{value ? '\u2713 Yes' : '\u2717 No'}
</button>
<label className="inline-flex cursor-pointer items-center gap-1.5" data-testid="boolean-toggle">
<input
type="checkbox"
checked={value}
onChange={onToggle}
className="size-3.5 cursor-pointer accent-primary"
/>
<span className="text-[12px] text-secondary-foreground">{value ? 'Yes' : 'No'}</span>
</label>
)
}
@@ -160,11 +164,10 @@ function DateValue({ value, onSave }: {
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
className="inline-flex min-w-0 cursor-pointer items-center gap-1 rounded border-none bg-transparent px-1 py-0.5 text-right text-[12px] text-secondary-foreground transition-colors hover:bg-muted"
className={`inline-flex min-w-0 cursor-pointer items-center gap-1 border-none px-2 py-1 text-right text-[12px] font-medium transition-colors hover:opacity-80${formatted ? ' rounded-md bg-muted text-accent-foreground' : ' bg-transparent text-muted-foreground'}`}
title={value}
data-testid="date-display"
>
<CalendarIcon className="size-3 shrink-0 text-muted-foreground" />
<span className={`min-w-0 truncate${!formatted ? ' text-muted-foreground' : ''}`}>{formatted || 'Pick a date\u2026'}</span>
</button>
</PopoverTrigger>

View File

@@ -167,7 +167,7 @@ function DayGroup({ label, commits, onOpenNote }: {
onClick={() => setCollapsed((v) => !v)}
>
<Chevron size={12} className="text-muted-foreground" />
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
<span className="text-[11px] font-medium text-muted-foreground">
{label}
</span>
<span className="text-[11px] text-muted-foreground">

View File

@@ -67,7 +67,7 @@ export function ResizeHandle({ onResize }: ResizeHandleProps) {
return (
<div
className="-ml-1 w-1 shrink-0 cursor-col-resize bg-transparent transition-colors hover:bg-[var(--border)]"
className="-ml-1 w-1 shrink-0 self-stretch cursor-col-resize bg-transparent transition-colors hover:bg-[var(--border)]"
onMouseDown={handleMouseDown}
/>
)

View File

@@ -315,7 +315,7 @@ export const Sidebar = memo(function Sidebar({
{/* Sections header + visibility popover */}
<div ref={customizeRef} style={{ position: 'relative', padding: '4px 6px 0' }}>
<div className="flex w-full select-none items-center justify-between" style={{ padding: '4px 16px' }}>
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">Sections</span>
<span className="text-[11px] font-medium text-muted-foreground">Sections</span>
<button className="flex shrink-0 cursor-pointer items-center justify-center rounded border-none bg-transparent p-0 text-muted-foreground transition-colors hover:text-foreground" style={{ width: 20, height: 20 }} onClick={() => setShowCustomize((v) => !v)} aria-label="Customize sections" title="Customize sections">
<SlidersHorizontal size={14} />
</button>

View File

@@ -13,11 +13,10 @@ export function StatusPill({ status, className }: { status: string; className?:
color: style.color,
borderRadius: 16,
padding: '1px 6px',
fontFamily: "'IBM Plex Mono', monospace",
fontFamily: "'Inter', sans-serif",
fontSize: 10,
fontWeight: 600,
letterSpacing: '0',
textTransform: 'uppercase' as const,
maxWidth: 160,
}}
title={status}
@@ -98,11 +97,10 @@ function StatusOption({
}
const SECTION_LABEL_STYLE = {
fontFamily: "'IBM Plex Mono', monospace",
fontFamily: "'Inter', sans-serif",
fontSize: 9,
fontWeight: 500,
letterSpacing: '0',
textTransform: 'uppercase' as const,
}
function SectionLabel({ children }: { children: string }) {

View File

@@ -10,11 +10,11 @@ describe('TagPill', () => {
expect(pill.textContent).toBe('React')
})
it('renders with default style (blue)', () => {
it('renders with a hash-based accent color', () => {
render(<TagPill tag="Unknown" />)
const pill = screen.getByTitle('Unknown')
expect(pill.style.backgroundColor).toBe('var(--accent-blue-light)')
expect(pill.style.color).toBe('var(--accent-blue)')
expect(pill.style.backgroundColor).toMatch(/^var\(--accent-\w+-light\)$/)
expect(pill.style.color).toMatch(/^var\(--accent-\w+\)$/)
})
it('applies truncate for long names', () => {

View File

@@ -13,11 +13,10 @@ export function TagPill({ tag, className }: { tag: string; className?: string })
color: style.color,
borderRadius: 16,
padding: '1px 6px',
fontFamily: "'IBM Plex Mono', monospace",
fontFamily: "'Inter', sans-serif",
fontSize: 10,
fontWeight: 600,
letterSpacing: '1.2px',
textTransform: 'uppercase' as const,
letterSpacing: '0',
maxWidth: 160,
}}
title={tag}
@@ -90,11 +89,10 @@ function TagOption({
}
const SECTION_LABEL_STYLE = {
fontFamily: "'IBM Plex Mono', monospace",
fontFamily: "'Inter', sans-serif",
fontSize: 9,
fontWeight: 500,
letterSpacing: '1.2px',
textTransform: 'uppercase' as const,
letterSpacing: '0',
}
function SectionLabel({ children }: { children: string }) {

View File

@@ -66,9 +66,9 @@ describe('TypeCustomizePopover', () => {
it('renders color, icon, and template sections', () => {
renderPopover()
expect(screen.getByText('COLOR')).toBeInTheDocument()
expect(screen.getByText('ICON')).toBeInTheDocument()
expect(screen.getByText('TEMPLATE')).toBeInTheDocument()
expect(screen.getByText('Color')).toBeInTheDocument()
expect(screen.getByText('Icon')).toBeInTheDocument()
expect(screen.getByText('Template')).toBeInTheDocument()
expect(screen.getByText('Done')).toBeInTheDocument()
})

View File

@@ -75,7 +75,7 @@ export function TypeCustomizePopover({
onContextMenu={(e) => e.stopPropagation()}
>
{/* Color section */}
<div className="font-mono-overline mb-2 text-muted-foreground">COLOR</div>
<div className="font-mono-overline mb-2 text-muted-foreground">Color</div>
<div className="flex gap-2 mb-3 flex-wrap">
{ACCENT_COLORS.map((c) => (
<button
@@ -92,7 +92,7 @@ export function TypeCustomizePopover({
</div>
{/* Icon section */}
<div className="font-mono-overline mb-2 text-muted-foreground">ICON</div>
<div className="font-mono-overline mb-2 text-muted-foreground">Icon</div>
{/* Search input */}
<div className="relative mb-2">
@@ -136,7 +136,7 @@ export function TypeCustomizePopover({
</div>
{/* Template section */}
<div className="font-mono-overline mb-2 mt-3 text-muted-foreground">TEMPLATE</div>
<div className="font-mono-overline mb-2 mt-3 text-muted-foreground">Template</div>
<textarea
value={templateText}
onChange={(e) => handleTemplateChange(e.target.value)}

View File

@@ -23,7 +23,7 @@ function ReadOnlyType({ isA, customColorKey, onNavigate }: { isA?: string | null
if (!isA) return null
return (
<div className="grid min-w-0 grid-cols-2 items-center gap-2 px-1.5">
<span className="font-mono-overline shrink-0 text-muted-foreground">Type</span>
<span className="text-[12px] shrink-0 text-muted-foreground">Type</span>
<div className="min-w-0">
{onNavigate ? (
<button
@@ -58,7 +58,7 @@ export function TypeSelector({ isA, customColorKey, availableTypes, typeColorKey
return (
<div className="grid min-w-0 grid-cols-2 items-center gap-2 px-1.5" data-testid="type-selector">
<span className="font-mono-overline shrink-0 text-muted-foreground">Type</span>
<span className="text-[12px] shrink-0 text-muted-foreground">Type</span>
<div className="min-w-0">
<Select value={currentValue} onValueChange={v => onUpdateProperty('type', v === TYPE_NONE ? null : v)}>
<SelectTrigger

View File

@@ -1,7 +1,5 @@
import { useState } from 'react'
import type { VaultEntry } from '../../types'
import { CaretRight, Trash } from '@phosphor-icons/react'
import { getTypeColor } from '../../utils/typeColors'
import { ArrowUpRight, Trash } from '@phosphor-icons/react'
import { isEmoji } from '../../utils/emoji'
import { entryStatusTitle } from './shared'
import { StatusSuffix } from './LinkButton'
@@ -11,23 +9,21 @@ export interface BacklinkItem {
context: string | null
}
function BacklinkEntry({ entry, context, typeEntryMap, onNavigate }: {
function BacklinkEntry({ entry, context, onNavigate }: {
entry: VaultEntry
context: string | null
typeEntryMap: Record<string, VaultEntry>
onNavigate: (target: string) => void
}) {
const te = typeEntryMap[entry.isA ?? '']
const isDimmed = entry.archived || entry.trashed
return (
<button
className="flex w-full cursor-pointer flex-col items-start gap-0.5 border-none bg-transparent p-0 text-left hover:opacity-80"
className="flex w-full cursor-pointer flex-col items-start gap-0.5 border-none bg-transparent p-0 text-left hover:underline"
onClick={() => onNavigate(entry.title)}
title={entryStatusTitle(entry)}
>
<span
className="flex items-center gap-1 text-xs font-medium"
style={{ color: isDimmed ? 'var(--muted-foreground)' : getTypeColor(entry.isA, te?.color) }}
className="flex items-center gap-1 text-xs text-primary"
style={isDimmed ? { color: 'var(--muted-foreground)' } : undefined}
>
{entry.trashed && <Trash size={12} className="shrink-0" />}
{entry.icon && isEmoji(entry.icon) && <span className="shrink-0">{entry.icon}</span>}
@@ -43,42 +39,28 @@ function BacklinkEntry({ entry, context, typeEntryMap, onNavigate }: {
)
}
export function BacklinksPanel({ backlinks, typeEntryMap, onNavigate }: {
export function BacklinksPanel({ backlinks, onNavigate }: {
backlinks: BacklinkItem[]
typeEntryMap: Record<string, VaultEntry>
onNavigate: (target: string) => void
}) {
const [expanded, setExpanded] = useState(false)
if (backlinks.length === 0) return null
return (
<div>
<button
className="font-mono-overline mb-2 flex w-full cursor-pointer items-center gap-1 border-none bg-transparent p-0 text-left text-muted-foreground hover:text-foreground"
onClick={() => setExpanded((v) => !v)}
data-testid="backlinks-toggle"
>
<CaretRight
size={12}
className="shrink-0 transition-transform"
style={{ transform: expanded ? 'rotate(90deg)' : undefined }}
/>
Backlinks ({backlinks.length})
</button>
{expanded && (
<div className="flex flex-col gap-1.5" data-testid="backlinks-list">
{backlinks.map(({ entry, context }) => (
<BacklinkEntry
key={entry.path}
entry={entry}
context={context}
typeEntryMap={typeEntryMap}
onNavigate={onNavigate}
/>
))}
</div>
)}
<h4 className="font-mono-overline mb-2 flex items-center gap-1 text-muted-foreground">
<ArrowUpRight size={12} className="shrink-0" />
Backlinks
</h4>
<div className="flex flex-col gap-1.5" data-testid="backlinks-list">
{backlinks.map(({ entry, context }) => (
<BacklinkEntry
key={entry.path}
entry={entry}
context={context}
onNavigate={onNavigate}
/>
))}
</div>
</div>
)
}

View File

@@ -1,3 +1,4 @@
import { ArrowCounterClockwise } from '@phosphor-icons/react'
import type { GitCommit } from '../../types'
function formatRelativeDate(timestamp: number): string {
@@ -11,26 +12,32 @@ function formatRelativeDate(timestamp: number): string {
}
export function GitHistoryPanel({ commits, onViewCommitDiff }: { commits: GitCommit[]; onViewCommitDiff?: (commitHash: string) => void }) {
if (commits.length === 0) return null
return (
<div>
<h4 className="font-mono-overline mb-2 text-muted-foreground">History</h4>
{commits.length === 0
? <p className="m-0 text-[13px] text-muted-foreground">No revision history</p>
: (
<div className="flex flex-col gap-2.5">
{commits.map((c) => (
<div key={c.hash} style={{ borderLeft: '2px solid var(--border)', paddingLeft: 10 }}>
<div className="mb-0.5 flex items-center justify-between">
<button className="border-none bg-transparent p-0 font-mono text-primary cursor-pointer hover:underline" style={{ fontSize: 11 }} onClick={() => onViewCommitDiff?.(c.hash)} title={`View diff for ${c.shortHash}`}>{c.shortHash}</button>
<span className="text-muted-foreground" style={{ fontSize: 10 }}>{formatRelativeDate(c.date)}</span>
</div>
<div className="truncate text-xs text-secondary-foreground">{c.message}</div>
{c.author && <div className="truncate text-muted-foreground" style={{ fontSize: 10, marginTop: 1 }}>{c.author}</div>}
</div>
))}
<h4 className="font-mono-overline mb-2 flex items-center gap-1 text-muted-foreground">
<ArrowCounterClockwise size={12} className="shrink-0" />
History
</h4>
<div className="flex flex-col gap-2.5">
{commits.map((c) => (
<div key={c.hash} style={{ borderLeft: '2px solid var(--border)', paddingLeft: 10 }}>
<button
className="mb-0.5 w-full cursor-pointer truncate border-none bg-transparent p-0 text-left text-xs text-primary hover:underline"
onClick={() => onViewCommitDiff?.(c.hash)}
title={`View diff for ${c.shortHash}`}
>
<span className="font-mono" style={{ fontSize: 11 }}>{c.shortHash}</span>
{' · '}
{c.message}
</button>
<div className="text-muted-foreground" style={{ fontSize: 10 }}>
{formatRelativeDate(c.date)}
</div>
</div>
)
}
))}
</div>
</div>
)
}

View File

@@ -31,7 +31,7 @@ export function ReferencedByPanel({ items, typeEntryMap, onNavigate }: {
<div className="flex flex-col gap-2.5">
{grouped.map(([viaKey, groupEntries]) => (
<div key={viaKey}>
<span className="mb-1 block font-mono text-muted-foreground" style={{ fontSize: 9, fontWeight: 400, letterSpacing: '1.2px', textTransform: 'uppercase', opacity: 0.7 }}>
<span className="mb-1 block text-muted-foreground" style={{ fontSize: 9, fontWeight: 400, letterSpacing: '0.02em', opacity: 0.7 }}>
{viaKey}
</span>
<div className="flex flex-col gap-0.5">

View File

@@ -8,10 +8,10 @@ interface InboxFilterPillsProps {
}
const PILLS: { value: InboxPeriod; label: string }[] = [
{ value: 'week', label: 'This week' },
{ value: 'month', label: 'This month' },
{ value: 'quarter', label: 'This quarter' },
{ value: 'all', label: 'All time' },
{ value: 'week', label: 'Week' },
{ value: 'month', label: 'Month' },
{ value: 'quarter', label: 'Quarter' },
{ value: 'all', label: 'All' },
]
function InboxFilterPillsInner({ active, counts, onChange }: InboxFilterPillsProps) {

View File

@@ -0,0 +1,17 @@
import type { CommandAction } from './types'
import type { NoteListFilter } from '../../utils/noteListHelpers'
interface FilterCommandsConfig {
isSectionGroup: boolean
noteListFilter?: NoteListFilter
onSetNoteListFilter?: (filter: NoteListFilter) => void
}
export function buildFilterCommands(config: FilterCommandsConfig): CommandAction[] {
const { isSectionGroup, noteListFilter, onSetNoteListFilter } = config
return [
{ id: 'filter-open', label: 'Show Open Notes', group: 'Navigation', keywords: ['filter', 'open', 'active', 'pill'], enabled: !!isSectionGroup && noteListFilter !== 'open', execute: () => onSetNoteListFilter?.('open') },
{ id: 'filter-archived', label: 'Show Archived Notes', group: 'Navigation', keywords: ['filter', 'archived', 'pill'], enabled: !!isSectionGroup && noteListFilter !== 'archived', execute: () => onSetNoteListFilter?.('archived') },
{ id: 'filter-trashed', label: 'Show Trashed Notes', group: 'Navigation', keywords: ['filter', 'trashed', 'trash', 'pill', 'deleted'], enabled: !!isSectionGroup && noteListFilter !== 'trashed', execute: () => onSetNoteListFilter?.('trashed') },
]
}

View File

@@ -0,0 +1,20 @@
import type { CommandAction } from './types'
import type { SidebarSelection } from '../../types'
interface GitCommandsConfig {
modifiedCount: number
onCommitPush: () => void
onPull?: () => void
onResolveConflicts?: () => void
onSelect: (sel: SidebarSelection) => void
}
export function buildGitCommands(config: GitCommandsConfig): CommandAction[] {
const { modifiedCount, onCommitPush, onPull, onResolveConflicts, onSelect } = config
return [
{ id: 'commit-push', label: 'Commit & Push', group: 'Git', keywords: ['git', 'save', 'sync'], enabled: modifiedCount > 0, execute: onCommitPush },
{ id: 'git-pull', label: 'Pull from Remote', group: 'Git', keywords: ['git', 'pull', 'fetch', 'download', 'sync', 'remote'], enabled: true, execute: () => onPull?.() },
{ id: 'resolve-conflicts', label: 'Resolve Conflicts', group: 'Git', keywords: ['conflict', 'merge', 'git', 'sync'], enabled: true, execute: () => onResolveConflicts?.() },
{ id: 'view-changes', label: 'View Pending Changes', group: 'Git', keywords: ['modified', 'diff'], enabled: true, execute: () => onSelect({ kind: 'filter', filter: 'changes' }) },
]
}

View File

@@ -0,0 +1,9 @@
export type { CommandAction, CommandGroup } from './types'
export { groupSortKey } from './types'
export { buildNavigationCommands } from './navigationCommands'
export { buildNoteCommands } from './noteCommands'
export { buildGitCommands } from './gitCommands'
export { buildViewCommands } from './viewCommands'
export { buildSettingsCommands } from './settingsCommands'
export { buildTypeCommands, extractVaultTypes, pluralizeType } from './typeCommands'
export { buildFilterCommands } from './filterCommands'

View File

@@ -0,0 +1,27 @@
import type { CommandAction } from './types'
import type { SidebarSelection } from '../../types'
interface NavigationCommandsConfig {
onQuickOpen: () => void
onSelect: (sel: SidebarSelection) => void
onOpenDailyNote: () => void
onGoBack?: () => void
onGoForward?: () => void
canGoBack?: boolean
canGoForward?: boolean
}
export function buildNavigationCommands(config: NavigationCommandsConfig): CommandAction[] {
const { onQuickOpen, onSelect, onGoBack, onGoForward, canGoBack, canGoForward } = config
return [
{ id: 'search-notes', label: 'Search Notes', group: 'Navigation', shortcut: '⌘P', keywords: ['find', 'open', 'quick'], enabled: true, execute: onQuickOpen },
{ id: 'go-all', label: 'Go to All Notes', group: 'Navigation', keywords: ['filter'], enabled: true, execute: () => onSelect({ kind: 'filter', filter: 'all' }) },
{ id: 'go-archived', label: 'Go to Archived', group: 'Navigation', keywords: [], enabled: true, execute: () => onSelect({ kind: 'filter', filter: 'archived' }) },
{ id: 'go-trash', label: 'Go to Trash', group: 'Navigation', keywords: ['deleted'], enabled: true, execute: () => onSelect({ kind: 'filter', filter: 'trash' }) },
{ id: 'go-changes', label: 'Go to Changes', group: 'Navigation', keywords: ['git', 'modified', 'pending'], enabled: true, execute: () => onSelect({ kind: 'filter', filter: 'changes' }) },
{ id: 'go-pulse', label: 'Go to Pulse', group: 'Navigation', keywords: ['activity', 'history', 'commits', 'git', 'feed'], enabled: true, execute: () => onSelect({ kind: 'filter', filter: 'pulse' }) },
{ id: 'go-inbox', label: 'Go to Inbox', group: 'Navigation', keywords: ['inbox', 'unlinked', 'orphan', 'unorganized', 'triage'], enabled: true, execute: () => onSelect({ kind: 'filter', filter: 'inbox' }) },
{ id: 'go-back', label: 'Go Back', group: 'Navigation', shortcut: '⌘[', keywords: ['previous', 'history', 'back'], enabled: !!canGoBack, execute: () => onGoBack?.() },
{ id: 'go-forward', label: 'Go Forward', group: 'Navigation', shortcut: '⌘]', keywords: ['next', 'history', 'forward'], enabled: !!canGoForward, execute: () => onGoForward?.() },
]
}

View File

@@ -0,0 +1,69 @@
import type { CommandAction } from './types'
interface NoteCommandsConfig {
hasActiveNote: boolean
activeTabPath: string | null
isArchived: boolean
isTrashed: boolean
activeNoteHasIcon?: boolean
trashedCount?: number
onCreateNote: () => void
onCreateType?: () => void
onOpenDailyNote: () => void
onSave: () => void
onTrashNote: (path: string) => void
onRestoreNote: (path: string) => void
onArchiveNote: (path: string) => void
onUnarchiveNote: (path: string) => void
onEmptyTrash?: () => void
onSetNoteIcon?: () => void
onRemoveNoteIcon?: () => void
onOpenInNewWindow?: () => void
}
export function buildNoteCommands(config: NoteCommandsConfig): CommandAction[] {
const {
hasActiveNote, activeTabPath, isArchived, isTrashed,
onCreateNote, onCreateType, onOpenDailyNote, onSave,
onTrashNote, onRestoreNote, onArchiveNote, onUnarchiveNote,
onEmptyTrash, trashedCount,
onSetNoteIcon, onRemoveNoteIcon, activeNoteHasIcon,
onOpenInNewWindow,
} = config
return [
{ id: 'create-note', label: 'Create New Note', group: 'Note', shortcut: '⌘N', keywords: ['new', 'add'], enabled: true, execute: onCreateNote },
{ id: 'create-type', label: 'New Type', group: 'Note', keywords: ['new', 'create', 'type', 'template'], enabled: !!onCreateType, execute: () => onCreateType?.() },
{ id: 'open-daily-note', label: "Open Today's Note", group: 'Note', shortcut: '⌘J', keywords: ['daily', 'journal', 'today'], enabled: true, execute: onOpenDailyNote },
{ id: 'save-note', label: 'Save Note', group: 'Note', shortcut: '⌘S', keywords: ['write'], enabled: hasActiveNote, execute: onSave },
{ id: 'empty-trash', label: 'Empty Trash', group: 'Note', keywords: ['delete', 'permanently', 'purge', 'clear', 'trash'], enabled: (trashedCount ?? 0) > 0, execute: () => onEmptyTrash?.() },
{
id: 'trash-note', label: isTrashed ? 'Restore Note' : 'Trash Note', group: 'Note', shortcut: '⌘⌫',
keywords: ['delete', 'remove', 'restore', 'trash'], enabled: hasActiveNote,
execute: () => { if (activeTabPath) (isTrashed ? onRestoreNote : onTrashNote)(activeTabPath) },
},
{
id: 'archive-note', label: isArchived ? 'Unarchive Note' : 'Archive Note', group: 'Note', shortcut: '⌘E',
keywords: ['archive'], enabled: hasActiveNote,
execute: () => { if (activeTabPath) (isArchived ? onUnarchiveNote : onArchiveNote)(activeTabPath) },
},
{
id: 'set-note-icon', label: 'Set Note Icon', group: 'Note',
keywords: ['icon', 'emoji', 'set', 'add', 'change', 'picker'],
enabled: hasActiveNote && !!onSetNoteIcon,
execute: () => onSetNoteIcon?.(),
},
{
id: 'remove-note-icon', label: 'Remove Note Icon', group: 'Note',
keywords: ['icon', 'emoji', 'remove', 'delete', 'clear'],
enabled: hasActiveNote && !!activeNoteHasIcon && !!onRemoveNoteIcon,
execute: () => onRemoveNoteIcon?.(),
},
{
id: 'open-in-new-window', label: 'Open in New Window', group: 'Note', shortcut: '⌘⇧O',
keywords: ['window', 'new', 'detach', 'pop', 'external', 'separate'],
enabled: hasActiveNote,
execute: () => onOpenInNewWindow?.(),
},
]
}

View File

@@ -0,0 +1,34 @@
import type { CommandAction } from './types'
interface SettingsCommandsConfig {
mcpStatus?: string
vaultCount?: number
isGettingStartedHidden?: boolean
onOpenSettings: () => void
onOpenVault?: () => void
onRemoveActiveVault?: () => void
onRestoreGettingStarted?: () => void
onCheckForUpdates?: () => void
onInstallMcp?: () => void
onReloadVault?: () => void
onRepairVault?: () => void
}
export function buildSettingsCommands(config: SettingsCommandsConfig): CommandAction[] {
const {
mcpStatus, vaultCount, isGettingStartedHidden,
onOpenSettings, onOpenVault, onRemoveActiveVault, onRestoreGettingStarted,
onCheckForUpdates, onInstallMcp, onReloadVault, onRepairVault,
} = config
return [
{ id: 'open-settings', label: 'Open Settings', group: 'Settings', shortcut: '⌘,', keywords: ['preferences', 'config'], enabled: true, execute: onOpenSettings },
{ id: 'open-vault', label: 'Open Vault…', group: 'Settings', keywords: ['vault', 'folder', 'switch', 'open', 'workspace'], enabled: true, execute: () => onOpenVault?.() },
{ id: 'remove-vault', label: 'Remove Vault from List', group: 'Settings', keywords: ['vault', 'remove', 'disconnect', 'hide'], enabled: (vaultCount ?? 0) > 1 && !!onRemoveActiveVault, execute: () => onRemoveActiveVault?.() },
{ id: 'restore-getting-started', label: 'Restore Getting Started Vault', group: 'Settings', keywords: ['vault', 'restore', 'demo', 'getting started', 'reset'], enabled: !!isGettingStartedHidden && !!onRestoreGettingStarted, execute: () => onRestoreGettingStarted?.() },
{ id: 'check-updates', label: 'Check for Updates', group: 'Settings', keywords: ['update', 'version', 'upgrade', 'release'], enabled: true, execute: () => onCheckForUpdates?.() },
{ id: 'install-mcp', label: mcpStatus === 'installed' ? 'Restore MCP Server' : 'Install MCP Server', group: 'Settings', keywords: ['mcp', 'claude', 'ai', 'tools', 'install', 'restore', 'fix', 'repair'], enabled: true, execute: () => onInstallMcp?.() },
{ id: 'reload-vault', label: 'Reload Vault', group: 'Settings', keywords: ['reload', 'refresh', 'rescan', 'sync', 'filesystem', 'cache'], enabled: !!onReloadVault, execute: () => onReloadVault?.() },
{ id: 'repair-vault', label: 'Repair Vault', group: 'Settings', keywords: ['repair', 'fix', 'restore', 'config', 'agents', 'themes', 'missing', 'reset', 'flatten', 'structure'], enabled: !!onRepairVault, execute: () => onRepairVault?.() },
]
}

View File

@@ -0,0 +1,48 @@
import type { CommandAction } from './types'
import type { SidebarSelection, VaultEntry } from '../../types'
const PLURAL_OVERRIDES: Record<string, string> = {
Person: 'People',
Responsibility: 'Responsibilities',
}
const DEFAULT_TYPES = ['Event', 'Person', 'Project', 'Note']
export function pluralizeType(type: string): string {
if (PLURAL_OVERRIDES[type]) return PLURAL_OVERRIDES[type]
if (type.endsWith('s') || type.endsWith('x') || type.endsWith('ch') || type.endsWith('sh')) return `${type}es`
if (type.endsWith('y') && !/[aeiou]y$/i.test(type)) return `${type.slice(0, -1)}ies`
return `${type}s`
}
export function extractVaultTypes(entries: VaultEntry[]): string[] {
const typeSet = new Set<string>()
for (const e of entries) {
if (e.isA && e.isA !== 'Type' && !e.trashed) typeSet.add(e.isA)
}
if (typeSet.size === 0) return DEFAULT_TYPES
return Array.from(typeSet).sort()
}
export function buildTypeCommands(
types: string[],
onCreateNoteOfType: (type: string) => void,
onSelect: (sel: SidebarSelection) => void,
): CommandAction[] {
return types.flatMap((type) => {
const slug = type.toLowerCase().replace(/\s+/g, '-')
const plural = pluralizeType(type)
return [
{
id: `new-${slug}`, label: `New ${type}`, group: 'Note' as const,
keywords: ['new', 'create', type.toLowerCase()],
enabled: true, execute: () => onCreateNoteOfType(type),
},
{
id: `list-${slug}`, label: `List ${plural}`, group: 'Navigation' as const,
keywords: ['list', 'show', 'filter', type.toLowerCase(), plural.toLowerCase()],
enabled: true, execute: () => onSelect({ kind: 'sectionGroup', type }),
},
]
})
}

View File

@@ -0,0 +1,17 @@
export type CommandGroup = 'Navigation' | 'Note' | 'Git' | 'View' | 'Settings'
export interface CommandAction {
id: string
label: string
group: CommandGroup
shortcut?: string
keywords?: string[]
enabled: boolean
execute: () => void
}
const GROUP_ORDER: CommandGroup[] = ['Navigation', 'Note', 'Git', 'View', 'Settings']
export function groupSortKey(group: CommandGroup): number {
return GROUP_ORDER.indexOf(group)
}

View File

@@ -0,0 +1,38 @@
import type { CommandAction } from './types'
import type { ViewMode } from '../useViewMode'
interface ViewCommandsConfig {
hasActiveNote: boolean
activeNoteModified: boolean
onSetViewMode: (mode: ViewMode) => void
onToggleInspector: () => void
onToggleDiff?: () => void
onToggleRawEditor?: () => void
onToggleAIChat?: () => void
zoomLevel: number
onZoomIn: () => void
onZoomOut: () => void
onZoomReset: () => void
}
export function buildViewCommands(config: ViewCommandsConfig): CommandAction[] {
const {
hasActiveNote, activeNoteModified,
onSetViewMode, onToggleInspector, onToggleDiff, onToggleRawEditor, onToggleAIChat,
zoomLevel, onZoomIn, onZoomOut, onZoomReset,
} = config
return [
{ id: 'view-editor', label: 'Editor Only', group: 'View', shortcut: '⌘1', keywords: ['layout', 'focus'], enabled: true, execute: () => onSetViewMode('editor-only') },
{ id: 'view-editor-list', label: 'Editor + Note List', group: 'View', shortcut: '⌘2', keywords: ['layout'], enabled: true, execute: () => onSetViewMode('editor-list') },
{ id: 'view-all', label: 'Full Layout', group: 'View', shortcut: '⌘3', keywords: ['layout', 'sidebar'], enabled: true, execute: () => onSetViewMode('all') },
{ id: 'toggle-inspector', label: 'Toggle Properties Panel', group: 'View', keywords: ['properties', 'inspector', 'panel', 'right', 'sidebar'], enabled: true, execute: onToggleInspector },
{ id: 'toggle-diff', label: 'Toggle Diff Mode', group: 'View', keywords: ['diff', 'changes', 'git', 'compare', 'version'], enabled: hasActiveNote && activeNoteModified, execute: () => onToggleDiff?.() },
{ id: 'toggle-raw-editor', label: 'Toggle Raw Editor', group: 'View', keywords: ['raw', 'source', 'markdown', 'frontmatter', 'code', 'textarea'], enabled: hasActiveNote, execute: () => onToggleRawEditor?.() },
{ id: 'toggle-ai-panel', label: 'Toggle AI Panel', group: 'View', shortcut: '⌘I', keywords: ['ai', 'agent', 'chat', 'assistant', 'contextual'], enabled: true, execute: () => onToggleAIChat?.() },
{ id: 'toggle-backlinks', label: 'Toggle Backlinks', group: 'View', keywords: ['backlinks', 'references', 'links', 'mentions', 'incoming'], enabled: hasActiveNote, execute: onToggleInspector },
{ id: 'zoom-in', label: `Zoom In (${zoomLevel}%)`, group: 'View', shortcut: '⌘=', keywords: ['zoom', 'bigger', 'larger', 'scale'], enabled: zoomLevel < 150, execute: onZoomIn },
{ id: 'zoom-out', label: `Zoom Out (${zoomLevel}%)`, group: 'View', shortcut: '⌘-', keywords: ['zoom', 'smaller', 'scale'], enabled: zoomLevel > 80, execute: onZoomOut },
{ id: 'zoom-reset', label: 'Reset Zoom', group: 'View', shortcut: '⌘0', keywords: ['zoom', 'actual', 'default', '100'], enabled: zoomLevel !== 100, execute: onZoomReset },
]
}

View File

@@ -2,24 +2,24 @@ import { useMemo } from 'react'
import type { SidebarSelection, VaultEntry } from '../types'
import type { NoteListFilter } from '../utils/noteListHelpers'
import type { ViewMode } from './useViewMode'
import { buildNavigationCommands } from './commands/navigationCommands'
import { buildNoteCommands } from './commands/noteCommands'
import { buildGitCommands } from './commands/gitCommands'
import { buildViewCommands } from './commands/viewCommands'
import { buildSettingsCommands } from './commands/settingsCommands'
import { buildTypeCommands, extractVaultTypes } from './commands/typeCommands'
import { buildFilterCommands } from './commands/filterCommands'
export type CommandGroup = 'Navigation' | 'Note' | 'Git' | 'View' | 'Settings'
export interface CommandAction {
id: string
label: string
group: CommandGroup
shortcut?: string
keywords?: string[]
enabled: boolean
execute: () => void
}
// Re-export types and helpers for backward compatibility
export type { CommandAction, CommandGroup } from './commands/types'
export { groupSortKey } from './commands/types'
export { pluralizeType, extractVaultTypes, buildTypeCommands } from './commands/typeCommands'
export { buildViewCommands } from './commands/viewCommands'
interface CommandRegistryConfig {
activeTabPath: string | null
entries: VaultEntry[]
modifiedCount: number
/** Whether the active note has an emoji icon set. */
activeNoteHasIcon?: boolean
mcpStatus?: string
onInstallMcp?: () => void
@@ -30,7 +30,6 @@ interface CommandRegistryConfig {
onSetNoteIcon?: () => void
onRemoveNoteIcon?: () => void
onOpenInNewWindow?: () => void
onQuickOpen: () => void
onCreateNote: () => void
onCreateNoteOfType: (type: string) => void
@@ -66,93 +65,12 @@ interface CommandRegistryConfig {
onRestoreGettingStarted?: () => void
isGettingStartedHidden?: boolean
vaultCount?: number
/** Current selection — used to scope filter pill commands to section group views. */
selection?: SidebarSelection
noteListFilter?: NoteListFilter
onSetNoteListFilter?: (filter: NoteListFilter) => void
}
const PLURAL_OVERRIDES: Record<string, string> = {
Person: 'People',
Responsibility: 'Responsibilities',
}
const DEFAULT_TYPES = ['Event', 'Person', 'Project', 'Note']
export function pluralizeType(type: string): string {
if (PLURAL_OVERRIDES[type]) return PLURAL_OVERRIDES[type]
if (type.endsWith('s') || type.endsWith('x') || type.endsWith('ch') || type.endsWith('sh')) return `${type}es`
if (type.endsWith('y') && !/[aeiou]y$/i.test(type)) return `${type.slice(0, -1)}ies`
return `${type}s`
}
export function extractVaultTypes(entries: VaultEntry[]): string[] {
const typeSet = new Set<string>()
for (const e of entries) {
if (e.isA && e.isA !== 'Type' && !e.trashed) typeSet.add(e.isA)
}
if (typeSet.size === 0) return DEFAULT_TYPES
return Array.from(typeSet).sort()
}
const GROUP_ORDER: CommandGroup[] = ['Navigation', 'Note', 'Git', 'View', 'Settings']
export function groupSortKey(group: CommandGroup): number {
return GROUP_ORDER.indexOf(group)
}
export function buildTypeCommands(
types: string[],
onCreateNoteOfType: (type: string) => void,
onSelect: (sel: SidebarSelection) => void,
): CommandAction[] {
return types.flatMap((type) => {
const slug = type.toLowerCase().replace(/\s+/g, '-')
const plural = pluralizeType(type)
return [
{
id: `new-${slug}`, label: `New ${type}`, group: 'Note' as CommandGroup,
keywords: ['new', 'create', type.toLowerCase()],
enabled: true, execute: () => onCreateNoteOfType(type),
},
{
id: `list-${slug}`, label: `List ${plural}`, group: 'Navigation' as CommandGroup,
keywords: ['list', 'show', 'filter', type.toLowerCase(), plural.toLowerCase()],
enabled: true, execute: () => onSelect({ kind: 'sectionGroup', type }),
},
]
})
}
export function buildViewCommands(
hasActiveNote: boolean,
activeNoteModified: boolean,
onSetViewMode: (mode: ViewMode) => void,
onToggleInspector: () => void,
onToggleDiff: (() => void) | undefined,
onToggleRawEditor: (() => void) | undefined,
onToggleAIChat: (() => void) | undefined,
zoomLevel: number,
onZoomIn: () => void,
onZoomOut: () => void,
onZoomReset: () => void,
): CommandAction[] {
return [
{ id: 'view-editor', label: 'Editor Only', group: 'View', shortcut: '⌘1', keywords: ['layout', 'focus'], enabled: true, execute: () => onSetViewMode('editor-only') },
{ id: 'view-editor-list', label: 'Editor + Note List', group: 'View', shortcut: '⌘2', keywords: ['layout'], enabled: true, execute: () => onSetViewMode('editor-list') },
{ id: 'view-all', label: 'Full Layout', group: 'View', shortcut: '⌘3', keywords: ['layout', 'sidebar'], enabled: true, execute: () => onSetViewMode('all') },
{ id: 'toggle-inspector', label: 'Toggle Properties Panel', group: 'View', keywords: ['properties', 'inspector', 'panel', 'right', 'sidebar'], enabled: true, execute: onToggleInspector },
{ id: 'toggle-diff', label: 'Toggle Diff Mode', group: 'View', keywords: ['diff', 'changes', 'git', 'compare', 'version'], enabled: hasActiveNote && activeNoteModified, execute: () => onToggleDiff?.() },
{ id: 'toggle-raw-editor', label: 'Toggle Raw Editor', group: 'View', keywords: ['raw', 'source', 'markdown', 'frontmatter', 'code', 'textarea'], enabled: hasActiveNote, execute: () => onToggleRawEditor?.() },
{ id: 'toggle-ai-panel', label: 'Toggle AI Panel', group: 'View', shortcut: '⌘I', keywords: ['ai', 'agent', 'chat', 'assistant', 'contextual'], enabled: true, execute: () => onToggleAIChat?.() },
{ id: 'toggle-backlinks', label: 'Toggle Backlinks', group: 'View', keywords: ['backlinks', 'references', 'links', 'mentions', 'incoming'], enabled: hasActiveNote, execute: onToggleInspector },
{ id: 'zoom-in', label: `Zoom In (${zoomLevel}%)`, group: 'View', shortcut: '⌘=', keywords: ['zoom', 'bigger', 'larger', 'scale'], enabled: zoomLevel < 150, execute: onZoomIn },
{ id: 'zoom-out', label: `Zoom Out (${zoomLevel}%)`, group: 'View', shortcut: '⌘-', keywords: ['zoom', 'smaller', 'scale'], enabled: zoomLevel > 80, execute: onZoomOut },
{ id: 'zoom-reset', label: 'Reset Zoom', group: 'View', shortcut: '⌘0', keywords: ['zoom', 'actual', 'default', '100'], enabled: zoomLevel !== 100, execute: onZoomReset },
]
}
export function useCommandRegistry(config: CommandRegistryConfig): CommandAction[] {
export function useCommandRegistry(config: CommandRegistryConfig): import('./commands/types').CommandAction[] {
const {
activeTabPath, entries, modifiedCount,
onQuickOpen, onCreateNote, onCreateNoteOfType, onSave, onOpenSettings,
@@ -162,21 +80,15 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction
onZoomIn, onZoomOut, onZoomReset, zoomLevel,
onSelect, onOpenDailyNote,
onGoBack, onGoForward, canGoBack, canGoForward,
onCheckForUpdates,
onCreateType,
onCheckForUpdates, onCreateType,
onRemoveActiveVault, onRestoreGettingStarted, isGettingStartedHidden, vaultCount,
mcpStatus, onInstallMcp,
onEmptyTrash, trashedCount,
onReloadVault,
onRepairVault,
onSetNoteIcon,
onRemoveNoteIcon,
activeNoteHasIcon,
mcpStatus, onInstallMcp, onEmptyTrash, trashedCount,
onReloadVault, onRepairVault,
onSetNoteIcon, onRemoveNoteIcon, activeNoteHasIcon,
onOpenInNewWindow,
selection, noteListFilter, onSetNoteListFilter,
} = config
const isSectionGroup = selection?.kind === 'sectionGroup'
const hasActiveNote = activeTabPath !== null
const activeEntry = useMemo(
@@ -185,87 +97,31 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction
)
const isArchived = activeEntry?.archived ?? false
const isTrashed = activeEntry?.trashed ?? false
const isSectionGroup = selection?.kind === 'sectionGroup'
const vaultTypes = useMemo(() => extractVaultTypes(entries), [entries])
return useMemo(() => {
const cmds: CommandAction[] = [
// Navigation
{ id: 'search-notes', label: 'Search Notes', group: 'Navigation', shortcut: '⌘P', keywords: ['find', 'open', 'quick'], enabled: true, execute: onQuickOpen },
{ id: 'go-all', label: 'Go to All Notes', group: 'Navigation', keywords: ['filter'], enabled: true, execute: () => onSelect({ kind: 'filter', filter: 'all' }) },
{ id: 'go-archived', label: 'Go to Archived', group: 'Navigation', keywords: [], enabled: true, execute: () => onSelect({ kind: 'filter', filter: 'archived' }) },
{ id: 'go-trash', label: 'Go to Trash', group: 'Navigation', keywords: ['deleted'], enabled: true, execute: () => onSelect({ kind: 'filter', filter: 'trash' }) },
{ id: 'empty-trash', label: 'Empty Trash', group: 'Note', keywords: ['delete', 'permanently', 'purge', 'clear', 'trash'], enabled: (trashedCount ?? 0) > 0, execute: () => onEmptyTrash?.() },
{ id: 'go-changes', label: 'Go to Changes', group: 'Navigation', keywords: ['git', 'modified', 'pending'], enabled: true, execute: () => onSelect({ kind: 'filter', filter: 'changes' }) },
{ id: 'go-pulse', label: 'Go to Pulse', group: 'Navigation', keywords: ['activity', 'history', 'commits', 'git', 'feed'], enabled: true, execute: () => onSelect({ kind: 'filter', filter: 'pulse' }) },
{ id: 'go-inbox', label: 'Go to Inbox', group: 'Navigation', keywords: ['inbox', 'unlinked', 'orphan', 'unorganized', 'triage'], enabled: true, execute: () => onSelect({ kind: 'filter', filter: 'inbox' }) },
{ id: 'go-back', label: 'Go Back', group: 'Navigation', shortcut: '⌘[', keywords: ['previous', 'history', 'back'], enabled: !!canGoBack, execute: () => onGoBack?.() },
{ id: 'go-forward', label: 'Go Forward', group: 'Navigation', shortcut: '⌘]', keywords: ['next', 'history', 'forward'], enabled: !!canGoForward, execute: () => onGoForward?.() },
// Note actions (contextual)
{ id: 'create-note', label: 'Create New Note', group: 'Note', shortcut: '⌘N', keywords: ['new', 'add'], enabled: true, execute: onCreateNote },
{ id: 'create-type', label: 'New Type', group: 'Note', keywords: ['new', 'create', 'type', 'template'], enabled: !!onCreateType, execute: () => onCreateType?.() },
{ id: 'open-daily-note', label: "Open Today's Note", group: 'Note', shortcut: '⌘J', keywords: ['daily', 'journal', 'today'], enabled: true, execute: onOpenDailyNote },
{ id: 'save-note', label: 'Save Note', group: 'Note', shortcut: '⌘S', keywords: ['write'], enabled: hasActiveNote, execute: onSave },
{
id: 'trash-note', label: isTrashed ? 'Restore Note' : 'Trash Note', group: 'Note', shortcut: '⌘⌫',
keywords: ['delete', 'remove', 'restore', 'trash'], enabled: hasActiveNote,
execute: () => { if (activeTabPath) (isTrashed ? onRestoreNote : onTrashNote)(activeTabPath) },
},
{
id: 'archive-note', label: isArchived ? 'Unarchive Note' : 'Archive Note', group: 'Note', shortcut: '⌘E',
keywords: ['archive'], enabled: hasActiveNote,
execute: () => { if (activeTabPath) (isArchived ? onUnarchiveNote : onArchiveNote)(activeTabPath) },
},
{
id: 'set-note-icon', label: 'Set Note Icon', group: 'Note',
keywords: ['icon', 'emoji', 'set', 'add', 'change', 'picker'],
enabled: hasActiveNote && !!onSetNoteIcon,
execute: () => onSetNoteIcon?.(),
},
{
id: 'remove-note-icon', label: 'Remove Note Icon', group: 'Note',
keywords: ['icon', 'emoji', 'remove', 'delete', 'clear'],
enabled: hasActiveNote && !!activeNoteHasIcon && !!onRemoveNoteIcon,
execute: () => onRemoveNoteIcon?.(),
},
{
id: 'open-in-new-window', label: 'Open in New Window', group: 'Note', shortcut: '⌘⇧O',
keywords: ['window', 'new', 'detach', 'pop', 'external', 'separate'],
enabled: hasActiveNote,
execute: () => onOpenInNewWindow?.(),
},
// Git
{ id: 'commit-push', label: 'Commit & Push', group: 'Git', keywords: ['git', 'save', 'sync'], enabled: modifiedCount > 0, execute: onCommitPush },
{ id: 'git-pull', label: 'Pull from Remote', group: 'Git', keywords: ['git', 'pull', 'fetch', 'download', 'sync', 'remote'], enabled: true, execute: () => onPull?.() },
{ id: 'resolve-conflicts', label: 'Resolve Conflicts', group: 'Git', keywords: ['conflict', 'merge', 'git', 'sync'], enabled: true, execute: () => onResolveConflicts?.() },
{ id: 'view-changes', label: 'View Pending Changes', group: 'Git', keywords: ['modified', 'diff'], enabled: true, execute: () => onSelect({ kind: 'filter', filter: 'changes' }) },
// View
...buildViewCommands(hasActiveNote, activeNoteModified, onSetViewMode, onToggleInspector, onToggleDiff, onToggleRawEditor, onToggleAIChat, zoomLevel, onZoomIn, onZoomOut, onZoomReset),
// Settings
{ id: 'open-settings', label: 'Open Settings', group: 'Settings', shortcut: '⌘,', keywords: ['preferences', 'config'], enabled: true, execute: onOpenSettings },
{ id: 'open-vault', label: 'Open Vault…', group: 'Settings', keywords: ['vault', 'folder', 'switch', 'open', 'workspace'], enabled: true, execute: () => onOpenVault?.() },
{ id: 'remove-vault', label: 'Remove Vault from List', group: 'Settings', keywords: ['vault', 'remove', 'disconnect', 'hide'], enabled: (vaultCount ?? 0) > 1 && !!onRemoveActiveVault, execute: () => onRemoveActiveVault?.() },
{ id: 'restore-getting-started', label: 'Restore Getting Started Vault', group: 'Settings', keywords: ['vault', 'restore', 'demo', 'getting started', 'reset'], enabled: !!isGettingStartedHidden && !!onRestoreGettingStarted, execute: () => onRestoreGettingStarted?.() },
{ id: 'check-updates', label: 'Check for Updates', group: 'Settings', keywords: ['update', 'version', 'upgrade', 'release'], enabled: true, execute: () => onCheckForUpdates?.() },
{ id: 'install-mcp', label: mcpStatus === 'installed' ? 'Restore MCP Server' : 'Install MCP Server', group: 'Settings', keywords: ['mcp', 'claude', 'ai', 'tools', 'install', 'restore', 'fix', 'repair'], enabled: true, execute: () => onInstallMcp?.() },
{ id: 'reload-vault', label: 'Reload Vault', group: 'Settings', keywords: ['reload', 'refresh', 'rescan', 'sync', 'filesystem', 'cache'], enabled: !!onReloadVault, execute: () => onReloadVault?.() },
{ id: 'repair-vault', label: 'Repair Vault', group: 'Settings', keywords: ['repair', 'fix', 'restore', 'config', 'agents', 'themes', 'missing', 'reset', 'flatten', 'structure'], enabled: !!onRepairVault, execute: () => onRepairVault?.() },
// Type-aware: "New [Type]" and "List [Type]"
...buildTypeCommands(vaultTypes, onCreateNoteOfType, onSelect),
// Note list filter pills (scoped to section group views)
{ id: 'filter-open', label: 'Show Open Notes', group: 'Navigation', keywords: ['filter', 'open', 'active', 'pill'], enabled: !!isSectionGroup && noteListFilter !== 'open', execute: () => onSetNoteListFilter?.('open') },
{ id: 'filter-archived', label: 'Show Archived Notes', group: 'Navigation', keywords: ['filter', 'archived', 'pill'], enabled: !!isSectionGroup && noteListFilter !== 'archived', execute: () => onSetNoteListFilter?.('archived') },
{ id: 'filter-trashed', label: 'Show Trashed Notes', group: 'Navigation', keywords: ['filter', 'trashed', 'trash', 'pill', 'deleted'], enabled: !!isSectionGroup && noteListFilter !== 'trashed', execute: () => onSetNoteListFilter?.('trashed') },
]
return cmds
}, [
return useMemo(() => [
...buildNavigationCommands({ onQuickOpen, onSelect, onOpenDailyNote, onGoBack, onGoForward, canGoBack, canGoForward }),
...buildNoteCommands({
hasActiveNote, activeTabPath, isArchived, isTrashed,
onCreateNote, onCreateType, onOpenDailyNote, onSave,
onTrashNote, onRestoreNote, onArchiveNote, onUnarchiveNote,
onEmptyTrash, trashedCount, onSetNoteIcon, onRemoveNoteIcon, activeNoteHasIcon, onOpenInNewWindow,
}),
...buildGitCommands({ modifiedCount, onCommitPush, onPull, onResolveConflicts, onSelect }),
...buildViewCommands({
hasActiveNote, activeNoteModified, onSetViewMode, onToggleInspector,
onToggleDiff, onToggleRawEditor, onToggleAIChat, zoomLevel, onZoomIn, onZoomOut, onZoomReset,
}),
...buildSettingsCommands({
mcpStatus, vaultCount, isGettingStartedHidden,
onOpenSettings, onOpenVault, onRemoveActiveVault, onRestoreGettingStarted,
onCheckForUpdates, onInstallMcp, onReloadVault, onRepairVault,
}),
...buildTypeCommands(vaultTypes, onCreateNoteOfType, onSelect),
...buildFilterCommands({ isSectionGroup, noteListFilter, onSetNoteListFilter }),
], [
hasActiveNote, activeTabPath, isArchived, isTrashed, modifiedCount, activeNoteModified,
onQuickOpen, onCreateNote, onCreateNoteOfType, onCreateType, onSave, onOpenSettings,
onTrashNote, onRestoreNote, onArchiveNote, onUnarchiveNote,

View File

@@ -149,22 +149,20 @@
}
}
/* --- IBM Plex Mono label styles (from typography scale) --- */
/* --- Label typography (Inter, sentence case) --- */
/* t8: Section labels, metadata tags — 11px medium */
.font-mono-label {
font-family: 'IBM Plex Mono', monospace;
font-family: 'Inter', sans-serif;
font-size: 11px;
font-weight: 500;
text-transform: uppercase;
}
/* t9: Overlines, category labels — 10px regular */
.font-mono-overline {
font-family: 'IBM Plex Mono', monospace;
font-family: 'Inter', sans-serif;
font-size: 10px;
font-weight: 400;
letter-spacing: 0.02em;
text-transform: uppercase;
}
.referenced-by-panel button:hover {

View File

@@ -77,6 +77,14 @@ describe('detectPropertyType', () => {
expect(detectPropertyType('custom_list', ['x', 'y'])).toBe('text')
})
it('detects tags from tag-like key names even with scalar string values', () => {
expect(detectPropertyType('tags', 'Has Pic')).toBe('tags')
expect(detectPropertyType('Tags', 'solo-tag')).toBe('tags')
expect(detectPropertyType('keywords', 'react')).toBe('tags')
expect(detectPropertyType('categories', 'frontend')).toBe('tags')
expect(detectPropertyType('labels', 'bug')).toBe('tags')
})
it('treats date-keyed fields with non-date values as text', () => {
expect(detectPropertyType('deadline', 'active')).toBe('text')
})

View File

@@ -38,7 +38,8 @@ function detectStringType(key: string, strValue: string): PropertyDisplayMode {
export function detectPropertyType(key: string, value: FrontmatterValue): PropertyDisplayMode {
if (value === null || value === undefined) return 'text'
if (typeof value === 'boolean') return 'boolean'
if (Array.isArray(value)) return keyMatchesPatterns(key, TAGS_KEY_PATTERNS) ? 'tags' : 'text'
if (keyMatchesPatterns(key, TAGS_KEY_PATTERNS)) return 'tags'
if (Array.isArray(value)) return 'text'
return detectStringType(key, String(value))
}

View File

@@ -3,7 +3,6 @@ import {
getTagStyle,
getTagColorKey,
setTagColor,
DEFAULT_TAG_STYLE,
initTagColors,
} from './tagStyles'
import { bindVaultConfigStore, getVaultConfig, resetVaultConfigStore } from './vaultConfigStore'
@@ -19,8 +18,11 @@ describe('tagStyles — color overrides', () => {
initTagColors({})
})
it('returns default style when no override exists', () => {
expect(getTagStyle('SomeTag')).toEqual(DEFAULT_TAG_STYLE)
it('returns a hash-based style when no override exists', () => {
const style = getTagStyle('SomeTag')
// Should have bg and color properties from the accent palette
expect(style.bg).toMatch(/^var\(--accent-\w+-light\)$/)
expect(style.color).toMatch(/^var\(--accent-\w+\)$/)
})
it('getTagColorKey returns null when no override set', () => {
@@ -47,7 +49,9 @@ describe('tagStyles — color overrides', () => {
expect(getTagColorKey('React')).toBe('red')
setTagColor('React', null)
expect(getTagColorKey('React')).toBeNull()
expect(getTagStyle('React')).toEqual(DEFAULT_TAG_STYLE)
// Falls back to hash-based color (no longer DEFAULT_TAG_STYLE)
const style = getTagStyle('React')
expect(style.bg).toMatch(/^var\(--accent-\w+-light\)$/)
})
it('applies different overrides for different tags', () => {
@@ -57,10 +61,26 @@ describe('tagStyles — color overrides', () => {
expect(getTagStyle('TypeScript').color).toBe('var(--accent-purple)')
})
it('returns deterministic hash-based color when no override exists', () => {
const style1 = getTagStyle('React')
const style2 = getTagStyle('React')
// Same tag → same color every time
expect(style1).toEqual(style2)
// Should NOT be the old default (all-blue) — should vary by tag name
const styleA = getTagStyle('React')
const styleB = getTagStyle('TypeScript')
const styleC = getTagStyle('Tauri')
// At least two of three should differ (hash distribution)
const colors = [styleA.color, styleB.color, styleC.color]
const unique = new Set(colors)
expect(unique.size).toBeGreaterThanOrEqual(2)
})
it('ignores invalid color key in override', () => {
setTagColor('React', 'nonexistent-color')
// Falls back to default since "nonexistent-color" isn't a valid ACCENT_COLOR key
expect(getTagStyle('React')).toEqual(DEFAULT_TAG_STYLE)
// Falls back to hash-based color since "nonexistent-color" isn't a valid ACCENT_COLOR key
const style = getTagStyle('React')
expect(style.bg).toMatch(/^var\(--accent-\w+-light\)$/)
})
it('persists multiple overrides to vault config', () => {

View File

@@ -11,6 +11,17 @@ export const DEFAULT_TAG_STYLE: TagStyle = {
color: 'var(--accent-blue)',
}
/** Deterministic hash → accent color index for tags without a manual override. */
function hashTagColor(tag: string): TagStyle {
let hash = 0
for (let i = 0; i < tag.length; i++) {
hash = ((hash << 5) - hash + tag.charCodeAt(i)) | 0
}
const idx = ((hash % ACCENT_COLORS.length) + ACCENT_COLORS.length) % ACCENT_COLORS.length
const accent = ACCENT_COLORS[idx]
return { bg: accent.cssLight, color: accent.css }
}
const STORAGE_KEY = 'laputa:tag-color-overrides'
const COLOR_KEY_TO_STYLE: Record<string, TagStyle> = Object.fromEntries(
@@ -53,5 +64,5 @@ export function getTagStyle(tag: string): TagStyle {
const style = COLOR_KEY_TO_STYLE[overrideKey]
if (style) return style
}
return DEFAULT_TAG_STYLE
return hashTagColor(tag)
}

View File

@@ -0,0 +1,131 @@
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('Properties panel visual style', () => {
test.beforeEach(async ({ page }) => {
await page.setViewportSize({ width: 1600, height: 900 })
await page.goto('/')
await page.waitForLoadState('networkidle')
})
test('labels are sentence case, not ALL CAPS', async ({ page }) => {
await openNoteViaQuickOpen(page, 'Untitled note 58')
const propertyRows = page.locator('[data-testid="editable-property"]')
await expect(propertyRows.first()).toBeVisible({ timeout: 5000 })
// Labels should be sentence case (e.g. "Status"), not ALL CAPS
const statusLabel = propertyRows.locator('span.truncate').filter({ hasText: 'Status' })
await expect(statusLabel).toBeVisible()
// Verify no ALL CAPS label exists
const allLabels = propertyRows.locator('span.truncate')
const count = await allLabels.count()
for (let i = 0; i < count; i++) {
const text = await allLabels.nth(i).textContent()
if (text && text.length > 1) {
expect(text).not.toEqual(text.toUpperCase())
}
}
})
test('status renders as colored badge with dot indicator', async ({ page }) => {
await openNoteViaQuickOpen(page, 'Untitled note 58')
const statusBadge = page.locator('[data-testid="status-badge"]')
await expect(statusBadge).toBeVisible({ timeout: 5000 })
await expect(statusBadge).toHaveText('Active')
// Should have a dot indicator (small circle span inside)
const dot = statusBadge.locator('span.rounded-full')
await expect(dot).toBeVisible()
})
test('date renders as chip with background', async ({ page }) => {
await openNoteViaQuickOpen(page, 'Untitled note 58')
const dateDisplay = page.locator('[data-testid="date-display"]')
// Date may or may not exist in the note — if it exists, verify chip styling
const count = await dateDisplay.count()
if (count > 0) {
await expect(dateDisplay.first()).toHaveClass(/bg-muted/)
}
})
test('boolean renders as checkbox', async ({ page }) => {
await openNoteViaQuickOpen(page, 'Untitled note 58')
const booleanToggle = page.locator('[data-testid="boolean-toggle"]')
const count = await booleanToggle.count()
if (count > 0) {
// Should contain an actual checkbox input
const checkbox = booleanToggle.first().locator('input[type="checkbox"]')
await expect(checkbox).toBeVisible()
}
})
test('no horizontal overflow from property values', async ({ page }) => {
await openNoteViaQuickOpen(page, 'Untitled note 58')
const propertyRows = page.locator('[data-testid="editable-property"]')
await expect(propertyRows.first()).toBeVisible({ timeout: 5000 })
// Each row should not overflow its container
const count = await propertyRows.count()
for (let i = 0; i < count; i++) {
const row = propertyRows.nth(i)
const box = await row.boundingBox()
if (!box) continue
// Inspector panel is ~300px wide; rows shouldn't exceed parent
expect(box.width).toBeLessThan(500)
}
})
test('Type label uses same font-size as other property labels', async ({ page }) => {
await openNoteViaQuickOpen(page, 'Untitled note 58')
const typeSelector = page.locator('[data-testid="type-selector"]')
const typeCount = await typeSelector.count()
if (typeCount > 0) {
const typeLabel = typeSelector.locator('span').first()
const typeFontSize = await typeLabel.evaluate(el => getComputedStyle(el).fontSize)
// Should be 12px, matching other property labels
expect(typeFontSize).toBe('12px')
}
})
test('tags add button is solid pill, not dashed circle', async ({ page }) => {
await openNoteViaQuickOpen(page, 'Untitled note 58')
const tagsAddBtn = page.locator('[data-testid="tags-add-button"]')
const count = await tagsAddBtn.count()
if (count > 0) {
// Should NOT have dashed border
const borderStyle = await tagsAddBtn.first().evaluate(el => getComputedStyle(el).borderStyle)
expect(borderStyle).not.toBe('dashed')
// Should have rounded-md (not rounded-full circle)
await expect(tagsAddBtn.first()).toHaveClass(/rounded-md/)
}
})
test('add property button is subtle', async ({ page }) => {
await openNoteViaQuickOpen(page, 'Untitled note 58')
const addBtn = page.getByText('Add property')
await expect(addBtn).toBeVisible({ timeout: 5000 })
// Should have reduced opacity (subtle appearance)
await expect(addBtn).toHaveCSS('opacity', '0.5')
})
})

View File

@@ -45,10 +45,23 @@ test.describe('Title H1 with inline emoji', () => {
expect(fontWeight).toBeGreaterThanOrEqual(700)
})
test('no reserved space for emoji when note has no icon', async ({ page }) => {
const titleRow = page.locator('.title-section__row')
await expect(titleRow).toBeVisible({ timeout: 3000 })
// When no emoji is set, icon-display should not be in the row
const iconInRow = titleRow.locator('[data-testid="note-icon-display"]')
await expect(iconInRow).toHaveCount(0)
// Title should be the first (and only significant) child in the row
const titleInput = titleRow.locator('[data-testid="title-field-input"]')
await expect(titleInput).toBeVisible()
})
test('emoji icon and title are on the same horizontal line when icon present', async ({ page }) => {
// Add an icon via the "Add icon" button
const iconArea = page.locator('[data-testid="note-icon-area"]')
await iconArea.hover()
// Hover over the title section to reveal the "Add icon" button
const titleSection = page.locator('.title-section')
await titleSection.hover()
await page.waitForTimeout(200)
const addBtn = page.locator('[data-testid="note-icon-add"]')