Compare commits
9 Commits
v0.2026032
...
v0.2026033
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
14b5c34b94 | ||
|
|
a7a61d9751 | ||
|
|
68066b857f | ||
|
|
858468aec6 | ||
|
|
67ac8db888 | ||
|
|
1ae1377b2d | ||
|
|
adfceb3c70 | ||
|
|
46856b4dc2 | ||
|
|
2746fb88ad |
29
docs/adr/0029-domain-command-builder-pattern.md
Normal file
29
docs/adr/0029-domain-command-builder-pattern.md
Normal 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.58–10.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.
|
||||
29
docs/adr/0030-rust-commands-module-split.md
Normal file
29
docs/adr/0030-rust-commands-module-split.md
Normal 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.
|
||||
@@ -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 |
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()} />)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -112,7 +112,7 @@ describe('DynamicPropertiesPanel', () => {
|
||||
frontmatter={{ Status: 'Active' }}
|
||||
/>
|
||||
)
|
||||
// Status rendered with CSS text-transform: uppercase, DOM text is still "Active"
|
||||
// Status rendered as sentence case
|
||||
expect(screen.getByTestId('status-badge')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
|
||||
@@ -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,
|
||||
@@ -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()}`}
|
||||
>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -42,7 +42,7 @@ function StatusValue({ propKey, value, isEditing, vaultStatuses, onSave, onStart
|
||||
return (
|
||||
<span className="relative inline-flex min-w-0 items-center">
|
||||
<span
|
||||
className="inline-flex cursor-pointer items-center gap-1.5 rounded px-2 py-0.5 text-[12px] font-medium 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"
|
||||
@@ -83,14 +83,14 @@ 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,
|
||||
fontSize: 11,
|
||||
fontSize: 12,
|
||||
fontWeight: 500,
|
||||
overflow: 'hidden',
|
||||
whiteSpace: 'nowrap' as const,
|
||||
@@ -110,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"
|
||||
@@ -163,7 +164,7 @@ function DateValue({ value, onSave }: {
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
className={`inline-flex min-w-0 cursor-pointer items-center gap-1 border-none px-2 py-0.5 text-right text-[12px] font-medium transition-colors hover:opacity-80${formatted ? ' rounded bg-muted text-accent-foreground' : ' bg-transparent text-muted-foreground'}`}
|
||||
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"
|
||||
>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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) {
|
||||
|
||||
17
src/hooks/commands/filterCommands.ts
Normal file
17
src/hooks/commands/filterCommands.ts
Normal 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') },
|
||||
]
|
||||
}
|
||||
20
src/hooks/commands/gitCommands.ts
Normal file
20
src/hooks/commands/gitCommands.ts
Normal 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' }) },
|
||||
]
|
||||
}
|
||||
9
src/hooks/commands/index.ts
Normal file
9
src/hooks/commands/index.ts
Normal 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'
|
||||
27
src/hooks/commands/navigationCommands.ts
Normal file
27
src/hooks/commands/navigationCommands.ts
Normal 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?.() },
|
||||
]
|
||||
}
|
||||
69
src/hooks/commands/noteCommands.ts
Normal file
69
src/hooks/commands/noteCommands.ts
Normal 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?.(),
|
||||
},
|
||||
]
|
||||
}
|
||||
34
src/hooks/commands/settingsCommands.ts
Normal file
34
src/hooks/commands/settingsCommands.ts
Normal 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?.() },
|
||||
]
|
||||
}
|
||||
48
src/hooks/commands/typeCommands.ts
Normal file
48
src/hooks/commands/typeCommands.ts
Normal 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 }),
|
||||
},
|
||||
]
|
||||
})
|
||||
}
|
||||
17
src/hooks/commands/types.ts
Normal file
17
src/hooks/commands/types.ts
Normal 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)
|
||||
}
|
||||
38
src/hooks/commands/viewCommands.ts
Normal file
38
src/hooks/commands/viewCommands.ts
Normal 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 },
|
||||
]
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -92,6 +92,33 @@ test.describe('Properties panel visual style', () => {
|
||||
}
|
||||
})
|
||||
|
||||
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')
|
||||
|
||||
|
||||
@@ -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"]')
|
||||
|
||||
Reference in New Issue
Block a user